create-pathfinder 1.4.1 → 1.5.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.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Adapters: what they contain, and which ones Pathfinder is allowed to write.
3
+ *
4
+ * A canonical skill at `skills/<name>/SKILL.md` is the single behavior
5
+ * contract. An adapter is a generated discovery shim that a harness can find,
6
+ * carrying the canonical metadata and a pointer — and no behavior of its own.
7
+ * Two properties make that arrangement safe, and both are enforced here.
8
+ *
9
+ * **Body-independence.** `render` is a function of the canonical *frontmatter*
10
+ * only. Editing a skill body — the overwhelming majority of skill changes —
11
+ * produces a byte-identical adapter, so generated files can be committed and
12
+ * verified without turning every prose edit into a diff. Nothing in this file
13
+ * ever reads a canonical body.
14
+ *
15
+ * **Decidable ownership.** Pathfinder regenerates a file only when the file
16
+ * itself says Pathfinder wrote it. A user may legitimately have their own
17
+ * `.claude/skills/debug-issue/SKILL.md`, and a name match is not evidence of
18
+ * anything. The marker is the mechanism — not a hash, not a manifest, not the
19
+ * name. Nothing here deletes.
20
+ *
21
+ * Everything in this module is pure except `readSkillMetadata`, which reads one
22
+ * file. No planning, no writing, no CLI: those are Chunk 2's.
23
+ */
24
+
25
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ /** The marker token, and the format version this build writes and owns. */
29
+ export const MARKER_TOKEN = "pathfinder:adapter";
30
+ export const MARKER_VERSION = 1;
31
+
32
+ /**
33
+ * The marker line, parsed strictly.
34
+ *
35
+ * The version is captured rather than matched, so a file written by a future
36
+ * format is *recognized* but not claimed: this build owns v1 and nothing else.
37
+ * Silently regenerating a format it does not understand would be the same
38
+ * mistake as owning a file by name.
39
+ */
40
+ const MARKER_PATTERN = new RegExp(
41
+ `^<!--\\s*${MARKER_TOKEN} v(\\d+)(?:\\s+source=(\\S+))?\\s*-->$`,
42
+ );
43
+
44
+ /**
45
+ * The ownership classes. Every path under a harness skills directory is
46
+ * exactly one of these, and each maps to one behavior:
47
+ *
48
+ * | State | Means | Behavior |
49
+ * | ----------- | ---------------------------------------------- | ------------------------------- |
50
+ * | `absent` | canonical skill, nothing on disk | generate |
51
+ * | `stale` | our marker, contents differ | regenerate, no flag needed |
52
+ * | `current` | our marker, byte-identical | nothing; report as up to date |
53
+ * | `conflict` | canonical name, no marker we own | leave it, report by name |
54
+ * | `orphan` | our marker, name this version no longer ships | report, never delete |
55
+ * | `unmanaged` | anything else under the harness directory | never read, never written |
56
+ *
57
+ * `conflict` is the only state `--force` may act on, and the only one where a
58
+ * user's file is at stake. It covers a hand-written file *and* a marker whose
59
+ * version this build does not own — both are "someone else's file" as far as
60
+ * this code is concerned.
61
+ */
62
+ export const ADAPTER_STATE = Object.freeze({
63
+ ABSENT: "absent",
64
+ STALE: "stale",
65
+ CURRENT: "current",
66
+ CONFLICT: "conflict",
67
+ ORPHAN: "orphan",
68
+ UNMANAGED: "unmanaged",
69
+ });
70
+
71
+ /** States Pathfinder may write without `--force`. */
72
+ const OWNED_STATES = new Set([ADAPTER_STATE.ABSENT, ADAPTER_STATE.STALE, ADAPTER_STATE.CURRENT]);
73
+
74
+ /**
75
+ * Where a canonical skill lives, relative to the project root.
76
+ *
77
+ * Always forward slashes. This string is rendered into the adapter body, so it
78
+ * is a fact about the document rather than about the host filesystem — a
79
+ * backslash here would make Windows-generated adapters differ from everyone
80
+ * else's, which is exactly the byte-determinism this layer promises not to
81
+ * break.
82
+ */
83
+ export function canonicalPath(name) {
84
+ return `skills/${name}/SKILL.md`;
85
+ }
86
+
87
+ /** Where a harness looks for that skill, relative to the project root. */
88
+ export function adapterPath(harness, name) {
89
+ return `${harness.skillsDir}/${name}/SKILL.md`;
90
+ }
91
+
92
+ /**
93
+ * Parse a canonical `SKILL.md`'s frontmatter into the metadata an adapter
94
+ * carries.
95
+ *
96
+ * Deliberately the same tolerance `.github/scripts/validate-kit.py` uses — a
97
+ * flat block of `key: value` lines between two `---` fences — so the installer
98
+ * and the validator cannot disagree about what a skill declares, and neither
99
+ * needs a YAML dependency. Values are taken verbatim: a description containing
100
+ * a colon keeps it, and nothing is re-worded, wrapped, or truncated.
101
+ *
102
+ * Throws rather than returning a partial result. A skill with no `description`
103
+ * would otherwise produce an adapter a harness silently ignores, and a loud
104
+ * failure naming the file is far cheaper than that.
105
+ *
106
+ * @returns {{name: string, description: string, argumentHint: string|null}}
107
+ */
108
+ export function parseSkillFrontmatter(text, { source = "SKILL.md" } = {}) {
109
+ const lines = text.split(/\r?\n/);
110
+
111
+ if (lines[0]?.trimEnd() !== "---") {
112
+ throw new Error(`${source}: line 1 must be exactly \`---\``);
113
+ }
114
+
115
+ const close = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
116
+ if (close === -1) {
117
+ throw new Error(`${source}: frontmatter has no closing \`---\``);
118
+ }
119
+
120
+ const data = new Map();
121
+ for (const line of lines.slice(1, close)) {
122
+ if (line.trim() === "") continue;
123
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
124
+ if (!match) {
125
+ throw new Error(`${source}: frontmatter line is not a \`key: value\` pair: ${line}`);
126
+ }
127
+ data.set(match[1], match[2].trim());
128
+ }
129
+
130
+ for (const key of ["name", "description"]) {
131
+ if (!data.get(key)) throw new Error(`${source}: frontmatter \`${key}\` is missing or empty`);
132
+ }
133
+
134
+ return {
135
+ name: data.get("name"),
136
+ description: data.get("description"),
137
+ argumentHint: data.get("argument-hint") || null,
138
+ };
139
+ }
140
+
141
+ /** Read one canonical skill's metadata. */
142
+ export function readSkillMetadata(path, { source = path } = {}) {
143
+ return parseSkillFrontmatter(readFileSync(path, "utf8"), { source });
144
+ }
145
+
146
+ /**
147
+ * Every skill this version of the kit ships, in name order.
148
+ *
149
+ * Read from the kit rather than from the destination project, because the kit
150
+ * is what defines the set: a stale directory left behind in someone's project
151
+ * must not be able to add itself to the list of things Pathfinder claims to
152
+ * own. It is also what makes an orphan detectable at all.
153
+ *
154
+ * @returns {{name: string, description: string, argumentHint: string|null}[]}
155
+ */
156
+ export function readCanonicalSkills(kitRoot) {
157
+ const skillsRoot = join(kitRoot, "skills");
158
+ if (!existsSync(skillsRoot)) return [];
159
+
160
+ return readdirSync(skillsRoot, { withFileTypes: true })
161
+ .filter((entry) => entry.isDirectory())
162
+ .map((entry) => entry.name)
163
+ .sort()
164
+ .filter((name) => existsSync(join(skillsRoot, name, "SKILL.md")))
165
+ .map((name) =>
166
+ readSkillMetadata(join(skillsRoot, name, "SKILL.md"), {
167
+ source: canonicalPath(name),
168
+ }),
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Render the adapter bytes for one skill.
174
+ *
175
+ * Same input, same bytes, on every platform: lines are joined with `\n`
176
+ * explicitly and never `os.EOL`, and nothing here consults the clock, the
177
+ * environment, or the canonical body.
178
+ *
179
+ * `harness` is taken and validated but does not currently change the output —
180
+ * both harnesses in the plan read `SKILL.md` with `name`/`description`
181
+ * frontmatter, so one renderer serves both. It stays in the signature because
182
+ * the first harness that needs a different file format supplies its own
183
+ * renderer, and callers should already be passing the harness by then.
184
+ *
185
+ * @returns {string} the complete file contents, LF, one trailing newline
186
+ */
187
+ export function render(harness, { name, description, argumentHint = null } = {}) {
188
+ if (!harness?.skillsDir) throw new Error("render: a harness is required");
189
+ assertRenderableName(name);
190
+ if (!description) throw new Error(`render: skill \`${name}\` has no description`);
191
+
192
+ const canonical = canonicalPath(name);
193
+
194
+ return [
195
+ "---",
196
+ `name: ${name}`,
197
+ `description: ${description}`,
198
+ ...(argumentHint ? [`argument-hint: ${argumentHint}`] : []),
199
+ "---",
200
+ "",
201
+ `<!-- ${MARKER_TOKEN} v${MARKER_VERSION} source=${canonical} -->`,
202
+ "<!-- Generated by create-pathfinder. Do not edit; re-run `npx create-pathfinder`. -->",
203
+ "",
204
+ `Read \`${canonical}\` in this repository and follow it exactly.`,
205
+ "",
206
+ "That file is the canonical, tool-neutral definition of this skill. This file",
207
+ "exists only so this tool can discover it, and contains no behavior of its own.",
208
+ "",
209
+ `If \`${canonical}\` does not exist, stop and tell the user the`,
210
+ "Pathfinder kit is not installed in this repository. Do not improvise the skill.",
211
+ "",
212
+ ].join("\n");
213
+ }
214
+
215
+ /**
216
+ * The marker a file carries, or null.
217
+ *
218
+ * Searched line by line rather than with a multiline regex so a marker quoted
219
+ * inside a fenced block or a longer line cannot be mistaken for the real one.
220
+ *
221
+ * @returns {{version: number, source: string|null}|null}
222
+ */
223
+ export function readMarker(content) {
224
+ if (typeof content !== "string") return null;
225
+
226
+ for (const line of content.split(/\r?\n/)) {
227
+ const match = MARKER_PATTERN.exec(line.trim());
228
+ if (match) return { version: Number(match[1]), source: match[2] ?? null };
229
+ }
230
+
231
+ return null;
232
+ }
233
+
234
+ /** Does this file carry a marker in the format this build owns? */
235
+ export function isPathfinderAdapter(content) {
236
+ return readMarker(content)?.version === MARKER_VERSION;
237
+ }
238
+
239
+ /**
240
+ * Decide what Pathfinder may do with one path under a harness skills
241
+ * directory.
242
+ *
243
+ * Takes the file's current contents rather than a path, so the decision is a
244
+ * pure function of what is on disk and can be tested exhaustively without one.
245
+ * `existing` is null when nothing is there.
246
+ *
247
+ * @param {{name: string, isCanonicalSkill: boolean,
248
+ * existing: string|null, expected?: string|null}} input
249
+ * @returns {{name: string, state: string, marker: {version: number, source: string|null}|null,
250
+ * owned: boolean, forceReplaceable: boolean}}
251
+ */
252
+ export function classifyAdapter({ name, isCanonicalSkill, existing = null, expected = null }) {
253
+ const marker = readMarker(existing);
254
+ const ours = marker?.version === MARKER_VERSION;
255
+
256
+ const state = (() => {
257
+ // A marked file naming a skill this version does not ship. Reported so it
258
+ // cannot rot unnoticed, and left alone: deleting in someone else's
259
+ // repository is a different authority than writing, and is not claimed.
260
+ if (!isCanonicalSkill) return ours ? ADAPTER_STATE.ORPHAN : ADAPTER_STATE.UNMANAGED;
261
+ if (existing === null) return ADAPTER_STATE.ABSENT;
262
+ if (!ours) return ADAPTER_STATE.CONFLICT;
263
+ return existing === expected ? ADAPTER_STATE.CURRENT : ADAPTER_STATE.STALE;
264
+ })();
265
+
266
+ return {
267
+ name,
268
+ state,
269
+ marker,
270
+ owned: OWNED_STATES.has(state),
271
+ forceReplaceable: state === ADAPTER_STATE.CONFLICT,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * A name that can be rendered into a path and a document.
277
+ *
278
+ * Narrow on purpose. The name arrives from a file the installer did not write
279
+ * and is interpolated into `skills/<name>/SKILL.md`, so a separator or a `..`
280
+ * would turn a metadata bug into a path that escapes the directory it was
281
+ * meant to describe.
282
+ */
283
+ function assertRenderableName(name) {
284
+ if (!name) throw new Error("render: skill has no name");
285
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
286
+ throw new Error(`render: skill name is not usable as a path segment: ${name}`);
287
+ }
288
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The harness registry.
3
+ *
4
+ * A harness is a coding tool that discovers skills by reading files from a
5
+ * fixed directory in the project. This table says where each one looks and how
6
+ * a user invokes what it finds there — nothing more. It is a table, not a
7
+ * plugin system: a new harness is one object, and there is deliberately no
8
+ * loader, no manifest format, and no way for a user to register their own.
9
+ *
10
+ * Two entries today, and the second one cost exactly what this shape promised:
11
+ * one object. Adding Codex changed no renderer, no ownership rule, no planner,
12
+ * and no line of `cli.mjs` — the only difference between the harnesses is where
13
+ * the file goes.
14
+ *
15
+ * Detection is not re-implemented here. `src/detect.mjs` already probes for
16
+ * these tools and reports them by id, and duplicating that would create a
17
+ * second answer to "is Claude Code here?" that can disagree with the one the
18
+ * report prints. A harness therefore consumes the finding rather than
19
+ * repeating the probe.
20
+ */
21
+
22
+ /**
23
+ * Every harness Pathfinder can generate adapters for.
24
+ *
25
+ * - `id` matches the tool id in `src/detect.mjs`, which is what makes
26
+ * `detect` a lookup instead of a second probe.
27
+ * - `skillsDir` is written with forward slashes and joined per-platform by
28
+ * whatever touches the filesystem. It is a relative path inside the
29
+ * destination project, never absolute.
30
+ * - `invocation` is how a user calls the skill once the harness has found it.
31
+ * Reporting only; nothing branches on it.
32
+ *
33
+ * @type {ReadonlyArray<{id: string, label: string, skillsDir: string,
34
+ * detect: (findings: object) => boolean,
35
+ * invocation: (name: string) => string}>}
36
+ */
37
+ export const HARNESSES = Object.freeze([
38
+ Object.freeze({
39
+ id: "claude-code",
40
+ label: "Claude Code",
41
+ skillsDir: ".claude/skills",
42
+ detect: (findings) => toolDetected(findings, "claude-code"),
43
+ invocation: (name) => `/${name}`,
44
+ }),
45
+ Object.freeze({
46
+ id: "codex",
47
+ label: "Codex",
48
+ // Codex scans `.agents/skills` in every directory from the working
49
+ // directory up to the repository root, so one directory at the root is
50
+ // found from anywhere inside the project. Nothing extra is generated for
51
+ // subdirectories. The personal scope, `$HOME/.agents/skills`, is a
52
+ // different place and Pathfinder never writes there.
53
+ skillsDir: ".agents/skills",
54
+ detect: (findings) => toolDetected(findings, "codex"),
55
+ invocation: (name) => `$${name}`,
56
+ }),
57
+ ]);
58
+
59
+ /** Valid `--agents` values, in registry order. For error messages and help. */
60
+ export const HARNESS_IDS = Object.freeze(HARNESSES.map((harness) => harness.id));
61
+
62
+ /** The harness with this id, or null. Unknown ids are the caller's to report. */
63
+ export function findHarness(id) {
64
+ return HARNESSES.find((harness) => harness.id === id) ?? null;
65
+ }
66
+
67
+ /**
68
+ * The harness a person means when they type this, or null.
69
+ *
70
+ * Only reached when someone has said their tool is *not* one of the supported
71
+ * ones, so its whole job is to catch the case where it is after all — typing
72
+ * "claude" at a question that already offered Claude Code on the line above.
73
+ * Recording that as an unsupported tool would tell them Pathfinder cannot do
74
+ * the thing it just offered to do.
75
+ *
76
+ * Matching is exact against the names a harness actually goes by: its id, its
77
+ * label, and the first word of its label. Deliberately not a prefix or
78
+ * substring search, which would claim "code" for Codex when the person almost
79
+ * certainly meant VS Code — and refusing to record a name is only defensible
80
+ * when the alternative really is a duplicate.
81
+ */
82
+ export function harnessNamed(name) {
83
+ const typed = normalizeName(name);
84
+ if (typed === "") return null;
85
+
86
+ return (
87
+ HARNESSES.find((harness) =>
88
+ [harness.id, harness.label, harness.label.split(" ")[0]]
89
+ .map(normalizeName)
90
+ .includes(typed),
91
+ ) ?? null
92
+ );
93
+ }
94
+
95
+ /** Case, spaces, and punctuation are not what distinguishes one tool from another. */
96
+ function normalizeName(name) {
97
+ return String(name ?? "")
98
+ .toLowerCase()
99
+ .replace(/[^a-z0-9]/g, "");
100
+ }
101
+
102
+ /**
103
+ * The harnesses `detect()` found, in registry order.
104
+ *
105
+ * These form the *default* selection and are never applied without
106
+ * confirmation — detection offers, it does not decide.
107
+ */
108
+ export function detectedHarnesses(findings) {
109
+ return HARNESSES.filter((harness) => harness.detect(findings));
110
+ }
111
+
112
+ /**
113
+ * Did detection see this tool?
114
+ *
115
+ * Tolerant of a findings object that predates a harness or was synthesized by
116
+ * a caller: an absent entry reads as "not detected" rather than throwing, for
117
+ * the same reason every probe in detect.mjs degrades that way.
118
+ */
119
+ function toolDetected(findings, id) {
120
+ const tools = findings?.tools ?? [];
121
+ return tools.some((tool) => tool.id === id && tool.detected === true);
122
+ }
package/src/install.mjs CHANGED
@@ -5,12 +5,26 @@
5
5
  * nothing; `applyPlan` carries a plan out. That split is what makes --dry-run
6
6
  * honest — it runs the identical planning code the real install runs, rather
7
7
  * than a parallel description of it that can drift.
8
+ *
9
+ * Harness adapters get their own pair, `planAdapters` / `applyAdapterPlan`,
10
+ * under the same discipline. They are kept apart from the kit copy because
11
+ * they answer a different question — the kit copy asks "does this file exist
12
+ * yet?", and an adapter asks "did Pathfinder write this file?" — and because
13
+ * adapters are generated bytes rather than copied ones.
8
14
  */
9
15
 
10
- import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
16
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
11
17
  import { dirname, join, relative, sep } from "node:path";
12
18
 
13
19
  import { COPY_LIST, isExcluded } from "./kit.mjs";
20
+ import {
21
+ ADAPTER_STATE,
22
+ adapterPath,
23
+ classifyAdapter,
24
+ isPathfinderAdapter,
25
+ readCanonicalSkills,
26
+ render,
27
+ } from "./harnesses/adapter.mjs";
14
28
 
15
29
  /**
16
30
  * Decide the fate of every file in the kit. Reads only; writes nothing.
@@ -83,6 +97,200 @@ export function applyPlan(plan, { dryRun = false } = {}) {
83
97
  return result;
84
98
  }
85
99
 
100
+ /**
101
+ * Decide the fate of every adapter path for the selected harnesses.
102
+ *
103
+ * The same plan/apply discipline the kit copy uses, and for the same reason:
104
+ * `--dry-run` reports by running this exact function, not a description of it.
105
+ * Reads only.
106
+ *
107
+ * The set of skills comes from the kit, so an adapter is planned for what this
108
+ * version ships and nothing else. Anything already under the harness directory
109
+ * that is not one of those names is looked at exactly once — to see whether it
110
+ * carries our marker, which makes it an orphan worth reporting — and is never
111
+ * written.
112
+ *
113
+ * @returns {{harness: object, name: string, relativePath: string, destination: string,
114
+ * state: string, action: "write"|"replace"|"up-to-date"|"conflict"|"orphan"|"unreadable",
115
+ * contents: string|null, message?: string}[]}
116
+ */
117
+ export function planAdapters(harnesses, { kitRoot, targetRoot, force = false } = {}) {
118
+ const skills = readCanonicalSkills(kitRoot);
119
+ const canonicalNames = new Set(skills.map((skill) => skill.name));
120
+ const plan = [];
121
+
122
+ for (const harness of harnesses) {
123
+ for (const skill of skills) {
124
+ const relativePath = adapterPath(harness, skill.name);
125
+ const destination = join(targetRoot, ...relativePath.split("/"));
126
+ const contents = render(harness, skill);
127
+ const existing = readAdapter(destination);
128
+
129
+ if (existing.unreadable) {
130
+ plan.push({
131
+ harness,
132
+ name: skill.name,
133
+ relativePath,
134
+ destination,
135
+ state: ADAPTER_STATE.CONFLICT,
136
+ action: "unreadable",
137
+ contents: null,
138
+ message: existing.message,
139
+ });
140
+ continue;
141
+ }
142
+
143
+ const classified = classifyAdapter({
144
+ name: skill.name,
145
+ isCanonicalSkill: true,
146
+ existing: existing.content,
147
+ expected: contents,
148
+ });
149
+
150
+ plan.push({
151
+ harness,
152
+ name: skill.name,
153
+ relativePath,
154
+ destination,
155
+ state: classified.state,
156
+ action: actionFor(classified.state, force),
157
+ contents,
158
+ });
159
+ }
160
+
161
+ for (const name of orphanNames(harness, targetRoot, canonicalNames)) {
162
+ const relativePath = adapterPath(harness, name);
163
+ plan.push({
164
+ harness,
165
+ name,
166
+ relativePath,
167
+ destination: join(targetRoot, ...relativePath.split("/")),
168
+ state: ADAPTER_STATE.ORPHAN,
169
+ action: "orphan",
170
+ contents: null,
171
+ });
172
+ }
173
+ }
174
+
175
+ return plan;
176
+ }
177
+
178
+ /**
179
+ * What a classification means for this run.
180
+ *
181
+ * `--force` reaches exactly one state. It authorizes replacing a file
182
+ * Pathfinder does not own at a path it would otherwise generate — the same
183
+ * category of decision as replacing a kit file the user has edited, which is
184
+ * why it is the same flag rather than a second one.
185
+ */
186
+ function actionFor(state, force) {
187
+ switch (state) {
188
+ case ADAPTER_STATE.ABSENT:
189
+ case ADAPTER_STATE.STALE:
190
+ return "write";
191
+ case ADAPTER_STATE.CURRENT:
192
+ return "up-to-date";
193
+ case ADAPTER_STATE.CONFLICT:
194
+ return force ? "replace" : "conflict";
195
+ default:
196
+ return "orphan";
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Carry out an adapter plan.
202
+ *
203
+ * Failures are collected rather than thrown, exactly as the kit copy does, so
204
+ * one unwritable path reports itself next to everything that did succeed.
205
+ * Conflicts and orphans are outcomes, not errors: nothing went wrong, and the
206
+ * files they name are the ones this tool successfully left alone.
207
+ *
208
+ * @returns {{generated: number, replaced: number, unchanged: number,
209
+ * conflicts: string[], orphans: string[],
210
+ * errors: {relativePath: string, message: string}[]}}
211
+ */
212
+ export function applyAdapterPlan(plan, { dryRun = false } = {}) {
213
+ const result = { generated: 0, replaced: 0, unchanged: 0, conflicts: [], orphans: [], errors: [] };
214
+
215
+ for (const item of plan) {
216
+ switch (item.action) {
217
+ case "up-to-date":
218
+ result.unchanged += 1;
219
+ continue;
220
+ case "conflict":
221
+ result.conflicts.push(item.relativePath);
222
+ continue;
223
+ case "orphan":
224
+ result.orphans.push(item.relativePath);
225
+ continue;
226
+ case "unreadable":
227
+ result.errors.push({ relativePath: item.relativePath, message: item.message });
228
+ continue;
229
+ default:
230
+ break;
231
+ }
232
+
233
+ if (!dryRun) {
234
+ try {
235
+ mkdirSync(dirname(item.destination), { recursive: true });
236
+ writeFileSync(item.destination, item.contents, "utf8");
237
+ } catch (error) {
238
+ result.errors.push({ relativePath: item.relativePath, message: error.message });
239
+ continue;
240
+ }
241
+ }
242
+
243
+ if (item.action === "replace") result.replaced += 1;
244
+ else result.generated += 1;
245
+ }
246
+
247
+ return result;
248
+ }
249
+
250
+ /**
251
+ * Read a file that may not be there.
252
+ *
253
+ * A missing file and a missing directory are the same answer — nothing is
254
+ * there — but an *unreadable* file is not. Treating a permissions error as
255
+ * absence would classify someone's file as "generate here", which is the one
256
+ * mistake this layer exists to prevent, so it becomes a reported error instead.
257
+ */
258
+ function readAdapter(path) {
259
+ try {
260
+ return { content: readFileSync(path, "utf8"), unreadable: false };
261
+ } catch (error) {
262
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") {
263
+ return { content: null, unreadable: false };
264
+ }
265
+ return { content: null, unreadable: true, message: error.message };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Names under the harness directory that carry our marker but are not skills
271
+ * this version ships.
272
+ *
273
+ * Reported, never deleted. Any failure to look — no directory, no permission —
274
+ * is simply "no orphans", because this is a courtesy check and must not be able
275
+ * to fail an install.
276
+ */
277
+ function orphanNames(harness, targetRoot, canonicalNames) {
278
+ const skillsDirectory = join(targetRoot, ...harness.skillsDir.split("/"));
279
+
280
+ try {
281
+ return readdirSync(skillsDirectory, { withFileTypes: true })
282
+ .filter((entry) => entry.isDirectory() && !canonicalNames.has(entry.name))
283
+ .map((entry) => entry.name)
284
+ .sort()
285
+ .filter((name) => {
286
+ const file = readAdapter(join(skillsDirectory, name, "SKILL.md"));
287
+ return file.content !== null && isPathfinderAdapter(file.content);
288
+ });
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
86
294
  /** Every file under `path`, recursively, minus junk. A file yields itself. */
87
295
  function* walkFiles(path) {
88
296
  const stats = statSync(path);