liteagents 2.23.0 → 2.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [2.24.1] - 2026-09-03
11
+
12
+ ### Changed
13
+ - **The file-referent ledger half is SHELVED — no demonstrated problem.** The v2.24.0
14
+ entry below says shipping the ledger side is "gated on a future exact-label-agreement
15
+ measurement," which implied the work was justified and merely queued. That premise was
16
+ never checked, and it is wrong. The harm this channel prevents — a false match
17
+ inflating an entry's count until a `hot` entry hits `recurred_while_hot >= 2` and has
18
+ its rule rewritten — has never occurred: `ag-001` is the only `hot` entry, its
19
+ `recurred_while_hot` is 1 against a threshold of 2, and its two `attempts` are a
20
+ deliberate August rephrasing rather than a false-match rewrite. A POC also established
21
+ the naive ledger design would not have worked: cluster-level unions collide at 1.8%,
22
+ but entry-level unions collide at **38%**, because an entry accumulates paths across
23
+ every session it matches and inevitably collects `README.md` / `CLAUDE.md`. A
24
+ document-frequency filter repairs it (9.5% at df<=2), but repairing a fix for a problem
25
+ that is not occurring is not a reason to ship. **The incoming half is kept** — it costs
26
+ nothing, adds no LLM step, and accumulates evidence for free.
27
+ - **The un-shelve trigger is now checkable rather than a judgement call:** a false match
28
+ observed under a sonnet-class classifier, OR `ag-001` reaching `recurred_while_hot = 2`
29
+ on evidence that is not about validation.
30
+ - **And that trigger must NOT be exact-label agreement.** A fourth POC arm with the
31
+ user's quotes stripped from both sides scored best on every stability measure (0.900
32
+ exact agreement, 3/20 unstable) and is plainly the worst arm: it unanimously dropped
33
+ three clusters (`ag-012`, `ag-007`, `ag-001`) that all nine quote-carrying runs matched
34
+ unanimously, and named antigens after session hashes. It wins by having nothing to go
35
+ on and defaulting to `drop`. High agreement on "I don't know" is not quality — the same
36
+ degenerate shape as the severity axis that was seeded and rated on the same signal. A
37
+ re-attempt needs a human-labelled gold set.
38
+
39
+ ### Added
40
+ - `poc/friction-file-referents/` — the corpus, all four arm prompts, raw labels from 12
41
+ runs, and `score.py`, which reproduces every table above. Kept so a re-attempt starts
42
+ from the numbers. Outside `package.json`'s `files` allowlist; not published.
43
+
44
+ ---
45
+
46
+ ## [2.24.0] - 2026-09-03
47
+
48
+ ### Added
49
+ - **Friction clusters carry file referents through as a second matching signature.**
50
+ `friction.cjs` already computed a `files` list per candidate (74/101 populated on a
51
+ frozen 34-cluster / 101-candidate corpus) but silently dropped it at clustering, so no
52
+ cluster ever saw it. It is now unioned per cluster and capped at 8 sorted paths. Measured
53
+ before building: `preceding` (tool-name sequence + result) gave 13 distinct signatures
54
+ over 34 clusters at 19.3% collision, `tool_sequence` gave 10 at 25.8% — both close to the
55
+ coin flip the existing quote channel already is. File basenames gave 28 at 3.7% on a
56
+ per-candidate join; the per-cluster union does better still: 30 distinct signatures over
57
+ 34 clusters at 1.8% collision, 29/34 populated, a 10× reduction against `preceding`. A
58
+ prediction from the design notes turned out wrong in the favourable direction: paths are
59
+ real strings, not a distillation, so the channel needed no LLM judgement step to add.
60
+ **This channel is carried on the incoming side only.** No ledger entry stores `files` and
61
+ step 4a is not shown it, so ledger matching still runs on the single quote channel it
62
+ always has — shipping that half is gated on a future exact-label-agreement measurement,
63
+ documented in `docs/product/antigen-gate-prd.md` §13 and
64
+ `docs/product/remember-README.md` §6.
65
+
66
+ ### Fixed
67
+ - **Friction's `preceding.result` read the wrong signal — text-matched `'Exit code 0'`
68
+ instead of the `is_error` boolean.** The text match hit 1 of 2623 sampled result blocks;
69
+ `is_error` is present on 2065 of 2624. Before the fix, `preceding.result` was `unknown`
70
+ on 31 of 34 clusters; after, 18 claimed-success, 4 error, 12 unknown. Cluster hashes are
71
+ unchanged — this only corrects a field that was already there.
72
+ - **`docs-builder`'s cleanup output named counts ambiguously.** The advisory now says "N
73
+ file(s) with link rewrites" and the restore step says "restored N inbound reference(s)" —
74
+ the advisory counts files touched, the restore counts the references inside them, and the
75
+ two numbers are not the same unit.
76
+ - **`docs-builder` inbound references follow the core page back out of the archive.**
77
+ `cleanup-apply` archives the source, then relocates the core page back to its original
78
+ path. The archive step had already rewritten every inbound reference to point at
79
+ `docs/archive/` — correct at that moment, since the archive was briefly the only copy —
80
+ but nothing walked them back once the core page reoccupied the original path, leaving the
81
+ corpus telling readers the doc lived in the archive while the live page sat unreferenced.
82
+ A third restore step now walks them back. What must *not* be restored is the point of the
83
+ design rather than an edge case: the split's own pages cite the original by line number,
84
+ so their `sources:` and citations stay pinned to the frozen archive copy. Field-reported
85
+ from a real PRD split (33 references across 15 files), reproduced failing-first.
86
+
87
+ ### Security
88
+ - **`fast-uri` bumped 3.1.5 → 3.1.7 and `qs` bumped 6.15.2 → 6.16.0** in the
89
+ live-canvas-channel plugin lockfile (Dependabot #43/#44, folded into one commit since
90
+ both edit the same lockfile). `fast-uri` 3.1.7 closes five high-severity advisories:
91
+ authority injection via an unvalidated port in `serialize()` (GHSA-qw65-cvwx-89v3), host
92
+ confusion via unbalanced IP-literal brackets (GHSA-58mr-gqgx-xq4g) and skipped IDN
93
+ canonicalization (GHSA-5jgf-p345-68v8), and SSRF via repeated hostname percent-decoding
94
+ (GHSA-fph4-wmhf-6fwf) and malformed IPv6 normalization (GHSA-f65p-4m7j-42xc). Both
95
+ packages are transitive dependencies of `@modelcontextprotocol/sdk`.
96
+
10
97
  ## [2.23.0] - 2026-09-02
11
98
 
12
99
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "liteagents",
3
- "version": "2.23.0",
3
+ "version": "2.24.1",
4
4
  "description": "AI development toolkit with 11 specialized agents and 18 commands including live-canvas UI design with click-to-annotate feedback. Simple one-question installer for Claude, Opencode, Ampcode, and Droid.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1085,10 +1085,17 @@ const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1085
1085
  // moves is already covered by the resident check the instant its own git mv lands, before
1086
1086
  // rewriteLinks ever runs for it.
1087
1087
  let plannedArchiveSrc = new Set();
1088
+ // Set only for the duration of cleanup-apply's restore pass (see cleanupApply below), which
1089
+ // walks inbound references back from the archived copy to the relocated core page. That pass
1090
+ // must skip the split's own pages: they cite the original BY LINE NUMBER, so their `sources:`
1091
+ // and citations belong on the frozen archive copy, not on the shortened core page. A
1092
+ // predicate rather than a Set because the exemption is a directory prefix plus one file.
1093
+ let linkRestoreExempt = null;
1088
1094
  // One predicate, called from the one place rewriteLinks() loops over candidate files, so the
1089
1095
  // exemption can never desync across callers the way moveDoc's follow-ups almost did.
1090
1096
  function isRewriteExempt(f) {
1091
- return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f);
1097
+ return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f)
1098
+ || (linkRestoreExempt !== null && linkRestoreExempt(f));
1092
1099
  }
1093
1100
 
1094
1101
  // A real corpus (astral-sh/uv) cross-links its docs with RELATIVE paths — `../concepts/x.md`,
@@ -1301,8 +1308,12 @@ function flushCommitAdvisory() {
1301
1308
  const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1302
1309
  console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1303
1310
  + 'the copy+unlink fallback used outside a git repo does not)');
1311
+ // Unit, stated: linkSet holds FILE paths, so this is a file count. cleanup-apply's
1312
+ // restore pass just below reports OCCURRENCES, and a real run printed "33" here next to
1313
+ // "restored 38" — same-sounding nouns, two different units, and the reader has no way to
1314
+ // tell they are not a discrepancy. Name the unit in both places.
1304
1315
  console.log(linkSet.length
1305
- ? `${linkSet.length} link rewrite(s) UNSTAGED` + (outsideFiles.length
1316
+ ? `${linkSet.length} file(s) with link rewrites UNSTAGED` + (outsideFiles.length
1306
1317
  ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1307
1318
  : ' (all inside docs/)')
1308
1319
  : 'no inbound-link rewrites this run.');
@@ -2352,6 +2363,40 @@ function cleanupApply(file, outlineF, labelsF) {
2352
2363
  for (const m of r.artifactNotes) console.log(` ${m}`);
2353
2364
  if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2354
2365
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2366
+
2367
+ // FIELD BUG (real, reproduced): archiveOrThrow above rewrote EVERY inbound reference
2368
+ // to point at docs/archive/, because at that moment the archive genuinely was the
2369
+ // only copy. The relocation on the line above then put a live page back at the
2370
+ // original path — so the corpus now tells readers that "the PRD" lives in the
2371
+ // archive while the live page sits unreferenced. On a real split that was 33
2372
+ // references across 15 files outside docs/, and it is precisely the backwards
2373
+ // outcome this command exists to prevent. Walk them back.
2374
+ //
2375
+ // The ordering itself cannot be swapped: the original must vacate the path before
2376
+ // the core page can occupy it, and rewriting links to a path nothing occupies yet
2377
+ // would be worse. So the restore is a third step, not a reordering.
2378
+ //
2379
+ // EXEMPT, and this is the point of the pass rather than an edge case: the split's
2380
+ // own pages cite the original BY LINE NUMBER, so their `sources:` and citations
2381
+ // must stay on the frozen archive copy — as must the relocated core page's own.
2382
+ // Rewriting those back would be a new bug wearing the old one's clothes.
2383
+ const archivedRel = path.posix.join(REORG_DEST.archive, path.basename(file));
2384
+ const pagesPrefix = pages.replace(/\/*$/, '') + '/';
2385
+ linkRestoreExempt = f => f === r.rel || f.startsWith(pagesPrefix);
2386
+ let restored;
2387
+ try { restored = rewriteLinks(archivedRel, r.rel); }
2388
+ finally { linkRestoreExempt = null; }
2389
+ if (restored.skipped) {
2390
+ console.log(` ${restored.skipped}`);
2391
+ } else if (restored.total) {
2392
+ console.log(` restored ${restored.total} inbound reference(s) from ${archivedRel} `
2393
+ + `back to ${r.rel} (the archive step had aimed them at the frozen copy; `
2394
+ + `${pagesPrefix} pages keep theirs, their citations are line-numbered)`);
2395
+ for (const { file: lf, n } of restored.files) console.log(` ${lf}: ${n}`);
2396
+ noteLinks(restored.files.map(x => x.file));
2397
+ } else {
2398
+ console.log(` no inbound references to ${archivedRel} needed restoring.`);
2399
+ }
2355
2400
  } catch (e) {
2356
2401
  console.error(` WARN could not relocate the core page from ${from} to ${to}: `
2357
2402
  + `${e.message} — it remains at ${from}.`);
@@ -1765,8 +1765,26 @@ function extractToolsFromTurn(event) {
1765
1765
  const toolName = block.name || 'unknown';
1766
1766
  tools.push({ tool: toolName, action: 'call' });
1767
1767
  } else if (block.type === 'tool_result') {
1768
- const result = String(block.content || '');
1769
- if (result.includes('Exit code 0')) {
1768
+ // The transcript marks a failed tool call with `is_error`; a success
1769
+ // carries no sentinel in the result text. Reading the text for
1770
+ // 'Exit code 0' matched 1 block in 2623 sampled from the real
1771
+ // corpus, so `result` was 'unknown' on 31 of 34 clusters — and the
1772
+ // case this field exists for is a CLAIMED success the user is
1773
+ // contradicting, which is the one that never fired. Content is also
1774
+ // sometimes an array of blocks (403/2623), which String() turned
1775
+ // into "[object Object]" so no pattern could match it.
1776
+ //
1777
+ // A block with no `is_error` stays unknown on purpose: those are
1778
+ // harness meta-results (skill/agent launches, question answers —
1779
+ // 560/2624 sampled) and calling them success would inflate the very
1780
+ // signal this field is here to detect.
1781
+ const raw = block.content;
1782
+ const result = Array.isArray(raw)
1783
+ ? raw.map(b => (b && typeof b === 'object' ? (b.text || '') : String(b || ''))).join('\n')
1784
+ : String(raw || '');
1785
+ if (block.is_error === true) {
1786
+ tools.push({ tool: 'result', action: 'error' });
1787
+ } else if (typeof block.is_error === 'boolean') {
1770
1788
  tools.push({ tool: 'result', action: 'success' });
1771
1789
  } else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
1772
1790
  tools.push({ tool: 'result', action: 'error' });
@@ -2142,6 +2160,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2142
2160
  errors: b.errors,
2143
2161
  peak: b.peak,
2144
2162
  texts: b.texts,
2163
+ files: [...b.files],
2145
2164
  preceding: b.preceding,
2146
2165
  anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2147
2166
  };
@@ -2166,7 +2185,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2166
2185
  if (best) {
2167
2186
  cl = best;
2168
2187
  } else {
2169
- cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2188
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null, files: new Set() };
2170
2189
  clusters.push(cl);
2171
2190
  }
2172
2191
  for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
@@ -2178,6 +2197,15 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2178
2197
  cl.peaks.push(ss.peak);
2179
2198
  if (ss.anySelf) cl.anySelf = true;
2180
2199
  if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2200
+ // The file referents a cluster's sessions touched. Measured on a 34-cluster real
2201
+ // corpus as the one channel that actually separates clusters: file basenames gave 28
2202
+ // distinct signatures at a 3.7% collision rate, against 13 / 19.3% for the preceding
2203
+ // action+result pair and 10 / 25.8% for tool_sequence. Matching an incoming cluster
2204
+ // to a ledger entry otherwise runs on class_hints alone, and those ARE fragments of
2205
+ // the entry's own evidence quotes — one channel, where identity and proof are the
2206
+ // same strings. These paths are mechanical, so carrying them adds a second channel
2207
+ // without reintroducing an LLM distillation step.
2208
+ for (const f of ss.files) cl.files.add(f);
2181
2209
  }
2182
2210
 
2183
2211
  const out = clusters.map(cl => {
@@ -2251,6 +2279,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2251
2279
  max_peak: peaks[peaks.length - 1],
2252
2280
  contexts: cl.contexts,
2253
2281
  errors: cl.errors,
2282
+ files: [...cl.files].sort().slice(0, 8), // referents: the discriminative match channel (see merge above)
2254
2283
  preceding: cl.preceding, // #4: agent action + result just before the reaction
2255
2284
  self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2256
2285
  top_keywords: topSh.slice(0, 10),
@@ -1085,10 +1085,17 @@ const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1085
1085
  // moves is already covered by the resident check the instant its own git mv lands, before
1086
1086
  // rewriteLinks ever runs for it.
1087
1087
  let plannedArchiveSrc = new Set();
1088
+ // Set only for the duration of cleanup-apply's restore pass (see cleanupApply below), which
1089
+ // walks inbound references back from the archived copy to the relocated core page. That pass
1090
+ // must skip the split's own pages: they cite the original BY LINE NUMBER, so their `sources:`
1091
+ // and citations belong on the frozen archive copy, not on the shortened core page. A
1092
+ // predicate rather than a Set because the exemption is a directory prefix plus one file.
1093
+ let linkRestoreExempt = null;
1088
1094
  // One predicate, called from the one place rewriteLinks() loops over candidate files, so the
1089
1095
  // exemption can never desync across callers the way moveDoc's follow-ups almost did.
1090
1096
  function isRewriteExempt(f) {
1091
- return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f);
1097
+ return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f)
1098
+ || (linkRestoreExempt !== null && linkRestoreExempt(f));
1092
1099
  }
1093
1100
 
1094
1101
  // A real corpus (astral-sh/uv) cross-links its docs with RELATIVE paths — `../concepts/x.md`,
@@ -1301,8 +1308,12 @@ function flushCommitAdvisory() {
1301
1308
  const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1302
1309
  console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1303
1310
  + 'the copy+unlink fallback used outside a git repo does not)');
1311
+ // Unit, stated: linkSet holds FILE paths, so this is a file count. cleanup-apply's
1312
+ // restore pass just below reports OCCURRENCES, and a real run printed "33" here next to
1313
+ // "restored 38" — same-sounding nouns, two different units, and the reader has no way to
1314
+ // tell they are not a discrepancy. Name the unit in both places.
1304
1315
  console.log(linkSet.length
1305
- ? `${linkSet.length} link rewrite(s) UNSTAGED` + (outsideFiles.length
1316
+ ? `${linkSet.length} file(s) with link rewrites UNSTAGED` + (outsideFiles.length
1306
1317
  ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1307
1318
  : ' (all inside docs/)')
1308
1319
  : 'no inbound-link rewrites this run.');
@@ -2352,6 +2363,40 @@ function cleanupApply(file, outlineF, labelsF) {
2352
2363
  for (const m of r.artifactNotes) console.log(` ${m}`);
2353
2364
  if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2354
2365
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2366
+
2367
+ // FIELD BUG (real, reproduced): archiveOrThrow above rewrote EVERY inbound reference
2368
+ // to point at docs/archive/, because at that moment the archive genuinely was the
2369
+ // only copy. The relocation on the line above then put a live page back at the
2370
+ // original path — so the corpus now tells readers that "the PRD" lives in the
2371
+ // archive while the live page sits unreferenced. On a real split that was 33
2372
+ // references across 15 files outside docs/, and it is precisely the backwards
2373
+ // outcome this command exists to prevent. Walk them back.
2374
+ //
2375
+ // The ordering itself cannot be swapped: the original must vacate the path before
2376
+ // the core page can occupy it, and rewriting links to a path nothing occupies yet
2377
+ // would be worse. So the restore is a third step, not a reordering.
2378
+ //
2379
+ // EXEMPT, and this is the point of the pass rather than an edge case: the split's
2380
+ // own pages cite the original BY LINE NUMBER, so their `sources:` and citations
2381
+ // must stay on the frozen archive copy — as must the relocated core page's own.
2382
+ // Rewriting those back would be a new bug wearing the old one's clothes.
2383
+ const archivedRel = path.posix.join(REORG_DEST.archive, path.basename(file));
2384
+ const pagesPrefix = pages.replace(/\/*$/, '') + '/';
2385
+ linkRestoreExempt = f => f === r.rel || f.startsWith(pagesPrefix);
2386
+ let restored;
2387
+ try { restored = rewriteLinks(archivedRel, r.rel); }
2388
+ finally { linkRestoreExempt = null; }
2389
+ if (restored.skipped) {
2390
+ console.log(` ${restored.skipped}`);
2391
+ } else if (restored.total) {
2392
+ console.log(` restored ${restored.total} inbound reference(s) from ${archivedRel} `
2393
+ + `back to ${r.rel} (the archive step had aimed them at the frozen copy; `
2394
+ + `${pagesPrefix} pages keep theirs, their citations are line-numbered)`);
2395
+ for (const { file: lf, n } of restored.files) console.log(` ${lf}: ${n}`);
2396
+ noteLinks(restored.files.map(x => x.file));
2397
+ } else {
2398
+ console.log(` no inbound references to ${archivedRel} needed restoring.`);
2399
+ }
2355
2400
  } catch (e) {
2356
2401
  console.error(` WARN could not relocate the core page from ${from} to ${to}: `
2357
2402
  + `${e.message} — it remains at ${from}.`);
@@ -1765,8 +1765,26 @@ function extractToolsFromTurn(event) {
1765
1765
  const toolName = block.name || 'unknown';
1766
1766
  tools.push({ tool: toolName, action: 'call' });
1767
1767
  } else if (block.type === 'tool_result') {
1768
- const result = String(block.content || '');
1769
- if (result.includes('Exit code 0')) {
1768
+ // The transcript marks a failed tool call with `is_error`; a success
1769
+ // carries no sentinel in the result text. Reading the text for
1770
+ // 'Exit code 0' matched 1 block in 2623 sampled from the real
1771
+ // corpus, so `result` was 'unknown' on 31 of 34 clusters — and the
1772
+ // case this field exists for is a CLAIMED success the user is
1773
+ // contradicting, which is the one that never fired. Content is also
1774
+ // sometimes an array of blocks (403/2623), which String() turned
1775
+ // into "[object Object]" so no pattern could match it.
1776
+ //
1777
+ // A block with no `is_error` stays unknown on purpose: those are
1778
+ // harness meta-results (skill/agent launches, question answers —
1779
+ // 560/2624 sampled) and calling them success would inflate the very
1780
+ // signal this field is here to detect.
1781
+ const raw = block.content;
1782
+ const result = Array.isArray(raw)
1783
+ ? raw.map(b => (b && typeof b === 'object' ? (b.text || '') : String(b || ''))).join('\n')
1784
+ : String(raw || '');
1785
+ if (block.is_error === true) {
1786
+ tools.push({ tool: 'result', action: 'error' });
1787
+ } else if (typeof block.is_error === 'boolean') {
1770
1788
  tools.push({ tool: 'result', action: 'success' });
1771
1789
  } else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
1772
1790
  tools.push({ tool: 'result', action: 'error' });
@@ -2142,6 +2160,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2142
2160
  errors: b.errors,
2143
2161
  peak: b.peak,
2144
2162
  texts: b.texts,
2163
+ files: [...b.files],
2145
2164
  preceding: b.preceding,
2146
2165
  anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2147
2166
  };
@@ -2166,7 +2185,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2166
2185
  if (best) {
2167
2186
  cl = best;
2168
2187
  } else {
2169
- cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2188
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null, files: new Set() };
2170
2189
  clusters.push(cl);
2171
2190
  }
2172
2191
  for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
@@ -2178,6 +2197,15 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2178
2197
  cl.peaks.push(ss.peak);
2179
2198
  if (ss.anySelf) cl.anySelf = true;
2180
2199
  if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2200
+ // The file referents a cluster's sessions touched. Measured on a 34-cluster real
2201
+ // corpus as the one channel that actually separates clusters: file basenames gave 28
2202
+ // distinct signatures at a 3.7% collision rate, against 13 / 19.3% for the preceding
2203
+ // action+result pair and 10 / 25.8% for tool_sequence. Matching an incoming cluster
2204
+ // to a ledger entry otherwise runs on class_hints alone, and those ARE fragments of
2205
+ // the entry's own evidence quotes — one channel, where identity and proof are the
2206
+ // same strings. These paths are mechanical, so carrying them adds a second channel
2207
+ // without reintroducing an LLM distillation step.
2208
+ for (const f of ss.files) cl.files.add(f);
2181
2209
  }
2182
2210
 
2183
2211
  const out = clusters.map(cl => {
@@ -2251,6 +2279,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2251
2279
  max_peak: peaks[peaks.length - 1],
2252
2280
  contexts: cl.contexts,
2253
2281
  errors: cl.errors,
2282
+ files: [...cl.files].sort().slice(0, 8), // referents: the discriminative match channel (see merge above)
2254
2283
  preceding: cl.preceding, // #4: agent action + result just before the reaction
2255
2284
  self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2256
2285
  top_keywords: topSh.slice(0, 10),
@@ -333,9 +333,9 @@
333
333
  }
334
334
  },
335
335
  "node_modules/es-object-atoms": {
336
- "version": "1.1.1",
337
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
338
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
336
+ "version": "1.1.2",
337
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
338
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
339
339
  "license": "MIT",
340
340
  "dependencies": {
341
341
  "es-errors": "^1.3.0"
@@ -448,9 +448,9 @@
448
448
  "license": "MIT"
449
449
  },
450
450
  "node_modules/fast-uri": {
451
- "version": "3.1.5",
452
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
453
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
451
+ "version": "3.1.7",
452
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
453
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
454
454
  "funding": [
455
455
  {
456
456
  "type": "github",
@@ -573,9 +573,9 @@
573
573
  }
574
574
  },
575
575
  "node_modules/hasown": {
576
- "version": "2.0.3",
577
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
578
- "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
576
+ "version": "2.0.4",
577
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
578
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
579
579
  "license": "MIT",
580
580
  "dependencies": {
581
581
  "function-bind": "^1.1.2"
@@ -849,12 +849,13 @@
849
849
  }
850
850
  },
851
851
  "node_modules/qs": {
852
- "version": "6.15.2",
853
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
854
- "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
852
+ "version": "6.16.0",
853
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
854
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
855
855
  "license": "BSD-3-Clause",
856
856
  "dependencies": {
857
- "side-channel": "^1.1.0"
857
+ "es-define-property": "^1.0.1",
858
+ "side-channel": "^1.1.1"
858
859
  },
859
860
  "engines": {
860
861
  "node": ">=0.6"
@@ -991,14 +992,14 @@
991
992
  }
992
993
  },
993
994
  "node_modules/side-channel": {
994
- "version": "1.1.0",
995
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
996
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
995
+ "version": "1.1.1",
996
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
997
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
997
998
  "license": "MIT",
998
999
  "dependencies": {
999
1000
  "es-errors": "^1.3.0",
1000
- "object-inspect": "^1.13.3",
1001
- "side-channel-list": "^1.0.0",
1001
+ "object-inspect": "^1.13.4",
1002
+ "side-channel-list": "^1.0.1",
1002
1003
  "side-channel-map": "^1.0.1",
1003
1004
  "side-channel-weakmap": "^1.0.2"
1004
1005
  },
@@ -1085,10 +1085,17 @@ const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1085
1085
  // moves is already covered by the resident check the instant its own git mv lands, before
1086
1086
  // rewriteLinks ever runs for it.
1087
1087
  let plannedArchiveSrc = new Set();
1088
+ // Set only for the duration of cleanup-apply's restore pass (see cleanupApply below), which
1089
+ // walks inbound references back from the archived copy to the relocated core page. That pass
1090
+ // must skip the split's own pages: they cite the original BY LINE NUMBER, so their `sources:`
1091
+ // and citations belong on the frozen archive copy, not on the shortened core page. A
1092
+ // predicate rather than a Set because the exemption is a directory prefix plus one file.
1093
+ let linkRestoreExempt = null;
1088
1094
  // One predicate, called from the one place rewriteLinks() loops over candidate files, so the
1089
1095
  // exemption can never desync across callers the way moveDoc's follow-ups almost did.
1090
1096
  function isRewriteExempt(f) {
1091
- return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f);
1097
+ return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f)
1098
+ || (linkRestoreExempt !== null && linkRestoreExempt(f));
1092
1099
  }
1093
1100
 
1094
1101
  // A real corpus (astral-sh/uv) cross-links its docs with RELATIVE paths — `../concepts/x.md`,
@@ -1301,8 +1308,12 @@ function flushCommitAdvisory() {
1301
1308
  const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1302
1309
  console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1303
1310
  + 'the copy+unlink fallback used outside a git repo does not)');
1311
+ // Unit, stated: linkSet holds FILE paths, so this is a file count. cleanup-apply's
1312
+ // restore pass just below reports OCCURRENCES, and a real run printed "33" here next to
1313
+ // "restored 38" — same-sounding nouns, two different units, and the reader has no way to
1314
+ // tell they are not a discrepancy. Name the unit in both places.
1304
1315
  console.log(linkSet.length
1305
- ? `${linkSet.length} link rewrite(s) UNSTAGED` + (outsideFiles.length
1316
+ ? `${linkSet.length} file(s) with link rewrites UNSTAGED` + (outsideFiles.length
1306
1317
  ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1307
1318
  : ' (all inside docs/)')
1308
1319
  : 'no inbound-link rewrites this run.');
@@ -2352,6 +2363,40 @@ function cleanupApply(file, outlineF, labelsF) {
2352
2363
  for (const m of r.artifactNotes) console.log(` ${m}`);
2353
2364
  if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2354
2365
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2366
+
2367
+ // FIELD BUG (real, reproduced): archiveOrThrow above rewrote EVERY inbound reference
2368
+ // to point at docs/archive/, because at that moment the archive genuinely was the
2369
+ // only copy. The relocation on the line above then put a live page back at the
2370
+ // original path — so the corpus now tells readers that "the PRD" lives in the
2371
+ // archive while the live page sits unreferenced. On a real split that was 33
2372
+ // references across 15 files outside docs/, and it is precisely the backwards
2373
+ // outcome this command exists to prevent. Walk them back.
2374
+ //
2375
+ // The ordering itself cannot be swapped: the original must vacate the path before
2376
+ // the core page can occupy it, and rewriting links to a path nothing occupies yet
2377
+ // would be worse. So the restore is a third step, not a reordering.
2378
+ //
2379
+ // EXEMPT, and this is the point of the pass rather than an edge case: the split's
2380
+ // own pages cite the original BY LINE NUMBER, so their `sources:` and citations
2381
+ // must stay on the frozen archive copy — as must the relocated core page's own.
2382
+ // Rewriting those back would be a new bug wearing the old one's clothes.
2383
+ const archivedRel = path.posix.join(REORG_DEST.archive, path.basename(file));
2384
+ const pagesPrefix = pages.replace(/\/*$/, '') + '/';
2385
+ linkRestoreExempt = f => f === r.rel || f.startsWith(pagesPrefix);
2386
+ let restored;
2387
+ try { restored = rewriteLinks(archivedRel, r.rel); }
2388
+ finally { linkRestoreExempt = null; }
2389
+ if (restored.skipped) {
2390
+ console.log(` ${restored.skipped}`);
2391
+ } else if (restored.total) {
2392
+ console.log(` restored ${restored.total} inbound reference(s) from ${archivedRel} `
2393
+ + `back to ${r.rel} (the archive step had aimed them at the frozen copy; `
2394
+ + `${pagesPrefix} pages keep theirs, their citations are line-numbered)`);
2395
+ for (const { file: lf, n } of restored.files) console.log(` ${lf}: ${n}`);
2396
+ noteLinks(restored.files.map(x => x.file));
2397
+ } else {
2398
+ console.log(` no inbound references to ${archivedRel} needed restoring.`);
2399
+ }
2355
2400
  } catch (e) {
2356
2401
  console.error(` WARN could not relocate the core page from ${from} to ${to}: `
2357
2402
  + `${e.message} — it remains at ${from}.`);
@@ -1765,8 +1765,26 @@ function extractToolsFromTurn(event) {
1765
1765
  const toolName = block.name || 'unknown';
1766
1766
  tools.push({ tool: toolName, action: 'call' });
1767
1767
  } else if (block.type === 'tool_result') {
1768
- const result = String(block.content || '');
1769
- if (result.includes('Exit code 0')) {
1768
+ // The transcript marks a failed tool call with `is_error`; a success
1769
+ // carries no sentinel in the result text. Reading the text for
1770
+ // 'Exit code 0' matched 1 block in 2623 sampled from the real
1771
+ // corpus, so `result` was 'unknown' on 31 of 34 clusters — and the
1772
+ // case this field exists for is a CLAIMED success the user is
1773
+ // contradicting, which is the one that never fired. Content is also
1774
+ // sometimes an array of blocks (403/2623), which String() turned
1775
+ // into "[object Object]" so no pattern could match it.
1776
+ //
1777
+ // A block with no `is_error` stays unknown on purpose: those are
1778
+ // harness meta-results (skill/agent launches, question answers —
1779
+ // 560/2624 sampled) and calling them success would inflate the very
1780
+ // signal this field is here to detect.
1781
+ const raw = block.content;
1782
+ const result = Array.isArray(raw)
1783
+ ? raw.map(b => (b && typeof b === 'object' ? (b.text || '') : String(b || ''))).join('\n')
1784
+ : String(raw || '');
1785
+ if (block.is_error === true) {
1786
+ tools.push({ tool: 'result', action: 'error' });
1787
+ } else if (typeof block.is_error === 'boolean') {
1770
1788
  tools.push({ tool: 'result', action: 'success' });
1771
1789
  } else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
1772
1790
  tools.push({ tool: 'result', action: 'error' });
@@ -2142,6 +2160,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2142
2160
  errors: b.errors,
2143
2161
  peak: b.peak,
2144
2162
  texts: b.texts,
2163
+ files: [...b.files],
2145
2164
  preceding: b.preceding,
2146
2165
  anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2147
2166
  };
@@ -2166,7 +2185,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2166
2185
  if (best) {
2167
2186
  cl = best;
2168
2187
  } else {
2169
- cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2188
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null, files: new Set() };
2170
2189
  clusters.push(cl);
2171
2190
  }
2172
2191
  for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
@@ -2178,6 +2197,15 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2178
2197
  cl.peaks.push(ss.peak);
2179
2198
  if (ss.anySelf) cl.anySelf = true;
2180
2199
  if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2200
+ // The file referents a cluster's sessions touched. Measured on a 34-cluster real
2201
+ // corpus as the one channel that actually separates clusters: file basenames gave 28
2202
+ // distinct signatures at a 3.7% collision rate, against 13 / 19.3% for the preceding
2203
+ // action+result pair and 10 / 25.8% for tool_sequence. Matching an incoming cluster
2204
+ // to a ledger entry otherwise runs on class_hints alone, and those ARE fragments of
2205
+ // the entry's own evidence quotes — one channel, where identity and proof are the
2206
+ // same strings. These paths are mechanical, so carrying them adds a second channel
2207
+ // without reintroducing an LLM distillation step.
2208
+ for (const f of ss.files) cl.files.add(f);
2181
2209
  }
2182
2210
 
2183
2211
  const out = clusters.map(cl => {
@@ -2251,6 +2279,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2251
2279
  max_peak: peaks[peaks.length - 1],
2252
2280
  contexts: cl.contexts,
2253
2281
  errors: cl.errors,
2282
+ files: [...cl.files].sort().slice(0, 8), // referents: the discriminative match channel (see merge above)
2254
2283
  preceding: cl.preceding, // #4: agent action + result just before the reaction
2255
2284
  self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2256
2285
  top_keywords: topSh.slice(0, 10),
@@ -1085,10 +1085,17 @@ const LINK_SKIP = /(^|\/)(CHANGELOG\.md|log\.md)$/;
1085
1085
  // moves is already covered by the resident check the instant its own git mv lands, before
1086
1086
  // rewriteLinks ever runs for it.
1087
1087
  let plannedArchiveSrc = new Set();
1088
+ // Set only for the duration of cleanup-apply's restore pass (see cleanupApply below), which
1089
+ // walks inbound references back from the archived copy to the relocated core page. That pass
1090
+ // must skip the split's own pages: they cite the original BY LINE NUMBER, so their `sources:`
1091
+ // and citations belong on the frozen archive copy, not on the shortened core page. A
1092
+ // predicate rather than a Set because the exemption is a directory prefix plus one file.
1093
+ let linkRestoreExempt = null;
1088
1094
  // One predicate, called from the one place rewriteLinks() loops over candidate files, so the
1089
1095
  // exemption can never desync across callers the way moveDoc's follow-ups almost did.
1090
1096
  function isRewriteExempt(f) {
1091
- return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f);
1097
+ return LINK_SKIP.test(f) || f.startsWith(REORG_DEST.archive + '/') || plannedArchiveSrc.has(f)
1098
+ || (linkRestoreExempt !== null && linkRestoreExempt(f));
1092
1099
  }
1093
1100
 
1094
1101
  // A real corpus (astral-sh/uv) cross-links its docs with RELATIVE paths — `../concepts/x.md`,
@@ -1301,8 +1308,12 @@ function flushCommitAdvisory() {
1301
1308
  const outsideDocs = Array.from(new Set(outsideFiles.map(f => f.split('/')[0]))).sort();
1302
1309
  console.log(`\n${moveSet.length} rename(s) this run (git mv stages these automatically; `
1303
1310
  + 'the copy+unlink fallback used outside a git repo does not)');
1311
+ // Unit, stated: linkSet holds FILE paths, so this is a file count. cleanup-apply's
1312
+ // restore pass just below reports OCCURRENCES, and a real run printed "33" here next to
1313
+ // "restored 38" — same-sounding nouns, two different units, and the reader has no way to
1314
+ // tell they are not a discrepancy. Name the unit in both places.
1304
1315
  console.log(linkSet.length
1305
- ? `${linkSet.length} link rewrite(s) UNSTAGED` + (outsideFiles.length
1316
+ ? `${linkSet.length} file(s) with link rewrites UNSTAGED` + (outsideFiles.length
1306
1317
  ? `, ${outsideFiles.length} outside docs/: ${outsideDocs.join(', ')}`
1307
1318
  : ' (all inside docs/)')
1308
1319
  : 'no inbound-link rewrites this run.');
@@ -2352,6 +2363,40 @@ function cleanupApply(file, outlineF, labelsF) {
2352
2363
  for (const m of r.artifactNotes) console.log(` ${m}`);
2353
2364
  if (r.failures.length) console.error(` WARN core page relocated, but ${r.failures.join('; ')}`);
2354
2365
  noteMoved(r.rel); noteLinks(r.linkFiles.map(x => x.file));
2366
+
2367
+ // FIELD BUG (real, reproduced): archiveOrThrow above rewrote EVERY inbound reference
2368
+ // to point at docs/archive/, because at that moment the archive genuinely was the
2369
+ // only copy. The relocation on the line above then put a live page back at the
2370
+ // original path — so the corpus now tells readers that "the PRD" lives in the
2371
+ // archive while the live page sits unreferenced. On a real split that was 33
2372
+ // references across 15 files outside docs/, and it is precisely the backwards
2373
+ // outcome this command exists to prevent. Walk them back.
2374
+ //
2375
+ // The ordering itself cannot be swapped: the original must vacate the path before
2376
+ // the core page can occupy it, and rewriting links to a path nothing occupies yet
2377
+ // would be worse. So the restore is a third step, not a reordering.
2378
+ //
2379
+ // EXEMPT, and this is the point of the pass rather than an edge case: the split's
2380
+ // own pages cite the original BY LINE NUMBER, so their `sources:` and citations
2381
+ // must stay on the frozen archive copy — as must the relocated core page's own.
2382
+ // Rewriting those back would be a new bug wearing the old one's clothes.
2383
+ const archivedRel = path.posix.join(REORG_DEST.archive, path.basename(file));
2384
+ const pagesPrefix = pages.replace(/\/*$/, '') + '/';
2385
+ linkRestoreExempt = f => f === r.rel || f.startsWith(pagesPrefix);
2386
+ let restored;
2387
+ try { restored = rewriteLinks(archivedRel, r.rel); }
2388
+ finally { linkRestoreExempt = null; }
2389
+ if (restored.skipped) {
2390
+ console.log(` ${restored.skipped}`);
2391
+ } else if (restored.total) {
2392
+ console.log(` restored ${restored.total} inbound reference(s) from ${archivedRel} `
2393
+ + `back to ${r.rel} (the archive step had aimed them at the frozen copy; `
2394
+ + `${pagesPrefix} pages keep theirs, their citations are line-numbered)`);
2395
+ for (const { file: lf, n } of restored.files) console.log(` ${lf}: ${n}`);
2396
+ noteLinks(restored.files.map(x => x.file));
2397
+ } else {
2398
+ console.log(` no inbound references to ${archivedRel} needed restoring.`);
2399
+ }
2355
2400
  } catch (e) {
2356
2401
  console.error(` WARN could not relocate the core page from ${from} to ${to}: `
2357
2402
  + `${e.message} — it remains at ${from}.`);
@@ -1765,8 +1765,26 @@ function extractToolsFromTurn(event) {
1765
1765
  const toolName = block.name || 'unknown';
1766
1766
  tools.push({ tool: toolName, action: 'call' });
1767
1767
  } else if (block.type === 'tool_result') {
1768
- const result = String(block.content || '');
1769
- if (result.includes('Exit code 0')) {
1768
+ // The transcript marks a failed tool call with `is_error`; a success
1769
+ // carries no sentinel in the result text. Reading the text for
1770
+ // 'Exit code 0' matched 1 block in 2623 sampled from the real
1771
+ // corpus, so `result` was 'unknown' on 31 of 34 clusters — and the
1772
+ // case this field exists for is a CLAIMED success the user is
1773
+ // contradicting, which is the one that never fired. Content is also
1774
+ // sometimes an array of blocks (403/2623), which String() turned
1775
+ // into "[object Object]" so no pattern could match it.
1776
+ //
1777
+ // A block with no `is_error` stays unknown on purpose: those are
1778
+ // harness meta-results (skill/agent launches, question answers —
1779
+ // 560/2624 sampled) and calling them success would inflate the very
1780
+ // signal this field is here to detect.
1781
+ const raw = block.content;
1782
+ const result = Array.isArray(raw)
1783
+ ? raw.map(b => (b && typeof b === 'object' ? (b.text || '') : String(b || ''))).join('\n')
1784
+ : String(raw || '');
1785
+ if (block.is_error === true) {
1786
+ tools.push({ tool: 'result', action: 'error' });
1787
+ } else if (typeof block.is_error === 'boolean') {
1770
1788
  tools.push({ tool: 'result', action: 'success' });
1771
1789
  } else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
1772
1790
  tools.push({ tool: 'result', action: 'error' });
@@ -2142,6 +2160,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2142
2160
  errors: b.errors,
2143
2161
  peak: b.peak,
2144
2162
  texts: b.texts,
2163
+ files: [...b.files],
2145
2164
  preceding: b.preceding,
2146
2165
  anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2147
2166
  };
@@ -2166,7 +2185,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2166
2185
  if (best) {
2167
2186
  cl = best;
2168
2187
  } else {
2169
- cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2188
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null, files: new Set() };
2170
2189
  clusters.push(cl);
2171
2190
  }
2172
2191
  for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
@@ -2178,6 +2197,15 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2178
2197
  cl.peaks.push(ss.peak);
2179
2198
  if (ss.anySelf) cl.anySelf = true;
2180
2199
  if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2200
+ // The file referents a cluster's sessions touched. Measured on a 34-cluster real
2201
+ // corpus as the one channel that actually separates clusters: file basenames gave 28
2202
+ // distinct signatures at a 3.7% collision rate, against 13 / 19.3% for the preceding
2203
+ // action+result pair and 10 / 25.8% for tool_sequence. Matching an incoming cluster
2204
+ // to a ledger entry otherwise runs on class_hints alone, and those ARE fragments of
2205
+ // the entry's own evidence quotes — one channel, where identity and proof are the
2206
+ // same strings. These paths are mechanical, so carrying them adds a second channel
2207
+ // without reintroducing an LLM distillation step.
2208
+ for (const f of ss.files) cl.files.add(f);
2181
2209
  }
2182
2210
 
2183
2211
  const out = clusters.map(cl => {
@@ -2251,6 +2279,7 @@ function clusterCandidates(allCandidates, canonicalGroups) {
2251
2279
  max_peak: peaks[peaks.length - 1],
2252
2280
  contexts: cl.contexts,
2253
2281
  errors: cl.errors,
2282
+ files: [...cl.files].sort().slice(0, 8), // referents: the discriminative match channel (see merge above)
2254
2283
  preceding: cl.preceding, // #4: agent action + result just before the reaction
2255
2284
  self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2256
2285
  top_keywords: topSh.slice(0, 10),