@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,711 @@
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.PLAYBOOK_SKILL_MARKER = void 0;
37
+ exports.installedSkillBytes = installedSkillBytes;
38
+ exports.carriesMarker = carriesMarker;
39
+ exports.frontmatterEnd = frontmatterEnd;
40
+ exports.invocableSkillName = invocableSkillName;
41
+ exports.skillPathFor = skillPathFor;
42
+ exports.installPlaybookSkills = installPlaybookSkills;
43
+ exports.removePlaybookSkills = removePlaybookSkills;
44
+ exports.skillLabel = skillLabel;
45
+ exports.playbookSkillStates = playbookSkillStates;
46
+ exports.classifyExtraEntries = classifyExtraEntries;
47
+ exports.installedPlaybookCount = installedPlaybookCount;
48
+ // @implements A-SPEC-142
49
+ const fs = __importStar(require("node:fs"));
50
+ const path = __importStar(require("node:path"));
51
+ /**
52
+ * Install the recovery playbooks into a project as agent skills.
53
+ *
54
+ * WHY this exists, measured: the gate denies with five deliberately specific strings, and each
55
+ * playbook is a skill whose `description` quotes the exact deny text that should summon it — the
56
+ * layer that turns a refusal into a next step. It did not ship. `npm pack --dry-run` carried ZERO
57
+ * playbook files, because `files: ["bin/","dist/"]` covered neither `playbooks/` nor anything like
58
+ * it, and `init` never wrote one either. Every installed user got the enforcement and none of the
59
+ * recovery, while all 47 conformance assertions passed in this repository and protected nobody.
60
+ *
61
+ * Playbooks are ADVISORY. No gate decision reads them, and a user who deletes every one of them
62
+ * still gets identical enforcement — which is why a missing playbook is a doctor WARN, never a FAIL.
63
+ */
64
+ /**
65
+ * Marks a file as OURS. Ownership is decided by this marker and never by the path, because the path
66
+ * is exactly what a user's own skill of the same name would occupy, and overwriting that would be
67
+ * the same class of error as clobbering their settings.
68
+ */
69
+ exports.PLAYBOOK_SKILL_MARKER = '<!-- installed by holmes-kit; edits will be replaced on upgrade -->';
70
+ /**
71
+ * @implements A-SPEC-190 (round 7)
72
+ * The bytes an install writes. The marker used to go FIRST, which put it ahead of the playbook's
73
+ * YAML frontmatter — and frontmatter is only frontmatter when it starts at byte 0. Measured on this
74
+ * repository's own installed skills: the agent's skill list rendered
75
+ * `holmes-adopt: <!-- installed by holmes-kit; edits will be replaced on upgrade -->` for all three,
76
+ * i.e. the `description` that quotes the deny text — the entire reason this layer exists, since a
77
+ * refusal is supposed to summon the playbook by that sentence — was destroyed at install time,
78
+ * while doctor certified the layout as `current`. The marker now follows the frontmatter block.
79
+ */
80
+ function installedSkillBytes(body) {
81
+ const end = frontmatterEnd(body);
82
+ if (end === null)
83
+ return `${exports.PLAYBOOK_SKILL_MARKER}\n${body}`;
84
+ // The offset is computed on normalised text; a body with CRLF or a BOM is rewritten normalised so
85
+ // the marker cannot land inside the frontmatter it must follow.
86
+ const text = body.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
87
+ const head = text.slice(0, end);
88
+ return `${head}${head.endsWith('\n') ? '' : '\n'}${exports.PLAYBOOK_SKILL_MARKER}\n${text.slice(end)}`;
89
+ }
90
+ /**
91
+ * @implements A-SPEC-190 (round 7)
92
+ * Ownership is the marker AT ITS PLACE, not the string anywhere in the file. `includes` made any
93
+ * document that merely QUOTES the marker — documentation about holmes-kit, a review note — read as
94
+ * ours: doctor called it `drifted` and the refresh it advised overwrote the user's file with no
95
+ * backup. A quotation is not a claim of ownership.
96
+ */
97
+ function carriesMarker(content) {
98
+ // Round-8: judging on raw bytes made ownership brittle in the direction that cannot be repaired.
99
+ // A CRLF normalisation (a Windows editor, `core.autocrlf`, `.gitattributes eol=crlf`), a BOM, or a
100
+ // trailing space on the marker line each turned a file the kit itself wrote into `foreign` —
101
+ // permanently, since install then skips it forever and doctor advises deleting it as "yours".
102
+ // Whitespace and line endings are not ownership; the marker is.
103
+ const text = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
104
+ const markerAt = (s) => {
105
+ const line = s.split('\n', 1)[0];
106
+ return line.trimEnd() === exports.PLAYBOOK_SKILL_MARKER;
107
+ };
108
+ if (markerAt(text))
109
+ return true; // 옛 배치(마커 먼저) — 우리 것이므로 고칠 수 있다
110
+ const fm = frontmatterEnd(text);
111
+ // 빈 줄 하나가 소유를 지우지 않는다(round-9: frontmatter 뒤 개행 하나로 영구 foreign 이 됐다).
112
+ return fm !== null && markerAt(text.slice(fm).replace(/^\s*\n/, ''));
113
+ }
114
+ /**
115
+ * @implements A-SPEC-190 (round 8)
116
+ * Where a leading YAML frontmatter block ends, or null. Round-8 measured the shapes the first
117
+ * regex missed — a BOM, an EMPTY block (`---\n---\n`), and a closing fence at end-of-file with no
118
+ * trailing newline — each of which sent the marker back to byte 0, recreating the CRITICAL the
119
+ * round-7 fix existed to remove while doctor certified the result as `current`.
120
+ */
121
+ function frontmatterEnd(content) {
122
+ const text = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
123
+ if (!/^---[ \t]*(\n|$)/.test(text))
124
+ return null;
125
+ const m = /\n---[ \t]*(\n|$)/.exec(text.slice(3));
126
+ if (!m)
127
+ return null;
128
+ return 3 + m.index + m[0].length;
129
+ }
130
+ /**
131
+ * @implements A-SPEC-172
132
+ * What the user types to reach a playbook.
133
+ *
134
+ * The install directory is namespaced (`holmes-adopt`), but the internal playbook name is bare
135
+ * (`adopt`), and every message echoed the bare one. Measured 2026-08-12 on an installed tarball: init
136
+ * printed `Installed 3 recovery skill(s): adopt, author-slice, promote-slice` while nothing by those
137
+ * names existed. Anything shown to a user goes through here.
138
+ */
139
+ function invocableSkillName(playbookName) {
140
+ return `holmes-${playbookName}`;
141
+ }
142
+ /** `<target>/.claude/skills/holmes-<name>/SKILL.md` */
143
+ function skillPathFor(target, playbookName) {
144
+ return path.join(target, '.claude', 'skills', invocableSkillName(playbookName), 'SKILL.md');
145
+ }
146
+ function playbookNames(packageRoot) {
147
+ const dir = path.join(packageRoot, 'playbooks');
148
+ try {
149
+ return fs.readdirSync(dir)
150
+ // Presence by lstat, not existsSync (round-8): existsSync follows links and needs read
151
+ // permission, so a DANGLING PLAYBOOK.md link or an unreadable playbook directory dropped the
152
+ // name from the shipped list — and a currently-installed skill of that name then read as
153
+ // `orphaned`, i.e. doctor told the operator to DELETE a skill the package still ships.
154
+ // A damaged source is reported as damage (`source-unreadable`), never as an orphan.
155
+ .filter((n) => { try {
156
+ fs.lstatSync(path.join(dir, n, 'PLAYBOOK.md'));
157
+ return true;
158
+ }
159
+ catch {
160
+ return false;
161
+ } })
162
+ .sort();
163
+ }
164
+ catch {
165
+ return []; // no playbooks directory: nothing to install, not an error
166
+ }
167
+ }
168
+ const isOurs = (file) => {
169
+ try {
170
+ return carriesMarker(fs.readFileSync(file, 'utf8'));
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ };
176
+ /**
177
+ * @implements A-SPEC-190 (round 8)
178
+ * Which shipped names resolve to the SAME file as another (a link between skill directories, at file
179
+ * or directory level). Shared by install and by the state report so both say the same thing.
180
+ */
181
+ function aliasGroupsOf(packageRoot, target) {
182
+ const names = playbookNames(packageRoot);
183
+ const realOf = (name) => {
184
+ const dest = skillPathFor(target, name);
185
+ try {
186
+ return fs.realpathSync(dest);
187
+ }
188
+ catch { /* not yet materialised */ }
189
+ try {
190
+ return path.join(fs.realpathSync(path.dirname(dest)), path.basename(dest));
191
+ }
192
+ catch {
193
+ return dest;
194
+ }
195
+ };
196
+ // @implements A-SPEC-190 §10 (round 9) — identity is (dev, ino), not a path STRING. On a
197
+ // case-insensitive filesystem (macOS default) `…/holmes-Adopt/SKILL.md` and `…/holmes-adopt/SKILL.md`
198
+ // are one file with two spellings, so the string-keyed grouping saw two destinations and let one
199
+ // playbook's body overwrite the other's — the exact harm the round-8 pre-scan was added to stop.
200
+ const identity = (name) => {
201
+ const real = realOf(name);
202
+ try {
203
+ const st = fs.statSync(real);
204
+ return `${st.dev}:${st.ino}`;
205
+ }
206
+ catch { /* not yet there */ }
207
+ try {
208
+ const st = fs.statSync(path.dirname(real));
209
+ return `${st.dev}:${st.ino}:${path.basename(real).toLowerCase()}`;
210
+ }
211
+ catch { /* dangling */ }
212
+ // round-9: a DANGLING directory link resolves to nothing, so the old fallback compared path
213
+ // STRINGS — two skills whose links point at one missing directory read as two destinations and
214
+ // one playbook's body overwrote the other's, exit 0. Follow the link TEXTUALLY: where it points
215
+ // is identity enough, even when nothing is there yet.
216
+ const dest = skillPathFor(target, name);
217
+ let dir = path.dirname(dest);
218
+ for (let hop = 0; hop < 10; hop++) {
219
+ try {
220
+ if (!fs.lstatSync(dir).isSymbolicLink())
221
+ break;
222
+ dir = path.resolve(path.dirname(dir), fs.readlinkSync(dir));
223
+ }
224
+ catch {
225
+ break;
226
+ }
227
+ }
228
+ return path.join(dir, path.basename(dest)).toLowerCase();
229
+ };
230
+ const groups = new Map();
231
+ for (const name of names) {
232
+ const key = identity(name);
233
+ const g = groups.get(key);
234
+ if (g)
235
+ g.push(name);
236
+ else
237
+ groups.set(key, [name]);
238
+ }
239
+ const out = new Map();
240
+ for (const [, group] of groups) {
241
+ if (group.length < 2)
242
+ continue;
243
+ for (const n of group)
244
+ out.set(n, group.filter((o) => o !== n));
245
+ }
246
+ return out;
247
+ }
248
+ function aliasedNames(packageRoot, target) {
249
+ return new Set(aliasGroupsOf(packageRoot, target).keys());
250
+ }
251
+ /**
252
+ * The skills root, but only if it is genuinely INSIDE the target.
253
+ *
254
+ * @implements A-SPEC-190 §12 (round 11) — the link was refused at `.claude/skills` and at each skill
255
+ * directory, and traversed one level ABOVE both: with `<target>/.claude` itself a link to a central
256
+ * store, install wrote five shipped skills into someone else's tree and reported success, and
257
+ * `--remove` then deleted marker-owned files there with `failed` empty. One containment question,
258
+ * asked once, cannot be walked around by moving the link up a level. It also names the component
259
+ * that is actually a link — the old wording blamed `.claude/skills` for `.claude`.
260
+ */
261
+ function skillsRootInside(target) {
262
+ const dir = path.join(target, '.claude', 'skills');
263
+ let base;
264
+ try {
265
+ base = fs.realpathSync(target);
266
+ }
267
+ catch {
268
+ return { dir };
269
+ } // 타깃이 없으면 상류가 답한다
270
+ // @implements A-SPEC-193 §7 — 묻는 것은 "링크인가"가 아니라 "**타깃 밖으로 나가는가**"다.
271
+ // 첫 판(§12)은 해소된 경로가 제자리와 다르기만 하면 거부했는데, 그것은 다중 에이전트의 표준
272
+ // 배치를 통째로 막는다: 원본을 `.agents/skills` 에 두고 `.claude/skills` 를 그리로 링크하는
273
+ // SSOT 는 타깃 **안**에 머문다(실측: 그 배치에서 written 0, 전 스킬 unresolvable). §12 가
274
+ // 막으려던 해악은 타깃 밖 중앙 저장소의 파괴였고, 그 해악은 아래 검사로 그대로 막힌다.
275
+ for (const rel of [path.join('.claude'), path.join('.claude', 'skills')]) {
276
+ const at = path.join(target, rel);
277
+ let real;
278
+ try {
279
+ real = fs.realpathSync(at);
280
+ }
281
+ catch {
282
+ continue;
283
+ } // 아직 없는 것은 여기서 만든다
284
+ if (real !== base && !real.startsWith(base + path.sep)) {
285
+ return { refusal: `${at} 가 타깃(${target}) 밖(${real})을 가리킵니다 — 링크 너머의 파일을 쓰거나 지우지 않았습니다. 링크를 풀거나 그 저장소에서 직접 다루십시오` };
286
+ }
287
+ }
288
+ return { dir };
289
+ }
290
+ function installPlaybookSkills(packageRoot, target) {
291
+ const out = { written: [], backedUp: [], skipped: [], failed: [] };
292
+ const rootCheck = skillsRootInside(target);
293
+ if ('refusal' in rootCheck) {
294
+ out.failed.push({ name: path.join('.claude', 'skills'), reason: rootCheck.refusal });
295
+ return out;
296
+ }
297
+ // @implements A-SPEC-190 (round 8) — two destinations can resolve to ONE file (a link between
298
+ // skill directories, at file OR directory level). The round-7 guard noticed the collision only
299
+ // when the SECOND write arrived, by which time the first had already destroyed the other skill's
300
+ // body — success reported, never converging. The aliases are computed BEFORE anything is written,
301
+ // and every member of a colliding set is refused with the alias named.
302
+ const names = playbookNames(packageRoot);
303
+ const collidingWith0 = aliasGroupsOf(packageRoot, target);
304
+ const realOf = (name) => {
305
+ const dest = skillPathFor(target, name);
306
+ try {
307
+ return fs.realpathSync(dest);
308
+ }
309
+ catch { /* not yet materialised */ }
310
+ // The file may not exist while its DIRECTORY is a link into another skill — resolve the parent.
311
+ try {
312
+ return path.join(fs.realpathSync(path.dirname(dest)), path.basename(dest));
313
+ }
314
+ catch {
315
+ return dest;
316
+ }
317
+ };
318
+ const collidingWith = collidingWith0;
319
+ for (const name of names) {
320
+ const others = collidingWith.get(name);
321
+ if (others) {
322
+ out.failed.push({ name, reason: `${skillPathFor(target, name)} 와 ${others.map((o) => skillPathFor(target, o)).join(', ')} 가 같은 파일(${realOf(name)})로 해석됩니다 — 한쪽 본문이 다른 쪽을 덮어쓰지 않도록 아무것도 쓰지 않았습니다. 링크를 풀고 다시 실행하십시오` });
323
+ }
324
+ }
325
+ for (const name of names) {
326
+ if (collidingWith.has(name))
327
+ continue;
328
+ const dest = skillPathFor(target, name);
329
+ try {
330
+ // Presence by lstat, not existsSync: existsSync FOLLOWS symlinks, so a user's broken or
331
+ // self-referential symlink read as "absent" and the shipped file was written over it —
332
+ // while doctor, classifying the same entry foreign, promised "leaves it untouched"
333
+ // (round-2 ELOOP finding). An entry we cannot prove ours is never overwritten.
334
+ const present = (() => {
335
+ try {
336
+ fs.lstatSync(dest);
337
+ return true;
338
+ }
339
+ catch { /* fall through */ }
340
+ try {
341
+ return fs.lstatSync(path.dirname(dest)).isSymbolicLink();
342
+ }
343
+ catch {
344
+ return false;
345
+ }
346
+ })();
347
+ if (present && !isOurs(dest)) {
348
+ out.skipped.push(name);
349
+ continue;
350
+ }
351
+ const body = fs.readFileSync(path.join(packageRoot, 'playbooks', name, 'PLAYBOOK.md'), 'utf8');
352
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
353
+ // Write via a temp file in the same directory, then rename: a half-written skill is worse than
354
+ // an absent one, and rename within a directory is atomic. Same discipline as the settings merge.
355
+ // Rename onto the REAL file: a user's file-level symlink to a marker-owned SKILL.md (dotfiles
356
+ // pattern) was replaced by a plain file, leaving the linked central copy stale under a marker
357
+ // that promises replacement on upgrade (round-3). Resolving first refreshes THROUGH the link
358
+ // and preserves it; for a plain file realpath is the path itself.
359
+ const real = (() => { try {
360
+ return fs.realpathSync(dest);
361
+ }
362
+ catch {
363
+ return dest;
364
+ } })();
365
+ // Unique name + 'wx' (round-5): a FIXED tmp name silently consumed a user's file planted
366
+ // there, followed a planted symlink, and raced concurrent runs. 'wx' refuses to open any
367
+ // existing path (including a symlink) and the suffix defuses collisions.
368
+ const tmp = `${real}.holmes-tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
369
+ // Preserve the destination's mode (round-5: refreshing through a link rewrote a 0600
370
+ // central copy as 0644).
371
+ const prevMode = (() => { try {
372
+ return fs.statSync(real).mode & 0o777;
373
+ }
374
+ catch {
375
+ return undefined;
376
+ } })();
377
+ // @implements A-SPEC-190 (round 8) — a marker-owned file whose bytes differ is REWRITTEN, and
378
+ // that is the point of refresh. But 'ours' is judged by a marker anyone can paste at the top
379
+ // of their own notes, so the rewrite keeps a recoverable copy the way init's settings merge
380
+ // does. Nothing is destroyed without a `.holmes-bak` beside it, and the caller is told.
381
+ // @implements A-SPEC-190 §10 (round 9) — the round-8 backup was itself a hazard: a FIXED name
382
+ // written with the default flag followed a planted `SKILL.md.holmes-bak` symlink and destroyed
383
+ // an arbitrary file outside the target, a second rewrite overwrote the first backup (the
384
+ // user's original gone for good), a swallowed failure left the only copy destroyed in
385
+ // silence, and utf8 round-tripping replaced non-UTF8 bytes with U+FFFD. Bytes, a unique name,
386
+ // 'wx' (which refuses any existing path INCLUDING a symlink), and — if the copy cannot be
387
+ // made — the rewrite does not happen at all.
388
+ const prevBytes = (() => { try {
389
+ return fs.readFileSync(real);
390
+ }
391
+ catch {
392
+ return undefined;
393
+ } })();
394
+ const wanted = installedSkillBytes(body);
395
+ if (prevBytes !== undefined && prevBytes.toString('utf8') !== wanted) {
396
+ // round-9: beside the DESTINATION, never beside the link's far end. The rewrite follows a
397
+ // user's symlink by design (§5g), but a file the kit CREATES belongs inside the target the
398
+ // operator named — a backup appearing in a central dotfiles repo is a write they never
399
+ // asked for.
400
+ // round-9 (3rd): asking whether the SKILL DIRECTORY is a link closed one level and left the
401
+ // one above it open — with `.claude/skills` itself linked into a dotfiles repo the backup
402
+ // still landed outside the target, and the report printed an in-target path for a file that
403
+ // was never there. Containment is a fact about the RESOLVED location, not about which
404
+ // component happened to be a link.
405
+ //
406
+ // When neither candidate resolves inside the target, nothing new is invented: this falls
407
+ // into the rule §10(a) already set — a rewrite whose previous content cannot be preserved
408
+ // does not happen, and says so. Minting a backup directory of our own in a project that
409
+ // deliberately keeps its skills elsewhere would be one more write the operator did not ask
410
+ // for, which is the very thing this clause exists to stop.
411
+ const insideTarget = (p) => {
412
+ try {
413
+ const real = fs.realpathSync(path.dirname(p));
414
+ const base = fs.realpathSync(target);
415
+ return real === base || real.startsWith(base + path.sep);
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ };
421
+ const suffix = `.holmes-bak-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
422
+ const bak = [
423
+ `${dest}${suffix}`,
424
+ path.join(path.dirname(path.dirname(dest)), `${invocableSkillName(name)}.SKILL.md${suffix}`),
425
+ ].find(insideTarget);
426
+ if (bak === undefined) {
427
+ out.failed.push({ name, reason: `${dest} 의 이전 내용을 타깃(${target}) 안에 보존할 수 없어 다시 쓰지 않았습니다 — .claude/skills 가 타깃 밖을 가리키는 링크입니다. 링크를 풀거나 그 저장소에서 직접 갱신하십시오` });
428
+ continue;
429
+ }
430
+ try {
431
+ fs.writeFileSync(bak, prevBytes, { flag: 'wx' });
432
+ out.backedUp.push({ name, path: bak });
433
+ }
434
+ catch (bakErr) {
435
+ out.failed.push({ name, reason: `${skillPathFor(target, name)} 의 이전 내용을 보존할 수 없어 다시 쓰지 않았습니다: ${bakErr instanceof Error ? bakErr.message : String(bakErr)}` });
436
+ continue;
437
+ }
438
+ }
439
+ let madeBackup = out.backedUp.find((b) => b.name === name && b.path.includes('holmes-bak-'))?.path;
440
+ try {
441
+ fs.writeFileSync(tmp, wanted, { encoding: 'utf8', flag: 'wx' });
442
+ if (prevMode !== undefined)
443
+ fs.chmodSync(tmp, prevMode);
444
+ fs.renameSync(tmp, real);
445
+ madeBackup = undefined; // 갱신이 끝났으므로 백업은 정당하게 남는다
446
+ }
447
+ finally {
448
+ // round-9: a failed rewrite left BOTH the backup and the temp file behind, so following
449
+ // doctor's advice grew two files per attempt — a remedy loop that never converges and never
450
+ // repairs. Nothing we could not finish is left on disk.
451
+ try {
452
+ fs.rmSync(tmp, { force: true });
453
+ }
454
+ catch { /* already gone */ }
455
+ if (madeBackup !== undefined) {
456
+ try {
457
+ fs.rmSync(madeBackup, { force: true });
458
+ }
459
+ catch { /* leave it */ }
460
+ out.backedUp = out.backedUp.filter((b) => b.path !== madeBackup);
461
+ }
462
+ }
463
+ out.written.push(name);
464
+ }
465
+ catch (err) {
466
+ // A permission problem must not kill an init that has already done real work.
467
+ // round-7: the raw message named the unique temp path ('…SKILL.md.holmes-tmp-4711-x9f2'),
468
+ // a file that never existed by the time the operator read it — the reason now names the
469
+ // destination they can actually inspect.
470
+ const raw = err instanceof Error ? err.message : String(err);
471
+ out.failed.push({ name, reason: `${skillPathFor(target, name)}: ${raw.replace(/\.holmes-tmp-\d+-[a-z0-9]+/g, '')}` });
472
+ }
473
+ }
474
+ return out;
475
+ }
476
+ /** Removes ONLY files carrying the marker; a foreign file, and the directory holding it, are left. */
477
+ function removePlaybookSkills(target) {
478
+ const out = { removed: [], failed: [] };
479
+ // round-9 (3rd): the per-skill guard refused a link while `readdirSync` had already followed the
480
+ // link ONE LEVEL UP — a `.claude/skills -> ~/dotfiles/skills` project lost its central store to
481
+ // `init --remove`, with `failed` empty and the report naming in-target paths nothing touched.
482
+ // The same one physical link cannot be refused below and traversed above. §12 moved that question
483
+ // one level further up, where `.claude` itself was the link.
484
+ const rootCheck = skillsRootInside(target);
485
+ if ('refusal' in rootCheck) {
486
+ out.failed.push({ name: path.join('.claude', 'skills'), reason: rootCheck.refusal });
487
+ return out;
488
+ }
489
+ const skillsDir = rootCheck.dir;
490
+ let entries;
491
+ try {
492
+ entries = fs.readdirSync(skillsDir);
493
+ }
494
+ catch {
495
+ return out;
496
+ }
497
+ for (const entry of entries.sort()) {
498
+ // Case-folded prefix (round-3): a case-only rename to `Holmes-adopt` still read as current on a
499
+ // case-insensitive FS, but failed this exact-case check — uninstall left the marker-owned file
500
+ // behind and the count disagreed with the states. Ownership is the MARKER's job, not the case's.
501
+ if (!entry.toLowerCase().startsWith('holmes-'))
502
+ continue;
503
+ const file = path.join(skillsDir, entry, 'SKILL.md');
504
+ try {
505
+ // @implements A-SPEC-190 (round 8) — a skill DIRECTORY the user manages as a symlink pointed
506
+ // at a central copy OUTSIDE the target: remove deleted that copy, reported the in-target path
507
+ // it never touched, and left the link dangling so reinstall failed forever after.
508
+ if (fs.lstatSync(path.join(skillsDir, entry)).isSymbolicLink()) {
509
+ out.failed.push({ name: entry, reason: `${path.join(skillsDir, entry)} 는 심볼릭 링크입니다 — 링크 너머의 파일을 지우지 않았습니다. 링크를 직접 정리하십시오` });
510
+ continue;
511
+ }
512
+ if (!fs.existsSync(file) || !isOurs(file))
513
+ continue;
514
+ fs.rmSync(file);
515
+ // round-9: our own backups kept the directory alive, and no state named the leftovers — the
516
+ // uninstall never converged. What we wrote, we take back.
517
+ try {
518
+ for (const leftover of fs.readdirSync(path.join(skillsDir, entry))) {
519
+ if (leftover.startsWith('SKILL.md.holmes-bak'))
520
+ fs.rmSync(path.join(skillsDir, entry, leftover), { force: true });
521
+ }
522
+ }
523
+ catch { /* leave it */ }
524
+ // Only if nothing else lives there — a user may keep their own files alongside ours.
525
+ try {
526
+ if (fs.readdirSync(path.join(skillsDir, entry)).length === 0)
527
+ fs.rmdirSync(path.join(skillsDir, entry));
528
+ }
529
+ catch { /* leave it */ }
530
+ out.removed.push(entry);
531
+ }
532
+ catch (err) {
533
+ out.failed.push({ name: entry, reason: err instanceof Error ? err.message : String(err) });
534
+ }
535
+ }
536
+ return out;
537
+ }
538
+ // @implements A-SPEC-190
539
+ // @implements A-SPEC-190 §20 (round 13) — `unresolvable`: the skills root points outside the target,
540
+ // so nothing under it is ours to judge OR to repair. A state of its own, because calling it
541
+ // `drifted` prescribes a refresh that is refused every time.
542
+ /**
543
+ * How a failed entry should be NAMED in a report.
544
+ *
545
+ * @implements A-SPEC-190 §21 (round 13) — root-level refusals carry `.claude/skills` as their name,
546
+ * and the callers ran every name through `invocableSkillName`, printing `holmes-.claude/skills` —
547
+ * a skill that does not exist. Round 7 fixed the same prefixing on the remove side. A name that
548
+ * already looks like a path is a path.
549
+ */
550
+ function skillLabel(name) {
551
+ return name.includes('/') || name.includes('\\') ? name : invocableSkillName(name);
552
+ }
553
+ /**
554
+ * @implements A-SPEC-190
555
+ * Per-playbook state of the installed copy, judged against the bytes install would write NOW
556
+ * (`marker + '\n' + shipped body`). An installed skill is a COPY: the kit's conventions move on
557
+ * while the copy stays frozen at install time. Measured 2026-08-15 in this very repository: all
558
+ * three installed skills had drifted (diff 50/38/64 lines) and the author-slice copy still taught
559
+ * the abolished `A-SPEC-125.5 → T-SPEC-1255` id convention — while the count-only doctor check
560
+ * reported "3 of 3 … PASS". Equality with the reconstruction is the single source of truth; a
561
+ * version stamp or hash file would be a second truth that itself goes stale.
562
+ *
563
+ * A shipped body that cannot be read (a race — playbookNames just confirmed it) yields no entry
564
+ * for that name: silence beats a wrong repair hint.
565
+ */
566
+ function playbookSkillStates(packageRoot, target) {
567
+ const out = [];
568
+ // @implements A-SPEC-190 §20 (round 13) — §12's fence went on install and remove, not on the
569
+ // function DOCTOR judges from. So with `<target>/.claude` linked outside the target, doctor read
570
+ // the files beyond the link, declared them drifted/missing, and prescribed `skills refresh` —
571
+ // which §12 refuses every time. A diagnosis whose remedy can never run is worse than no
572
+ // diagnosis: it teaches the operator to ignore the tool. Say the one thing that IS actionable.
573
+ const fenced = skillsRootInside(target);
574
+ if ('refusal' in fenced) {
575
+ return playbookNames(packageRoot).map((name) => ({ name, state: 'unresolvable' }));
576
+ }
577
+ const shipped = playbookNames(packageRoot);
578
+ for (const name of shipped) {
579
+ let body;
580
+ // @implements A-SPEC-190 (round 7) — an unreadable SHIPPED playbook used to `continue`, so the
581
+ // name vanished from the report: doctor said PASS (or the nonsense '3 of 0') while the refresh
582
+ // it never advised exited 1 on the same target. The package's own damage gets its own state.
583
+ try {
584
+ body = fs.readFileSync(path.join(packageRoot, 'playbooks', name, 'PLAYBOOK.md'), 'utf8');
585
+ }
586
+ catch {
587
+ out.push({ name, state: 'source-unreadable' });
588
+ continue;
589
+ }
590
+ const dest = skillPathFor(target, name);
591
+ // @implements A-SPEC-190 (round 8) — two skills resolving to ONE file is a state, not just an
592
+ // install-time failure: doctor used to call one of them `drifted` and advise a refresh that
593
+ // could only ever exit 1. The operator has to unlink; say so.
594
+ if (aliasedNames(packageRoot, target).has(name)) {
595
+ out.push({ name, state: 'aliased' });
596
+ continue;
597
+ }
598
+ let installed;
599
+ try {
600
+ installed = fs.readFileSync(dest, 'utf8');
601
+ }
602
+ catch (err) {
603
+ // ENOENT alone is absence — but only when nothing occupies the path: readFileSync FOLLOWS
604
+ // symlinks, so a BROKEN link also reads ENOENT while install's lstat sees it present and
605
+ // skips it as foreign (round-3: doctor said 'missing', the advised refresh said 'yours' —
606
+ // an unresolvable loop). Presence is judged the way install judges it: by lstat. Any other
607
+ // read failure (EACCES, EISDIR, ELOOP…) means the marker cannot be read, and ownership is
608
+ // decided by the marker — a file we cannot prove ours is not ours to repair.
609
+ const lstatPresent = (() => {
610
+ try {
611
+ fs.lstatSync(dest);
612
+ return true;
613
+ }
614
+ catch { /* fall through */ }
615
+ // round-8: the SKILLS directory itself may be a dangling link. Reading through it is ENOENT
616
+ // while install's mkdir/write is ENOENT too — the old 'missing' verdict advised a refresh
617
+ // that could only ever fail, the exact non-convergent loop rounds 3 and 5 closed one level
618
+ // deeper. Something occupies the path; it is not ours to repair.
619
+ try {
620
+ const skillsRoot = path.dirname(path.dirname(dest));
621
+ if (fs.lstatSync(skillsRoot).isSymbolicLink() && !fs.existsSync(skillsRoot))
622
+ return true;
623
+ }
624
+ catch { /* fall through */ }
625
+ // The skill DIRECTORY itself may be a (possibly dangling) symlink the user manages —
626
+ // read fails ENOENT through it while something clearly occupies the path (round-5:
627
+ // 'missing' here + install's skip = another unresolvable remedy loop).
628
+ try {
629
+ return fs.lstatSync(path.dirname(dest)).isSymbolicLink();
630
+ }
631
+ catch {
632
+ return false;
633
+ }
634
+ })();
635
+ out.push({ name, state: err.code === 'ENOENT' && !lstatPresent ? 'missing' : 'foreign' });
636
+ continue;
637
+ }
638
+ const state = !carriesMarker(installed) ? 'foreign'
639
+ : installed === installedSkillBytes(body) ? 'current' : 'drifted';
640
+ out.push({ name, state });
641
+ }
642
+ // Marker-owned installs of names we no longer ship are STALE GUIDANCE — exactly REQ-190's problem
643
+ // class — yet the shipped loop above cannot see them. Markerless non-shipped entries are the
644
+ // user's own skills and stay off our list entirely.
645
+ const skillsDir = path.join(target, '.claude', 'skills');
646
+ let entries;
647
+ try {
648
+ entries = fs.readdirSync(skillsDir);
649
+ }
650
+ catch {
651
+ entries = [];
652
+ }
653
+ for (const entry of classifyExtraEntries(entries, shipped, (entry2, canonical) => {
654
+ try {
655
+ const a = fs.statSync(path.join(skillsDir, entry2));
656
+ const b = fs.statSync(path.join(skillsDir, canonical));
657
+ return a.ino === b.ino && a.dev === b.dev;
658
+ }
659
+ catch {
660
+ return undefined;
661
+ }
662
+ }, (entry2) => isOurs(path.join(skillsDir, entry2, 'SKILL.md')))) {
663
+ // The RAW directory name, case intact (round-6 HIGH): slicing the prefix folded
664
+ // `Holmes-adopt` to `adopt`, doctor re-prefixed it to `holmes-adopt`, and the orphan remedy
665
+ // told the operator to DELETE THE CURRENT SKILL on a case-sensitive FS.
666
+ out.push({ name: entry, state: 'orphaned' });
667
+ }
668
+ return out;
669
+ }
670
+ /**
671
+ * @implements A-SPEC-190 (§6)
672
+ * Which extra `.claude/skills` entries are ORPHANED marker-owned installs? Pure — the fs facts
673
+ * (inode identity, marker ownership) come in as functions, so the case-SENSITIVE world is testable
674
+ * on any machine (round-5 HIGH lived exactly in the branch this box's case-folding FS cannot
675
+ * exercise: `Holmes-adopt` sliced to `adopt`, matched shipped, and was swallowed BEFORE the
676
+ * variant/inode logic — doctor printed PASS over stale marker-owned guidance).
677
+ *
678
+ * - exact canonical entries are the shipped loop's business (skip);
679
+ * - a case-variant with the SAME inode is the same physical dir (case-insensitive FS — skip;
680
+ * round-2: double-counting made the orphan remedy delete the current skill);
681
+ * - everything else marker-owned is orphaned; unreadable/markerless stays silent (ownership is
682
+ * the marker's job — user territory).
683
+ */
684
+ function classifyExtraEntries(entries, shipped, sameInode, owned) {
685
+ const orphans = [];
686
+ for (const entry of [...entries].sort()) {
687
+ if (!entry.toLowerCase().startsWith('holmes-'))
688
+ continue; // 'Holmes-adopt' is still ours to judge
689
+ const name = entry.slice('holmes-'.length);
690
+ if (entry.startsWith('holmes-') && shipped.includes(name))
691
+ continue; // exact canonical only
692
+ const variantOf = shipped.find((s) => `holmes-${s}`.toLowerCase() === entry.toLowerCase());
693
+ if (variantOf && sameInode(entry, invocableSkillName(variantOf)) === true)
694
+ continue;
695
+ if (owned(entry))
696
+ orphans.push(entry); // RAW entry — the name the operator must act on
697
+ }
698
+ return orphans;
699
+ }
700
+ /** How many recovery skills are installed — the fact `doctor` reports. */
701
+ function installedPlaybookCount(target) {
702
+ const skillsDir = path.join(target, '.claude', 'skills');
703
+ try {
704
+ return fs.readdirSync(skillsDir)
705
+ .filter((e) => e.toLowerCase().startsWith('holmes-') && isOurs(path.join(skillsDir, e, 'SKILL.md')))
706
+ .length;
707
+ }
708
+ catch {
709
+ return 0;
710
+ }
711
+ }