@holmes-lab/holmes-kit 0.1.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 (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,416 @@
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.MAX_CONSECUTIVE_BLOCKS = void 0;
37
+ exports.governanceLostPreflight = governanceLostPreflight;
38
+ exports.evaluateStop = evaluateStop;
39
+ exports.stopDebtAction = stopDebtAction;
40
+ exports.decideStopGuard = decideStopGuard;
41
+ exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
42
+ exports.readGuardCount = readGuardCount;
43
+ exports.writeGuardCount = writeGuardCount;
44
+ const fs = __importStar(require("node:fs"));
45
+ const node_child_process_1 = require("node:child_process");
46
+ const path = __importStar(require("node:path"));
47
+ const test_scope_1 = require("../rtm/test-scope");
48
+ const constitution_1 = require("../governance/constitution");
49
+ const provenance_chain_1 = require("../governance/provenance-chain");
50
+ const test_evidence_1 = require("../review/test-evidence");
51
+ const pre_tool_use_1 = require("./pre-tool-use");
52
+ const governance_history_1 = require("../guardrail/governance-history");
53
+ const constitution_debt_1 = require("../governance/constitution-debt");
54
+ /**
55
+ * @implements A-SPEC-175
56
+ * The turn-boundary answer to "this project is governed but its specs are gone".
57
+ *
58
+ * Extracted so it is reachable by a test: the runtime block below only runs under
59
+ * `require.main === module`, and a check that lives only there is a check nothing can mutate against.
60
+ * Returns the reason to block with, or null when there is nothing to report.
61
+ */
62
+ function governanceLostPreflight(specsDir, projectRoot) {
63
+ if (fs.existsSync(specsDir))
64
+ return null;
65
+ // @implements A-SPEC-191 §18 (round 11) — the project is not "two directories up from the spec
66
+ // store". That arithmetic assumed a two-segment layout (`.ax/specs`), and `init --specs-dir specs`
67
+ // is an explicitly supported one-segment value: there `../..` names the project's PARENT, so the
68
+ // A-SPEC-175 protection looked for governance history in the wrong tree — silent where it should
69
+ // block, and blocking where it should be silent. The caller knows the root; it passes it.
70
+ const root = projectRoot ?? path.resolve(specsDir, '..', '..');
71
+ return (0, governance_history_1.hasGovernanceHistory)(root) ? governance_history_1.GOVERNANCE_LOST_HINT : null;
72
+ }
73
+ function evaluateStop(specs, evidence) {
74
+ // L1: the Stop gate IS the constitution's re-verification point — every turn boundary re-runs the
75
+ // inviolable articles (ART-2 RTM, ART-3 validity incl. 4-quadrant GWT, ART-4 coverage evidence).
76
+ // The articles live in ONE place (governance/constitution.ts); this gate merely executes them.
77
+ const violations = (0, constitution_1.verifyConstitution)({ specs, testCasesByAspec: evidence?.testCasesByAspec, executedByAspec: evidence?.executedByAspec, findings: evidence?.findings });
78
+ const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
79
+ // Tampered audit trail blocks finishing — wires the provenance chain's verify into the live gate
80
+ // (review finding: verify() previously had no product caller, so the chain's guarantee was inert).
81
+ if (evidence?.provenance && !evidence.provenance.ok) {
82
+ problems.push(`[ART-2] provenance chain broken at seq ${evidence.provenance.brokenAt}: ${evidence.provenance.detail}`);
83
+ }
84
+ // @implements A-SPEC-191 (§4a) — an existing-but-unreadable findings ledger is not a clean turn:
85
+ // it may hold an open critical, and a "clean" verdict here would also CLEAR standing ART-7 debt.
86
+ if (evidence?.findingsUnreadable) {
87
+ problems.push('[ART-7] findings 원장을 읽을 수 없습니다 — 열린 치명 발견의 존재를 확인할 수 없는 턴은 깨끗한 턴이 아닙니다 (원장 파일의 권한·형식을 복구하십시오)');
88
+ }
89
+ if (problems.length === 0)
90
+ return { block: false };
91
+ const shown = problems.slice(0, 20);
92
+ const more = problems.length > shown.length ? `\n…and ${problems.length - shown.length} more` : '';
93
+ // @implements A-SPEC-134 — the distinct articles feed the constitution-debt state on a cap-yield.
94
+ const articles = [...new Set(violations.map((x) => x.article)
95
+ .concat(evidence?.provenance && !evidence.provenance.ok ? ['ART-2'] : [])
96
+ .concat(evidence?.findingsUnreadable ? ['ART-7'] : []))];
97
+ return {
98
+ block: true,
99
+ articles,
100
+ reason: `[Holmes-Kit] constitution gate: ${problems.length} article violation(s) must be ` +
101
+ `fixed before finishing:\n${shown.join('\n')}${more}`,
102
+ };
103
+ }
104
+ /**
105
+ * @implements A-SPEC-134
106
+ * Pure decision for the constitution-debt state at a turn boundary. Replaces the Stop gate's silent
107
+ * cap-yield: a clean turn clears any standing debt; a cap-yield (the gate giving up its block after
108
+ * MAX_CONSECUTIVE_BLOCKS) RECORDS the unresolved articles as debt so new code is blocked until the
109
+ * constitution is clean again; a normal block (still within the cap) touches nothing.
110
+ */
111
+ function stopDebtAction(evaluation, guard) {
112
+ if (!evaluation.block)
113
+ return { action: 'clear', articles: [] }; // clean turn → debt paid
114
+ if (guard.capped)
115
+ return { action: 'write', articles: evaluation.articles ?? [] }; // gave up → debt owed
116
+ return { action: 'none', articles: [] }; // still blocking within cap
117
+ }
118
+ // ---- H2: stop loop-guard with a bounded consecutive-block cap ----------------------------------
119
+ // The old guard exited unconditionally when stop_hook_active was set, making the gate a ONE-SHOT
120
+ // speed bump (block once, then the very next stop sails through with the problems unfixed —
121
+ // adversarial review F7). Now consecutive blocked stops are COUNTED in a state file: the gate keeps
122
+ // blocking while problems persist, up to MAX_CONSECUTIVE_BLOCKS, then yields with a loud warning
123
+ // (bounded, so an unfixable state cannot infinite-loop the session). Any clean stop resets the count.
124
+ exports.MAX_CONSECUTIVE_BLOCKS = 3;
125
+ function decideStopGuard(wantsBlock, priorConsecutiveBlocks, cap = exports.MAX_CONSECUTIVE_BLOCKS) {
126
+ if (!wantsBlock)
127
+ return { block: false, nextCount: 0, capped: false };
128
+ if (priorConsecutiveBlocks >= cap)
129
+ return { block: false, nextCount: 0, capped: true }; // yield, reset
130
+ return { block: true, nextCount: priorConsecutiveBlocks + 1, capped: false };
131
+ }
132
+ /**
133
+ * @implements A-SPEC-191 §14 (round 10)
134
+ * WHERE this hook keeps its state, and it is the project — not the shell.
135
+ *
136
+ * Measured: with a raw relative path, running a session from a subdirectory made this hook create
137
+ * `<cwd>/.ax/ledger/stop-guard.json` on every CLEAN turn. That directory is a project marker, so the
138
+ * NEXT tool call's walk-up stopped there, and the PreToolUse gate then classified nearly the whole
139
+ * real project as "outside the project" — roles policy, .mcp.json, the ledger, spec promotion and
140
+ * ordinary source writes all flipped from deny to allow, one turn after the session started. The
141
+ * round-9 anchor fix went into PreToolUse only; this hook quietly undid it.
142
+ */
143
+ /**
144
+ * @implements A-SPEC-191 §19 (round 11, 3rd)
145
+ * The project this turn belongs to, or NULL when there is none to be found.
146
+ *
147
+ * Round-10 walked up for a `.ax` marker, and `resolveProjectRoot` returns its INPUT when the walk
148
+ * finds nothing. So on the first-class `--specs-dir docs/specs` deployment — where `.ax` may not
149
+ * exist at all (its contents are derived state the generated .gitignore ignores, and `.ax/roles` is
150
+ * optional) — a clean turn in a subdirectory wrote `<sub>/.ax/ledger/stop-guard.json` and thereby
151
+ * MINTED a project marker. From then on the nearest-marker walk stopped there: the real root could
152
+ * later gain its own `.ax` and still lose, and measured, that subdirectory session had No-Spec-No-Code
153
+ * and config-write open forever. §15(b) excused this on the premise that "nothing above is a
154
+ * project"; the premise was false — a real project above simply had no marker to find.
155
+ *
156
+ * So: look for the marker, then for the wired spec directory (the deployment's own evidence of where
157
+ * the project is), and if neither answers, say so. A hook that cannot find the project does not
158
+ * invent one; it keeps no state and falls back to the stateless one-shot guard.
159
+ */
160
+ function findProjectRoot(specsDir) {
161
+ try {
162
+ const { resolveProjectRoot } = require('../project/root');
163
+ const r = resolveProjectRoot(process.cwd());
164
+ if (r.marker !== 'given')
165
+ return r.root;
166
+ }
167
+ catch { /* fall through to the specs-dir walk */ }
168
+ if (specsDir && !path.isAbsolute(specsDir)) {
169
+ let dir = process.cwd();
170
+ for (let hop = 0; hop < 64; hop++) {
171
+ if (fs.existsSync(path.join(dir, specsDir)))
172
+ return dir;
173
+ const parent = path.dirname(dir);
174
+ if (parent === dir)
175
+ break;
176
+ dir = parent;
177
+ }
178
+ }
179
+ // @implements A-SPEC-191 §21 (round 11) — `dirname(dirname(specsDir))` is the same two-segment
180
+ // arithmetic §18 deleted from `governanceLostPreflight`, and it came back on the absolute branch.
181
+ // On the first-class one-segment deployment (`--specs-dir specs`) it names the project's PARENT,
182
+ // and §19's own minting then plants `.ax/ledger/stop-guard.json` there — measured: a project at
183
+ // `<outer>/proj` (marker and all) had the anchor moved to `<outer>` by one clean turn. Cut a tail
184
+ // only after confirming it is there; otherwise ask the marker, and if nothing answers, say so.
185
+ if (specsDir && path.isAbsolute(specsDir) && fs.existsSync(specsDir)) {
186
+ const norm = specsDir.replace(/\\/g, '/').replace(/\/+$/, '');
187
+ if (/\/\.ax\/specs$/i.test(norm))
188
+ return path.dirname(path.dirname(norm));
189
+ try {
190
+ const { resolveProjectRoot } = require('../project/root');
191
+ const r = resolveProjectRoot(norm);
192
+ if (r.marker !== 'given')
193
+ return r.root;
194
+ }
195
+ catch { /* 표지가 없으면 짓지 않는다 */ }
196
+ return null;
197
+ }
198
+ return null;
199
+ }
200
+ /** The wired spec directory for this run, set once by the CLI so the lookups above can use it. */
201
+ let WIRED_SPECS;
202
+ /** Test seam for the same reason as the two exports below — the CLI sets this on a real run. */
203
+ function __setWiredSpecsForTest(v) { WIRED_SPECS = v; }
204
+ function stopProjectRoot() {
205
+ return findProjectRoot(WIRED_SPECS) ?? process.cwd();
206
+ }
207
+ /** null when no project could be found — nothing is written, nothing is minted. */
208
+ function guardStatePath() {
209
+ const root = findProjectRoot(WIRED_SPECS);
210
+ return root === null ? null : path.join(root, '.ax', 'ledger', 'stop-guard.json');
211
+ }
212
+ const GUARD_STATE = () => guardStatePath() ?? path.join(process.cwd(), '.ax', 'ledger', 'stop-guard.json');
213
+ // State is keyed by SESSION so two concurrent sessions in one repo don't share/reset each other's
214
+ // count (adversarial finding MED-5). A different session id starts from 0.
215
+ //
216
+ // @implements A-SPEC-191 §18 (round 11) — keyed for READING is not keyed for STORING. The file held
217
+ // ONE {sessionId, consecutiveBlocks} cell, so two sessions taking turns each read 0 (the id does not
218
+ // match) and wrote 1 (overwriting the other) — the count could never reach the cap and the Stop gate
219
+ // blocked FOREVER. That is precisely the unbounded loop H2 exists to prevent, on the ordinary
220
+ // two-sessions-in-one-repo case, and round-10 widened it from same-cwd to project-wide by moving
221
+ // every session into a single project-level file. One cell per session, oldest evicted.
222
+ const GUARD_SESSIONS_MAX = 32;
223
+ function readGuardState() {
224
+ try {
225
+ return JSON.parse(fs.readFileSync(GUARD_STATE(), 'utf8'));
226
+ }
227
+ catch {
228
+ return {};
229
+ }
230
+ }
231
+ function readGuardCount(sessionId) {
232
+ const s = readGuardState();
233
+ const entry = s.sessions?.[sessionId];
234
+ if (entry && Number.isInteger(entry.n) && entry.n >= 0)
235
+ return entry.n;
236
+ // Pre-§18 files carried a single cell; honour it for the session that owns it so an upgrade in
237
+ // mid-session does not silently reset a count the operator is already living with.
238
+ if (s.sessionId === sessionId && Number.isInteger(s.consecutiveBlocks) && (s.consecutiveBlocks ?? -1) >= 0) {
239
+ return s.consecutiveBlocks;
240
+ }
241
+ return 0;
242
+ }
243
+ // Returns whether the state actually persisted. If it did NOT, the caller must fall back to the
244
+ // stateless one-shot guard (stop_hook_active) — otherwise an unwritable state file would make the
245
+ // count always read 0 and the gate block FOREVER (adversarial finding MED-4: the exact infinite
246
+ // loop H2 exists to prevent).
247
+ /**
248
+ * Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
249
+ * and a test that can only reach it through a spawned hook cannot pin what the lock does.
250
+ */
251
+ function writeGuardCount(sessionId, n) {
252
+ // @implements A-SPEC-191 §19 — no project, no state. Minting `<cwd>/.ax` to hold a counter is how
253
+ // the gate lost the project in the first place; returning false puts the caller on the stateless
254
+ // one-shot guard, which is the documented fallback for "the state did not persist".
255
+ const target = guardStatePath();
256
+ if (target === null)
257
+ return false;
258
+ try {
259
+ // @implements A-SPEC-191 §19 — read-modify-write under the SAME lock the findings ledger uses.
260
+ // Round-10 moved every session into one project-level file and round-11 gave each its own cell,
261
+ // but the merge was unguarded: two hooks overlapping meant the later writer dropped the other's
262
+ // cell, so a session's count kept resetting and the cap never fired — measured 12/12 consecutive
263
+ // blocks with three concurrent sessions, which is exactly the unbounded loop H2 exists to stop.
264
+ const { withLedgerLock } = require('../governance/ledger-lock');
265
+ return withLedgerLock(target, () => {
266
+ const prev = readGuardState();
267
+ const sessions = { ...(prev.sessions ?? {}) };
268
+ sessions[sessionId] = { n, at: Date.now() };
269
+ // Bounded: a long-lived repository must not accumulate a cell per session forever. The oldest
270
+ // are dropped, never the caller's own.
271
+ const ordered = Object.entries(sessions).sort((a, b) => b[1].at - a[1].at).slice(0, GUARD_SESSIONS_MAX);
272
+ fs.mkdirSync(path.dirname(target), { recursive: true });
273
+ fs.writeFileSync(target, JSON.stringify({ sessions: Object.fromEntries(ordered) }));
274
+ return true;
275
+ });
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
281
+ // CLI entry: Claude Code passes Stop-hook JSON on stdin. Fail-open (never crash a turn).
282
+ if (require.main === module) {
283
+ let buf = '';
284
+ process.stdin.on('data', (c) => (buf += c));
285
+ process.stdin.on('end', () => {
286
+ try {
287
+ const input = JSON.parse(buf || '{}');
288
+ const sessionId = typeof input.session_id === 'string' ? input.session_id : 'unknown-session';
289
+ // @implements A-SPEC-190 (round 8) — same wiring channel as the PreToolUse hook.
290
+ const { wiredSpecsDir } = require('./pre-tool-use');
291
+ // @implements A-SPEC-191 §16 (round 11) — the INPUTS move with the outputs, or the hook judges
292
+ // one tree and writes the verdict onto another. Round-10 moved the state paths to the project
293
+ // and left the spec path relative to the shell: a subdirectory turn then saw an EMPTY project,
294
+ // called the turn clean, and applied that clean verdict to the REAL root — deleting its
295
+ // constitution-debt record and reopening WRITE_CODE. Before round 10 the same miss was inert
296
+ // (it wrote to cwd too); the repair is what turned an inert miss into active laundering.
297
+ const wired = wiredSpecsDir(process.argv, process.env);
298
+ // The wiring is also EVIDENCE of where the project is (§19): a deployment that keeps its specs
299
+ // in `docs/specs` says so on this hook's own command line, and that is a marker no `.ax`
300
+ // gitignore can erase. Set before any path is resolved.
301
+ WIRED_SPECS = wired;
302
+ const specsDir = path.isAbsolute(wired) ? wired : path.join(stopProjectRoot(), wired);
303
+ // @implements A-SPEC-175
304
+ // BEFORE the constitution runs, because with the spec tree gone there are no articles to check
305
+ // and this hook simply exited 0 in silence — measured 2026-08-12, while the PreToolUse escape
306
+ // hatch justified itself by claiming this hook re-verifies. It does, but only when there is
307
+ // something to verify; a project whose ledger records approvals and whose specs have vanished
308
+ // is exactly the case that fell between the two.
309
+ const lost = governanceLostPreflight(specsDir, stopProjectRoot());
310
+ if (lost) {
311
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: lost }));
312
+ process.stderr.write(`${lost}\n`);
313
+ return;
314
+ }
315
+ const specs = (0, pre_tool_use_1.readSpecsSync)(specsDir);
316
+ // H1 evidence: count EXECUTABLE test cases per anchored A-SPEC (countTestCases strips comments/
317
+ // strings — sham-resistant). A scan FAILURE yields `undefined` (ART-4 skipped this turn, true
318
+ // fail-open); a SUCCESSFUL scan that finds nothing yields `{}` and DOES enforce — all evidence
319
+ // vanishing while approved T-SPECs exist is a genuine violation state, not a hiccup.
320
+ let testCasesByAspec;
321
+ try {
322
+ testCasesByAspec = {};
323
+ for (const [file, ids] of Object.entries((0, test_scope_1.scanTestAnchors)(stopProjectRoot()))) {
324
+ let n = 0;
325
+ // Pass the path: the counter picks the test framework from the extension, and without it
326
+ // every non-JS suite counts zero and ART-4 reports "no tests" for a project that has them.
327
+ try {
328
+ n = (0, test_scope_1.countTestCases)(fs.readFileSync(file, 'utf8'), file);
329
+ }
330
+ catch { /* unreadable file: skip */ }
331
+ for (const id of ids)
332
+ testCasesByAspec[id] = (testCasesByAspec[id] ?? 0) + n;
333
+ }
334
+ }
335
+ catch {
336
+ testCasesByAspec = undefined;
337
+ }
338
+ // EXECUTION evidence (ART-4 upgrade): usable only when produced at the CURRENT git HEAD, so a
339
+ // stale record can never vouch for changed code. Absent/stale -> syntactic fallback.
340
+ let executedByAspec;
341
+ try {
342
+ const head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: stopProjectRoot(), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
343
+ const ev = (0, test_evidence_1.readTestEvidence)(stopProjectRoot());
344
+ if ((0, test_evidence_1.isFresh)(ev, head))
345
+ executedByAspec = ev.executedByAspec;
346
+ }
347
+ catch {
348
+ executedByAspec = undefined;
349
+ }
350
+ // Provenance-chain verification (fail-open: a verify error skips the check, never crashes).
351
+ let provenance;
352
+ // @implements A-SPEC-148
353
+ // Verify EVERY replica chain: after the split a single file is only part of the ledger, and a
354
+ // broken chain elsewhere must still block finishing. `ok` collapses the per-chain results in
355
+ // the safe direction — one bad chain means the audit trail is not intact.
356
+ try {
357
+ const { FileLedgerStore } = require('../governance/ledger-store');
358
+ // Same rule for the tamper check (round-11): verifying the shell's `.ax/ledger` let a forged
359
+ // chain in the real project pass unseen from a subdirectory.
360
+ const r = new FileLedgerStore(path.join(stopProjectRoot(), path.dirname(provenance_chain_1.PROVENANCE_FILE))).verifyAll();
361
+ provenance = r.ok ? { ok: true } : { ok: false, detail: r.broken.map((b) => `${b.replicaId ?? 'legacy'}: ${b.detail ?? 'broken'}`).join('; ') };
362
+ }
363
+ catch {
364
+ provenance = undefined;
365
+ }
366
+ // @implements A-SPEC-160 — supply the findings ledger so ART-6 can see judgements sealed on a
367
+ // diverged server. An article nobody feeds is a dead article; ADR-014's discipline applies to
368
+ // the constitution too.
369
+ let findings;
370
+ let findingsUnreadable = false;
371
+ try {
372
+ const { FindingsLedger } = require('../review/findings');
373
+ findings = new FindingsLedger(path.join(stopProjectRoot(), '.ax', 'ledger', 'findings.jsonl')).list();
374
+ }
375
+ catch {
376
+ findingsUnreadable = true;
377
+ } // @implements A-SPEC-191 (§4a) — list() absorbs ENOENT; a throw means the ledger EXISTS and cannot be read, which must block, not launder
378
+ const out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
379
+ const guard = decideStopGuard(out.block, readGuardCount(sessionId));
380
+ const persisted = writeGuardCount(sessionId, guard.nextCount);
381
+ // @implements A-SPEC-134 — the cap-yield is no longer silent: a clean turn clears any debt, a
382
+ // give-up records the unresolved articles so WRITE_CODE is blocked until the constitution is
383
+ // clean again. Fail-open: a debt-state write error never changes the stop verdict.
384
+ try {
385
+ const debt = stopDebtAction(out, guard);
386
+ if (debt.action === 'clear')
387
+ (0, constitution_debt_1.clearDebt)(stopProjectRoot());
388
+ else if (debt.action === 'write')
389
+ (0, constitution_debt_1.writeDebt)(stopProjectRoot(), debt.articles);
390
+ }
391
+ catch { /* debt bookkeeping must never break the gate */ }
392
+ if (guard.block && !persisted && input.stop_hook_active) {
393
+ // State cannot persist -> the counter can never advance. Degrade to the stateless one-shot
394
+ // guard instead of blocking forever: stop_hook_active means we already blocked once.
395
+ process.stderr.write(`[Holmes-Kit] governance gate yielding (guard state unwritable, one-shot fallback) — issues remain UNRESOLVED:\n${out.reason ?? ''}\n`);
396
+ process.exitCode = 0;
397
+ return;
398
+ }
399
+ if (guard.block) {
400
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: out.reason }));
401
+ process.stderr.write(`${out.reason}\n`);
402
+ }
403
+ else if (guard.capped) {
404
+ process.stderr.write(`[Holmes-Kit] governance gate YIELDING after ${exports.MAX_CONSECUTIVE_BLOCKS} consecutive blocks — issues remain UNRESOLVED:\n${out.reason ?? ''}\n`);
405
+ }
406
+ // exitCode, NOT process.exit(): exit() tears the process down before large async stdout
407
+ // writes flush — a >64KB block decision reached the hook runner as TRUNCATED (invalid) JSON
408
+ // and the block silently became a pass (round-3). Natural exit drains the pipe first.
409
+ process.exitCode = 0;
410
+ }
411
+ catch (err) {
412
+ process.stderr.write(`[Holmes-Kit Stop Hook] Error: ${err instanceof Error ? err.message : String(err)}\n`);
413
+ process.exitCode = 0; // fail-open — exitCode 로 파이프를 비운 뒤 자연 종료(잘림 방지)
414
+ }
415
+ });
416
+ }
@@ -0,0 +1,162 @@
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.BASIS_DIVERGED = exports.MAX_BUILD_LEN = exports.MAX_DIGEST_LEN = void 0;
37
+ exports.basisDiverged = basisDiverged;
38
+ exports.loadedBuildId = loadedBuildId;
39
+ exports.collectBasis = collectBasis;
40
+ exports.basisDigest = basisDigest;
41
+ exports.withBasis = withBasis;
42
+ // @implements A-SPEC-156
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ /**
46
+ * There is deliberately NO graph axis. `RtmGraph` is in-memory only (ADR-011 — a derived index,
47
+ * rebuilt every time), so the graph is a FUNCTION of specs and scan and cannot diverge from them
48
+ * independently. Including it would mean either an always-`?` field or a rebuild per call: token
49
+ * cost with no information in the first case, a constant-I/O violation in the second.
50
+ */
51
+ /**
52
+ * Cap on the digest. A basis rides on every one of the tools, so its size multiplies across a
53
+ * session — this bound is what keeps "carry the basis" compatible with REQ-122's requirement that
54
+ * the harness be net-token-positive, rather than opposed to it.
55
+ */
56
+ exports.MAX_DIGEST_LEN = 64;
57
+ /**
58
+ * Cap on the build id alone. Truncating the whole digest at the end would eat the trailing axes, so
59
+ * a long build id could make `x:` disappear entirely and the digest would read as if that axis had
60
+ * never existed — the exact silent-omission failure `?` exists to prevent. Bounding the variable
61
+ * part instead keeps the structure intact whatever the stamp looks like.
62
+ */
63
+ exports.MAX_BUILD_LEN = 32;
64
+ /**
65
+ * @implements A-SPEC-160
66
+ * Appended when the loaded and on-disk builds are BOTH known and differ.
67
+ *
68
+ * This is an observation, not a verdict, and the distinction matters: REQ-156 deliberately refused to
69
+ * let the server decide whether it was stale, because that logic would be running inside the stale
70
+ * build. Reporting that two measured values disagree is a fact; what to do about it is ART-6's call.
71
+ *
72
+ * An unknown disk build never carries the marker. Reading absence as divergence would fire on every
73
+ * tool but `review_record` — none of the others read disk — and an article that fires constantly is
74
+ * one people route around within a day.
75
+ */
76
+ exports.BASIS_DIVERGED = '/!';
77
+ /** Reads the marker back out of a sealed digest. */
78
+ function basisDiverged(digest) {
79
+ return typeof digest === 'string' && digest.endsWith(exports.BASIS_DIVERGED);
80
+ }
81
+ const BUILD_ID_FILE = path.join('dist', '.build-id');
82
+ /**
83
+ * The build identity of the code that is RUNNING. Read once at start-up by the server, never per
84
+ * call: hashing `dist` on every request buys I/O for no new information, and mtime changes on a
85
+ * checkout or a copy, which would manufacture divergence that is not there.
86
+ *
87
+ * An unstamped build is reported as `unknown` rather than guessed. Absence is a fact too, and a
88
+ * fabricated id would be worse than none — it would look like agreement.
89
+ */
90
+ function loadedBuildId(root) {
91
+ try {
92
+ const raw = fs.readFileSync(path.join(root, BUILD_ID_FILE), 'utf8').trim();
93
+ if (raw === '')
94
+ return 'unknown';
95
+ return raw.length <= exports.MAX_BUILD_LEN ? raw : `${raw.slice(0, exports.MAX_BUILD_LEN - 1)}~`;
96
+ }
97
+ catch {
98
+ return 'unknown';
99
+ }
100
+ }
101
+ /** Runs one axis, converting any failure into absence. */
102
+ const attempt = (f) => {
103
+ if (!f)
104
+ return undefined;
105
+ try {
106
+ return f();
107
+ }
108
+ catch {
109
+ // Basis is diagnostic. If collecting it could fail a request, adding it would make the harness
110
+ // LESS available than before — the exact opposite of the point.
111
+ return undefined;
112
+ }
113
+ };
114
+ function collectBasis(ctx) {
115
+ const basis = { loadedBuild: ctx.loadedBuild ?? (ctx.root ? loadedBuildId(ctx.root) : 'unknown') };
116
+ const scanFp = attempt(ctx.scanFp);
117
+ if (scanFp !== undefined)
118
+ basis.scanFp = scanFp;
119
+ const specs = attempt(ctx.specs);
120
+ if (specs !== undefined)
121
+ basis.specs = specs;
122
+ // The on-disk value is deliberately NOT compared here. If the server rendered the "am I stale?"
123
+ // verdict, that logic would itself be running inside the stale build — the exact circle that let
124
+ // today's defect answer confidently. Both values are exposed; the caller draws the conclusion.
125
+ if (ctx.root)
126
+ basis.diskBuild = loadedBuildId(ctx.root);
127
+ return basis;
128
+ }
129
+ /**
130
+ * `b:<build>/s:<scanFp>/x:<specs>`
131
+ *
132
+ * Short by design: a caller compares the value against another and learns that something diverged
133
+ * without needing to interpret any field. An unmeasured axis renders as `?` rather than being
134
+ * dropped — omitting it would let a reader take "these two matched" for agreement when the third
135
+ * was never measured at all.
136
+ */
137
+ function basisDigest(b) {
138
+ const diverged = b.diskBuild !== undefined && b.diskBuild !== b.loadedBuild ? exports.BASIS_DIVERGED : '';
139
+ const digest = `b:${b.loadedBuild}/s:${b.scanFp ?? '?'}/x:${b.specs ?? '?'}`;
140
+ const capped = digest.length <= exports.MAX_DIGEST_LEN ? digest : `${digest.slice(0, exports.MAX_DIGEST_LEN - 1)}~`;
141
+ // The marker is appended AFTER capping so a long build id can never truncate it away — losing it
142
+ // would silently downgrade a divergent record to an agreeing one, which is the one direction this
143
+ // must never fail in.
144
+ return capped + diverged;
145
+ }
146
+ /**
147
+ * Attaches a basis to a handler's result.
148
+ *
149
+ * Exported and pure so the contract is testable without a handler that exercises each branch. The
150
+ * array guard in particular has no live caller today — every tool returns an object — and an
151
+ * untested guard is one a later change deletes for free. Spreading an array into an object yields
152
+ * `{0:…, 1:…, basis}`, silently reshaping a response into something no caller can read, so the
153
+ * branch has to survive on its own merits rather than on nobody having tried it yet.
154
+ */
155
+ function withBasis(fn, basisOf) {
156
+ return async (a) => {
157
+ const out = await fn(a);
158
+ if (out === null || typeof out !== 'object' || Array.isArray(out))
159
+ return out;
160
+ return { ...out, basis: basisDigest(basisOf(a)) };
161
+ };
162
+ }