create-pathfinder 4.0.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.
package/src/cli.mjs CHANGED
@@ -7,7 +7,14 @@
7
7
  */
8
8
 
9
9
  import { findKitRoot, COPY_LIST, VERSION } from "./kit.mjs";
10
- import { applyAdapterPlan, applyPlan, planAdapters, planInstall } from "./install.mjs";
10
+ import {
11
+ applyAdapterPlan,
12
+ applyHookPlan,
13
+ applyPlan,
14
+ planAdapters,
15
+ planHooks,
16
+ planInstall,
17
+ } from "./install.mjs";
11
18
  import { detect, detectedToolLabels } from "./detect.mjs";
12
19
  import { initRepository } from "./git.mjs";
13
20
  import { nonInteractivePrompter } from "./prompt.mjs";
@@ -17,6 +24,7 @@ import { kickstartPrompt, kickstartPromptLines } from "./kickstart-prompt.mjs";
17
24
  import { createTheme } from "./theme.mjs";
18
25
  import { createProgress } from "./progress.mjs";
19
26
  import { summarize } from "./outcome.mjs";
27
+ import { activationLines } from "./activation.mjs";
20
28
  import {
21
29
  HARNESSES,
22
30
  HARNESS_IDS,
@@ -239,12 +247,20 @@ export async function run(
239
247
  ? planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force })
240
248
  : [];
241
249
 
250
+ // Planned here for the same reasons, and safe for one more: a handler lives
251
+ // under a harness's hooks directory, which no copy-list entry writes to
252
+ // either. A harness with no session lifecycle event contributes nothing, so
253
+ // this is empty for every destination but Claude Code — and empty is the
254
+ // whole of what a Codex destination receives.
255
+ const hookPlan =
256
+ harnesses.length > 0 ? planHooks(harnesses, { targetRoot: cwd, force: options.force }) : [];
257
+
242
258
  // Zero on a dry run, which disables the bar. A dry run carries nothing out,
243
259
  // and a bar filling for work that is not happening would be the exact species
244
260
  // of theatre this treatment was designed to avoid.
245
261
  const progress = createProgress({
246
262
  theme,
247
- total: options.dryRun ? 0 : plan.length + adapterPlan.length,
263
+ total: options.dryRun ? 0 : plan.length + adapterPlan.length + hookPlan.length,
248
264
  out,
249
265
  });
250
266
 
@@ -286,6 +302,14 @@ export async function run(
286
302
  ),
287
303
  });
288
304
 
305
+ const hooks = generateHooks({
306
+ plan: hookPlan,
307
+ harnesses,
308
+ options,
309
+ result,
310
+ onProgress: (unit) => progress.advance(unit),
311
+ });
312
+
289
313
  progress.finish();
290
314
  if (!options.dryRun && theme.tier !== "contract") out("\n");
291
315
 
@@ -293,7 +317,7 @@ export async function run(
293
317
  // plan or a result: the two renderings disagree about everything except the
294
318
  // facts, and this is what makes "except the facts" true rather than a hope
295
319
  // about two functions being edited together.
296
- const outcome = summarize({ plan, result, adapters, harnesses, options });
320
+ const outcome = summarize({ plan, result, adapters, hooks, harnesses, options });
297
321
 
298
322
  report({ outcome, harnesses, customTools, cwd, gitRoot, options, out, err, theme });
299
323
 
@@ -515,6 +539,35 @@ function generateAdapters({ plan, harnesses, options, result, onProgress, onHarn
515
539
  return { plan, result: applied, blocked: false };
516
540
  }
517
541
 
542
+ /**
543
+ * Generate the session hook handlers for the selected harnesses.
544
+ *
545
+ * Returns the plan and the result together, exactly as `generateAdapters`
546
+ * does, so the report can tell "no harness has a handler" from "a handler was
547
+ * planned and produced nothing".
548
+ *
549
+ * No per-harness milestone. A handler is one file, and a milestone line
550
+ * announcing it would give a single inert file the same weight as the
551
+ * twenty-two adapters above it. The summary states it once, where the reader
552
+ * can act on it.
553
+ *
554
+ * Blocked by a failed copy for the same reason adapters are: the handler reads
555
+ * `context/` to orient a session, and putting one down beside a copy that did
556
+ * not finish would generate a file whose whole subject may be missing.
557
+ */
558
+ function generateHooks({ plan, harnesses, options, result, onProgress }) {
559
+ const none = { plan: [], result: applyHookPlan([]), blocked: false };
560
+
561
+ if (harnesses.length === 0 || plan.length === 0) return none;
562
+ if (result.errors.length > 0) return { ...none, blocked: true };
563
+
564
+ return {
565
+ plan,
566
+ result: applyHookPlan(plan, { dryRun: options.dryRun, onProgress }),
567
+ blocked: false,
568
+ };
569
+ }
570
+
518
571
  /**
519
572
  * The one line under the closing headline: what this run actually did.
520
573
  *
@@ -1261,7 +1314,7 @@ function contractAdapterLines({ outcome, options, theme }) {
1261
1314
 
1262
1315
  const lines = [];
1263
1316
 
1264
- for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1317
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans, handlers } of outcome.harnessRows) {
1265
1318
  lines.push(
1266
1319
  ` ${generated} ${harness.label} skill adapter${plural(generated)} ` +
1267
1320
  (options.dryRun ? "to generate" : "generated"),
@@ -1294,6 +1347,105 @@ function contractAdapterLines({ outcome, options, theme }) {
1294
1347
  lines.push(` ${path} delegates to a skill this version no longer`);
1295
1348
  lines.push(" ships. It was left in place; delete it yourself if you want it gone.");
1296
1349
  }
1350
+
1351
+ lines.push(...contractHandlerLines({ harness, handlers, options }));
1352
+ }
1353
+
1354
+ return lines;
1355
+ }
1356
+
1357
+ /**
1358
+ * The hook handler half, for a harness that has one.
1359
+ *
1360
+ * Silent when nothing was generated and nothing needs a human, which is every
1361
+ * harness with no session lifecycle event. A tool that has no handlers should
1362
+ * print no sentence about handlers.
1363
+ *
1364
+ * The inert clause is the one thing this must say. A file appearing under
1365
+ * `.claude/hooks/` looks like something that runs, and it does not: Pathfinder
1366
+ * writes no settings file, so nothing references it until a human says so.
1367
+ */
1368
+ function contractHandlerLines({ harness, handlers, options }) {
1369
+ const { generated, replaced, unchanged, conflicts, orphans } = handlers;
1370
+ const lines = [];
1371
+
1372
+ if (generated > 0) {
1373
+ lines.push(
1374
+ ` ${generated} ${harness.label} session hook handler${plural(generated)} ` +
1375
+ (options.dryRun ? "to generate" : "generated") +
1376
+ " (inert; nothing runs it yet)",
1377
+ );
1378
+ }
1379
+
1380
+ if (replaced > 0) {
1381
+ lines.push(` ${replaced} ${harness.label} session hook handler${plural(replaced)} replaced (--force)`);
1382
+ }
1383
+
1384
+ if (unchanged > 0) {
1385
+ lines.push(` ${unchanged} ${harness.label} session hook handler${plural(unchanged)} already up to date`);
1386
+ }
1387
+
1388
+ for (const path of conflicts) {
1389
+ lines.push(` ${path} was left untouched because Pathfinder`);
1390
+ lines.push(" did not write it. Re-run with --force to replace it.");
1391
+ }
1392
+
1393
+ for (const path of orphans) {
1394
+ lines.push(` ${path} is a handler this version no longer`);
1395
+ lines.push(" ships. It was left in place; delete it yourself if you want it gone.");
1396
+ }
1397
+
1398
+ return lines;
1399
+ }
1400
+
1401
+ /**
1402
+ * The harnesses whose handler this run put on disk, or would.
1403
+ *
1404
+ * Generated, replaced, or already up to date — all three mean the file is
1405
+ * there, or will be, and the fragment below is worth pasting. Under
1406
+ * `--dry-run` none of it has happened yet, which is what the note's tense is
1407
+ * for rather than a second membership rule here. A conflict deliberately does
1408
+ * not count: that path holds a file Pathfinder did not write, so telling
1409
+ * someone to activate "the handler" would point their settings at a stranger's
1410
+ * script.
1411
+ */
1412
+ function activatable(outcome) {
1413
+ if (outcome.blocked) return [];
1414
+ return outcome.harnessRows
1415
+ .filter(({ handlers }) => handlers.generated + handlers.replaced + handlers.unchanged > 0)
1416
+ .map(({ harness }) => harness);
1417
+ }
1418
+
1419
+ /**
1420
+ * The activation note, as a block in the expressive rendering.
1421
+ *
1422
+ * This rendering, and only this one. `contractReport` is a promise kept to
1423
+ * scripts written against 1.4.1 and every byte of it is pinned; a multi-line
1424
+ * JSON fragment appended to that output is a new fact in the middle of a
1425
+ * stream somebody is parsing, and no amount of usefulness makes that a safe
1426
+ * place to put it. A person at a terminal reads the note here; everyone else
1427
+ * reads the guide, which is checked against this same text by the validator.
1428
+ *
1429
+ * A heading and a payload, like `warnBlock`, and pointedly not one: nothing
1430
+ * here went wrong, and a warning glyph over an optional capability is how a
1431
+ * tool teaches people to ignore its warnings. The fragment stays undecorated
1432
+ * for the reason the warning blocks keep their paths undecorated — selecting
1433
+ * it in a terminal must copy characters, not escapes.
1434
+ *
1435
+ * `--dry-run` gets the same block in the future tense. The handler it names
1436
+ * has not been written, and telling somebody to activate a file that is not
1437
+ * there is exactly the kind of confident narration a dry run exists to avoid.
1438
+ */
1439
+ function expressiveActivationBlock({ outcome, options, theme }) {
1440
+ const lines = [];
1441
+
1442
+ for (const harness of activatable(outcome)) {
1443
+ lines.push("");
1444
+ lines.push(` ${theme.glyph.info} ${theme.bold(`${harness.label} session orientation is optional`)}`);
1445
+ lines.push("");
1446
+ for (const line of activationLines(harness, { dryRun: options.dryRun })) {
1447
+ lines.push(line === "" ? "" : ` ${line}`);
1448
+ }
1297
1449
  }
1298
1450
 
1299
1451
  return lines;
@@ -1422,6 +1574,7 @@ function expressiveReport({ outcome, harnesses, customTools, cwd, gitRoot, optio
1422
1574
  }
1423
1575
 
1424
1576
  lines.push(...expressiveAdapterBlocks({ outcome, theme }));
1577
+ lines.push(...expressiveActivationBlock({ outcome, options, theme }));
1425
1578
 
1426
1579
  if (customTools.length > 0) lines.push(...customToolLines(customTools));
1427
1580
 
@@ -1524,7 +1677,7 @@ function expressiveAdapterLines({ outcome, options, theme }) {
1524
1677
 
1525
1678
  const lines = [];
1526
1679
 
1527
- for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1680
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans, handlers } of outcome.harnessRows) {
1528
1681
  lines.push(
1529
1682
  railed(
1530
1683
  theme,
@@ -1566,6 +1719,74 @@ function expressiveAdapterLines({ outcome, options, theme }) {
1566
1719
  ),
1567
1720
  );
1568
1721
  }
1722
+
1723
+ lines.push(...expressiveHandlerLines({ harness, handlers, options, theme }));
1724
+ }
1725
+
1726
+ return lines;
1727
+ }
1728
+
1729
+ /**
1730
+ * The hook handler counts for one harness, on the gutter.
1731
+ *
1732
+ * Nothing at all for a harness with no handlers. The inert clause rides the
1733
+ * generated line rather than a line of its own, because it is not news — it is
1734
+ * what the file *is*, and a separate line would read as a warning about
1735
+ * something going wrong.
1736
+ */
1737
+ function expressiveHandlerLines({ harness, handlers, options, theme }) {
1738
+ const mark = theme.glyph;
1739
+ const { generated, replaced, unchanged, conflicts, orphans } = handlers;
1740
+ const lines = [];
1741
+
1742
+ if (generated > 0) {
1743
+ lines.push(
1744
+ railed(
1745
+ theme,
1746
+ theme.ok(
1747
+ `${mark.ok} ${generated} ${harness.label} session hook handler${plural(generated)} ` +
1748
+ (options.dryRun ? "to generate" : "generated"),
1749
+ ) + theme.dim(" (inert; nothing runs it yet)"),
1750
+ ),
1751
+ );
1752
+ }
1753
+
1754
+ if (replaced > 0) {
1755
+ lines.push(
1756
+ railed(
1757
+ theme,
1758
+ theme.info(`${mark.info} ${replaced} ${harness.label} session hook handler${plural(replaced)} replaced (--force)`),
1759
+ ),
1760
+ );
1761
+ }
1762
+
1763
+ if (unchanged > 0) {
1764
+ lines.push(
1765
+ railed(
1766
+ theme,
1767
+ theme.dim(`${mark.info} ${unchanged} ${harness.label} session hook handler${plural(unchanged)} already up to date`),
1768
+ ),
1769
+ );
1770
+ }
1771
+
1772
+ if (conflicts.length > 0) {
1773
+ lines.push(
1774
+ railed(
1775
+ theme,
1776
+ theme.warn(`${mark.warn} ${conflicts.length} ${harness.label} hook file${plural(conflicts.length)} left untouched`) +
1777
+ theme.dim(conflicts.length === 1 ? " (Pathfinder did not write it)" : " (Pathfinder did not write them)"),
1778
+ ),
1779
+ );
1780
+ }
1781
+
1782
+ if (orphans.length > 0) {
1783
+ lines.push(
1784
+ railed(
1785
+ theme,
1786
+ theme.warn(`${mark.warn} ${orphans.length} ${harness.label} orphan hook handler${plural(orphans.length)}`) +
1787
+ theme.dim(" (this version no longer ships it)"),
1788
+ ),
1789
+ );
1569
1790
  }
1570
1791
 
1571
1792
  return lines;
@@ -1577,7 +1798,7 @@ function expressiveAdapterBlocks({ outcome, theme }) {
1577
1798
 
1578
1799
  const blocks = [];
1579
1800
 
1580
- for (const { harness, conflicts, orphans } of outcome.harnessRows) {
1801
+ for (const { harness, conflicts, orphans, handlers } of outcome.harnessRows) {
1581
1802
  if (conflicts.length > 0) {
1582
1803
  const one = conflicts.length === 1;
1583
1804
  blocks.push(
@@ -1608,6 +1829,39 @@ function expressiveAdapterBlocks({ outcome, theme }) {
1608
1829
  }),
1609
1830
  );
1610
1831
  }
1832
+
1833
+ if (handlers.conflicts.length > 0) {
1834
+ const one = handlers.conflicts.length === 1;
1835
+ blocks.push(
1836
+ ...warnBlock({
1837
+ theme,
1838
+ word: "Conflict",
1839
+ summary: `${handlers.conflicts.length} ${harness.label} file${plural(handlers.conflicts.length)} at ${one ? "a path a session hook handler wants" : "paths session hook handlers want"}, which Pathfinder did not write`,
1840
+ paths: handlers.conflicts,
1841
+ advice: [
1842
+ `Re-run with --force to replace ${one ? "it" : "them"} ${theme.glyph.dash} note that --force also`,
1843
+ "overwrites Pathfinder kit files you have edited.",
1844
+ ],
1845
+ }),
1846
+ );
1847
+ }
1848
+
1849
+ if (handlers.orphans.length > 0) {
1850
+ const one = handlers.orphans.length === 1;
1851
+ blocks.push(
1852
+ ...warnBlock({
1853
+ theme,
1854
+ word: "Orphan",
1855
+ summary: `${handlers.orphans.length} ${harness.label} session hook handler${plural(handlers.orphans.length)} this version no longer ships`,
1856
+ paths: handlers.orphans,
1857
+ advice: [
1858
+ `Left in place, deliberately: if you activated ${one ? "it" : "them"} by hand, removing`,
1859
+ `${one ? "it" : "them"} would break that. Delete ${one ? "it" : "them"} yourself, and the hook`,
1860
+ "configuration pointing at it, whenever you like.",
1861
+ ],
1862
+ }),
1863
+ );
1864
+ }
1611
1865
  }
1612
1866
 
1613
1867
  return blocks;
@@ -71,6 +71,41 @@ export const ADAPTER_STATE = Object.freeze({
71
71
  /** States Pathfinder may write without `--force`. */
72
72
  const OWNED_STATES = new Set([ADAPTER_STATE.ABSENT, ADAPTER_STATE.STALE, ADAPTER_STATE.CURRENT]);
73
73
 
74
+ /**
75
+ * The state table above, as a function, for every generated artifact.
76
+ *
77
+ * Deliberately knows nothing about adapters, skills, markers, or comment
78
+ * syntax. It takes the two facts a caller has already established — does this
79
+ * file carry a marker *this build owns*, and does this version still ship the
80
+ * thing at this path — plus the bytes, and returns which of the six states
81
+ * that is. `classifyAdapter` below is the skill-adapter spelling of it, and a
82
+ * generated hook handler is another; both get one table rather than two that
83
+ * drift.
84
+ *
85
+ * Ownership is the caller's to decide because the marker is where artifacts
86
+ * genuinely differ: an adapter is Markdown and carries an HTML comment, a
87
+ * handler is a script and carries a line comment, and each owns its own format
88
+ * version. Nothing else about the decision changes.
89
+ *
90
+ * @param {{existing: string|null, expected?: string|null,
91
+ * ours?: boolean, shipped?: boolean}} input
92
+ * @returns {string} one of ADAPTER_STATE
93
+ */
94
+ export function classifyOwnership({ existing = null, expected = null, ours = false, shipped = true }) {
95
+ // A marked file naming something this version does not ship. Reported so it
96
+ // cannot rot unnoticed, and left alone: deleting in someone else's
97
+ // repository is a different authority than writing, and is not claimed.
98
+ if (!shipped) return ours ? ADAPTER_STATE.ORPHAN : ADAPTER_STATE.UNMANAGED;
99
+ if (existing === null) return ADAPTER_STATE.ABSENT;
100
+ if (!ours) return ADAPTER_STATE.CONFLICT;
101
+ return existing === expected ? ADAPTER_STATE.CURRENT : ADAPTER_STATE.STALE;
102
+ }
103
+
104
+ /** May Pathfinder write this state without `--force`? */
105
+ export function isOwnedState(state) {
106
+ return OWNED_STATES.has(state);
107
+ }
108
+
74
109
  /**
75
110
  * Where a canonical skill lives, relative to the project root.
76
111
  *
@@ -251,23 +286,19 @@ export function isPathfinderAdapter(content) {
251
286
  */
252
287
  export function classifyAdapter({ name, isCanonicalSkill, existing = null, expected = null }) {
253
288
  const marker = readMarker(existing);
254
- const ours = marker?.version === MARKER_VERSION;
255
289
 
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
- })();
290
+ const state = classifyOwnership({
291
+ existing,
292
+ expected,
293
+ ours: marker?.version === MARKER_VERSION,
294
+ shipped: isCanonicalSkill,
295
+ });
265
296
 
266
297
  return {
267
298
  name,
268
299
  state,
269
300
  marker,
270
- owned: OWNED_STATES.has(state),
301
+ owned: isOwnedState(state),
271
302
  forceReplaceable: state === ADAPTER_STATE.CONFLICT,
272
303
  };
273
304
  }
@@ -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",