@holmes-lab/holmes-kit 0.3.8 → 0.3.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 CHANGED
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.3.9] - 2026-09-01
9
+
10
+ Scope de-amplification and the push gate — the governance model stops taxing legitimate work
11
+ (one test-class addition used to cost >=3 human interventions) and starts guarding the one
12
+ moment nothing guarded: the push.
13
+
14
+ ### Added
15
+
16
+ - **Tenure/admission scope model** (A-SPEC-508.1): a file already anchored on disk is IN its
17
+ specs' scope — editing it is free and silent (the double registration with `Files to Touch`
18
+ is gone). Only NEW claims (a new file's anchors, a re-anchor) pass admission: an FtT **glob**
19
+ (`src/core/**`, `test_*.py`), the **test-file dispensation** (a new test joining an approved
20
+ spec is coverage growth, which ART-4 asks for), or an out-of-band approval. All disk anchors
21
+ participate (first-match-only is dead), deleting an anchor line warns (RTM loss made audible)
22
+ but never blocks, and a re-anchor refusal names the transfer. Dogfooded: 96+ anchored files
23
+ in this repo, zero tenure regressions (pinned as a permanent sweep test).
24
+ - **pre-push evidence gate** (A-SPEC-509.1): `npx holmes-kit install-push-gate` installs a
25
+ git pre-push hook (honoring `core.hooksPath`; a foreign hook is never overwritten) that
26
+ refuses a push unless the test-evidence ledger vouches for EXACTLY the pushed HEAD — green,
27
+ with real executions. Commit-message success claims never move the verdict; an unbacked claim
28
+ is surfaced as a warning. Collection failures allow (this safens the default; security stays
29
+ with the tool gates), `--no-verify` is respected, and doctor diagnoses a missing hook. Live
30
+ probe on this repo: a stale-ledger push was refused with both heads and the recovery command.
31
+
32
+ ### Fixed
33
+
34
+ - **FtT parent-directory amplification**: a FILE token in Files-to-Touch silently covered its
35
+ whole sibling directory (`src/a/x.ts` admitted all of `src/a/`); a file token now names that
36
+ file only, and directory intent is written as a glob.
37
+
7
38
  <!-- @implements A-SPEC-209 -->
8
39
  ## [0.3.8] - 2026-09-01
9
40
 
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // @implements A-SPEC-509.1
3
+ // HOLMES-KIT PUSH GATE v1 — thin stdin adapter over dist/holmes/governance/push-gate-runner.js.
4
+ // Fail-open by contract (H-SPEC-509): this gate safens the default, it is not a security
5
+ // boundary, so any load or collection failure lets the push proceed.
6
+ try {
7
+ const { runPrePush } = require('../dist/holmes/governance/push-gate-runner.js');
8
+ let text = '';
9
+ process.stdin.setEncoding('utf8');
10
+ process.stdin.on('data', (c) => { text += c; });
11
+ process.stdin.on('end', () => {
12
+ let code = 0;
13
+ try {
14
+ code = runPrePush({ cwd: process.cwd(), stdinText: text, write: (s) => process.stderr.write(s) });
15
+ } catch { code = 0; }
16
+ process.exit(code);
17
+ });
18
+ } catch {
19
+ process.exit(0);
20
+ }
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 809f8c3-mthx43ms
1
+ ab67afc-mti0dz4k
@@ -102,6 +102,12 @@ export declare function runDoctor(packageRoot: string, target?: string, opts?: D
102
102
  * very first run against a healthy server).
103
103
  */
104
104
  export declare function wiringSpawnCheck(command: string, args: string[], timeoutMs?: number, platform?: NodeJS.Platform): Promise<Check>;
105
+ /**
106
+ * Push-gate presence, diagnosed only where it applies: a repo that opted into governance (.ax)
107
+ * AND has git. Absence or a hook without our signature is a WARN carrying the install command —
108
+ * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
109
+ */
110
+ export declare function pushGateCheck(target: string): Check | null;
105
111
  export declare function formatChecks(checks: Check[]): string;
106
112
  /**
107
113
  * Prove that EVERY wired harness can actually start a server, not merely that its file parses.
@@ -40,6 +40,7 @@ exports.prefixVerdict = prefixVerdict;
40
40
  exports.probeEnv = probeEnv;
41
41
  exports.runDoctor = runDoctor;
42
42
  exports.wiringSpawnCheck = wiringSpawnCheck;
43
+ exports.pushGateCheck = pushGateCheck;
43
44
  exports.formatChecks = formatChecks;
44
45
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
45
46
  exports.semanticTierVerdict = semanticTierVerdict;
@@ -972,6 +973,13 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
972
973
  }
973
974
  }
974
975
  add('environment', 'PASS', present);
976
+ // @implements A-SPEC-509.1 — honest diagnosis, not a gate: a governed git repo without our
977
+ // pre-push evidence gate is told so once, with the one command that installs it.
978
+ {
979
+ const pg = pushGateCheck(target ?? process.cwd());
980
+ if (pg)
981
+ checks.push(pg);
982
+ }
975
983
  if (extraChecks) {
976
984
  checks.push(...extraChecks);
977
985
  }
@@ -1227,6 +1235,35 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1227
1235
  send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } });
1228
1236
  });
1229
1237
  }
1238
+ // @implements A-SPEC-509.1
1239
+ /**
1240
+ * Push-gate presence, diagnosed only where it applies: a repo that opted into governance (.ax)
1241
+ * AND has git. Absence or a hook without our signature is a WARN carrying the install command —
1242
+ * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
1243
+ */
1244
+ function pushGateCheck(target) {
1245
+ try {
1246
+ if (!fs.existsSync(path.join(target, '.ax')))
1247
+ return null;
1248
+ if (!fs.existsSync(path.join(target, '.git')))
1249
+ return null;
1250
+ const { PUSH_HOOK_SIGNATURE } = require('../governance/push-gate');
1251
+ const { effectiveHooksDir } = require('../governance/push-gate-runner');
1252
+ // core.hooksPath honored (live-probe finding): diagnose where git actually looks.
1253
+ const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
1254
+ const installed = fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes(PUSH_HOOK_SIGNATURE);
1255
+ return installed
1256
+ ? { name: 'push gate', level: 'PASS', detail: 'pre-push 증빙 게이트가 설치되어 있습니다 (test_run 원장 대조)' }
1257
+ : {
1258
+ name: 'push gate', level: 'WARN',
1259
+ detail: 'pre-push 증빙 게이트가 설치되어 있지 않습니다 — 미검증 HEAD 가 다른 에이전트의 기준선이 될 수 있습니다',
1260
+ fix: 'npx holmes-kit install-push-gate 를 실행하십시오 (기존 훅은 덮지 않습니다)',
1261
+ };
1262
+ }
1263
+ catch {
1264
+ return null;
1265
+ } // diagnosis must never crash doctor
1266
+ }
1230
1267
  function formatChecks(checks) {
1231
1268
  const lines = checks.map((c) => {
1232
1269
  const head = `${c.level.padEnd(4)} ${c.name} — ${c.detail}`;
@@ -80,6 +80,8 @@ const KNOWN_FLAGS = {
80
80
  ledger: ['help', 'target', 'ref', 'dry-run'],
81
81
  // @implements A-SPEC-477 — the human's opt-in act for the cloud semantic tier.
82
82
  'semantic-key': ['help'],
83
+ // @implements A-SPEC-509.1 — the explicit installation act for the pre-push evidence gate.
84
+ 'install-push-gate': ['help', 'target'],
83
85
  };
84
86
  // @implements A-SPEC-171 — subcommands that render their OWN usage on `--help`. A-SPEC-171 governs
85
87
  // "help before any side effect"; a command whose usage lives past this handler (approve, whose
@@ -897,6 +899,40 @@ async function main(argv) {
897
899
  await runServeCommand({ root: target, port });
898
900
  return 0;
899
901
  }
902
+ // @implements A-SPEC-509.1 — installing the pre-push evidence gate is an EXPLICIT operator act:
903
+ // holmes-kit never rewires a repository's git hooks as a side effect, and it never overwrites a
904
+ // hook it did not write (a foreign pre-push is refused byte-intact, with manual-merge guidance).
905
+ if (cmd === 'install-push-gate') {
906
+ const target = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
907
+ if (!fs.existsSync(path.join(target, '.git'))) {
908
+ process.stderr.write(`✗ ${target} 는 git 저장소가 아닙니다 — pre-push 게이트는 .git/hooks 에 설치됩니다\n`);
909
+ return 1;
910
+ }
911
+ const { PUSH_HOOK_SIGNATURE } = require('../governance/push-gate');
912
+ // Live-probe finding (2026-09-01, this repo): `core.hooksPath` redirects git away from
913
+ // `.git/hooks`, so installing there is a SILENT no-op — wiring presence is not enforcement.
914
+ // Install where git actually looks, honoring the operator's config instead of editing it.
915
+ const { effectiveHooksDir } = require('../governance/push-gate-runner');
916
+ const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
917
+ // dist/holmes/cli → three up is the package root, the same derivation server.ts uses.
918
+ const runner = path.join(path.resolve(__dirname, '..', '..', '..'), 'bin', 'holmes-pre-push.js');
919
+ const script = `#!/usr/bin/env node\n// ${PUSH_HOOK_SIGNATURE} — \`npx holmes-kit install-push-gate\` 가 설치했습니다.\n`
920
+ + `// test-evidence 원장과 push HEAD 를 대조합니다. 로드 실패는 통과(fail-open) — 게이트 오류가 push 를 막지 않습니다.\n`
921
+ + `try { require(${JSON.stringify(runner)}); } catch { process.exit(0); }\n`;
922
+ if (fs.existsSync(hookPath)) {
923
+ const current = fs.readFileSync(hookPath, 'utf8');
924
+ if (!current.includes(PUSH_HOOK_SIGNATURE)) {
925
+ process.stderr.write(`✗ 기존 pre-push 훅이 있고 holmes-kit 이 쓴 것이 아닙니다 — 덮지 않습니다.\n`
926
+ + ` 수동 병합: 기존 훅 끝에 \`node ${runner} || exit 1\` 형태로 이 게이트를 이어 붙이십시오.\n`);
927
+ return 1;
928
+ }
929
+ }
930
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
931
+ fs.writeFileSync(hookPath, script);
932
+ fs.chmodSync(hookPath, 0o755);
933
+ process.stdout.write(`✓ pre-push 증빙 게이트 설치 — ${hookPath}\n 통과 조건: test_run 원장 head == push HEAD, 초록, 실행 > 0. 우회: git push --no-verify\n`);
934
+ return 0;
935
+ }
900
936
  if (cmd === 'init') {
901
937
  const mode = (typeof flags.mode === 'string' ? flags.mode : 'guardrail');
902
938
  // @implements A-SPEC-251.1 — an unknown launch mode is refused, not defaulted: substituting a
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where git ACTUALLY looks for hooks. Live probe (2026-09-01): this repo carries
3
+ * `core.hooksPath=.git/hooks-disabled`, so a hook written to `.git/hooks` never ran and the
4
+ * install success message was a lie — presence is not enforcement. A relative hooksPath is
5
+ * resolved against the repo root, matching git's own semantics.
6
+ */
7
+ export declare function effectiveHooksDir(repoRoot: string): string;
8
+ export interface PrePushIo {
9
+ cwd: string;
10
+ stdinText: string;
11
+ /** Refusals and notices land here (stderr in the shipped hook). */
12
+ write: (s: string) => void;
13
+ }
14
+ /** Exit code of the hook: 0 = push proceeds, 1 = refused. */
15
+ export declare function runPrePush(io: PrePushIo): 0 | 1;
@@ -0,0 +1,101 @@
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.effectiveHooksDir = effectiveHooksDir;
37
+ exports.runPrePush = runPrePush;
38
+ // @implements A-SPEC-509.1
39
+ /**
40
+ * The push gate's IMPURE half: collect (stdin lines, git facts, the evidence ledger) and hand
41
+ * everything to the pure judgePush. Any collection failure ALLOWS — this gate safens the default
42
+ * and must never be the thing that bricks a push (security stays with the tool gates).
43
+ */
44
+ const fs = __importStar(require("node:fs"));
45
+ const path = __importStar(require("node:path"));
46
+ const node_child_process_1 = require("node:child_process");
47
+ const push_gate_1 = require("./push-gate");
48
+ const test_evidence_1 = require("../review/test-evidence");
49
+ /**
50
+ * Where git ACTUALLY looks for hooks. Live probe (2026-09-01): this repo carries
51
+ * `core.hooksPath=.git/hooks-disabled`, so a hook written to `.git/hooks` never ran and the
52
+ * install success message was a lie — presence is not enforcement. A relative hooksPath is
53
+ * resolved against the repo root, matching git's own semantics.
54
+ */
55
+ function effectiveHooksDir(repoRoot) {
56
+ try {
57
+ const p = (0, node_child_process_1.execFileSync)('git', ['config', 'core.hooksPath'], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
58
+ if (p)
59
+ return path.isAbsolute(p) ? p : path.join(repoRoot, p);
60
+ }
61
+ catch { /* unset config exits 1 — the default location applies */ }
62
+ return path.join(repoRoot, '.git', 'hooks');
63
+ }
64
+ /** Exit code of the hook: 0 = push proceeds, 1 = refused. */
65
+ function runPrePush(io) {
66
+ try {
67
+ const lines = (0, push_gate_1.parsePushLines)(io.stdinText);
68
+ if (lines.length === 0)
69
+ return 0; // deletions / nothing to judge
70
+ const hasGovernance = fs.existsSync(path.join(io.cwd, '.ax'));
71
+ if (!hasGovernance)
72
+ return 0; // un-adopted: silent
73
+ const evidence = (0, test_evidence_1.readTestEvidence)(io.cwd) ?? null;
74
+ let refused = false;
75
+ for (const line of lines) {
76
+ let claimCommits = [];
77
+ try {
78
+ const range = /^0{40}$/.test(line.remoteSha)
79
+ ? ['-n', '20', line.localSha] // new branch: a bounded recent window
80
+ : [`${line.remoteSha}..${line.localSha}`];
81
+ claimCommits = (0, node_child_process_1.execFileSync)('git', ['log', '--format=%H%x09%s', ...range], { cwd: io.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
82
+ .split('\n').filter(Boolean).map((l) => {
83
+ const [sha, ...rest] = l.split('\t');
84
+ return { sha, subject: rest.join('\t') };
85
+ });
86
+ }
87
+ catch { /* claim surfacing is optional — judgment inputs stay deterministic */ }
88
+ const v = (0, push_gate_1.judgePush)({ pushedHead: line.localSha, evidence, hasGovernance, claimCommits });
89
+ for (const n of v.notices)
90
+ io.write(n + '\n');
91
+ if (!v.allow) {
92
+ io.write((v.reason ?? '[Holmes-Kit push 게이트] 거부') + '\n');
93
+ refused = true;
94
+ }
95
+ }
96
+ return refused ? 1 : 0;
97
+ }
98
+ catch {
99
+ return 0; // fail-open by contract (H-SPEC-509)
100
+ }
101
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The push gate's PURE judgment. A push is the moment a commit becomes other agents' baseline
3
+ * (field basis: a multi-agent regression reached production unguarded), so the default is made
4
+ * safe: the test-evidence ledger must vouch for EXACTLY the head being pushed, green, with real
5
+ * executions. Deterministic facts only — commit-message prose ("전체 스위트 초록") never moves
6
+ * the verdict; an unbacked claim is SURFACED as a notice so the dishonesty is audible.
7
+ *
8
+ * Deliberately NOT a security boundary: collection failures and un-adopted repos pass (the tool
9
+ * gates own security). And `git push --no-verify` is git's own escape hatch — respected, not
10
+ * re-litigated: this gate safens the default, it does not take the human's final say.
11
+ */
12
+ import type { TestEvidence } from '../review/test-evidence';
13
+ /** Identifies OUR installed hook — the single truth doctor and re-install both key on. */
14
+ export declare const PUSH_HOOK_SIGNATURE = "HOLMES-KIT PUSH GATE v1";
15
+ export interface PushJudgeInput {
16
+ pushedHead: string;
17
+ evidence: TestEvidence | null;
18
+ /** `.ax` exists — the repo opted into governance. */
19
+ hasGovernance: boolean;
20
+ /** Commits in the pushed range, for the claim cross-check (surfacing only). */
21
+ claimCommits: Array<{
22
+ sha: string;
23
+ subject: string;
24
+ }>;
25
+ }
26
+ export interface PushVerdict {
27
+ allow: boolean;
28
+ reason?: string;
29
+ notices: string[];
30
+ }
31
+ export declare function judgePush(input: PushJudgeInput): PushVerdict;
32
+ /**
33
+ * The pre-push stdin format: `<local ref> <local sha> <remote ref> <remote sha>` per line.
34
+ * A deletion push (all-zero local sha) deletes a remote ref — there is nothing to vouch for.
35
+ */
36
+ export declare function parsePushLines(stdinText: string): Array<{
37
+ localSha: string;
38
+ remoteSha: string;
39
+ }>;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PUSH_HOOK_SIGNATURE = void 0;
4
+ exports.judgePush = judgePush;
5
+ exports.parsePushLines = parsePushLines;
6
+ /** Identifies OUR installed hook — the single truth doctor and re-install both key on. */
7
+ exports.PUSH_HOOK_SIGNATURE = 'HOLMES-KIT PUSH GATE v1';
8
+ /** Prose that CLAIMS a test success — a surfacing pattern, never a judgment input. */
9
+ const SUCCESS_CLAIM_RE = /초록|green|pass(es|ed)?\b|✅/i;
10
+ // @implements A-SPEC-509.1
11
+ function judgePush(input) {
12
+ if (!input.hasGovernance)
13
+ return { allow: true, notices: [] }; // un-adopted: never coerce adoption
14
+ const notices = [];
15
+ const ev = input.evidence;
16
+ // Claim cross-check first — it rides allow AND deny alike.
17
+ for (const c of input.claimCommits) {
18
+ if (!SUCCESS_CLAIM_RE.test(c.subject))
19
+ continue;
20
+ if (ev && ev.head === c.sha)
21
+ continue; // the ledger backs this claim
22
+ notices.push(`[Holmes-Kit] 커밋 ${c.sha.slice(0, 7)}의 메시지가 테스트 성공을 주장하지만 test_run 원장이 그 커밋을 뒷받침하지 않습니다: "${flatSubject(c.subject)}"`);
23
+ }
24
+ if (!ev) {
25
+ // Adopted but no ledger yet: pass honestly, say how to activate. A broken ledger folds here
26
+ // too — the gate is a default-safener, not a security boundary, so it must not dead-lock a
27
+ // repo whose ledger file was corrupted.
28
+ notices.push('[Holmes-Kit] test-evidence 원장이 없어 push 증빙 대조를 건너뜁니다 — test_run을 실행하면 다음 push부터 대조가 활성화됩니다');
29
+ return { allow: true, notices };
30
+ }
31
+ const executed = Object.values(ev.executedByAspec).reduce((a, b) => a + b, 0);
32
+ if (ev.head === input.pushedHead && ev.passed === true && executed > 0) {
33
+ return { allow: true, notices };
34
+ }
35
+ const why = ev.head !== input.pushedHead
36
+ ? `원장 head ${ev.head.slice(0, 7)} ≠ push head ${input.pushedHead.slice(0, 7)} — 증빙 이후 커밋이 있습니다`
37
+ : ev.passed !== true
38
+ ? `원장이 붉은 실행(passed: false)입니다 — 붉은 스위트는 기준선이 될 수 없습니다`
39
+ : `원장이 아무것도 실행하지 않았습니다(executed 0) — no-op 초록은 증빙이 아닙니다`;
40
+ return {
41
+ allow: false,
42
+ reason: `[Holmes-Kit push 게이트] ${why}. 복구: 현재 HEAD에서 test_run(또는 전체 스위트)을 초록으로 실행해 원장을 갱신한 뒤 다시 push하십시오. 의도적 우회는 git push --no-verify.`,
43
+ notices,
44
+ };
45
+ }
46
+ /** One control-character-free line of a commit subject for interpolation into gate text. */
47
+ function flatSubject(s) {
48
+ return s.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 80);
49
+ }
50
+ /**
51
+ * The pre-push stdin format: `<local ref> <local sha> <remote ref> <remote sha>` per line.
52
+ * A deletion push (all-zero local sha) deletes a remote ref — there is nothing to vouch for.
53
+ */
54
+ function parsePushLines(stdinText) {
55
+ const out = [];
56
+ for (const line of stdinText.split('\n')) {
57
+ const parts = line.trim().split(/\s+/);
58
+ if (parts.length !== 4)
59
+ continue;
60
+ const [, localSha, , remoteSha] = parts;
61
+ if (!/^[0-9a-f]{40}$/.test(localSha))
62
+ continue;
63
+ if (/^0{40}$/.test(localSha))
64
+ continue; // deletion: not judged
65
+ out.push({ localSha, remoteSha });
66
+ }
67
+ return out;
68
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The de-amplified scope judgment — PURE (no fs, no git; the hook gathers, this decides).
3
+ *
4
+ * Field basis (2026-08-23, jarvis): one test-class addition to an ALREADY-ANCHORED file cost
5
+ * >=3 human interventions, because the old model re-checked Files-to-Touch on every edit of an
6
+ * anchored file (double registration: the anchor said "this file belongs to X" and FtT had to
7
+ * say it again), while its matcher quietly widened every FILE token to the file's whole parent
8
+ * directory (`src/a/x.ts` covered all of `src/a/`). Both directions were amplification.
9
+ *
10
+ * The model here splits TENURE from ADMISSION:
11
+ * - TENURE: a file already anchored ON DISK is in its specs' scope — editing it is free. The
12
+ * entitlement comes from the fact that the file passed admission when it first joined (gated
13
+ * writes are the only way anchors legitimately reach disk), or was deliberately grandfathered
14
+ * at adoption (REQ-508's explicit acceptance of the 252-file reality).
15
+ * - ADMISSION: joining a file to a spec — a new file's payload anchors, or a re-anchor bringing
16
+ * an id the disk does not carry — must pass a gate: an FtT glob match, or the test-file
17
+ * dispensation (coverage growth is what ART-4 ASKS for), or the caller's out-of-band
18
+ * code-write approval (judged by the hook, not here).
19
+ *
20
+ * Anchor DELETION never blocks (un-anchoring may be legitimate refactoring) but is surfaced as
21
+ * a warning — silent RTM loss is the failure mode C12 taught us to fear.
22
+ */
23
+ /** Repo test conventions — the single census every dispensation consumer shares. */
24
+ export declare function isTestPath(relPath: string): boolean;
25
+ /**
26
+ * One FtT token against one repo-relative path. `**` crosses separators, `*` does not; a literal
27
+ * token with an extension names EXACTLY that file (the parent-directory widening is gone); a
28
+ * literal directory token covers its subtree. The regex build is linear — each glob char maps to
29
+ * one bounded fragment, so no ReDoS surface.
30
+ */
31
+ export declare function matchesFtt(token: string, relPath: string): boolean;
32
+ /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
33
+ export declare function fttPathTokens(fttText: string): string[];
34
+ export interface ScopeInput {
35
+ relPath: string;
36
+ isNewFile: boolean;
37
+ /** All @implements A-SPEC ids found ON DISK (string-literal-stripped) — the full set, not the first. */
38
+ diskAnchorIds: string[];
39
+ /** All A-SPEC ids the write payload carries (content / new_string, stripped). */
40
+ payloadAnchorIds: string[];
41
+ approvedIds: ReadonlySet<string>;
42
+ fttTokensOf: (id: string) => string[];
43
+ /** Disk anchors this edit deliberately removes (hook-computed: anchors(old)−anchors(new)). */
44
+ removedAnchorIds?: string[];
45
+ }
46
+ export interface ScopeVerdict {
47
+ decision: 'in-scope' | 'admission-required';
48
+ failedAdmissions: Array<{
49
+ id: string;
50
+ reason: string;
51
+ }>;
52
+ warnings: string[];
53
+ }
54
+ export declare function judgeScope(input: ScopeInput): ScopeVerdict;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ // @implements A-SPEC-508.1
3
+ /**
4
+ * The de-amplified scope judgment — PURE (no fs, no git; the hook gathers, this decides).
5
+ *
6
+ * Field basis (2026-08-23, jarvis): one test-class addition to an ALREADY-ANCHORED file cost
7
+ * >=3 human interventions, because the old model re-checked Files-to-Touch on every edit of an
8
+ * anchored file (double registration: the anchor said "this file belongs to X" and FtT had to
9
+ * say it again), while its matcher quietly widened every FILE token to the file's whole parent
10
+ * directory (`src/a/x.ts` covered all of `src/a/`). Both directions were amplification.
11
+ *
12
+ * The model here splits TENURE from ADMISSION:
13
+ * - TENURE: a file already anchored ON DISK is in its specs' scope — editing it is free. The
14
+ * entitlement comes from the fact that the file passed admission when it first joined (gated
15
+ * writes are the only way anchors legitimately reach disk), or was deliberately grandfathered
16
+ * at adoption (REQ-508's explicit acceptance of the 252-file reality).
17
+ * - ADMISSION: joining a file to a spec — a new file's payload anchors, or a re-anchor bringing
18
+ * an id the disk does not carry — must pass a gate: an FtT glob match, or the test-file
19
+ * dispensation (coverage growth is what ART-4 ASKS for), or the caller's out-of-band
20
+ * code-write approval (judged by the hook, not here).
21
+ *
22
+ * Anchor DELETION never blocks (un-anchoring may be legitimate refactoring) but is surfaced as
23
+ * a warning — silent RTM loss is the failure mode C12 taught us to fear.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.isTestPath = isTestPath;
27
+ exports.matchesFtt = matchesFtt;
28
+ exports.fttPathTokens = fttPathTokens;
29
+ exports.judgeScope = judgeScope;
30
+ /** Repo test conventions — the single census every dispensation consumer shares. */
31
+ function isTestPath(relPath) {
32
+ const base = relPath.split('/').at(-1) ?? '';
33
+ if (/\.(test|spec)\.[^.]+$/.test(base))
34
+ return true; // x.test.ts / x.spec.tsx
35
+ if (/^test_[^/]*\.py$/.test(base))
36
+ return true; // pytest collection default
37
+ if (/_test\.(py|go)$/.test(base))
38
+ return true; // pytest alt / go test
39
+ const segs = relPath.split('/').slice(0, -1);
40
+ return segs.some((s) => s === 'tests' || s === '__tests__' || s === 'test');
41
+ }
42
+ /**
43
+ * One FtT token against one repo-relative path. `**` crosses separators, `*` does not; a literal
44
+ * token with an extension names EXACTLY that file (the parent-directory widening is gone); a
45
+ * literal directory token covers its subtree. The regex build is linear — each glob char maps to
46
+ * one bounded fragment, so no ReDoS surface.
47
+ */
48
+ function matchesFtt(token, relPath) {
49
+ const tok = token.replace(/\/+$/, '');
50
+ if (tok === '')
51
+ return false;
52
+ if (tok.includes('*')) {
53
+ const rx = tok.split(/(\*\*|\*)/).map((part) => {
54
+ if (part === '**')
55
+ return '.*';
56
+ if (part === '*')
57
+ return '[^/]*';
58
+ return part.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
59
+ }).join('');
60
+ return new RegExp(`^${rx}$`).test(relPath);
61
+ }
62
+ if (/\.[A-Za-z0-9]+$/.test(tok))
63
+ return relPath === tok; // file literal: that file only
64
+ return relPath === tok || relPath.startsWith(tok + '/'); // dir literal: its subtree
65
+ }
66
+ /** Concrete path-like tokens of a Files-to-Touch section (globs included) — prose yields none. */
67
+ function fttPathTokens(fttText) {
68
+ return fttText.match(/[\w@.*-]+(?:\/[\w@.*-]+)+/g) ?? [];
69
+ }
70
+ // @implements A-SPEC-508.1
71
+ function judgeScope(input) {
72
+ const disk = new Set(input.diskAnchorIds);
73
+ const warnings = (input.removedAnchorIds ?? [])
74
+ .filter((id) => disk.has(id))
75
+ .map((id) => `이 편집은 ${id} 앵커를 제거합니다 — RTM 추적 연결이 끊깁니다(커버리지·그래프·게이트가 이 파일을 더는 ${id}에 귀속시키지 않음). 의도한 재구성이면 진행하고, 아니면 앵커를 보존하십시오`);
76
+ // NEW CLAIMS are the only thing admission ever judges: a new file's anchors, or a re-anchor
77
+ // bringing an id the disk does not carry. An existing file that claims nothing new is in scope
78
+ // by tenure — including the vacuous cases (no anchors at all, draft-only anchors): the gates
79
+ // that own those questions (No-Spec-No-Code, stale-seal) run on their own lanes.
80
+ const newClaims = input.payloadAnchorIds.filter((id) => !disk.has(id));
81
+ if (!input.isNewFile && newClaims.length === 0) {
82
+ return { decision: 'in-scope', failedAdmissions: [], warnings };
83
+ }
84
+ const claims = input.isNewFile ? input.payloadAnchorIds : newClaims;
85
+ const failedAdmissions = [];
86
+ for (const id of claims) {
87
+ const tokens = input.fttTokensOf(id);
88
+ if (tokens.length === 0)
89
+ continue; // prose-only imposes nothing
90
+ if (tokens.some((t) => matchesFtt(t, input.relPath)))
91
+ continue; // FtT glob admits
92
+ if (isTestPath(input.relPath) && input.approvedIds.has(id))
93
+ continue; // coverage dispensation
94
+ failedAdmissions.push({
95
+ id,
96
+ reason: input.isNewFile
97
+ ? `${id}의 'Files to Touch' 밖이고 테스트 특례에도 해당하지 않습니다`
98
+ : `재앵커(이관): ${id}는 이 파일의 디스크 앵커가 아니며, 'Files to Touch' 관문과 테스트 특례 모두 통과하지 못했습니다`,
99
+ });
100
+ }
101
+ return {
102
+ decision: failedAdmissions.length > 0 ? 'admission-required' : 'in-scope',
103
+ failedAdmissions,
104
+ warnings,
105
+ };
106
+ }
@@ -138,6 +138,7 @@ export declare function evaluateHook(input: {
138
138
  file_path?: string;
139
139
  notebook_path?: string;
140
140
  content?: string;
141
+ old_string?: string;
141
142
  new_string?: string;
142
143
  command?: string;
143
144
  };
@@ -1218,11 +1218,10 @@ function evaluateHook(input, specsDir, opts) {
1218
1218
  const payloadAnchor = ANCHOR_RE.exec([input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n'));
1219
1219
  const diskAnchor = ANCHOR_RE.exec(onDiskContent);
1220
1220
  const m = diskAnchor ?? payloadAnchor;
1221
- // RE-ANCHORING must be scope-checked against the NEW spec (review C6: on-disk precedence meant an
1222
- // edit that rewrites the anchor line was judged under the OLD spec, so a file could be re-attributed
1223
- // to any spec without that spec's Files-to-Touch ever being consulted which then mis-credits
1224
- // coverage evidence). Both the current and the incoming anchor must accept the path.
1225
- const anchorsToScope = [m?.[1], payloadAnchor?.[1] !== m?.[1] ? payloadAnchor?.[1] : undefined].filter(Boolean);
1221
+ // RE-ANCHORING scope (review C6) moved into judgeScope below (S-508.1): every payload anchor the
1222
+ // disk does not carry is an ADMISSION, judged against the incoming spec's gate the C6 property
1223
+ // (a rewrite of the anchor line is judged under the NEW spec) is preserved there, now for the
1224
+ // FULL anchor set instead of the first match.
1226
1225
  // @implements A-SPEC-132
1227
1226
  // Stale-aware gate (rev.1): a target A-SPEC whose seal is PRESENT and BROKEN (edited after
1228
1227
  // approval) or STALE (a parent moved) is not a trustworthy approval, so code must not be written
@@ -1307,33 +1306,46 @@ function evaluateHook(input, specsDir, opts) {
1307
1306
  + (lost ? `\n${governance_history_1.GOVERNANCE_LOST_HINT}` : ''),
1308
1307
  };
1309
1308
  }
1310
- // Marker self-attestation mitigation: an @implements anchor is a claim the writer makes. When the
1311
- // anchored A-SPEC's `Files to Touch` names CONCRETE paths, the file must fall under one of them —
1312
- // so anchoring evil.ts (or an unrelated edit) to a random approved spec fails when that spec scoped
1313
- // its files. Applies to EVERY governed code Write/Edit, not just new files: the earlier new-file-only
1314
- // scoping left "create it some other way, then edit freely" open, and a stale anchor blessed
1315
- // unrelated edits forever. The legitimate flow is to extend the spec's Files-to-Touch FIRST
1316
- // (editing an approved spec's sections is allowed; only the approval transition is gated).
1317
- // Still bounded: only when concrete paths are listed (a prose/TODO section imposes nothing), and a
1318
- // valid out-of-band approval overrides. Verified 0/93 violations across this repo's anchored files.
1319
- for (const anchorId of anchorsToScope) {
1320
- const aspec = specs.find((s) => s.id === anchorId);
1321
- const ftt = aspec?.sections['Files to Touch'] ?? '';
1322
- const listed = ftt.match(/[\w@.-]+(?:\/[\w@.*-]+)+/g) ?? []; // concrete path-like tokens only
1323
- if (listed.length === 0)
1324
- continue; // prose-only section imposes nothing
1325
- const rel = relPath;
1326
- const covered = listed.some((t) => {
1327
- const tok = t.replace(/\/?\*+$/, ''); // dir/** -> dir
1328
- const dir = /\.[A-Za-z0-9]+$/.test(tok) ? tok.replace(/\/[^/]*$/, '') : tok; // file -> its dir
1329
- return rel === tok || rel.startsWith(tok + '/') || rel.startsWith(dir + '/');
1330
- });
1331
- if (!covered && !codeWriteCovered) { // @implements A-SPEC-133 — same code-write coverage as the stale gate
1332
- return {
1333
- permissionDecision: 'deny',
1334
- permissionDecisionReason: `[Holmes-Kit] ${rel} is anchored to ${anchorId} but falls outside its 'Files to Touch' scope — extend that section first (spec edit is allowed) or supply out-of-band approval`,
1335
- };
1336
- }
1309
+ // @implements A-SPEC-508.1
1310
+ // De-amplified scope: TENURE vs ADMISSION (S-508.1 replaced the per-edit FtT re-check that made
1311
+ // one test-class addition cost >=3 human interventions, and the file-token→parent-directory
1312
+ // widening that quietly covered whole sibling trees). A file already anchored on disk is in its
1313
+ // specs' scope editing it is free and silent. Only NEW CLAIMS (a new file's anchors, or a
1314
+ // re-anchor bringing an id the disk does not carry) pass admission: FtT glob, or the test-file
1315
+ // dispensation (ART-4 asks for coverage the gate must not refuse it), or the out-of-band
1316
+ // code-write approval. Anchor deletion never blocks but rides the allow as a warning silent
1317
+ // RTM loss is the C12 failure mode, made audible.
1318
+ const { judgeScope, fttPathTokens: fttTokens } = require('../guardrail/scope-judgment');
1319
+ const { anchorSpecIds, stripStringLiterals } = require('../rtm/anchor-ids');
1320
+ const oldStr = input.tool_input.old_string ?? '';
1321
+ const newPayload = [input.tool_input.content ?? '', input.tool_input.new_string ?? ''].join('\n');
1322
+ // Deletion intent: a WRITE replaces the whole file, so disk→content is the comparison; an EDIT
1323
+ // only proves a deletion when its old_string carried the anchor (a fragment NOT containing an
1324
+ // anchor says nothing about it — no evidence, no warning). Discriminate by TOOL NAME, not by
1325
+ // which payload fields exist: the antigravity adapter aliases `content` alongside `new_string`
1326
+ // for its fragment-replace tool, and field-based detection read that as a whole-file wipe of
1327
+ // every disk anchor (14 false warnings, caught by the A-SPEC-338 spawn-parity pin).
1328
+ const isWholeFileWrite = input.tool_name.toLowerCase() === 'write';
1329
+ const beforeIds = anchorSpecIds(stripStringLiterals(isWholeFileWrite ? onDiskContent : oldStr));
1330
+ const afterIds = new Set(anchorSpecIds(stripStringLiterals(newPayload)));
1331
+ const scopeVerdict = judgeScope({
1332
+ relPath,
1333
+ isNewFile: onDiskContent === '' && !fs.existsSync(p),
1334
+ diskAnchorIds: anchorSpecIds(stripStringLiterals(onDiskContent)),
1335
+ payloadAnchorIds: anchorSpecIds(stripStringLiterals(newPayload)),
1336
+ approvedIds: new Set(specs.filter((s) => s.status === 'approved').map((s) => s.id)),
1337
+ fttTokensOf: (id) => fttTokens(specs.find((s) => s.id === id)?.sections['Files to Touch'] ?? ''),
1338
+ removedAnchorIds: beforeIds.filter((id) => !afterIds.has(id)),
1339
+ });
1340
+ const scopeWarnings = scopeVerdict.warnings.map((w) => `[Holmes-Kit] ${w}`).join('\n');
1341
+ if (scopeVerdict.decision === 'admission-required' && !codeWriteCovered) { // @implements A-SPEC-133 — same code-write coverage as the stale gate
1342
+ const failed = scopeVerdict.failedAdmissions
1343
+ .map((f) => `${relPath} is anchored to ${f.id} but falls outside its 'Files to Touch' scope (${f.reason})`).join('; ');
1344
+ return {
1345
+ permissionDecision: 'deny',
1346
+ permissionDecisionReason: `[Holmes-Kit] ${failed} — extend that section first (spec edit is allowed), add it as a test under the anchored spec, or supply out-of-band approval`
1347
+ + (scopeWarnings ? `\n${scopeWarnings}` : ''),
1348
+ };
1337
1349
  }
1338
1350
  // @implements A-SPEC-278
1339
1351
  // Pre-edit impact evidence, deliberately the LAST gate on this path.
@@ -1361,9 +1373,19 @@ function evaluateHook(input, specsDir, opts) {
1361
1373
  declaredDigest: process.env.HOLMES_EVIDENCE?.trim() || null,
1362
1374
  currentHead: evidenceHead,
1363
1375
  });
1364
- if (evidenceDecision)
1376
+ if (evidenceDecision) {
1377
+ // @implements A-SPEC-508.1 — an allow-with-reason from the evidence gate must not swallow
1378
+ // the anchor-deletion warning: both ride the same reason field, newline-joined.
1379
+ if (scopeWarnings && evidenceDecision.permissionDecision === 'allow') {
1380
+ return { ...evidenceDecision, permissionDecisionReason: [evidenceDecision.permissionDecisionReason, scopeWarnings].filter(Boolean).join('\n') };
1381
+ }
1365
1382
  return evidenceDecision;
1383
+ }
1366
1384
  }
1385
+ // @implements A-SPEC-508.1 — anchor-deletion warnings ride the allow (allow-with-reason, the
1386
+ // same channel A-SPEC-278 uses): the write proceeds, but the RTM loss is on the record.
1387
+ if (scopeWarnings)
1388
+ return { permissionDecision: 'allow', permissionDecisionReason: scopeWarnings };
1367
1389
  return { permissionDecision: 'allow' };
1368
1390
  }
1369
1391
  // Synchronous spec loader for hook context (fail-open: directory read errors are skipped)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.3.8",
4
+ "version": "0.3.9",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",