create-pathfinder 4.1.0 → 4.2.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,142 @@
1
+ /**
2
+ * Hook handlers: what they are, and which ones Pathfinder is allowed to write.
3
+ *
4
+ * A harness may expose a session lifecycle event. Where one does, Pathfinder
5
+ * can put a handler on disk for it — and that is the whole of what Pathfinder
6
+ * owns. It writes no settings file, registers nothing, and activates nothing:
7
+ * a generated handler is inert until a human adds native configuration
8
+ * pointing at it. That is why this module has no notion of a settings file, a
9
+ * hook registry, an event schema, or a cross-harness runtime, and must not
10
+ * grow one.
11
+ *
12
+ * Two differences from `adapter.mjs`, and only two:
13
+ *
14
+ * **There is no renderer.** An adapter is *rendered* from canonical
15
+ * frontmatter, which is what makes it body-independent and safe to commit. A
16
+ * handler's bytes *are* its behavior, so the canonical file beside this one is
17
+ * shipped verbatim. "Generation" here is a byte-for-byte copy of a file this
18
+ * package carries, which is also what makes the installed handler mechanically
19
+ * comparable to the canonical implementation.
20
+ *
21
+ * **The marker is a line comment.** The adapter marker is written for Markdown
22
+ * and spelled as an HTML comment; a script needs its own syntax and its own
23
+ * format version. Ownership is otherwise the identical discipline, decided by
24
+ * `classifyOwnership`, which both artifacts share.
25
+ *
26
+ * Nothing here deletes.
27
+ */
28
+
29
+ import { readFileSync } from "node:fs";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import { classifyOwnership, isOwnedState } from "./adapter.mjs";
33
+
34
+ /** The marker token, and the format version this build writes and owns. */
35
+ export const HOOK_MARKER_TOKEN = "pathfinder:hook";
36
+ export const HOOK_MARKER_VERSION = 1;
37
+
38
+ /**
39
+ * The marker line, parsed strictly.
40
+ *
41
+ * The version is captured rather than matched, for the same reason it is in
42
+ * `adapter.mjs`: a file written by a future format is recognized but not
43
+ * claimed. This build owns v1 and nothing else.
44
+ */
45
+ const HOOK_MARKER_PATTERN = new RegExp(
46
+ `^//\\s*${HOOK_MARKER_TOKEN} v(\\d+)(?:\\s+name=(\\S+))?\\s*$`,
47
+ );
48
+
49
+ /**
50
+ * The marker a file carries, or null.
51
+ *
52
+ * Searched line by line rather than with a multiline regex so a marker quoted
53
+ * inside a string or a longer line cannot be mistaken for the real one.
54
+ *
55
+ * @returns {{version: number, name: string|null}|null}
56
+ */
57
+ export function readHookMarker(content) {
58
+ if (typeof content !== "string") return null;
59
+
60
+ for (const line of content.split(/\r?\n/)) {
61
+ const match = HOOK_MARKER_PATTERN.exec(line.trim());
62
+ if (match) return { version: Number(match[1]), name: match[2] ?? null };
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ /** Does this file carry a marker in the format this build owns? */
69
+ export function isPathfinderHook(content) {
70
+ return readHookMarker(content)?.version === HOOK_MARKER_VERSION;
71
+ }
72
+
73
+ /**
74
+ * The handlers a harness receives, in registry order.
75
+ *
76
+ * A harness with no `hooks` field receives none — which is every harness but
77
+ * Claude Code today, and is why a Codex destination gets no `.claude` artifact
78
+ * of any kind rather than a substitute for one.
79
+ */
80
+ export function hooksFor(harness) {
81
+ return harness?.hooks ?? [];
82
+ }
83
+
84
+ /**
85
+ * The file names this version ships into one harness's hooks directory.
86
+ *
87
+ * Read from the registry rather than from the destination, for the reason
88
+ * `readCanonicalSkills` reads the kit: a stale file left behind in someone's
89
+ * project must not be able to add itself to the set Pathfinder claims to own,
90
+ * and this is what makes an orphan detectable at all.
91
+ */
92
+ export function shippedHookFiles(harness) {
93
+ return new Set(hooksFor(harness).map((hook) => hook.file));
94
+ }
95
+
96
+ /** Where a harness looks for this handler, relative to the project root. */
97
+ export function hookPath(harness, hook) {
98
+ return `${harness.hooksDir}/${hook.file}`;
99
+ }
100
+
101
+ /**
102
+ * The canonical bytes of one handler, exactly as they will be installed.
103
+ *
104
+ * Resolved against this module rather than against the kit root, because the
105
+ * handler is part of the *installer* — it ships inside the npm package under
106
+ * `src/`, and never through `copy-list.json`, which is uniform and would hand
107
+ * a Claude Code handler to every destination regardless of harness.
108
+ *
109
+ * Read on demand rather than cached: an install reads it once, and a stale
110
+ * module-level copy is a worse failure than a second `readFileSync`.
111
+ */
112
+ export function readHookSource(hook) {
113
+ return readFileSync(hookSourcePath(hook), "utf8");
114
+ }
115
+
116
+ /** Where the canonical handler lives inside this package. Absolute. */
117
+ export function hookSourcePath(hook) {
118
+ return fileURLToPath(new URL(`../hooks/${hook.name}.mjs`, import.meta.url));
119
+ }
120
+
121
+ /**
122
+ * Decide what Pathfinder may do with one path a handler would occupy.
123
+ *
124
+ * Takes the file's current contents rather than a path, so the decision is a
125
+ * pure function of what is on disk and can be tested exhaustively without one.
126
+ * `existing` is null when nothing is there.
127
+ *
128
+ * @param {{name: string, isShippedHook?: boolean,
129
+ * existing: string|null, expected?: string|null}} input
130
+ */
131
+ export function classifyHook({ name, isShippedHook = true, existing = null, expected = null }) {
132
+ const marker = readHookMarker(existing);
133
+
134
+ const state = classifyOwnership({
135
+ existing,
136
+ expected,
137
+ ours: marker?.version === HOOK_MARKER_VERSION,
138
+ shipped: isShippedHook,
139
+ });
140
+
141
+ return { name, state, marker, owned: isOwnedState(state) };
142
+ }
@@ -2,10 +2,12 @@
2
2
  * The harness registry.
3
3
  *
4
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.
5
+ * fixed directory in the project. This table says where each one looks, how a
6
+ * user invokes what it finds there, and where the tool has a session
7
+ * lifecycle event which handler files Pathfinder generates for it. Nothing
8
+ * more: it is a table, not a plugin system. A new harness is one object, and
9
+ * there is deliberately no loader, no manifest format, and no way for a user
10
+ * to register their own.
9
11
  *
10
12
  * Two entries today, and the second one cost exactly what this shape promised:
11
13
  * one object. Adding Codex changed no renderer, no ownership rule, no planner,
@@ -30,9 +32,22 @@
30
32
  * - `invocation` is how a user calls the skill once the harness has found it.
31
33
  * Reporting only; nothing branches on it.
32
34
  *
35
+ * - `hooksDir` and `hooks` are optional and describe session lifecycle
36
+ * handlers this harness can run. A harness that omits them receives no
37
+ * handler and no substitute for one — which is the honest answer for a tool
38
+ * with no lifecycle primitive, not a gap to fill. `name` names the canonical
39
+ * handler in `src/hooks/<name>.mjs`; `file` is its destination name, stable
40
+ * so a human who activated it once keeps a valid activation across updates
41
+ * with no settings rewrite.
42
+ *
43
+ * Pathfinder owns the handler file and nothing else. It writes no settings
44
+ * file, so a generated handler is inert until a human activates it.
45
+ *
33
46
  * @type {ReadonlyArray<{id: string, label: string, skillsDir: string,
34
47
  * detect: (findings: object) => boolean,
35
- * invocation: (name: string) => string}>}
48
+ * invocation: (name: string) => string,
49
+ * hooksDir?: string,
50
+ * hooks?: ReadonlyArray<{name: string, file: string}>}>}
36
51
  */
37
52
  export const HARNESSES = Object.freeze([
38
53
  Object.freeze({
@@ -41,6 +56,10 @@ export const HARNESSES = Object.freeze([
41
56
  skillsDir: ".claude/skills",
42
57
  detect: (findings) => toolDetected(findings, "claude-code"),
43
58
  invocation: (name) => `/${name}`,
59
+ hooksDir: ".claude/hooks",
60
+ hooks: Object.freeze([
61
+ Object.freeze({ name: "session-orientation", file: "pathfinder-session-orientation.mjs" }),
62
+ ]),
44
63
  }),
45
64
  Object.freeze({
46
65
  id: "codex",
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // pathfinder:hook v1 name=session-orientation
3
+ // Generated by create-pathfinder. Do not edit; re-run `npx create-pathfinder`.
4
+ /**
5
+ * SessionStart orientation: surface Pathfinder work state to a starting session.
6
+ *
7
+ * Inert until you activate it. Pathfinder writes this file and nothing else —
8
+ * no settings file is created, merged, or read — so nothing runs it until you
9
+ * add native hook configuration pointing here yourself. Delete this file to
10
+ * remove it; an unreferenced handler never runs, and a reference to a missing
11
+ * handler is a non-blocking no-op.
12
+ *
13
+ * Emits a compact, read-only snapshot so the agent does not rediscover the
14
+ * current ticket, branch, and context availability by hand. It reports facts
15
+ * and one bounded verbatim excerpt; it interprets nothing. Role, Feature,
16
+ * ticket identity, and the next action are `whereami`'s reading of that same
17
+ * file, and this handler must not produce a second, divergent one.
18
+ *
19
+ * The excerpt is quoted, never parsed. `context/current-ticket.md` is written
20
+ * by `/ticket load` with no schema, and nothing validates its shape — bounded
21
+ * verbatim transport is what makes this handler immune to a file whose shape
22
+ * is not guaranteed. A parser here would break that, whatever it improved.
23
+ *
24
+ * Read-only by construction: it opens files for reading and runs exactly one
25
+ * Git command, with --no-optional-locks so not even the index cache is written.
26
+ *
27
+ * Fails open and silent: any error, or any directory that is not a Pathfinder
28
+ * project, exits 0 with no output. Orientation is a convenience, and a broken
29
+ * convenience must not announce itself into every session.
30
+ */
31
+
32
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
33
+ import { spawnSync } from "node:child_process";
34
+ import { join, isAbsolute } from "node:path";
35
+
36
+ const MAX_LINES = 40;
37
+ const MAX_CHARS = 2000;
38
+
39
+ /** Read stdin fully; the harness closes it. */
40
+ function readStdin() {
41
+ try {
42
+ return readFileSync(0, "utf8");
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ /** The directory the session is actually working in, worktrees included. */
49
+ function resolveRoot(input) {
50
+ const candidates = [input?.cwd, process.env.CLAUDE_PROJECT_DIR, process.cwd()];
51
+ for (const candidate of candidates) {
52
+ if (typeof candidate === "string" && candidate && isAbsolute(candidate) && existsSync(candidate)) {
53
+ return candidate;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /** Pathfinder ships exactly two context files; this one is always among them. */
60
+ function isPathfinderProject(root) {
61
+ return existsSync(join(root, "context", "ai-interaction.md"));
62
+ }
63
+
64
+ function gitLine(root) {
65
+ const result = spawnSync(
66
+ "git",
67
+ ["--no-optional-locks", "status", "--porcelain=v1", "--branch"],
68
+ { cwd: root, encoding: "utf8", timeout: 3000 },
69
+ );
70
+ if (result.status !== 0 || typeof result.stdout !== "string") return "unavailable";
71
+ const lines = result.stdout.split("\n").filter(Boolean);
72
+ const head = lines[0] ?? "";
73
+ const branch = head.startsWith("## ") ? head.slice(3).split("...")[0].trim() : "unknown";
74
+ const changed = lines.length - 1;
75
+ return `${branch} — ${changed === 0 ? "clean" : `${changed} changed`}`;
76
+ }
77
+
78
+ /** Availability only. Never content, never a count that implies a selection. */
79
+ function availability(root) {
80
+ const marks = [];
81
+ const file = (name) =>
82
+ marks.push(`${name} ${existsSync(join(root, "context", name)) ? "yes" : "no"}`);
83
+ file("project-overview.md");
84
+ file("history.md");
85
+ file("tracker.md");
86
+ file("handoff.md");
87
+ for (const directory of ["features", "tickets"]) {
88
+ const path = join(root, "context", directory);
89
+ let count = null;
90
+ try {
91
+ if (existsSync(path)) {
92
+ count = readdirSync(path).filter((entry) => entry.endsWith(".md")).length;
93
+ }
94
+ } catch {
95
+ count = null;
96
+ }
97
+ marks.push(`${directory}/ ${count === null ? "no" : `${count} spec${count === 1 ? "" : "s"}`}`);
98
+ }
99
+ return marks.join(" · ");
100
+ }
101
+
102
+ /** The one file whereami reads, quoted verbatim and bounded. */
103
+ function currentTicketExcerpt(root) {
104
+ const path = join(root, "context", "current-ticket.md");
105
+ if (!existsSync(path)) return { present: false, text: null, truncated: false };
106
+
107
+ let raw;
108
+ try {
109
+ raw = readFileSync(path, "utf8");
110
+ } catch {
111
+ return { present: true, text: null, truncated: false };
112
+ }
113
+ if (!raw.trim()) return { present: true, text: null, truncated: false };
114
+
115
+ const lines = raw.split("\n");
116
+ let truncated = lines.length > MAX_LINES;
117
+ let text = lines.slice(0, MAX_LINES).join("\n");
118
+ if (text.length > MAX_CHARS) {
119
+ text = text.slice(0, MAX_CHARS);
120
+ truncated = true;
121
+ }
122
+ return { present: true, text: text.trimEnd(), truncated };
123
+ }
124
+
125
+ function main() {
126
+ const input = (() => {
127
+ try {
128
+ return JSON.parse(readStdin());
129
+ } catch {
130
+ return {};
131
+ }
132
+ })();
133
+
134
+ const root = resolveRoot(input);
135
+ if (!root || !isPathfinderProject(root)) return;
136
+
137
+ // Claude Code sends the lifecycle source as `source`; the published reference
138
+ // calls it `session_start_reason`. Verified against a real payload — accept
139
+ // either, and prefer what the tool actually sends.
140
+ const source =
141
+ [input.source, input.session_start_reason].find((value) => typeof value === "string" && value) ??
142
+ "unknown";
143
+ const ticket = currentTicketExcerpt(root);
144
+
145
+ const out = [];
146
+ out.push("# Pathfinder work state (read-only orientation)");
147
+ out.push("");
148
+ out.push(`Session: ${source}`);
149
+ out.push(`Git: ${gitLine(root)}`);
150
+ out.push(`Context: ${availability(root)}`);
151
+ out.push("Role: none — no /role override can be active yet; do not assume one");
152
+ out.push("");
153
+
154
+ if (!ticket.present) {
155
+ out.push(
156
+ "context/current-ticket.md does not exist. No ticket is loaded. Treat Feature, ticket, and next action as `none`.",
157
+ );
158
+ } else if (ticket.text === null) {
159
+ out.push(
160
+ "context/current-ticket.md exists but is empty or unreadable. Treat Feature, ticket, and next action as `none` until it is read directly.",
161
+ );
162
+ } else {
163
+ out.push(
164
+ `context/current-ticket.md, verbatim${ticket.truncated ? " (truncated — read the file for the rest)" : ""}:`,
165
+ );
166
+ out.push("");
167
+ out.push(ticket.text);
168
+ }
169
+
170
+ out.push("");
171
+ out.push(
172
+ "Orientation only. Nothing above advances the workflow, assumes a role, or selects a next action; run /whereami for the interpreted snapshot.",
173
+ );
174
+
175
+ process.stdout.write(out.join("\n") + "\n");
176
+ }
177
+
178
+ try {
179
+ main();
180
+ } catch {
181
+ // Fail open and silent.
182
+ }
183
+ process.exit(0);
package/src/install.mjs CHANGED
@@ -11,6 +11,15 @@
11
11
  * they answer a different question — the kit copy asks "does this file exist
12
12
  * yet?", and an adapter asks "did Pathfinder write this file?" — and because
13
13
  * adapters are generated bytes rather than copied ones.
14
+ *
15
+ * Session hook handlers get a third pair, `planHooks` / `applyHookPlan`. They
16
+ * ask an adapter's ownership question and answer it with the shared state
17
+ * table, but they are not folded into the adapter pair, for two reasons. A
18
+ * handler is not rendered from frontmatter — its canonical bytes are shipped
19
+ * verbatim — and only some harnesses have any, so counting one as an adapter
20
+ * would make the installer report a number that is not true. Nothing here
21
+ * writes, reads, or merges a settings file: a generated handler is inert until
22
+ * a human activates it.
14
23
  */
15
24
 
16
25
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -25,6 +34,14 @@ import {
25
34
  readCanonicalSkills,
26
35
  render,
27
36
  } from "./harnesses/adapter.mjs";
37
+ import {
38
+ classifyHook,
39
+ hookPath,
40
+ hooksFor,
41
+ isPathfinderHook,
42
+ readHookSource,
43
+ shippedHookFiles,
44
+ } from "./harnesses/hook.mjs";
28
45
 
29
46
  /**
30
47
  * Decide the fate of every file in the kit. Reads only; writes nothing.
@@ -143,7 +160,7 @@ export function planAdapters(harnesses, { kitRoot, targetRoot, force = false } =
143
160
  const relativePath = adapterPath(harness, skill.name);
144
161
  const destination = join(targetRoot, ...relativePath.split("/"));
145
162
  const contents = render(harness, skill);
146
- const existing = readAdapter(destination);
163
+ const existing = readExistingFile(destination);
147
164
 
148
165
  if (existing.unreadable) {
149
166
  plan.push({
@@ -279,6 +296,144 @@ export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
279
296
  return result;
280
297
  }
281
298
 
299
+ /**
300
+ * Decide the fate of every hook handler for the selected harnesses.
301
+ *
302
+ * The same plan/apply discipline everything else here uses, and for the same
303
+ * reason: `--dry-run` reports the handler by running this exact function, not
304
+ * a description of it. Reads only.
305
+ *
306
+ * The set of handlers comes from the registry, so one is planned for what this
307
+ * version ships and nothing else. A harness with no lifecycle event has no
308
+ * handlers and contributes nothing to the plan — which is what keeps a Codex
309
+ * destination free of `.claude` artifacts, rather than a check somewhere for
310
+ * the string "claude".
311
+ *
312
+ * Anything else already in the harness's hooks directory is looked at exactly
313
+ * once — to see whether it carries our marker, which makes it an orphan worth
314
+ * reporting — and is never written and never removed. No settings file is
315
+ * read, written, or considered anywhere in here.
316
+ *
317
+ * @returns {{harness: object, name: string, relativePath: string, destination: string,
318
+ * state: string, action: "write"|"replace"|"up-to-date"|"conflict"|"orphan"|"unreadable",
319
+ * contents: string|null, message?: string}[]}
320
+ */
321
+ export function planHooks(harnesses, { targetRoot, force = false } = {}) {
322
+ const plan = [];
323
+
324
+ for (const harness of harnesses) {
325
+ for (const hook of hooksFor(harness)) {
326
+ const relativePath = hookPath(harness, hook);
327
+ const destination = join(targetRoot, ...relativePath.split("/"));
328
+ // The canonical bytes, shipped verbatim. There is no renderer: a
329
+ // handler's bytes are its behavior, which is also what makes the
330
+ // installed file mechanically comparable to the canonical one.
331
+ const contents = readHookSource(hook);
332
+ const existing = readExistingFile(destination);
333
+
334
+ if (existing.unreadable) {
335
+ plan.push({
336
+ harness,
337
+ name: hook.name,
338
+ relativePath,
339
+ destination,
340
+ state: ADAPTER_STATE.CONFLICT,
341
+ action: "unreadable",
342
+ contents: null,
343
+ message: existing.message,
344
+ });
345
+ continue;
346
+ }
347
+
348
+ const classified = classifyHook({
349
+ name: hook.name,
350
+ existing: existing.content,
351
+ expected: contents,
352
+ });
353
+
354
+ plan.push({
355
+ harness,
356
+ name: hook.name,
357
+ relativePath,
358
+ destination,
359
+ state: classified.state,
360
+ action: actionFor(classified.state, force),
361
+ contents,
362
+ });
363
+ }
364
+
365
+ for (const file of orphanHookFiles(harness, targetRoot)) {
366
+ plan.push({
367
+ harness,
368
+ name: file,
369
+ relativePath: `${harness.hooksDir}/${file}`,
370
+ destination: join(targetRoot, ...`${harness.hooksDir}/${file}`.split("/")),
371
+ state: ADAPTER_STATE.ORPHAN,
372
+ action: "orphan",
373
+ contents: null,
374
+ });
375
+ }
376
+ }
377
+
378
+ return plan;
379
+ }
380
+
381
+ /**
382
+ * Carry out a hook plan.
383
+ *
384
+ * Deliberately the same shape and the same outcome vocabulary as
385
+ * `applyAdapterPlan`: failures are collected rather than thrown, conflicts and
386
+ * orphans are outcomes rather than errors, and `onProgress` fires once per
387
+ * item after it resolves.
388
+ *
389
+ * @returns {{generated: number, replaced: number, unchanged: number,
390
+ * conflicts: string[], orphans: string[],
391
+ * errors: {relativePath: string, message: string}[]}}
392
+ */
393
+ export function applyHookPlan(plan, { dryRun = false, onProgress } = {}) {
394
+ const result = { generated: 0, replaced: 0, unchanged: 0, conflicts: [], orphans: [], errors: [] };
395
+
396
+ for (const item of plan) {
397
+ switch (item.action) {
398
+ case "up-to-date":
399
+ result.unchanged += 1;
400
+ onProgress?.({ item, ok: true });
401
+ continue;
402
+ case "conflict":
403
+ result.conflicts.push(item.relativePath);
404
+ onProgress?.({ item, ok: true });
405
+ continue;
406
+ case "orphan":
407
+ result.orphans.push(item.relativePath);
408
+ onProgress?.({ item, ok: true });
409
+ continue;
410
+ case "unreadable":
411
+ result.errors.push({ relativePath: item.relativePath, message: item.message });
412
+ onProgress?.({ item, ok: false });
413
+ continue;
414
+ default:
415
+ break;
416
+ }
417
+
418
+ if (!dryRun) {
419
+ try {
420
+ mkdirSync(dirname(item.destination), { recursive: true });
421
+ writeFileSync(item.destination, item.contents, "utf8");
422
+ } catch (error) {
423
+ result.errors.push({ relativePath: item.relativePath, message: error.message });
424
+ onProgress?.({ item, ok: false });
425
+ continue;
426
+ }
427
+ }
428
+
429
+ if (item.action === "replace") result.replaced += 1;
430
+ else result.generated += 1;
431
+ onProgress?.({ item, ok: true });
432
+ }
433
+
434
+ return result;
435
+ }
436
+
282
437
  /**
283
438
  * Read a file that may not be there.
284
439
  *
@@ -287,7 +442,7 @@ export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
287
442
  * absence would classify someone's file as "generate here", which is the one
288
443
  * mistake this layer exists to prevent, so it becomes a reported error instead.
289
444
  */
290
- function readAdapter(path) {
445
+ function readExistingFile(path) {
291
446
  try {
292
447
  return { content: readFileSync(path, "utf8"), unreadable: false };
293
448
  } catch (error) {
@@ -315,7 +470,7 @@ function orphanNames(harness, targetRoot, canonicalNames) {
315
470
  .map((entry) => entry.name)
316
471
  .sort()
317
472
  .filter((name) => {
318
- const file = readAdapter(join(skillsDirectory, name, "SKILL.md"));
473
+ const file = readExistingFile(join(skillsDirectory, name, "SKILL.md"));
319
474
  return file.content !== null && isPathfinderAdapter(file.content);
320
475
  });
321
476
  } catch {
@@ -323,6 +478,39 @@ function orphanNames(harness, targetRoot, canonicalNames) {
323
478
  }
324
479
  }
325
480
 
481
+ /**
482
+ * Files in the harness's hooks directory that carry our marker but are not
483
+ * handlers this version ships.
484
+ *
485
+ * Reported, never deleted — and here that is not merely consistency with the
486
+ * adapters. A human may have activated the handler by hand, and removing the
487
+ * file underneath that activation would knowingly point their settings at
488
+ * something that is gone. An orphaned handler that still works is strictly
489
+ * better, and removal stays the human's, in whichever order they like.
490
+ *
491
+ * Any failure to look — no directory, no permission — is simply "no orphans",
492
+ * because this is a courtesy check and must not be able to fail an install.
493
+ */
494
+ function orphanHookFiles(harness, targetRoot) {
495
+ if (!harness.hooksDir) return [];
496
+
497
+ const shipped = shippedHookFiles(harness);
498
+ const directory = join(targetRoot, ...harness.hooksDir.split("/"));
499
+
500
+ try {
501
+ return readdirSync(directory, { withFileTypes: true })
502
+ .filter((entry) => entry.isFile() && !shipped.has(entry.name))
503
+ .map((entry) => entry.name)
504
+ .sort()
505
+ .filter((name) => {
506
+ const file = readExistingFile(join(directory, name));
507
+ return file.content !== null && isPathfinderHook(file.content);
508
+ });
509
+ } catch {
510
+ return [];
511
+ }
512
+ }
513
+
326
514
  /** Every file under `path`, recursively, minus junk. A file yields itself. */
327
515
  function* walkFiles(path) {
328
516
  const stats = statSync(path);