liteagents 2.23.0 → 2.24.0
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 +51 -0
- package/package.json +1 -1
- package/packages/ampcode/commands/docs-builder/docs-builder.cjs +47 -2
- package/packages/ampcode/commands/remember/friction.cjs +32 -3
- package/packages/claude/commands/docs-builder/docs-builder.cjs +47 -2
- package/packages/claude/commands/remember/friction.cjs +32 -3
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +19 -18
- package/packages/droid/commands/docs-builder/docs-builder.cjs +47 -2
- package/packages/droid/commands/remember/friction.cjs +32 -3
- package/packages/opencode/command/docs-builder/docs-builder.cjs +47 -2
- package/packages/opencode/command/remember/friction.cjs +32 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.24.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Friction clusters carry file referents through as a second matching signature.**
|
|
14
|
+
`friction.cjs` already computed a `files` list per candidate (74/101 populated on a
|
|
15
|
+
frozen 34-cluster / 101-candidate corpus) but silently dropped it at clustering, so no
|
|
16
|
+
cluster ever saw it. It is now unioned per cluster and capped at 8 sorted paths. Measured
|
|
17
|
+
before building: `preceding` (tool-name sequence + result) gave 13 distinct signatures
|
|
18
|
+
over 34 clusters at 19.3% collision, `tool_sequence` gave 10 at 25.8% — both close to the
|
|
19
|
+
coin flip the existing quote channel already is. File basenames gave 28 at 3.7% on a
|
|
20
|
+
per-candidate join; the per-cluster union does better still: 30 distinct signatures over
|
|
21
|
+
34 clusters at 1.8% collision, 29/34 populated, a 10× reduction against `preceding`. A
|
|
22
|
+
prediction from the design notes turned out wrong in the favourable direction: paths are
|
|
23
|
+
real strings, not a distillation, so the channel needed no LLM judgement step to add.
|
|
24
|
+
**This channel is carried on the incoming side only.** No ledger entry stores `files` and
|
|
25
|
+
step 4a is not shown it, so ledger matching still runs on the single quote channel it
|
|
26
|
+
always has — shipping that half is gated on a future exact-label-agreement measurement,
|
|
27
|
+
documented in `docs/product/antigen-gate-prd.md` §13 and
|
|
28
|
+
`docs/product/remember-README.md` §6.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **Friction's `preceding.result` read the wrong signal — text-matched `'Exit code 0'`
|
|
32
|
+
instead of the `is_error` boolean.** The text match hit 1 of 2623 sampled result blocks;
|
|
33
|
+
`is_error` is present on 2065 of 2624. Before the fix, `preceding.result` was `unknown`
|
|
34
|
+
on 31 of 34 clusters; after, 18 claimed-success, 4 error, 12 unknown. Cluster hashes are
|
|
35
|
+
unchanged — this only corrects a field that was already there.
|
|
36
|
+
- **`docs-builder`'s cleanup output named counts ambiguously.** The advisory now says "N
|
|
37
|
+
file(s) with link rewrites" and the restore step says "restored N inbound reference(s)" —
|
|
38
|
+
the advisory counts files touched, the restore counts the references inside them, and the
|
|
39
|
+
two numbers are not the same unit.
|
|
40
|
+
- **`docs-builder` inbound references follow the core page back out of the archive.**
|
|
41
|
+
`cleanup-apply` archives the source, then relocates the core page back to its original
|
|
42
|
+
path. The archive step had already rewritten every inbound reference to point at
|
|
43
|
+
`docs/archive/` — correct at that moment, since the archive was briefly the only copy —
|
|
44
|
+
but nothing walked them back once the core page reoccupied the original path, leaving the
|
|
45
|
+
corpus telling readers the doc lived in the archive while the live page sat unreferenced.
|
|
46
|
+
A third restore step now walks them back. What must *not* be restored is the point of the
|
|
47
|
+
design rather than an edge case: the split's own pages cite the original by line number,
|
|
48
|
+
so their `sources:` and citations stay pinned to the frozen archive copy. Field-reported
|
|
49
|
+
from a real PRD split (33 references across 15 files), reproduced failing-first.
|
|
50
|
+
|
|
51
|
+
### Security
|
|
52
|
+
- **`fast-uri` bumped 3.1.5 → 3.1.7 and `qs` bumped 6.15.2 → 6.16.0** in the
|
|
53
|
+
live-canvas-channel plugin lockfile (Dependabot #43/#44, folded into one commit since
|
|
54
|
+
both edit the same lockfile). `fast-uri` 3.1.7 closes five high-severity advisories:
|
|
55
|
+
authority injection via an unvalidated port in `serialize()` (GHSA-qw65-cvwx-89v3), host
|
|
56
|
+
confusion via unbalanced IP-literal brackets (GHSA-58mr-gqgx-xq4g) and skipped IDN
|
|
57
|
+
canonicalization (GHSA-5jgf-p345-68v8), and SSRF via repeated hostname percent-decoding
|
|
58
|
+
(GHSA-fph4-wmhf-6fwf) and malformed IPv6 normalization (GHSA-f65p-4m7j-42xc). Both
|
|
59
|
+
packages are transitive dependencies of `@modelcontextprotocol/sdk`.
|
|
60
|
+
|
|
10
61
|
## [2.23.0] - 2026-09-02
|
|
11
62
|
|
|
12
63
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "liteagents",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.0",
|
|
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}
|
|
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
|
-
|
|
1769
|
-
|
|
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}
|
|
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
|
-
|
|
1769
|
-
|
|
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.
|
|
337
|
-
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.
|
|
338
|
-
"integrity": "sha512-
|
|
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.
|
|
452
|
-
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.
|
|
453
|
-
"integrity": "sha512-
|
|
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.
|
|
577
|
-
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.
|
|
578
|
-
"integrity": "sha512-
|
|
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.
|
|
853
|
-
"resolved": "https://registry.npmjs.org/qs/-/qs-6.
|
|
854
|
-
"integrity": "sha512-
|
|
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
|
-
"
|
|
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.
|
|
995
|
-
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.
|
|
996
|
-
"integrity": "sha512-
|
|
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.
|
|
1001
|
-
"side-channel-list": "^1.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}
|
|
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
|
-
|
|
1769
|
-
|
|
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}
|
|
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
|
-
|
|
1769
|
-
|
|
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),
|