@holmes-lab/holmes-kit 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -17
  13. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  14. package/dist/holmes/cli/codex-toml.js +282 -0
  15. package/dist/holmes/cli/doctor.d.ts +56 -0
  16. package/dist/holmes/cli/doctor.js +348 -18
  17. package/dist/holmes/cli/index.js +16 -2
  18. package/dist/holmes/cli/init.js +78 -0
  19. package/dist/holmes/cli/interactive-prompt.js +4 -4
  20. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  21. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  22. package/dist/holmes/cli/semantic-key.js +93 -0
  23. package/dist/holmes/config/config.d.ts +9 -0
  24. package/dist/holmes/config/config.js +8 -1
  25. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  26. package/dist/holmes/cpg/language-capability.js +145 -0
  27. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  28. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  29. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  30. package/dist/holmes/governance/ledger-rechain.js +17 -2
  31. package/dist/holmes/governance/provenance-ledger.js +21 -0
  32. package/dist/holmes/guardrail/blind-spots.js +12 -1
  33. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  34. package/dist/holmes/guardrail/impact-gate.js +263 -0
  35. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  36. package/dist/holmes/guardrail/write-target.js +48 -4
  37. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  38. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  39. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  40. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  41. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  42. package/dist/holmes/hooks/stop.d.ts +65 -0
  43. package/dist/holmes/hooks/stop.js +200 -2
  44. package/dist/holmes/mcp/handlers.d.ts +88 -0
  45. package/dist/holmes/mcp/handlers.js +597 -16
  46. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  47. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  48. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  49. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  50. package/dist/holmes/mcp/tool-schemas.js +71 -0
  51. package/dist/holmes/project/root.js +3 -1
  52. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  53. package/dist/holmes/review/baseline-arm.js +51 -0
  54. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  55. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  56. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  57. package/dist/holmes/review/coherence-verify.js +144 -0
  58. package/dist/holmes/review/commit-text.d.ts +50 -0
  59. package/dist/holmes/review/commit-text.js +76 -0
  60. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  61. package/dist/holmes/review/confidence-calibration.js +39 -0
  62. package/dist/holmes/review/content-baseline.d.ts +38 -0
  63. package/dist/holmes/review/content-baseline.js +103 -0
  64. package/dist/holmes/review/content-verify.d.ts +20 -0
  65. package/dist/holmes/review/content-verify.js +73 -0
  66. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  67. package/dist/holmes/review/dense-retrieval.js +97 -0
  68. package/dist/holmes/review/edge-quality.d.ts +44 -0
  69. package/dist/holmes/review/edge-quality.js +117 -0
  70. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  71. package/dist/holmes/review/evaluation-metrics.js +175 -0
  72. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  73. package/dist/holmes/review/graph-verifier.js +62 -0
  74. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  75. package/dist/holmes/review/hop-ablation.js +89 -0
  76. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  77. package/dist/holmes/review/manual-baseline.js +2846 -0
  78. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  79. package/dist/holmes/review/oracle-gap.js +102 -0
  80. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  81. package/dist/holmes/review/point-in-time-replay.js +161 -0
  82. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  83. package/dist/holmes/review/rank-diagnosis.js +163 -0
  84. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  85. package/dist/holmes/review/replay-calibration.js +83 -0
  86. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  87. package/dist/holmes/review/replay-corpus.js +210 -0
  88. package/dist/holmes/review/run-replay.d.ts +260 -0
  89. package/dist/holmes/review/run-replay.js +729 -0
  90. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  91. package/dist/holmes/review/semantic-arm.js +717 -0
  92. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  93. package/dist/holmes/review/semantic-retrieval.js +156 -0
  94. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  95. package/dist/holmes/review/spec-layer-stats.js +52 -0
  96. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  97. package/dist/holmes/review/temporal-prior.js +53 -0
  98. package/dist/holmes/review/test-runner.d.ts +15 -0
  99. package/dist/holmes/review/test-runner.js +41 -4
  100. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  101. package/dist/holmes/review/test-selection-breadth.js +57 -0
  102. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  103. package/dist/holmes/review/traceability-benchmark.js +135 -0
  104. package/dist/holmes/review/union-verify.d.ts +12 -0
  105. package/dist/holmes/review/union-verify.js +70 -0
  106. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  107. package/dist/holmes/rtm/graph-store.js +122 -0
  108. package/dist/holmes/rtm/incremental.d.ts +25 -1
  109. package/dist/holmes/rtm/incremental.js +18 -1
  110. package/dist/holmes/rtm/localize.d.ts +28 -0
  111. package/dist/holmes/rtm/localize.js +272 -14
  112. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  113. package/dist/holmes/rtm/rtm-builder.js +265 -28
  114. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  115. package/dist/holmes/rtm/rtm-graph.js +194 -31
  116. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  117. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  118. package/dist/holmes/rtm/test-scope.js +24 -1
  119. package/dist/holmes/semantic/credentials.d.ts +15 -0
  120. package/dist/holmes/semantic/credentials.js +134 -0
  121. package/dist/holmes/semantic/embedder.d.ts +44 -0
  122. package/dist/holmes/semantic/embedder.js +185 -0
  123. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  124. package/dist/holmes/semantic/hit-rerank.js +38 -0
  125. package/dist/holmes/semantic/tier.d.ts +37 -0
  126. package/dist/holmes/semantic/tier.js +54 -0
  127. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  128. package/dist/holmes/semantic/vector-cache.js +91 -0
  129. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  130. package/dist/holmes/spec/acceptance-quality.js +169 -0
  131. package/dist/holmes/spec/validator.js +33 -1
  132. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  133. package/dist/holmes/spec/yaml-scalar.js +43 -0
  134. package/package.json +1 -1
@@ -0,0 +1,263 @@
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.isLowRiskTarget = isLowRiskTarget;
37
+ exports.evaluateEditEvidence = evaluateEditEvidence;
38
+ exports.preEditEvidenceDecision = preEditEvidenceDecision;
39
+ exports.loadEditEvidence = loadEditEvidence;
40
+ // @implements A-SPEC-279
41
+ // @implements A-SPEC-278
42
+ // @implements A-SPEC-277
43
+ // @implements A-SPEC-276
44
+ const fs = __importStar(require("node:fs"));
45
+ const crypto = __importStar(require("node:crypto"));
46
+ const path = __importStar(require("node:path"));
47
+ const spec_types_1 = require("../spec/spec-types");
48
+ const CODE_EXTENSIONS = new Set([
49
+ '.ts', '.mts', '.cts', '.tsx', '.jsx', '.js', '.mjs', '.cjs', '.py', '.cs', '.java', '.go',
50
+ '.rs', '.cpp', '.cc', '.cxx', '.hpp', '.hh', '.h', '.rb', '.php', '.kt', '.kts', '.swift',
51
+ '.scala', '.sh', '.ex', '.exs', '.lua', '.r',
52
+ ]);
53
+ /**
54
+ * Documents are edited constantly and almost never break anything, so demanding a graph analysis for
55
+ * every prose change would train people to route around the gate — the proportional path exists to
56
+ * keep the gate credible where it matters. The exception is documents that GOVERN: an ADR or a
57
+ * C-SPEC is prose that decides architecture, and A-SPEC-233 already treats those as full-regression
58
+ * material. Same locations, read from the type table rather than spelled twice.
59
+ */
60
+ function isLowRiskTarget(target) {
61
+ const normalized = target.replace(/\\/g, '/');
62
+ const ext = path.extname(normalized).toLowerCase();
63
+ if (CODE_EXTENSIONS.has(ext))
64
+ return false;
65
+ const cspecDir = `.ax/specs/${spec_types_1.SPEC_TYPES['C-SPEC'].folder}/`;
66
+ if (normalized.startsWith('.ax/decisions/') || normalized.startsWith(cspecDir))
67
+ return false;
68
+ return true;
69
+ }
70
+ const REMEDIATION = {
71
+ 'no-analysis': 'Run maintenance_analyze({ root, request, persist: true }) for this change and retry with the digest it returns.',
72
+ 'basis-drift': 'HEAD moved after the analysis was taken — re-run maintenance_analyze so the evidence is computed on the current commit.',
73
+ // A separate code because it needs a separate sentence: dogfooding 2026-08-28 showed a file-drift
74
+ // refusal explaining itself as "HEAD moved", which was simply false. Same lesson as A-SPEC-269.
75
+ 'target-drift': 'The file changed in the working tree after the analysis was taken — re-run maintenance_analyze so the evidence describes the content you are about to edit.',
76
+ 'target-out-of-scope': 'The analysis never named this file. Re-run maintenance_analyze with a request that covers it, or widen the analysis, rather than editing outside the evidence.',
77
+ 'contract-untested': 'Add or approve the T-SPEC for the impacted A-SPEC, then re-run the analysis so the contract is covered.',
78
+ 'unproven-absence': 'The graph did not cover everything it would need to in order to claim no impact. Widen the analysis (supply changedFiles, add language coverage, or raise the walk bounds) before editing.',
79
+ 'architecture-unchecked': 'Pass changedFiles (e.g. a base..head diff) to maintenance_analyze so the .ax/decisions and C-SPEC axis is actually assessed.',
80
+ 'security-unchecked': 'The security axis was never examined — re-run maintenance_analyze against a tree it can actually scan so data-flow reachability is walked.',
81
+ 'compatibility-unchecked': 'The compatibility axis was never examined — re-run maintenance_analyze where the spec store is readable so breaking-change declarations can be read.',
82
+ 'basis-unverifiable': 'The analysis recorded no digest for this file, or the file cannot be read now, so drift cannot be ruled out — re-run maintenance_analyze with persist: true to take a verifiable basis.',
83
+ };
84
+ const RANK = { allow: 0, widen: 1, refuse: 2 };
85
+ function evaluateEditEvidence(inputArgs) {
86
+ const target = inputArgs.target.replace(/\\/g, '/');
87
+ const lowRisk = isLowRiskTarget(target);
88
+ const reasons = [];
89
+ let decision = 'allow';
90
+ const escalate = (next) => { if (RANK[next] > RANK[decision])
91
+ decision = next; };
92
+ const artifact = inputArgs.artifact;
93
+ if (!artifact) {
94
+ // The proportional path: ordinary prose needs no graph evidence. A governing document does, and
95
+ // `isLowRiskTarget` has already excluded those.
96
+ if (lowRisk)
97
+ return { decision: 'allow', reasons: [], remediation: '', lowRisk: true };
98
+ reasons.push({ code: 'no-analysis', detail: `No impact analysis backs an edit to ${target}.` });
99
+ return { decision: 'refuse', reasons, remediation: REMEDIATION['no-analysis'], lowRisk };
100
+ }
101
+ // (2) An analysis describes one commit. Once HEAD moves, its claims are about a tree that no
102
+ // longer exists — the one condition that makes every other check meaningless.
103
+ if (artifact.graphBasis.head !== inputArgs.currentHead) {
104
+ reasons.push({
105
+ code: 'basis-drift',
106
+ detail: `The analysis was taken on ${artifact.graphBasis.head} but HEAD is now ${inputArgs.currentHead}.`,
107
+ });
108
+ escalate('refuse');
109
+ }
110
+ // (2b) HEAD standing still is not enough: the working tree can move under an analysis. Checked
111
+ // only for a file the analysis actually predicted — for anything else `target-out-of-scope` is
112
+ // the honest finding and a digest comparison would be noise.
113
+ if (artifact.predicted.files.includes(target)) {
114
+ const recorded = artifact.predicted.fileDigests[target];
115
+ if (!recorded || inputArgs.currentTargetDigest === null || inputArgs.currentTargetDigest === undefined) {
116
+ reasons.push({
117
+ code: 'basis-unverifiable',
118
+ detail: !recorded
119
+ ? `The analysis recorded no digest for ${target}, so drift cannot be ruled out.`
120
+ : `${target} could not be read now, so it cannot be compared against the analysed content.`,
121
+ });
122
+ escalate('widen');
123
+ }
124
+ else if (recorded !== inputArgs.currentTargetDigest) {
125
+ reasons.push({
126
+ code: 'target-drift',
127
+ detail: `${target} changed since the analysis: recorded ${recorded}, now ${inputArgs.currentTargetDigest}.`,
128
+ });
129
+ escalate('refuse');
130
+ }
131
+ }
132
+ // (3) Being AFFECTED is not being analysed. `impactedFiles` is reachable-by-call-edge, a tier
133
+ // A-SPEC-274 measured at zero true positives as an edit prediction, so it grants no permission.
134
+ if (!artifact.predicted.files.includes(target)) {
135
+ const affectedOnly = artifact.uncertainty.impactedFiles.includes(target);
136
+ reasons.push({
137
+ code: 'target-out-of-scope',
138
+ detail: affectedOnly
139
+ ? `${target} appears only as an impacted file, which is not the same as having been analysed as an edit candidate.`
140
+ : `${target} is not among the files this analysis predicted.`,
141
+ });
142
+ escalate('widen');
143
+ }
144
+ // (4) The analysis already writes this down when an impacted A-SPEC has no answering T-SPEC.
145
+ if (artifact.uncertainty.unknowns.some((line) => /No T-SPEC was found/i.test(line))) {
146
+ reasons.push({ code: 'contract-untested', detail: 'An impacted contract has no T-SPEC covering it.' });
147
+ escalate('widen');
148
+ }
149
+ // (6) Likewise for the architecture axis, which cannot be assessed without a changed-file basis.
150
+ if (artifact.uncertainty.unknowns.some((line) => /Architecture-governing documents were not assessed/i.test(line))) {
151
+ reasons.push({
152
+ code: 'architecture-unchecked',
153
+ detail: 'The architecture axis was never assessed, so no architecture constraint has been checked.',
154
+ });
155
+ escalate('widen');
156
+ }
157
+ // (6b) @implements A-SPEC-279 — the remaining two constraint axes. The condition is "was it
158
+ // EXAMINED", not "does a constraint exist": 61 of this repository's 192 A-SPECs declare a non-none
159
+ // breaking change, so widening on mere presence would fire on about a third of all edits and teach
160
+ // people to click past the gate. What the axes FOUND is reported by the analysis; what the gate
161
+ // acts on is an axis that was never looked at.
162
+ if (!artifact.uncertainty.securityAssessed) {
163
+ reasons.push({ code: 'security-unchecked', detail: 'No data-flow reachability was walked for this change.' });
164
+ escalate('widen');
165
+ }
166
+ if (!artifact.uncertainty.compatibilityAssessed) {
167
+ reasons.push({ code: 'compatibility-unchecked', detail: 'No breaking-change declaration was read for this change.' });
168
+ escalate('widen');
169
+ }
170
+ // (5) The asymmetric one. An EMPTY impact set is only trustworthy when the analysis could actually
171
+ // see everything; with a coverage hole or a truncated walk, emptiness is ignorance wearing the
172
+ // costume of a result.
173
+ const claimsNoImpact = artifact.predicted.symbols.length === 0 && artifact.predicted.tests.length === 0;
174
+ const { coverage } = artifact.uncertainty;
175
+ const incomplete = artifact.uncertainty.impactTruncated
176
+ || coverage.skippedFiles.length > 0
177
+ || coverage.unsupportedLanguages.length > 0
178
+ || coverage.scannedFiles === 0;
179
+ if (claimsNoImpact && incomplete) {
180
+ reasons.push({
181
+ code: 'unproven-absence',
182
+ detail: 'The analysis reports no impact, but its coverage was incomplete — absence was not established.',
183
+ });
184
+ escalate('widen');
185
+ }
186
+ // Lead with whichever refusal the caller must fix FIRST; a widen reason never speaks for a refusal.
187
+ const primary = reasons.find((r) => r.code === 'basis-drift')
188
+ ?? reasons.find((r) => r.code === 'target-drift')
189
+ ?? reasons[0];
190
+ return {
191
+ decision,
192
+ reasons,
193
+ remediation: primary ? REMEDIATION[primary.code] : '',
194
+ lowRisk,
195
+ };
196
+ }
197
+ /**
198
+ * @implements A-SPEC-278
199
+ * Turn a verdict into a hook decision, or into silence.
200
+ *
201
+ * Silence — `null` — is deliberate for both `off` and a clean `allow`. Returning `allow` would still
202
+ * be an opinion: if this gate were ever consulted ahead of another, its `allow` could mask that
203
+ * gate's denial. A gate that has nothing to say should say nothing.
204
+ */
205
+ function preEditEvidenceDecision(verdict, mode) {
206
+ if (mode === 'off')
207
+ return null;
208
+ if (verdict.decision === 'allow')
209
+ return null;
210
+ const codes = verdict.reasons.map((r) => r.code).join(', ');
211
+ const detail = verdict.reasons.map((r) => r.detail).join(' ');
212
+ // @implements A-SPEC-333
213
+ // OUTCOME first, verdict second. Dogfooding the raised default (A-SPEC-332) showed this message
214
+ // saying `(refuse)` while the hook returned `allow` — the first words contradicted what happened,
215
+ // so a reader either believes the edit was blocked or stops reading the message, and an ignored
216
+ // warning is worse than none because it looks like coverage.
217
+ //
218
+ // The verdict word is KEPT, as the note below argues it should be: `refuse` and `widen` are
219
+ // different kinds of insufficiency. It moves into a parenthesis rather than leading the sentence.
220
+ const outcome = mode === 'block' ? 'edit denied' : 'warning only';
221
+ const reason = `[Holmes-Kit] pre-edit impact evidence — ${outcome} (verdict: ${verdict.decision}): ${codes}. ${detail} ${verdict.remediation}`.trim();
222
+ // `widen` denies under `block` too: "the evidence is not sufficient yet" is not permission. The
223
+ // separate verdict word survives in the message so the reader knows which kind of insufficiency.
224
+ return mode === 'block'
225
+ ? { permissionDecision: 'deny', permissionDecisionReason: reason }
226
+ : { permissionDecision: 'allow', permissionDecisionReason: reason };
227
+ }
228
+ /**
229
+ * @implements A-SPEC-278
230
+ * The I/O adapter, kept apart from the pure core on purpose: everything above this line is decided
231
+ * by fixtures, and only this function touches a disk. It reads the artifact the session declared
232
+ * and measures the target's current content so the core can compare them.
233
+ *
234
+ * A digest that names no stored artifact yields `artifact: null` — the same shape as "no analysis",
235
+ * because from the gate's side an unfindable analysis and an absent one justify equally little.
236
+ */
237
+ function loadEditEvidence(projectRoot, target, digest, currentHead) {
238
+ const rel = target.replace(/\\/g, '/');
239
+ let artifact = null;
240
+ if (digest) {
241
+ const bare = digest.startsWith('sha256:') ? digest.slice(7) : digest;
242
+ if (/^[a-f0-9]{64}$/.test(bare)) {
243
+ const file = path.join(projectRoot, '.ax', 'evidence', 'maintenance', `${bare}.json`);
244
+ try {
245
+ artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
246
+ }
247
+ catch {
248
+ artifact = null;
249
+ }
250
+ }
251
+ }
252
+ let currentTargetDigest = null;
253
+ try {
254
+ const resolved = path.resolve(projectRoot, rel);
255
+ if (resolved === projectRoot || resolved.startsWith(projectRoot + path.sep)) {
256
+ currentTargetDigest = `sha256:${crypto.createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}`;
257
+ }
258
+ }
259
+ catch {
260
+ currentTargetDigest = null;
261
+ }
262
+ return { target: rel, artifact, currentHead, currentTargetDigest };
263
+ }
@@ -71,7 +71,22 @@ export declare function writesFiles(toolName: string | undefined, input: unknown
71
71
  * A relative `raw` carries no grammar of its own, so the root decides.
72
72
  */
73
73
  export declare function pathFlavorFor(root: string, raw: string): path.PlatformPath;
74
- export declare function resolveTarget(root: string, raw: string, depth?: number): string;
74
+ /**
75
+ * The filesystem operations `resolveTarget` consults, injectable so the CROSS-flavor scenario is
76
+ * testable from either host (A-SPEC-465): production always passes the real `fs`.
77
+ */
78
+ export interface ResolveFs {
79
+ lstatSync(p: string): {
80
+ isSymbolicLink(): boolean;
81
+ };
82
+ readlinkSync(p: string): string;
83
+ realpathSync(p: string): string;
84
+ }
85
+ export interface ResolveOpts {
86
+ host?: path.PlatformPath;
87
+ fs?: ResolveFs;
88
+ }
89
+ export declare function resolveTarget(root: string, raw: string, depth?: number, opts?: ResolveOpts): string;
75
90
  /**
76
91
  * Whether a write lands inside a governance directory.
77
92
  *
@@ -117,3 +132,25 @@ export declare function specTargetOf(root: string, specsDir: string, raw: string
117
132
  */
118
133
  export declare function protectedFileKindOf(root: string, raw: string): string | null;
119
134
  export declare function protectedKindOf(root: string, raw: string): string | null;
135
+ /**
136
+ * @implements A-SPEC-447
137
+ * Does `raw` land inside one of `roots`?
138
+ *
139
+ * The one containment question two gate rules ask — A-SPEC-445 for project code files, A-SPEC-446
140
+ * for the config files that can change this session. It lived inline in both, which is how one of
141
+ * two copies gets fixed.
142
+ *
143
+ * `impl` is the `path` implementation, injected rather than detected. Reading `process.platform`
144
+ * here would make the Windows verdicts checkable only on Windows, and this repository has already
145
+ * paid for that once: A-SPEC-220.1 records a gate asking `startsWith('/')` for "is absolute",
146
+ * which is false for `C:/proj/src/app.ts` and skipped the whole identity block on native Windows
147
+ * paths. Keeping the question about the STRING is what lets a POSIX run assert the win32 answers.
148
+ *
149
+ * No filesystem access: the file being written usually does not exist yet, and asking whether it
150
+ * is there would miss exactly the case these rules are for.
151
+ */
152
+ export interface PathImpl {
153
+ resolve: (...parts: string[]) => string;
154
+ sep: string;
155
+ }
156
+ export declare function resolvesInside(raw: string, roots: readonly string[], impl?: PathImpl): boolean;
@@ -43,9 +43,11 @@ exports.isProtectedTarget = isProtectedTarget;
43
43
  exports.specTargetOf = specTargetOf;
44
44
  exports.protectedFileKindOf = protectedFileKindOf;
45
45
  exports.protectedKindOf = protectedKindOf;
46
+ exports.resolvesInside = resolvesInside;
46
47
  // @implements A-SPEC-163
47
48
  const fs = __importStar(require("node:fs"));
48
49
  const path = __importStar(require("node:path"));
50
+ const os = __importStar(require("node:os"));
49
51
  /**
50
52
  * Tools known to only READ. Everything else that carries a path is treated as a write.
51
53
  *
@@ -136,17 +138,33 @@ function pathFlavorFor(root, raw) {
136
138
  }
137
139
  /** Compare paths in one spelling — win32 hands back backslashes that must not defeat a prefix test. */
138
140
  const foldSep = (p) => p.replace(/\\/g, '/');
139
- function resolveTarget(root, raw, depth = 0) {
141
+ function resolveTarget(root, raw, depth = 0, opts = {}) {
140
142
  const pp = pathFlavorFor(root, raw);
141
143
  const abs = pp.resolve(root, raw);
144
+ // @implements A-SPEC-465 — W-7. The filesystem is consulted only in ITS OWN grammar. Asking a
145
+ // host about a foreign-flavor path leaks the host's spelling into the answer: on Windows,
146
+ // realpathSync('/') returns 'C:\', and the ancestor walk below joined it into the hybrid
147
+ // 'C:\/proj' — so protectedKindOf('/proj', …) answered null on a Windows host and '.ax/roles' on
148
+ // a POSIX host. Same string, different answer per host, which is the invariant A-SPEC-220.2
149
+ // exists to keep. When the chosen flavor is not the host's native one, resolve by string alone —
150
+ // fully platform-independent, and exactly what this function already returned whenever no
151
+ // ancestor existed on disk. The host's flavor is read off the `path` module's own separator (not
152
+ // process.platform; NOT module identity — `import * as path` yields a namespace object that is
153
+ // never `===` path.posix, which silently turned every posix resolution string-only and broke the
154
+ // symlink rule, caught by the A-SPEC-163 corner test). Injectable so the cross-flavor case is
155
+ // testable from either host.
156
+ const host = opts.host ?? (path.sep === '\\' ? path.win32 : path.posix);
157
+ if (pp !== host)
158
+ return abs;
159
+ const fsi = opts.fs ?? fs;
142
160
  // A DANGLING symlink still names where a write would land, and `realpath` fails on it — so the
143
161
  // link is followed explicitly. Without this, `ln -s .ax/ledger/not-yet-there innocent` then
144
162
  // writing `innocent` creates a file inside the protected directory while the gate sees a name
145
163
  // outside it. The depth cap stops a symlink cycle from spinning.
146
164
  if (depth < 8) {
147
165
  try {
148
- if (fs.lstatSync(abs).isSymbolicLink()) {
149
- return resolveTarget(pp.dirname(abs), fs.readlinkSync(abs), depth + 1);
166
+ if (fsi.lstatSync(abs).isSymbolicLink()) {
167
+ return resolveTarget(pp.dirname(abs), fsi.readlinkSync(abs), depth + 1, opts);
150
168
  }
151
169
  }
152
170
  catch { /* not a symlink, or unreadable — fall through to ancestor resolution */ }
@@ -155,7 +173,7 @@ function resolveTarget(root, raw, depth = 0) {
155
173
  let cur = abs;
156
174
  for (;;) {
157
175
  try {
158
- return pp.join(fs.realpathSync(cur), ...parts.reverse());
176
+ return pp.join(fsi.realpathSync(cur), ...parts.reverse());
159
177
  }
160
178
  catch {
161
179
  const parent = pp.dirname(cur);
@@ -256,6 +274,13 @@ function protectedFileKindOf(root, raw) {
256
274
  const parent = pp.basename(pp.dirname(resolved)).toLowerCase();
257
275
  if (parent === '.agents' && (name === 'hooks.json' || name === 'mcp_config.json'))
258
276
  return `.agents/${name}`;
277
+ // @implements A-SPEC-266 — codex 는 훅 게이트가 없어(HARNESS_ENFORCES.codex=false) `.codex/config.toml`
278
+ // 의 [mcp_servers.holmes-kit] 가 codex 지배의 **유일한** 경로다. 이 슬라이스가 그 배선을 죽은 JSON 에서
279
+ // 실제 거버넌스로 바꿨으므로, `.agents/*`·`.mcp.json` 과 정확히 같은 성질이 된다 — 세션이 고칠 수 있으면
280
+ // 세션이 codex 게이트를 끈다. A-SPEC-193 §8 의 원칙을 새로 자격을 갖춘 파일에 적용해 같은 self-disarm
281
+ // 보호를 준다. 구식 `.codex/mcp_config.json` 도 잠근다 — 그 파일을 되살려 배선을 흐릴 수 없게.
282
+ if (parent === '.codex' && (name === 'config.toml' || name === 'mcp_config.json'))
283
+ return `.codex/${name}`;
259
284
  // Example/sample/template/dist copies carry no secret — the same exclusion the hook's regex had.
260
285
  if (/^\.env(\.[\w-]+)?$/.test(name) && !/\.(example|sample|template|dist)$/.test(name))
261
286
  return '.env';
@@ -275,3 +300,22 @@ function protectedKindOf(root, raw) {
275
300
  }
276
301
  return null;
277
302
  }
303
+ function resolvesInside(raw, roots, impl = path) {
304
+ if (typeof raw !== 'string' || raw.length === 0 || roots.length === 0)
305
+ return false;
306
+ const expanded = raw === '~' || raw.startsWith('~/') ? impl.resolve(os.homedir(), raw.slice(1) || '.') : raw;
307
+ for (const root of roots) {
308
+ const base = impl.resolve(root);
309
+ const abs = impl.resolve(base, expanded);
310
+ // @implements A-SPEC-462 (Windows W-1) — drive/unc filesystems fold case, so a lowercased drive
311
+ // (`c:\proj`, which cmd.exe commonly emits) or an all-caps path must still be judged inside.
312
+ // Fold ONLY when the base's syntax says so (foldsCaseBySyntax) — POSIX stays case-sensitive, so
313
+ // `/Proj` ≠ `/proj` is preserved. The separator comparison is kept, so `C:\proj-old` ∉ `C:\proj`.
314
+ const fold = foldsCaseBySyntax(absoluteKindOf(base));
315
+ const b = fold ? base.toLowerCase() : base;
316
+ const a = fold ? abs.toLowerCase() : abs;
317
+ if (a === b || a.startsWith(b + impl.sep))
318
+ return true;
319
+ }
320
+ return false;
321
+ }
@@ -169,7 +169,18 @@ function adaptPreToolUse(stdin, cwd = process.cwd(), run = runShippedHook) {
169
169
  if (out.decision === undefined) {
170
170
  return { decision: 'deny', reason: '[Holmes-Kit] 게이트가 답하지 않았습니다 — 판정 없이 진행하지 않습니다.' };
171
171
  }
172
- return out.decision === 'deny' ? { decision: 'deny', reason: out.reason } : { decision: 'allow' };
172
+ // @implements A-SPEC-336
173
+ // The reason travels on an ALLOW too. It used to be dropped, which was harmless while the evidence
174
+ // gate was `off`: a clean allow carries no reason by rule (A-SPEC-278), so an allow-with-reason did
175
+ // not exist. Raising the default to `warn` (A-SPEC-332) created exactly that case, and this branch
176
+ // silently swallowed the warning — the gate was turned on to be seen, and one harness could not
177
+ // see it. Wiring parity cannot catch that; the behavioural parity test (A-SPEC-335) caught it on
178
+ // its first run.
179
+ //
180
+ // Still nothing when there is nothing to say: a silent gate must stay silent, or it masks another
181
+ // gate's verdict.
182
+ const decision = out.decision === 'deny' ? 'deny' : 'allow';
183
+ return out.reason ? { decision, reason: out.reason } : { decision };
173
184
  }
174
185
  /**
175
186
  * Stop: 게이트가 "막아라"라고 하면, 이 하네스에서 막는 말은 `continue` 다.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Classify a spawnSync result into a gate VERDICT or an INFRASTRUCTURE failure.
3
+ *
4
+ * Split out of the test's runner so the killed-child case can be checked without racing an OOM.
5
+ * S-444's intermittent was a hook the parallel suite SIGKILLed: spawnSync then reports
6
+ * error===undefined (the spawn succeeded), signal set, and empty stdout, which the old runner
7
+ * returned as an empty verdict and the test read as "the gate did not block". A killed child and a
8
+ * gate that let something through are opposite conclusions and must not share an outcome.
9
+ *
10
+ * `status` alone is NOT a throw condition: the Stop hook exits 0 by design and carries its block
11
+ * decision in stdout JSON, so a zero exit with empty stdout is a legitimate (allow) verdict, not a
12
+ * failure. `error` (spawn failed) and `signal` (child killed) mean "no verdict was produced".
13
+ *
14
+ * @implements A-SPEC-464 — W-6. The signal branch closes the killed-child hole only on POSIX.
15
+ * Windows has no POSIX signals, so an external kill (taskkill / worker teardown / OOM) leaves
16
+ * signal=null and a NONZERO exit status with empty output, which fell through to a {'',''} verdict
17
+ * the suite reads as "the gate allowed". A FULLY SILENT unclean exit — empty stdout AND empty
18
+ * stderr AND a non-zero status (a nonzero number, or null when the platform reported no code) —
19
+ * produced no verdict and is infrastructure failure, same as the signal case. status===0 with empty
20
+ * output stays a legitimate allow (it fails this test), and any output at all is a verdict (a
21
+ * blocking hook exits non-zero WITH a reason, so it is never silent).
22
+ */
23
+ export interface SpawnLike {
24
+ error?: Error;
25
+ signal?: NodeJS.Signals | null;
26
+ status?: number | null;
27
+ stdout?: string | Buffer | null;
28
+ stderr?: string | Buffer | null;
29
+ }
30
+ export declare function classifyRun(r: SpawnLike): {
31
+ stdout: string;
32
+ stderr: string;
33
+ };
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyRun = classifyRun;
4
+ function classifyRun(r) {
5
+ if (r.error !== undefined)
6
+ throw r.error;
7
+ if (r.signal) {
8
+ throw new Error(`hook killed by ${r.signal} — infrastructure failure (OOM/kill under load), not a gate verdict`);
9
+ }
10
+ const stdout = String(r.stdout ?? '');
11
+ const stderr = String(r.stderr ?? '');
12
+ if (stdout === '' && stderr === '' && r.status !== 0) {
13
+ throw new Error(`hook exited ${r.status} with no output — no verdict produced (signalless external kill under load, e.g. Windows taskkill), not a gate verdict`);
14
+ }
15
+ return { stdout, stderr };
16
+ }
@@ -1,6 +1,7 @@
1
1
  import { Spec } from '../spec/spec-parser';
2
2
  import { Approval } from '../guardrail/risk-gate';
3
3
  import { RiskAction, RiskAssessment, RmSignal } from '../guardrail/risk-types';
4
+ import { type PreEditEvidenceMode } from '../guardrail/impact-gate';
4
5
  export declare function resolveRmSignals(command: string, cwd: string): Record<string, RmSignal> | undefined;
5
6
  /**
6
7
  * @implements A-SPEC-149
@@ -67,6 +68,31 @@ export declare function normalizeHookInput(raw: unknown): HookInput;
67
68
  * and inventing a denial there is its own failure mode. And an operator — never the session — can
68
69
  * escape via `HOLMES_GATE_BYPASS`.
69
70
  */
71
+ /**
72
+ * @implements A-SPEC-278
73
+ * Pre-edit impact evidence, wired but OFF by default.
74
+ *
75
+ * `mode: 'off'` returns null before touching anything — a repository that never opted in must behave
76
+ * exactly as it did before this gate existed, and that includes not paying for a disk read. A
77
+ * covering `code-write` approval overrides it exactly as it overrides every other gate here, and
78
+ * actions that are not code or test writes are simply not this gate's business.
79
+ *
80
+ * The evidence digest comes from the ENVIRONMENT (`HOLMES_EVIDENCE`), never from the tool payload —
81
+ * the same rule the approval channel follows, because a session naming its own justification inside
82
+ * the request it is making is the session asserting its own authority.
83
+ */
84
+ export declare function preEditEvidenceGate(o: {
85
+ action: string;
86
+ projectRoot: string;
87
+ relPath: string;
88
+ covered: boolean;
89
+ mode: PreEditEvidenceMode;
90
+ declaredDigest: string | null;
91
+ currentHead: string;
92
+ }): {
93
+ permissionDecision: 'allow' | 'deny';
94
+ permissionDecisionReason?: string;
95
+ } | null;
70
96
  export declare function decideOnGateError(opts: {
71
97
  governed: boolean;
72
98
  bypass: boolean;