@holmes-lab/holmes-kit 0.1.7 → 0.1.9
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 +8 -0
- package/README.md +11 -7
- package/bin/holmes-mcp.js +0 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +50 -0
- package/dist/holmes/cli/ci-gate.d.ts +18 -0
- package/dist/holmes/cli/doctor.d.ts +44 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +18 -0
- package/dist/holmes/cli/governed-precondition.d.ts +27 -0
- package/dist/holmes/cli/index.d.ts +14 -0
- package/dist/holmes/cli/index.js +23 -1
- package/dist/holmes/cli/init.d.ts +69 -0
- package/dist/holmes/cli/interactive-prompt.d.ts +9 -0
- package/dist/holmes/cli/playbook-skills.d.ts +129 -0
- package/dist/holmes/cli/roles-readme.d.ts +12 -0
- package/dist/holmes/cli/serve.d.ts +14 -0
- package/dist/holmes/cli/serve.js +70 -0
- package/dist/holmes/cli/settings-merge.d.ts +66 -0
- package/dist/holmes/config/config.d.ts +13 -0
- package/dist/holmes/context/bundler.d.ts +40 -0
- package/dist/holmes/context/render.d.ts +9 -0
- package/dist/holmes/context/tiers.d.ts +54 -0
- package/dist/holmes/context/tokens.d.ts +2 -0
- package/dist/holmes/cpg/ast-mutation.d.ts +31 -0
- package/dist/holmes/cpg/ast-mutation.js +126 -0
- package/dist/holmes/cpg/cpg-scanner.d.ts +69 -0
- package/dist/holmes/cpg/dynamic-cpg.d.ts +18 -0
- package/dist/holmes/cpg/dynamic-cpg.js +82 -0
- package/dist/holmes/cpg/hash-cache.d.ts +21 -0
- package/dist/holmes/cpg/language-parser-walk.d.ts +31 -0
- package/dist/holmes/cpg/language-parser-worker.d.ts +1 -0
- package/dist/holmes/cpg/language-parser.d.ts +116 -0
- package/dist/holmes/cpg/program-slicing.d.ts +21 -0
- package/dist/holmes/cpg/program-slicing.js +123 -0
- package/dist/holmes/cpg/scan-cache.d.ts +21 -0
- package/dist/holmes/cpg/source-path.d.ts +2 -0
- package/dist/holmes/cpg/test-files.d.ts +36 -0
- package/dist/holmes/governance/constitution-debt.d.ts +3 -0
- package/dist/holmes/governance/constitution-report.d.ts +23 -0
- package/dist/holmes/governance/constitution.d.ts +61 -0
- package/dist/holmes/governance/identity.d.ts +28 -0
- package/dist/holmes/governance/ledger-lock.d.ts +55 -0
- package/dist/holmes/governance/ledger-store.conformance.d.ts +11 -0
- package/dist/holmes/governance/ledger-store.d.ts +114 -0
- package/dist/holmes/governance/progress-ledger.d.ts +25 -0
- package/dist/holmes/governance/provenance-chain.d.ts +154 -0
- package/dist/holmes/governance/provenance-ledger.d.ts +102 -0
- package/dist/holmes/governance/provenance-schema.d.ts +21 -0
- package/dist/holmes/governance/replica-id.d.ts +20 -0
- package/dist/holmes/governance/role-policy.d.ts +52 -0
- package/dist/holmes/governance/trust-score.d.ts +32 -0
- package/dist/holmes/guardrail/anchors.d.ts +8 -0
- package/dist/holmes/guardrail/blind-spots.d.ts +32 -0
- package/dist/holmes/guardrail/decision-ledger.d.ts +29 -0
- package/dist/holmes/guardrail/executable-artifact.d.ts +1 -0
- package/dist/holmes/guardrail/governance-history.d.ts +15 -0
- package/dist/holmes/guardrail/phase.d.ts +27 -0
- package/dist/holmes/guardrail/risk-classifier.d.ts +52 -0
- package/dist/holmes/guardrail/risk-gate.d.ts +77 -0
- package/dist/holmes/guardrail/risk-types.d.ts +48 -0
- package/dist/holmes/guardrail/tspec-state.d.ts +71 -0
- package/dist/holmes/guardrail/write-target.d.ts +77 -0
- package/dist/holmes/hooks/adapters/antigravity.d.ts +42 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +144 -0
- package/dist/holmes/hooks/stop.d.ts +91 -0
- package/dist/holmes/mcp/basis.d.ts +100 -0
- package/dist/holmes/mcp/handlers.d.ts +482 -0
- package/dist/holmes/mcp/server.d.ts +2 -0
- package/dist/holmes/mcp/stdio-client.d.ts +51 -0
- package/dist/holmes/mcp/supervisor.d.ts +48 -0
- package/dist/holmes/mcp/tool-schemas.d.ts +12 -0
- package/dist/holmes/mcp/validate-args.d.ts +27 -0
- package/dist/holmes/messages/registry.d.ts +23 -0
- package/dist/holmes/project/baseline.d.ts +66 -0
- package/dist/holmes/project/change-source.d.ts +89 -0
- package/dist/holmes/project/ignore.d.ts +33 -0
- package/dist/holmes/project/root.d.ts +34 -0
- package/dist/holmes/reverse/anchor.d.ts +47 -0
- package/dist/holmes/reverse/cluster.d.ts +45 -0
- package/dist/holmes/reverse/draft.d.ts +33 -0
- package/dist/holmes/reverse/dynamic-wiring.d.ts +18 -0
- package/dist/holmes/reverse/scan.d.ts +103 -0
- package/dist/holmes/reverse/surface.d.ts +91 -0
- package/dist/holmes/reverse/test-map.d.ts +91 -0
- package/dist/holmes/review/coverage.d.ts +21 -0
- package/dist/holmes/review/findings.d.ts +49 -0
- package/dist/holmes/review/package.d.ts +26 -0
- package/dist/holmes/review/review-targets.d.ts +28 -0
- package/dist/holmes/review/scope.d.ts +13 -0
- package/dist/holmes/review/test-evidence.d.ts +31 -0
- package/dist/holmes/review/test-runner.d.ts +141 -0
- package/dist/holmes/rtm/dataflow-taint.d.ts +81 -0
- package/dist/holmes/rtm/gap-analyzer.d.ts +28 -0
- package/dist/holmes/rtm/git-changes.d.ts +33 -0
- package/dist/holmes/rtm/heatmap.d.ts +21 -0
- package/dist/holmes/rtm/incremental.d.ts +38 -0
- package/dist/holmes/rtm/localize.d.ts +36 -0
- package/dist/holmes/rtm/rtm-builder.d.ts +101 -0
- package/dist/holmes/rtm/rtm-check.d.ts +13 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +88 -0
- package/dist/holmes/rtm/taint.d.ts +35 -0
- package/dist/holmes/rtm/test-scope.d.ts +85 -0
- package/dist/holmes/server/dashboard.d.ts +85 -0
- package/dist/holmes/server/dashboard.js +2188 -0
- package/dist/holmes/spec/approval-blockers.d.ts +62 -0
- package/dist/holmes/spec/breaking-change.d.ts +42 -0
- package/dist/holmes/spec/legacy-format.d.ts +93 -0
- package/dist/holmes/spec/spec-digest.d.ts +29 -0
- package/dist/holmes/spec/spec-parser.d.ts +12 -0
- package/dist/holmes/spec/spec-store.conformance.d.ts +10 -0
- package/dist/holmes/spec/spec-store.d.ts +130 -0
- package/dist/holmes/spec/spec-types.d.ts +150 -0
- package/dist/holmes/spec/validator.d.ts +65 -0
- package/package.json +2 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type RiskLevel = 'auto' | 'notify' | 'confirm' | 'hard-hitl';
|
|
2
|
+
export declare const RISK_ORDER: RiskLevel[];
|
|
3
|
+
export type RiskAxis = 'reversibility' | 'architecture' | 'secret' | 'blast-radius';
|
|
4
|
+
export interface AxisVerdict {
|
|
5
|
+
axis: RiskAxis;
|
|
6
|
+
level: RiskLevel;
|
|
7
|
+
reasons: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface RmSignal {
|
|
10
|
+
gitIgnored?: boolean;
|
|
11
|
+
tracked?: boolean;
|
|
12
|
+
inProject?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface RiskAction {
|
|
15
|
+
kind: string;
|
|
16
|
+
target: string;
|
|
17
|
+
command?: string;
|
|
18
|
+
tracked?: boolean;
|
|
19
|
+
isDirectory?: boolean;
|
|
20
|
+
adrSupersedes?: string[];
|
|
21
|
+
cspecConstraintChange?: boolean;
|
|
22
|
+
stagedContent?: string;
|
|
23
|
+
blastRadius?: {
|
|
24
|
+
impactedSpecCount: number;
|
|
25
|
+
hitsFoundational: boolean;
|
|
26
|
+
};
|
|
27
|
+
rmTargetSignals?: Record<string, RmSignal>;
|
|
28
|
+
/**
|
|
29
|
+
* Absolute path of the project this command runs against, supplied by the hook (the classifier
|
|
30
|
+
* itself stays pure and does no I/O).
|
|
31
|
+
*
|
|
32
|
+
* Used to scope the protected-`.ax` rules to THIS project's governance directory. Without it the
|
|
33
|
+
* substring match gated `.ax` paths belonging to any other repository on the machine — real
|
|
34
|
+
* friction for anyone working across projects. Absent means unknown scope, which stays
|
|
35
|
+
* conservative: every `.ax` path is treated as protected.
|
|
36
|
+
*/
|
|
37
|
+
projectRoot?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RiskThresholds {
|
|
40
|
+
blastRadiusConfirm?: number;
|
|
41
|
+
blastRadiusHardHitl?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface RiskAssessment {
|
|
44
|
+
level: RiskLevel;
|
|
45
|
+
verdicts: AxisVerdict[];
|
|
46
|
+
reasons: string[];
|
|
47
|
+
}
|
|
48
|
+
export declare function maxLevel(a: RiskLevel, b: RiskLevel): RiskLevel;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Spec } from '../spec/spec-parser';
|
|
2
|
+
/**
|
|
3
|
+
* The T-SPEC situation for one A-SPEC, kept as a state rather than the boolean the gate used to
|
|
4
|
+
* compute.
|
|
5
|
+
*
|
|
6
|
+
* The defect this exists for, measured 2026-08-13: four different situations produced the identical
|
|
7
|
+
* sentence `A-SPEC A-SPEC-129의 approved T-SPEC이 없습니다(테스트 먼저).` while needing different
|
|
8
|
+
* actions. The harness already knew — `promote-slice`'s 「침묵의 실패 모드」 section said outright
|
|
9
|
+
* that the refusal offered no way to tell them apart. A document was compensating for what the gate
|
|
10
|
+
* could have said.
|
|
11
|
+
*
|
|
12
|
+
* THE PARTITION LOOKS ONLY AT THE TARGET. An earlier draft split on whether the PROJECT had any
|
|
13
|
+
* T-SPEC at all, which made "none written yet" unreachable in any repo past its first slice —
|
|
14
|
+
* measured: 0 of this repository's 73 A-SPECs could reach it, and a new A-SPEC was instead handed
|
|
15
|
+
* three unrelated documents with "add depends_on to one of them". That was worse than the sentence
|
|
16
|
+
* it replaced, which at least said 테스트 먼저.
|
|
17
|
+
*
|
|
18
|
+
* So there are two deny states. "Not written yet" and "written but the edge is missing" are NOT
|
|
19
|
+
* distinguishable by the gate — only the author knows which — so they are one state whose message
|
|
20
|
+
* offers both moves. Guessing between them (e.g. from the `T-SPEC-<number>` naming convention,
|
|
21
|
+
* which measures 71/73 here) would be the gate judging rather than reporting.
|
|
22
|
+
*/
|
|
23
|
+
export type TspecState = {
|
|
24
|
+
kind: 'approved';
|
|
25
|
+
spec: Spec;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'linked-not-approved';
|
|
28
|
+
specs: Spec[];
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'unlinked';
|
|
31
|
+
orphans: Spec[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Classify the T-SPEC situation for `aspecId`. Array lookups only — the gate calls this from a
|
|
35
|
+
* PreToolUse hook on every tool call, so it never touches the filesystem.
|
|
36
|
+
*/
|
|
37
|
+
export declare function tspecStateFor(aspecId: string, specs: Spec[]): TspecState;
|
|
38
|
+
export declare const ID_BUDGET = 200;
|
|
39
|
+
export declare const REASON_BUDGET = 1200;
|
|
40
|
+
export declare const ID_MAX = 80;
|
|
41
|
+
/**
|
|
42
|
+
* `a, b 외 N개` — or, when nothing fits, `N개` (rule 2). The count always agrees with what is shown.
|
|
43
|
+
*/
|
|
44
|
+
export declare const listOrCount: (items: string[], budget: number, unit: string, cap?: number) => string;
|
|
45
|
+
/**
|
|
46
|
+
* What the author should read, and what they should do, for a state that denies.
|
|
47
|
+
*
|
|
48
|
+
* `null` for `approved` — there is nothing to say when nothing is wrong.
|
|
49
|
+
*/
|
|
50
|
+
export declare function tspecRemediation(rawAspecId: string, state: TspecState, why: (spec: Spec) => string[]): {
|
|
51
|
+
message: string;
|
|
52
|
+
next_action: string;
|
|
53
|
+
} | null;
|
|
54
|
+
/**
|
|
55
|
+
* @implements A-SPEC-192 §10R (round 10, 4th)
|
|
56
|
+
* ONE spec's clause, budgeted — the shared discipline both refusal surfaces now use.
|
|
57
|
+
*
|
|
58
|
+
* The twin in `phase.ts` compared the whole joined summary (framing + id + every blocker) against
|
|
59
|
+
* REASON_BUDGET and dropped it ALL when it did not fit. Two harms followed, both measured. A 93-char
|
|
60
|
+
* parent blocker — the one action the author could take right now — vanished because a sibling
|
|
61
|
+
* blocker happened to be 1,154 characters. And because the id sits inside what was being measured,
|
|
62
|
+
* the effective budget slid with the id's LENGTH: growing an id by one character past ID_MAX made
|
|
63
|
+
* the §7R scrub fold it, freed 63 characters, and turned a 236-char refusal that named nothing into
|
|
64
|
+
* a 1,209-char refusal that named everything. Worse input, better answer, decided by something the
|
|
65
|
+
* author cannot control.
|
|
66
|
+
*
|
|
67
|
+
* Here the framing is priced first and every judgement about a blocker is made against what remains
|
|
68
|
+
* for bodies; blockers are dropped one at a time and counted, and the label distinguishes "no reason
|
|
69
|
+
* fits at all" from "the budget was spent elsewhere" — the same words the sibling uses.
|
|
70
|
+
*/
|
|
71
|
+
export declare function budgetedClause(labelId: string, fallbackLabel: string, blockers: string[], spentElsewhere?: boolean): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools known to only READ. Everything else that carries a path is treated as a write.
|
|
3
|
+
*
|
|
4
|
+
* The list is inverted on purpose. The gate used to ask `['Write','Edit'].includes(name)`, and
|
|
5
|
+
* measured 2026-08-08 that meant `MultiEdit`, `NotebookEdit`, and a lower-case `write` reached the
|
|
6
|
+
* same protected files `Write` was denied — No-Spec-No-Code, the role gate, the ledger ban and the
|
|
7
|
+
* approval-config ban all bypassed by a different name. Adding those three names would postpone the
|
|
8
|
+
* problem to the fourth tool; inverting it means an unrecognised tool fails CLOSED.
|
|
9
|
+
*/
|
|
10
|
+
export declare const READ_ONLY_TOOLS: readonly ["read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch", "todowrite", "task", "bashoutput", "killshell"];
|
|
11
|
+
/**
|
|
12
|
+
* Payload keys that name a file. Shared with normalization so the two cannot drift: a field the
|
|
13
|
+
* gate does not know about is a path the gate cannot see, which is how `notebook_path` slipped
|
|
14
|
+
* through.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PATH_FIELDS: readonly ["file_path", "notebook_path"];
|
|
17
|
+
/**
|
|
18
|
+
* Whether this tool call writes a file.
|
|
19
|
+
*
|
|
20
|
+
* A missing or non-string name counts as a write. `normalizeHookInput` turns both into `''`, and the
|
|
21
|
+
* old membership test read that as "not a write tool" — REQ-144 closed exactly this direction for
|
|
22
|
+
* the path field while leaving the name field open.
|
|
23
|
+
*/
|
|
24
|
+
export declare function writesFiles(toolName: string | undefined, input: unknown): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The file a raw path actually names.
|
|
27
|
+
*
|
|
28
|
+
* `path.resolve` folds `//`, `.` and `..`. Then the longest EXISTING ancestor is realpath'd and the
|
|
29
|
+
* remainder rejoined lexically — a whole-path `realpath` throws on a file that does not exist yet,
|
|
30
|
+
* which is precisely when the gate has to answer.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveTarget(root: string, raw: string, depth?: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a write lands inside a governance directory.
|
|
35
|
+
*
|
|
36
|
+
* Comparison folds case because that is what the filesystem does: measured 2026-08-08 on macOS,
|
|
37
|
+
* writing `.AX/roles/policy.yaml` changed the very file the gate protects. On a case-sensitive
|
|
38
|
+
* filesystem folding over-protects, which is the safe direction and only collides for a project
|
|
39
|
+
* that deliberately keeps both `.ax` and `.AX`.
|
|
40
|
+
*
|
|
41
|
+
* The separator suffix matters: without it `.axolotl/` would inherit `.ax/`'s protection by prefix.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isProtectedTarget(root: string, raw: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Which governance directory a write lands in, or `null`.
|
|
46
|
+
*
|
|
47
|
+
* Callers that phrase a refusal need to name the right thing — REQ-155 measured how much a message
|
|
48
|
+
* that points at the wrong file costs — so the matched directory is returned rather than a bare
|
|
49
|
+
* boolean re-derived from a second regex.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The spec FILE a write lands on, resolved — or `null` when the target is not a spec.
|
|
53
|
+
*
|
|
54
|
+
* @implements A-SPEC-191 §23 (round 12) — §22 moved `.mcp.json`, `.env`, `.ax/roles` and
|
|
55
|
+
* `.ax/ledger` onto identity and left the spec store itself on a case-sensitive regex over the
|
|
56
|
+
* caller's string. Measured on one fixture: `.AX/specs/…`, `.Ax/specs/…`, `.ax/SPECS/…`,
|
|
57
|
+
* `.ax//specs/…`, `.ax/./specs/…`, a link named `alias.md` and a link named `specslink` ALL
|
|
58
|
+
* reached the same files with the pattern looking elsewhere — so a session could self-approve an
|
|
59
|
+
* A-SPEC and a T-SPEC and then write the code they authorise, and could rewrite the body of an
|
|
60
|
+
* already-approved spec. The two rules the spec chain rests on (no self-approval, no seal-breaking
|
|
61
|
+
* edit) were both spelling-deep. The RESOLVED path is returned so callers read the disk at the
|
|
62
|
+
* same file they judged — asking `existsSync` about the caller's spelling was its own bypass.
|
|
63
|
+
*/
|
|
64
|
+
export declare function specTargetOf(root: string, specsDir: string, raw: string): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Which governance FILE a write lands on, or `null`.
|
|
67
|
+
*
|
|
68
|
+
* @implements A-SPEC-191 §22 (round 11) — `.ax/*` and `.claude` were judged by identity here while
|
|
69
|
+
* `.mcp.json` and `.env` were judged by a regex over the caller's string, one function away. So the
|
|
70
|
+
* same sentence that A-SPEC-163 wrote ("identity, not spelling") was true of half its subject:
|
|
71
|
+
* a link inside the project under any other name reached `.mcp.json` with the pattern looking at a
|
|
72
|
+
* different string, and on a case-insensitive volume `.MCP.json` was the same file too. The
|
|
73
|
+
* resolution is `resolveTarget`'s — it follows links, including dangling ones, because a write
|
|
74
|
+
* through a dangling link creates exactly the file it names.
|
|
75
|
+
*/
|
|
76
|
+
export declare function protectedFileKindOf(root: string, raw: string): string | null;
|
|
77
|
+
export declare function protectedKindOf(root: string, raw: string): string | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface AgyPreDecision {
|
|
2
|
+
decision: 'allow' | 'deny';
|
|
3
|
+
reason?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface AgyStopDecision {
|
|
6
|
+
decision?: 'continue';
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 실측한 도구만 등재한다.
|
|
11
|
+
*
|
|
12
|
+
* 이름과 인자 키는 이 기계에 설치된 Antigravity CLI 의 실제 대화 기록에서 뽑았다
|
|
13
|
+
* (run_command{CommandLine,Cwd} 101건, view_file{AbsolutePath} 99건,
|
|
14
|
+
* replace_file_content{TargetFile,ReplacementContent,TargetContent} 46건, grep_search, list_dir).
|
|
15
|
+
* **추측으로 이름을 더하지 않는다** — 읽기로 잘못 등재하면 쓰기가 게이트를 지나가고, 쓰기로
|
|
16
|
+
* 잘못 등재하면 읽기가 막힌다. 등재되지 않은 이름은 그대로 게이트에 넘겨 fail-closed 로 받는다
|
|
17
|
+
* (A-SPEC-163: 읽기 전용이라고 알려지지 않은 도구가 경로를 들고 오면 그것은 쓰기다).
|
|
18
|
+
*/
|
|
19
|
+
export declare const TOOL_MAP: Record<string, {
|
|
20
|
+
as: string;
|
|
21
|
+
pathArg?: string;
|
|
22
|
+
contentArg?: string;
|
|
23
|
+
commandArg?: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** 훅을 실행해 그 표준 출력을 돌려준다. 실행 자체가 실패하면 `null`(호출자가 닫는 쪽으로 받는다). */
|
|
26
|
+
export type HookRunner = (hookPath: string, payload: string, cwd: string) => Record<string, unknown> | null;
|
|
27
|
+
/**
|
|
28
|
+
* PreToolUse: Antigravity 의 말 → 게이트 → Antigravity 의 말.
|
|
29
|
+
*
|
|
30
|
+
* 번역할 수 없는 입력은 **닫는 쪽**으로 답한다. 어댑터가 조용히 `allow` 를 돌려주면 게이트가
|
|
31
|
+
* 없는 것과 같고, 그 사실은 아무 데도 나타나지 않는다 — REQ-144 가 같은 방향으로 세운 규율이다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function adaptPreToolUse(stdin: string, cwd?: string, run?: HookRunner): AgyPreDecision;
|
|
34
|
+
/**
|
|
35
|
+
* Stop: 게이트가 "막아라"라고 하면, 이 하네스에서 막는 말은 `continue` 다.
|
|
36
|
+
*
|
|
37
|
+
* 이름이 반대라서 헷갈리기 쉽다 — Claude Code 의 `block` 은 "정지를 막아라"이고 Antigravity 의
|
|
38
|
+
* `continue` 도 "정지하지 말고 계속하라"이다. 같은 뜻, 다른 낱말이다.
|
|
39
|
+
*
|
|
40
|
+
* 여기서는 실행 실패를 **열어 준다**. Stop 훅은 이미 fail-open 계약이다(턴을 죽이지 않는다).
|
|
41
|
+
*/
|
|
42
|
+
export declare function adaptStop(stdin: string, cwd?: string, run?: HookRunner): AgyStopDecision;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Spec } from '../spec/spec-parser';
|
|
2
|
+
import { Approval } from '../guardrail/risk-gate';
|
|
3
|
+
import { RiskAction, RiskAssessment, RmSignal } from '../guardrail/risk-types';
|
|
4
|
+
export declare function resolveRmSignals(command: string, cwd: string): Record<string, RmSignal> | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* @implements A-SPEC-149
|
|
7
|
+
* The out-of-band approval channel, read at the ENTRY only. `evaluateHook` no longer calls this on
|
|
8
|
+
* its own — an exported HOLMES_APPROVAL used to reach the decision directly and turn a denial into
|
|
9
|
+
* permission with nothing recording that it had. Exported so the entry's contract (parse, and treat
|
|
10
|
+
* malformed input as absent) is testable at the layer that owns it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readApprovalFromEnv(): Approval | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* True when `env`/`printenv` runs as an actual COMMAND (an environment dump) rather than appearing as
|
|
15
|
+
* a mere word — `grep -rn env src/` and `git commit -m "set the env"` must stay allowed, while
|
|
16
|
+
* `env`, `/usr/bin/env`, `X=1 env`, `command env`, `(env)`, `sh -c 'env'` must not. Each segment is
|
|
17
|
+
* stripped of leading assignments/wrappers and, for a nested shell, its `-c` body is re-examined.
|
|
18
|
+
* `env FOO=1 cmd` is a RUNNER (assignment then command), not a dump.
|
|
19
|
+
*/
|
|
20
|
+
export declare function dumpsEnvInCommandPosition(command: string, depth?: number): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Coerce a hook envelope into known shapes so a type surprise cannot become an exception.
|
|
23
|
+
*
|
|
24
|
+
* @implements A-SPEC-144
|
|
25
|
+
* Measured against a real installed package, in a governed project where the control payload was
|
|
26
|
+
* correctly DENIED: `file_path: ["src/x.ts"]` and `file_path: {"a":1}` both threw
|
|
27
|
+
* `p.replace is not a function`, and an absent `tool_input` threw on property access — and the
|
|
28
|
+
* entry point's catch turned each of those into `allow`. No-Spec-No-Code was bypassed by a wrong type.
|
|
29
|
+
*
|
|
30
|
+
* A non-string target is DROPPED rather than coerced. `String(['a'])` is `'a'` and `String({})` is
|
|
31
|
+
* `'[object Object]'`; either would hand the classifier a path that does not exist and let it decide
|
|
32
|
+
* confidently about nothing. An absent target is honest, and the gate already knows how to treat one.
|
|
33
|
+
*/
|
|
34
|
+
export interface HookInput {
|
|
35
|
+
tool_name: string;
|
|
36
|
+
tool_input: {
|
|
37
|
+
file_path?: string;
|
|
38
|
+
notebook_path?: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
new_string?: string;
|
|
41
|
+
command?: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* @implements A-SPEC-163
|
|
45
|
+
* The raw payload carried a path field that was NOT a string, or `tool_input` was not an object.
|
|
46
|
+
*
|
|
47
|
+
* REQ-144's guarantee — a malformed payload denies rather than slips through — used to hold only
|
|
48
|
+
* by accident: `Write` happened to be in the hard-coded write list, so a dropped `file_path` fell
|
|
49
|
+
* into the write branch and failed there. Inverting the tool predicate removed that accident and
|
|
50
|
+
* three of its tests went red, which is what a guarantee resting on a coincidence looks like.
|
|
51
|
+
* Recording the fact makes it independent of which tool asked.
|
|
52
|
+
*/
|
|
53
|
+
malformed?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function normalizeHookInput(raw: unknown): HookInput;
|
|
56
|
+
/**
|
|
57
|
+
* Which way to fail when the gate itself throws.
|
|
58
|
+
*
|
|
59
|
+
* @implements A-SPEC-144
|
|
60
|
+
* The entry point used to answer `allow` unconditionally, with the comment "to avoid blocking the
|
|
61
|
+
* workflow". The tension is real — an unrelated bug that denies every tool call bricks a session —
|
|
62
|
+
* but resolving it toward a silent grant makes every future bug in the gate a bypass instead of a
|
|
63
|
+
* visible failure, which is the opposite of the posture the rest of this codebase takes.
|
|
64
|
+
*
|
|
65
|
+
* So: a governed project DENIES, because a gate that cannot evaluate must not grant (the same rule
|
|
66
|
+
* REQ-141 applied to the ledger). An ungoverned project ALLOWS, because there was no policy to apply
|
|
67
|
+
* and inventing a denial there is its own failure mode. And an operator — never the session — can
|
|
68
|
+
* escape via `HOLMES_GATE_BYPASS`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function decideOnGateError(opts: {
|
|
71
|
+
governed: boolean;
|
|
72
|
+
bypass: boolean;
|
|
73
|
+
message: string;
|
|
74
|
+
}): {
|
|
75
|
+
permissionDecision: 'allow' | 'deny';
|
|
76
|
+
permissionDecisionReason?: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Is this project governed? Computed on the ERROR path, so it must not re-run the logic that just
|
|
80
|
+
* failed: presence of a configured spec root or a non-empty spec directory, nothing more. Any
|
|
81
|
+
* problem reading it means NOT governed — the conservative direction here is the one that keeps an
|
|
82
|
+
* unrelated failure from bricking a project that never asked for gating.
|
|
83
|
+
*/
|
|
84
|
+
export declare function isGovernedProject(specsDir: string, env?: NodeJS.ProcessEnv): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The whole error-path decision, assembled from an environment.
|
|
87
|
+
*
|
|
88
|
+
* @implements A-SPEC-144
|
|
89
|
+
* Exported and env-injected so the WIRING is tested rather than trusted. Mutation testing found the
|
|
90
|
+
* gap: hardcoding `bypass: true` and inverting the governance check both left every test green,
|
|
91
|
+
* because the tests exercised the pure helpers directly and nothing exercised the code that reads
|
|
92
|
+
* the environment. An untested wire is where a fix quietly stops applying.
|
|
93
|
+
*/
|
|
94
|
+
export declare function gateErrorDecision(message: string, env?: NodeJS.ProcessEnv, argv?: readonly string[], projectRoot?: string): {
|
|
95
|
+
permissionDecision: 'allow' | 'deny';
|
|
96
|
+
permissionDecisionReason?: string;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* @implements A-SPEC-190 (round 8)
|
|
100
|
+
* Which spec directory this hook enforces against. The wiring writes it on the hook's own command
|
|
101
|
+
* line because the hook entry in settings carries no environment: `init --specs-dir docs/specs`
|
|
102
|
+
* reached the MCP server (HOLMES_SPECS in .mcp.json) and NOT the gate, so a project could be wired
|
|
103
|
+
* `governed` while the gate read an empty `.ax/specs` and refused nothing. Precedence is
|
|
104
|
+
* argv > env > default: the wiring is more specific than an ambient variable.
|
|
105
|
+
*/
|
|
106
|
+
export declare function wiredSpecsDir(argv: readonly string[], env: NodeJS.ProcessEnv): string;
|
|
107
|
+
/** Default spec root. Used to tell "never governed" apart from an explicitly configured HOLMES_SPECS. */
|
|
108
|
+
export declare const DEFAULT_SPECS_DIR = ".ax/specs";
|
|
109
|
+
export declare function evaluateHook(input: {
|
|
110
|
+
tool_name: string;
|
|
111
|
+
tool_input: {
|
|
112
|
+
file_path?: string;
|
|
113
|
+
notebook_path?: string;
|
|
114
|
+
content?: string;
|
|
115
|
+
new_string?: string;
|
|
116
|
+
command?: string;
|
|
117
|
+
};
|
|
118
|
+
}, specsDir: string, opts: {
|
|
119
|
+
assess?: (a: RiskAction) => RiskAssessment;
|
|
120
|
+
/**
|
|
121
|
+
* @implements A-SPEC-149
|
|
122
|
+
* REQUIRED, `undefined` allowed explicitly. The old `?? readApprovalFromEnv()` fallback let an
|
|
123
|
+
* exported HOLMES_APPROVAL reach the decision: measured, a root with planted debt produced NO
|
|
124
|
+
* denial because `codeWriteCovered` became true, while the same call under `env -u` denied. That
|
|
125
|
+
* failure mode is worse than the cwd one — it makes tests PASS. The out-of-band channel stays;
|
|
126
|
+
* only the entry point reads it.
|
|
127
|
+
*/
|
|
128
|
+
approval: Approval | undefined;
|
|
129
|
+
resolveRmSignals?: (command: string) => Record<string, RmSignal> | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* @implements A-SPEC-149
|
|
132
|
+
* REQUIRED. The implicit `process.cwd()` fallback is gone: it made the gate read whichever
|
|
133
|
+
* project the process happened to sit in, so a unit test's verdict depended on the repository's
|
|
134
|
+
* own `.ax/state`. Measured — thirteen tests failed deterministically while constitution debt was
|
|
135
|
+
* recorded here, and the code was correct. A missing root is now a compile error, which is the
|
|
136
|
+
* only way the coupling stops being re-introduced by the next caller.
|
|
137
|
+
*/
|
|
138
|
+
projectRoot: string;
|
|
139
|
+
now?: string;
|
|
140
|
+
}): {
|
|
141
|
+
permissionDecision: 'allow' | 'deny';
|
|
142
|
+
permissionDecisionReason?: string;
|
|
143
|
+
};
|
|
144
|
+
export declare function readSpecsSync(root: string): Spec[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Spec } from '../spec/spec-parser';
|
|
2
|
+
/**
|
|
3
|
+
* @implements A-SPEC-100.2
|
|
4
|
+
* Stop-hook governance gate (Phase-2 #1: push, not pull).
|
|
5
|
+
*
|
|
6
|
+
* RTM integrity (rtm_check) and spec correctness (spec_validate) were only reachable as MCP tools
|
|
7
|
+
* the model had to VOLUNTARILY invoke — and across a full benchmark it invoked them 0/143 times. A
|
|
8
|
+
* "deterministic control plane" cannot depend on the LLM choosing to call its own governance. This
|
|
9
|
+
* hook runs those checks at every turn boundary (PUSH): if the governed spec set has unresolved RTM
|
|
10
|
+
* or validation errors, it BLOCKS the stop and feeds the findings back so the model must fix them
|
|
11
|
+
* before finishing. No model cooperation required — the governance fires whether or not it is asked.
|
|
12
|
+
*
|
|
13
|
+
* HARDENING H1 (GWT-실테스트 bridge): declared 4-quadrant coverage previously verified only the
|
|
14
|
+
* T-SPEC DOCUMENT. Now, for every approved T-SPEC, its parent A-SPEC must have at least one anchored
|
|
15
|
+
* TEST FILE containing REAL test cases (`it(`/`test(`) — declared coverage over an empty/absent test
|
|
16
|
+
* suite blocks the stop. Evidence is supplied by the CLI (I/O half); evaluateStop stays pure.
|
|
17
|
+
*
|
|
18
|
+
* Pure and testable: evaluateStop takes the loaded spec set (+ optional evidence); the CLI does I/O.
|
|
19
|
+
*/
|
|
20
|
+
export interface StopEvidence {
|
|
21
|
+
/** A-SPEC id -> number of real test cases (`it(`/`test(`) found in test files anchored to it. */
|
|
22
|
+
testCasesByAspec?: Record<string, number>;
|
|
23
|
+
/** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
|
|
24
|
+
provenance?: {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
brokenAt?: number;
|
|
27
|
+
detail?: string;
|
|
28
|
+
};
|
|
29
|
+
/** A-SPEC -> cases that ACTUALLY EXECUTED in a fresh run; authoritative over the syntactic count. */
|
|
30
|
+
executedByAspec?: Record<string, number>;
|
|
31
|
+
/** @implements A-SPEC-160 — recorded findings, for ART-6. @implements A-SPEC-191 — severity rides along for ART-7. */
|
|
32
|
+
findings?: {
|
|
33
|
+
id: string;
|
|
34
|
+
status: 'open' | 'resolved';
|
|
35
|
+
basis?: string;
|
|
36
|
+
severity?: string;
|
|
37
|
+
summary?: string;
|
|
38
|
+
}[];
|
|
39
|
+
/**
|
|
40
|
+
* @implements A-SPEC-191 (§4a)
|
|
41
|
+
* The findings ledger EXISTS but could not be read (any list() throw — ENOENT is absorbed as an
|
|
42
|
+
* empty list upstream). Measured: swallowing this into `undefined` let an open critical pass AND
|
|
43
|
+
* cleared standing ART-7 debt — an unreadable ledger laundering the very state it records.
|
|
44
|
+
*/
|
|
45
|
+
findingsUnreadable?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* @implements A-SPEC-175
|
|
49
|
+
* The turn-boundary answer to "this project is governed but its specs are gone".
|
|
50
|
+
*
|
|
51
|
+
* Extracted so it is reachable by a test: the runtime block below only runs under
|
|
52
|
+
* `require.main === module`, and a check that lives only there is a check nothing can mutate against.
|
|
53
|
+
* Returns the reason to block with, or null when there is nothing to report.
|
|
54
|
+
*/
|
|
55
|
+
export declare function governanceLostPreflight(specsDir: string, projectRoot?: string): string | null;
|
|
56
|
+
export declare function evaluateStop(specs: Spec[], evidence?: StopEvidence): {
|
|
57
|
+
block: boolean;
|
|
58
|
+
reason?: string;
|
|
59
|
+
articles?: string[];
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* @implements A-SPEC-134
|
|
63
|
+
* Pure decision for the constitution-debt state at a turn boundary. Replaces the Stop gate's silent
|
|
64
|
+
* cap-yield: a clean turn clears any standing debt; a cap-yield (the gate giving up its block after
|
|
65
|
+
* MAX_CONSECUTIVE_BLOCKS) RECORDS the unresolved articles as debt so new code is blocked until the
|
|
66
|
+
* constitution is clean again; a normal block (still within the cap) touches nothing.
|
|
67
|
+
*/
|
|
68
|
+
export declare function stopDebtAction(evaluation: {
|
|
69
|
+
block: boolean;
|
|
70
|
+
articles?: string[];
|
|
71
|
+
}, guard: {
|
|
72
|
+
block: boolean;
|
|
73
|
+
capped: boolean;
|
|
74
|
+
}): {
|
|
75
|
+
action: 'write' | 'clear' | 'none';
|
|
76
|
+
articles: string[];
|
|
77
|
+
};
|
|
78
|
+
export declare const MAX_CONSECUTIVE_BLOCKS = 3;
|
|
79
|
+
export declare function decideStopGuard(wantsBlock: boolean, priorConsecutiveBlocks: number, cap?: number): {
|
|
80
|
+
block: boolean;
|
|
81
|
+
nextCount: number;
|
|
82
|
+
capped: boolean;
|
|
83
|
+
};
|
|
84
|
+
/** Test seam for the same reason as the two exports below — the CLI sets this on a real run. */
|
|
85
|
+
export declare function __setWiredSpecsForTest(v: string | undefined): void;
|
|
86
|
+
export declare function readGuardCount(sessionId: string): number;
|
|
87
|
+
/**
|
|
88
|
+
* Exported for the §19 race discriminator: the persistence layer was the untested half (round-11),
|
|
89
|
+
* and a test that can only reach it through a spawned hook cannot pin what the lock does.
|
|
90
|
+
*/
|
|
91
|
+
export declare function writeGuardCount(sessionId: string, n: number): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a computation was based on.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-08: the live MCP server answered `impactedSpecs: []` for a commit that touched 39
|
|
5
|
+
* specs, while the same handler in a fresh process answered correctly. The server had been running
|
|
6
|
+
* since 8/5 against a 8/8 build. Nothing in the response said which build, which scan generation, or
|
|
7
|
+
* which graph produced it — so a reviewer had no way to distrust it, and `doctor` reported
|
|
8
|
+
* "handshake ok".
|
|
9
|
+
*
|
|
10
|
+
* The fix is not staleness detection. That catches one cause; the next divergence (a graph
|
|
11
|
+
* generation, a spec corpus) slips through the same way. Carrying the basis makes divergence a FACT
|
|
12
|
+
* the caller reads rather than a verdict the server renders.
|
|
13
|
+
*/
|
|
14
|
+
export interface Basis {
|
|
15
|
+
/** The build this process loaded, read once at start-up. `unknown` when unstamped. */
|
|
16
|
+
loadedBuild: string;
|
|
17
|
+
/** What is on disk NOW. Present only in the detail — see `collectBasis`. */
|
|
18
|
+
diskBuild?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Fingerprint of the scan artefact. `undefined` when the axis could not be measured.
|
|
21
|
+
*
|
|
22
|
+
* A fingerprint, not a file count: measured 2026-08-08, `cachedScan` costs 69ms on this repo, and
|
|
23
|
+
* charging that to every one of the tools on every call would break this module's own constant-I/O
|
|
24
|
+
* requirement. A stat over the scan cache costs nothing and changes whenever the scan does, which
|
|
25
|
+
* is all divergence detection needs.
|
|
26
|
+
*/
|
|
27
|
+
scanFp?: number;
|
|
28
|
+
specs?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* There is deliberately NO graph axis. `RtmGraph` is in-memory only (ADR-011 — a derived index,
|
|
32
|
+
* rebuilt every time), so the graph is a FUNCTION of specs and scan and cannot diverge from them
|
|
33
|
+
* independently. Including it would mean either an always-`?` field or a rebuild per call: token
|
|
34
|
+
* cost with no information in the first case, a constant-I/O violation in the second.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Cap on the digest. A basis rides on every one of the tools, so its size multiplies across a
|
|
38
|
+
* session — this bound is what keeps "carry the basis" compatible with REQ-122's requirement that
|
|
39
|
+
* the harness be net-token-positive, rather than opposed to it.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MAX_DIGEST_LEN = 64;
|
|
42
|
+
/**
|
|
43
|
+
* Cap on the build id alone. Truncating the whole digest at the end would eat the trailing axes, so
|
|
44
|
+
* a long build id could make `x:` disappear entirely and the digest would read as if that axis had
|
|
45
|
+
* never existed — the exact silent-omission failure `?` exists to prevent. Bounding the variable
|
|
46
|
+
* part instead keeps the structure intact whatever the stamp looks like.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_BUILD_LEN = 32;
|
|
49
|
+
/**
|
|
50
|
+
* @implements A-SPEC-160
|
|
51
|
+
* Appended when the loaded and on-disk builds are BOTH known and differ.
|
|
52
|
+
*
|
|
53
|
+
* This is an observation, not a verdict, and the distinction matters: REQ-156 deliberately refused to
|
|
54
|
+
* let the server decide whether it was stale, because that logic would be running inside the stale
|
|
55
|
+
* build. Reporting that two measured values disagree is a fact; what to do about it is ART-6's call.
|
|
56
|
+
*
|
|
57
|
+
* An unknown disk build never carries the marker. Reading absence as divergence would fire on every
|
|
58
|
+
* tool but `review_record` — none of the others read disk — and an article that fires constantly is
|
|
59
|
+
* one people route around within a day.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BASIS_DIVERGED = "/!";
|
|
62
|
+
/** Reads the marker back out of a sealed digest. */
|
|
63
|
+
export declare function basisDiverged(digest: string | undefined): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The build identity of the code that is RUNNING. Read once at start-up by the server, never per
|
|
66
|
+
* call: hashing `dist` on every request buys I/O for no new information, and mtime changes on a
|
|
67
|
+
* checkout or a copy, which would manufacture divergence that is not there.
|
|
68
|
+
*
|
|
69
|
+
* An unstamped build is reported as `unknown` rather than guessed. Absence is a fact too, and a
|
|
70
|
+
* fabricated id would be worse than none — it would look like agreement.
|
|
71
|
+
*/
|
|
72
|
+
export declare function loadedBuildId(root: string): string;
|
|
73
|
+
export interface BasisContext {
|
|
74
|
+
scanFp?: () => number;
|
|
75
|
+
specs?: () => number;
|
|
76
|
+
/** Project root, for reading the on-disk build id. Omit to skip that axis. */
|
|
77
|
+
root?: string;
|
|
78
|
+
/** The build this process loaded. Injected so the server reads it exactly once. */
|
|
79
|
+
loadedBuild?: string;
|
|
80
|
+
}
|
|
81
|
+
export declare function collectBasis(ctx: BasisContext): Basis;
|
|
82
|
+
/**
|
|
83
|
+
* `b:<build>/s:<scanFp>/x:<specs>`
|
|
84
|
+
*
|
|
85
|
+
* Short by design: a caller compares the value against another and learns that something diverged
|
|
86
|
+
* without needing to interpret any field. An unmeasured axis renders as `?` rather than being
|
|
87
|
+
* dropped — omitting it would let a reader take "these two matched" for agreement when the third
|
|
88
|
+
* was never measured at all.
|
|
89
|
+
*/
|
|
90
|
+
export declare function basisDigest(b: Basis): string;
|
|
91
|
+
/**
|
|
92
|
+
* Attaches a basis to a handler's result.
|
|
93
|
+
*
|
|
94
|
+
* Exported and pure so the contract is testable without a handler that exercises each branch. The
|
|
95
|
+
* array guard in particular has no live caller today — every tool returns an object — and an
|
|
96
|
+
* untested guard is one a later change deletes for free. Spreading an array into an object yields
|
|
97
|
+
* `{0:…, 1:…, basis}`, silently reshaping a response into something no caller can read, so the
|
|
98
|
+
* branch has to survive on its own merits rather than on nobody having tried it yet.
|
|
99
|
+
*/
|
|
100
|
+
export declare function withBasis<A>(fn: (a: A) => Promise<unknown>, basisOf: (a: A) => Basis): (a: A) => Promise<unknown>;
|