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.
@@ -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 = readAdapter(destination);
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 readAdapter(path) {
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 = readAdapter(join(skillsDirectory, name, "SKILL.md"));
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 the order they happened
58
- // in and the order the failure list has always printed.
59
- const failures = Object.freeze([...result.errors, ...adapters.result.errors]);
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
- attention: attentionCount(adapters),
75
- harnessRows: harnessRows({ adapters, harnesses }),
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(adapters) {
93
- if (adapters.blocked) return 0;
94
- return adapters.plan.filter(
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
- * Paths that failed to write are excluded from every count and list here: an
114
- * adapter that could not be written was not generated, is not up to date, and
115
- * is not a conflict the user can resolve by re-running with `--force`. They are
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
- // Identity, not label: the harness object on a plan item is the registry
126
- // entry itself, and two entries could plausibly share a label one day.
127
- const mine = adapters.plan.filter(
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
- return Object.freeze({
137
- harness,
138
- generated: count("write"),
139
- replaced: count("replace"),
140
- unchanged: count("up-to-date"),
141
- // `planAdapters` order, which is the order they will be printed in.
142
- conflicts: paths("conflict"),
143
- orphans: paths("orphan"),
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
  }