@holmes-lab/holmes-kit 0.1.8 → 0.1.10

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 (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/test-platform.d.ts +25 -0
  5. package/dist/holmes/cli/test-platform.js +38 -0
  6. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  7. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  8. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  9. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  10. package/dist/holmes/cpg/hash-cache.js +13 -5
  11. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  12. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  13. package/dist/holmes/cpg/proposed-content.js +72 -0
  14. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  15. package/dist/holmes/cpg/required-calls.js +93 -0
  16. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  17. package/dist/holmes/guardrail/cspec-change.js +70 -0
  18. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  19. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  20. package/dist/holmes/guardrail/write-target.js +69 -18
  21. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  22. package/dist/holmes/hooks/stop.d.ts +17 -0
  23. package/dist/holmes/hooks/stop.js +39 -2
  24. package/dist/holmes/mcp/handlers.d.ts +41 -0
  25. package/dist/holmes/mcp/handlers.js +173 -3
  26. package/dist/holmes/mcp/tool-schemas.js +12 -0
  27. package/dist/holmes/project/dependencies.d.ts +15 -0
  28. package/dist/holmes/project/dependencies.js +58 -0
  29. package/dist/holmes/project/json-state.d.ts +24 -0
  30. package/dist/holmes/project/json-state.js +30 -0
  31. package/dist/holmes/reverse/scan.js +8 -1
  32. package/dist/holmes/review/scope.d.ts +29 -0
  33. package/dist/holmes/review/scope.js +44 -0
  34. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  35. package/dist/holmes/rtm/test-scope.js +92 -2
  36. package/dist/holmes/server/dashboard.d.ts +77 -0
  37. package/dist/holmes/server/dashboard.js +703 -183
  38. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  39. package/dist/holmes/spec/approval-blockers.js +49 -6
  40. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  41. package/dist/holmes/spec/legacy-format.js +15 -1
  42. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  43. package/dist/holmes/spec/nonfunctional.js +119 -0
  44. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  45. package/dist/holmes/spec/spec-parser.js +46 -2
  46. package/dist/holmes/spec/spec-types.d.ts +4 -1
  47. package/dist/holmes/spec/spec-types.js +13 -1
  48. package/dist/holmes/testing/effects.d.ts +54 -0
  49. package/dist/holmes/testing/effects.js +107 -0
  50. package/package.json +3 -2
  51. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -1,3 +1,4 @@
1
+ import * as path from 'node:path';
1
2
  /**
2
3
  * Tools known to only READ. Everything else that carries a path is treated as a write.
3
4
  *
@@ -8,6 +9,34 @@
8
9
  * problem to the fourth tool; inverting it means an unrecognised tool fails CLOSED.
9
10
  */
10
11
  export declare const READ_ONLY_TOOLS: readonly ["read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch", "todowrite", "task", "bashoutput", "killshell"];
12
+ /**
13
+ * What KIND of absolute path this is, read from the syntax alone.
14
+ *
15
+ * @implements A-SPEC-220.1
16
+ * The gate used to ask `startsWith('/')` to mean "is absolute". That question is wrong on Windows:
17
+ * `C:/proj/src/app.ts` is absolute and answers false, so the whole identity block — symlink
18
+ * resolution, case folding, project containment — was skipped for every native Windows path. The
19
+ * block's own comment names NTFS as a case-insensitive filesystem while NTFS paths could not reach
20
+ * it.
21
+ *
22
+ * Deliberately does NOT read `process.platform`. Platform is a property of where this process runs;
23
+ * the question here is what a STRING is, and keeping it that way is what lets a POSIX CI verify the
24
+ * Windows verdicts. A classifier that asked the platform could only be tested on the platform.
25
+ *
26
+ * `unc` is reported separately rather than folded into `drive` because a network path cannot be
27
+ * resolved to a local filesystem identity at all — the caller must refuse it, not gate it.
28
+ */
29
+ export type AbsoluteKind = 'posix' | 'drive' | 'unc';
30
+ export declare function absoluteKindOf(p: string): AbsoluteKind | null;
31
+ /**
32
+ * Whether this path's syntax alone guarantees a case-insensitive filesystem.
33
+ *
34
+ * @implements A-SPEC-220.1
35
+ * A fallback, never the first answer: the caller probes the real filesystem (dev/ino identity across
36
+ * a case-flipped root) and only consults this when the probe cannot answer. A drive letter means
37
+ * NTFS or FAT, and both fold case — that is a fact the syntax carries, not a guess about the host.
38
+ */
39
+ export declare function foldsCaseBySyntax(kind: AbsoluteKind | null): boolean;
11
40
  /**
12
41
  * Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
13
42
  * gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
@@ -29,6 +58,19 @@ export declare function writesFiles(toolName: string | undefined, input: unknown
29
58
  * remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
30
59
  * which is precisely when the gate has to answer.
31
60
  */
61
+ /**
62
+ * Which path grammar to reason in, chosen from the STRINGS rather than from where this process runs.
63
+ *
64
+ * @implements A-SPEC-220.2
65
+ * `node:path` binds its flavor to the host: on POSIX, `path.resolve('C:/proj', 'C:/proj/.ax/…')`
66
+ * treats a drive path as relative and resolves it under the cwd, so every containment answer about
67
+ * a Windows path is about the wrong location. That is invisible on Windows (where the ambient flavor
68
+ * is already win32) and it is precisely why the Windows verdicts were unverifiable from CI — the
69
+ * gap REQ-220 exists to close.
70
+ *
71
+ * A relative `raw` carries no grammar of its own, so the root decides.
72
+ */
73
+ export declare function pathFlavorFor(root: string, raw: string): path.PlatformPath;
32
74
  export declare function resolveTarget(root: string, raw: string, depth?: number): string;
33
75
  /**
34
76
  * Whether a write lands inside a governance directory.
@@ -34,7 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.PATH_FIELDS = exports.READ_ONLY_TOOLS = void 0;
37
+ exports.absoluteKindOf = absoluteKindOf;
38
+ exports.foldsCaseBySyntax = foldsCaseBySyntax;
37
39
  exports.writesFiles = writesFiles;
40
+ exports.pathFlavorFor = pathFlavorFor;
38
41
  exports.resolveTarget = resolveTarget;
39
42
  exports.isProtectedTarget = isProtectedTarget;
40
43
  exports.specTargetOf = specTargetOf;
@@ -56,6 +59,30 @@ exports.READ_ONLY_TOOLS = [
56
59
  'read', 'grep', 'glob', 'ls', 'notebookread',
57
60
  'webfetch', 'websearch', 'todowrite', 'task', 'bashoutput', 'killshell',
58
61
  ];
62
+ function absoluteKindOf(p) {
63
+ if (typeof p !== 'string' || p.length === 0)
64
+ return null;
65
+ if (/^[/\\]{2}/.test(p))
66
+ return 'unc';
67
+ if (p.startsWith('/'))
68
+ return 'posix';
69
+ // A single letter before the colon — `http:` and `file:` are schemes, not drives. The separator
70
+ // is required: `C:src/app.ts` is drive-RELATIVE and therefore not an absolute path.
71
+ if (/^[A-Za-z]:[/\\]/.test(p))
72
+ return 'drive';
73
+ return null;
74
+ }
75
+ /**
76
+ * Whether this path's syntax alone guarantees a case-insensitive filesystem.
77
+ *
78
+ * @implements A-SPEC-220.1
79
+ * A fallback, never the first answer: the caller probes the real filesystem (dev/ino identity across
80
+ * a case-flipped root) and only consults this when the probe cannot answer. A drive letter means
81
+ * NTFS or FAT, and both fold case — that is a fact the syntax carries, not a guess about the host.
82
+ */
83
+ function foldsCaseBySyntax(kind) {
84
+ return kind === 'drive';
85
+ }
59
86
  /**
60
87
  * Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
61
88
  * gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
@@ -91,8 +118,27 @@ function writesFiles(toolName, input) {
91
118
  * remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
92
119
  * which is precisely when the gate has to answer.
93
120
  */
121
+ /**
122
+ * Which path grammar to reason in, chosen from the STRINGS rather than from where this process runs.
123
+ *
124
+ * @implements A-SPEC-220.2
125
+ * `node:path` binds its flavor to the host: on POSIX, `path.resolve('C:/proj', 'C:/proj/.ax/…')`
126
+ * treats a drive path as relative and resolves it under the cwd, so every containment answer about
127
+ * a Windows path is about the wrong location. That is invisible on Windows (where the ambient flavor
128
+ * is already win32) and it is precisely why the Windows verdicts were unverifiable from CI — the
129
+ * gap REQ-220 exists to close.
130
+ *
131
+ * A relative `raw` carries no grammar of its own, so the root decides.
132
+ */
133
+ function pathFlavorFor(root, raw) {
134
+ const kind = absoluteKindOf(raw) ?? absoluteKindOf(root);
135
+ return kind === 'drive' || kind === 'unc' ? path.win32 : path.posix;
136
+ }
137
+ /** Compare paths in one spelling — win32 hands back backslashes that must not defeat a prefix test. */
138
+ const foldSep = (p) => p.replace(/\\/g, '/');
94
139
  function resolveTarget(root, raw, depth = 0) {
95
- const abs = path.resolve(root, raw);
140
+ const pp = pathFlavorFor(root, raw);
141
+ const abs = pp.resolve(root, raw);
96
142
  // A DANGLING symlink still names where a write would land, and `realpath` fails on it — so the
97
143
  // link is followed explicitly. Without this, `ln -s .ax/ledger/not-yet-there innocent` then
98
144
  // writing `innocent` creates a file inside the protected directory while the gate sees a name
@@ -100,7 +146,7 @@ function resolveTarget(root, raw, depth = 0) {
100
146
  if (depth < 8) {
101
147
  try {
102
148
  if (fs.lstatSync(abs).isSymbolicLink()) {
103
- return resolveTarget(path.dirname(abs), fs.readlinkSync(abs), depth + 1);
149
+ return resolveTarget(pp.dirname(abs), fs.readlinkSync(abs), depth + 1);
104
150
  }
105
151
  }
106
152
  catch { /* not a symlink, or unreadable — fall through to ancestor resolution */ }
@@ -109,13 +155,13 @@ function resolveTarget(root, raw, depth = 0) {
109
155
  let cur = abs;
110
156
  for (;;) {
111
157
  try {
112
- return path.join(fs.realpathSync(cur), ...parts.reverse());
158
+ return pp.join(fs.realpathSync(cur), ...parts.reverse());
113
159
  }
114
160
  catch {
115
- const parent = path.dirname(cur);
161
+ const parent = pp.dirname(cur);
116
162
  if (parent === cur)
117
163
  return abs; // reached the filesystem root without an existing ancestor
118
- parts.push(path.basename(cur));
164
+ parts.push(pp.basename(cur));
119
165
  cur = parent;
120
166
  }
121
167
  }
@@ -165,13 +211,14 @@ function specTargetOf(root, specsDir, raw) {
165
211
  const resolved = resolveTarget(root, raw);
166
212
  if (!/\.md$/i.test(resolved))
167
213
  return null;
168
- const t = resolved.toLowerCase();
214
+ // @implements A-SPEC-220.2 — one spelling for every comparison below.
215
+ const t = foldSep(resolved).toLowerCase();
169
216
  // The WIRED store and the default layout both count. A deployment can move its specs, but
170
217
  // `<root>/.ax/specs` stays a spec store by the project's own convention — judging only the wired
171
218
  // one would hand back exactly the bypass this section closes for anyone who writes to the other.
172
219
  for (const dir of [specsDir, path.join('.ax', 'specs')]) {
173
- const b = resolveTarget(root, dir).toLowerCase();
174
- if (t.startsWith(b + path.sep))
220
+ const b = foldSep(resolveTarget(root, dir)).toLowerCase();
221
+ if (t.startsWith(b + '/'))
175
222
  return resolved;
176
223
  }
177
224
  return null;
@@ -188,13 +235,15 @@ function specTargetOf(root, specsDir, raw) {
188
235
  * through a dangling link creates exactly the file it names.
189
236
  */
190
237
  function protectedFileKindOf(root, raw) {
238
+ // @implements A-SPEC-220.2
239
+ const pp = pathFlavorFor(root, raw);
191
240
  const resolved = resolveTarget(root, raw);
192
241
  const base = resolveTarget(root, '.');
193
- const foldedTarget = resolved.toLowerCase();
194
- const foldedBase = base.toLowerCase();
195
- if (foldedTarget !== foldedBase && !foldedTarget.startsWith(foldedBase + path.sep))
242
+ const foldedTarget = foldSep(resolved).toLowerCase();
243
+ const foldedBase = foldSep(base).toLowerCase();
244
+ if (foldedTarget !== foldedBase && !foldedTarget.startsWith(foldedBase + '/'))
196
245
  return null;
197
- const name = path.basename(resolved).toLowerCase();
246
+ const name = pp.basename(resolved).toLowerCase();
198
247
  if (name === '.mcp.json')
199
248
  return '.mcp.json';
200
249
  // @implements A-SPEC-193 §8 (round 13) — 하네스가 늘면 **집행 지점도 는다**. Antigravity 는
@@ -202,7 +251,7 @@ function protectedFileKindOf(root, raw) {
202
251
  // 읽는다. 그 둘은 `.claude/settings*` 및 `.mcp.json` 과 정확히 같은 성질의 파일이다 — 세션이
203
252
  // 고칠 수 있으면 세션이 제 게이트를 끌 수 있다(실측: 배선 직후 두 파일 모두 allow 였다).
204
253
  // `.agents` 전체를 잠그지는 않는다: 규칙과 스킬은 평범한 저작물이고, 잠그면 진짜 작업이 막힌다.
205
- const parent = path.basename(path.dirname(resolved)).toLowerCase();
254
+ const parent = pp.basename(pp.dirname(resolved)).toLowerCase();
206
255
  if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
207
256
  return `.agents/${name}`;
208
257
  // Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
@@ -211,13 +260,15 @@ function protectedFileKindOf(root, raw) {
211
260
  return null;
212
261
  }
213
262
  function protectedKindOf(root, raw) {
214
- const target = resolveTarget(root, raw).toLowerCase();
215
- const base = resolveTarget(root, '.').toLowerCase();
216
- if (target !== base && !target.startsWith(base + path.sep))
263
+ // @implements A-SPEC-220.2
264
+ const pp = pathFlavorFor(root, raw);
265
+ const target = foldSep(resolveTarget(root, raw)).toLowerCase();
266
+ const base = foldSep(resolveTarget(root, '.')).toLowerCase();
267
+ if (target !== base && !target.startsWith(base + '/'))
217
268
  return null; // outside the project
218
269
  for (const d of PROTECTED_DIRS) {
219
- const dir = path.join(base, d).toLowerCase();
220
- if (target === dir || target.startsWith(dir + path.sep))
270
+ const dir = foldSep(pp.join(base, d)).toLowerCase();
271
+ if (target === dir || target.startsWith(dir + '/'))
221
272
  return d;
222
273
  }
223
274
  return null;
@@ -58,6 +58,10 @@ const tspec_state_1 = require("../guardrail/tspec-state");
58
58
  const identity_1 = require("../governance/identity");
59
59
  const role_policy_1 = require("../governance/role-policy");
60
60
  const risk_classifier_1 = require("../guardrail/risk-classifier");
61
+ const proposed_content_1 = require("../cpg/proposed-content");
62
+ const forbidden_edges_1 = require("../cpg/forbidden-edges");
63
+ const required_calls_1 = require("../cpg/required-calls");
64
+ const language_parser_1 = require("../cpg/language-parser");
61
65
  const write_target_1 = require("../guardrail/write-target");
62
66
  const anchors_1 = require("../guardrail/anchors");
63
67
  const governance_history_1 = require("../guardrail/governance-history");
@@ -224,7 +228,15 @@ function normalizeHookInput(raw) {
224
228
  || ['file_path', 'notebook_path'].some((f) => f in ti && typeof ti[f] !== 'string');
225
229
  const out = {};
226
230
  // Assigned individually so an undefined never becomes a present-but-undefined key downstream.
227
- const fp = str(ti.file_path ?? ti.TargetFile);
231
+ // @implements A-SPEC-232 surrounding whitespace is trimmed HERE, at the input boundary, not
232
+ // in each gate. Measured 2026-08-22: `src/app.ts ` walked past No-Spec-No-Code entirely, because
233
+ // action classification matches the extension at the end of the string and an unrecognised action
234
+ // passes — the same inversion `write-target.ts` applied to tool names, never applied to path
235
+ // spelling. Inside a name it is left alone: `my file.ts` is a real filename, and merging distinct
236
+ // files would be worse than the defect. A path of only whitespace is no path, so the field is
237
+ // omitted rather than left present-but-empty (the direction REQ-144 set).
238
+ const fpRaw = str(ti.file_path ?? ti.TargetFile);
239
+ const fp = fpRaw === undefined ? undefined : (fpRaw.trim() || undefined);
228
240
  if (fp !== undefined)
229
241
  out.file_path = fp;
230
242
  const c = str(ti.content ?? ti.CodeContent);
@@ -238,7 +250,8 @@ function normalizeHookInput(raw) {
238
250
  out.command = cmd;
239
251
  // @implements A-SPEC-163 — a path field the gate does not preserve is a path the gate cannot see,
240
252
  // which is how a NotebookEdit reached protected files unexamined.
241
- const nb = str(ti.notebook_path);
253
+ const nbRaw = str(ti.notebook_path);
254
+ const nb = nbRaw === undefined ? undefined : (nbRaw.trim() || undefined);
242
255
  if (nb !== undefined)
243
256
  out.notebook_path = nb;
244
257
  return { tool_name: str(r.tool_name) ?? '', tool_input: out, ...(malformed ? { malformed: true } : {}) };
@@ -612,7 +625,23 @@ function evaluateHook(input, specsDir, opts) {
612
625
  // .mcp.json` → allow, and likewise for `.ax/roles`, `.ax/ledger` and No-Spec-No-Code on `src/app.ts`
613
626
  // — the whole gate off for one character of case. Identity now decides, and the target is rewritten
614
627
  // into the PROJECT'S OWN SPELLING so every check downstream reasons about one string.
615
- if (norm.startsWith('/')) {
628
+ // @implements A-SPEC-220.1 — the entry condition used to be `norm.startsWith('/')`, which asks
629
+ // "is this POSIX-absolute" while meaning "is this absolute". `C:/proj/src/app.ts` answers false,
630
+ // so identity resolution, case folding and containment were all skipped on native Windows — the
631
+ // very filesystem (NTFS) the case-folding comment below names.
632
+ const absKind = (0, write_target_1.absoluteKindOf)(norm);
633
+ if (absKind === 'unc') {
634
+ // A network path has no local filesystem identity to resolve, so containment cannot be decided.
635
+ // A-SPEC-191 §17/§29 both began as "the gate did not refuse to govern — it concluded there was
636
+ // nothing to govern"; an undecidable target is refused, not allowed.
637
+ return {
638
+ permissionDecision: 'deny',
639
+ permissionDecisionReason: '[Holmes-Kit] 네트워크 경로(UNC)는 로컬 파일시스템 정체로 해석할 수 없어'
640
+ + ' 프로젝트 안팎을 판정할 수 없습니다 — 판정할 수 없는 대상은 허용하지 않습니다.'
641
+ + ' 프로젝트를 로컬 드라이브에 두거나 드라이브 문자로 매핑한 경로를 사용하십시오.',
642
+ };
643
+ }
644
+ if (absKind === 'posix' || absKind === 'drive') {
616
645
  const inside = (t, r) => t === r || t.startsWith(r + '/');
617
646
  const asProject = (abs, base) => (abs.length <= base.length ? projectRoot : `${projectRoot}/${abs.slice(base.length + 1)}`);
618
647
  const realTarget = realOf(norm).replace(/\/+$/, '');
@@ -622,8 +651,11 @@ function evaluateHook(input, specsDir, opts) {
622
651
  // files folded together, and a case-insensitive volume on Linux must not keep the bypass.
623
652
  const caseBlind = (() => {
624
653
  const flipped = projectRoot.replace(/[a-zA-Z]/, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
654
+ // @implements A-SPEC-220.1 — the probe cannot answer when the root has no letter to flip.
655
+ // A drive letter means NTFS or FAT, both case-folding; that is carried by the syntax, so it is
656
+ // a fact here rather than a guess about the host.
625
657
  if (flipped === projectRoot)
626
- return process.platform === 'darwin' || process.platform === 'win32';
658
+ return (0, write_target_1.foldsCaseBySyntax)(absKind) || process.platform === 'darwin' || process.platform === 'win32';
627
659
  if (flipped === projectRoot)
628
660
  return false;
629
661
  try {
@@ -632,7 +664,13 @@ function evaluateHook(input, specsDir, opts) {
632
664
  return a.dev === b.dev && a.ino === b.ino;
633
665
  }
634
666
  catch {
635
- return false;
667
+ // @implements A-SPEC-220.1 — the probe THREW, which means it has no answer, not that the
668
+ // filesystem is case-sensitive. Conflating the two is why a drive-letter path kept failing
669
+ // both containment tests: neither spelling exists to stat from a POSIX CI, and a Windows
670
+ // project reached over a path this process cannot resolve is the same situation. Only the
671
+ // syntax fact is used here — a drive letter is NTFS or FAT, both case-folding — so POSIX
672
+ // inputs, for which this is always false, keep their previous verdict exactly.
673
+ return (0, write_target_1.foldsCaseBySyntax)(absKind);
636
674
  }
637
675
  })();
638
676
  const fold = (s) => (caseBlind ? s.toLowerCase() : s);
@@ -762,6 +800,53 @@ function evaluateHook(input, specsDir, opts) {
762
800
  return { permissionDecision: 'allow' };
763
801
  // Read specs synchronously via a fresh walk (hook must be sync)
764
802
  const specs = readSpecsSync(specsDir);
803
+ // @implements A-SPEC-230 — structural constraints move from "checked by the suite" to "enforced at
804
+ // the write". One parse of the proposed file, not a scan: both engines already judge file by file,
805
+ // and measured 2026-08-22 that is 2ms (18ms for the 109KB outlier) against 3.3s + 1.5s for the
806
+ // two full scans.
807
+ //
808
+ // ONLY APPROVED C-SPECs supply rules. If a draft could enforce, anyone could block anyone else's
809
+ // writes by dropping one in — enforcement without approval.
810
+ {
811
+ const proposed = typeof input.tool_input.content === 'string'
812
+ ? { content: input.tool_input.content, mode: 'full' }
813
+ : typeof input.tool_input.new_string === 'string'
814
+ ? { content: input.tool_input.new_string, mode: 'fragment' }
815
+ : null;
816
+ if (proposed && (0, write_target_1.specTargetOf)(opts.projectRoot, specsDir, p) === null) {
817
+ const forbidden = [];
818
+ const required = [];
819
+ for (const sp of specs) {
820
+ if (sp.type !== 'C-SPEC' || sp.status !== 'approved')
821
+ continue;
822
+ forbidden.push(...(0, forbidden_edges_1.parseForbiddenEdges)(sp.sections?.['Forbidden Edges'] ?? '').rules);
823
+ required.push(...(0, required_calls_1.parseRequiredCalls)(sp.sections?.['Layer Rules'] ?? '').rules);
824
+ }
825
+ if (forbidden.length > 0 || required.length > 0) {
826
+ const { violations } = (0, proposed_content_1.checkProposedContent)({
827
+ forbidden,
828
+ required,
829
+ // @implements A-SPEC-231 — the path is normalised before the prefix test. Adversarial
830
+ // pass 3, 2026-08-22: `./src/a/x.ts` and `src//a/x.ts` both walked past a rule scoped to
831
+ // `src/a/`. The same evasion class was already paid for on the command side, where
832
+ // `normalizeCommandPaths` exists because those spellings "slipped every protected-path
833
+ // regex". A rule must not depend on how the caller spelled the path.
834
+ sourcePath: path.posix.normalize(relPath.trim()).replace(/^\.\//, ''),
835
+ content: proposed.content,
836
+ mode: proposed.mode,
837
+ parser: new language_parser_1.TreeSitterTsParser(),
838
+ });
839
+ if (violations.length > 0) {
840
+ return {
841
+ permissionDecision: 'deny',
842
+ permissionDecisionReason: '[Holmes-Kit] 구조 제약 위반 — 승인된 C-SPEC 이 금지한 형태입니다:\n'
843
+ + violations.map((v) => ' - ' + v).join('\n')
844
+ + '\n' + proposed_content_1.CSPEC_GATE_LIMITS,
845
+ };
846
+ }
847
+ }
848
+ }
849
+ }
765
850
  // @implements A-SPEC-166
766
851
  // What this change CLAIMS to implement, judged independently of what the file already carried.
767
852
  // Measured 2026-08-08: the same unapproved `@implements` was denied in a new file and allowed in
@@ -83,6 +83,23 @@ export declare function decideStopGuard(wantsBlock: boolean, priorConsecutiveBlo
83
83
  };
84
84
  /** Test seam for the same reason as the two exports below — the CLI sets this on a real run. */
85
85
  export declare function __setWiredSpecsForTest(v: string | undefined): void;
86
+ /**
87
+ * `readGuardCount`, degraded to 0 if it throws.
88
+ *
89
+ * @implements A-SPEC-237
90
+ * The shape check in `readGuardState` removes the corruption we KNOW about. This removes the path
91
+ * an unknown one would take. In the same block, three other risky reads — provenance, findings and
92
+ * debt — already sit inside a try/catch; `readGuardCount` alone did not, and that asymmetry was the
93
+ * route this defect travelled to the outer fail-open handler.
94
+ *
95
+ * Zero means "the counter is unknown", never "there is nothing to block". The gate still evaluates.
96
+ *
97
+ * REDUNDANT WITH THE SHAPE CHECK, ON PURPOSE — and the mutation results say so plainly. Removing
98
+ * either layer alone leaves every case green, because each fully covers the corruption we know
99
+ * about; removing BOTH fails two. That is what defence in depth looks like from a mutation run, and
100
+ * it is recorded here rather than left as an unexplained survivor for the next reader to rediscover.
101
+ */
102
+ export declare function guardCountOrZero(sessionId: string, read?: (id: string) => number): number;
86
103
  export declare function readGuardCount(sessionId: string): number;
87
104
  /**
88
105
  * Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
@@ -39,9 +39,11 @@ exports.evaluateStop = evaluateStop;
39
39
  exports.stopDebtAction = stopDebtAction;
40
40
  exports.decideStopGuard = decideStopGuard;
41
41
  exports.__setWiredSpecsForTest = __setWiredSpecsForTest;
42
+ exports.guardCountOrZero = guardCountOrZero;
42
43
  exports.readGuardCount = readGuardCount;
43
44
  exports.writeGuardCount = writeGuardCount;
44
45
  const fs = __importStar(require("node:fs"));
46
+ const json_state_1 = require("../project/json-state");
45
47
  const node_child_process_1 = require("node:child_process");
46
48
  const path = __importStar(require("node:path"));
47
49
  const test_scope_1 = require("../rtm/test-scope");
@@ -222,12 +224,47 @@ const GUARD_STATE = () => guardStatePath() ?? path.join(process.cwd(), '.ax', 'l
222
224
  // every session into a single project-level file. One cell per session, oldest evicted.
223
225
  const GUARD_SESSIONS_MAX = 32;
224
226
  function readGuardState() {
227
+ let parsed;
225
228
  try {
226
- return JSON.parse(fs.readFileSync(GUARD_STATE(), 'utf8'));
229
+ parsed = JSON.parse(fs.readFileSync(GUARD_STATE(), 'utf8'));
227
230
  }
228
231
  catch {
229
232
  return {};
230
233
  }
234
+ // @implements A-SPEC-237
235
+ // The catch above sees only a parse FAILURE. `null`, `42`, `"x"`, `true` and `[1,2]` parse
236
+ // fine, and the failure arrives later at `s.sessions?.[…]` as a TypeError. Measured 2026-08-23,
237
+ // end to end: a project that answered `{"decision":"block"}` with eight article violations
238
+ // produced NO OUTPUT and exit 0 once this file contained the four characters `null`. The file is
239
+ // gitignored and written by the hook itself, so one echo disabled governance leaving no trace.
240
+ if (!(0, json_state_1.isJsonStateObject)(parsed))
241
+ return {};
242
+ return parsed;
243
+ }
244
+ /**
245
+ * `readGuardCount`, degraded to 0 if it throws.
246
+ *
247
+ * @implements A-SPEC-237
248
+ * The shape check in `readGuardState` removes the corruption we KNOW about. This removes the path
249
+ * an unknown one would take. In the same block, three other risky reads — provenance, findings and
250
+ * debt — already sit inside a try/catch; `readGuardCount` alone did not, and that asymmetry was the
251
+ * route this defect travelled to the outer fail-open handler.
252
+ *
253
+ * Zero means "the counter is unknown", never "there is nothing to block". The gate still evaluates.
254
+ *
255
+ * REDUNDANT WITH THE SHAPE CHECK, ON PURPOSE — and the mutation results say so plainly. Removing
256
+ * either layer alone leaves every case green, because each fully covers the corruption we know
257
+ * about; removing BOTH fails two. That is what defence in depth looks like from a mutation run, and
258
+ * it is recorded here rather than left as an unexplained survivor for the next reader to rediscover.
259
+ */
260
+ function guardCountOrZero(sessionId, read = readGuardCount) {
261
+ try {
262
+ const n = read(sessionId);
263
+ return Number.isInteger(n) && n >= 0 ? n : 0;
264
+ }
265
+ catch {
266
+ return 0;
267
+ }
231
268
  }
232
269
  function readGuardCount(sessionId) {
233
270
  const s = readGuardState();
@@ -377,7 +414,7 @@ if (require.main === module) {
377
414
  findingsUnreadable = true;
378
415
  } // @implements A-SPEC-191 (§4a) — list() absorbs ENOENT; a throw means the ledger EXISTS and cannot be read, which must block, not launder
379
416
  const out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
380
- const guard = decideStopGuard(out.block, readGuardCount(sessionId));
417
+ const guard = decideStopGuard(out.block, guardCountOrZero(sessionId));
381
418
  const persisted = writeGuardCount(sessionId, guard.nextCount);
382
419
  // @implements A-SPEC-134 — the cap-yield is no longer silent: a clean turn clears any debt, a
383
420
  // give-up records the unresolved articles so WRITE_CODE is blocked until the constitution is
@@ -104,6 +104,46 @@ declare function makeRawHandlers(store: SpecStore): {
104
104
  remaining: import("../spec/validator").Finding[];
105
105
  reason?: undefined;
106
106
  }>;
107
+ /**
108
+ * Move a document to `outdated` — the only path there.
109
+ *
110
+ * @implements A-SPEC-222
111
+ * `outdated` was in SPEC_STATUSES with no code writing it and no document carrying it: a
112
+ * declared state nothing could reach. That mattered the moment 23 deprecated REQs needed
113
+ * cleaning up, because the only transition tool sends everything to `draft`, and calling a
114
+ * retired document "draft" is a worse lie than the non-canonical word it replaced.
115
+ *
116
+ * Retirement withdraws authority rather than granting it, so it does NOT need the approval key
117
+ * by default. Two cases invert that, and the threshold follows the RISK rather than the name of
118
+ * the act:
119
+ *
120
+ * - A sealed document. The code gate demands an approved T-SPEC naming the target A-SPEC;
121
+ * retiring that T-SPEC removes the demand. Unguarded, retirement is an approval bypass.
122
+ * - A document an APPROVED spec depends on. That chain is holding something up right now.
123
+ */
124
+ spec_retire(a: {
125
+ root?: string;
126
+ id: string;
127
+ reason?: string;
128
+ }): Promise<{
129
+ ok: boolean;
130
+ reason: string;
131
+ retired?: undefined;
132
+ id?: undefined;
133
+ dependents?: undefined;
134
+ } | {
135
+ ok: boolean;
136
+ retired: boolean;
137
+ id: string;
138
+ dependents: never[];
139
+ reason: string;
140
+ } | {
141
+ ok: boolean;
142
+ retired: boolean;
143
+ id: string;
144
+ dependents: string[];
145
+ reason?: undefined;
146
+ }>;
107
147
  spec_approve(a: {
108
148
  root?: string;
109
149
  id: string;
@@ -304,6 +344,7 @@ declare function makeRawHandlers(store: SpecStore): {
304
344
  }>;
305
345
  unrequestedSymbols: string[];
306
346
  coverageGaps: string[];
347
+ obligationGaps: string[];
307
348
  }>;
308
349
  review_prepare(a: {
309
350
  root: string;