Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions .claude/hooks/issues-surface.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,22 +44,52 @@ 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)
gsub(/^[ \t]+|[ \t]+$/, "", acuity)
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)"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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 |
26 changes: 23 additions & 3 deletions scripts/issues-report.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Comment thread
BigSimmo marked this conversation as resolved.
});
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),
};
}
Expand Down
73 changes: 73 additions & 0 deletions tests/issues-report.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
"<!-- issues:next-id=5 -->",
"## 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",
"<!-- issues:next-id=255 -->",
"## 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 {
Expand Down
Loading