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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pathfinder:hook v1 name=session-orientation
|
|
3
|
+
// Generated by create-pathfinder. Do not edit; re-run `npx create-pathfinder`.
|
|
4
|
+
/**
|
|
5
|
+
* SessionStart orientation: surface Pathfinder work state to a starting session.
|
|
6
|
+
*
|
|
7
|
+
* Inert until you activate it. Pathfinder writes this file and nothing else —
|
|
8
|
+
* no settings file is created, merged, or read — so nothing runs it until you
|
|
9
|
+
* add native hook configuration pointing here yourself. Delete this file to
|
|
10
|
+
* remove it; an unreferenced handler never runs, and a reference to a missing
|
|
11
|
+
* handler is a non-blocking no-op.
|
|
12
|
+
*
|
|
13
|
+
* Emits a compact, read-only snapshot so the agent does not rediscover the
|
|
14
|
+
* current ticket, branch, and context availability by hand. It reports facts
|
|
15
|
+
* and one bounded verbatim excerpt; it interprets nothing. Role, Feature,
|
|
16
|
+
* ticket identity, and the next action are `whereami`'s reading of that same
|
|
17
|
+
* file, and this handler must not produce a second, divergent one.
|
|
18
|
+
*
|
|
19
|
+
* The excerpt is quoted, never parsed. `context/current-ticket.md` is written
|
|
20
|
+
* by `/ticket load` with no schema, and nothing validates its shape — bounded
|
|
21
|
+
* verbatim transport is what makes this handler immune to a file whose shape
|
|
22
|
+
* is not guaranteed. A parser here would break that, whatever it improved.
|
|
23
|
+
*
|
|
24
|
+
* Read-only by construction: it opens files for reading and runs exactly one
|
|
25
|
+
* Git command, with --no-optional-locks so not even the index cache is written.
|
|
26
|
+
*
|
|
27
|
+
* Fails open and silent: any error, or any directory that is not a Pathfinder
|
|
28
|
+
* project, exits 0 with no output. Orientation is a convenience, and a broken
|
|
29
|
+
* convenience must not announce itself into every session.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { join, isAbsolute } from "node:path";
|
|
35
|
+
|
|
36
|
+
const MAX_LINES = 40;
|
|
37
|
+
const MAX_CHARS = 2000;
|
|
38
|
+
|
|
39
|
+
/** Read stdin fully; the harness closes it. */
|
|
40
|
+
function readStdin() {
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(0, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The directory the session is actually working in, worktrees included. */
|
|
49
|
+
function resolveRoot(input) {
|
|
50
|
+
const candidates = [input?.cwd, process.env.CLAUDE_PROJECT_DIR, process.cwd()];
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
if (typeof candidate === "string" && candidate && isAbsolute(candidate) && existsSync(candidate)) {
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pathfinder ships exactly two context files; this one is always among them. */
|
|
60
|
+
function isPathfinderProject(root) {
|
|
61
|
+
return existsSync(join(root, "context", "ai-interaction.md"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitLine(root) {
|
|
65
|
+
const result = spawnSync(
|
|
66
|
+
"git",
|
|
67
|
+
["--no-optional-locks", "status", "--porcelain=v1", "--branch"],
|
|
68
|
+
{ cwd: root, encoding: "utf8", timeout: 3000 },
|
|
69
|
+
);
|
|
70
|
+
if (result.status !== 0 || typeof result.stdout !== "string") return "unavailable";
|
|
71
|
+
const lines = result.stdout.split("\n").filter(Boolean);
|
|
72
|
+
const head = lines[0] ?? "";
|
|
73
|
+
const branch = head.startsWith("## ") ? head.slice(3).split("...")[0].trim() : "unknown";
|
|
74
|
+
const changed = lines.length - 1;
|
|
75
|
+
return `${branch} — ${changed === 0 ? "clean" : `${changed} changed`}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Availability only. Never content, never a count that implies a selection. */
|
|
79
|
+
function availability(root) {
|
|
80
|
+
const marks = [];
|
|
81
|
+
const file = (name) =>
|
|
82
|
+
marks.push(`${name} ${existsSync(join(root, "context", name)) ? "yes" : "no"}`);
|
|
83
|
+
file("project-overview.md");
|
|
84
|
+
file("history.md");
|
|
85
|
+
file("tracker.md");
|
|
86
|
+
file("handoff.md");
|
|
87
|
+
for (const directory of ["features", "tickets"]) {
|
|
88
|
+
const path = join(root, "context", directory);
|
|
89
|
+
let count = null;
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(path)) {
|
|
92
|
+
count = readdirSync(path).filter((entry) => entry.endsWith(".md")).length;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
count = null;
|
|
96
|
+
}
|
|
97
|
+
marks.push(`${directory}/ ${count === null ? "no" : `${count} spec${count === 1 ? "" : "s"}`}`);
|
|
98
|
+
}
|
|
99
|
+
return marks.join(" · ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The one file whereami reads, quoted verbatim and bounded. */
|
|
103
|
+
function currentTicketExcerpt(root) {
|
|
104
|
+
const path = join(root, "context", "current-ticket.md");
|
|
105
|
+
if (!existsSync(path)) return { present: false, text: null, truncated: false };
|
|
106
|
+
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = readFileSync(path, "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
return { present: true, text: null, truncated: false };
|
|
112
|
+
}
|
|
113
|
+
if (!raw.trim()) return { present: true, text: null, truncated: false };
|
|
114
|
+
|
|
115
|
+
const lines = raw.split("\n");
|
|
116
|
+
let truncated = lines.length > MAX_LINES;
|
|
117
|
+
let text = lines.slice(0, MAX_LINES).join("\n");
|
|
118
|
+
if (text.length > MAX_CHARS) {
|
|
119
|
+
text = text.slice(0, MAX_CHARS);
|
|
120
|
+
truncated = true;
|
|
121
|
+
}
|
|
122
|
+
return { present: true, text: text.trimEnd(), truncated };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function main() {
|
|
126
|
+
const input = (() => {
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(readStdin());
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
const root = resolveRoot(input);
|
|
135
|
+
if (!root || !isPathfinderProject(root)) return;
|
|
136
|
+
|
|
137
|
+
// Claude Code sends the lifecycle source as `source`; the published reference
|
|
138
|
+
// calls it `session_start_reason`. Verified against a real payload — accept
|
|
139
|
+
// either, and prefer what the tool actually sends.
|
|
140
|
+
const source =
|
|
141
|
+
[input.source, input.session_start_reason].find((value) => typeof value === "string" && value) ??
|
|
142
|
+
"unknown";
|
|
143
|
+
const ticket = currentTicketExcerpt(root);
|
|
144
|
+
|
|
145
|
+
const out = [];
|
|
146
|
+
out.push("# Pathfinder work state (read-only orientation)");
|
|
147
|
+
out.push("");
|
|
148
|
+
out.push(`Session: ${source}`);
|
|
149
|
+
out.push(`Git: ${gitLine(root)}`);
|
|
150
|
+
out.push(`Context: ${availability(root)}`);
|
|
151
|
+
out.push("Role: none — no /role override can be active yet; do not assume one");
|
|
152
|
+
out.push("");
|
|
153
|
+
|
|
154
|
+
if (!ticket.present) {
|
|
155
|
+
out.push(
|
|
156
|
+
"context/current-ticket.md does not exist. No ticket is loaded. Treat Feature, ticket, and next action as `none`.",
|
|
157
|
+
);
|
|
158
|
+
} else if (ticket.text === null) {
|
|
159
|
+
out.push(
|
|
160
|
+
"context/current-ticket.md exists but is empty or unreadable. Treat Feature, ticket, and next action as `none` until it is read directly.",
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
out.push(
|
|
164
|
+
`context/current-ticket.md, verbatim${ticket.truncated ? " (truncated — read the file for the rest)" : ""}:`,
|
|
165
|
+
);
|
|
166
|
+
out.push("");
|
|
167
|
+
out.push(ticket.text);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
out.push("");
|
|
171
|
+
out.push(
|
|
172
|
+
"Orientation only. Nothing above advances the workflow, assumes a role, or selects a next action; run /whereami for the interpreted snapshot.",
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
main();
|
|
180
|
+
} catch {
|
|
181
|
+
// Fail open and silent.
|
|
182
|
+
}
|
|
183
|
+
process.exit(0);
|
package/src/install.mjs
CHANGED
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* they answer a different question — the kit copy asks "does this file exist
|
|
12
12
|
* yet?", and an adapter asks "did Pathfinder write this file?" — and because
|
|
13
13
|
* adapters are generated bytes rather than copied ones.
|
|
14
|
+
*
|
|
15
|
+
* Session hook handlers get a third pair, `planHooks` / `applyHookPlan`. They
|
|
16
|
+
* ask an adapter's ownership question and answer it with the shared state
|
|
17
|
+
* table, but they are not folded into the adapter pair, for two reasons. A
|
|
18
|
+
* handler is not rendered from frontmatter — its canonical bytes are shipped
|
|
19
|
+
* verbatim — and only some harnesses have any, so counting one as an adapter
|
|
20
|
+
* would make the installer report a number that is not true. Nothing here
|
|
21
|
+
* writes, reads, or merges a settings file: a generated handler is inert until
|
|
22
|
+
* a human activates it.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -25,6 +34,14 @@ import {
|
|
|
25
34
|
readCanonicalSkills,
|
|
26
35
|
render,
|
|
27
36
|
} from "./harnesses/adapter.mjs";
|
|
37
|
+
import {
|
|
38
|
+
classifyHook,
|
|
39
|
+
hookPath,
|
|
40
|
+
hooksFor,
|
|
41
|
+
isPathfinderHook,
|
|
42
|
+
readHookSource,
|
|
43
|
+
shippedHookFiles,
|
|
44
|
+
} from "./harnesses/hook.mjs";
|
|
28
45
|
|
|
29
46
|
/**
|
|
30
47
|
* Decide the fate of every file in the kit. Reads only; writes nothing.
|
|
@@ -143,7 +160,7 @@ export function planAdapters(harnesses, { kitRoot, targetRoot, force = false } =
|
|
|
143
160
|
const relativePath = adapterPath(harness, skill.name);
|
|
144
161
|
const destination = join(targetRoot, ...relativePath.split("/"));
|
|
145
162
|
const contents = render(harness, skill);
|
|
146
|
-
const existing =
|
|
163
|
+
const existing = readExistingFile(destination);
|
|
147
164
|
|
|
148
165
|
if (existing.unreadable) {
|
|
149
166
|
plan.push({
|
|
@@ -279,6 +296,144 @@ export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
|
279
296
|
return result;
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Decide the fate of every hook handler for the selected harnesses.
|
|
301
|
+
*
|
|
302
|
+
* The same plan/apply discipline everything else here uses, and for the same
|
|
303
|
+
* reason: `--dry-run` reports the handler by running this exact function, not
|
|
304
|
+
* a description of it. Reads only.
|
|
305
|
+
*
|
|
306
|
+
* The set of handlers comes from the registry, so one is planned for what this
|
|
307
|
+
* version ships and nothing else. A harness with no lifecycle event has no
|
|
308
|
+
* handlers and contributes nothing to the plan — which is what keeps a Codex
|
|
309
|
+
* destination free of `.claude` artifacts, rather than a check somewhere for
|
|
310
|
+
* the string "claude".
|
|
311
|
+
*
|
|
312
|
+
* Anything else already in the harness's hooks directory is looked at exactly
|
|
313
|
+
* once — to see whether it carries our marker, which makes it an orphan worth
|
|
314
|
+
* reporting — and is never written and never removed. No settings file is
|
|
315
|
+
* read, written, or considered anywhere in here.
|
|
316
|
+
*
|
|
317
|
+
* @returns {{harness: object, name: string, relativePath: string, destination: string,
|
|
318
|
+
* state: string, action: "write"|"replace"|"up-to-date"|"conflict"|"orphan"|"unreadable",
|
|
319
|
+
* contents: string|null, message?: string}[]}
|
|
320
|
+
*/
|
|
321
|
+
export function planHooks(harnesses, { targetRoot, force = false } = {}) {
|
|
322
|
+
const plan = [];
|
|
323
|
+
|
|
324
|
+
for (const harness of harnesses) {
|
|
325
|
+
for (const hook of hooksFor(harness)) {
|
|
326
|
+
const relativePath = hookPath(harness, hook);
|
|
327
|
+
const destination = join(targetRoot, ...relativePath.split("/"));
|
|
328
|
+
// The canonical bytes, shipped verbatim. There is no renderer: a
|
|
329
|
+
// handler's bytes are its behavior, which is also what makes the
|
|
330
|
+
// installed file mechanically comparable to the canonical one.
|
|
331
|
+
const contents = readHookSource(hook);
|
|
332
|
+
const existing = readExistingFile(destination);
|
|
333
|
+
|
|
334
|
+
if (existing.unreadable) {
|
|
335
|
+
plan.push({
|
|
336
|
+
harness,
|
|
337
|
+
name: hook.name,
|
|
338
|
+
relativePath,
|
|
339
|
+
destination,
|
|
340
|
+
state: ADAPTER_STATE.CONFLICT,
|
|
341
|
+
action: "unreadable",
|
|
342
|
+
contents: null,
|
|
343
|
+
message: existing.message,
|
|
344
|
+
});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const classified = classifyHook({
|
|
349
|
+
name: hook.name,
|
|
350
|
+
existing: existing.content,
|
|
351
|
+
expected: contents,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
plan.push({
|
|
355
|
+
harness,
|
|
356
|
+
name: hook.name,
|
|
357
|
+
relativePath,
|
|
358
|
+
destination,
|
|
359
|
+
state: classified.state,
|
|
360
|
+
action: actionFor(classified.state, force),
|
|
361
|
+
contents,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const file of orphanHookFiles(harness, targetRoot)) {
|
|
366
|
+
plan.push({
|
|
367
|
+
harness,
|
|
368
|
+
name: file,
|
|
369
|
+
relativePath: `${harness.hooksDir}/${file}`,
|
|
370
|
+
destination: join(targetRoot, ...`${harness.hooksDir}/${file}`.split("/")),
|
|
371
|
+
state: ADAPTER_STATE.ORPHAN,
|
|
372
|
+
action: "orphan",
|
|
373
|
+
contents: null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return plan;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Carry out a hook plan.
|
|
383
|
+
*
|
|
384
|
+
* Deliberately the same shape and the same outcome vocabulary as
|
|
385
|
+
* `applyAdapterPlan`: failures are collected rather than thrown, conflicts and
|
|
386
|
+
* orphans are outcomes rather than errors, and `onProgress` fires once per
|
|
387
|
+
* item after it resolves.
|
|
388
|
+
*
|
|
389
|
+
* @returns {{generated: number, replaced: number, unchanged: number,
|
|
390
|
+
* conflicts: string[], orphans: string[],
|
|
391
|
+
* errors: {relativePath: string, message: string}[]}}
|
|
392
|
+
*/
|
|
393
|
+
export function applyHookPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
394
|
+
const result = { generated: 0, replaced: 0, unchanged: 0, conflicts: [], orphans: [], errors: [] };
|
|
395
|
+
|
|
396
|
+
for (const item of plan) {
|
|
397
|
+
switch (item.action) {
|
|
398
|
+
case "up-to-date":
|
|
399
|
+
result.unchanged += 1;
|
|
400
|
+
onProgress?.({ item, ok: true });
|
|
401
|
+
continue;
|
|
402
|
+
case "conflict":
|
|
403
|
+
result.conflicts.push(item.relativePath);
|
|
404
|
+
onProgress?.({ item, ok: true });
|
|
405
|
+
continue;
|
|
406
|
+
case "orphan":
|
|
407
|
+
result.orphans.push(item.relativePath);
|
|
408
|
+
onProgress?.({ item, ok: true });
|
|
409
|
+
continue;
|
|
410
|
+
case "unreadable":
|
|
411
|
+
result.errors.push({ relativePath: item.relativePath, message: item.message });
|
|
412
|
+
onProgress?.({ item, ok: false });
|
|
413
|
+
continue;
|
|
414
|
+
default:
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!dryRun) {
|
|
419
|
+
try {
|
|
420
|
+
mkdirSync(dirname(item.destination), { recursive: true });
|
|
421
|
+
writeFileSync(item.destination, item.contents, "utf8");
|
|
422
|
+
} catch (error) {
|
|
423
|
+
result.errors.push({ relativePath: item.relativePath, message: error.message });
|
|
424
|
+
onProgress?.({ item, ok: false });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (item.action === "replace") result.replaced += 1;
|
|
430
|
+
else result.generated += 1;
|
|
431
|
+
onProgress?.({ item, ok: true });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
|
|
282
437
|
/**
|
|
283
438
|
* Read a file that may not be there.
|
|
284
439
|
*
|
|
@@ -287,7 +442,7 @@ export function applyAdapterPlan(plan, { dryRun = false, onProgress } = {}) {
|
|
|
287
442
|
* absence would classify someone's file as "generate here", which is the one
|
|
288
443
|
* mistake this layer exists to prevent, so it becomes a reported error instead.
|
|
289
444
|
*/
|
|
290
|
-
function
|
|
445
|
+
function readExistingFile(path) {
|
|
291
446
|
try {
|
|
292
447
|
return { content: readFileSync(path, "utf8"), unreadable: false };
|
|
293
448
|
} catch (error) {
|
|
@@ -315,7 +470,7 @@ function orphanNames(harness, targetRoot, canonicalNames) {
|
|
|
315
470
|
.map((entry) => entry.name)
|
|
316
471
|
.sort()
|
|
317
472
|
.filter((name) => {
|
|
318
|
-
const file =
|
|
473
|
+
const file = readExistingFile(join(skillsDirectory, name, "SKILL.md"));
|
|
319
474
|
return file.content !== null && isPathfinderAdapter(file.content);
|
|
320
475
|
});
|
|
321
476
|
} catch {
|
|
@@ -323,6 +478,39 @@ function orphanNames(harness, targetRoot, canonicalNames) {
|
|
|
323
478
|
}
|
|
324
479
|
}
|
|
325
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Files in the harness's hooks directory that carry our marker but are not
|
|
483
|
+
* handlers this version ships.
|
|
484
|
+
*
|
|
485
|
+
* Reported, never deleted — and here that is not merely consistency with the
|
|
486
|
+
* adapters. A human may have activated the handler by hand, and removing the
|
|
487
|
+
* file underneath that activation would knowingly point their settings at
|
|
488
|
+
* something that is gone. An orphaned handler that still works is strictly
|
|
489
|
+
* better, and removal stays the human's, in whichever order they like.
|
|
490
|
+
*
|
|
491
|
+
* Any failure to look — no directory, no permission — is simply "no orphans",
|
|
492
|
+
* because this is a courtesy check and must not be able to fail an install.
|
|
493
|
+
*/
|
|
494
|
+
function orphanHookFiles(harness, targetRoot) {
|
|
495
|
+
if (!harness.hooksDir) return [];
|
|
496
|
+
|
|
497
|
+
const shipped = shippedHookFiles(harness);
|
|
498
|
+
const directory = join(targetRoot, ...harness.hooksDir.split("/"));
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
502
|
+
.filter((entry) => entry.isFile() && !shipped.has(entry.name))
|
|
503
|
+
.map((entry) => entry.name)
|
|
504
|
+
.sort()
|
|
505
|
+
.filter((name) => {
|
|
506
|
+
const file = readExistingFile(join(directory, name));
|
|
507
|
+
return file.content !== null && isPathfinderHook(file.content);
|
|
508
|
+
});
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
326
514
|
/** Every file under `path`, recursively, minus junk. A file yields itself. */
|
|
327
515
|
function* walkFiles(path) {
|
|
328
516
|
const stats = statSync(path);
|
package/src/outcome.mjs
CHANGED
|
@@ -38,11 +38,15 @@
|
|
|
38
38
|
* @param {{written: number, skipped: number, overwritten: number,
|
|
39
39
|
* errors: {relativePath: string, message: string}[]}} args.result
|
|
40
40
|
* @param {{plan: object[], result: object, blocked: boolean}} args.adapters
|
|
41
|
+
* @param {{plan: object[], result: object, blocked: boolean}} [args.hooks]
|
|
42
|
+
* the session hook handler plan and result, shaped exactly like `adapters`.
|
|
43
|
+
* Optional, and absent means "no handler was planned" — a harness with no
|
|
44
|
+
* lifecycle event has none, which is the ordinary case rather than an error.
|
|
41
45
|
* @param {{label: string}[]} args.harnesses the selected harnesses, registry order
|
|
42
46
|
* @param {{dryRun?: boolean}} args.options
|
|
43
47
|
* @returns {Readonly<object>} frozen; rows and lists frozen with it
|
|
44
48
|
*/
|
|
45
|
-
export function summarize({ plan, result, adapters, harnesses, options }) {
|
|
49
|
+
export function summarize({ plan, result, adapters, hooks = NO_HOOKS, harnesses, options }) {
|
|
46
50
|
// A dry run has no `result.written` to report, because nothing was written.
|
|
47
51
|
// The plan is counted instead, which is the same number the run would have
|
|
48
52
|
// produced had it been allowed to write.
|
|
@@ -54,9 +58,14 @@ export function summarize({ plan, result, adapters, harnesses, options }) {
|
|
|
54
58
|
.filter((item) => item.status === "skip")
|
|
55
59
|
.map((item) => item.relativePath);
|
|
56
60
|
|
|
57
|
-
// Copy errors before adapter errors, because that is
|
|
58
|
-
// in and the order the failure list has always
|
|
59
|
-
|
|
61
|
+
// Copy errors before adapter errors before handler errors, because that is
|
|
62
|
+
// the order they happened in and the order the failure list has always
|
|
63
|
+
// printed.
|
|
64
|
+
const failures = Object.freeze([
|
|
65
|
+
...result.errors,
|
|
66
|
+
...adapters.result.errors,
|
|
67
|
+
...hooks.result.errors,
|
|
68
|
+
]);
|
|
60
69
|
|
|
61
70
|
return Object.freeze({
|
|
62
71
|
written,
|
|
@@ -71,11 +80,35 @@ export function summarize({ plan, result, adapters, harnesses, options }) {
|
|
|
71
80
|
// that errored and this does not, so the two disagree exactly when a write
|
|
72
81
|
// fails — and this is the number the closing headline speaks for.
|
|
73
82
|
built: adapters.result.generated + adapters.result.replaced,
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
// Counted apart from `built`, and never folded into it. A handler is not
|
|
84
|
+
// an adapter — it delegates to nothing and is inert until a human
|
|
85
|
+
// activates it — so adding one to the adapter count would make the closing
|
|
86
|
+
// line state a number that is not true.
|
|
87
|
+
handlers: hooks.result.generated + hooks.result.replaced,
|
|
88
|
+
attention: attentionCount(adapters) + attentionCount(hooks),
|
|
89
|
+
harnessRows: harnessRows({ adapters, hooks, harnesses }),
|
|
76
90
|
});
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* What a run with no hook plan looks like.
|
|
95
|
+
*
|
|
96
|
+
* The same shape `generateHooks` returns, so a caller that has none and a
|
|
97
|
+
* caller whose harnesses have none reach identical code below.
|
|
98
|
+
*/
|
|
99
|
+
const NO_HOOKS = Object.freeze({
|
|
100
|
+
plan: Object.freeze([]),
|
|
101
|
+
result: Object.freeze({
|
|
102
|
+
generated: 0,
|
|
103
|
+
replaced: 0,
|
|
104
|
+
unchanged: 0,
|
|
105
|
+
conflicts: Object.freeze([]),
|
|
106
|
+
orphans: Object.freeze([]),
|
|
107
|
+
errors: Object.freeze([]),
|
|
108
|
+
}),
|
|
109
|
+
blocked: false,
|
|
110
|
+
});
|
|
111
|
+
|
|
79
112
|
/**
|
|
80
113
|
* What actually wants a human: a contested path, or an adapter pointing at a
|
|
81
114
|
* skill that is gone.
|
|
@@ -89,9 +122,9 @@ export function summarize({ plan, result, adapters, harnesses, options }) {
|
|
|
89
122
|
* errored paths included, because a path that could not be written is still a
|
|
90
123
|
* path somebody has to go and look at.
|
|
91
124
|
*/
|
|
92
|
-
function attentionCount(
|
|
93
|
-
if (
|
|
94
|
-
return
|
|
125
|
+
function attentionCount(generated) {
|
|
126
|
+
if (generated.blocked) return 0;
|
|
127
|
+
return generated.plan.filter(
|
|
95
128
|
(item) => item.action === "conflict" || item.action === "orphan",
|
|
96
129
|
).length;
|
|
97
130
|
}
|
|
@@ -110,38 +143,57 @@ function attentionCount(adapters) {
|
|
|
110
143
|
* empty plan, so that a harness which was chosen and never reached is never
|
|
111
144
|
* described as having generated zero adapters.
|
|
112
145
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* reported once, as failures.
|
|
146
|
+
* Each row carries a harness's adapters and, under `handlers`, that harness's
|
|
147
|
+
* hook handlers — the same five facts twice, because they are the same five
|
|
148
|
+
* questions about two different kinds of generated file.
|
|
117
149
|
*/
|
|
118
|
-
function harnessRows({ adapters, harnesses }) {
|
|
150
|
+
function harnessRows({ adapters, hooks, harnesses }) {
|
|
119
151
|
if (harnesses.length === 0 || adapters.blocked) return Object.freeze([]);
|
|
120
152
|
|
|
121
|
-
const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
|
|
122
|
-
|
|
123
153
|
return Object.freeze(
|
|
124
|
-
harnesses.map((harness) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
harnesses.map((harness) =>
|
|
155
|
+
Object.freeze({
|
|
156
|
+
harness,
|
|
157
|
+
...tally(adapters, harness),
|
|
158
|
+
// The same five facts for this harness's hook handlers, under their
|
|
159
|
+
// own names. A harness with no lifecycle event has no handlers, and
|
|
160
|
+
// every one of these is then zero or empty — which is what a report
|
|
161
|
+
// needs in order to say nothing at all about them.
|
|
162
|
+
handlers: Object.freeze(tally(hooks, harness)),
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One harness's share of a generated plan, as the five facts a report prints.
|
|
170
|
+
*
|
|
171
|
+
* Paths that failed to write are excluded from every count and list: a file
|
|
172
|
+
* that could not be written was not generated, is not up to date, and is not a
|
|
173
|
+
* conflict the user can resolve by re-running with `--force`. They are
|
|
174
|
+
* reported once, as failures.
|
|
175
|
+
*/
|
|
176
|
+
function tally(generated, harness) {
|
|
177
|
+
const failed = new Set(generated.result.errors.map((error) => error.relativePath));
|
|
178
|
+
|
|
179
|
+
// Identity, not label: the harness object on a plan item is the registry
|
|
180
|
+
// entry itself, and two entries could plausibly share a label one day.
|
|
181
|
+
const mine = generated.blocked
|
|
182
|
+
? []
|
|
183
|
+
: generated.plan.filter(
|
|
128
184
|
(item) => item.harness === harness && !failed.has(item.relativePath),
|
|
129
185
|
);
|
|
130
|
-
const count = (action) => mine.filter((item) => item.action === action).length;
|
|
131
|
-
const paths = (action) =>
|
|
132
|
-
Object.freeze(
|
|
133
|
-
mine.filter((item) => item.action === action).map((item) => item.relativePath),
|
|
134
|
-
);
|
|
135
186
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
187
|
+
const count = (action) => mine.filter((item) => item.action === action).length;
|
|
188
|
+
const paths = (action) =>
|
|
189
|
+
Object.freeze(mine.filter((item) => item.action === action).map((item) => item.relativePath));
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
generated: count("write"),
|
|
193
|
+
replaced: count("replace"),
|
|
194
|
+
unchanged: count("up-to-date"),
|
|
195
|
+
// Plan order, which is the order they will be printed in.
|
|
196
|
+
conflicts: paths("conflict"),
|
|
197
|
+
orphans: paths("orphan"),
|
|
198
|
+
};
|
|
147
199
|
}
|