@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,233 @@
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.EMPTY_CHANGE_SET = exports.GitChangeSource = exports.SnapshotChangeSource = exports.hashOf = exports.isDerivedState = void 0;
37
+ exports.hashTree = hashTree;
38
+ exports.diffHashes = diffHashes;
39
+ // @implements A-SPEC-128
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const node_crypto_1 = require("node:crypto");
43
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
44
+ const git_changes_1 = require("../rtm/git-changes");
45
+ const baseline_1 = require("./baseline");
46
+ const root_1 = require("./root");
47
+ const EMPTY = { added: [], modified: [], deleted: [], renamed: [] };
48
+ exports.EMPTY_CHANGE_SET = EMPTY;
49
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
50
+ /**
51
+ * Derived state under `.ax/` — bookkeeping the harness writes about the project, not project content.
52
+ *
53
+ * Both adapters exclude exactly this set, which is what makes "the two sources agree" a property
54
+ * rather than a coincidence. Found by the agreement test: the snapshot source skipped all of `.ax/`
55
+ * while git reported a freshly written baseline file as an addition, so the same edits produced
56
+ * different change sets depending on which adapter answered.
57
+ *
58
+ * `.ax/specs/**` is deliberately NOT here. A spec is project content — a reviewer who changed a
59
+ * requirement should see that in the change set, and hiding it would make the two halves of the chain
60
+ * visible at different times. The line is derived-vs-authored, not "anything under .ax".
61
+ */
62
+ const DERIVED_STATE_RE = /^\.ax\/(state|cpg_cache|ledger|reports)\/|^\.ax\/[^/]*\.sqlite/;
63
+ const isDerivedState = (relPath) => DERIVED_STATE_RE.test(relPath);
64
+ exports.isDerivedState = isDerivedState;
65
+ /** Same digest the scan caches use, so a baseline and a scan cache never disagree about a file. */
66
+ const hashOf = (content) => (0, node_crypto_1.createHash)('sha256').update(content, 'utf8').digest('hex');
67
+ exports.hashOf = hashOf;
68
+ /**
69
+ * Content hashes for every file in the tree, keyed by project-relative POSIX path.
70
+ *
71
+ * Derived state under `.ax/` is excluded (see `isDerivedState`) along with the scanner's vendor/cache
72
+ * skips, so a run never reports its own bookkeeping as project churn. Authored specs under
73
+ * `.ax/specs/**` ARE included — they are project content.
74
+ *
75
+ * Files that cannot be read are skipped rather than failing the walk — an unreadable file is not
76
+ * evidence of a change.
77
+ */
78
+ function hashTree(root, opts = {}) {
79
+ const out = {};
80
+ const walk = (dir) => {
81
+ let entries;
82
+ try {
83
+ entries = fs.readdirSync(dir, { withFileTypes: true });
84
+ }
85
+ catch {
86
+ return;
87
+ }
88
+ for (const e of entries) {
89
+ if (cpg_scanner_1.SKIP.has(e.name))
90
+ continue;
91
+ const abs = path.join(dir, e.name);
92
+ const rel = path.relative(root, abs).split(path.sep).join('/');
93
+ if ((0, exports.isDerivedState)(`${rel}${e.isDirectory() ? '/' : ''}`) || opts.isIgnored?.(rel))
94
+ continue;
95
+ if (e.isDirectory()) {
96
+ walk(abs);
97
+ continue;
98
+ }
99
+ if (!e.isFile())
100
+ continue; // symlinks and devices are not project content
101
+ try {
102
+ out[rel] = (0, exports.hashOf)(fs.readFileSync(abs, 'utf8'));
103
+ }
104
+ catch { /* unreadable — not a change */ }
105
+ }
106
+ };
107
+ walk(root);
108
+ return out;
109
+ }
110
+ /** Compare a baseline's hashes against the tree's. Pure — the adapters supply the two maps. */
111
+ function diffHashes(before, after) {
112
+ const added = [];
113
+ const modified = [];
114
+ const deleted = [];
115
+ for (const [p, h] of Object.entries(after)) {
116
+ if (!(p in before))
117
+ added.push(p);
118
+ else if (before[p] !== h)
119
+ modified.push(p);
120
+ }
121
+ for (const p of Object.keys(before))
122
+ if (!(p in after))
123
+ deleted.push(p);
124
+ return { added: added.sort(cmp), modified: modified.sort(cmp), deleted: deleted.sort(cmp), renamed: [] };
125
+ }
126
+ /** Changes since a labelled baseline. Available on any directory, with or without version control. */
127
+ class SnapshotChangeSource {
128
+ root;
129
+ label;
130
+ opts;
131
+ constructor(root, label,
132
+ // @implements A-SPEC-134 — `verify` is the provenance ledger path. When set, a baseline whose
133
+ // digest is not anchored on the chain (tampered, or written unanchored) is treated as ABSENT:
134
+ // a forged narrowing must never be honored. Omitted (pure unit tests) → today's behavior.
135
+ opts = {}) {
136
+ this.root = root;
137
+ this.label = label;
138
+ this.opts = opts;
139
+ }
140
+ /** Trusted iff a baseline exists AND (no verify requested OR its digest is anchored on the chain). */
141
+ trustedBaseline() {
142
+ const baseline = (0, baseline_1.readBaseline)(this.root, this.label);
143
+ if (!baseline)
144
+ return null;
145
+ if (this.opts.verify && !(0, baseline_1.isBaselineTrusted)(this.root, this.label, this.opts.verify))
146
+ return null;
147
+ return baseline;
148
+ }
149
+ changes() {
150
+ const baseline = this.trustedBaseline();
151
+ if (!baseline)
152
+ return null;
153
+ return diffHashes(baseline.files, hashTree(this.root, this.opts));
154
+ }
155
+ describe() {
156
+ const raw = (0, baseline_1.readBaseline)(this.root, this.label);
157
+ const trusted = this.trustedBaseline();
158
+ const tampered = raw !== null && trusted === null; // exists but failed the integrity check
159
+ return {
160
+ kind: 'snapshot',
161
+ baseline: this.label,
162
+ detectsRenames: false,
163
+ available: trusted !== null,
164
+ ...(trusted ? {} : {
165
+ reason: tampered
166
+ ? `baseline "${this.label}" failed its integrity check (not anchored on the provenance chain) — treating as absent, full scope`
167
+ : `no baseline named "${this.label}" — run a passing test_run, or take one explicitly`,
168
+ }),
169
+ };
170
+ }
171
+ }
172
+ exports.SnapshotChangeSource = SnapshotChangeSource;
173
+ /**
174
+ * Changes across a git range.
175
+ *
176
+ * THE TOP-LEVEL GUARD LIVES HERE, and only here. `gitChangedFiles` yields paths relative to the git
177
+ * top-level while `scan(root, root)` tags them relative to `root`; when those disagree the keys
178
+ * silently diverge and impact sets come back wrong. That hazard belongs to this adapter — it was
179
+ * previously imposed on every tool, including ones that never look at a diff, which is what made the
180
+ * whole harness demand a repository.
181
+ */
182
+ class GitChangeSource {
183
+ root;
184
+ base;
185
+ head;
186
+ topLevel;
187
+ constructor(root, base, head) {
188
+ this.root = root;
189
+ this.base = base;
190
+ this.head = head;
191
+ this.topLevel = (0, root_1.resolveProjectRoot)(root).git?.topLevel;
192
+ if (this.topLevel && fs.realpathSync(path.resolve(root)) !== this.topLevel) {
193
+ throw new Error(`GitChangeSource: root must be the git top-level (got ${root}, top-level is ${this.topLevel})`);
194
+ }
195
+ }
196
+ changes() {
197
+ if (!this.topLevel)
198
+ return null;
199
+ try {
200
+ const c = (0, git_changes_1.gitChangedFiles)(this.root, this.base, this.head);
201
+ // The same derived-state exclusion the snapshot source applies. A project that committed its
202
+ // `.ax/state` before the ignore block existed would otherwise get different answers from the
203
+ // two adapters for identical edits.
204
+ const keep = (p) => !(0, exports.isDerivedState)(p);
205
+ return {
206
+ added: c.added.filter(keep).sort(cmp),
207
+ modified: c.modified.filter(keep).sort(cmp),
208
+ deleted: c.deleted.filter(keep).sort(cmp),
209
+ renamed: c.renamed.filter((r) => keep(r.to)).sort((a, b) => cmp(a.to, b.to)),
210
+ };
211
+ }
212
+ catch {
213
+ // An unreachable ref (a shallow clone, a range that does not exist yet) is an unavailable
214
+ // source, not a crash — the caller widens to full scope exactly as it does with no baseline.
215
+ return null;
216
+ }
217
+ }
218
+ describe() {
219
+ const range = `${this.base}..${this.head}`;
220
+ if (!this.topLevel) {
221
+ return { kind: 'git', baseline: range, detectsRenames: true, available: false, reason: 'not a git working tree' };
222
+ }
223
+ const available = this.changes() !== null;
224
+ return {
225
+ kind: 'git',
226
+ baseline: range,
227
+ detectsRenames: true,
228
+ available,
229
+ ...(available ? {} : { reason: `git range ${range} could not be resolved` }),
230
+ };
231
+ }
232
+ }
233
+ exports.GitChangeSource = GitChangeSource;
@@ -0,0 +1,145 @@
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.parseIgnore = parseIgnore;
37
+ exports.loadIgnore = loadIgnore;
38
+ // @implements A-SPEC-128
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const LIMITATIONS = [
42
+ 'only the project-root .gitignore is read — nested per-directory .gitignore files are not',
43
+ 'backslash escapes are not interpreted',
44
+ '.git/info/exclude and the global core.excludesFile are not read',
45
+ ];
46
+ /** Constructs the subset deliberately does not implement: escapes, and an unterminated class. */
47
+ const UNSUPPORTED_RE = /\\/;
48
+ const UNTERMINATED_CLASS_RE = /\[[^\]]*$/;
49
+ /**
50
+ * Translate one supported pattern into an anchored regex over project-relative FILE paths.
51
+ *
52
+ * `dirOnly` is expressed in the tail rather than as a second check at match time: a directory rule
53
+ * must have something UNDER it (`dist/` excludes `dist/bundle.js`, never a file named `dist`). An
54
+ * earlier version re-assembled the compiled regex's source at match time to express that, which
55
+ * passed its tests and was still the wrong shape — unreadable, and correct only for the cases tried.
56
+ */
57
+ function compile(pattern, dirOnly) {
58
+ const anchored = pattern.startsWith('/');
59
+ let body = anchored ? pattern.slice(1) : pattern;
60
+ // A pattern containing a slash anywhere is anchored to the root; a bare name matches at any depth.
61
+ const rooted = anchored || body.includes('/');
62
+ let out = '';
63
+ for (let i = 0; i < body.length; i++) {
64
+ const c = body[i];
65
+ if (c === '*') {
66
+ if (body[i + 1] === '*') {
67
+ // `**/` spans zero or more directories; a bare `**` spans anything including separators.
68
+ if (body[i + 2] === '/') {
69
+ out += '(?:[^/]+/)*';
70
+ i += 2;
71
+ }
72
+ else {
73
+ out += '.*';
74
+ i += 1;
75
+ }
76
+ }
77
+ else {
78
+ out += '[^/]*';
79
+ }
80
+ continue;
81
+ }
82
+ if (c === '?') {
83
+ out += '[^/]';
84
+ continue;
85
+ }
86
+ if (c === '[') {
87
+ // A character class, copied through with git's `!` negation translated to a regex `^`. `/` is
88
+ // excluded so a class can never cross a path separator.
89
+ const end = body.indexOf(']', i + 1);
90
+ const inner = body.slice(i + 1, end);
91
+ const negated = inner.startsWith('!') || inner.startsWith('^');
92
+ out += `[${negated ? '^' : ''}${(negated ? inner.slice(1) : inner).replace(/[\\\]]/g, '')}]`;
93
+ i = end;
94
+ continue;
95
+ }
96
+ out += c.replace(/[.+^${}()|]/g, '\\$&');
97
+ }
98
+ // Matching a FILE path: an ordinary rule matches the file itself or anything beneath it; a
99
+ // directory-only rule matches ONLY what is beneath it.
100
+ const tail = dirOnly ? '/.+$' : '(?:/.*)?$';
101
+ return new RegExp(rooted ? `^${out}${tail}` : `^(?:.*/)?${out}${tail}`);
102
+ }
103
+ function parseIgnore(content) {
104
+ const rules = [];
105
+ const unsupported = [];
106
+ for (const raw of content.split('\n')) {
107
+ const line = raw.replace(/\s+$/, ''); // trailing whitespace is not part of a pattern
108
+ if (line.trim() === '' || line.trimStart().startsWith('#'))
109
+ continue;
110
+ if (UNSUPPORTED_RE.test(line) || UNTERMINATED_CLASS_RE.test(line)) {
111
+ unsupported.push(line);
112
+ continue;
113
+ }
114
+ const negated = line.startsWith('!');
115
+ let body = negated ? line.slice(1) : line;
116
+ const dirOnly = body.endsWith('/');
117
+ if (dirOnly)
118
+ body = body.slice(0, -1);
119
+ if (body === '')
120
+ continue;
121
+ rules.push({ negated, dirOnly, re: compile(body, dirOnly), source: line });
122
+ }
123
+ return {
124
+ isIgnored(relPath) {
125
+ const p = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
126
+ let verdict = false;
127
+ // LAST match wins, which is what makes negation work: `*.log` then `!keep.log` re-includes.
128
+ for (const r of rules)
129
+ if (r.re.test(p))
130
+ verdict = !r.negated;
131
+ return verdict;
132
+ },
133
+ unsupportedPatterns: () => [...unsupported],
134
+ limitations: () => [...LIMITATIONS],
135
+ };
136
+ }
137
+ /** Load the project-root `.gitignore`. A missing file ignores nothing. */
138
+ function loadIgnore(root) {
139
+ try {
140
+ return parseIgnore(fs.readFileSync(path.join(root, '.gitignore'), 'utf8'));
141
+ }
142
+ catch {
143
+ return parseIgnore('');
144
+ }
145
+ }
@@ -0,0 +1,113 @@
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.MARKER = void 0;
37
+ exports.cleanSubprocessEnv = cleanSubprocessEnv;
38
+ exports.resolveProjectRoot = resolveProjectRoot;
39
+ // @implements A-SPEC-128
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const node_child_process_1 = require("node:child_process");
43
+ // @implements A-SPEC-194
44
+ // @implements A-SPEC-196
45
+ function cleanSubprocessEnv(env = process.env) {
46
+ const cleaned = { ...env };
47
+ const SCRUB_KEYS = new Set(['HOLMES_APPROVAL', 'HOLMES_LEDGER_KEY']);
48
+ for (const k of Object.keys(cleaned)) {
49
+ if (SCRUB_KEYS.has(k.toUpperCase())) {
50
+ delete cleaned[k];
51
+ }
52
+ }
53
+ return cleaned;
54
+ }
55
+ /** The marker directory that makes a directory a Holmes-Kit project. */
56
+ exports.MARKER = '.ax';
57
+ function gitTopLevel(dir) {
58
+ try {
59
+ // stderr silenced: "not a git repository" is an expected branch here, not a fault worth printing.
60
+ const out = (0, node_child_process_1.execFileSync)('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
61
+ encoding: 'utf8',
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ env: cleanSubprocessEnv(),
64
+ }).trim();
65
+ // git resolves symlinks in its output; realpath ours too so comparisons are like-for-like
66
+ // (macOS /var -> /private/var otherwise makes an identical directory look like a mismatch).
67
+ return out ? fs.realpathSync(out) : undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ function resolveProjectRoot(dir) {
74
+ let start;
75
+ try {
76
+ start = fs.realpathSync(path.resolve(dir));
77
+ }
78
+ catch {
79
+ // @implements A-SPEC-189 §7 (round 11) — this is a POINTED refusal about the caller's own
80
+ // argument ('that path does not exist'), not a fault. Without the marker the MCP boundary
81
+ // rethrew it and 11 tools answered the commonest user mistake with a raw -32603 — the class
82
+ // this REQ exists to remove — while phase_status and spec_approve, which catch it themselves,
83
+ // answered with {ok:false, reason}. One mistake, two shapes.
84
+ throw Object.assign(new Error(`resolveProjectRoot: ${dir} does not exist`), { holmesRefusal: true });
85
+ }
86
+ if (!fs.statSync(start).isDirectory()) {
87
+ throw Object.assign(new Error(`resolveProjectRoot: ${dir} is not a directory`), { holmesRefusal: true });
88
+ }
89
+ // Walk up for the marker. The loop terminates at the filesystem root, where dirname(p) === p.
90
+ //
91
+ // @implements A-SPEC-191 §13 — a candidate INSIDE a `.ax` directory is never a project root.
92
+ // Round-9 measured the attack this closes: one ordinary `review_record({root: <P>/.ax/specs})`
93
+ // call — no approval, no shell, no gate — creates `<P>/.ax/specs/.ax/ledger/`, and the very next
94
+ // derivation walks up from the spec store and stops at that planted marker. Every consumed nonce
95
+ // then reopened, the audit lines moved to a shadow ledger the project never reads, and
96
+ // spec_approve refused the CORRECT root forever after. `.ax` holds a project's records; a project
97
+ // does not live inside its own records.
98
+ // round-10: the exclusion compared SPELLING. On a case-insensitive filesystem (macOS default) a
99
+ // store written as `.AX/specs` slipped past it, and the planted-marker hijack came straight back.
100
+ const insideAx = (p) => p.split(path.sep).some((seg) => seg.toLowerCase() === exports.MARKER.toLowerCase());
101
+ let found;
102
+ for (let cur = start;; cur = path.dirname(cur)) {
103
+ if (!insideAx(cur) && fs.existsSync(path.join(cur, exports.MARKER))) {
104
+ found = cur;
105
+ break;
106
+ }
107
+ if (path.dirname(cur) === cur)
108
+ break;
109
+ }
110
+ const root = found ?? start;
111
+ const topLevel = gitTopLevel(root);
112
+ return { root, marker: found ? exports.MARKER : 'given', ...(topLevel ? { git: { topLevel } } : {}) };
113
+ }
@@ -0,0 +1,162 @@
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.planAnchors = planAnchors;
37
+ exports.applyAnchors = applyAnchors;
38
+ // @implements A-SPEC-126
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ /**
42
+ * Comment prefix per language, covering every extension in `SCANNABLE_EXTENSIONS`.
43
+ * An anchor written with the wrong prefix is a syntax error in the target file, so this table is
44
+ * matched explicitly rather than defaulted.
45
+ */
46
+ const COMMENT_PREFIX = [
47
+ [/\.(py|rb|sh|yml|yaml|toml)$/, '#'],
48
+ [/\.(ts|mts|cts|tsx|jsx|js|mjs|cjs|java|cs|go|rs|cpp|cc|cxx|hpp|hh|h)$/, '//'],
49
+ ];
50
+ const prefixFor = (file) => (COMMENT_PREFIX.find(([re]) => re.test(file)) ?? [null, '//'])[1];
51
+ /**
52
+ * Compute the anchor edits for a mapping, refusing anything that must not be written.
53
+ *
54
+ * `approvedAspecs` is the caller's list of APPROVED A-SPEC ids — passed in rather than read here so
55
+ * the function stays pure with respect to the spec store and can be tested without one.
56
+ */
57
+ function planAnchors(root, mapping, approvedAspecs) {
58
+ const edits = [];
59
+ const refused = [];
60
+ const approved = new Set(approvedAspecs);
61
+ const rootReal = path.resolve(root);
62
+ // The disk is read at PLAN time only, so a repeated pair would produce two identical edits and
63
+ // apply would insert the same anchor twice. Dedupe here rather than trusting the caller's mapping.
64
+ const planned = new Set();
65
+ // JSON-encoded pair, not a hand-picked separator: a filename may contain any character, and a
66
+ // separator that can occur in a path would silently merge two different mappings into one key.
67
+ const keyOf = (file, aspec) => JSON.stringify([file, aspec]);
68
+ for (const { file, aspec } of mapping) {
69
+ if (planned.has(keyOf(file, aspec))) {
70
+ refused.push({ file, aspec, reason: 'duplicate mapping entry' });
71
+ continue;
72
+ }
73
+ if (!approved.has(aspec)) {
74
+ refused.push({ file, aspec, reason: `target A-SPEC ${aspec} is not approved` });
75
+ continue;
76
+ }
77
+ // The mapping arrives as tool input, and applying an edit is a file write: a traversing path
78
+ // would let an anchor land anywhere on the host. Containment is checked before the read.
79
+ // @implements A-SPEC-189 §11 (round 12) — `path.resolve` folds `..` and nothing else, so a
80
+ // symlink INSIDE the root (a directory link or a file link) carried the write past this check
81
+ // while the returned plan still showed a root-relative path. The plan/apply split exists so a
82
+ // human can inspect the destinations before anything is written; naming the wrong destination
83
+ // defeats the split itself. Judge where the write LANDS, and say so when it lands outside.
84
+ const abs = path.resolve(rootReal, file);
85
+ const landing = (() => {
86
+ let head = abs;
87
+ const tail = [];
88
+ for (let hop = 0; hop < 64; hop++) {
89
+ try {
90
+ return [fs.realpathSync(head), ...tail].join(path.sep);
91
+ }
92
+ catch { /* walk up */ }
93
+ const parent = path.dirname(head);
94
+ if (parent === head)
95
+ return abs;
96
+ tail.unshift(path.basename(head));
97
+ head = parent;
98
+ }
99
+ return abs;
100
+ })();
101
+ // 뿌리도 같은 방식으로 해소해야 비교가 성립한다 — `/var` → `/private/var` 처럼 뿌리만
102
+ // 링크를 지나면 제 파일이 전부 '밖'이 된다(§17b 가 훅에서 배운 것과 같은 함정).
103
+ const rootLanding = (() => { try {
104
+ return fs.realpathSync(rootReal);
105
+ }
106
+ catch {
107
+ return rootReal;
108
+ } })();
109
+ const contained = (x, base) => x === base || x.startsWith(base + path.sep);
110
+ if (!contained(abs, rootReal) || !contained(landing, rootLanding)) {
111
+ refused.push({ file, aspec, reason: 'path resolves outside the root' });
112
+ continue;
113
+ }
114
+ let code;
115
+ try {
116
+ code = fs.readFileSync(abs, 'utf8');
117
+ }
118
+ catch {
119
+ refused.push({ file, aspec, reason: 'unreadable' });
120
+ continue;
121
+ }
122
+ if (new RegExp(`@implements\\s+${aspec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(code)) {
123
+ refused.push({ file, aspec, reason: 'already anchored' });
124
+ continue;
125
+ }
126
+ planned.add(keyOf(file, aspec));
127
+ edits.push({
128
+ file, aspec,
129
+ insert: `${prefixFor(file)} @implements ${aspec}\n`,
130
+ // A shebang must stay executable: line 1 is reserved for it.
131
+ atLine: /^#!/.test(code) ? 1 : 0,
132
+ });
133
+ }
134
+ return { edits, refused };
135
+ }
136
+ /** Apply an anchor plan. DRY-RUN BY DEFAULT: writing requires an explicit `dryRun: false`. */
137
+ function applyAnchors(root, edits, opts = {}) {
138
+ const dryRun = opts.dryRun !== false;
139
+ if (dryRun)
140
+ return { applied: 0, dryRun: true, would: edits.length, failed: [] };
141
+ let applied = 0;
142
+ const failed = [];
143
+ for (const e of edits) {
144
+ try {
145
+ const abs = path.join(root, e.file);
146
+ const code = fs.readFileSync(abs, 'utf8');
147
+ // Match the file's own line ending. Splicing an LF line into a CRLF file leaves one stray
148
+ // ending, which reads as a whole-file change in some tooling and a lint error in others —
149
+ // noise attributable to the anchor rather than to the code. Brownfield trees have both.
150
+ const eol = code.includes('\r\n') ? '\r\n' : '\n';
151
+ const lines = code.split(eol);
152
+ lines.splice(e.atLine, 0, e.insert.replace(/\n$/, ''));
153
+ fs.writeFileSync(abs, lines.join(eol));
154
+ applied++;
155
+ }
156
+ catch (err) {
157
+ // Fail-safe: one unwritable file yields fewer anchors, never a corrupted tree or a lost report.
158
+ failed.push({ file: e.file, aspec: e.aspec, reason: err instanceof Error ? err.message : String(err) });
159
+ }
160
+ }
161
+ return { applied, dryRun: false, would: edits.length, failed };
162
+ }