create-pathfinder 1.5.1 → 1.6.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/src/cli.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * whose identity is "not a framework."
7
7
  */
8
8
 
9
- import { findKitRoot, COPY_LIST } from "./kit.mjs";
9
+ import { findKitRoot, COPY_LIST, VERSION } from "./kit.mjs";
10
10
  import { applyAdapterPlan, applyPlan, planAdapters, planInstall } from "./install.mjs";
11
11
  import { detect, detectedToolLabels } from "./detect.mjs";
12
12
  import { initRepository } from "./git.mjs";
@@ -14,6 +14,8 @@ import { nonInteractivePrompter } from "./prompt.mjs";
14
14
  import { copyToClipboard } from "./clipboard.mjs";
15
15
  import { detectEditors, openInEditor } from "./editor.mjs";
16
16
  import { kickstartPrompt, kickstartPromptLines } from "./kickstart-prompt.mjs";
17
+ import { createTheme } from "./theme.mjs";
18
+ import { createProgress } from "./progress.mjs";
17
19
  import {
18
20
  HARNESSES,
19
21
  HARNESS_IDS,
@@ -83,14 +85,29 @@ export async function run(
83
85
  // Detection runs before anything is decided and before anything is asked, so
84
86
  // the user reads what the tool found before reading what it wants to do.
85
87
  const findings = detect({ cwd, env, platform });
86
- const unicode = supportsUnicode(env, platform);
87
- const mark = marks(unicode);
88
88
 
89
- // Printed only to a terminal. The report is for a person, and the acceptance
90
- // criteria require non-interactive output to stay what 1.4.1 produced so a
91
- // piped run, a CI log, and `> install.txt` all keep the old bytes.
92
- if (stdoutIsTTY) {
93
- out(formatFindings(findings, { unicode }));
89
+ // Every capability question this run will ask is answered once, here, from
90
+ // the three things this function was handed. Threaded downward as an argument
91
+ // rather than reached for: a module-level theme would be a second opinion
92
+ // about the terminal that no test could disagree with.
93
+ const theme = createTheme({ env, platform, isTTY: stdoutIsTTY });
94
+ const mark = theme.glyph;
95
+
96
+ // The one branch in this file that chooses between whole presentations, and
97
+ // the reason the theme exposes `tier` at all.
98
+ //
99
+ // `contract` is a promise, not a fallback: a piped run, a CI log, and
100
+ // `> install.txt` get the bytes 1.4.1 produced, and no amount of ambition in
101
+ // this feature reaches them. Written as a tier test rather than as
102
+ // `if (stdoutIsTTY)` — the two are the same value by construction, but only
103
+ // one of them says why, and the next person to add a decorated block here
104
+ // needs to read the reason and not rediscover it.
105
+ //
106
+ // The identity block rides on the same test. `--help` never reaches this line
107
+ // (it returns above), which is how it keeps its plain reference form.
108
+ if (theme.tier !== "contract") {
109
+ out(formatIdentity({ theme }));
110
+ out(formatFindings(findings, { theme }));
94
111
  }
95
112
 
96
113
  // Refused rather than allowed with a warning: this tool writes several
@@ -156,27 +173,109 @@ export async function run(
156
173
  // the user before the first file moves. Detection supplies the default and
157
174
  // nothing more: a tool being installed on this machine is not permission to
158
175
  // write into the project on its behalf.
159
- const { harnesses, customTools } = await selectHarnesses({ findings, options, prompter, out });
176
+ const { harnesses, customTools } = await selectHarnesses({
177
+ findings,
178
+ options,
179
+ prompter,
180
+ out,
181
+ theme,
182
+ });
160
183
 
161
184
  const plan = planInstall(kitRoot, cwd, { force: options.force });
162
- const result = applyPlan(plan, { dryRun: options.dryRun });
163
185
 
164
- // Deliberately after the copy. An adapter delegates to a canonical file, so
165
- // generating one beside a copy that failed would point the user's tool at a
166
- // file that is not there.
167
- const adapters = generateAdapters({ harnesses, kitRoot, cwd, options, result });
186
+ // Both plans are computed before anything is written, which is what lets the
187
+ // progress bar state a real denominator instead of discovering its own total
188
+ // as it goes.
189
+ //
190
+ // Planning adapters this early is safe, and specifically because of what the
191
+ // copy list contains: AGENTS.md, CLAUDE.md, context, skills, and templates.
192
+ // No entry writes into `.claude/` or `.agents/`, so the copy cannot change
193
+ // the answer `planAdapters` gives about an adapter path, and the canonical
194
+ // skills it reads come from the kit rather than from the destination. If a
195
+ // future entry ever does write to an adapter path, this has to move back.
196
+ //
197
+ // Applying them stays where it was, after the copy: an adapter delegates to a
198
+ // canonical file, so generating one beside a copy that failed would point the
199
+ // user's tool at a file that is not there.
200
+ const adapterPlan =
201
+ harnesses.length > 0
202
+ ? planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force })
203
+ : [];
204
+
205
+ // Zero on a dry run, which disables the bar. A dry run carries nothing out,
206
+ // and a bar filling for work that is not happening would be the exact species
207
+ // of theatre this treatment was designed to avoid.
208
+ const progress = createProgress({
209
+ theme,
210
+ total: options.dryRun ? 0 : plan.length + adapterPlan.length,
211
+ out,
212
+ });
213
+
214
+ if (!options.dryRun && theme.tier !== "contract") {
215
+ out(` ${mark.box} ${theme.bold("INSTALLING")}\n`);
216
+ }
217
+
218
+ const result = applyPlan(plan, {
219
+ dryRun: options.dryRun,
220
+ onProgress: (unit) => progress.advance(unit),
221
+ });
222
+
223
+ progress.milestone(
224
+ result.errors.length > 0
225
+ ? railed(theme, theme.warn(`${mark.warn} Kit files`) + ` ${mark.dash} ${result.errors.length} could not be written`)
226
+ : railed(
227
+ theme,
228
+ countWritten(result, plan, options) > 0
229
+ ? theme.ok(`${mark.ok} Kit files`) + ` ${mark.dash} ${theme.bold(countWritten(result, plan, options))} copied`
230
+ : theme.info(`${mark.info} Kit files`) + ` ${mark.dash} already in place`,
231
+ ),
232
+ );
233
+
234
+ const adapters = generateAdapters({
235
+ plan: adapterPlan,
236
+ harnesses,
237
+ options,
238
+ result,
239
+ onProgress: (unit) => progress.advance(unit),
240
+ onHarnessDone: (harness, counts) =>
241
+ progress.milestone(
242
+ railed(
243
+ theme,
244
+ counts.conflicts > 0
245
+ ? theme.warn(`${mark.warn} ${harness.label}`) +
246
+ ` ${mark.dash} ${counts.done} adapters, ${counts.conflicts} left alone`
247
+ : theme.ok(`${mark.ok} ${harness.label}`) + ` ${mark.dash} ${theme.bold(counts.done)} adapters`,
248
+ ),
249
+ ),
250
+ });
251
+
252
+ progress.finish();
253
+ if (!options.dryRun && theme.tier !== "contract") out("\n");
168
254
 
169
- report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err });
255
+ report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme });
170
256
 
171
257
  // After the report, because the first offer is about the prompt the report
172
258
  // just printed — and because a question above the summary would make the user
173
259
  // answer before seeing what happened. Every run that gets this far printed a
174
260
  // prompt, including one that wrote no files, so there is nothing to guard on.
175
- await offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform });
261
+ await offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform, theme });
262
+
263
+ // The last line of the run, and the reason it is here rather than in the
264
+ // report: the report is followed by two questions, so anything printed there
265
+ // would not be last. Before this, a successful first run ended on
266
+ // "Opening VS Code." — the tool installed several hundred files and then
267
+ // signed off with a subordinate clause about somebody else's editor.
268
+ //
269
+ // Not printed when anything failed. A sign-off over an error is a tool that
270
+ // did not read its own output.
271
+ const failed = result.errors.length > 0 || adapters.result.errors.length > 0;
272
+ if (theme.tier !== "contract" && !failed) {
273
+ out(`\n ${theme.dim(SIGN_OFF)}\n`);
274
+ }
176
275
 
177
276
  // Neither offer can change this. An install that wrote every file succeeded
178
277
  // whether or not the machine has `pbcopy` or an editor on it.
179
- return result.errors.length > 0 || adapters.result.errors.length > 0 ? 1 : 0;
278
+ return failed ? 1 : 0;
180
279
  }
181
280
 
182
281
  /**
@@ -188,7 +287,7 @@ export async function run(
188
287
  * needing to remember to skip it, and one that forgot would generate adapters
189
288
  * into a directory named after a tool that cannot read them.
190
289
  */
191
- const SOMETHING_ELSE = Object.freeze({ label: "Something else" });
290
+ const SOMETHING_ELSE = Object.freeze({ label: "Something else" });
192
291
 
193
292
  /** How many custom names one run will take before it stops asking. */
194
293
  const CUSTOM_TOOL_LIMIT = 10;
@@ -215,7 +314,7 @@ const CUSTOM_TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,39}$/;
215
314
  *
216
315
  * @returns {Promise<{harnesses: object[], customTools: string[]}>}
217
316
  */
218
- async function selectHarnesses({ findings, options, prompter, out }) {
317
+ async function selectHarnesses({ findings, options, prompter, out, theme }) {
219
318
  if (options.agents !== null) {
220
319
  return { harnesses: options.agents.map((id) => findHarness(id)), customTools: [] };
221
320
  }
@@ -223,13 +322,21 @@ async function selectHarnesses({ findings, options, prompter, out }) {
223
322
 
224
323
  const detected = detectedHarnesses(findings);
225
324
  const entries = [...HARNESSES, SOMETHING_ELSE];
226
- const width = Math.max(...entries.map((entry) => entry.label.length));
325
+
326
+ // The sentinel's trailing ellipsis is the theme's, not a character baked into
327
+ // the label — and because it is the longest row, it also decides the column
328
+ // the arrows line up in. So the width is measured on the rendered label
329
+ // rather than the stored one: an ASCII terminal spends three characters on
330
+ // `...` where a UTF-8 one spends one, and the arrows must follow.
331
+ const labelOf = (entry) =>
332
+ entry === SOMETHING_ELSE ? `${entry.label}${theme.glyph.ellipsis}` : entry.label;
333
+ const width = Math.max(...entries.map((entry) => labelOf(entry).length));
227
334
 
228
335
  const answer = await prompter.chooseMany("Configure Pathfinder for which tools?", {
229
336
  options: entries.map((entry) => ({
230
337
  value: entry,
231
338
  label:
232
- `${entry.label.padEnd(width)} -> ` +
339
+ `${labelOf(entry).padEnd(width)} -> ` +
233
340
  // The path is shown so nobody has to check a box to find out what it
234
341
  // writes. The last entry earns the same courtesy by admitting it
235
342
  // writes nothing, in the column where every other row names a file.
@@ -244,7 +351,9 @@ async function selectHarnesses({ findings, options, prompter, out }) {
244
351
 
245
352
  const chosen = answer ?? [];
246
353
  const harnesses = chosen.filter((entry) => entry !== SOMETHING_ELSE);
247
- const customTools = chosen.includes(SOMETHING_ELSE) ? await askCustomTools({ prompter, out }) : [];
354
+ const customTools = chosen.includes(SOMETHING_ELSE)
355
+ ? await askCustomTools({ prompter, out, theme })
356
+ : [];
248
357
 
249
358
  return { harnesses, customTools };
250
359
  }
@@ -262,7 +371,7 @@ async function selectHarnesses({ findings, options, prompter, out }) {
262
371
  * there because a question that repeats itself is a question that can repeat
263
372
  * itself forever on a stream that never closes.
264
373
  */
265
- async function askCustomTools({ prompter, out }) {
374
+ async function askCustomTools({ prompter, out, theme }) {
266
375
  out(
267
376
  "Pathfinder generates adapters only for tools it can generate them for.\n" +
268
377
  "Name the others and the summary will say what does work for them.\n\n",
@@ -279,7 +388,7 @@ async function askCustomTools({ prompter, out }) {
279
388
  const supported = harnessNamed(answer);
280
389
  if (supported !== null) {
281
390
  out(
282
- ` ${supported.label} is supported it is in the list above, and writes to\n` +
391
+ ` ${supported.label} is supported ${theme.glyph.dash} it is in the list above, and writes to\n` +
283
392
  ` ${supported.skillsDir}/. Choose it there, or pass --agents ${supported.id}.\n\n`,
284
393
  );
285
394
  continue;
@@ -308,17 +417,121 @@ async function askCustomTools({ prompter, out }) {
308
417
  * harness was chosen" from "a harness was chosen and produced nothing", which
309
418
  * are the same zero and mean opposite things.
310
419
  */
311
- function generateAdapters({ harnesses, kitRoot, cwd, options, result }) {
420
+ function generateAdapters({ plan, harnesses, options, result, onProgress, onHarnessDone }) {
312
421
  const none = { plan: [], result: applyAdapterPlan([]), blocked: false };
313
422
 
314
423
  if (harnesses.length === 0) return none;
315
424
 
316
425
  // The copy failed part-way. Reporting adapters as generated on top of that
317
426
  // would be a success message about a broken install.
427
+ //
428
+ // The progress bar is deliberately left short here rather than topped up.
429
+ // These units were planned and never carried out, and a bar that reached 100%
430
+ // anyway would be the one thing it must never do — agree with itself while
431
+ // disagreeing with the error the user is about to read.
318
432
  if (result.errors.length > 0) return { ...none, blocked: true };
319
433
 
320
- const plan = planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force });
321
- return { plan, result: applyAdapterPlan(plan, { dryRun: options.dryRun }), blocked: false };
434
+ // A milestone per harness, emitted when that harness's last unit resolves
435
+ // rather than after the whole phase, so the lines appear as the work happens.
436
+ // The plan is grouped by harness because `planAdapters` walks the harnesses in
437
+ // order, so a change of harness is the boundary — no second pass needed.
438
+ let current = null;
439
+ let counts = { done: 0, conflicts: 0 };
440
+
441
+ const flush = () => {
442
+ if (current) onHarnessDone?.(current, counts);
443
+ };
444
+
445
+ const applied = applyAdapterPlan(plan, {
446
+ dryRun: options.dryRun,
447
+ onProgress: (unit) => {
448
+ if (current && unit.item.harness !== current) {
449
+ flush();
450
+ counts = { done: 0, conflicts: 0 };
451
+ }
452
+ current = unit.item.harness;
453
+ if (unit.item.action === "conflict") counts.conflicts += 1;
454
+ else if (unit.ok) counts.done += 1;
455
+ onProgress?.(unit);
456
+ },
457
+ });
458
+
459
+ flush();
460
+
461
+ return { plan, result: applied, blocked: false };
462
+ }
463
+
464
+ /**
465
+ * The one line under the closing headline: what this run actually did.
466
+ *
467
+ * Counts rather than adjectives, because "success" is not information and the
468
+ * person reading has just watched a bar fill. A re-run that wrote nothing says
469
+ * so plainly instead of inventing an achievement.
470
+ */
471
+ function endingHeadline({ theme, written, adapters, attention, options }) {
472
+ const mark = theme.glyph;
473
+ const built = adapters.result.generated + adapters.result.replaced;
474
+
475
+ // Something wants a human. Still ready — it is — but this is not the moment
476
+ // for confetti over somebody's conflicted file.
477
+ if (attention > 0) return theme.ok(`${mark.ok} READY`);
478
+
479
+ // Nothing to do, and nothing wrong. A tool that throws a party for doing no
480
+ // work is a tool whose party means nothing.
481
+ if (written === 0 && built === 0 && !options.dryRun) {
482
+ return theme.ok(`${mark.ok} ALREADY UP TO DATE`);
483
+ }
484
+
485
+ // The emotional peak, and the only place it is earned.
486
+ return `${mark.party} ${theme.brand(options.dryRun ? "READY WHEN YOU ARE" : "YOU'RE ALL SET")}`;
487
+ }
488
+
489
+ /**
490
+ * The one line under the closing headline: what this run actually did.
491
+ *
492
+ * Counts rather than adjectives, because "success" is not information and the
493
+ * person reading has just watched a bar fill. A run that changed nothing says
494
+ * so plainly instead of inventing an achievement out of the harnesses it did
495
+ * not have to configure.
496
+ */
497
+ function endingDetail({ written, adapters, harnesses, attention, options }) {
498
+ const parts = [];
499
+ const built = adapters.result.generated + adapters.result.replaced;
500
+
501
+ if (written > 0) parts.push(`${written} file${plural(written)}`);
502
+ if (built > 0) parts.push(`${built} adapter${plural(built)}`);
503
+ if (built > 0 && harnesses.length > 0) {
504
+ parts.push(joinNames(harnesses.map((harness) => harness.label)));
505
+ }
506
+
507
+ if (parts.length === 0) {
508
+ parts.push(options.dryRun ? "nothing to write" : "everything was already in place");
509
+ }
510
+
511
+ if (attention > 0) parts.push(`${attention} thing${plural(attention)} to look at above`);
512
+
513
+ return parts.join(", ");
514
+ }
515
+
516
+ /** `ok` for a count that did something, `info` for one that did not. */
517
+ function tally(theme, count) {
518
+ return count > 0 ? theme.ok : theme.info;
519
+ }
520
+
521
+ /** One line inside a phase block, hung off the gutter. */
522
+ function railed(theme, text) {
523
+ return ` ${theme.dim(theme.glyph.gutter)} ${text}`;
524
+ }
525
+
526
+ /**
527
+ * How many files the copy put down, phrased for whichever mode this is.
528
+ *
529
+ * A dry run has no `result.written` worth reporting, so the plan is counted
530
+ * instead — the same number the summary underneath will state.
531
+ */
532
+ function countWritten(result, plan, options) {
533
+ if (options.dryRun) return plan.filter((item) => item.status === "write").length;
534
+ return result.written + result.overwritten;
322
535
  }
323
536
 
324
537
  function parseArguments(argv) {
@@ -532,6 +745,137 @@ function indent(text) {
532
745
  .join("\n");
533
746
  }
534
747
 
748
+ /**
749
+ * The Pathfinder mark, transcribed from `assets/logo.svg` into cells.
750
+ *
751
+ * The real mark is four rounded horizontal strokes, centred on one axis and
752
+ * tapering upward — a trail blaze, the paint splash on a rock that tells you
753
+ * you are still on the path. Its widths in the 32-unit grid are 24, 18, 12.8,
754
+ * and 7.2 from the bottom up, all centred on x=16.
755
+ *
756
+ * Those proportions are what is preserved here, not the pixels: scaled to a
757
+ * nine-cell base and rounded, 24:18:12.8:7.2 becomes 9:7:5:3, and centring each
758
+ * row on the base gives the indents 0, 1, 2, 3. The slight rotation on each
759
+ * stroke in the SVG is the one feature that does not survive — a terminal cell
760
+ * grid has no way to express three degrees, and faking it by stepping a row
761
+ * sideways would read as a mistake rather than as a tilt.
762
+ *
763
+ * Every number here is authored, not measured. Nothing in this file asks how
764
+ * wide a rendered string is; these are the constants a designer would hand you,
765
+ * and they are the reason the block is stable under any terminal width.
766
+ */
767
+ const MARK_ROWS = Object.freeze([
768
+ Object.freeze({ indent: 3, width: 3 }),
769
+ Object.freeze({ indent: 2, width: 5 }),
770
+ Object.freeze({ indent: 1, width: 7 }),
771
+ Object.freeze({ indent: 0, width: 9 }),
772
+ ]);
773
+
774
+ /** Where the text column starts, counted in the mark's own authored cells. */
775
+ const TEXT_COLUMN = 13;
776
+
777
+ /**
778
+ * What the tool is, in five words.
779
+ *
780
+ * Reviewed against the finished run and kept. It earns its place by being the
781
+ * only line that says what Pathfinder *is* rather than what it just did, and
782
+ * "markers" is load-bearing: this project's whole argument is that it is not a
783
+ * framework, and a marker is the least presumptuous thing you can leave on a
784
+ * trail. Somebody still has to walk it.
785
+ */
786
+ const TAGLINE = "trail markers for AI-assisted work";
787
+
788
+ /**
789
+ * The last thing the run says.
790
+ *
791
+ * Warm, and stops. No exhortation, no link, and specifically no request to star
792
+ * anything — a tool that has just written several hundred files into somebody's
793
+ * repository has taken enough of their attention, and asking for a favour on the
794
+ * way out would spend the goodwill this whole feature exists to build.
795
+ */
796
+ const SIGN_OFF = "Trail's marked. The rest is yours.";
797
+
798
+ /**
799
+ * Who is running, said once, at the top.
800
+ *
801
+ * The requirement is that a reader recognises this tool before parsing the word
802
+ * "Pathfinder", so recognition is carried by form and colour: the actual mark,
803
+ * drawn, in the actual brand orange when the terminal can render it, beside
804
+ * letterspacing no other scaffolder's output has. Someone who ran three
805
+ * installers this afternoon can tell which one this was from the shape alone —
806
+ * which is the test, and it is why the mark is a transcription of the logo
807
+ * rather than an emoji that merely gestures at the same idea.
808
+ *
809
+ * The colour degrades and the mark does not. At 24-bit the strokes are
810
+ * `#E0611F` exactly; at 256 they are its nearest cube neighbour; at 16 they are
811
+ * the one warm accent ANSI offers; with colour off they are still unmistakably
812
+ * four tapering strokes. That ordering is deliberate — form is the part that
813
+ * survives every terminal, so form is what the identity rests on.
814
+ *
815
+ * Every device here is anchored on the left. There is no border and nothing
816
+ * closes on the right, because that would require knowing the printed width of
817
+ * a decorated string. The prototype that inspired this block had a box, and its
818
+ * right edge did not line up — not a bug in the prototype, just what happens.
819
+ *
820
+ * Not printed for `--help`, which is reference output someone pipes to `less`,
821
+ * and not printed for the `contract` tier, which has a byte promise to keep.
822
+ * Both of those decisions live at the call site, where the tier is known.
823
+ */
824
+ function markBlock(theme, beside = []) {
825
+ const stroke = theme.glyph.rule;
826
+
827
+ return MARK_ROWS.map((row, index) => {
828
+ const drawn = " ".repeat(row.indent) + stroke.repeat(row.width);
829
+ const text = beside[index] ?? "";
830
+
831
+ // Padding to a constant from two constants. The decorated text is appended
832
+ // after the padding is already decided, so no escape sequence is ever part
833
+ // of a length this function computes.
834
+ const pad = " ".repeat(TEXT_COLUMN - row.indent - row.width);
835
+ return ` ${theme.brand(drawn)}${text ? pad + text : ""}`;
836
+ });
837
+ }
838
+
839
+ export function formatIdentity({ theme = createTheme(), version = VERSION } = {}) {
840
+ // The two text lines sit beside the mark's middle rows, so the wordmark lands
841
+ // level with the widest part of the blaze rather than floating above it.
842
+ return (
843
+ [
844
+ "",
845
+ ...markBlock(theme, [
846
+ "",
847
+ `${theme.brand("P A T H F I N D E R")} ${theme.dim(`v${version}`)}`,
848
+ theme.dim(TAGLINE),
849
+ "",
850
+ ]),
851
+ ].join("\n") + "\n"
852
+ );
853
+ }
854
+
855
+ /**
856
+ * The end of a successful run, and the one place the mark appears twice.
857
+ *
858
+ * The requirement is that this reads as an arrival rather than a receipt, and
859
+ * the device that does the work is the bookend: the run opens with the blaze
860
+ * and closes with it, and no phase in between draws the mark at all. A reader
861
+ * who sees it a second time knows the run is over before reading a word — the
862
+ * same recognition-before-reading test the startup block has to pass.
863
+ *
864
+ * Three endings, because claiming one of them for another would be a lie:
865
+ *
866
+ * - **Clean.** Nothing was skipped, nothing conflicted, nothing failed. This is
867
+ * the emotional peak of the tool and is allowed to behave like it.
868
+ * - **Ready, with notes.** The install did its job and something above wants a
869
+ * look. It still says you are ready, because you are, and it does not throw
870
+ * confetti over a conflict.
871
+ * - **Nothing at all.** A run with write failures gets no ending block. The
872
+ * error is the ending, and a celebration above it would be the output
873
+ * disagreeing with itself.
874
+ */
875
+ function readyBlock({ theme, headline, detail }) {
876
+ return markBlock(theme, ["", headline, theme.dim(detail), ""]);
877
+ }
878
+
535
879
  /**
536
880
  * Say what was found, before saying what will be done.
537
881
  *
@@ -539,75 +883,103 @@ function indent(text) {
539
883
  * the machine; none of them implies an intention. The parenthetical on the
540
884
  * tools line is load-bearing — a bare list of everything installed on someone's
541
885
  * laptop reads like an announcement that all of it is about to be configured,
542
- * which is not true here and will still not be true after Feature 11, where
543
- * configuring anything requires an answer to a question.
886
+ * which is not true here, and configuring anything requires an answer to a
887
+ * question.
888
+ *
889
+ * This is also the run's first phase, and it is rendered as one: a heading that
890
+ * names it, and a gutter down the left of everything that belongs to it. The
891
+ * gutter is what makes the phase a block rather than a paragraph — it survives
892
+ * with colour off, it survives in ASCII, and it costs no width maths, which is
893
+ * the whole reason it was chosen over a box.
544
894
  */
545
- export function formatFindings(findings, { unicode = false } = {}) {
546
- const mark = marks(unicode);
547
-
548
- const lines = ["", "Pathfinder", ""];
895
+ export function formatFindings(findings, { theme = createTheme() } = {}) {
896
+ // The default is the theme an empty environment produces: ASCII, no colour.
897
+ // Not a convenience — it is the same answer this function gave before it took
898
+ // a theme at all, so a caller that forgets one gets the readable alphabet
899
+ // rather than a guess about a terminal it never described.
900
+ const mark = theme.glyph;
901
+
902
+ // Every finding line hangs off the same gutter, so the block reads as one
903
+ // thing rather than as three sentences that happen to be adjacent.
904
+ const rail = railed(theme, "");
905
+
906
+ // A severity span covers the glyph *and* the words it qualifies, never the
907
+ // glyph alone. Two reasons, and the second is the one that bites: a coloured
908
+ // glyph beside plain text reads as a bullet with a tint rather than as a
909
+ // statement with a level, and painting only the glyph puts a reset in the
910
+ // middle of the sentence — so `✓ Git repository detected` stops existing as
911
+ // contiguous bytes, and every assertion about what this line says has to
912
+ // learn the escape codes to find it. Emphasis inside a line still gets its
913
+ // own span; it just starts after the statement's own words have ended.
914
+ const lines = ["", ` ${mark.scan} ${theme.bold("ENVIRONMENT")}`];
549
915
 
550
916
  if (findings.git.insideRepository) {
551
- lines.push(` ${mark.ok} Git repository detected`);
917
+ lines.push(`${rail}${theme.ok(`${mark.ok} Git repository detected`)}`);
552
918
  } else if (findings.git.binary) {
553
- lines.push(` ${mark.info} No Git repository here`);
919
+ lines.push(`${rail}${theme.info(`${mark.info} No Git repository here`)}`);
554
920
  } else {
555
- lines.push(` ${mark.bad} No Git repository here, and \`git\` is not on your PATH`);
921
+ lines.push(
922
+ `${rail}${theme.bad(`${mark.bad} No Git repository here, and \`git\` is not on your PATH`)}`,
923
+ );
556
924
  }
557
925
 
558
926
  if (findings.pathfinder.installed) {
559
927
  const { skillCount } = findings.pathfinder;
560
- lines.push(` ${mark.ok} Pathfinder already installed (${skillCount} skill${plural(skillCount)})`);
928
+ lines.push(
929
+ `${rail}${theme.ok(`${mark.ok} Pathfinder already installed`)} ` +
930
+ `(${theme.bold(skillCount)} skill${plural(skillCount)})`,
931
+ );
561
932
  }
562
933
 
934
+ // The tool names are emphasised and the caveat is dimmed, which is the
935
+ // hierarchy the sentence always had and the flat rendering threw away. What
936
+ // the reader wants from this line is the list; what they need from it is the
937
+ // parenthetical, exactly once.
563
938
  const tools = detectedToolLabels(findings);
564
939
  lines.push(
565
940
  tools.length > 0
566
- ? ` ${mark.ok} Tools detected: ${tools.join(", ")} (noted, not configured)`
567
- : ` ${mark.info} No supported tools detected`,
941
+ ? `${rail}${theme.ok(`${mark.ok} Tools detected:`)} ${theme.bold(tools.join(", "))} ` +
942
+ `${theme.dim("(noted, not configured)")}`
943
+ : `${rail}${theme.info(`${mark.info} No supported tools detected`)}`,
568
944
  );
569
945
 
570
946
  // Trailing blank line: whatever comes next is a different statement — the
571
- // install summary, a refusal, or in a later chunk a question — and it must
572
- // not read as a sixth finding.
947
+ // install summary, a refusal, or a question — and it must not read as one
948
+ // more finding.
573
949
  return lines.join("\n") + "\n\n";
574
950
  }
575
951
 
576
952
  /**
577
- * The line markers, in whichever alphabet this terminal can be trusted with.
578
- *
579
- * One table, so a finding and an action it leads to are marked the same way.
580
- */
581
- function marks(unicode) {
582
- return unicode
583
- ? { ok: "✓", info: "·", bad: "✗", dash: "—" }
584
- : { ok: "+", info: "-", bad: "!", dash: "-" };
585
- }
586
-
587
- /**
588
- * Can this terminal be trusted with the decorated marks?
953
+ * Say what happened, in full.
589
954
  *
590
- * Answered from the environment rather than attempted and hoped for, and biased
591
- * hard toward "no": an unanswerable environment gets ASCII, which is readable
592
- * everywhere, while a wrong "yes" leaves mojibake in the first output a new
593
- * user ever sees from this tool.
955
+ * Skipped files are listed individually, not counted. The whole promise of the
956
+ * default mode is that it left your work alone, and a bare "42 skipped" does
957
+ * not let anyone check that claim.
594
958
  */
595
- function supportsUnicode(env, platform) {
596
- if (platform === "win32") {
597
- return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode";
598
- }
599
- const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
600
- return /utf-?8/i.test(locale);
959
+ function report(args) {
960
+ // Two renderings, kept adjacent on purpose.
961
+ //
962
+ // The duplication below is a known, accepted cost rather than an oversight.
963
+ // `contractReport` owes byte-for-byte what 1.4.1 printed, to scripts that
964
+ // parse it; `expressiveReport` owes a person a legible hierarchy. Merging
965
+ // them would mean one function whose every line carries a conditional, and
966
+ // the first wording improvement would silently break somebody's grep.
967
+ //
968
+ // They are written next to each other so that editing one is an obvious
969
+ // prompt to consider the other. Anything that changes what is *reported* —
970
+ // as opposed to how it looks — has to be made twice, and that is the point.
971
+ if (args.theme.tier === "contract") return contractReport(args);
972
+ return expressiveReport(args);
601
973
  }
602
974
 
603
975
  /**
604
- * Say what happened, in full.
976
+ * The rendering that is a promise, not a design.
605
977
  *
606
- * Skipped files are listed individually, not counted. The whole promise of the
607
- * default mode is that it left your work alone, and a bare "42 skipped" does
608
- * not let anyone check that claim.
978
+ * Unchanged since 1.4.1 and deliberately frozen. Every byte here is pinned by
979
+ * `test/non-interactive.test.mjs` and by a capture-and-compare against the
980
+ * published package, because a script somewhere is reading it.
609
981
  */
610
- function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err }) {
982
+ function contractReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
611
983
  const lines = [];
612
984
  const verb = options.dryRun ? "Would install" : "Installed";
613
985
 
@@ -624,7 +996,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
624
996
  lines.push(` ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`);
625
997
  }
626
998
 
627
- lines.push(...adapterLines({ adapters, harnesses, options }));
999
+ lines.push(...contractAdapterLines({ adapters, harnesses, options, theme }));
628
1000
  lines.push(...customToolLines(customTools));
629
1001
 
630
1002
  const skipped = plan.filter((item) => item.status === "skip");
@@ -647,7 +1019,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
647
1019
  // top of this block rather than the only channel, which is what lets every
648
1020
  // clipboard failure be a non-event.
649
1021
  lines.push("");
650
- lines.push("Next step give your agent this prompt:");
1022
+ lines.push(`Next step ${theme.glyph.dash} give your agent this prompt:`);
651
1023
  lines.push("");
652
1024
  lines.push(...kickstartPromptLines(harnesses));
653
1025
 
@@ -675,7 +1047,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
675
1047
  * here that an install should be judged by, so there is no value for the exit
676
1048
  * code to be computed from.
677
1049
  */
678
- async function offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform }) {
1050
+ async function offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform, theme }) {
679
1051
  if (!prompter.interactive || options.yes) return;
680
1052
 
681
1053
  const suppressed = options.noClipboard && options.noOpen;
@@ -692,14 +1064,29 @@ async function offerOnboardingActions({ harnesses, cwd, options, prompter, out,
692
1064
  return;
693
1065
  }
694
1066
 
695
- if (!options.noClipboard) await offerClipboard({ harnesses, options, prompter, out, env, platform });
696
- if (!options.noOpen) await offerEditor({ cwd, prompter, out, env, platform });
1067
+ // The report ends on the prompt block, which is the one thing on screen the
1068
+ // user is meant to act on. A question butted straight against its last line
1069
+ // reads as part of that block rather than as something being asked, so the
1070
+ // questions get the same leading blank every other device in this run gets —
1071
+ // separation is led here, never trailed.
1072
+ //
1073
+ // Printed only when a question actually follows, which is why the editors are
1074
+ // detected here rather than inside `offerEditor`: a machine with no editor on
1075
+ // PATH and `--no-clipboard` asks nothing, and must not be given a separator
1076
+ // for it.
1077
+ const editors = options.noOpen ? [] : detectEditors({ env, platform });
1078
+ if (!options.noClipboard || editors.length > 0) out("\n");
1079
+
1080
+ if (!options.noClipboard) {
1081
+ await offerClipboard({ harnesses, options, prompter, out, env, platform, theme });
1082
+ }
1083
+ if (editors.length > 0) await offerEditor({ editors, cwd, prompter, out, env, platform, theme });
697
1084
  }
698
1085
 
699
1086
  /**
700
1087
  * Offer to put the printed prompt on the clipboard. Never take it.
701
1088
  */
702
- async function offerClipboard({ harnesses, options, prompter, out, env, platform }) {
1089
+ async function offerClipboard({ harnesses, options, prompter, out, env, platform, theme }) {
703
1090
  const answer = await prompter.confirm(
704
1091
  "Copy that prompt to your clipboard? This replaces what is on it now.",
705
1092
  { defaultAnswer: true },
@@ -718,7 +1105,7 @@ async function offerClipboard({ harnesses, options, prompter, out, env, platform
718
1105
  out(
719
1106
  copied.ok
720
1107
  ? " Copied.\n"
721
- : ` Not copied ${copied.reason}. The prompt is printed above.\n`,
1108
+ : ` Not copied ${theme.glyph.dash} ${copied.reason}. The prompt is printed above.\n`,
722
1109
  );
723
1110
  }
724
1111
 
@@ -728,14 +1115,15 @@ async function offerClipboard({ harnesses, options, prompter, out, env, platform
728
1115
  * The question exists only when there is something to answer it with. No editor
729
1116
  * on PATH means no question at all, rather than a question whose honest answer
730
1117
  * is "then don't" — an installer that asks about software you do not have is
731
- * asking to be told about itself.
1118
+ * asking to be told about itself. That decision is made by the caller and the
1119
+ * detected list handed down, because the caller has to know whether anything
1120
+ * will be asked before it prints the blank line above the questions.
732
1121
  *
733
1122
  * One editor is a yes/no; several are a numbered list with an explicit way out.
734
1123
  * Neither shape can be answered by not answering: a decline, an unanswered
735
1124
  * question, and "Don't open" all land on the same nothing.
736
1125
  */
737
- async function offerEditor({ cwd, prompter, out, env, platform }) {
738
- const editors = detectEditors({ env, platform });
1126
+ async function offerEditor({ editors, cwd, prompter, out, env, platform, theme }) {
739
1127
  if (editors.length === 0) return;
740
1128
 
741
1129
  const chosen =
@@ -765,7 +1153,7 @@ async function offerEditor({ cwd, prompter, out, env, platform }) {
765
1153
  out(
766
1154
  opened.ok
767
1155
  ? ` Opening ${chosen.label}.\n`
768
- : ` Not opened ${opened.reason}. The install is complete; open ${cwd} yourself.\n`,
1156
+ : ` Not opened ${theme.glyph.dash} ${opened.reason}. The install is complete; open ${cwd} yourself.\n`,
769
1157
  );
770
1158
  }
771
1159
 
@@ -781,7 +1169,7 @@ async function offerEditor({ cwd, prompter, out, env, platform }) {
781
1169
  * Empty when no harness was chosen, which is the default and must stay
782
1170
  * invisible: a scripted 1.4.1-era run prints exactly what it always did.
783
1171
  */
784
- function adapterLines({ adapters, harnesses, options }) {
1172
+ function contractAdapterLines({ adapters, harnesses, options, theme }) {
785
1173
  if (harnesses.length === 0) return [];
786
1174
 
787
1175
  if (adapters.blocked) {
@@ -829,8 +1217,8 @@ function adapterLines({ adapters, harnesses, options }) {
829
1217
  lines.push("");
830
1218
  lines.push(
831
1219
  conflicts.length === 1
832
- ? " Re-run with --force to replace it note that --force also overwrites"
833
- : " Re-run with --force to replace them note that --force also overwrites",
1220
+ ? ` Re-run with --force to replace it ${theme.glyph.dash} note that --force also overwrites`
1221
+ : ` Re-run with --force to replace them ${theme.glyph.dash} note that --force also overwrites`,
834
1222
  );
835
1223
  lines.push(" Pathfinder kit files you have edited.",
836
1224
  );
@@ -875,6 +1263,317 @@ function customToolLines(customTools = []) {
875
1263
  ];
876
1264
  }
877
1265
 
1266
+ /**
1267
+ * The rendering a person reads.
1268
+ *
1269
+ * The problem it exists to solve is the re-run screen, which is the screen
1270
+ * experienced users see most and was the weakest thing this tool printed: a
1271
+ * conflict, an orphan, eight skipped files and twenty generated adapters all
1272
+ * arrived as prose at one indent level, so nothing about the shape of the
1273
+ * output told you whether anything needed your attention.
1274
+ *
1275
+ * Three rules hold it together:
1276
+ *
1277
+ * - **Every severity is a colour, a glyph, and a word.** `warn` is never the
1278
+ * only signal — the line also carries `mark.warn` and opens with a category
1279
+ * word, so the hierarchy survives `NO_COLOR`, ASCII, a screen reader, and a
1280
+ * colour-blind reader identically.
1281
+ * - **Summary lines are decorated; payload is not.** Counts sit on the gutter
1282
+ * and get colour. The paths underneath get neither, for the reason below.
1283
+ * - **Diagnostics stay pasteable.** See `pathList`.
1284
+ */
1285
+ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
1286
+ const mark = theme.glyph;
1287
+ const lines = [];
1288
+ const verb = options.dryRun ? "Would install" : "Installed";
1289
+
1290
+ lines.push(` ${mark.clipboard} ${theme.bold(options.dryRun ? "DRY RUN" : "SUMMARY")}`);
1291
+ lines.push(railed(theme, `${verb} the Pathfinder kit into ${theme.bold(cwd)}`));
1292
+
1293
+ if (gitRoot !== cwd) {
1294
+ lines.push(
1295
+ railed(theme, theme.info(`${mark.info} The repository root is ${gitRoot}, not this directory.`)),
1296
+ );
1297
+ }
1298
+
1299
+ const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
1300
+ // A zero is reported, never celebrated. `✓ 0 files written` is a tick over
1301
+ // nothing happening, which is the kind of detail that makes a whole summary
1302
+ // feel automated rather than read.
1303
+ lines.push(
1304
+ railed(
1305
+ theme,
1306
+ tally(theme, written)(
1307
+ `${written > 0 ? mark.ok : mark.info} ${written} file${plural(written)} ${options.dryRun ? "to write" : "written"}`,
1308
+ ),
1309
+ ),
1310
+ );
1311
+
1312
+ // `--force` overwriting is `info`, not `warn`. It is exactly what the flag
1313
+ // was asked to do, and marking a requested action as a warning is how a tool
1314
+ // teaches people to ignore its warnings. The files it replaced are still
1315
+ // worth stating plainly, which is what `info` is for.
1316
+ if (result.overwritten > 0) {
1317
+ lines.push(
1318
+ railed(
1319
+ theme,
1320
+ theme.info(`${mark.info} ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`),
1321
+ ),
1322
+ );
1323
+ }
1324
+
1325
+ lines.push(...expressiveAdapterLines({ adapters, harnesses, options, theme }));
1326
+
1327
+ const skipped = plan.filter((item) => item.status === "skip");
1328
+ if (skipped.length > 0) {
1329
+ lines.push(
1330
+ railed(
1331
+ theme,
1332
+ theme.warn(`${mark.warn} ${skipped.length} file${plural(skipped.length)} left untouched`) +
1333
+ theme.dim(" (they already exist)"),
1334
+ ),
1335
+ );
1336
+ }
1337
+
1338
+ if (customTools.length > 0) {
1339
+ lines.push(
1340
+ railed(theme, theme.info(`${mark.info} No native integration for ${joinNames(customTools)}`)),
1341
+ );
1342
+ }
1343
+
1344
+ // The detail blocks, below the summary rather than inside it. A reader who
1345
+ // only wants to know whether anything needs them stops at the gutter; a
1346
+ // reader who needs the paths scrolls once and finds them undecorated.
1347
+ if (skipped.length > 0) {
1348
+ lines.push(
1349
+ ...warnBlock({
1350
+ theme,
1351
+ word: "Skipped",
1352
+ summary: `${skipped.length} file${plural(skipped.length)} already exist${skipped.length === 1 ? "s" : ""} and ${skipped.length === 1 ? "was" : "were"} left untouched`,
1353
+ paths: skipped.map((item) => item.relativePath),
1354
+ advice: ["Nothing above was modified. Re-run with --force to replace them."],
1355
+ }),
1356
+ );
1357
+ }
1358
+
1359
+ lines.push(...expressiveAdapterBlocks({ adapters, harnesses, theme }));
1360
+
1361
+ if (customTools.length > 0) lines.push(...customToolLines(customTools));
1362
+
1363
+ const failureCount = result.errors.length + adapters.result.errors.length;
1364
+ // What actually wants a human: a contested path, or an adapter pointing at a
1365
+ // skill that is gone. Skipped files are deliberately *not* counted here.
1366
+ // A re-run over an existing install skips every file by design, and calling
1367
+ // thirty-six routine skips "things to look at" would turn the one number that
1368
+ // should mean something into noise nobody reads twice.
1369
+ const attention = adapters.blocked
1370
+ ? 0
1371
+ : adapters.plan.filter((item) => item.action === "conflict" || item.action === "orphan").length;
1372
+
1373
+ if (failureCount === 0) {
1374
+ lines.push("");
1375
+ lines.push(
1376
+ ...readyBlock({
1377
+ theme,
1378
+ headline: endingHeadline({ theme, written, adapters, attention, options }),
1379
+ detail: endingDetail({ written, adapters, harnesses, attention, options }),
1380
+ }),
1381
+ );
1382
+ }
1383
+
1384
+ // The prompt is printed here, always — including on a re-run that wrote
1385
+ // nothing, and including a run that ends on an error. A second run is how
1386
+ // someone configures a harness they skipped, or simply comes back for the
1387
+ // invocation they have forgotten, and both of those want the same line. It is
1388
+ // also what makes the clipboard a convenience on top of this block rather
1389
+ // than the only channel, which is what lets every clipboard failure be a
1390
+ // non-event.
1391
+ lines.push("");
1392
+ lines.push(` ${theme.bold("Hand your agent this prompt to begin:")}`);
1393
+ lines.push("");
1394
+
1395
+ // The one piece of text on screen the user is meant to act on, so it gets the
1396
+ // strongest emphasis in the run and its own indent. Colour is safe here in a
1397
+ // way it is not for a diagnostic path: selecting text in a terminal copies
1398
+ // the characters, not the escapes.
1399
+ for (const line of kickstartPromptLines(harnesses)) {
1400
+ lines.push(line.trim() === "" ? line : ` ${theme.info(theme.bold(line.trim()))}`);
1401
+ }
1402
+
1403
+ out(lines.join("\n") + "\n");
1404
+
1405
+ const failures = [...result.errors, ...adapters.result.errors];
1406
+ if (failures.length > 0) {
1407
+ // `bad`, not `warn`, and the distinction is the whole point of having both:
1408
+ // everything above is an outcome somebody may want to know about, and this
1409
+ // is the install failing to do what it said it would.
1410
+ const heading = theme.bad(
1411
+ `${mark.bad} ${failures.length} file${plural(failures.length)} could not be written:`,
1412
+ );
1413
+ const detail = failures.map((error) => ` ${error.relativePath}: ${error.message}`).join("\n");
1414
+ err(`\ncreate-pathfinder: ${heading}\n${detail}\n`);
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * One warning, as a heading a reader can skim and a payload they can paste.
1420
+ *
1421
+ * The split is the requirement. The heading is decorated — colour, glyph, and
1422
+ * a leading category word — because its job is to be noticed. The paths are
1423
+ * printed plain, one per line, never wrapped, never truncated, never coloured,
1424
+ * and never hung off the gutter, because their job is to survive a
1425
+ * copy-and-paste into a GitHub issue. A box character or an ANSI escape in
1426
+ * that list means somebody has to hand-edit every line they pasted.
1427
+ */
1428
+ function warnBlock({ theme, word, summary, paths, advice }) {
1429
+ const mark = theme.glyph;
1430
+
1431
+ return [
1432
+ "",
1433
+ ` ${theme.warn(`${mark.warn} ${word}`)} ${mark.dash} ${summary}:`,
1434
+ "",
1435
+ ...pathList(paths),
1436
+ "",
1437
+ ...advice.map((line) => ` ${theme.dim(line)}`),
1438
+ ];
1439
+ }
1440
+
1441
+ /**
1442
+ * Paths, and nothing else.
1443
+ *
1444
+ * Indented for alignment and otherwise untouched: no glyph, no colour, no
1445
+ * gutter, no truncation, no wrapping at any width. Leading spaces are the only
1446
+ * decoration, and they are the one kind that survives a paste — a reader who
1447
+ * selects these lines gets the paths, and a reader who pastes them into a
1448
+ * Markdown issue body gets a code block for free.
1449
+ */
1450
+ function pathList(paths) {
1451
+ return paths.map((path) => ` ${path}`);
1452
+ }
1453
+
1454
+ /** The per-harness summary counts, on the gutter, each at its own severity. */
1455
+ function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1456
+ const mark = theme.glyph;
1457
+ if (harnesses.length === 0) return [];
1458
+
1459
+ if (adapters.blocked) {
1460
+ return [
1461
+ railed(theme, theme.bad(`${mark.bad} No adapters were generated`) + theme.dim(" (the kit copy did not finish)")),
1462
+ ];
1463
+ }
1464
+
1465
+ const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1466
+ const lines = [];
1467
+
1468
+ for (const harness of harnesses) {
1469
+ const mine = adapters.plan.filter(
1470
+ (item) => item.harness === harness && !failed.has(item.relativePath),
1471
+ );
1472
+ const count = (action) => mine.filter((item) => item.action === action).length;
1473
+
1474
+ const generated = count("write");
1475
+ const replaced = count("replace");
1476
+ const unchanged = count("up-to-date");
1477
+
1478
+ lines.push(
1479
+ railed(
1480
+ theme,
1481
+ tally(theme, generated)(
1482
+ `${generated > 0 ? mark.ok : mark.info} ${generated} ${harness.label} skill adapter${plural(generated)} ` +
1483
+ (options.dryRun ? "to generate" : "generated"),
1484
+ ),
1485
+ ),
1486
+ );
1487
+
1488
+ if (replaced > 0) {
1489
+ lines.push(
1490
+ railed(theme, theme.info(`${mark.info} ${replaced} ${harness.label} adapter${plural(replaced)} replaced (--force)`)),
1491
+ );
1492
+ }
1493
+
1494
+ if (unchanged > 0) {
1495
+ lines.push(
1496
+ railed(theme, theme.dim(`${mark.info} ${unchanged} ${harness.label} adapter${plural(unchanged)} already up to date`)),
1497
+ );
1498
+ }
1499
+
1500
+ const conflicts = mine.filter((item) => item.action === "conflict");
1501
+ const orphans = mine.filter((item) => item.action === "orphan");
1502
+
1503
+ if (conflicts.length > 0) {
1504
+ lines.push(
1505
+ railed(
1506
+ theme,
1507
+ theme.warn(`${mark.warn} ${conflicts.length} ${harness.label} file${plural(conflicts.length)} left untouched`) +
1508
+ theme.dim(conflicts.length === 1 ? " (Pathfinder did not write it)" : " (Pathfinder did not write them)"),
1509
+ ),
1510
+ );
1511
+ }
1512
+
1513
+ if (orphans.length > 0) {
1514
+ lines.push(
1515
+ railed(
1516
+ theme,
1517
+ theme.warn(`${mark.warn} ${orphans.length} ${harness.label} orphan adapter${plural(orphans.length)}`) +
1518
+ theme.dim(orphans.length === 1 ? " (the skill it points at was retired)" : " (the skills they point at were retired)"),
1519
+ ),
1520
+ );
1521
+ }
1522
+ }
1523
+
1524
+ return lines;
1525
+ }
1526
+
1527
+ /** The conflict and orphan detail blocks, with their paths kept pasteable. */
1528
+ function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1529
+ if (harnesses.length === 0 || adapters.blocked) return [];
1530
+
1531
+ const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1532
+ const blocks = [];
1533
+
1534
+ for (const harness of harnesses) {
1535
+ const mine = adapters.plan.filter(
1536
+ (item) => item.harness === harness && !failed.has(item.relativePath),
1537
+ );
1538
+
1539
+ const conflicts = mine.filter((item) => item.action === "conflict");
1540
+ const orphans = mine.filter((item) => item.action === "orphan");
1541
+
1542
+ if (conflicts.length > 0) {
1543
+ const one = conflicts.length === 1;
1544
+ blocks.push(
1545
+ ...warnBlock({
1546
+ theme,
1547
+ word: "Conflict",
1548
+ summary: `${conflicts.length} ${harness.label} file${plural(conflicts.length)} at ${one ? "a path an adapter wants" : "paths adapters want"}, which Pathfinder did not write`,
1549
+ paths: conflicts.map((item) => item.relativePath),
1550
+ advice: [
1551
+ `Re-run with --force to replace ${one ? "it" : "them"} ${theme.glyph.dash} note that --force also`,
1552
+ "overwrites Pathfinder kit files you have edited.",
1553
+ ],
1554
+ }),
1555
+ );
1556
+ }
1557
+
1558
+ if (orphans.length > 0) {
1559
+ const one = orphans.length === 1;
1560
+ blocks.push(
1561
+ ...warnBlock({
1562
+ theme,
1563
+ word: "Orphan",
1564
+ summary: `${orphans.length} ${harness.label} adapter${plural(orphans.length)} delegat${one ? "es" : "e"} to a skill this version no longer ships`,
1565
+ paths: orphans.map((item) => item.relativePath),
1566
+ advice: [
1567
+ `Left in place. Delete ${one ? "it" : "them"} yourself if you want ${one ? "it" : "them"} gone.`,
1568
+ ],
1569
+ }),
1570
+ );
1571
+ }
1572
+ }
1573
+
1574
+ return blocks;
1575
+ }
1576
+
878
1577
  /** `a`, `a and b`, `a, b, and c`. */
879
1578
  function joinNames(names) {
880
1579
  if (names.length === 1) return names[0];