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 +1 -1
- package/skills/load-feature/SKILL.md +27 -7
- package/src/cli.mjs +65 -95
- package/src/outcome.mjs +147 -0
package/package.json
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
507
|
+
function endingHeadline({ theme, outcome, options }) {
|
|
501
508
|
const mark = theme.glyph;
|
|
502
|
-
const built
|
|
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({
|
|
533
|
+
function endingDetail({ outcome, harnesses, options }) {
|
|
527
534
|
const parts = [];
|
|
528
|
-
const built
|
|
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
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
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
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
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({
|
|
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
|
|
1029
|
+
const { written, overwritten, skipped } = outcome;
|
|
1022
1030
|
lines.push(` ${written} file${plural(written)} ${options.dryRun ? "to write" : "written"}`);
|
|
1023
1031
|
|
|
1024
|
-
if (
|
|
1025
|
-
lines.push(` ${
|
|
1032
|
+
if (overwritten > 0) {
|
|
1033
|
+
lines.push(` ${overwritten} file${plural(overwritten)} overwritten (--force)`);
|
|
1026
1034
|
}
|
|
1027
1035
|
|
|
1028
|
-
lines.push(...contractAdapterLines({
|
|
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
|
|
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 (
|
|
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 =
|
|
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({
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
|
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
|
|
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
|
|
1257
|
-
lines.push(` ${
|
|
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({
|
|
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
|
|
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 (
|
|
1342
|
+
if (overwritten > 0) {
|
|
1346
1343
|
lines.push(
|
|
1347
1344
|
railed(
|
|
1348
1345
|
theme,
|
|
1349
|
-
theme.info(`${mark.info} ${
|
|
1346
|
+
theme.info(`${mark.info} ${overwritten} file${plural(overwritten)} overwritten (--force)`),
|
|
1350
1347
|
),
|
|
1351
1348
|
);
|
|
1352
1349
|
}
|
|
1353
1350
|
|
|
1354
|
-
lines.push(...expressiveAdapterLines({
|
|
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
|
|
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({
|
|
1384
|
+
lines.push(...expressiveAdapterBlocks({ outcome, theme }));
|
|
1389
1385
|
|
|
1390
1386
|
if (customTools.length > 0) lines.push(...customToolLines(customTools));
|
|
1391
1387
|
|
|
1392
|
-
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
|
|
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,
|
|
1408
|
-
detail: endingDetail({
|
|
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 =
|
|
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({
|
|
1473
|
+
function expressiveAdapterLines({ outcome, options, theme }) {
|
|
1485
1474
|
const mark = theme.glyph;
|
|
1486
|
-
if (harnesses.length === 0) return [];
|
|
1487
1475
|
|
|
1488
|
-
|
|
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
|
|
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({
|
|
1558
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
],
|
package/src/outcome.mjs
ADDED
|
@@ -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
|
+
}
|