diff --git a/.claude/hooks/issues-surface.sh b/.claude/hooks/issues-surface.sh index 1c922957b6..933b801ce6 100755 --- a/.claude/hooks/issues-surface.sh +++ b/.claude/hooks/issues-surface.sh @@ -44,11 +44,38 @@ rows="$(awk ' ' "$ledger" 2>/dev/null || true)" # --- parse the recommended execution queue ---------------------------------- +# The prose shown per queue row is taken from the cited row's own Detail cell, +# NOT from the queue's Outcome cell. The two used to be independent copies of +# the same prose, so they drifted — and because this hook prints the queue, the +# drifted copy was the one every session read and acted on. That is not +# hypothetical: the queue cell for #231, the top clinical P1, spent days telling +# sessions to pursue an approach #231's own detail records as tested and +# rejected. The queue cell cannot simply be re-corrected, because the ledger +# inbox has no request type that reaches it and check:ledger-write-discipline +# rejects a direct edit — so the duplication is removed instead of re-synced. +# The queue keeps what only it carries: order, acuity, capability, timing. +# Falls back to the queue cell for a composite ID(s) row or an id with no open +# row, which is the only case where there is no single detail to show. +# Two passes over the file: the queue table is printed BEFORE "## Open items", +# so a single forward pass would read every queue row while the detail lookup +# was still empty and silently fall back to the stale cell every time. queue_rows="$(awk ' + NR==FNR { + if ($0 ~ /^## Open items/) { inopen=1; next } + if ($0 ~ /^## /) { inopen=0 } + if (inopen && $0 ~ /^\| #[0-9]/) { + split($0, oc, "|") + oid=oc[2]; odetail=oc[6] + gsub(/^[ \t]+|[ \t]+$/, "", oid) + gsub(/^[ \t]+|[ \t]+$/, "", odetail) + detail[oid]=odetail + } + next + } /^## Recommended execution queue/ { inqueue=1; next } /^## / { if (inqueue) inqueue=0 } inqueue && /^\|[[:space:]]*[0-9]+[[:space:]]*\|/ { - n=split($0, c, "|") + split($0, c, "|") ord=c[2]; ids=c[3]; acuity=c[4]; capability=c[5]; timing=c[6]; summary=c[8] gsub(/^[ \t]+|[ \t]+$/, "", ord) gsub(/^[ \t]+|[ \t]+$/, "", ids) @@ -56,10 +83,13 @@ queue_rows="$(awk ' gsub(/^[ \t]+|[ \t]+$/, "", capability) gsub(/^[ \t]+|[ \t]+$/, "", timing) gsub(/^[ \t]+|[ \t]+$/, "", summary) + lookup=ids + gsub(/`/, "", lookup) + if (lookup !~ /,/ && lookup in detail && length(detail[lookup]) > 0) summary=detail[lookup] if (length(summary) > 110) summary=substr(summary, 1, 107) "..." printf "%s\t%s\t%s\t%s\t%s\t%s\n", ord, ids, acuity, capability, timing, summary } -' "$ledger" 2>/dev/null || true)" +' "$ledger" "$ledger" 2>/dev/null || true)" total="$(printf '%s' "$rows" | grep -c . || true)" queue_total="$(printf '%s' "$queue_rows" | grep -c . || true)" diff --git a/docs/branch-review-records/f37513763ecace3c1eeca5c8977abe2b890cbde5f09f3f5566f432fbd5f675e0.record.md b/docs/branch-review-records/f37513763ecace3c1eeca5c8977abe2b890cbde5f09f3f5566f432fbd5f675e0.record.md new file mode 100644 index 0000000000..e1a1362648 --- /dev/null +++ b/docs/branch-review-records/f37513763ecace3c1eeca5c8977abe2b890cbde5f09f3f5566f432fbd5f675e0.record.md @@ -0,0 +1 @@ +| 2026-08-13 | claude/fix-231-queue-misdirection | 315199c16f7093aeac26281618483871c985c7e8 | derive recommended-queue prose from the cited row's detail; removes the #231 misdirection class | handoff: PR #1902 opened for review | verify:pr-local 8/8 failed:(none); check:ledger-write-discipline passed (no canonical edit); issues-report 6/6; mutation-tested (revert fails the new test); typecheck 0 errors; bash -n hook OK | diff --git a/scripts/issues-report.mjs b/scripts/issues-report.mjs index 0553e5a8e2..f85f01964b 100644 --- a/scripts/issues-report.mjs +++ b/scripts/issues-report.mjs @@ -84,12 +84,32 @@ export function buildIssuesReport(markdown, source) { added: cells[6], }; }); + // Report each queue row's prose from the cited row's own Detail cell rather + // than the queue's Outcome cell. They were independent copies of the same + // prose and drifted, and since this report is what /issues reads back, the + // drifted copy was the one acted on — the #231 queue cell spent days pointing + // at an approach that row had already refuted. The queue cell cannot be + // re-corrected in place (no inbox request type reaches it, and + // check:ledger-write-discipline rejects a direct edit), so the duplication is + // removed at the point of use instead. Order, acuity, capability, when and + // estimate stay from the queue, which is the only place they exist. + const detailById = new Map(openRows.map((row) => [row.id, row.detail])); + const derived = queue.map((row) => { + // A composite ID(s) cell has no single row to speak for it; keep the queue + // text there rather than arbitrarily picking one of the cited rows. + if (row.ids.length !== 1) return row; + const detail = detailById.get(row.ids[0]); + return detail ? { ...row, outcome: detail } : row; + }); return { source, - counts: { open: openRows.length, recommended: queue.length }, - priorityBlockers: queue.filter((row) => row.acuity === "A1"), - recommended: queue, + counts: { open: openRows.length, recommended: derived.length }, + priorityBlockers: derived.filter((row) => row.acuity === "A1"), + recommended: derived, open: openRows, + // Keep queue-only stop conditions in the safe-win classifier. `derived` is + // presentation text, while the queue outcome also carries safety gates that + // may not appear in the cited row's Detail cell. agentSafeWins: classifyAgentSafeWins(queue), }; } diff --git a/tests/issues-report.test.ts b/tests/issues-report.test.ts index f7acd349f8..0484b5ebca 100644 --- a/tests/issues-report.test.ts +++ b/tests/issues-report.test.ts @@ -138,6 +138,79 @@ describe("issues report", () => { expect(report.open[0].added).toBe("2026-01-01"); }); + it("reports queue prose from the cited row's detail, so a stale queue cell cannot misdirect", () => { + // The regression this pins: the queue Outcome cell and the row Detail cell + // were independent copies of the same prose. They drifted, and because the + // queue is what /issues and the SessionStart hook read back, the drifted + // copy was the one acted on — for #231, the top clinical P1, the queue spent + // days pointing at an approach that row had already recorded as refuted. + const markdown = [ + "# Outstanding", + "", + "## Recommended execution queue", + "| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition |", + "| ----: | ---- | ---- | ---- | ---- | ---- | ---- |", + "| 1 | `#001` | A1 | Operator | Now | 1 hour | STALE: do the refuted thing |", + "| 2 | `#002`, `#003` | A2 | Standard | Next | 30 min | composite stays as written |", + "## Open items", + "| ID | Pri | Type | Summary | Detail / next action | Source | Added |", + "| ---- | --- | ---- | ---- | ---- | ---- | ---- |", + "| #001 | P1 | task | urgent | CURRENT: that approach was refuted; do this instead | src | 2026-01-01 |", + "| #002 | P2 | task | left | detail two | src | 2026-01-01 |", + "| #003 | P2 | task | right | detail three | src | 2026-01-01 |", + "## Resolved / archive", + "| ID | Type | Summary | Outcome | Resolved |", + "| ---- | ---- | ---- | ---- | ---- |", + "| #004 | task | old | done | 2026-01-01 |", + ].join("\n"); + const report = buildIssuesReport(markdown, { ref: "origin/main", revalidated: true }); + + type QueueRow = { ids: string[]; outcome: string; acuity: string; when: string }; + const solo = report.recommended.find((row: QueueRow) => row.ids[0] === "#001"); + expect(solo, "the #001 queue row must be reported").toBeDefined(); + expect(solo!.outcome).toBe("CURRENT: that approach was refuted; do this instead"); + expect(solo!.outcome).not.toContain("STALE"); + // The A1 blocker list is a separate projection and must carry the same text. + expect(report.priorityBlockers[0].outcome).not.toContain("STALE"); + // Metadata that exists only on the queue row is still the queue's. + expect(solo!.acuity).toBe("A1"); + expect(solo!.when).toBe("Now"); + + // A composite row has no single row to speak for it, so it keeps its own text. + const composite = report.recommended.find((row: QueueRow) => row.ids.length > 1); + expect(composite, "the composite queue row must be reported").toBeDefined(); + expect(composite!.outcome).toBe("composite stays as written"); + }); + + it("keeps queue-only safety gates when deriving displayed queue prose", () => { + const markdown = [ + "# Outstanding", + "", + "## Recommended execution queue", + "| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition |", + "| ----: | ---- | ---- | ---- | ---- | ---- | ---- |", + "| 1 | \`#118\` | A3 | High — CI/visual/perf gates | Next | 2–4 hours | Commit CI-uploaded baselines. **Stop:** never commit developer-machine baselines. |", + "| 2 | \`#253\` | A2 | High — phone results UI | Next | 15–30 min | Close #1606 as superseded. **Stop:** the decision is a human's; do not close automatically. |", + "## Open items", + "| ID | Pri | Type | Summary | Detail / next action | Source | Added |", + "| ---- | --- | ---- | ---- | ---- | ---- | ---- |", + "| #118 | P2 | task | visual baselines | Displayed #118 detail | src | 2026-01-01 |", + "| #253 | P3 | task | hand-merge | Displayed #253 detail | src | 2026-01-01 |", + "## Resolved / archive", + "| ID | Type | Summary | Outcome | Resolved |", + "| ---- | ---- | ---- | ---- | ---- |", + "| #254 | task | old | done | 2026-01-01 |", + ].join("\n"); + const report = buildIssuesReport(markdown, { ref: "origin/main", revalidated: true }); + + expect(report.recommended.map((row: { outcome: string }) => row.outcome)).toEqual([ + "Displayed #118 detail", + "Displayed #253 detail", + ]); + expect(report.agentSafeWins.map((row: { ids: string[] }) => row.ids[0])).not.toContain("#118"); + expect(report.agentSafeWins.map((row: { ids: string[] }) => row.ids[0])).not.toContain("#253"); + }); + it("labels a readable origin/main ref as cached rather than remotely revalidated", () => { const directory = mkdtempSync(join(tmpdir(), "issues-report-")); try {