create-pathfinder 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pathfinder",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Install the Pathfinder AI-assisted, human-in-the-loop workflow kit into a Git repository.",
5
5
  "keywords": [
6
6
  "pathfinder",
@@ -5,24 +5,44 @@ description: Load one approved Feature and the minimum context needed to work on
5
5
 
6
6
  # Load Feature
7
7
 
8
- 1. Select the requested approved Feature. Its spec is
9
- `NN-feature-name.md` in the spec source, and `NN` is its Feature number.
8
+ 1. Select the Feature the human named. Being invoked on it is the approval to
9
+ prepare it for execution, so its spec does not already have to say `Ready`.
10
+ Its spec is `NN-feature-name.md` in the spec source, and `NN` is its Feature
11
+ number.
10
12
  2. Read the Feature spec.
11
13
  3. Read only the files or context needed for the current work.
12
14
  4. Inspect the current Git state.
13
15
  5. Stop if a required human decision or explicit dependency blocks the work.
14
- 6. Create or update `context/current-feature.md` it does not ship, so the
16
+ 6. Record the approval in the Feature spec's `## Status`, which holds the
17
+ durable lifecycle state:
18
+ - `Proposed` becomes `Ready`. That is the only value this skill writes.
19
+ - `Ready` or `In Progress` is left exactly as it is. Reloading a Feature
20
+ mid-work is normal and must not rewrite its state.
21
+ - `Complete`, `Cancelled`, or `Superseded` blocks the load. Report it and
22
+ stop. Reopening terminal work is the human's decision.
23
+
24
+ Do this only once steps 1-5 found no blocker, and before the next step, so a
25
+ blocked load never leaves a promoted spec behind.
26
+ 7. Create or update `context/current-feature.md` — it does not ship, so the
15
27
  first load writes it — with:
16
28
  - Feature number, name, and spec path
17
29
  - active delivery chunk
18
30
  - Git state
19
31
  - blocker, if any
20
32
  - next action
21
- 7. If `context/tracker.md` exists, name the tracked item for this Feature —
33
+
34
+ Do not record the lifecycle status here. This file is transient workspace
35
+ state belonging to one session on one machine; the spec carries the durable
36
+ status.
37
+ 8. If `context/tracker.md` exists, name the tracked item for this Feature —
22
38
  its key is that Feature number. Do nothing here if it does not.
23
- 8. Present a short readiness summary.
39
+ 9. Present a short readiness summary.
24
40
 
25
41
  Do not implement the Feature.
26
42
 
27
- Do not scan unrelated repository areas, load history or roadmap by default,
28
- rewrite the Feature, or silently resolve `TBD` decisions.
43
+ Do not scan unrelated repository areas, load history or roadmap by default, or
44
+ silently resolve `TBD` decisions.
45
+
46
+ Do not rewrite the Feature's substance — its Goal, Context, Requirements, Out of
47
+ Scope, Delivery Chunks, or Acceptance Criteria. `## Status` is the one field this
48
+ skill maintains.
package/src/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import { detectEditors, openInEditor } from "./editor.mjs";
16
16
  import { kickstartPrompt, kickstartPromptLines } from "./kickstart-prompt.mjs";
17
17
  import { createTheme } from "./theme.mjs";
18
18
  import { createProgress } from "./progress.mjs";
19
+ import { summarize } from "./outcome.mjs";
19
20
  import {
20
21
  HARNESSES,
21
22
  HARNESS_IDS,
@@ -270,7 +271,13 @@ export async function run(
270
271
  progress.finish();
271
272
  if (!options.dryRun && theme.tier !== "contract") out("\n");
272
273
 
273
- report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme });
274
+ // Derived here, once, and handed down. Below this line nothing re-reads a
275
+ // plan or a result: the two renderings disagree about everything except the
276
+ // facts, and this is what makes "except the facts" true rather than a hope
277
+ // about two functions being edited together.
278
+ const outcome = summarize({ plan, result, adapters, harnesses, options });
279
+
280
+ report({ outcome, harnesses, customTools, cwd, gitRoot, options, out, err, theme });
274
281
 
275
282
  // After the report, because the first offer is about the prompt the report
276
283
  // just printed — and because a question above the summary would make the user
@@ -286,7 +293,7 @@ export async function run(
286
293
  //
287
294
  // Not printed when anything failed. A sign-off over an error is a tool that
288
295
  // did not read its own output.
289
- const failed = result.errors.length > 0 || adapters.result.errors.length > 0;
296
+ const failed = outcome.failures.length > 0;
290
297
  if (theme.tier !== "contract" && !failed) {
291
298
  out(`\n ${theme.dim(SIGN_OFF)}\n`);
292
299
  }
@@ -497,9 +504,9 @@ function generateAdapters({ plan, harnesses, options, result, onProgress, onHarn
497
504
  * person reading has just watched a bar fill. A re-run that wrote nothing says
498
505
  * so plainly instead of inventing an achievement.
499
506
  */
500
- function endingHeadline({ theme, written, adapters, attention, options }) {
507
+ function endingHeadline({ theme, outcome, options }) {
501
508
  const mark = theme.glyph;
502
- const built = adapters.result.generated + adapters.result.replaced;
509
+ const { written, built, attention } = outcome;
503
510
 
504
511
  // Something wants a human. Still ready — it is — but this is not the moment
505
512
  // for confetti over somebody's conflicted file.
@@ -523,9 +530,9 @@ function endingHeadline({ theme, written, adapters, attention, options }) {
523
530
  * so plainly instead of inventing an achievement out of the harnesses it did
524
531
  * not have to configure.
525
532
  */
526
- function endingDetail({ written, adapters, harnesses, attention, options }) {
533
+ function endingDetail({ outcome, harnesses, options }) {
527
534
  const parts = [];
528
- const built = adapters.result.generated + adapters.result.replaced;
535
+ const { written, built, attention } = outcome;
529
536
 
530
537
  if (written > 0) parts.push(`${written} file${plural(written)}`);
531
538
  if (built > 0) parts.push(`${built} adapter${plural(built)}`);
@@ -988,15 +995,16 @@ export function formatFindings(findings, { theme = createTheme() } = {}) {
988
995
  function report(args) {
989
996
  // Two renderings, kept adjacent on purpose.
990
997
  //
991
- // The duplication below is a known, accepted cost rather than an oversight.
992
- // `contractReport` owes byte-for-byte what 1.4.1 printed, to scripts that
993
- // parse it; `expressiveReport` owes a person a legible hierarchy. Merging
994
- // them would mean one function whose every line carries a conditional, and
995
- // the first wording improvement would silently break somebody's grep.
998
+ // What is reported is decided once, in `summarize`: both functions below read
999
+ // the same outcome and neither recounts anything. What remains separate is how
1000
+ // it looks. `contractReport` owes byte-for-byte what 1.4.1 printed, to scripts
1001
+ // that parse it; `expressiveReport` owes a person a legible hierarchy. Merging
1002
+ // them would mean one function whose every line carries a conditional, and the
1003
+ // first wording improvement would silently break somebody's grep.
996
1004
  //
997
- // They are written next to each other so that editing one is an obvious
998
- // prompt to consider the other. Anything that changes what is *reported*
999
- // as opposed to how it looks has to be made twice, and that is the point.
1005
+ // So a new reported fact belongs in the outcome, and reaches both renderings
1006
+ // from there; only its wording has to be chosen twice. They are written next
1007
+ // to each other so that editing one is an obvious prompt to consider the other.
1000
1008
  if (args.theme.tier === "contract") return contractReport(args);
1001
1009
  return expressiveReport(args);
1002
1010
  }
@@ -1008,7 +1016,7 @@ function report(args) {
1008
1016
  * `test/non-interactive.test.mjs` and by a capture-and-compare against the
1009
1017
  * published package, because a script somewhere is reading it.
1010
1018
  */
1011
- function contractReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
1019
+ function contractReport({ outcome, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
1012
1020
  const lines = [];
1013
1021
  const verb = options.dryRun ? "Would install" : "Installed";
1014
1022
 
@@ -1018,25 +1026,24 @@ function contractReport({ result, plan, adapters, harnesses, customTools, cwd, g
1018
1026
  }
1019
1027
  lines.push("");
1020
1028
 
1021
- const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
1029
+ const { written, overwritten, skipped } = outcome;
1022
1030
  lines.push(` ${written} file${plural(written)} ${options.dryRun ? "to write" : "written"}`);
1023
1031
 
1024
- if (result.overwritten > 0) {
1025
- lines.push(` ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`);
1032
+ if (overwritten > 0) {
1033
+ lines.push(` ${overwritten} file${plural(overwritten)} overwritten (--force)`);
1026
1034
  }
1027
1035
 
1028
- lines.push(...contractAdapterLines({ adapters, harnesses, options, theme }));
1036
+ lines.push(...contractAdapterLines({ outcome, options, theme }));
1029
1037
  lines.push(...customToolLines(customTools));
1030
1038
 
1031
- const skipped = plan.filter((item) => item.status === "skip");
1032
1039
  if (skipped.length > 0) {
1033
1040
  lines.push(` ${skipped.length} file${plural(skipped.length)} left untouched because they already exist:`);
1034
- for (const item of skipped) lines.push(` ${item.relativePath}`);
1041
+ for (const path of skipped) lines.push(` ${path}`);
1035
1042
  lines.push("");
1036
1043
  lines.push(" Nothing above was modified. Re-run with --force to replace them.");
1037
1044
  }
1038
1045
 
1039
- if (written === 0 && skipped.length === plan.length) {
1046
+ if (outcome.alreadyInstalled) {
1040
1047
  lines.push("");
1041
1048
  lines.push("The kit is already installed here.");
1042
1049
  }
@@ -1054,7 +1061,7 @@ function contractReport({ result, plan, adapters, harnesses, customTools, cwd, g
1054
1061
 
1055
1062
  out(lines.join("\n") + "\n");
1056
1063
 
1057
- const failures = [...result.errors, ...adapters.result.errors];
1064
+ const { failures } = outcome;
1058
1065
  if (failures.length > 0) {
1059
1066
  const detail = failures.map((error) => ` ${error.relativePath}: ${error.message}`).join("\n");
1060
1067
  err(`\ncreate-pathfinder: ${failures.length} file${plural(failures.length)} could not be written:\n${detail}\n`);
@@ -1198,10 +1205,12 @@ async function offerEditor({ editors, cwd, prompter, out, env, platform, theme }
1198
1205
  * Empty when no harness was chosen, which is the default and must stay
1199
1206
  * invisible: a scripted 1.4.1-era run prints exactly what it always did.
1200
1207
  */
1201
- function contractAdapterLines({ adapters, harnesses, options, theme }) {
1202
- if (harnesses.length === 0) return [];
1203
-
1204
- if (adapters.blocked) {
1208
+ function contractAdapterLines({ outcome, options, theme }) {
1209
+ // No explicit "no harness chosen" guard: that case is no rows and
1210
+ // `blocked: false`, so it falls straight through to an empty list. The
1211
+ // blocked case is checked first because it is *also* no rows, and the two
1212
+ // must not print the same nothing.
1213
+ if (outcome.blocked) {
1205
1214
  return [
1206
1215
  "",
1207
1216
  " No adapters were generated, because the kit copy did not finish.",
@@ -1210,21 +1219,9 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1210
1219
  ];
1211
1220
  }
1212
1221
 
1213
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1214
1222
  const lines = [];
1215
1223
 
1216
- for (const harness of harnesses) {
1217
- const mine = adapters.plan.filter(
1218
- (item) => item.harness === harness && !failed.has(item.relativePath),
1219
- );
1220
- const count = (action) => mine.filter((item) => item.action === action).length;
1221
-
1222
- const generated = count("write");
1223
- const replaced = count("replace");
1224
- const unchanged = count("up-to-date");
1225
- const conflicts = mine.filter((item) => item.action === "conflict");
1226
- const orphans = mine.filter((item) => item.action === "orphan");
1227
-
1224
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1228
1225
  lines.push(
1229
1226
  ` ${generated} ${harness.label} skill adapter${plural(generated)} ` +
1230
1227
  (options.dryRun ? "to generate" : "generated"),
@@ -1242,7 +1239,7 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1242
1239
  lines.push(
1243
1240
  ` ${conflicts.length} file${plural(conflicts.length)} left untouched because Pathfinder did not write ${conflicts.length === 1 ? "it" : "them"}:`,
1244
1241
  );
1245
- for (const item of conflicts) lines.push(` ${item.relativePath}`);
1242
+ for (const path of conflicts) lines.push(` ${path}`);
1246
1243
  lines.push("");
1247
1244
  lines.push(
1248
1245
  conflicts.length === 1
@@ -1253,8 +1250,8 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1253
1250
  );
1254
1251
  }
1255
1252
 
1256
- for (const item of orphans) {
1257
- lines.push(` ${item.relativePath} delegates to a skill this version no longer`);
1253
+ for (const path of orphans) {
1254
+ lines.push(` ${path} delegates to a skill this version no longer`);
1258
1255
  lines.push(" ships. It was left in place; delete it yourself if you want it gone.");
1259
1256
  }
1260
1257
  }
@@ -1311,7 +1308,7 @@ function customToolLines(customTools = []) {
1311
1308
  * and get colour. The paths underneath get neither, for the reason below.
1312
1309
  * - **Diagnostics stay pasteable.** See `pathList`.
1313
1310
  */
1314
- function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
1311
+ function expressiveReport({ outcome, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
1315
1312
  const mark = theme.glyph;
1316
1313
  const lines = [];
1317
1314
  const verb = options.dryRun ? "Would install" : "Installed";
@@ -1325,7 +1322,7 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1325
1322
  );
1326
1323
  }
1327
1324
 
1328
- const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
1325
+ const { written, overwritten, skipped } = outcome;
1329
1326
  // A zero is reported, never celebrated. `✓ 0 files written` is a tick over
1330
1327
  // nothing happening, which is the kind of detail that makes a whole summary
1331
1328
  // feel automated rather than read.
@@ -1342,18 +1339,17 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1342
1339
  // was asked to do, and marking a requested action as a warning is how a tool
1343
1340
  // teaches people to ignore its warnings. The files it replaced are still
1344
1341
  // worth stating plainly, which is what `info` is for.
1345
- if (result.overwritten > 0) {
1342
+ if (overwritten > 0) {
1346
1343
  lines.push(
1347
1344
  railed(
1348
1345
  theme,
1349
- theme.info(`${mark.info} ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`),
1346
+ theme.info(`${mark.info} ${overwritten} file${plural(overwritten)} overwritten (--force)`),
1350
1347
  ),
1351
1348
  );
1352
1349
  }
1353
1350
 
1354
- lines.push(...expressiveAdapterLines({ adapters, harnesses, options, theme }));
1351
+ lines.push(...expressiveAdapterLines({ outcome, options, theme }));
1355
1352
 
1356
- const skipped = plan.filter((item) => item.status === "skip");
1357
1353
  if (skipped.length > 0) {
1358
1354
  lines.push(
1359
1355
  railed(
@@ -1379,33 +1375,26 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1379
1375
  theme,
1380
1376
  word: "Skipped",
1381
1377
  summary: `${skipped.length} file${plural(skipped.length)} already exist${skipped.length === 1 ? "s" : ""} and ${skipped.length === 1 ? "was" : "were"} left untouched`,
1382
- paths: skipped.map((item) => item.relativePath),
1378
+ paths: skipped,
1383
1379
  advice: ["Nothing above was modified. Re-run with --force to replace them."],
1384
1380
  }),
1385
1381
  );
1386
1382
  }
1387
1383
 
1388
- lines.push(...expressiveAdapterBlocks({ adapters, harnesses, theme }));
1384
+ lines.push(...expressiveAdapterBlocks({ outcome, theme }));
1389
1385
 
1390
1386
  if (customTools.length > 0) lines.push(...customToolLines(customTools));
1391
1387
 
1392
- const failureCount = result.errors.length + adapters.result.errors.length;
1393
- // What actually wants a human: a contested path, or an adapter pointing at a
1394
- // skill that is gone. Skipped files are deliberately *not* counted here.
1395
- // A re-run over an existing install skips every file by design, and calling
1396
- // thirty-six routine skips "things to look at" would turn the one number that
1397
- // should mean something into noise nobody reads twice.
1398
- const attention = adapters.blocked
1399
- ? 0
1400
- : adapters.plan.filter((item) => item.action === "conflict" || item.action === "orphan").length;
1401
-
1402
- if (failureCount === 0) {
1388
+ // `attention` is what actually wants a human: a contested path, or an adapter
1389
+ // pointing at a skill that is gone. Skipped files are deliberately not among
1390
+ // them see `summarize`, which is where that decision now lives.
1391
+ if (outcome.failures.length === 0) {
1403
1392
  lines.push("");
1404
1393
  lines.push(
1405
1394
  ...readyBlock({
1406
1395
  theme,
1407
- headline: endingHeadline({ theme, written, adapters, attention, options }),
1408
- detail: endingDetail({ written, adapters, harnesses, attention, options }),
1396
+ headline: endingHeadline({ theme, outcome, options }),
1397
+ detail: endingDetail({ outcome, harnesses, options }),
1409
1398
  }),
1410
1399
  );
1411
1400
  }
@@ -1431,7 +1420,7 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1431
1420
 
1432
1421
  out(lines.join("\n") + "\n");
1433
1422
 
1434
- const failures = [...result.errors, ...adapters.result.errors];
1423
+ const { failures } = outcome;
1435
1424
  if (failures.length > 0) {
1436
1425
  // `bad`, not `warn`, and the distinction is the whole point of having both:
1437
1426
  // everything above is an outcome somebody may want to know about, and this
@@ -1481,29 +1470,21 @@ function pathList(paths) {
1481
1470
  }
1482
1471
 
1483
1472
  /** The per-harness summary counts, on the gutter, each at its own severity. */
1484
- function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1473
+ function expressiveAdapterLines({ outcome, options, theme }) {
1485
1474
  const mark = theme.glyph;
1486
- if (harnesses.length === 0) return [];
1487
1475
 
1488
- if (adapters.blocked) {
1476
+ // No "no harness chosen" guard: that case is no rows and `blocked: false`,
1477
+ // so it falls through to an empty list. Blocked is checked first because it
1478
+ // is also no rows, and the two must not print the same nothing.
1479
+ if (outcome.blocked) {
1489
1480
  return [
1490
1481
  railed(theme, theme.bad(`${mark.bad} No adapters were generated`) + theme.dim(" (the kit copy did not finish)")),
1491
1482
  ];
1492
1483
  }
1493
1484
 
1494
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1495
1485
  const lines = [];
1496
1486
 
1497
- for (const harness of harnesses) {
1498
- const mine = adapters.plan.filter(
1499
- (item) => item.harness === harness && !failed.has(item.relativePath),
1500
- );
1501
- const count = (action) => mine.filter((item) => item.action === action).length;
1502
-
1503
- const generated = count("write");
1504
- const replaced = count("replace");
1505
- const unchanged = count("up-to-date");
1506
-
1487
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1507
1488
  lines.push(
1508
1489
  railed(
1509
1490
  theme,
@@ -1526,9 +1507,6 @@ function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1526
1507
  );
1527
1508
  }
1528
1509
 
1529
- const conflicts = mine.filter((item) => item.action === "conflict");
1530
- const orphans = mine.filter((item) => item.action === "orphan");
1531
-
1532
1510
  if (conflicts.length > 0) {
1533
1511
  lines.push(
1534
1512
  railed(
@@ -1554,20 +1532,12 @@ function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1554
1532
  }
1555
1533
 
1556
1534
  /** The conflict and orphan detail blocks, with their paths kept pasteable. */
1557
- function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1558
- if (harnesses.length === 0 || adapters.blocked) return [];
1535
+ function expressiveAdapterBlocks({ outcome, theme }) {
1536
+ if (outcome.blocked) return [];
1559
1537
 
1560
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1561
1538
  const blocks = [];
1562
1539
 
1563
- for (const harness of harnesses) {
1564
- const mine = adapters.plan.filter(
1565
- (item) => item.harness === harness && !failed.has(item.relativePath),
1566
- );
1567
-
1568
- const conflicts = mine.filter((item) => item.action === "conflict");
1569
- const orphans = mine.filter((item) => item.action === "orphan");
1570
-
1540
+ for (const { harness, conflicts, orphans } of outcome.harnessRows) {
1571
1541
  if (conflicts.length > 0) {
1572
1542
  const one = conflicts.length === 1;
1573
1543
  blocks.push(
@@ -1575,7 +1545,7 @@ function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1575
1545
  theme,
1576
1546
  word: "Conflict",
1577
1547
  summary: `${conflicts.length} ${harness.label} file${plural(conflicts.length)} at ${one ? "a path an adapter wants" : "paths adapters want"}, which Pathfinder did not write`,
1578
- paths: conflicts.map((item) => item.relativePath),
1548
+ paths: conflicts,
1579
1549
  advice: [
1580
1550
  `Re-run with --force to replace ${one ? "it" : "them"} ${theme.glyph.dash} note that --force also`,
1581
1551
  "overwrites Pathfinder kit files you have edited.",
@@ -1591,7 +1561,7 @@ function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1591
1561
  theme,
1592
1562
  word: "Orphan",
1593
1563
  summary: `${orphans.length} ${harness.label} adapter${plural(orphans.length)} delegat${one ? "es" : "e"} to a skill this version no longer ships`,
1594
- paths: orphans.map((item) => item.relativePath),
1564
+ paths: orphans,
1595
1565
  advice: [
1596
1566
  `Left in place. Delete ${one ? "it" : "them"} yourself if you want ${one ? "it" : "them"} gone.`,
1597
1567
  ],
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What a run did, derived once.
3
+ *
4
+ * Two renderings print this install — `contractReport` owes byte-for-byte what
5
+ * 1.4.1 printed, `expressiveReport` owes a person a legible hierarchy — and
6
+ * they had each grown their own copy of the same four derivations: the
7
+ * mode-dependent written count, the skipped filter, the failure merge, and a
8
+ * per-harness adapter tally that appeared three times character-for-character.
9
+ * Four facts, ten spellings, and no mechanism keeping them in agreement. This
10
+ * module is the one spelling. The renderings stay two renderings; they just
11
+ * stop each deciding what the numbers are.
12
+ *
13
+ * Pure by construction: no filesystem, no `process`, no writing. Everything
14
+ * here is a function of the plans and results it is handed, which is what lets
15
+ * a summary be tested without building a temporary repository or a fake
16
+ * terminal.
17
+ *
18
+ * Two counters elsewhere are deliberately *not* folded in, and a later change
19
+ * that "finishes the job" will break them:
20
+ *
21
+ * - `countWritten` in `cli.mjs` runs mid-run, before adapters are applied, and
22
+ * reports a different number — `written + overwritten`, labelled "copied" —
23
+ * than the summary's `written`. It cannot read a result that does not exist
24
+ * yet.
25
+ * - The streaming per-harness counts in `generateAdapters` accumulate as units
26
+ * resolve, so milestones can print while the work is happening, and they
27
+ * count conflicts, orphans, and up-to-date adapters differently from the
28
+ * rows below. A summary computed at the end cannot drive a progress bar.
29
+ */
30
+
31
+ /**
32
+ * Every derived fact both renderings need, and nothing either of them can
33
+ * compute for itself.
34
+ *
35
+ * @param {object} args
36
+ * @param {{relativePath: string, status: "write"|"skip"|"overwrite"}[]} args.plan
37
+ * the kit copy plan, in `planInstall`'s sort
38
+ * @param {{written: number, skipped: number, overwritten: number,
39
+ * errors: {relativePath: string, message: string}[]}} args.result
40
+ * @param {{plan: object[], result: object, blocked: boolean}} args.adapters
41
+ * @param {{label: string}[]} args.harnesses the selected harnesses, registry order
42
+ * @param {{dryRun?: boolean}} args.options
43
+ * @returns {Readonly<object>} frozen; rows and lists frozen with it
44
+ */
45
+ export function summarize({ plan, result, adapters, harnesses, options }) {
46
+ // A dry run has no `result.written` to report, because nothing was written.
47
+ // The plan is counted instead, which is the same number the run would have
48
+ // produced had it been allowed to write.
49
+ const written = options.dryRun
50
+ ? plan.filter((item) => item.status === "write").length
51
+ : result.written;
52
+
53
+ const skipped = plan
54
+ .filter((item) => item.status === "skip")
55
+ .map((item) => item.relativePath);
56
+
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]);
60
+
61
+ return Object.freeze({
62
+ written,
63
+ overwritten: result.overwritten,
64
+ skipped: Object.freeze(skipped),
65
+ // Nothing to write and every file already there. Not the same as `written
66
+ // === 0`, which a partly failed copy also satisfies.
67
+ alreadyInstalled: written === 0 && skipped.length === plan.length,
68
+ failures,
69
+ blocked: adapters.blocked,
70
+ // Summed from the result, never from `harnessRows`. The rows exclude paths
71
+ // that errored and this does not, so the two disagree exactly when a write
72
+ // fails — and this is the number the closing headline speaks for.
73
+ built: adapters.result.generated + adapters.result.replaced,
74
+ attention: attentionCount(adapters),
75
+ harnessRows: harnessRows({ adapters, harnesses }),
76
+ });
77
+ }
78
+
79
+ /**
80
+ * What actually wants a human: a contested path, or an adapter pointing at a
81
+ * skill that is gone.
82
+ *
83
+ * Skipped files are deliberately not counted. A re-run over an existing install
84
+ * skips every file by design, and calling thirty-six routine skips "things to
85
+ * look at" would turn the one number that should mean something into noise
86
+ * nobody reads twice.
87
+ *
88
+ * Counted across the whole adapter plan rather than across `harnessRows`,
89
+ * errored paths included, because a path that could not be written is still a
90
+ * path somebody has to go and look at.
91
+ */
92
+ function attentionCount(adapters) {
93
+ if (adapters.blocked) return 0;
94
+ return adapters.plan.filter(
95
+ (item) => item.action === "conflict" || item.action === "orphan",
96
+ ).length;
97
+ }
98
+
99
+ /**
100
+ * One row per selected harness, in the order the harnesses were given.
101
+ *
102
+ * The three-way distinction the report depends on is carried by the rows
103
+ * themselves, and all three collapse to a zero if it is lost:
104
+ *
105
+ * - no harness chosen — no rows, `blocked: false`
106
+ * - the kit copy failed — no rows, `blocked: true`
107
+ * - a harness that produced nothing — a row of zeroes
108
+ *
109
+ * The blocked case returns no rows explicitly rather than falling out of an
110
+ * empty plan, so that a harness which was chosen and never reached is never
111
+ * described as having generated zero adapters.
112
+ *
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.
117
+ */
118
+ function harnessRows({ adapters, harnesses }) {
119
+ if (harnesses.length === 0 || adapters.blocked) return Object.freeze([]);
120
+
121
+ const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
122
+
123
+ 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(
128
+ (item) => item.harness === harness && !failed.has(item.relativePath),
129
+ );
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
+
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
+ );
147
+ }