create-pathfinder 1.4.1 → 1.5.1
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/AGENTS.md +4 -0
- package/CLAUDE.md +7 -1
- package/README.md +97 -9
- package/bin/create-pathfinder.mjs +32 -4
- package/context/coding-standards.md +19 -0
- package/context/project-overview.md +14 -2
- package/copy-list.json +0 -1
- package/package.json +2 -2
- package/skills/complete-feature/SKILL.md +2 -2
- package/src/cli.mjs +765 -34
- package/src/clipboard.mjs +134 -0
- package/src/detect.mjs +183 -0
- package/src/editor.mjs +136 -0
- package/src/git.mjs +58 -0
- package/src/harnesses/adapter.mjs +288 -0
- package/src/harnesses/index.mjs +122 -0
- package/src/install.mjs +209 -1
- package/src/kickstart-prompt.mjs +81 -0
- package/src/prompt.mjs +307 -0
- package/templates/project-overview.template.md +14 -2
- package/prompts/01-kickstart-project.md +0 -1
- package/prompts/01-teach-current-feature.md +0 -9
- package/prompts/02-debate-me.md +0 -1
- package/prompts/02-quiz-current-feature.md +0 -7
- package/prompts/03-challenge-current-feature.md +0 -7
- package/prompts/03-prototype.md +0 -1
- package/prompts/04-teach-current-architecture.md +0 -7
- package/prompts/04-to-specs.md +0 -1
- package/prompts/05-learning-review.md +0 -5
- package/prompts/05-load-feature.md +0 -1
- package/prompts/06-start-feature.md +0 -1
- package/prompts/07-review-feature.md +0 -1
- package/prompts/08-complete-feature.md +0 -1
- package/prompts/09-learn-feature.md +0 -1
- package/prompts/10-learn-codebase.md +0 -1
- package/prompts/11-handoff.md +0 -1
- package/prompts/12-skillsmith.md +0 -1
- package/prompts/13-reverse-engineer.md +0 -18
- package/prompts/14-reflect.md +0 -13
- package/prompts/15-debug-issue.md +0 -15
|
@@ -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);
|