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 +1 -1
- 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/package.json
CHANGED
|
@@ -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 {
|
|
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
|
}
|