@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- 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
|
+
}
|