@holmes-lab/holmes-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeTrustScore = computeTrustScore;
4
+ function computeTrustScore(m) {
5
+ // Malformed metrics fail SAFE to observe-only — trust must never be granted on unreadable data.
6
+ const nums = [m.slicesCompleted, m.suiteGreenRate, m.guardrailDenials, m.faults, m.regressions, m.criticalFindings];
7
+ if (nums.some((n) => typeof n !== 'number' || Number.isNaN(n) || n < 0) || m.suiteGreenRate > 1) {
8
+ return { score: 0, level: 'L0', rationale: 'malformed metrics — trust cannot be assessed (fail-safe to observe-only)' };
9
+ }
10
+ // Base: earned by volume of green work, saturating (12 slices ≈ full credit), scaled by green rate.
11
+ const earned = Math.min(1, m.slicesCompleted / 12) * m.suiteGreenRate;
12
+ // Penalties: multiplicative decay — each incident class independently shrinks trust.
13
+ const penalty = Math.pow(0.5, m.faults) *
14
+ Math.pow(0.6, m.regressions) *
15
+ Math.pow(0.7, m.criticalFindings) *
16
+ Math.pow(0.9, m.guardrailDenials);
17
+ const score = Math.round(earned * penalty * 1000) / 1000;
18
+ // Level from score, with HARD CAPS: fresh faults/regressions cap at L1; criticals cap at L2.
19
+ let level = score >= 0.85 ? 'L4' : score >= 0.65 ? 'L3' : score >= 0.35 ? 'L2' : score >= 0.15 ? 'L1' : 'L0';
20
+ if (m.faults > 0 || m.regressions > 0)
21
+ level = capLevel(level, 'L1');
22
+ else if (m.criticalFindings > 0)
23
+ level = capLevel(level, 'L2');
24
+ if (m.slicesCompleted === 0)
25
+ level = capLevel(level, 'L2'); // no track record -> never above default-gated
26
+ return { score, level, rationale: rationaleFor(m, score, level) };
27
+ }
28
+ const ORDER = ['L0', 'L1', 'L2', 'L3', 'L4'];
29
+ function capLevel(l, cap) {
30
+ return ORDER.indexOf(l) > ORDER.indexOf(cap) ? cap : l;
31
+ }
32
+ function rationaleFor(m, score, level) {
33
+ const parts = [`score=${score}`, `slices=${m.slicesCompleted}`, `green=${m.suiteGreenRate}`];
34
+ if (m.faults)
35
+ parts.push(`faults=${m.faults} (caps L1)`);
36
+ if (m.regressions)
37
+ parts.push(`regressions=${m.regressions} (caps L1)`);
38
+ if (m.criticalFindings)
39
+ parts.push(`criticals=${m.criticalFindings} (caps L2)`);
40
+ if (m.guardrailDenials)
41
+ parts.push(`denials=${m.guardrailDenials}`);
42
+ return `${level}: ${parts.join(', ')} — hard-hitl always gates regardless of level`;
43
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claimedAnchors = claimedAnchors;
4
+ exports.newlyClaimed = newlyClaimed;
5
+ // @implements A-SPEC-166
6
+ /**
7
+ * Which specs a piece of content CLAIMS to implement.
8
+ *
9
+ * Measured 2026-08-08: writing `@implements A-SPEC-163` — an unapproved spec — into an EXISTING file
10
+ * was allowed, while the identical content in a new file was denied. The gate asked whether the FILE
11
+ * carried an approved anchor, never what the CHANGE was claiming, so No-Spec-No-Code held per file
12
+ * and not per change. Most real work edits existing files.
13
+ *
14
+ * Whether a change falls within its spec's scope is a natural-language judgement, and this harness
15
+ * keeps LLM judgement out of governance. What a change CLAIMS, though, is right there in the text —
16
+ * that is the decidable subset, and this is it.
17
+ */
18
+ const ANCHOR_RE = /@implements\s+([A-Za-z]+-SPEC-[\w.]+)/g;
19
+ function claimedAnchors(content) {
20
+ return [...new Set([...content.matchAll(ANCHOR_RE)].map((m) => m[1]))];
21
+ }
22
+ /**
23
+ * The anchors this write ADDS, relative to what the file already had.
24
+ *
25
+ * Only the additions are judged. Re-judging anchors already in the file would block every edit to a
26
+ * file whose spec later moved back to draft — including the edits needed to fix exactly that.
27
+ */
28
+ function newlyClaimed(content, previous) {
29
+ const had = new Set(previous ? claimedAnchors(previous) : []);
30
+ return claimedAnchors(content).filter((a) => !had.has(a));
31
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GATE_BLIND_SPOTS = void 0;
4
+ exports.blindSpotSummary = blindSpotSummary;
5
+ exports.GATE_BLIND_SPOTS = [
6
+ { example: 'node ./cleanup.js', why: '스크립트 파일의 내용은 명령 문자열에 없습니다' },
7
+ { example: 'bash ./deploy.sh', why: '같은 이유 — 실행할 내용이 파일 안에 있습니다' },
8
+ { example: 'npm run clean', why: 'package.json의 스크립트 정의가 명령 문자열 밖에 있습니다' },
9
+ { example: 'make distclean', why: 'Makefile의 규칙이 명령 문자열 밖에 있습니다' },
10
+ { example: 'CMD="something"; $CMD', why: '실행될 명령이 실행 시점에 조립됩니다' },
11
+ { example: 'xargs rm < targets.txt', why: '대상 목록이 표준 입력으로 들어옵니다' },
12
+ ];
13
+ /**
14
+ * One line for `doctor`.
15
+ *
16
+ * Deliberately neither "the gate stops everything" nor "the gate stops nothing". The first gets
17
+ * someone hurt; the second makes a reader distrust the parts that do work — and after REQ-167 those
18
+ * parts include creating the script in the first place.
19
+ */
20
+ function blindSpotSummary() {
21
+ return '가드레일은 명령 문자열을 검사합니다 — 이미 저장소에 있는 스크립트를 실행하는 명령'
22
+ + `(${exports.GATE_BLIND_SPOTS.slice(0, 3).map((b) => b.example).join(', ')} 등)의 내용은 보지 못합니다.`
23
+ + ' 스크립트를 새로 작성하는 것은 승인이 필요합니다(A-SPEC-167).'
24
+ // @implements A-SPEC-170
25
+ // Stated, not implied. Four sweep rounds each found runner-manifest names the previous round's
26
+ // list had missed, so calling the list complete would be false — and this is the one place the
27
+ // harness tells a user what it cannot see. It belongs in the prose, not in GATE_BLIND_SPOTS,
28
+ // whose entries are commands a test executes against the gate.
29
+ + ' 루트 러너 매니페스트(Rakefile·Gemfile 등)의 이름 목록은 완전하지 않습니다 — 생태계마다 새로 생깁니다.'
30
+ // @implements A-SPEC-175
31
+ // The residual after REQ-175. Losing `.ax/specs` is now detected, because the ledger records the
32
+ // approvals and `init`'s .gitignore block keeps `provenance*.jsonl` committed. Losing the whole
33
+ // `.ax` takes that evidence with it, and a project then looks like one that never opted in. Every
34
+ // shell route to either state is denied by this gate; what remains is deletion from outside the
35
+ // session, and saying so is the only honest option left.
36
+ + ' `.ax` 전체가 세션 밖에서 삭제되면(에디터·Finder·세션 밖 git) 원장도 함께 사라져'
37
+ + ' 거버넌스를 켠 적 없는 프로젝트와 구별되지 않습니다 — `.ax/specs`만 사라진 경우는 탐지됩니다(REQ-175).';
38
+ }
@@ -0,0 +1,107 @@
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.DecisionLedger = void 0;
37
+ // @implements A-SPEC-125.2
38
+ const fs = __importStar(require("node:fs"));
39
+ const path = __importStar(require("node:path"));
40
+ // Default path used by callers: .ax/ledger/decisions.jsonl
41
+ /**
42
+ * Append-only, provenance audit trail for riskGate decisions. Each call to
43
+ * `record` appends one JSON line per event to a `.jsonl` file -- never
44
+ * truncated, never rewritten -- so the file itself is the durable evidence
45
+ * trail.
46
+ *
47
+ * Timestamps and ids are caller-supplied: this ledger has no clock, so
48
+ * recording is fully deterministic given its inputs. Ledger I/O is
49
+ * deliberately separate from riskGate's decision -- a write failure here
50
+ * must never change a gate verdict that was already computed.
51
+ */
52
+ class DecisionLedger {
53
+ path;
54
+ constructor(path) {
55
+ this.path = path;
56
+ }
57
+ record(events) {
58
+ if (events.length === 0)
59
+ return;
60
+ const lines = events.map((e) => DecisionLedger.serialize(e)).join('');
61
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
62
+ fs.appendFileSync(this.path, lines, 'utf8');
63
+ }
64
+ list() {
65
+ let raw;
66
+ try {
67
+ raw = fs.readFileSync(this.path, 'utf8');
68
+ }
69
+ catch (err) {
70
+ // Missing file: no decisions recorded yet, not an error. Any other
71
+ // failure (EACCES, a directory at this path, etc.) must NOT be
72
+ // swallowed: silently returning [] here would erase the audit trail
73
+ // that the DecisionLedger exists to guarantee -- a silent gate-pass
74
+ // equivalent.
75
+ if (err.code === 'ENOENT')
76
+ return [];
77
+ throw err;
78
+ }
79
+ const events = [];
80
+ for (const line of raw.split('\n')) {
81
+ if (line.trim().length === 0)
82
+ continue;
83
+ try {
84
+ events.push(JSON.parse(line));
85
+ }
86
+ catch {
87
+ // Corrupt line: skip it rather than throwing away the whole ledger.
88
+ continue;
89
+ }
90
+ }
91
+ return events;
92
+ }
93
+ /** Ordered-object serialization so identical events always produce identical bytes. */
94
+ static serialize(e) {
95
+ const ordered = {
96
+ ts: e.ts,
97
+ actor: e.actor,
98
+ action: e.action,
99
+ level: e.level,
100
+ decision: e.decision,
101
+ rationale: e.rationale,
102
+ reasons: e.reasons,
103
+ };
104
+ return JSON.stringify(ordered) + '\n';
105
+ }
106
+ }
107
+ exports.DecisionLedger = DecisionLedger;
@@ -0,0 +1,129 @@
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.isExecutableArtifact = isExecutableArtifact;
37
+ const path = __importStar(require("node:path"));
38
+ // @implements A-SPEC-167
39
+ /**
40
+ * Whether writing this file creates an EXECUTION path.
41
+ *
42
+ * Measured 2026-08-08: `package.json`, `Makefile`, `Dockerfile`, `.github/workflows/*` and `*.sh`
43
+ * all classified as nothing and were therefore allowed unconditionally — while every one of them
44
+ * runs. `package.json` executes on install, a workflow executes holding repository secrets, the
45
+ * Makefile and Dockerfile execute at build time. Combined with the indirection limit (REQ-165),
46
+ * writing `x.sh` and then running `bash ./x.sh` was a complete governance bypass in two allowed
47
+ * steps.
48
+ *
49
+ * The cause is that "code" was an extension list. What executes is code; the compiler's file list
50
+ * is a proxy that misses everything else.
51
+ */
52
+ /** Names the ecosystem fixed. A short exact list is defensible where an extension list is not. */
53
+ const MANIFESTS = [
54
+ 'package.json', 'makefile', 'dockerfile',
55
+ // Axis sweep round 2: the first list was JS and C only, so a Java, Rust or Python project's build
56
+ // file went ungated while the harness claims eight languages. Each executes at build or install
57
+ // time; `.npmrc` reaches the supply chain through `ignore-scripts` and the registry.
58
+ 'build.gradle', 'build.gradle.kts', 'pom.xml', 'cargo.toml', 'pyproject.toml',
59
+ 'justfile', 'taskfile.yml', 'taskfile.yaml', '.npmrc', 'setup.py',
60
+ // Round 4. Root runner manifests share no property, so this stays an enumeration — and the fact
61
+ // that it is INCOMPLETE is declared rather than pretended away (see blind-spots.ts).
62
+ 'rakefile', 'gemfile', 'jenkinsfile', 'vagrantfile',
63
+ 'docker-compose.yml', 'docker-compose.yaml', '.gitlab-ci.yml', '.gitlab-ci.yaml',
64
+ // Round 5 parity: GitLab was in and Travis, Azure and Bitbucket were not — one CI service
65
+ // covered and three not is an inconsistency in this list, the same shape `.command` was. Brewfile
66
+ // and Procfile were also found and deliberately LEFT OUT: they describe an environment rather
67
+ // than running on write, and they fall inside the incompleteness this list already declares.
68
+ '.travis.yml', 'azure-pipelines.yml', 'bitbucket-pipelines.yml',
69
+ ];
70
+ /** Scripts without a shebang. A supplement, and known to be insufficient on its own. */
71
+ // `.command` is macOS's — double-clickable and executed by Finder. Round 3 found it missing while
72
+ // `.bat` and `.cmd` were present, which was an inconsistency in this list rather than a new idea.
73
+ /** Suffixes that mark a copy, a draft or a note rather than a live manifest. */
74
+ const DOC_SUFFIXES = ['.md', '.txt', '.bak', '.example', '.sample', '.template', '.orig'];
75
+ const SCRIPT_EXTS = ['.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', '.command'];
76
+ /**
77
+ * @implements A-SPEC-170
78
+ * Directories whose CONTENTS execute, whatever the files are called.
79
+ *
80
+ * Rounds 1-3 each filled a name list and round 4 found eight more — the trend was the finding, not
81
+ * the eight names. One rule covers them and the next CI tool lands in the same place.
82
+ *
83
+ * `.github` is narrowed to `workflows/` on purpose: `ISSUE_TEMPLATE/` and `CODEOWNERS` do not
84
+ * execute, and a blanket rule over the whole directory would repeat REQ-164's measured accident,
85
+ * where a broad directory match swallowed files that never belonged to the category.
86
+ */
87
+ const EXECUTABLE_DIRS = ['.github/workflows/', '.circleci/', '.husky/', '.git/hooks/'];
88
+ function isExecutableArtifact(relPath, content) {
89
+ // A shebang is the broadest signal and the cheapest — the content is already in the payload, so
90
+ // there is no I/O — and it is the only one that catches an extensionless `bin/deploy`.
91
+ if (typeof content === 'string' && content.startsWith('#!'))
92
+ return true;
93
+ // Lexically normalised before matching. Round 9 found `.github/workflows/../ISSUE_TEMPLATE/x.md`
94
+ // treated as a workflow because the raw string CONTAINS `.github/workflows/` — REQ-163 established
95
+ // that a path is judged by what it resolves to, and this function was still doing substring work.
96
+ // `path.posix.normalize` collapses `..` and `.` without touching the filesystem, so the rule stays
97
+ // pure.
98
+ const norm = path.posix.normalize(relPath.replace(/\\/g, '/')).toLowerCase();
99
+ const base = norm.slice(norm.lastIndexOf('/') + 1);
100
+ // Exact base name, or a manifest with a VARIANT suffix — `Dockerfile.prod`,
101
+ // `docker-compose.override.yml` and `Makefile.inc` are real conventions that build tools read,
102
+ // found in round 10 while their bare forms were covered. Still never a bare prefix: the suffix
103
+ // must not be a document or backup extension, or `Makefile.md` and `pom.xml.bak` would inherit
104
+ // protection they did not earn, and the over-blocking this design spends half its effort avoiding
105
+ // would start.
106
+ if (MANIFESTS.includes(base))
107
+ return true;
108
+ // A VARIANT keeps the manifest's identity, not just its opening letters. Two shapes, because the
109
+ // manifests come in two shapes:
110
+ // - extensionless (`Dockerfile`) → `Dockerfile.prod` : name + a suffix
111
+ // - with an extension (`…-compose.yml`) → `…-compose.override.yml`: stem + a segment + SAME ext
112
+ // A bare prefix rule was tried first and pulled in `pyproject.py`, a Python source file that is
113
+ // not a variant of `pyproject.toml` at all — the existing over-blocking control caught it.
114
+ if (!DOC_SUFFIXES.some((d) => base.endsWith(d))) {
115
+ for (const m of MANIFESTS) {
116
+ const dot = m.startsWith('.') ? m.indexOf('.', 1) : m.indexOf('.');
117
+ if (dot < 0) {
118
+ if (base.startsWith(`${m}.`))
119
+ return true; // Dockerfile.prod
120
+ }
121
+ else if (base.startsWith(`${m.slice(0, dot)}.`) && base.endsWith(m.slice(dot))) {
122
+ return true; // docker-compose.override.yml
123
+ }
124
+ }
125
+ }
126
+ if (EXECUTABLE_DIRS.some((d) => norm.includes(d)))
127
+ return true;
128
+ return SCRIPT_EXTS.some((e) => base.endsWith(e));
129
+ }
@@ -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.GOVERNANCE_LOST_HINT = void 0;
37
+ exports.hasGovernanceHistory = hasGovernanceHistory;
38
+ // @implements A-SPEC-175
39
+ /**
40
+ * Did this project ever opt into governance?
41
+ *
42
+ * The ungoverned escape hatch allows every write on a project with no spec tree, which is correct for
43
+ * a repository that never opted in — without it, pointing the Write matcher at a spec-less project
44
+ * denies 100% of source writes. But the condition could not tell that case apart from a governed
45
+ * project whose specs went missing, and measured 2026-08-12 on an installed tarball: moving
46
+ * `.ax/specs` aside turned an unanchored code write from `deny` to `allow`, silently.
47
+ *
48
+ * The answer has to come from evidence that outlives the spec tree. `spec_approve` writes a
49
+ * `spec-approved` event carrying the sealed digest, and the `.gitignore` block `init` installs
50
+ * excludes `provenance*.jsonl` from being ignored — so the record is committed and survives a clone.
51
+ * A marker file would fail twice over: it can be deleted alongside the specs, and no already-installed
52
+ * project has one.
53
+ */
54
+ const fs = __importStar(require("node:fs"));
55
+ const path = __importStar(require("node:path"));
56
+ const APPROVAL_EVENT = '"kind":"spec-approved"';
57
+ /**
58
+ * The two ways out, and why the project is being judged governed at all.
59
+ *
60
+ * Shared by both hooks so they cannot describe the same situation differently. Naming only the
61
+ * restore path would be no guidance to a user whose actual intent is to stop being governed — they
62
+ * would keep deleting things until it stopped.
63
+ */
64
+ exports.GOVERNANCE_LOST_HINT = '[Holmes-Kit] .ax/specs is missing, but this project has approved specs in its ledger — governance was'
65
+ + ' switched on here and its documents are gone, so every code write is refused rather than silently'
66
+ + ' ungoverned. Restore the spec tree (e.g. `git checkout -- .ax/specs`), or run'
67
+ + ' `holmes-kit init --target . --remove` if you are done with governance.';
68
+ /**
69
+ * True if any provenance replica records a spec approval.
70
+ *
71
+ * Fails toward `true` on anything unreadable: if "cannot read" meant "never governed", corrupting a
72
+ * ledger file would be a way to switch the gate off — worse than the hole this closes.
73
+ */
74
+ function hasGovernanceHistory(root) {
75
+ const dir = path.join(root, '.ax', 'ledger');
76
+ let entries;
77
+ try {
78
+ entries = fs.readdirSync(dir);
79
+ }
80
+ catch (err) {
81
+ // A ledger directory that exists but cannot be listed is evidence withheld, not evidence absent.
82
+ return err.code !== 'ENOENT';
83
+ }
84
+ // Only provenance replicas. `stop-guard.json` and other bookkeeping live here too, and treating
85
+ // any file as the record would let unrelated state decide whether the gate is armed.
86
+ for (const name of entries.filter((n) => n.startsWith('provenance') && n.endsWith('.jsonl'))) {
87
+ let text;
88
+ try {
89
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ // A substring scan, not a per-line parse: the answer is boolean, a broken line must not hide a
95
+ // real approval on another line, and a large ledger must not be walked object by object inside a
96
+ // hook. The needle is the serialized field, so ordinary prose in a `summary` cannot forge it.
97
+ if (text.includes(APPROVAL_EVENT))
98
+ return true;
99
+ }
100
+ return false;
101
+ }
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ACTIONS = void 0;
4
+ exports.classifyAction = classifyAction;
5
+ exports.phaseCheck = phaseCheck;
6
+ const executable_artifact_1 = require("./executable-artifact");
7
+ const test_files_1 = require("../cpg/test-files");
8
+ const approval_blockers_1 = require("../spec/approval-blockers");
9
+ const tspec_state_1 = require("./tspec-state");
10
+ // @implements A-SPEC-100.2
11
+ // @implements A-SPEC-189
12
+ // The RUNTIME list is the single truth; the type derives from it. The phase_check schema's enum
13
+ // advertises this same array — a hand-copied literal there was a second truth, and round-3 measured
14
+ // that deleting two of its members survived the entire suite while the wire falsely refused actions
15
+ // this module accepts.
16
+ /** 문면에 실을 수 있는 id 만 문자 그대로 — 나머지는 이름을 잃되 문장은 잃지 않는다(§5R). */
17
+ const quotableId = (id) => (id === undefined ? null : id.length <= tspec_state_1.ID_MAX ? id : null);
18
+ exports.ACTIONS = [
19
+ 'AUTHOR_REQ', 'AUTHOR_HSPEC', 'AUTHOR_ASPEC', 'AUTHOR_CSPEC', 'AUTHOR_TSPEC',
20
+ 'WRITE_TEST', 'WRITE_CODE',
21
+ ];
22
+ const NO_SPEC_NO_CODE = 'No Spec, No Code — 선행 스펙 승인 전 구현 진입 금지';
23
+ // Executable-source extensions the WRITE_CODE gate must cover. Kept broad on purpose: an extension
24
+ // NOT listed here silently escapes No-Spec-No-Code, so err toward inclusion (adversarial-review F2 —
25
+ // .mjs/.cjs/.mts/.cts and C/C++ header/variant + other-language sources were escaping).
26
+ const CODE_EXT_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|kts|scala|cs|c|cc|cxx|cpp|h|hh|hpp|hxx|m|mm|swift|rb|php)$/i;
27
+ /**
28
+ * Classify a write target. `p` must be PROJECT-RELATIVE (POSIX or Windows separators both fine).
29
+ *
30
+ * Relative matters for the test rules: several of them key on a directory segment, so an absolute
31
+ * path would let a project checked out under `/…/test/` classify its production code as tests —
32
+ * DOWNGRADING the gate, since WRITE_TEST does not require an approved T-SPEC. Callers that hold an
33
+ * absolute path make it relative first (see evaluateHook).
34
+ */
35
+ function classifyAction(p, content) {
36
+ const norm = p.replace(/\\/g, '/');
37
+ // Spec authoring is a MARKDOWN file under a spec folder. A code file merely PLACED at such a path
38
+ // (e.g. src/x/.ax/specs/01_req/evil.ts) must NOT be downgraded to the permissive author gate —
39
+ // require the `.md` extension so it falls through to the WRITE_CODE gate instead (review F4).
40
+ if (/\.md$/i.test(norm)) {
41
+ if (norm.includes('.ax/specs/01_req/'))
42
+ return 'AUTHOR_REQ';
43
+ if (norm.includes('.ax/specs/02_h-spec/'))
44
+ return 'AUTHOR_HSPEC';
45
+ if (norm.includes('.ax/specs/03_a-spec/'))
46
+ return 'AUTHOR_ASPEC';
47
+ if (norm.includes('.ax/specs/04_cpg/'))
48
+ return 'AUTHOR_CSPEC';
49
+ if (norm.includes('.ax/specs/05_t-spec/'))
50
+ return 'AUTHOR_TSPEC';
51
+ }
52
+ // Test detection uses the SCANNER'S table, not a bespoke `.test.`/`.spec.` regex. The bespoke one
53
+ // knew only the TS/JS infix, so every pytest file (`tests/test_briefing.py`), Go `_test.go`, Rust
54
+ // `tests/*.rs` and JUnit `AppTest.java` fell through to WRITE_CODE — gating a project's own test
55
+ // suite MORE strictly than the code it tests (WRITE_CODE additionally demands an approved T-SPEC),
56
+ // which no Python or Go project can satisfy for its tests. Measured on a real Python target. One
57
+ // table means the gate and the graph cannot disagree about what a test is.
58
+ if ((0, test_files_1.isTestFile)(norm))
59
+ return 'WRITE_TEST';
60
+ if (/\.(test|spec)\.[a-z]+$/i.test(norm))
61
+ return 'WRITE_TEST';
62
+ if (CODE_EXT_RE.test(norm))
63
+ return 'WRITE_CODE';
64
+ // @implements A-SPEC-167
65
+ // What EXECUTES is code. The extension list above is the compiler's view, and it missed
66
+ // package.json, Makefile, Dockerfile, CI workflows and shell scripts — each an execution path,
67
+ // each previously unclassified and therefore ungated. Folding into WRITE_CODE rather than adding
68
+ // an action kind means the role policy, the phase gate and the playbooks all already know it.
69
+ if ((0, executable_artifact_1.isExecutableArtifact)(norm, content))
70
+ return 'WRITE_CODE';
71
+ return null;
72
+ }
73
+ function deny(phase, missing, message, next, discipline = NO_SPEC_NO_CODE) {
74
+ return { decision: 'deny', phase, missing, remediation: { message, next_action: next, discipline } };
75
+ }
76
+ function phaseCheck(action, ctx) {
77
+ const byId = new Map(ctx.specs.map((s) => [s.id, s]));
78
+ const approvedOfType = (t) => ctx.specs.some((s) => s.type === t && s.status === 'approved');
79
+ const existsOfType = (t) => ctx.specs.some((s) => s.type === t);
80
+ switch (action) {
81
+ case 'AUTHOR_REQ': return { decision: 'allow', phase: 'INTAKE' };
82
+ case 'AUTHOR_HSPEC':
83
+ return existsOfType('REQ') ? { decision: 'allow', phase: 'DESIGN' }
84
+ : deny('INTAKE', ['REQ'], '선행 REQ가 없습니다.', "spec_create(type='REQ', ...)");
85
+ case 'AUTHOR_ASPEC':
86
+ return approvedOfType('H-SPEC') ? { decision: 'allow', phase: 'SPECIFY' }
87
+ : deny('DESIGN', ['approved H-SPEC'], 'approved H-SPEC이 없습니다.', "H-SPEC 완성 후 spec_validate → approve");
88
+ case 'AUTHOR_CSPEC':
89
+ case 'AUTHOR_TSPEC':
90
+ return approvedOfType('A-SPEC') ? { decision: 'allow', phase: 'TEST-SPEC' }
91
+ : deny('SPECIFY', ['approved A-SPEC'], 'approved A-SPEC이 없습니다.', "A-SPEC 먼저 작성·승인");
92
+ case 'WRITE_TEST':
93
+ case 'WRITE_CODE': {
94
+ const aspec = ctx.targetAspecId ? byId.get(ctx.targetAspecId) : undefined;
95
+ if (!aspec || aspec.status !== 'approved') {
96
+ // @implements A-SPEC-182
97
+ // Say WHY it cannot be approved, not just that it is not. The reason is already computable
98
+ // here — `spec_approve` derives it from the same specs moments later. Measured over 244
99
+ // governed runs: 425 refusals in 99 wordings, none of which carried the reason, and authors
100
+ // then groped through `.ax/` with the shell (8.5 calls/run, r=0.71 against total tokens).
101
+ // Nothing is appended when the target is absent (nothing to compute) or when nothing blocks
102
+ // (the silence itself says "approve it") — see the boundary cases in T-SPEC-182.
103
+ // @implements A-SPEC-192 §5R (round 9) — the TWIN of the approval-claim refusal, and until
104
+ // now the unbudgeted one: the same fact, computed by the same `blockerSummary`, printed
105
+ // raw. Measured 60,286 characters on one call (a 20k-char id echoed twice plus a
106
+ // pathological section name repeated 700 times), while its sibling one function away was
107
+ // capped at 1,200 — so whether a refusal was bounded depended on whether the anchor already
108
+ // existed on disk, i.e. on input position. Same budget, same omission labels, same rule:
109
+ // sentences are omitted whole and counted, never cut.
110
+ // @implements A-SPEC-192 §7R (round 10) — a sentence that says the name was omitted must not
111
+ // then print it. Measured: with a 120-char id the refusal read "구현 대상 A-SPEC(이름이
112
+ // 문면 예산을 넘어 생략)…" and quoted the id whole two lines later — through the clause
113
+ // LABEL (blockerSummary defaults it to spec.id) and again through blocker BODIES that embed
114
+ // the id in their own prose (breaking_change, id-format). Both carriers are folded here, at
115
+ // the one place that knows the id is unquotable.
116
+ // One carrier, not two: folding the clause LABEL separately was unobservable once the whole
117
+ // summary is scrubbed (a mutation battery proved the label branch equivalent), and two
118
+ // places that must agree about the same fact is how they stop agreeing.
119
+ // @implements A-SPEC-192 §10R (round 10, 4th) — the twin now renders through the SAME
120
+ // budgeted clause the sibling uses (`budgetedClause`), instead of measuring the whole joined
121
+ // summary and dropping it all. Measured before: a 93-char parent blocker — the one action the
122
+ // author could take — disappeared because another blocker was 1,154 chars, and the effective
123
+ // budget slid with the id's length (one character past ID_MAX freed 63 and flipped a 236-char
124
+ // refusal that named nothing into a 1,209-char one that named everything).
125
+ const unquotable = ctx.targetAspecId !== undefined && quotableId(ctx.targetAspecId) === null;
126
+ const fold = (s) => (unquotable && ctx.targetAspecId
127
+ ? s.split(ctx.targetAspecId).join('<이름이 문면 예산을 넘어 생략>')
128
+ : s);
129
+ // Never throws — the contract `blockerSummary` carried and this call must keep: this runs in
130
+ // a PreToolUse hook on every tool call, and a refusal that says less is recoverable while a
131
+ // hook that dies is not. (Round-10 lost it for one build by calling approvalBlockers raw.)
132
+ const rawBlockers = (() => {
133
+ try {
134
+ return aspec ? (0, approval_blockers_1.approvalBlockers)(aspec, (id) => byId.get(id) ?? null).map(fold) : [];
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ })();
140
+ const why = (0, tspec_state_1.budgetedClause)(unquotable ? '<이름이 문면 예산을 넘어 생략>' : (ctx.targetAspecId ?? aspec?.id ?? ''), `${aspec?.type ?? 'A-SPEC'}`, rawBlockers);
141
+ // 부재는 '미지정'이고, 이름이 문면 예산을 넘는 것은 다른 사실이다 — 두 사정을 한 낱말로
142
+ // 뭉치면 호출자는 자기가 무엇을 잘못했는지 알 수 없다.
143
+ // 빈 문자열도 부재다(round-10: 빈 괄호만 남아 무엇이 문제인지 말하지 않았다).
144
+ const named = ctx.targetAspecId === undefined || ctx.targetAspecId === ''
145
+ ? '미지정'
146
+ : (quotableId(ctx.targetAspecId) ?? '이름이 문면 예산을 넘어 생략');
147
+ return deny('DESIGN', ['approved A-SPEC'], `구현 대상 A-SPEC(${named})이 approved가 아닙니다.${why}`, "spec_create/approve로 A-SPEC → T-SPEC 완성");
148
+ }
149
+ // @implements A-SPEC-183
150
+ // A STATE, not a boolean. Measured 2026-08-13: four different T-SPEC situations produced one
151
+ // identical sentence while needing four different actions, and `promote-slice`'s 「침묵의
152
+ // 실패 모드」 section existed to explain what the refusal could not. `decision`, `phase` and
153
+ // `missing` stay exactly as they were — this changes what is SAID, never what is decided.
154
+ if (action === 'WRITE_CODE') {
155
+ const state = (0, tspec_state_1.tspecStateFor)(aspec.id, ctx.specs);
156
+ if (state.kind !== 'approved') {
157
+ const r = (0, tspec_state_1.tspecRemediation)(aspec.id, state, (s) => { try {
158
+ return (0, approval_blockers_1.approvalBlockers)(s, (id) => byId.get(id) ?? null);
159
+ }
160
+ catch {
161
+ return [];
162
+ } });
163
+ return deny('TEST-SPEC', ['approved T-SPEC'], r.message, r.next_action);
164
+ }
165
+ }
166
+ return { decision: 'allow', phase: 'IMPLEMENT' };
167
+ }
168
+ }
169
+ }