@holmes-lab/holmes-kit 0.19.0 → 0.19.2

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 (35) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.d.ts +22 -0
  4. package/dist/holmes/cli/agents.js +76 -1
  5. package/dist/holmes/cli/approve.js +6 -1
  6. package/dist/holmes/cli/doctor.d.ts +36 -1
  7. package/dist/holmes/cli/doctor.js +182 -35
  8. package/dist/holmes/cli/index.js +7 -1
  9. package/dist/holmes/cli/init.js +12 -0
  10. package/dist/holmes/cli/native-deps.d.ts +65 -0
  11. package/dist/holmes/cli/native-deps.js +131 -0
  12. package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
  13. package/dist/holmes/cpg/cycle-observation.js +146 -0
  14. package/dist/holmes/governance/approval-queue.d.ts +23 -4
  15. package/dist/holmes/governance/approval-queue.js +44 -6
  16. package/dist/holmes/hooks/stop.d.ts +15 -0
  17. package/dist/holmes/hooks/stop.js +46 -3
  18. package/dist/holmes/mcp/handlers.d.ts +2 -0
  19. package/dist/holmes/mcp/handlers.js +29 -2
  20. package/dist/holmes/mcp/maintenance-analyze.d.ts +37 -0
  21. package/dist/holmes/mcp/maintenance-analyze.js +73 -1
  22. package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
  23. package/dist/holmes/mcp/maintenance-evidence.js +71 -4
  24. package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
  25. package/dist/holmes/project/install-scripts-policy.js +131 -0
  26. package/dist/holmes/project/npx-bin.d.ts +6 -0
  27. package/dist/holmes/project/npx-bin.js +10 -0
  28. package/dist/holmes/review/failed-test-names.d.ts +19 -0
  29. package/dist/holmes/review/failed-test-names.js +43 -0
  30. package/dist/holmes/review/run-replay.d.ts +23 -0
  31. package/dist/holmes/review/run-replay.js +30 -0
  32. package/dist/holmes/review/test-runner.d.ts +27 -0
  33. package/dist/holmes/review/test-runner.js +59 -3
  34. package/docs/install-guide.md +54 -5
  35. package/package.json +4 -1
@@ -0,0 +1,65 @@
1
+ import type { Coverage } from '../project/install-scripts-policy';
2
+ /**
3
+ * @implements A-SPEC-580
4
+ * Why better-sqlite3 has no binary — judged from EVIDENCE, not from the load error's wording.
5
+ *
6
+ * Measured 2026-09-09 (Windows, npm 12.0.1, Node 24.19.0): doctor said "ABI-locked … reinstall"
7
+ * when the real cause was npm 12 skipping the install script for lack of an `allowScripts` entry.
8
+ * A reinstall reproduces the same state. At least five causes hide behind one FAIL and each has a
9
+ * different remedy, so this module collects what is observable and refuses to assert what is not:
10
+ * a prebuilt-download failure leaves no trace doctor can read, and is named as a possibility only.
11
+ *
12
+ * Pure. The caller (doctor) gathers the evidence; every process it spawns for that is optional,
13
+ * and "not observed" is carried as `undefined`, never as `false`.
14
+ */
15
+ export type InstallKind = 'repo' | 'local' | 'global' | 'npx' | 'unknown';
16
+ export type NativeCause = 'ok' | 'scripts-blocked' | 'abi-mismatch' | 'build-failed' | 'unknown';
17
+ export type Level = 'PASS' | 'WARN' | 'FAIL';
18
+ export interface NativeEvidence {
19
+ platform: string;
20
+ packageName: string;
21
+ packageVersion: string;
22
+ /** `build/Release/<name>.node` exists under the package. */
23
+ bindingPresent: boolean;
24
+ /** The first line of the `require` failure, when it failed. */
25
+ loadError?: string;
26
+ /** `npm --version` major; undefined when npm could not be consulted. */
27
+ npmMajor?: number;
28
+ /** How the ROOT package.json that governs this install covers the package (A-SPEC-579). */
29
+ coverage: Coverage;
30
+ installKind: InstallKind;
31
+ /** win32 only: whether a source build could even start. */
32
+ toolchain?: {
33
+ python: boolean;
34
+ msvc: boolean;
35
+ };
36
+ /** node-gyp is known to trip over spaces in the install path. */
37
+ pathHasSpace: boolean;
38
+ /** win32 only: `Get-ExecutionPolicy`, when observed. */
39
+ psPolicy?: string;
40
+ }
41
+ /** Where this package lives — the layout decides which package.json (if any) holds the policy. */
42
+ export declare function installKind(packageRoot: string, globalDir?: string): InstallKind;
43
+ /**
44
+ * The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
45
+ * scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
46
+ *
47
+ * Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
48
+ * Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
49
+ * existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
50
+ * --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
51
+ * prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
52
+ * check's territory, not this one's.)
53
+ */
54
+ export declare function recoveryCommands(ev: Pick<NativeEvidence, 'platform' | 'installKind' | 'packageName' | 'packageVersion'>): string[];
55
+ /**
56
+ * What a PowerShell execution policy means for the emitted commands. Only the two policies that
57
+ * refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
58
+ */
59
+ export declare function powershellPolicyNote(policy: string | undefined): string;
60
+ export declare function nativeVerdict(ev: NativeEvidence): {
61
+ cause: NativeCause;
62
+ level: Level;
63
+ detail: string;
64
+ fix?: string;
65
+ };
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installKind = installKind;
4
+ exports.recoveryCommands = recoveryCommands;
5
+ exports.powershellPolicyNote = powershellPolicyNote;
6
+ exports.nativeVerdict = nativeVerdict;
7
+ // @implements A-SPEC-580
8
+ // @implements A-SPEC-580.1
9
+ const npx_bin_1 = require("../project/npx-bin");
10
+ const install_scripts_policy_1 = require("../project/install-scripts-policy");
11
+ const HOLMES_PKG = '@holmes-lab/holmes-kit';
12
+ /** Where this package lives — the layout decides which package.json (if any) holds the policy. */
13
+ function installKind(packageRoot, globalDir) {
14
+ const norm = packageRoot.replace(/\\/g, '/').replace(/\/+$/, '');
15
+ if (norm.split('/').includes('_npx'))
16
+ return 'npx';
17
+ if (globalDir) {
18
+ const g = globalDir.replace(/\\/g, '/').replace(/\/+$/, '');
19
+ if (norm.toLowerCase().startsWith(`${g.toLowerCase()}/`))
20
+ return 'global';
21
+ }
22
+ if (/\/node_modules\/@holmes-lab\/holmes-kit$/.test(norm))
23
+ return 'local';
24
+ return 'repo';
25
+ }
26
+ /**
27
+ * The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
28
+ * scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
29
+ *
30
+ * Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
31
+ * Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
32
+ * existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
33
+ * --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
34
+ * prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
35
+ * check's territory, not this one's.)
36
+ */
37
+ function recoveryCommands(ev) {
38
+ const npm = (0, npx_bin_1.npmBin)(ev.platform);
39
+ const project = [
40
+ (0, install_scripts_policy_1.approveCommand)(ev.packageName, ev.packageVersion, npm),
41
+ `${npm} rebuild ${ev.packageName} --foreground-scripts`,
42
+ ];
43
+ switch (ev.installKind) {
44
+ case 'global': return [`${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`];
45
+ case 'npx': return [`${npm} install --save-dev ${HOLMES_PKG}`, ...project];
46
+ default: return project;
47
+ }
48
+ }
49
+ /** A rebuild alone (the script is approved or the policy is not the problem). */
50
+ function rebuildCommand(ev) {
51
+ const npm = (0, npx_bin_1.npmBin)(ev.platform);
52
+ return ev.installKind === 'global'
53
+ ? `${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`
54
+ : `${npm} rebuild ${ev.packageName} --foreground-scripts`;
55
+ }
56
+ /**
57
+ * What a PowerShell execution policy means for the emitted commands. Only the two policies that
58
+ * refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
59
+ */
60
+ function powershellPolicyNote(policy) {
61
+ if (policy === 'Restricted' || policy === 'AllSigned') {
62
+ return `PowerShell execution policy ${policy} blocks the npm.ps1/npx.ps1 shims — use npm.cmd/npx.cmd (the commands above already do).`;
63
+ }
64
+ return '';
65
+ }
66
+ const ABI_RE = /NODE_MODULE_VERSION|compiled against a different Node\.js version/;
67
+ function nativeVerdict(ev) {
68
+ const pkg = `${ev.packageName}@${ev.packageVersion}`;
69
+ const isWin = ev.platform === 'win32';
70
+ const psNote = isWin ? powershellPolicyNote(ev.psPolicy) : '';
71
+ const withPs = (detail) => (psNote ? `${detail} ${psNote}` : detail);
72
+ const approved = ev.coverage === 'approved-pinned' || ev.coverage === 'approved-unpinned';
73
+ const policyHome = ev.installKind === 'repo' || ev.installKind === 'local'
74
+ ? 'the project package.json (allowScripts)'
75
+ : `no package.json can carry the approval for a ${ev.installKind} install — approve per command instead`;
76
+ // 1. Loads → nothing to diagnose. The wording is read by other suites; keep it byte-identical.
77
+ if (ev.bindingPresent && !ev.loadError) {
78
+ return { cause: 'ok', level: 'PASS', detail: 'loads and executes against :memory:' };
79
+ }
80
+ // 2. The binary exists but was built for another Node ABI — the ONE case the old wording fit.
81
+ if (ev.loadError && ABI_RE.test(ev.loadError)) {
82
+ return {
83
+ cause: 'abi-mismatch', level: 'FAIL',
84
+ detail: withPs(`${pkg} was built for a different Node ABI (running ${process.version}): ${ev.loadError}`),
85
+ fix: `Rebuild against this Node: ${rebuildCommand(ev)}`,
86
+ };
87
+ }
88
+ if (!ev.bindingPresent && !approved) {
89
+ // 3. npm ≥ 12 skips the script without an approval. That the approval is missing IS observed;
90
+ // an unobserved npm version does not change the remedy, so it is said and the same commands go out.
91
+ if (ev.npmMajor === undefined || ev.npmMajor >= 12) {
92
+ const npmSaid = ev.npmMajor === undefined ? 'npm (npm version not observed)' : `npm ${ev.npmMajor}`;
93
+ const denied = ev.coverage === 'denied' ? ` (allowScripts explicitly denied ${ev.packageName})` : '';
94
+ return {
95
+ cause: 'scripts-blocked', level: 'FAIL',
96
+ detail: withPs(`no binary — ${npmSaid} blocks dependency install scripts unless allowScripts covers ${pkg}${denied}; policy home: ${policyHome}.`),
97
+ fix: `Approve the one script that must run, then rebuild: ${recoveryCommands(ev).join(' && ')}`,
98
+ };
99
+ }
100
+ // 6b. Early npm 11 did not block scripts, so a missing approval proves nothing about why the
101
+ // script left no binary — say so instead of inventing a cause.
102
+ return {
103
+ cause: 'unknown', level: 'FAIL',
104
+ detail: withPs(`no binary — npm ${ev.npmMajor} may or may not have run the install script; rerun with --foreground-scripts to see what happened.`),
105
+ fix: `${rebuildCommand(ev)} — then run doctor again.`,
106
+ };
107
+ }
108
+ // 4. Approved, yet no binary: the script ran and produced nothing. What it could not do is
109
+ // partly observable (toolchain, path); a failed prebuild download is not.
110
+ if (!ev.bindingPresent) {
111
+ const missing = [];
112
+ if (ev.toolchain?.python === false)
113
+ missing.push('Python is not on PATH (node-gyp needs it)');
114
+ if (ev.toolchain?.msvc === false)
115
+ missing.push('Visual Studio C++ Build Tools (MSVC) were not found');
116
+ if (ev.pathHasSpace)
117
+ missing.push('the install path contains a space, which node-gyp is known to mishandle');
118
+ const because = missing.length ? ` Observed obstacles to a source build: ${missing.join('; ')}.` : '';
119
+ return {
120
+ cause: 'build-failed', level: 'FAIL',
121
+ detail: withPs(`the install script for ${pkg} is approved but produced no binary — a prebuilt download failure is not observable here.${because} Rerun with --foreground-scripts to see the script's own output.`),
122
+ fix: `${rebuildCommand(ev)} — install the missing toolchain (or use a Node version with a prebuilt binary) if the output shows a compile step.`,
123
+ };
124
+ }
125
+ // 6a. Present but failing to load for a reason we do not recognise — quote it, do not classify it.
126
+ return {
127
+ cause: 'unknown', level: 'FAIL',
128
+ detail: withPs(`${pkg} is present but failed to load: ${ev.loadError ?? '(no error text)'}`),
129
+ fix: `${rebuildCommand(ev)} — then run doctor again.`,
130
+ };
131
+ }
@@ -0,0 +1,65 @@
1
+ import { type Cycle } from './cycle-detect';
2
+ /**
3
+ * How many cycles one record lists before it starts counting instead.
4
+ *
5
+ * A record must not grow with the tree: a repository with a thousand cycles would otherwise write a
6
+ * thousand-entry line every turn, and the ledger this exists to make readable would be the thing
7
+ * that makes it unreadable. What is dropped is COUNTED, never silently cut.
8
+ */
9
+ export declare const CYCLE_LIST_CAP = 50;
10
+ /**
11
+ * One turn's observation — paths, integers and enums only.
12
+ *
13
+ * ADR-012's redaction rule is what lets this file be git-tracked at all: no prose, no command
14
+ * strings, no file content. `files` are the scanner's project-relative paths, which the impact and
15
+ * density ledgers already carry.
16
+ */
17
+ export interface CycleObservationRecord {
18
+ ts: string;
19
+ mode: 'strict' | 'track' | 'off';
20
+ cycles: Array<{
21
+ key: string;
22
+ files: string[];
23
+ runtime: boolean;
24
+ edges: number;
25
+ }>;
26
+ /** Cycles beyond CYCLE_LIST_CAP: counted, so a truncation is never mistaken for a clean tree. */
27
+ cyclesOmitted: number;
28
+ /** Keys of the runtime cycles nobody allowed — the ratchet's verdict at this moment. */
29
+ violations: string[];
30
+ allowed: number;
31
+ scope: {
32
+ judged: string[];
33
+ unavailable: string[];
34
+ };
35
+ replica?: string;
36
+ }
37
+ /** The cycle evidence the Stop hook computes, in the shape it already holds it. */
38
+ export interface CycleEvidence {
39
+ current: Cycle[];
40
+ allowed: string[];
41
+ mode: 'strict' | 'track' | 'off';
42
+ scope: {
43
+ judged: string[];
44
+ unavailable: string[];
45
+ };
46
+ }
47
+ /**
48
+ * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
49
+ * disagree about what "now" was.
50
+ *
51
+ * A CLEAN run produces a record too. That is the whole design: a false-positive RATE is violations
52
+ * over chances, and a ledger that only speaks when something is wrong records the numerator and
53
+ * throws the denominator away.
54
+ */
55
+ export declare function buildCycleObservation(ev: CycleEvidence, ts: string): CycleObservationRecord;
56
+ /**
57
+ * Append one record. Never throws, and never creates `.ax` where governance was not opted into
58
+ * (A-SPEC-191 §25 — the same refusal the approval queue makes).
59
+ *
60
+ * A failure returns `false` and changes nothing else: this is an OBSERVATION, and an observation
61
+ * that could alter a verdict would be a gate wearing a different name.
62
+ */
63
+ export declare function appendCycleObservation(root: string, rec: CycleObservationRecord): boolean;
64
+ /** Every replica's records, merged. A corrupt line is skipped, never fatal. */
65
+ export declare function readCycleObservations(root: string): CycleObservationRecord[];
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CYCLE_LIST_CAP = void 0;
37
+ exports.buildCycleObservation = buildCycleObservation;
38
+ exports.appendCycleObservation = appendCycleObservation;
39
+ exports.readCycleObservations = readCycleObservations;
40
+ // @implements A-SPEC-578.1
41
+ // The observation ledger the cycle ratchet's promotion criterion was already waiting on.
42
+ //
43
+ // `stop.ts` says promotion to `strict` "waits on the observation ledger answering the false-positive
44
+ // rate, which is the same path impactAdvisory and anchorDensity took". Measured 2026-09-09: those two
45
+ // siblings had been appending to `<name>.<replica>.jsonl` all along, and this one wrote a single line
46
+ // to stderr and nothing to disk — 0 records against their 12 and 7. A criterion waiting on data
47
+ // nobody collects never fires. This is the missing half, and it is deliberately the SIBLINGS' shape
48
+ // rather than a better one: a second convention for the same job is the next drift point.
49
+ //
50
+ // `cycle-detect.ts` stays pure (zero imports) — the same split `rtm/anchor-density.ts` uses against
51
+ // the rule it observes.
52
+ const fs = __importStar(require("node:fs"));
53
+ const path = __importStar(require("node:path"));
54
+ const replica_id_1 = require("../governance/replica-id");
55
+ const cycle_detect_1 = require("./cycle-detect");
56
+ /**
57
+ * How many cycles one record lists before it starts counting instead.
58
+ *
59
+ * A record must not grow with the tree: a repository with a thousand cycles would otherwise write a
60
+ * thousand-entry line every turn, and the ledger this exists to make readable would be the thing
61
+ * that makes it unreadable. What is dropped is COUNTED, never silently cut.
62
+ */
63
+ exports.CYCLE_LIST_CAP = 50;
64
+ /**
65
+ * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
66
+ * disagree about what "now" was.
67
+ *
68
+ * A CLEAN run produces a record too. That is the whole design: a false-positive RATE is violations
69
+ * over chances, and a ledger that only speaks when something is wrong records the numerator and
70
+ * throws the denominator away.
71
+ */
72
+ function buildCycleObservation(ev, ts) {
73
+ const listed = ev.current.slice(0, exports.CYCLE_LIST_CAP);
74
+ // The ratchet's own predicate decides what counts as a violation — reimplementing the filter here
75
+ // would be a second rule for one question, which is how the two quietly disagree.
76
+ const violations = (0, cycle_detect_1.cycleRatchetViolations)(listed, ev.allowed).map((v) => v.key);
77
+ return {
78
+ ts,
79
+ mode: ev.mode,
80
+ cycles: listed.map((c) => ({ key: (0, cycle_detect_1.cycleKey)(c.files), files: [...c.files].sort(), runtime: c.runtime, edges: c.edges.length })),
81
+ cyclesOmitted: Math.max(0, ev.current.length - listed.length),
82
+ violations,
83
+ allowed: ev.allowed.length,
84
+ scope: { judged: [...ev.scope.judged], unavailable: [...ev.scope.unavailable] },
85
+ };
86
+ }
87
+ const OBSERVATION_FILE_RE = /^cycle-observations\.([^.]+)\.jsonl$/;
88
+ /**
89
+ * Append one record. Never throws, and never creates `.ax` where governance was not opted into
90
+ * (A-SPEC-191 §25 — the same refusal the approval queue makes).
91
+ *
92
+ * A failure returns `false` and changes nothing else: this is an OBSERVATION, and an observation
93
+ * that could alter a verdict would be a gate wearing a different name.
94
+ */
95
+ function appendCycleObservation(root, rec) {
96
+ try {
97
+ if (!fs.existsSync(path.join(root, '.ax')))
98
+ return false;
99
+ let replica = 'local';
100
+ try {
101
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
102
+ }
103
+ catch { /* keep the fallback */ }
104
+ const file = path.join(root, '.ax', 'ledger', `cycle-observations.${replica}.jsonl`);
105
+ fs.mkdirSync(path.dirname(file), { recursive: true });
106
+ fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ /** Every replica's records, merged. A corrupt line is skipped, never fatal. */
114
+ function readCycleObservations(root) {
115
+ const dir = path.join(root, '.ax', 'ledger');
116
+ let names;
117
+ try {
118
+ names = fs.readdirSync(dir).filter((n) => OBSERVATION_FILE_RE.test(n)).sort();
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const out = [];
124
+ for (const name of names) {
125
+ let text;
126
+ try {
127
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
128
+ }
129
+ catch {
130
+ continue;
131
+ }
132
+ for (const line of text.split('\n')) {
133
+ const s = line.trim();
134
+ if (!s)
135
+ continue;
136
+ try {
137
+ const r = JSON.parse(s);
138
+ if (r && typeof r === 'object' && typeof r.ts === 'string' && Array.isArray(r.cycles) && Array.isArray(r.violations)) {
139
+ out.push(r);
140
+ }
141
+ }
142
+ catch { /* a corrupt line never breaks the read */ }
143
+ }
144
+ }
145
+ return out;
146
+ }
@@ -109,8 +109,9 @@ export declare function approvalRequestId(kind: string, target: string): string;
109
109
  * waiting.
110
110
  */
111
111
  export declare function foldQueue(lines: string[], opts?: {
112
- now: number;
113
- ttlMs: number;
112
+ now?: number;
113
+ ttlMs?: number;
114
+ includeAllKinds?: boolean;
114
115
  }): QueueState;
115
116
  /**
116
117
  * Append a request event. Fire-and-forget.
@@ -126,10 +127,28 @@ export declare function enqueueApprovalRequest(root: string, req: {
126
127
  why: string;
127
128
  reasonBytes?: number;
128
129
  }): boolean;
130
+ /**
131
+ * The same act, told in full: what happened and why.
132
+ *
133
+ * @implements A-SPEC-576.1
134
+ * Nine modules read this one, and all of them want the boolean — so the boolean stays and this is
135
+ * the shape underneath it, rather than a breaking change rippling through nine call sites for the
136
+ * benefit of the one caller that wants to distinguish a duplicate from a refusal.
137
+ */
138
+ export declare function enqueueApprovalRequestDetailed(root: string, req: {
139
+ kind: string;
140
+ target: string;
141
+ why: string;
142
+ reasonBytes?: number;
143
+ }): {
144
+ written: boolean;
145
+ reason: 'written' | 'duplicate' | 'no-project' | 'unwritable';
146
+ };
129
147
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
130
148
  export declare function readQueue(root: string, opts?: {
131
- now: number;
132
- ttlMs: number;
149
+ now?: number;
150
+ ttlMs?: number;
151
+ includeAllKinds?: boolean;
133
152
  }): QueueState;
134
153
  /**
135
154
  * The refusal-message suffix pointing the operator at the review CLI.
@@ -40,6 +40,7 @@ exports.readRefusals = readRefusals;
40
40
  exports.approvalRequestId = approvalRequestId;
41
41
  exports.foldQueue = foldQueue;
42
42
  exports.enqueueApprovalRequest = enqueueApprovalRequest;
43
+ exports.enqueueApprovalRequestDetailed = enqueueApprovalRequestDetailed;
43
44
  exports.readQueue = readQueue;
44
45
  exports.queueHint = queueHint;
45
46
  // @implements A-SPEC-244
@@ -169,6 +170,16 @@ function foldQueue(lines, opts) {
169
170
  malformedLines++;
170
171
  break;
171
172
  }
173
+ // @implements A-SPEC-576.1 — the READER applies the writer's predicate.
174
+ // REQ-563 routed gate refusals away from the inbox and deliberately did NOT rewrite the
175
+ // 2,192 lines already written; append-only is the rule this ledger is worth something for.
176
+ // The two decisions together left the inbox permanently 99.93% noise (measured here:
177
+ // 1,418 `shell` entries around a single real decision) because only the writer changed.
178
+ // The same constant does both jobs, so there is no second list to drift.
179
+ // `decisions` and `holds` are built from the OTHER events and stay complete — the hold
180
+ // question and the denial reason a refused `shell` request needs still reach `queueHint`.
181
+ if (!opts?.includeAllKinds && !exports.DECISION_KINDS.has(String(e.kind ?? '')))
182
+ break;
172
183
  const prev = pending.get(id);
173
184
  const ts = typeof e.ts === 'string' ? e.ts : '';
174
185
  if (prev) {
@@ -223,7 +234,9 @@ function foldQueue(lines, opts) {
223
234
  }
224
235
  }
225
236
  const all = [...pending.values()];
226
- if (!opts)
237
+ const ttl = typeof opts?.ttlMs === 'number' && typeof opts?.now === 'number'
238
+ ? { ttlMs: opts.ttlMs, now: opts.now } : null;
239
+ if (ttl === null)
227
240
  return { pending: all, expired: [], malformedLines, decisions, holds };
228
241
  // @implements A-SPEC-507.1 — strict excess only, and an unparseable lastTs stays ACTIVE: a
229
242
  // clockless entry must never be silently hidden by a clock it does not carry.
@@ -231,7 +244,7 @@ function foldQueue(lines, opts) {
231
244
  const active = [];
232
245
  for (const p of all) {
233
246
  const last = Date.parse(p.lastTs);
234
- (Number.isFinite(last) && last + opts.ttlMs < opts.now ? expired : active).push(p);
247
+ (Number.isFinite(last) && last + ttl.ttlMs < ttl.now ? expired : active).push(p);
235
248
  }
236
249
  return { pending: active, expired, malformedLines, decisions, holds };
237
250
  }
@@ -244,6 +257,18 @@ function foldQueue(lines, opts) {
244
257
  * caller uses the boolean only to decide whether to print the review hint.
245
258
  */
246
259
  function enqueueApprovalRequest(root, req) {
260
+ return enqueueApprovalRequestDetailed(root, req).written;
261
+ }
262
+ /**
263
+ * The same act, told in full: what happened and why.
264
+ *
265
+ * @implements A-SPEC-576.1
266
+ * Nine modules read this one, and all of them want the boolean — so the boolean stays and this is
267
+ * the shape underneath it, rather than a breaking change rippling through nine call sites for the
268
+ * benefit of the one caller that wants to distinguish a duplicate from a refusal.
269
+ */
270
+ function enqueueApprovalRequestDetailed(root, req) {
271
+ let reason = 'unwritable';
247
272
  try {
248
273
  // @implements A-SPEC-244
249
274
  // Only under an EXISTING .ax. The constitution suite (§25a) caught the first cut creating
@@ -251,7 +276,7 @@ function enqueueApprovalRequest(root, req) {
251
276
  // marker where governance was never opted into, the exact defect A-SPEC-191 §25 exists to stop.
252
277
  // An ungoverned directory gets no queue and no hint; it is not part of the system.
253
278
  if (!fs.existsSync(path.join(root, '.ax')))
254
- return false;
279
+ return { written: false, reason: 'no-project' };
255
280
  // @implements A-SPEC-563.1 — kind routing: only decision-seeking kinds enter the tracked inbox;
256
281
  // a gate refusal (shell, or any future kind — fail-safe toward a clean inbox) goes to this
257
282
  // machine's LOCAL refusal log, raw target and all, where the approve fallback can still find it.
@@ -301,12 +326,25 @@ function enqueueApprovalRequest(root, req) {
301
326
  // not a regular file, so a link — dangling or not — is refused. A path that truly does not exist
302
327
  // throws ENOENT and is created, which is the ordinary first-write case.
303
328
  if (!isPlainFile(file))
304
- return false;
329
+ return { written: false, reason: 'unwritable' };
330
+ // @implements A-SPEC-576.1 — idempotency, on the INBOX only.
331
+ // Measured on this ledger: 2,203 `requested` lines carry 1,427 distinct ids, and 774 of the 776
332
+ // duplicates are one id — a `rm -rf /` test fixture re-filed 774 times across nine days. An
333
+ // outstanding question does not become more answerable by being asked again.
334
+ // The refusal log is deliberately left alone: it is a LOG, where each refusal is its own event
335
+ // and A-SPEC-564.2 counts them. Deduplicating a log erases the measurement it exists for.
336
+ // "Already asked" means still pending — a request re-filed AFTER a decision is a new question,
337
+ // and folding already drops decided ids from `pending`.
338
+ if (exports.DECISION_KINDS.has(req.kind)
339
+ && readQueue(root, { includeAllKinds: true }).pending.some((p) => p.id === event.id)) {
340
+ return { written: false, reason: 'duplicate' };
341
+ }
305
342
  fs.appendFileSync(file, JSON.stringify(event) + '\n');
306
- return true;
343
+ reason = 'written';
344
+ return { written: true, reason };
307
345
  }
308
346
  catch {
309
- return false;
347
+ return { written: false, reason };
310
348
  }
311
349
  }
312
350
  /**
@@ -174,6 +174,21 @@ export declare function rolledBackLedgers(root: string): string[] | undefined;
174
174
  * Returns the reason to block with, or null when there is nothing to report.
175
175
  */
176
176
  export declare function governanceLostPreflight(specsDir: string, projectRoot?: string): string | null;
177
+ /**
178
+ * One line per ARTICLE, each under its own name.
179
+ *
180
+ * This used to be a single line reading `ART-8 RED-first (track)` for everything in `tracked` —
181
+ * and the cycle ratchet pushes ART-2 findings into that same array, so an import cycle was
182
+ * reported to the operator as a RED-first violation. Two observers sharing one sentence means the
183
+ * sentence is wrong for at least one of them.
184
+ *
185
+ * An article with no label still speaks, under its bare name: a new observer that says nothing is
186
+ * worse than one that says something plain.
187
+ */
188
+ export declare function trackedLines(tracked?: {
189
+ article: string;
190
+ detail: string;
191
+ }[]): string[];
177
192
  export declare function evaluateStop(specs: Spec[], evidence?: StopEvidence): {
178
193
  block: boolean;
179
194
  reason?: string;
@@ -42,6 +42,7 @@ exports.escalateReappraisals = escalateReappraisals;
42
42
  exports.unrecordedApprovals = unrecordedApprovals;
43
43
  exports.rolledBackLedgers = rolledBackLedgers;
44
44
  exports.governanceLostPreflight = governanceLostPreflight;
45
+ exports.trackedLines = trackedLines;
45
46
  exports.evaluateStop = evaluateStop;
46
47
  exports.stopDebtAction = stopDebtAction;
47
48
  exports.acknowledgeStop = acknowledgeStop;
@@ -432,6 +433,36 @@ function governanceLostPreflight(specsDir, projectRoot) {
432
433
  const root = projectRoot ?? path.resolve(specsDir, '..', '..');
433
434
  return (0, governance_history_1.hasGovernanceHistory)(root) ? governance_history_1.GOVERNANCE_LOST_HINT : null;
434
435
  }
436
+ // @implements A-SPEC-578.1
437
+ /** What each article's track observations are called on the operator's screen. */
438
+ const TRACK_LABELS = {
439
+ 'ART-8': 'RED-first',
440
+ 'ART-2': 'code-graph cycles',
441
+ };
442
+ /**
443
+ * One line per ARTICLE, each under its own name.
444
+ *
445
+ * This used to be a single line reading `ART-8 RED-first (track)` for everything in `tracked` —
446
+ * and the cycle ratchet pushes ART-2 findings into that same array, so an import cycle was
447
+ * reported to the operator as a RED-first violation. Two observers sharing one sentence means the
448
+ * sentence is wrong for at least one of them.
449
+ *
450
+ * An article with no label still speaks, under its bare name: a new observer that says nothing is
451
+ * worse than one that says something plain.
452
+ */
453
+ function trackedLines(tracked) {
454
+ if (!tracked || tracked.length === 0)
455
+ return [];
456
+ const byArticle = new Map();
457
+ for (const t of tracked) {
458
+ const key = String(t?.article ?? '');
459
+ byArticle.set(key, [...(byArticle.get(key) ?? []), String(t?.detail ?? '')]);
460
+ }
461
+ return [...byArticle.entries()].map(([article, details]) => {
462
+ const label = TRACK_LABELS[article];
463
+ return `[Holmes-Kit] ${article}${label ? ` ${label}` : ''} (track): ${details.join(' | ')}`;
464
+ });
465
+ }
435
466
  function evaluateStop(specs, evidence) {
436
467
  // L1: the Stop gate IS the constitution's re-verification point — every turn boundary re-runs the
437
468
  // inviolable articles (ART-2 RTM, ART-3 validity incl. 4-quadrant GWT, ART-4 coverage evidence).
@@ -945,6 +976,18 @@ if (require.main === module) {
945
976
  unavailable: [...sawImports].filter((e) => !judged.has(e)).sort(),
946
977
  },
947
978
  };
979
+ // @implements A-SPEC-578.1 — record the observation the promotion criterion waits on.
980
+ // EVERY run, including a clean one: a false-positive rate is violations over chances, and a
981
+ // ledger that only speaks when something is wrong keeps the numerator and drops the
982
+ // denominator. Append failure is swallowed by the outer catch below — an observation that
983
+ // could change a verdict would be a gate wearing another name.
984
+ // Its OWN try: sharing the outer one would let a fault in the observation discard the
985
+ // article's evidence, which is the coupling this comment exists to deny.
986
+ try {
987
+ const { appendCycleObservation, buildCycleObservation } = require('../cpg/cycle-observation');
988
+ appendCycleObservation(root, buildCycleObservation(cycles, new Date().toISOString()));
989
+ }
990
+ catch { /* an observation never touches the verdict */ }
948
991
  }
949
992
  catch {
950
993
  cycles = undefined;
@@ -1046,9 +1089,9 @@ if (require.main === module) {
1046
1089
  }
1047
1090
  }
1048
1091
  catch { /* the reappraisal signal is advisory; a failure never affects the stop verdict */ }
1049
- if (out.tracked && out.tracked.length > 0) {
1050
- process.stderr.write(`[Holmes-Kit] ART-8 RED-first (track): ${out.tracked.map((t) => t.detail).join(' | ')}\n`);
1051
- }
1092
+ // @implements A-SPEC-578.1 one line per article, each under its own name.
1093
+ for (const line of trackedLines(out.tracked))
1094
+ process.stderr.write(`${line}\n`);
1052
1095
  // @implements A-SPEC-247 — before deciding to re-block, ask whether every unresolved debt is
1053
1096
  // already queued for the owner. If so, tell the user ONCE and let the turn finish; a single
1054
1097
  // non-waiting violation and we block exactly as before.