From 315199c16f7093aeac26281618483871c985c7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:30:37 +0000 Subject: [PATCH 1/3] fix(issues): derive queue prose from the row it cites, so a stale cell cannot misdirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recommended-queue entry for #231, the top clinical P1, told every session to "measure and fix the fast-route budget / generation timeout". #231's own detail records that approach as tested and rejected: the decisive 40-second probe completed generation in 25.272s (27.237s total) with route_deadline_exceeded false and still failed generation quality, so the budget is not the binding cause. The queue and the row had drifted, and the queue is the copy that gets read — .claude/hooks/issues-surface.sh prints it at every session start and scripts/issues-report.mjs serves it to /issues, while the corrected row detail is only seen by someone who opens the file. Re-correcting the cell was tried and cannot land. The ledger inbox has no request type that reaches the queue's Outcome cell, and check:ledger-write-discipline rejects a direct canonical edit, so the correction is unlandable by construction — verified by committing it and watching the gate refuse. SKIP_LEDGER_WRITE_GUARD only bypasses the pre-push hook, not the CI check, so that route pushes but can never merge. So the duplication is removed instead of re-synced, which is what #314 argued for: both consumers now take each queue row's prose from the cited row's own Detail cell. The queue keeps order, acuity, capability, when and estimate — the metadata that exists nowhere else. A composite ID(s) row has no single row to speak for it and keeps its own text. This fixes the whole class, not just #231: no future edit to a row's detail can leave the surfaced text behind, because there is no longer a second copy. The hook needed two passes over the file. The queue table is printed BEFORE "## Open items", so the obvious single forward pass reads every queue row while the lookup is still empty and silently falls back to the stale cell — which it did, on the first attempt, and looked like a working change. tests/issues-report.test.ts pins both halves and was mutation-tested: reverting the derivation fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuYJz8hauCsCdx8r4fXiZU --- .claude/hooks/issues-surface.sh | 34 +++++++++++++++++++++++-- scripts/issues-report.mjs | 25 ++++++++++++++++--- tests/issues-report.test.ts | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) 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/scripts/issues-report.mjs b/scripts/issues-report.mjs index 0553e5a8e2..f8e4709fe2 100644 --- a/scripts/issues-report.mjs +++ b/scripts/issues-report.mjs @@ -84,13 +84,30 @@ 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, - agentSafeWins: classifyAgentSafeWins(queue), + agentSafeWins: classifyAgentSafeWins(derived), }; } diff --git a/tests/issues-report.test.ts b/tests/issues-report.test.ts index f7acd349f8..4fe3bd93ab 100644 --- a/tests/issues-report.test.ts +++ b/tests/issues-report.test.ts @@ -138,6 +138,50 @@ 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("labels a readable origin/main ref as cached rather than remotely revalidated", () => { const directory = mkdtempSync(join(tmpdir(), "issues-report-")); try { From bf94072a99a0eeac9ef36a8b33e215509892b481 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:39:07 +0000 Subject: [PATCH 2/3] docs(ledger): record the #231 queue-misdirection fix handoff Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DuYJz8hauCsCdx8r4fXiZU --- ...e3c1eeca5c8977abe2b890cbde5f09f3f5566f432fbd5f675e0.record.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/branch-review-records/f37513763ecace3c1eeca5c8977abe2b890cbde5f09f3f5566f432fbd5f675e0.record.md 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 | From 10bd72038f73f37de0074038077369b0052cfd78 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:45:50 +0800 Subject: [PATCH 3/3] fix(issues): preserve queue safety gates --- scripts/issues-report.mjs | 5 ++++- tests/issues-report.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/issues-report.mjs b/scripts/issues-report.mjs index f8e4709fe2..f85f01964b 100644 --- a/scripts/issues-report.mjs +++ b/scripts/issues-report.mjs @@ -107,7 +107,10 @@ export function buildIssuesReport(markdown, source) { priorityBlockers: derived.filter((row) => row.acuity === "A1"), recommended: derived, open: openRows, - agentSafeWins: classifyAgentSafeWins(derived), + // 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 4fe3bd93ab..0484b5ebca 100644 --- a/tests/issues-report.test.ts +++ b/tests/issues-report.test.ts @@ -182,6 +182,35 @@ describe("issues report", () => { 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 {