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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pathfinder",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Install Pathfinder's ticket-first, human-in-the-loop workflow kit into a Git repository.",
5
5
  "keywords": [
6
6
  "pathfinder",
@@ -0,0 +1,199 @@
1
+ /**
2
+ * What a human does to turn a generated handler on, and how they turn it off.
3
+ *
4
+ * Pathfinder generates the handler and stops. Activation is native harness
5
+ * configuration in a file Pathfinder does not own, does not read, and never
6
+ * writes — so the only thing this package can offer is the exact text to paste
7
+ * and an honest account of what happens if nobody pastes it.
8
+ *
9
+ * That is why this module is prose and nothing else. It imports no `node:fs`,
10
+ * holds no path to a settings file it might one day open, and returns strings.
11
+ * `test/hooks.test.mjs` asserts both halves of that: this is the one module
12
+ * under `src/` allowed to name a settings file, and it is allowed to precisely
13
+ * because it cannot act on one.
14
+ *
15
+ * The fragment is built from the registry's stable handler path rather than
16
+ * written out, so a fragment that no longer matches what the installer
17
+ * generates is not a thing this package can print.
18
+ *
19
+ * The handler ships mode 0644 and is not executable, whatever its shebang
20
+ * says, so the command runs it through Node. `$CLAUDE_PROJECT_DIR` is what
21
+ * keeps the activation correct from a subdirectory and inside a worktree.
22
+ */
23
+
24
+ import { HARNESSES } from "./harnesses/index.mjs";
25
+ import { hookPath, hooksFor } from "./harnesses/hook.mjs";
26
+
27
+ /** Where a human puts the fragment. The first is the documented default. */
28
+ export const DEFAULT_SURFACE = ".claude/settings.local.json";
29
+ export const SHARED_SURFACE = ".claude/settings.json";
30
+
31
+ /**
32
+ * Does this harness declare that it can run a handler at all?
33
+ *
34
+ * Read from the registry — `hooksDir` is the field that says "this tool has a
35
+ * place handlers go" — rather than from the length of `hooks`, which is the
36
+ * thing being checked. Codex answers no, and that is a correct answer, not a
37
+ * gap: it gets no handler and no activation prose.
38
+ */
39
+ export function supportsActivation(harness) {
40
+ return typeof harness?.hooksDir === "string" && harness.hooksDir !== "";
41
+ }
42
+
43
+ /**
44
+ * The one handler a capable harness activates, or null for a harness that has
45
+ * no place to put one.
46
+ *
47
+ * v1 ships exactly one orientation handler per capable harness, and this is
48
+ * where that assumption is stated instead of assumed. `hooksFor(harness)[0]`
49
+ * reads identically whether the registry holds one handler, none, or three —
50
+ * which is precisely the property that makes it unsafe: a registry that lost
51
+ * its hook would print no fragment and a registry that grew a second one would
52
+ * silently document the first. Both are wrong, and both are silent.
53
+ *
54
+ * So a capable harness that does not resolve to exactly one handler throws.
55
+ * Callers that legitimately have nothing to say — a Codex run — are served by
56
+ * the null, not by the throw.
57
+ *
58
+ * @throws {Error} when the registry and this assumption disagree.
59
+ */
60
+ export function activationHandler(harness) {
61
+ const hooks = hooksFor(harness);
62
+
63
+ if (!supportsActivation(harness)) {
64
+ if (hooks.length > 0) {
65
+ throw new Error(
66
+ `activation: ${harness?.id ?? "harness"} declares ${hooks.length} handler(s) ` +
67
+ "but no hooksDir to put them in",
68
+ );
69
+ }
70
+ return null;
71
+ }
72
+
73
+ if (hooks.length !== 1) {
74
+ throw new Error(
75
+ `activation: ${harness.id} supports session orientation, so exactly one handler ` +
76
+ `must resolve; the registry gives ${hooks.length}`,
77
+ );
78
+ }
79
+
80
+ return hooks[0];
81
+ }
82
+
83
+ /**
84
+ * Every harness this build can print activation for, with its handler.
85
+ *
86
+ * Exported for `validate-kit.py`, which needs the set rather than one harness:
87
+ * checking the guide against `claude-code` alone would pass unchanged on the
88
+ * day a second capable harness is added and left undocumented.
89
+ *
90
+ * Throws for the same reasons `activationHandler` does, which is the point —
91
+ * a validator that enumerates this cannot pass vacuously.
92
+ */
93
+ export function activationTargets() {
94
+ return HARNESSES.map((harness) => ({ harness, hook: activationHandler(harness) })).filter(
95
+ ({ hook }) => hook !== null,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * The command that runs one handler, quoted for a shell.
101
+ *
102
+ * Exported for the documentation build and the tests, which check the site and
103
+ * the installer against one spelling rather than two.
104
+ */
105
+ export function activationCommand(harness, hook) {
106
+ return `node "$CLAUDE_PROJECT_DIR/${hookPath(harness, hook)}"`;
107
+ }
108
+
109
+ /**
110
+ * The native fragment, as the lines a person pastes.
111
+ *
112
+ * The matcher is deliberately omitted, so one entry covers the lifecycle
113
+ * sources without naming any of them.
114
+ *
115
+ * `startup`, `resume`, `clear`, and `compact` were each observed live per
116
+ * source against a real install, not inferred from `startup`. `fork` was not:
117
+ * the reachable surface, `claude --resume --fork-session`, emits
118
+ * `source: "resume"`, so it was covered by driving the handler with a `fork`
119
+ * payload instead. The handler branches on the source for nothing, which is
120
+ * what bounds what that weaker evidence can cost.
121
+ */
122
+ export function activationFragmentLines(harness, hook) {
123
+ return [
124
+ "{",
125
+ ' "hooks": {',
126
+ ' "SessionStart": [',
127
+ " {",
128
+ ' "hooks": [',
129
+ " {",
130
+ ' "type": "command",',
131
+ ` "command": ${JSON.stringify(activationCommand(harness, hook))}`,
132
+ " }",
133
+ " ]",
134
+ " }",
135
+ " ]",
136
+ " }",
137
+ "}",
138
+ ];
139
+ }
140
+
141
+ /**
142
+ * The whole activation note for one harness, as undecorated lines.
143
+ *
144
+ * Returned as text rather than printed, so the two renderings in `cli.mjs`
145
+ * decorate one set of sentences instead of each keeping their own copy — the
146
+ * same reason `outcome.mjs` exists. Empty for a harness with no handler, which
147
+ * is how a Codex run says nothing at all about activation.
148
+ *
149
+ * `dryRun` changes tense and nothing else. A dry run has written no handler, so
150
+ * a note that says "the handler is inert" is describing a file that is not
151
+ * there — the one sentence on that screen a reader could act on and be wrong
152
+ * about. The fragment itself is identical in both modes, because the path it
153
+ * points at is the path the real run will write.
154
+ *
155
+ * The unactivated answer is stated in the same breath as the fragment, because
156
+ * a reader who decides not to paste it is owed the consequence on the same
157
+ * screen: no automatic orientation, and `/whereami` on demand.
158
+ */
159
+ export function activationLines(harness, { dryRun = false } = {}) {
160
+ const hook = activationHandler(harness);
161
+ if (!hook) return [];
162
+
163
+ const opening = dryRun
164
+ ? [
165
+ "The handler would be inert. Once installed it would run only if you add",
166
+ `this to ${DEFAULT_SURFACE}:`,
167
+ ]
168
+ : [
169
+ "The handler is inert. It runs only if you add this to",
170
+ `${DEFAULT_SURFACE}:`,
171
+ ];
172
+
173
+ const closing = dryRun
174
+ ? [
175
+ "Without the fragment there would be no automatic orientation. Nothing else",
176
+ "would change: run /whereami whenever you want the same picture.",
177
+ "To remove it later, delete the handler and drop the fragment. Either order",
178
+ "is fine, and so is doing only one: an unreferenced handler never runs.",
179
+ "A fragment for a missing handler is a no-op that cannot block a session.",
180
+ ]
181
+ : [
182
+ "Without the fragment there is no automatic orientation. Nothing else",
183
+ "changes: run /whereami whenever you want the same picture.",
184
+ "To remove it later, delete the handler and drop the fragment. Either order",
185
+ "is fine, and so is doing only one: an unreferenced handler never runs.",
186
+ "A fragment for a missing handler is a no-op that cannot block a session.",
187
+ ];
188
+
189
+ return [
190
+ ...opening,
191
+ "",
192
+ ...activationFragmentLines(harness, hook),
193
+ "",
194
+ `${DEFAULT_SURFACE} keeps the choice yours and`,
195
+ `per-machine. Use ${SHARED_SURFACE} only to turn it on for everyone`,
196
+ "who clones the repository.",
197
+ ...closing,
198
+ ];
199
+ }
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
  }