@holmes-lab/holmes-kit 0.2.1 → 0.3.1
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.
- package/CHANGELOG.md +55 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -21
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +311 -5
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +41 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
- package/dist/holmes/mcp/elicit-approval.js +30 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +636 -35
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/server.js +13 -10
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EvidenceArtifact } from '../mcp/maintenance-evidence';
|
|
2
|
+
/**
|
|
3
|
+
* Pre-edit impact evidence — the decision core (Goal Phase 2, first slice).
|
|
4
|
+
*
|
|
5
|
+
* This module DECIDES; it does not enforce. Wiring it into the hook path is a separate slice, kept
|
|
6
|
+
* separate on purpose: a gate whose judgement and whose plumbing land together cannot be shown to
|
|
7
|
+
* have a correct judgement. Everything here is pure, so the same inputs always yield the same
|
|
8
|
+
* verdict and every branch is checkable against a fixture rather than against a live repository.
|
|
9
|
+
*
|
|
10
|
+
* The bias is fail-safe throughout. Where the evidence is silent the answer is `widen`, never
|
|
11
|
+
* `allow`: "we saw no impact" and "we could not look" are opposite claims, and only the first is a
|
|
12
|
+
* reason to proceed.
|
|
13
|
+
*/
|
|
14
|
+
export type EditDecision = 'allow' | 'widen' | 'refuse';
|
|
15
|
+
export interface EditEvidenceReason {
|
|
16
|
+
code: 'no-analysis' | 'basis-drift' | 'target-out-of-scope' | 'contract-untested' | 'unproven-absence' | 'architecture-unchecked' | 'target-drift' | 'basis-unverifiable' | 'security-unchecked' | 'compatibility-unchecked';
|
|
17
|
+
detail: string;
|
|
18
|
+
}
|
|
19
|
+
export interface EditEvidenceInput {
|
|
20
|
+
/** Repo-relative path the session wants to edit. */
|
|
21
|
+
target: string;
|
|
22
|
+
/** The persisted analysis backing this edit, or null when none was produced. */
|
|
23
|
+
artifact: EvidenceArtifact | null;
|
|
24
|
+
/** HEAD as it is NOW — compared against the commit the analysis was computed on. */
|
|
25
|
+
currentHead: string;
|
|
26
|
+
/**
|
|
27
|
+
* @implements A-SPEC-277
|
|
28
|
+
* sha256 of the target AS IT IS NOW, or null when it cannot be read (deleted, or not yet created).
|
|
29
|
+
* Compared against the digest the analysis recorded, so an uncommitted edit to the analysed file
|
|
30
|
+
* is caught even while HEAD sits still.
|
|
31
|
+
*/
|
|
32
|
+
currentTargetDigest?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface EditEvidenceVerdict {
|
|
35
|
+
decision: EditDecision;
|
|
36
|
+
reasons: EditEvidenceReason[];
|
|
37
|
+
remediation: string;
|
|
38
|
+
/** True when the target qualified for the proportional low-risk path. */
|
|
39
|
+
lowRisk: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Documents are edited constantly and almost never break anything, so demanding a graph analysis for
|
|
43
|
+
* every prose change would train people to route around the gate — the proportional path exists to
|
|
44
|
+
* keep the gate credible where it matters. The exception is documents that GOVERN: an ADR or a
|
|
45
|
+
* C-SPEC is prose that decides architecture, and A-SPEC-233 already treats those as full-regression
|
|
46
|
+
* material. Same locations, read from the type table rather than spelled twice.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isLowRiskTarget(target: string): boolean;
|
|
49
|
+
export declare function evaluateEditEvidence(inputArgs: EditEvidenceInput): EditEvidenceVerdict;
|
|
50
|
+
/**
|
|
51
|
+
* @implements A-SPEC-278
|
|
52
|
+
* The enforcement mode. `off` is the default and must be a TRUE no-op: a repository that never asked
|
|
53
|
+
* for this gate has to behave exactly as it did before, and a broken config falls back here too.
|
|
54
|
+
*/
|
|
55
|
+
export type PreEditEvidenceMode = 'off' | 'warn' | 'block';
|
|
56
|
+
/**
|
|
57
|
+
* @implements A-SPEC-278
|
|
58
|
+
* Turn a verdict into a hook decision, or into silence.
|
|
59
|
+
*
|
|
60
|
+
* Silence — `null` — is deliberate for both `off` and a clean `allow`. Returning `allow` would still
|
|
61
|
+
* be an opinion: if this gate were ever consulted ahead of another, its `allow` could mask that
|
|
62
|
+
* gate's denial. A gate that has nothing to say should say nothing.
|
|
63
|
+
*/
|
|
64
|
+
export declare function preEditEvidenceDecision(verdict: EditEvidenceVerdict, mode: PreEditEvidenceMode): {
|
|
65
|
+
permissionDecision: 'allow' | 'deny';
|
|
66
|
+
permissionDecisionReason?: string;
|
|
67
|
+
} | null;
|
|
68
|
+
/**
|
|
69
|
+
* @implements A-SPEC-278
|
|
70
|
+
* The I/O adapter, kept apart from the pure core on purpose: everything above this line is decided
|
|
71
|
+
* by fixtures, and only this function touches a disk. It reads the artifact the session declared
|
|
72
|
+
* and measures the target's current content so the core can compare them.
|
|
73
|
+
*
|
|
74
|
+
* A digest that names no stored artifact yields `artifact: null` — the same shape as "no analysis",
|
|
75
|
+
* because from the gate's side an unfindable analysis and an absent one justify equally little.
|
|
76
|
+
*/
|
|
77
|
+
export declare function loadEditEvidence(projectRoot: string, target: string, digest: string | null, currentHead: string): EditEvidenceInput;
|
|
@@ -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
|
-
|
|
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 (
|
|
149
|
-
return resolveTarget(pp.dirname(abs),
|
|
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(
|
|
176
|
+
return pp.join(fsi.realpathSync(cur), ...parts.reverse());
|
|
159
177
|
}
|
|
160
178
|
catch {
|
|
161
179
|
const parent = pp.dirname(cur);
|
|
@@ -282,3 +300,22 @@ function protectedKindOf(root, raw) {
|
|
|
282
300
|
}
|
|
283
301
|
return null;
|
|
284
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
|
-
|
|
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;
|