@event4u/agent-config 14.4.0 → 14.6.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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +132 -291
  3. package/dist/agent-src/commands/analyze/decision.md +7 -0
  4. package/dist/agent-src/commands/optimize/project.md +10 -0
  5. package/dist/agent-src/commands/roadmap/process-full.md +45 -3
  6. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
  7. package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
  8. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  9. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
  12. package/dist/cli-delegate/cmd_conformance.js +1 -1
  13. package/dist/cli-delegate/cmd_handoff.js +1 -1
  14. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +8 -8
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +419 -94
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/router.json +1 -1
  28. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  29. package/package.json +1 -1
  30. package/src/config/estate-count-budget.json +36 -1
  31. package/src/config/pack-size-budget.json +3 -2
  32. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  33. package/src/scripts/_lib/scope_guard.sh +62 -3
  34. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  35. package/src/scripts/adr/regenerate_index.ts +13 -2
  36. package/src/scripts/adr_cite_check.ts +424 -0
  37. package/src/scripts/bench_hook_injection.ts +18 -1
  38. package/src/scripts/check_adr_frontmatter.ts +187 -1
  39. package/src/scripts/check_single_delivery.ts +473 -0
  40. package/src/scripts/hooks/host_semantics.ts +28 -0
  41. package/src/scripts/hooks/injection_budget.ts +152 -16
  42. package/src/scripts/hooks/run_continuation_hook.ts +918 -46
  43. package/src/scripts/install.sh +12 -2
  44. package/src/scripts/memory_consolidation.ts +189 -0
  45. package/src/scripts/memory_eviction.ts +384 -0
  46. package/src/scripts/report_layer_overlap.ts +72 -0
  47. package/src/scripts/report_memory_pointers.ts +622 -0
  48. package/src/scripts/session_register_hook.ts +144 -21
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Pointer-integrity report over the curated engineering memory store.
4
+ *
5
+ * WHY THIS EXISTS (road-to-context-fidelity Phase 2, spike cf02).
6
+ * `memory_report` prints `staleness-rate=0.0%` over a store measured by hand at
7
+ * **21.5 %** the same day. The 0.0 % is an artefact: all 107 entries carry the
8
+ * same `last_validated` and the same `review_after_days: 365`, so the earliest
9
+ * date any entry can read stale is 2027-07-09. The age axis is a check that
10
+ * passes because it cannot fail.
11
+ *
12
+ * WHAT THIS IS NOT, AND THE MEASUREMENT THAT SETTLED IT.
13
+ * This shipped as a staleness TRIAGE ranker — the council's Option 2, unanimous
14
+ * 2/2 on 2026-08-19 — on the argument that pointer liveness would rank entries
15
+ * for human re-verification. That claim was then measured against cf02's
16
+ * hand-walked ground truth and **failed**: precision 0.0 % against a 20.6 %
17
+ * base rate, recall 0.0 %, lift **0.00x** on the flagged set and 0.35x on the
18
+ * dead-or-moved union. Worse than random, on the very corpus that motivated it.
19
+ * The council's own pre-registered falsifier fired immediately. Full numbers and
20
+ * reproduction: `agents/evidence/eval-findings/context-fidelity-cf04.md`.
21
+ *
22
+ * The mechanism is not mysterious. A curated entry's `body` is free prose, so
23
+ * "does the tree still support this claim" is a SEMANTIC judgement no script
24
+ * decides. What pointers measure is whether the entry's CITATIONS still resolve
25
+ * — a different failure, and empirically an uncorrelated one. cf02 already
26
+ * carried the counterexample: `typecheck-use-task-not-bare-tsc` cites live
27
+ * files throughout and its stated reason is false.
28
+ *
29
+ * So the ranking claim is withdrawn and the name follows it. What survived
30
+ * measurement, and all this report asserts, is pointer INTEGRITY:
31
+ * - `dead` — a repo-rooted citation that resolves to nothing.
32
+ * - `moved` — the file exists under a new path; re-anchor the citation.
33
+ * - `unparseable` — outside the grammar or gitignored; parser-coverage only.
34
+ * - anchor coverage — how many entries can be tied to a tree state at all.
35
+ * Those are real documentation debt and a real prerequisite for the ladder.
36
+ * None of them is evidence that an entry's claim is false.
37
+ *
38
+ * It NEVER writes to the store and NEVER demotes an entry. Demotion is
39
+ * `memory_eviction.ts`, and the signal it acts on is a recorded HUMAN verdict.
40
+ * Council 2026-08-19: "Automatic demotion: never from pointer-liveness or
41
+ * anchor-drift output alone." The measurement above turned that from a
42
+ * precaution into a finding.
43
+ *
44
+ * Not a gate: it always exits 0 and has no gate-coverage entry. A blocking gate
45
+ * over a signal with 0.00x lift would be a merge blocker made of noise.
46
+ *
47
+ * Usage:
48
+ * report_memory_pointers # text report
49
+ * report_memory_pointers --format json
50
+ * report_memory_pointers --limit 10 # worst 10 entries only
51
+ * report_memory_pointers --path agents/memory
52
+ *
53
+ * Exit codes: 0 always (report), 3 = internal error.
54
+ */
55
+
56
+ import { spawnSync } from 'node:child_process';
57
+ import * as fs from 'node:fs';
58
+ import * as path from 'node:path';
59
+ import process from 'node:process';
60
+ import { fileURLToPath } from 'node:url';
61
+ import YAML from 'yaml';
62
+
63
+ import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
64
+
65
+ const PROG = 'report_memory_pointers.ts';
66
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
67
+ const DEFAULT_STORE = 'agents/memory';
68
+
69
+ /** File extensions a bare path reference may carry. Bounded on purpose. */
70
+ const KNOWN_EXTENSIONS = new Set([
71
+ '.ts', '.js', '.mjs', '.cjs', '.md', '.yml', '.yaml', '.json', '.sh', '.py', '.txt',
72
+ ]);
73
+
74
+ /**
75
+ * Pointer classes the grammar recognises. Anything backticked that looks
76
+ * path-ish but matches no class is reported `unparseable` rather than
77
+ * silently treated as live — council refinement 2026-08-19: "ambiguous
78
+ * free-prose references should be reported as unparseable, not silently
79
+ * treated as live."
80
+ */
81
+ export type PointerKind = 'path' | 'path-line' | 'dir' | 'adr' | 'wiki-link' | 'unparseable';
82
+ /**
83
+ * `moved` is the state that made this instrument usable at all. The first
84
+ * measured run flagged 18 entries of which 16 cited a roadmap that had simply
85
+ * been archived — the file exists, at a new path, and the entry's CLAIM is
86
+ * untouched by the move. Counting those as dead put the sweep's precision
87
+ * (2/18 = 11 %) BELOW the store's own staleness base rate (21.5 %), i.e. the
88
+ * ranking was worse than random. Relocation-awareness is what separates
89
+ * "this pointer rotted" from "this file moved".
90
+ */
91
+ export type PointerState = 'live' | 'moved' | 'dead' | 'unparseable';
92
+
93
+ export interface Pointer {
94
+ raw: string;
95
+ kind: PointerKind;
96
+ state: PointerState;
97
+ /** Repo-relative path this pointer resolves to, when it resolves at all. */
98
+ target?: string;
99
+ /** Why a `dead` pointer is dead — the half a reader acts on. */
100
+ detail?: string;
101
+ }
102
+
103
+ export interface EntryReport {
104
+ id: string;
105
+ type: string;
106
+ status: string;
107
+ last_validated: string;
108
+ review_after_days: number | null;
109
+ /** The tree revision the entry was SEMANTICALLY verified against. */
110
+ verified_at_commit: string | null;
111
+ anchor_state: 'present' | 'missing' | 'unresolvable';
112
+ pointers: Pointer[];
113
+ dead: number;
114
+ moved: number;
115
+ live: number;
116
+ unparseable: number;
117
+ /** Cited paths that changed between the anchor and HEAD. */
118
+ drifted: string[];
119
+ rank: number;
120
+ }
121
+
122
+ export interface SweepResult {
123
+ scanned: number;
124
+ entries: EntryReport[];
125
+ stores: string[];
126
+ }
127
+
128
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
129
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
130
+ }
131
+
132
+ /** A 7-40 char lowercase hex string is the only accepted anchor shape. */
133
+ export function isCommitish(value: unknown): value is string {
134
+ return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);
135
+ }
136
+
137
+ /**
138
+ * Extract candidate pointers from one entry body.
139
+ *
140
+ * The grammar is deliberately narrow: backtick spans, `[[wiki-links]]`, and
141
+ * bare `ADR-NNN` ids. Prose outside backticks is never parsed — a sentence
142
+ * naming a file is not a pointer, and treating it as one is how a triage
143
+ * instrument starts manufacturing findings.
144
+ */
145
+ export function extractPointers(body: string): Array<{ raw: string; kind: PointerKind }> {
146
+ const out: Array<{ raw: string; kind: PointerKind }> = [];
147
+ const seen = new Set<string>();
148
+
149
+ const push = (raw: string, kind: PointerKind): void => {
150
+ const dedupKey = `${kind} ${raw}`;
151
+ if (seen.has(dedupKey)) return;
152
+ seen.add(dedupKey);
153
+ out.push({ raw, kind });
154
+ };
155
+
156
+ for (const m of body.matchAll(/\[\[([^\]]+)\]\]/g)) {
157
+ push((m[1] ?? '').trim(), 'wiki-link');
158
+ }
159
+ for (const m of body.matchAll(/\bADR-(\d{2,4})\b/g)) {
160
+ push(`ADR-${m[1] ?? ''}`, 'adr');
161
+ }
162
+ for (const m of body.matchAll(/`([^`\n]+)`/g)) {
163
+ const raw = (m[1] ?? '').trim();
164
+ if (!raw || /\s/.test(raw)) continue;
165
+ // Three shapes are NOT pointers and are dropped before classification,
166
+ // because flagging them is how a triage list stops being read:
167
+ // `<pack>/<name>/command.md` — a template, not a claim about a file
168
+ // `~/.claude/settings.json` — outside the repo, unresolvable here
169
+ // `/optimize-project` — a slash-command name, not a path
170
+ if (raw.includes('<') || raw.includes('>')) continue;
171
+ if (raw.includes('*')) continue; // a glob describes a set, not a file
172
+ if (raw.startsWith('~')) continue;
173
+ if (/^\/[A-Za-z][\w:-]*$/.test(raw)) continue;
174
+
175
+ const lineMatch = /^([\w./@+-]+):(\d+)(?:-\d+)?$/.exec(raw);
176
+ if (lineMatch && KNOWN_EXTENSIONS.has(path.extname(lineMatch[1] ?? ''))) {
177
+ push(raw, 'path-line');
178
+ continue;
179
+ }
180
+ // A trailing separator names a directory — mechanically resolvable and
181
+ // worth resolving: `src/domains/` disappearing is exactly the kind of
182
+ // structural move that invalidates a whole batch of entries at once.
183
+ if (raw.endsWith('/') && raw.length > 1) {
184
+ push(raw, 'dir');
185
+ continue;
186
+ }
187
+ // A bare path needs a separator AND a known extension. `--flag`,
188
+ // `some_function()`, and `task ci` are not paths and must not be
189
+ // ranked as dead ones.
190
+ if (raw.includes('/') && KNOWN_EXTENSIONS.has(path.extname(raw))) {
191
+ push(raw, 'path');
192
+ continue;
193
+ }
194
+ // Path-ish but unmatched: a separator and no recognised extension, or
195
+ // an extension and no separator. Reported as parser coverage, never
196
+ // resolved, and never ranked — see `rankOf`.
197
+ if (raw.includes('/') || KNOWN_EXTENSIONS.has(path.extname(raw))) {
198
+ push(raw, 'unparseable');
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Top-level names of the repo, cached. A pointer whose first segment is not
206
+ * one of them is a FRAGMENT (`analyze/decision.md`, `charge.ts:13`,
207
+ * `archive/`) rather than a repo-relative path, and resolving a fragment
208
+ * against the repo root manufactures a dead pointer out of a live claim.
209
+ *
210
+ * The trade-off is stated rather than hidden: this also demotes a genuinely
211
+ * dead reference to a top-level directory that ADR-051 retired from `dead` to
212
+ * `unparseable`, because its first segment no longer exists to match. A triage
213
+ * instrument must under-flag rather than over-flag — an unread queue catches
214
+ * nothing at all. (The retired directory is not named here: adding the literal
215
+ * anywhere under `src/` is what `check_no_new_legacy_path` forbids, and a
216
+ * docstring citing a dead path as an example is still a citation.)
217
+ */
218
+ let _repoRootNames: Set<string> | null = null;
219
+ function repoRootNames(): Set<string> {
220
+ if (_repoRootNames == null) {
221
+ try {
222
+ _repoRootNames = new Set(fs.readdirSync(REPO_ROOT));
223
+ } catch {
224
+ _repoRootNames = new Set();
225
+ }
226
+ }
227
+ return _repoRootNames;
228
+ }
229
+
230
+ /**
231
+ * basename → tracked paths, built once from `git ls-files`. Used to tell a
232
+ * relocated file from a deleted one.
233
+ */
234
+ let _basenameIndex: Map<string, string[]> | null = null;
235
+ function basenameIndex(): Map<string, string[]> {
236
+ if (_basenameIndex == null) {
237
+ _basenameIndex = new Map();
238
+ const res = spawnSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
239
+ if (res.status === 0 && typeof res.stdout === 'string') {
240
+ for (const line of res.stdout.split('\n')) {
241
+ const rel = line.trim();
242
+ if (!rel) continue;
243
+ const base = path.basename(rel);
244
+ const bucket = _basenameIndex.get(base);
245
+ if (bucket) bucket.push(rel);
246
+ else _basenameIndex.set(base, [rel]);
247
+ }
248
+ }
249
+ }
250
+ return _basenameIndex;
251
+ }
252
+
253
+ /**
254
+ * Is this path ignored by git?
255
+ *
256
+ * An ignored path (`agents/tmp/…`, `agents/runtime/council/responses/…`) is
257
+ * local state that is absent from every clone by design. Its absence says
258
+ * nothing about the entry that cites it, so reporting it as dead is a defect
259
+ * in the instrument rather than a finding about the store — and it was the
260
+ * single largest false-positive class in the first measured run (6 of 10).
261
+ */
262
+ function isGitIgnored(relPath: string): boolean {
263
+ const res = spawnSync('git', ['check-ignore', '-q', '--no-index', relPath], {
264
+ cwd: REPO_ROOT,
265
+ encoding: 'utf-8',
266
+ });
267
+ return res.status === 0;
268
+ }
269
+
270
+ function relocationsOf(relPath: string): string[] {
271
+ return (basenameIndex().get(path.basename(relPath)) ?? []).filter((p) => p !== relPath).sort();
272
+ }
273
+
274
+ function isRepoRooted(relPath: string): boolean {
275
+ const first = relPath.split('/')[0] ?? '';
276
+ return first.length > 0 && repoRootNames().has(first);
277
+ }
278
+
279
+ function fileLineCount(abs: string): number {
280
+ try {
281
+ const text = fs.readFileSync(abs, 'utf-8');
282
+ if (text === '') return 0;
283
+ const n = text.split('\n').length;
284
+ return text.endsWith('\n') ? n - 1 : n;
285
+ } catch {
286
+ return 0;
287
+ }
288
+ }
289
+
290
+ function resolveAdr(id: string): string | null {
291
+ const num = id.slice(4);
292
+ for (const dir of ['docs/decisions', 'docs/adr']) {
293
+ const abs = path.join(REPO_ROOT, dir);
294
+ let names: string[];
295
+ try {
296
+ names = fs.readdirSync(abs);
297
+ } catch {
298
+ continue;
299
+ }
300
+ const hit = names.find((n) => n.startsWith(`ADR-${num}-`) || n === `ADR-${num}.md`);
301
+ if (hit) return path.posix.join(dir, hit);
302
+ }
303
+ return null;
304
+ }
305
+
306
+ /** Resolve one candidate against the tree. Pure w.r.t. the store, not the FS. */
307
+ export function resolvePointer(
308
+ candidate: { raw: string; kind: PointerKind },
309
+ knownIds: ReadonlySet<string>,
310
+ ): Pointer {
311
+ const { raw, kind } = candidate;
312
+ if (kind === 'unparseable') {
313
+ return { raw, kind, state: 'unparseable', detail: 'no pointer class matched' };
314
+ }
315
+ if (kind === 'wiki-link') {
316
+ return knownIds.has(raw)
317
+ ? { raw, kind, state: 'live', target: raw }
318
+ : { raw, kind, state: 'dead', detail: 'no curated entry carries this id' };
319
+ }
320
+ if (kind === 'adr') {
321
+ const hit = resolveAdr(raw);
322
+ return hit
323
+ ? { raw, kind, state: 'live', target: hit }
324
+ : { raw, kind, state: 'dead', detail: 'no ADR file with this number' };
325
+ }
326
+ const [relPath, lineStr] = kind === 'path-line' ? raw.split(':') : [raw, null];
327
+ if (!isRepoRooted(relPath)) {
328
+ return { raw, kind, state: 'unparseable', detail: 'not repo-rooted — first segment is not a top-level name' };
329
+ }
330
+ const abs = path.join(REPO_ROOT, relPath);
331
+ if (!fs.existsSync(abs)) {
332
+ if (isGitIgnored(relPath)) {
333
+ return { raw, kind, state: 'unparseable', target: relPath, detail: 'gitignored — absent by design, not rotted' };
334
+ }
335
+ const moved = kind === 'dir' ? [] : relocationsOf(relPath);
336
+ if (moved.length > 0) {
337
+ return {
338
+ raw,
339
+ kind,
340
+ state: 'moved',
341
+ target: relPath,
342
+ detail: `now at ${moved.slice(0, 2).join(', ')}${moved.length > 2 ? ` (+${moved.length - 2})` : ''}`,
343
+ };
344
+ }
345
+ return { raw, kind, state: 'dead', target: relPath, detail: 'path does not exist' };
346
+ }
347
+ if (kind === 'dir' && !fs.statSync(abs).isDirectory()) {
348
+ return { raw, kind, state: 'dead', target: relPath, detail: 'exists but is not a directory' };
349
+ }
350
+ if (kind === 'path-line' && lineStr) {
351
+ const want = parseInt(lineStr, 10);
352
+ const have = fileLineCount(abs);
353
+ if (want > have) {
354
+ return {
355
+ raw,
356
+ kind,
357
+ state: 'dead',
358
+ target: relPath,
359
+ detail: `line ${want} is past end of file (${have} lines)`,
360
+ };
361
+ }
362
+ }
363
+ return { raw, kind, state: 'live', target: relPath };
364
+ }
365
+
366
+ /**
367
+ * Paths cited by the entry that changed between its anchor and HEAD.
368
+ *
369
+ * The anchor means "the revision this entry was semantically verified
370
+ * against", so drift is a re-verification prompt, never a staleness verdict —
371
+ * a file can change a hundred times without touching the claim.
372
+ */
373
+ function driftedPaths(anchor: string, targets: readonly string[]): string[] {
374
+ if (targets.length === 0) return [];
375
+ const res = spawnSync(
376
+ 'git',
377
+ ['diff', '--name-only', `${anchor}..HEAD`, '--', ...targets],
378
+ { cwd: REPO_ROOT, encoding: 'utf-8' },
379
+ );
380
+ if (res.status !== 0 || typeof res.stdout !== 'string') return [];
381
+ return res.stdout.split('\n').map((s) => s.trim()).filter(Boolean).sort();
382
+ }
383
+
384
+ function anchorResolves(anchor: string): boolean {
385
+ const res = spawnSync('git', ['cat-file', '-e', `${anchor}^{commit}`], {
386
+ cwd: REPO_ROOT,
387
+ encoding: 'utf-8',
388
+ });
389
+ return res.status === 0;
390
+ }
391
+
392
+ function readStore(storeDir: string): Array<{ file: string; type: string; entries: Array<Record<string, unknown>> }> {
393
+ const abs = path.isAbsolute(storeDir) ? storeDir : path.join(REPO_ROOT, storeDir);
394
+ let names: string[];
395
+ try {
396
+ names = fs.readdirSync(abs).filter((n) => n.endsWith('.yml') || n.endsWith('.yaml'));
397
+ } catch {
398
+ return [];
399
+ }
400
+ names.sort();
401
+ const out: Array<{ file: string; type: string; entries: Array<Record<string, unknown>> }> = [];
402
+ for (const name of names) {
403
+ const file = path.join(abs, name);
404
+ let parsed: unknown;
405
+ try {
406
+ parsed = YAML.parse(fs.readFileSync(file, 'utf-8'));
407
+ } catch {
408
+ continue;
409
+ }
410
+ const entries: Array<Record<string, unknown>> = [];
411
+ if (isPlainObject(parsed) && Array.isArray(parsed['entries'])) {
412
+ for (const item of parsed['entries'] as unknown[]) {
413
+ if (isPlainObject(item)) entries.push(item);
414
+ }
415
+ }
416
+ out.push({
417
+ file: path.relative(REPO_ROOT, file),
418
+ type: name.replace(/\.(ya?ml)$/, ''),
419
+ entries,
420
+ });
421
+ }
422
+ return out;
423
+ }
424
+
425
+ function bodyOf(entry: Record<string, unknown>): string {
426
+ const parts: string[] = [];
427
+ for (const f of ['body', 'rule', 'symptom', 'description', 'summary', 'key']) {
428
+ const v = entry[f];
429
+ if (typeof v === 'string' && v.trim()) parts.push(v);
430
+ }
431
+ return parts.join('\n');
432
+ }
433
+
434
+ function asString(v: unknown): string {
435
+ if (v == null) return '';
436
+ if (v instanceof Date) return v.toISOString().slice(0, 10);
437
+ return String(v);
438
+ }
439
+
440
+ /**
441
+ * Order entries by how much pointer repair they need. NOT a staleness score —
442
+ * that claim was measured at 0.00x lift and withdrawn (see the header).
443
+ *
444
+ * Dead citations dominate because they are the only unambiguous integrity
445
+ * defect. Drift and a missing anchor are weaker: they say the entry cannot be
446
+ * re-checked cheaply, not that anything about it is wrong.
447
+ *
448
+ * `unparseable` deliberately contributes NOTHING. It measures the grammar's
449
+ * coverage, not the entry's health, and the first run proved why: counting it
450
+ * put all 107 of 107 entries in the report, which is a list nobody reads.
451
+ */
452
+ export function rankOf(r: Omit<EntryReport, 'rank'>): number {
453
+ return r.dead * 100 + r.moved * 2 + r.drifted.length * 5 + (r.anchor_state === 'present' ? 0 : 1);
454
+ }
455
+
456
+ /**
457
+ * An entry is listed only on a structural defect — never on age, anchor state,
458
+ * or parser coverage.
459
+ *
460
+ * `moved` is deliberately outside this predicate. A cited path that now lives
461
+ * somewhere else is a documentation fix (re-anchor the citation), not a defect
462
+ * in the entry, and treating the two alike is what put the first measured
463
+ * precision below the base rate.
464
+ */
465
+ export function isFlagged(r: Pick<EntryReport, 'dead' | 'drifted'>): boolean {
466
+ return r.dead > 0 || r.drifted.length > 0;
467
+ }
468
+
469
+ export function sweep(storeDir: string): SweepResult {
470
+ const stores = readStore(storeDir);
471
+ const knownIds = new Set<string>();
472
+ for (const s of stores) {
473
+ for (const e of s.entries) {
474
+ const id = asString(e['id']);
475
+ if (id) knownIds.add(id);
476
+ }
477
+ }
478
+
479
+ const reports: EntryReport[] = [];
480
+ for (const s of stores) {
481
+ for (const e of s.entries) {
482
+ const candidates = extractPointers(bodyOf(e));
483
+ const pointers = candidates.map((c) => resolvePointer(c, knownIds));
484
+ const anchorRaw = e['verified_at_commit'];
485
+ let anchor_state: EntryReport['anchor_state'] = 'missing';
486
+ let verified_at_commit: string | null = null;
487
+ let drifted: string[] = [];
488
+ if (isCommitish(anchorRaw)) {
489
+ verified_at_commit = anchorRaw;
490
+ if (anchorResolves(anchorRaw)) {
491
+ anchor_state = 'present';
492
+ const targets = [
493
+ ...new Set(
494
+ pointers
495
+ .filter((p) => p.state === 'live' && p.target && p.kind !== 'wiki-link')
496
+ .map((p) => p.target as string),
497
+ ),
498
+ ].sort();
499
+ drifted = driftedPaths(anchorRaw, targets);
500
+ } else {
501
+ anchor_state = 'unresolvable';
502
+ }
503
+ } else if (anchorRaw != null) {
504
+ anchor_state = 'unresolvable';
505
+ }
506
+
507
+ const base: Omit<EntryReport, 'rank'> = {
508
+ id: asString(e['id']),
509
+ type: s.type,
510
+ status: asString(e['status']),
511
+ last_validated: asString(e['last_validated']),
512
+ review_after_days: typeof e['review_after_days'] === 'number' ? (e['review_after_days'] as number) : null,
513
+ verified_at_commit,
514
+ anchor_state,
515
+ pointers,
516
+ dead: pointers.filter((p) => p.state === 'dead').length,
517
+ moved: pointers.filter((p) => p.state === 'moved').length,
518
+ live: pointers.filter((p) => p.state === 'live').length,
519
+ unparseable: pointers.filter((p) => p.state === 'unparseable').length,
520
+ drifted,
521
+ };
522
+ reports.push({ ...base, rank: rankOf(base) });
523
+ }
524
+ }
525
+
526
+ reports.sort((a, b) => b.rank - a.rank || a.id.localeCompare(b.id));
527
+ return { scanned: reports.length, entries: reports, stores: stores.map((s) => s.file) };
528
+ }
529
+
530
+ function renderText(result: SweepResult, limit: number | null): string {
531
+ const lines: string[] = [];
532
+ const flagged = result.entries.filter((e) => isFlagged(e));
533
+ const sum = (pick: (e: EntryReport) => number): number => result.entries.reduce((n, e) => n + pick(e), 0);
534
+ const unparseable = sum((e) => e.unparseable);
535
+ const resolved = sum((e) => e.live) + sum((e) => e.dead) + sum((e) => e.moved);
536
+ const coverage = resolved + unparseable === 0 ? 100 : (resolved / (resolved + unparseable)) * 100;
537
+ lines.push(`${PROG} · pointer integrity, NOT staleness — measured lift over base rate: 0.00x (cf04)`);
538
+ lines.push(`scanned: ${result.scanned} entr(ies) across ${result.stores.length} store(s)`);
539
+ lines.push(
540
+ `flagged: ${flagged.length} · dead pointers: ${sum((e) => e.dead)}` +
541
+ ` · moved (re-anchor, not re-verify): ${sum((e) => e.moved)}` +
542
+ ` · missing anchor: ${result.entries.filter((e) => e.anchor_state !== 'present').length}`,
543
+ );
544
+ lines.push(`parser coverage: ${coverage.toFixed(1)}% (${resolved} resolved / ${unparseable} unparseable)`);
545
+ lines.push('');
546
+ const shown = limit == null ? flagged : flagged.slice(0, limit);
547
+ for (const e of shown) {
548
+ lines.push(` [${e.rank}] ${e.type}/${e.id}`);
549
+ for (const p of e.pointers.filter((x) => x.state === 'dead')) {
550
+ lines.push(` dead ${p.kind}: ${p.raw} — ${p.detail ?? ''}`);
551
+ }
552
+ for (const p of e.pointers.filter((x) => x.state === 'unparseable')) {
553
+ lines.push(` unparseable: ${p.raw}`);
554
+ }
555
+ if (e.drifted.length) {
556
+ lines.push(` drifted since ${e.verified_at_commit}: ${e.drifted.join(', ')}`);
557
+ }
558
+ if (e.anchor_state !== 'present') {
559
+ lines.push(` anchor ${e.anchor_state} — cannot tell a verified entry from a re-stamped one`);
560
+ }
561
+ }
562
+ if (limit != null && flagged.length > limit) {
563
+ lines.push(` … ${flagged.length - limit} more (raise --limit)`);
564
+ }
565
+ return lines.join('\n');
566
+ }
567
+
568
+ export function main(argv: readonly string[]): number {
569
+ let storeDir = DEFAULT_STORE;
570
+ let format = 'text';
571
+ let limit: number | null = null;
572
+ for (let i = 0; i < argv.length; i += 1) {
573
+ const a = argv[i];
574
+ if (a === '--path') storeDir = argv[++i] ?? DEFAULT_STORE;
575
+ else if (a === '--format') format = argv[++i] ?? 'text';
576
+ else if (a === '--limit') limit = parseInt(argv[++i] ?? '0', 10) || null;
577
+ else if (a === '--help' || a === '-h') {
578
+ process.stdout.write(
579
+ `usage: ${PROG} [--path DIR] [--format text|json] [--limit N]\n` +
580
+ `Pointer integrity of curated memory entries: dead, moved,\n` +
581
+ `unparseable citations plus anchor coverage. NOT a staleness\n` +
582
+ `signal — that claim measured 0.00x lift and was withdrawn.\n` +
583
+ `Never writes, never demotes, always exits 0.\n`,
584
+ );
585
+ return 0;
586
+ }
587
+ }
588
+
589
+ const result = sweep(storeDir);
590
+ try {
591
+ assertScanned({
592
+ gate: PROG,
593
+ scanned: result.scanned,
594
+ units: 'entr(ies)',
595
+ roots: [storeDir],
596
+ allowEmpty: 'a consumer install may carry no curated memory at all',
597
+ });
598
+ } catch (e) {
599
+ if (e instanceof DeadScopeError) {
600
+ process.stderr.write(`${e.message}\n`);
601
+ return 0;
602
+ }
603
+ throw e;
604
+ }
605
+
606
+ if (format === 'json') {
607
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
608
+ } else {
609
+ process.stdout.write(`${renderText(result, limit)}\n`);
610
+ }
611
+ return 0;
612
+ }
613
+
614
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
615
+ if (invokedDirectly) {
616
+ try {
617
+ process.exit(main(process.argv.slice(2)));
618
+ } catch (e) {
619
+ process.stderr.write(`${PROG}: internal error: ${(e as Error).message}\n`);
620
+ process.exit(3);
621
+ }
622
+ }