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/CLAUDE.md +1 -0
- package/package.json +1 -1
- package/skills/hooksmith/SKILL.md +265 -0
- package/src/activation.mjs +199 -0
- package/src/cli.mjs +260 -6
- package/src/harnesses/adapter.mjs +42 -11
- package/src/harnesses/hook.mjs +142 -0
- package/src/harnesses/index.mjs +24 -5
- package/src/hooks/session-orientation.mjs +183 -0
- package/src/install.mjs +191 -3
- package/src/outcome.mjs +88 -36
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 {
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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",
|