@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,462 @@
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.settingsPathOf = exports.MATCHERS = exports.SERVER_NAME = void 0;
37
+ exports.buildHookPlan = buildHookPlan;
38
+ exports.runInit = runInit;
39
+ // @implements A-SPEC-100.2
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const settings_merge_1 = require("./settings-merge");
43
+ const gitignore_merge_1 = require("./gitignore-merge");
44
+ const settings_merge_2 = require("./settings-merge");
45
+ const playbook_skills_1 = require("./playbook-skills");
46
+ const agents_1 = require("./agents");
47
+ const roles_readme_1 = require("./roles-readme");
48
+ const pre_tool_use_1 = require("../hooks/pre-tool-use");
49
+ const governed_precondition_1 = require("./governed-precondition");
50
+ const risk_gate_1 = require("../guardrail/risk-gate");
51
+ /**
52
+ * `holmes-kit init` — wire holmes-kit into a TARGET project (the I/O half; all merge logic is pure
53
+ * and lives in settings-merge.ts / gitignore-merge.ts).
54
+ *
55
+ * The mode choice is the single most consequential decision this tool makes:
56
+ * guardrail (DEFAULT) — PreToolUse matcher 'Bash' + Stop. On a spec-less project this blocks
57
+ * nothing (the shell-code-write gate checks for approved specs first, and the
58
+ * Stop gate early-returns on an empty governed set).
59
+ * governed — matcher 'Bash|Write|Edit'. phaseCheck has NO empty-spec escape, so on a
60
+ * project without an approved A-SPEC this denies EVERY code write — it would
61
+ * brick the target. Hence the precondition check below.
62
+ */
63
+ exports.SERVER_NAME = 'holmes-kit';
64
+ /**
65
+ * @implements A-SPEC-163
66
+ * Which tools the PreToolUse hook is registered for.
67
+ *
68
+ * `guardrail` used to be `Bash` alone, so an installed project's `Write` never reached the hook at
69
+ * all — measured 2026-08-08 against a real install. Fixing the gate's own tool predicate would have
70
+ * changed nothing there, which is the third time a capability existed in the repo and stopped at the
71
+ * installer (REQ-142, REQ-153).
72
+ *
73
+ * `.*` rather than a list: the list is what let `MultiEdit` and `NotebookEdit` through, and the next
74
+ * tool would repeat it. The gate itself decides what is a write (`writesFiles`); the matcher only
75
+ * decides what it gets to look at, and it should look at everything.
76
+ */
77
+ /**
78
+ * @implements A-SPEC-178
79
+ * The matcher `init` installs. Exported so `doctor` compares against THIS value rather than a second
80
+ * spelling of it — a copy drifts the moment this changes, and doctor would then warn about correct
81
+ * wiring, which is worse than saying nothing.
82
+ */
83
+ exports.MATCHERS = { guardrail: '.*', governed: '.*' };
84
+ const SPEC_SUBDIRS = ['01_req', '02_h-spec', '03_a-spec', '04_cpg', '05_t-spec'];
85
+ /** Absolute hook/mcp commands, quoted so a package path containing spaces still works. */
86
+ function buildHookPlan(packageRoot, matcher, specsDir = '.ax/specs') {
87
+ // @implements A-SPEC-190 (round 8) — the spec directory reached the MCP server (HOLMES_SPECS in
88
+ // .mcp.json) and NOT the hooks, which fall back to '.ax/specs'. So `init --mode governed
89
+ // --specs-dir docs/specs` reported governed while the gate looked at an empty default directory
90
+ // and refused nothing: the wiring and the enforcement pointed at two different projects. The
91
+ // hooks are told on their own command line; the default stays byte-identical to previous installs.
92
+ const arg = specsDir === '.ax/specs' ? '' : ` --specs "${specsDir}"`;
93
+ const q = (p) => `node "${p}"${arg}`;
94
+ return {
95
+ preToolUseMatcher: matcher,
96
+ preToolUseCommand: q(path.join(packageRoot, 'dist', 'holmes', 'hooks', 'pre-tool-use.js')),
97
+ stopCommand: q(path.join(packageRoot, 'dist', 'holmes', 'hooks', 'stop.js')),
98
+ };
99
+ }
100
+ /**
101
+ * @implements A-SPEC-190 §17 (round 13) — "could not read" was folded into "is not there", so a
102
+ * settings file the process lacks permission to read (EACCES, EISDIR, a dangling link) was treated
103
+ * as absent and REPLACED by a fresh holmes-only file — and the backup beside it was empty, because
104
+ * the previous content could not be read either. Absence is a fact about the filesystem; an
105
+ * unreadable file is a fact about this process, and only one of them licenses an overwrite.
106
+ */
107
+ function readJson(file) {
108
+ let raw;
109
+ try {
110
+ raw = fs.readFileSync(file, 'utf8');
111
+ }
112
+ catch (e) {
113
+ const missing = e.code === 'ENOENT';
114
+ return { value: undefined, raw: null, parseError: false, unreadable: !missing };
115
+ }
116
+ try {
117
+ return { value: JSON.parse(raw), raw, parseError: false, unreadable: false };
118
+ }
119
+ catch {
120
+ return { value: undefined, raw, parseError: true, unreadable: false };
121
+ }
122
+ }
123
+ /** Backup + atomic write (temp in the same dir, then rename) so a crash can't leave a half file. */
124
+ function writeFileSafe(file, content) {
125
+ fs.mkdirSync(path.dirname(file), { recursive: true });
126
+ // Write through a user's symlink, not over it (round-5: a dotfiles-style symlinked
127
+ // settings.local.json was replaced by a plain file, orphaning the central copy).
128
+ const real = (() => { try {
129
+ return fs.realpathSync(file);
130
+ }
131
+ catch {
132
+ return file;
133
+ } })();
134
+ // @implements A-SPEC-190 §15 (round 12) — the backup followed the symlink's FAR end. For the
135
+ // dotfiles pattern (`.claude/settings.local.json -> ~/dotfiles/…`) every run dropped a new file
136
+ // into the user's OTHER repository while the CLI reported an in-target path, and it copied
137
+ // unconditionally, so byte-identical re-runs accumulated forever. A backup belongs beside the name
138
+ // we were given, is only made when the content actually changes, and is created exclusively.
139
+ let madeBackup;
140
+ if (fs.existsSync(real)) {
141
+ let prev;
142
+ try {
143
+ prev = fs.readFileSync(real, 'utf8');
144
+ }
145
+ catch (e) {
146
+ // §17: 읽지 못한 파일을 덮어쓰지 않는다 — 백업이 0바이트가 되고 사용자 설정이 사라진다.
147
+ throw new Error(`${file} 를 읽을 수 없어 덮어쓰지 않았습니다: ${e instanceof Error ? e.message : String(e)}`);
148
+ }
149
+ if (prev !== content) {
150
+ const stamp = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
151
+ const bak = `${file}.holmes-bak-${stamp}`;
152
+ try {
153
+ fs.writeFileSync(bak, prev, { flag: 'wx' });
154
+ madeBackup = bak;
155
+ }
156
+ catch { /* 백업을 못 만들면 그대로 둔다 — 아래 쓰기는 원자적이다 */ }
157
+ }
158
+ }
159
+ // Same discipline the playbook writer earned in round-5/6: unique suffix + 'wx' (a predictable
160
+ // pid-only path followed a planted symlink), and mode preservation — this file HOLDS the
161
+ // out-of-band approval env, and a rewrite silently widened 0600 → 0644.
162
+ const prevMode = (() => { try {
163
+ return fs.statSync(real).mode & 0o777;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ } })();
168
+ const tmp = `${real}.holmes-tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
169
+ // @implements A-SPEC-190 §18 (round 13) — the playbook writer earned this cleanup in round 9 and
170
+ // it never crossed here: a failed rename left BOTH the temp file (holding the whole settings
171
+ // payload) and the backup behind, one pair per attempt, and died on a raw Node stack. Nothing we
172
+ // could not finish is left on disk, and the failure is reported as a refusal.
173
+ let renamed = false;
174
+ try {
175
+ fs.writeFileSync(tmp, content, { encoding: 'utf8', flag: 'wx' });
176
+ if (prevMode !== undefined)
177
+ fs.chmodSync(tmp, prevMode);
178
+ fs.renameSync(tmp, real);
179
+ renamed = true;
180
+ }
181
+ catch (e) {
182
+ throw new Error(`${file} 를 쓰지 못했습니다: ${e instanceof Error ? e.message : String(e)}`);
183
+ }
184
+ finally {
185
+ if (!renamed) {
186
+ try {
187
+ fs.rmSync(tmp, { force: true });
188
+ }
189
+ catch { /* already gone */ }
190
+ if (madeBackup !== undefined) {
191
+ try {
192
+ fs.rmSync(madeBackup, { force: true });
193
+ }
194
+ catch { /* already gone */ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+ const settingsPathOf = (target, which) => path.join(target, '.claude', which === 'local' ? 'settings.local.json' : 'settings.json');
200
+ exports.settingsPathOf = settingsPathOf;
201
+ /** Does the target already carry holmes wiring? (idempotency + self-disarm guard) */
202
+ function alreadyWired(settings) {
203
+ return Object.values(settings?.hooks ?? {}).some((groups) => (groups ?? []).some((g) => (g.hooks ?? []).some((h) => (0, settings_merge_1.isHolmesCommand)(h.command))));
204
+ }
205
+ function runInit(opts) {
206
+ const messages = [];
207
+ const changes = [];
208
+ const removals = [];
209
+ const settingsPath = (0, exports.settingsPathOf)(opts.target, opts.settingsFile);
210
+ const mcpPath = path.join(opts.target, '.mcp.json');
211
+ const gitignorePath = path.join(opts.target, '.gitignore');
212
+ // The installed package must actually be built — this doubles as the "prepare didn't run" detector.
213
+ const hookJs = path.join(opts.packageRoot, 'dist', 'holmes', 'hooks', 'pre-tool-use.js');
214
+ if (!fs.existsSync(hookJs)) {
215
+ return { ok: false, exitCode: 1, changes, messages: [
216
+ `holmes-kit is not built: ${hookJs} is missing.`,
217
+ // @implements A-SPEC-190 §16 (round 12) — this named a `prepare` script package.json does not
218
+ // declare (only build/mcp/release/test/typecheck exist), so the remedy sent readers to inspect
219
+ // npm settings for a hook that was never there. dist/ ships because `files` lists it.
220
+ '이 패키지는 컴파일된 dist/ 를 함께 배포합니다 — 저장소에서 쓰는 중이라면 `npm run build`, 설치본이라면 재설치하십시오.',
221
+ ] };
222
+ }
223
+ const s = readJson(settingsPath);
224
+ if (s.parseError) {
225
+ return { ok: false, exitCode: 2, changes, messages: [
226
+ `${settingsPath} is not valid JSON (comments/JSONC are not supported here).`,
227
+ 'Refusing to touch it — merge the holmes-kit hooks by hand, or fix the file and re-run.',
228
+ ] };
229
+ }
230
+ // Self-disarm guard: an agent inside a governed session could otherwise call `init --matcher Nothing`
231
+ // to weaken its own governance. Re-wiring an already-wired target needs --force AND a valid
232
+ // out-of-band approval.
233
+ // @implements A-SPEC-190 §19 (round 13) — the self-disarm guard had two doors out. It asked
234
+ // `alreadyWired` about the file THIS invocation happens to name, so a target wired through
235
+ // `.claude/settings.json` was silently re-wired by `--settings local` with no approval at all;
236
+ // and both the guard and the approval requirement excluded `--remove`, which is the most complete
237
+ // way to take governance off. A session must not be able to unwire the gate that governs it,
238
+ // whichever flag it reaches for. Wiring is asked of BOTH settings files.
239
+ const wiredAnywhere = ['local', 'project'].some((which) => alreadyWired(readJson((0, exports.settingsPathOf)(opts.target, which)).value));
240
+ if (!opts.remove && wiredAnywhere && !opts.force) {
241
+ return { ok: false, exitCode: 2, changes, messages: [
242
+ 'This target already has holmes-kit wiring. Re-run with --force to change it.',
243
+ ] };
244
+ }
245
+ if ((opts.force || (opts.remove && wiredAnywhere))
246
+ && !(0, risk_gate_1.approvalCovers)(opts.approval, { kind: 'governance-wiring', target: opts.target }, new Date().toISOString())) {
247
+ // covers, not merely well-formed (round-3): an expired or elsewhere-scoped token re-wired
248
+ // governance — the exact master-key behaviour A-SPEC-133's narrowing exists to end.
249
+ return { ok: false, exitCode: 2, changes, messages: [
250
+ opts.remove
251
+ ? '거버넌스 배선을 벗기는 것도 배선을 바꾸는 것입니다 — 대역외 승인이 필요합니다 (자기무장해제 방지).'
252
+ : '--force changes existing governance wiring and requires an out-of-band approval that covers it.',
253
+ `Set HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why>"}' (scoped approvals need kind "governance-wiring") and re-run.`,
254
+ ] };
255
+ }
256
+ const m = readJson(mcpPath);
257
+ if (m.parseError) {
258
+ return { ok: false, exitCode: 2, changes, messages: [`${mcpPath} is not valid JSON — refusing to touch it.`] };
259
+ }
260
+ const gitignoreBefore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : '';
261
+ if (opts.remove) {
262
+ let settings = (0, settings_merge_1.removeHooks)(s.value);
263
+ settings = (0, settings_merge_1.disableMcpServer)(settings, exports.SERVER_NAME);
264
+ changes.push({ path: settingsPath, before: s.raw, after: s.raw === null ? null : JSON.stringify(settings, null, 2) + '\n' });
265
+ if (m.raw !== null) {
266
+ changes.push({ path: mcpPath, before: m.raw, after: JSON.stringify((0, settings_merge_1.removeMcpServer)(m.value, exports.SERVER_NAME), null, 2) + '\n' });
267
+ }
268
+ if ((0, gitignore_merge_1.hasGitignoreBlock)(gitignoreBefore)) {
269
+ changes.push({ path: gitignorePath, before: gitignoreBefore, after: (0, gitignore_merge_1.removeGitignoreBlock)(gitignoreBefore) });
270
+ }
271
+ // round-7: the past tense fired under --dry-run too, so 'Removed' announced a removal that
272
+ // had not happened — the one spelling whose entire purpose is to write nothing.
273
+ messages.push(opts.dryRun
274
+ ? 'Would remove holmes-kit wiring (holmes-owned entries only; your other settings would be untouched).'
275
+ : 'Removed holmes-kit wiring (holmes-owned entries only; your other settings are untouched).');
276
+ }
277
+ else {
278
+ // @implements A-SPEC-172
279
+ // GOVERNED PRECONDITION: without an approved A-SPEC the phase gate denies every code write.
280
+ // The check runs on BOTH paths. It used to sit behind `!opts.force`, so a forced transition never
281
+ // computed it and said nothing — the same situation the refusal below explains in three lines.
282
+ // `--force` changes what happens with the answer, never whether the answer is known.
283
+ if (opts.mode === 'governed') {
284
+ const specs = (0, pre_tool_use_1.readSpecsSync)(path.join(opts.target, opts.specsDir));
285
+ const approvedAspec = specs.some((sp) => sp.type === 'A-SPEC' && sp.status === 'approved');
286
+ if (!approvedAspec) {
287
+ if (!opts.force) {
288
+ return { ok: false, exitCode: 2, changes, messages: [
289
+ 'Refusing --mode governed: this target has no approved A-SPEC.',
290
+ 'The Write/Edit gate has no empty-spec escape, so it would deny EVERY code edit here.',
291
+ 'Start with --mode guardrail, author a spec chain, approve it out-of-band, then re-run with --mode governed.',
292
+ ] };
293
+ }
294
+ // Passing is the design — greenfield governed is a supported path — but not silently.
295
+ messages.push(...(0, governed_precondition_1.governedWarning)(opts.target));
296
+ }
297
+ }
298
+ const matcher = opts.matcher ?? exports.MATCHERS[opts.mode];
299
+ const plan = buildHookPlan(opts.packageRoot, matcher, opts.specsDir);
300
+ let settings = (0, settings_merge_1.mergeHooks)(s.value, plan);
301
+ if (opts.mcp) {
302
+ settings = (0, settings_merge_1.enableMcpServer)(settings, exports.SERVER_NAME);
303
+ const mcpBin = path.join(opts.packageRoot, 'bin', 'holmes-mcp.js');
304
+ // @implements A-SPEC-179
305
+ // Say what survived. A quiet keep is as unverifiable as the quiet delete this replaced — the
306
+ // operator has no other way to confirm the thing they switched on is still on.
307
+ const prevEnv = m.value
308
+ ?.mcpServers?.[exports.SERVER_NAME]?.env ?? {};
309
+ const kept = Object.keys(prevEnv).filter((k) => !settings_merge_2.HOLMES_OWNED_MCP_ENV.includes(k));
310
+ if (kept.length > 0)
311
+ messages.push(`Preserved your MCP server env: ${kept.join(', ')}`);
312
+ changes.push({ path: mcpPath, before: m.raw, after: JSON.stringify((0, settings_merge_1.mergeMcpServers)(m.value, exports.SERVER_NAME, mcpBin, opts.specsDir), null, 2) + '\n' });
313
+ }
314
+ changes.push({ path: settingsPath, before: s.raw, after: JSON.stringify(settings, null, 2) + '\n' });
315
+ const gitignoreAfter = (0, gitignore_merge_1.mergeGitignore)(gitignoreBefore);
316
+ if (gitignoreAfter !== gitignoreBefore)
317
+ changes.push({ path: gitignorePath, before: gitignoreBefore || null, after: gitignoreAfter });
318
+ messages.push(`mode=${opts.mode} matcher=${matcher} specs=${opts.specsDir}`);
319
+ if (opts.mode === 'guardrail') {
320
+ messages.push('Guardrail mode: nothing is blocked on a spec-less project; only genuinely dangerous shell commands are gated.');
321
+ }
322
+ else {
323
+ messages.push('Governed mode: No-Spec-No-Code is live. Approving a spec needs out-of-band approval:');
324
+ messages.push(` HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why>"}' claude`);
325
+ }
326
+ messages.push('Restart Claude Code — hooks and MCP servers are read at session start.');
327
+ }
328
+ if (opts.dryRun) {
329
+ // @implements A-SPEC-190 (round 8) — dry-run must plan the SAME set the real run touches, and
330
+ // must not print a deletion under the verb 'would write'. Round-7 planned installs only from
331
+ // shipped states, so `--remove` under-reported (orphaned marker-owned skills are deleted by the
332
+ // real run and were absent from the plan: 3 announced, 4 deleted), and every planned path was
333
+ // rendered by the caller's write-verb.
334
+ const { playbookSkillStates } = require('./playbook-skills');
335
+ const states = playbookSkillStates(opts.packageRoot, opts.target);
336
+ if (opts.remove) {
337
+ // removePlaybookSkills deletes every marker-owned `holmes-*` SKILL.md — shipped or orphaned.
338
+ const planned = states.filter((x) => x.state === 'current' || x.state === 'drifted' || x.state === 'orphaned' || x.state === 'aliased');
339
+ for (const p of planned) {
340
+ removals.push(p.state === 'orphaned'
341
+ ? path.join(opts.target, '.claude', 'skills', p.name, 'SKILL.md')
342
+ : (0, playbook_skills_1.skillPathFor)(opts.target, p.name));
343
+ }
344
+ if (planned.length)
345
+ messages.push(`Would remove ${planned.length} recovery skill(s): ${planned.map((p) => p.state === 'orphaned' ? p.name : (0, playbook_skills_1.invocableSkillName)(p.name)).join(', ')}`);
346
+ }
347
+ else {
348
+ // aliased 도 설치되지 않는다(쓰기 전에 거부된다) — 계획이 실행보다 많으면 그것도 거짓말이다.
349
+ const planned = states.filter((x) => x.state !== 'foreign' && x.state !== 'orphaned' && x.state !== 'source-unreadable' && x.state !== 'aliased');
350
+ for (const p of planned)
351
+ changes.push({ path: (0, playbook_skills_1.skillPathFor)(opts.target, p.name), before: null, after: null });
352
+ if (planned.length)
353
+ messages.push(`Would install/refresh ${planned.length} recovery skill(s): ${planned.map((p) => (0, playbook_skills_1.invocableSkillName)(p.name)).join(', ')}`);
354
+ }
355
+ }
356
+ // @implements A-SPEC-193 — 하네스별 배선물. 계산과 쓰기를 나누므로 dry-run 이 실제 실행과
357
+ // 같은 집합을 예고한다(A-SPEC-190 §9 의 규율). Claude 는 위에서 이미 배선했으므로 빈 목록이다.
358
+ if (!opts.remove) {
359
+ for (const agent of opts.agents ?? []) {
360
+ for (const f of (0, agents_1.agentFiles)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir })) {
361
+ const before = fs.existsSync(f.path) ? fs.readFileSync(f.path, 'utf8') : null;
362
+ changes.push({ path: f.path, before, after: f.content });
363
+ }
364
+ messages.push(agents_1.HARNESS_ENFORCES[agent]
365
+ ? `${agent}: 게이트가 집행됩니다 (훅 배선됨).`
366
+ : `${agent}: 도구와 지침만 배선했습니다 — 이 하네스에서는 게이트를 집행하지 않습니다.`);
367
+ // 링크는 내용이 아니라 배치다 — dry-run 에서는 말만 하고, 실제 실행에서만 만든다.
368
+ for (const link of (0, agents_1.agentLinks)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir })) {
369
+ if (opts.dryRun) {
370
+ messages.push(`Would link ${link.path} -> ${link.target} (${link.why})`);
371
+ continue;
372
+ }
373
+ try {
374
+ if (!fs.existsSync(link.path)) {
375
+ fs.mkdirSync(path.dirname(link.path), { recursive: true });
376
+ fs.symlinkSync(link.target, link.path, 'dir');
377
+ messages.push(`linked ${link.path} -> ${link.target}`);
378
+ }
379
+ }
380
+ catch (e) {
381
+ // 심링크를 만들 수 없는 파일시스템(일부 Windows 배치)에서는 사실을 말하고 넘어간다 —
382
+ // 조용히 복사본을 만들면 그것이 곧 드리프트의 출발점이다.
383
+ messages.push(`${link.path} 를 링크하지 못했습니다(${e instanceof Error ? e.message : String(e)}) —`
384
+ + ` 이 하네스가 스킬을 보려면 ${link.target} 를 직접 링크하거나 복사하십시오.`);
385
+ }
386
+ }
387
+ }
388
+ }
389
+ if (!opts.dryRun) {
390
+ // @implements A-SPEC-190 §17/§18 (round 13) — a write we refuse to make (unreadable original)
391
+ // or cannot finish (rename failure) is a REFUSAL with a next action, not a raw Node stack. The
392
+ // files already written stay written and are reported; nothing half-written is left behind.
393
+ for (const c of changes) {
394
+ if (c.after === null)
395
+ continue;
396
+ try {
397
+ writeFileSafe(c.path, c.after);
398
+ }
399
+ catch (e) {
400
+ return { ok: false, exitCode: 2, changes, messages: [
401
+ ...messages,
402
+ e instanceof Error ? e.message : String(e),
403
+ '권한과 소유자를 확인한 뒤 다시 실행하십시오 — 읽지 못한 파일은 덮어쓰지 않았습니다.',
404
+ ] };
405
+ }
406
+ }
407
+ if (!opts.remove && opts.mode === 'governed') {
408
+ for (const d of SPEC_SUBDIRS)
409
+ fs.mkdirSync(path.join(opts.target, opts.specsDir, d), { recursive: true });
410
+ }
411
+ // @implements A-SPEC-142
412
+ // The recovery layer. Measured before this existed: `npm pack` carried zero playbooks and init
413
+ // wrote none, so an installed user got every deny string and none of the guidance written to
414
+ // answer them. Advisory by design — no gate decision reads these, so a failure here reports and
415
+ // never fails the init that has already done real work.
416
+ if (opts.remove) {
417
+ const r = (0, playbook_skills_1.removePlaybookSkills)(opts.target);
418
+ // round-9: the failures were dropped on the floor — a refusal ('this is a symlink; I did not
419
+ // delete through it') became silence, so the report said 3 and the disk said 2 with no word
420
+ // about the third.
421
+ for (const f of r.failed)
422
+ messages.push(`Could not remove ${f.name}: ${f.reason}`);
423
+ // round-7: the names are RAW directory names already — re-prefixing them produced the
424
+ // canonical skill's name for a case variant, i.e. a delete report about a different directory.
425
+ if (r.removed.length)
426
+ messages.push(`Removed ${r.removed.length} recovery skill(s) under ${path.join(opts.target, '.claude', 'skills')}: ${r.removed.join(', ')}`);
427
+ }
428
+ else {
429
+ const r = (0, playbook_skills_1.installPlaybookSkills)(opts.packageRoot, opts.target);
430
+ if (r.written.length)
431
+ messages.push(`Installed ${r.written.length} recovery skill(s) under ${path.join(opts.target, '.claude', 'skills')}: ${r.written.map(playbook_skills_1.invocableSkillName).join(', ')}`);
432
+ for (const n of r.skipped) {
433
+ const file = (0, playbook_skills_1.skillPathFor)(opts.target, n);
434
+ messages.push(`Left your own ${fs.existsSync(file) ? file : path.dirname(file)} untouched (not written by holmes-kit).`);
435
+ }
436
+ for (const b of r.backedUp)
437
+ messages.push(`Kept the previous ${(0, playbook_skills_1.invocableSkillName)(b.name)} content at ${b.path}`);
438
+ for (const f of r.failed)
439
+ messages.push(`Could not install recovery skill ${(0, playbook_skills_1.skillLabel)(f.name)}: ${f.reason}`);
440
+ }
441
+ }
442
+ // @implements A-SPEC-153
443
+ // Role guidance, deliberately INERT. `loadRolePolicy` reads only .yaml/.yml, so this markdown
444
+ // leaves the gate's decision unchanged — a project turns roles on by adding its own policy file.
445
+ // Shipping an active policy here would put every newly initialised project behind a fail-closed
446
+ // gate on first run.
447
+ if (opts.remove) {
448
+ if (!opts.dryRun && (0, roles_readme_1.removeRolesReadme)(opts.target))
449
+ messages.push('Removed .ax/roles/README.md (role guidance).');
450
+ }
451
+ else {
452
+ // @implements A-SPEC-171
453
+ // `--dry-run` says "write nothing", and this call did not honour it — measured 2026-08-12, a
454
+ // dry run left .ax/roles/README.md behind. Introduced by REQ-153, which added the guidance
455
+ // without threading the flag through. The message still prints, because describing what WOULD
456
+ // happen is exactly what a dry run is for.
457
+ if (!opts.dryRun)
458
+ (0, roles_readme_1.installRolesReadme)(opts.target);
459
+ messages.push('Role policy is OPTIONAL and currently off — see .ax/roles/README.md to enable it.');
460
+ }
461
+ return { ok: true, exitCode: 0, messages, changes, removals };
462
+ }