create-pathfinder 1.8.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.
Files changed (45) hide show
  1. package/AGENTS.md +3 -1
  2. package/CLAUDE.md +20 -0
  3. package/README.md +383 -65
  4. package/context/ai-interaction.md +78 -39
  5. package/context/coding-standards.md +119 -60
  6. package/copy-list.json +1 -0
  7. package/package.json +2 -1
  8. package/roles/developer.md +37 -0
  9. package/roles/planner.md +38 -0
  10. package/roles/tester.md +40 -0
  11. package/skills/challenge-me/SKILL.md +11 -7
  12. package/skills/complete-feature/SKILL.md +16 -11
  13. package/skills/handoff/SKILL.md +24 -4
  14. package/skills/kickstart-pathfinder/SKILL.md +6 -1
  15. package/skills/learning-review/SKILL.md +9 -6
  16. package/skills/load-feature/SKILL.md +40 -10
  17. package/skills/prototype/SKILL.md +3 -1
  18. package/skills/quiz-me/SKILL.md +33 -7
  19. package/skills/review-feature/SKILL.md +19 -11
  20. package/skills/role/SKILL.md +33 -0
  21. package/skills/setup-tracker/SKILL.md +29 -61
  22. package/skills/start-feature/SKILL.md +19 -16
  23. package/skills/sync-tracker/SKILL.md +67 -106
  24. package/skills/teach-architecture/SKILL.md +6 -5
  25. package/skills/teach-feature/SKILL.md +11 -8
  26. package/skills/to-specs/SKILL.md +40 -28
  27. package/skills/whereami/SKILL.md +87 -0
  28. package/src/cli.mjs +67 -96
  29. package/src/install.mjs +5 -1
  30. package/src/kit.mjs +70 -1
  31. package/src/outcome.mjs +147 -0
  32. package/templates/CHANGELOG.template.md +2 -1
  33. package/templates/feature-spec.template.md +18 -80
  34. package/templates/history.template.md +12 -0
  35. package/templates/lesson.template.md +22 -51
  36. package/templates/project-overview.template.md +80 -145
  37. package/context/current-feature.md +0 -46
  38. package/context/features/example-feature-spec.md +0 -103
  39. package/context/history.md +0 -14
  40. package/context/learning/learner-profile.md +0 -35
  41. package/context/learning/lessons/.gitkeep +0 -0
  42. package/context/learning/progress.md +0 -28
  43. package/context/project-overview.md +0 -211
  44. package/templates/progress-entry.template.md +0 -23
  45. package/templates/tracker.template.md +0 -359
@@ -13,13 +13,14 @@ The goal is not to produce generic documentation. The goal is to help the learne
13
13
 
14
14
  Read only the smallest useful context:
15
15
 
16
- 1. `context/learning/learner-profile.md`
17
- 2. `context/current-feature.md`
18
- 3. The source feature spec under `context/features/`
19
- 4. `context/project-overview.md`
20
- 5. The current branch, working tree status, and focused diff
21
- 6. Tests and implementation files touched by the feature
22
- 7. Relevant ADR, coding standard, or design-system guidance only when it materially affects the feature
16
+ 1. `context/current-feature.md`
17
+ 2. The source feature spec under `context/features/`
18
+ 3. `context/project-overview.md`
19
+ 4. The current branch, working tree status, and focused diff
20
+ 5. Tests and implementation files touched by the feature
21
+ 6. Relevant ADR, coding standard, or design-system guidance only when it materially affects the feature
22
+
23
+ Skip any of these that does not exist. Pathfinder creates these files only when a workflow first needs them, so their absence is normal and is not an error. Do not create them just to satisfy this list.
23
24
 
24
25
  Do not read the entire repository by default.
25
26
 
@@ -57,7 +58,9 @@ Explain:
57
58
 
58
59
  ## Adaptation
59
60
 
60
- Use `context/learning/learner-profile.md`.
61
+ Pitch the lesson at what the learner has already demonstrated, using
62
+ `context/learning/progress.md` when it exists and what the human tells you when
63
+ it does not. Ask rather than assume.
61
64
 
62
65
  For an experienced engineer:
63
66
 
@@ -1,49 +1,61 @@
1
1
  ---
2
2
  name: to-specs
3
- description: Convert approved project context into small sequential feature specs optimized for focused LLM context and reliable delivery.
3
+ description: Turn approved direction into small, clear Feature specs.
4
4
  ---
5
5
 
6
6
  # To Specs
7
7
 
8
- ## Readiness Check
8
+ Turn approved direction into the minimum Feature specs needed to implement it.
9
9
 
10
- Confirm:
10
+ ## Process
11
11
 
12
- - MVP and first useful flow are defined
13
- - critical stack and workflow choices are approved or deliberately deferred
14
- - required prototype direction is approved
15
- - known verification methods exist or are explicitly deferred
16
- - no material context contradictions remain
12
+ 1. Read the approved project context and relevant existing Features.
13
+ 2. Stop if a required human decision is still `TBD`.
14
+ 3. Decide whether the work is one Feature or needs a small number of Features.
15
+ 4. Create `context/features/` if it does not exist.
16
+ 5. Create each Feature from `templates/feature-spec.template.md`, named
17
+ `NN-feature-name.md` — see Naming below.
18
+ 6. Fill only information that materially helps implementation and review.
19
+ 7. Present the created Features and recommend which one to start first.
20
+ 8. If `context/tracker.md` exists, offer to publish them with `sync-tracker`.
21
+ Do nothing here if it does not.
17
22
 
18
- If not ready, report the blockers instead of inventing decisions.
23
+ ## Naming
19
24
 
20
- ## Sizing Principles
25
+ A Feature spec is named `NN-feature-name.md` in the spec source, for example
26
+ `context/features/27-export-saved-searches.md`.
21
27
 
22
- Each feature should:
28
+ `NN` is the Feature number: the next unused number in the spec source, counting
29
+ every spec already there whatever its status. Numbers are never reused and never
30
+ renumbered, because a published tracker item is matched on that number and
31
+ renumbering would orphan it.
23
32
 
24
- - create one visible or system-verifiable outcome
25
- - fit one focused branch/change set under the project's workflow
26
- - be independently reviewable and verifiable
27
- - require a coherent, bounded set of context
28
- - state dependencies, assumptions, and exclusions
29
- - contain stable delivery chunks
33
+ The number lives in the filename and nowhere else. The Feature template carries
34
+ no number field, and nothing else in the spec records one.
30
35
 
31
- Split a feature when it mixes several systems, requires a repo-wide mental model, combines infrastructure with unrelated UX/polish, or cannot be verified independently.
36
+ ## Sizing
32
37
 
33
- ## Output
38
+ Prefer one Feature when the work is coherent.
34
39
 
35
- Create only the coherent MVP roadmap in `context/features/`, using `templates/feature-spec.template.md` and project-selected naming/delivery policies.
40
+ Split only when separate outcomes:
36
41
 
37
- Each spec must include Context Boundary, Delivery Chunks, and Learning Targets.
42
+ - can be implemented or reviewed independently
43
+ - depend on meaningfully different context
44
+ - have a real dependency between them
45
+ - would make one Feature unnecessarily difficult to understand
38
46
 
39
- Write a `## Tags` section only when the project has configured work tracking and its `context/tracker.md` defines tag namespaces. Use the values that config already lists, never invented ones. No tags is a valid and common answer — omit the section rather than guessing.
47
+ Prefer fewer Features and fewer artifacts.
40
48
 
41
- After creation, summarize file, outcome, dependency, visible/verifiable win, context risk, and recommended first feature.
42
-
43
- If `context/tracker.md` exists, offer to publish the new specs with `sync-tracker`. If it does not, say nothing about tracking.
49
+ Use delivery chunks inside a Feature before creating additional Features.
44
50
 
45
51
  ## Rules
46
52
 
47
- - Do not implement or install packages.
48
- - Do not assume UI, mobile, a framework, a branch type, or conventional commits.
49
- - Do not plan the entire dream product.
53
+ - Do not implement.
54
+ - Do not invent unresolved decisions.
55
+ - Do not plan beyond the approved direction.
56
+ - Do not add workflow metadata that the Feature template does not require.
57
+ - Do not create tickets, tracker items, or extra planning files by default.
58
+ - Do not scan unrelated repository areas.
59
+
60
+ When implementation details are unclear, inspect only enough source code to
61
+ write an accurate Feature.
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: whereami
3
+ description: Report a compact read-only snapshot of the current Pathfinder work session.
4
+ ---
5
+
6
+ # Where Am I
7
+
8
+ Answer one question: what is this session working on right now?
9
+
10
+ Use it when a session resumes, after a long gap, or before deciding the next
11
+ action. It reports state. It never changes it.
12
+
13
+ ## Process
14
+
15
+ 1. Role: report the role activated in this session via `/role`.
16
+ Roles are session state and are never written to disk, so if no role was
17
+ activated in this conversation, report `none`. Do not search `roles/`.
18
+ 2. Read `context/current-feature.md` if it exists.
19
+ Take Feature, Active chunk, and Next from it verbatim. The Feature number is
20
+ the `NN` recorded there, from its spec filename.
21
+ If the file is missing or still holds template placeholders, report `none`.
22
+ 3. Run `git status --short --branch` once.
23
+ Report the branch/ref, and `clean` or the count of changed paths.
24
+ 4. Compare the Git section of `context/current-feature.md` with step 3.
25
+ Report a drift line only if the recorded branch differs from the real one.
26
+ 5. Context telemetry: report it only if this harness exposes it.
27
+ Otherwise `unavailable`. Do not estimate.
28
+
29
+ ## Output
30
+
31
+ Exactly this shape, one line each:
32
+
33
+ ```
34
+ Role: <role | none>
35
+ Feature: <## — name | none>
36
+ Chunk: <number and name | none>
37
+ Git: <branch/ref> — <clean | N changed>
38
+ Context: <telemetry | unavailable>
39
+ Next: <single next action | none>
40
+ ```
41
+
42
+ Add at most one line after it, and only when step 4 found drift:
43
+
44
+ ```
45
+ Drift: current-feature.md records <branch>, working tree is on <branch>
46
+ ```
47
+
48
+ Then stop.
49
+
50
+ ## Rules
51
+
52
+ - Read only. No writes, no commits, no `git` command that mutates anything.
53
+ - Read at most one file: `context/current-feature.md`.
54
+ - Do not open the feature spec, history, roadmap, `.features/`, or source.
55
+ - Report `none` or `unavailable` instead of inferring a missing value.
56
+ - Do not offer to fix drift, update state, or start the next action.
57
+ The human decides what happens after the snapshot.
58
+
59
+ ## Example
60
+
61
+ `/whereami` after resuming mid-feature:
62
+
63
+ ```
64
+ Role: developer
65
+ Feature: 12 — export saved searches
66
+ Chunk: 2 — CSV writer
67
+ Git: feature/12-export-saved-searches — 3 changed
68
+ Context: unavailable
69
+ Next: Verify the CSV writer against the acceptance criteria
70
+ ```
71
+
72
+ ## Anti-example
73
+
74
+ Do not do this:
75
+
76
+ ```
77
+ Role: developer (inferred from recent commits)
78
+ Feature: 12 — export saved searches
79
+ Chunk: 3 — probably the download endpoint
80
+ Git: feature/12-export-saved-searches — 3 changed
81
+ Context: ~60% used
82
+ Next: I can update current-feature.md and start chunk 3 — want me to?
83
+ ```
84
+
85
+ It guesses the role from history, invents a chunk the spec never named,
86
+ estimates telemetry it cannot see, and turns a status report into a proposal
87
+ to write state.
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,
@@ -205,7 +206,8 @@ export async function run(
205
206
  // as it goes.
206
207
  //
207
208
  // Planning adapters this early is safe, and specifically because of what the
208
- // copy list contains: AGENTS.md, CLAUDE.md, context, skills, and templates.
209
+ // copy list contains: AGENTS.md, CLAUDE.md, context, roles, skills, and
210
+ // templates.
209
211
  // No entry writes into `.claude/` or `.agents/`, so the copy cannot change
210
212
  // the answer `planAdapters` gives about an adapter path, and the canonical
211
213
  // skills it reads come from the kit rather than from the destination. If a
@@ -269,7 +271,13 @@ export async function run(
269
271
  progress.finish();
270
272
  if (!options.dryRun && theme.tier !== "contract") out("\n");
271
273
 
272
- 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 });
273
281
 
274
282
  // After the report, because the first offer is about the prompt the report
275
283
  // just printed — and because a question above the summary would make the user
@@ -285,7 +293,7 @@ export async function run(
285
293
  //
286
294
  // Not printed when anything failed. A sign-off over an error is a tool that
287
295
  // did not read its own output.
288
- const failed = result.errors.length > 0 || adapters.result.errors.length > 0;
296
+ const failed = outcome.failures.length > 0;
289
297
  if (theme.tier !== "contract" && !failed) {
290
298
  out(`\n ${theme.dim(SIGN_OFF)}\n`);
291
299
  }
@@ -496,9 +504,9 @@ function generateAdapters({ plan, harnesses, options, result, onProgress, onHarn
496
504
  * person reading has just watched a bar fill. A re-run that wrote nothing says
497
505
  * so plainly instead of inventing an achievement.
498
506
  */
499
- function endingHeadline({ theme, written, adapters, attention, options }) {
507
+ function endingHeadline({ theme, outcome, options }) {
500
508
  const mark = theme.glyph;
501
- const built = adapters.result.generated + adapters.result.replaced;
509
+ const { written, built, attention } = outcome;
502
510
 
503
511
  // Something wants a human. Still ready — it is — but this is not the moment
504
512
  // for confetti over somebody's conflicted file.
@@ -522,9 +530,9 @@ function endingHeadline({ theme, written, adapters, attention, options }) {
522
530
  * so plainly instead of inventing an achievement out of the harnesses it did
523
531
  * not have to configure.
524
532
  */
525
- function endingDetail({ written, adapters, harnesses, attention, options }) {
533
+ function endingDetail({ outcome, harnesses, options }) {
526
534
  const parts = [];
527
- const built = adapters.result.generated + adapters.result.replaced;
535
+ const { written, built, attention } = outcome;
528
536
 
529
537
  if (written > 0) parts.push(`${written} file${plural(written)}`);
530
538
  if (built > 0) parts.push(`${built} adapter${plural(built)}`);
@@ -987,15 +995,16 @@ export function formatFindings(findings, { theme = createTheme() } = {}) {
987
995
  function report(args) {
988
996
  // Two renderings, kept adjacent on purpose.
989
997
  //
990
- // The duplication below is a known, accepted cost rather than an oversight.
991
- // `contractReport` owes byte-for-byte what 1.4.1 printed, to scripts that
992
- // parse it; `expressiveReport` owes a person a legible hierarchy. Merging
993
- // them would mean one function whose every line carries a conditional, and
994
- // 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.
995
1004
  //
996
- // They are written next to each other so that editing one is an obvious
997
- // prompt to consider the other. Anything that changes what is *reported*
998
- // 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.
999
1008
  if (args.theme.tier === "contract") return contractReport(args);
1000
1009
  return expressiveReport(args);
1001
1010
  }
@@ -1007,7 +1016,7 @@ function report(args) {
1007
1016
  * `test/non-interactive.test.mjs` and by a capture-and-compare against the
1008
1017
  * published package, because a script somewhere is reading it.
1009
1018
  */
1010
- 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 }) {
1011
1020
  const lines = [];
1012
1021
  const verb = options.dryRun ? "Would install" : "Installed";
1013
1022
 
@@ -1017,25 +1026,24 @@ function contractReport({ result, plan, adapters, harnesses, customTools, cwd, g
1017
1026
  }
1018
1027
  lines.push("");
1019
1028
 
1020
- const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
1029
+ const { written, overwritten, skipped } = outcome;
1021
1030
  lines.push(` ${written} file${plural(written)} ${options.dryRun ? "to write" : "written"}`);
1022
1031
 
1023
- if (result.overwritten > 0) {
1024
- lines.push(` ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`);
1032
+ if (overwritten > 0) {
1033
+ lines.push(` ${overwritten} file${plural(overwritten)} overwritten (--force)`);
1025
1034
  }
1026
1035
 
1027
- lines.push(...contractAdapterLines({ adapters, harnesses, options, theme }));
1036
+ lines.push(...contractAdapterLines({ outcome, options, theme }));
1028
1037
  lines.push(...customToolLines(customTools));
1029
1038
 
1030
- const skipped = plan.filter((item) => item.status === "skip");
1031
1039
  if (skipped.length > 0) {
1032
1040
  lines.push(` ${skipped.length} file${plural(skipped.length)} left untouched because they already exist:`);
1033
- for (const item of skipped) lines.push(` ${item.relativePath}`);
1041
+ for (const path of skipped) lines.push(` ${path}`);
1034
1042
  lines.push("");
1035
1043
  lines.push(" Nothing above was modified. Re-run with --force to replace them.");
1036
1044
  }
1037
1045
 
1038
- if (written === 0 && skipped.length === plan.length) {
1046
+ if (outcome.alreadyInstalled) {
1039
1047
  lines.push("");
1040
1048
  lines.push("The kit is already installed here.");
1041
1049
  }
@@ -1053,7 +1061,7 @@ function contractReport({ result, plan, adapters, harnesses, customTools, cwd, g
1053
1061
 
1054
1062
  out(lines.join("\n") + "\n");
1055
1063
 
1056
- const failures = [...result.errors, ...adapters.result.errors];
1064
+ const { failures } = outcome;
1057
1065
  if (failures.length > 0) {
1058
1066
  const detail = failures.map((error) => ` ${error.relativePath}: ${error.message}`).join("\n");
1059
1067
  err(`\ncreate-pathfinder: ${failures.length} file${plural(failures.length)} could not be written:\n${detail}\n`);
@@ -1197,10 +1205,12 @@ async function offerEditor({ editors, cwd, prompter, out, env, platform, theme }
1197
1205
  * Empty when no harness was chosen, which is the default and must stay
1198
1206
  * invisible: a scripted 1.4.1-era run prints exactly what it always did.
1199
1207
  */
1200
- function contractAdapterLines({ adapters, harnesses, options, theme }) {
1201
- if (harnesses.length === 0) return [];
1202
-
1203
- 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) {
1204
1214
  return [
1205
1215
  "",
1206
1216
  " No adapters were generated, because the kit copy did not finish.",
@@ -1209,21 +1219,9 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1209
1219
  ];
1210
1220
  }
1211
1221
 
1212
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1213
1222
  const lines = [];
1214
1223
 
1215
- for (const harness of harnesses) {
1216
- const mine = adapters.plan.filter(
1217
- (item) => item.harness === harness && !failed.has(item.relativePath),
1218
- );
1219
- const count = (action) => mine.filter((item) => item.action === action).length;
1220
-
1221
- const generated = count("write");
1222
- const replaced = count("replace");
1223
- const unchanged = count("up-to-date");
1224
- const conflicts = mine.filter((item) => item.action === "conflict");
1225
- const orphans = mine.filter((item) => item.action === "orphan");
1226
-
1224
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1227
1225
  lines.push(
1228
1226
  ` ${generated} ${harness.label} skill adapter${plural(generated)} ` +
1229
1227
  (options.dryRun ? "to generate" : "generated"),
@@ -1241,7 +1239,7 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1241
1239
  lines.push(
1242
1240
  ` ${conflicts.length} file${plural(conflicts.length)} left untouched because Pathfinder did not write ${conflicts.length === 1 ? "it" : "them"}:`,
1243
1241
  );
1244
- for (const item of conflicts) lines.push(` ${item.relativePath}`);
1242
+ for (const path of conflicts) lines.push(` ${path}`);
1245
1243
  lines.push("");
1246
1244
  lines.push(
1247
1245
  conflicts.length === 1
@@ -1252,8 +1250,8 @@ function contractAdapterLines({ adapters, harnesses, options, theme }) {
1252
1250
  );
1253
1251
  }
1254
1252
 
1255
- for (const item of orphans) {
1256
- 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`);
1257
1255
  lines.push(" ships. It was left in place; delete it yourself if you want it gone.");
1258
1256
  }
1259
1257
  }
@@ -1310,7 +1308,7 @@ function customToolLines(customTools = []) {
1310
1308
  * and get colour. The paths underneath get neither, for the reason below.
1311
1309
  * - **Diagnostics stay pasteable.** See `pathList`.
1312
1310
  */
1313
- 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 }) {
1314
1312
  const mark = theme.glyph;
1315
1313
  const lines = [];
1316
1314
  const verb = options.dryRun ? "Would install" : "Installed";
@@ -1324,7 +1322,7 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1324
1322
  );
1325
1323
  }
1326
1324
 
1327
- const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
1325
+ const { written, overwritten, skipped } = outcome;
1328
1326
  // A zero is reported, never celebrated. `✓ 0 files written` is a tick over
1329
1327
  // nothing happening, which is the kind of detail that makes a whole summary
1330
1328
  // feel automated rather than read.
@@ -1341,18 +1339,17 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1341
1339
  // was asked to do, and marking a requested action as a warning is how a tool
1342
1340
  // teaches people to ignore its warnings. The files it replaced are still
1343
1341
  // worth stating plainly, which is what `info` is for.
1344
- if (result.overwritten > 0) {
1342
+ if (overwritten > 0) {
1345
1343
  lines.push(
1346
1344
  railed(
1347
1345
  theme,
1348
- theme.info(`${mark.info} ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`),
1346
+ theme.info(`${mark.info} ${overwritten} file${plural(overwritten)} overwritten (--force)`),
1349
1347
  ),
1350
1348
  );
1351
1349
  }
1352
1350
 
1353
- lines.push(...expressiveAdapterLines({ adapters, harnesses, options, theme }));
1351
+ lines.push(...expressiveAdapterLines({ outcome, options, theme }));
1354
1352
 
1355
- const skipped = plan.filter((item) => item.status === "skip");
1356
1353
  if (skipped.length > 0) {
1357
1354
  lines.push(
1358
1355
  railed(
@@ -1378,33 +1375,26 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1378
1375
  theme,
1379
1376
  word: "Skipped",
1380
1377
  summary: `${skipped.length} file${plural(skipped.length)} already exist${skipped.length === 1 ? "s" : ""} and ${skipped.length === 1 ? "was" : "were"} left untouched`,
1381
- paths: skipped.map((item) => item.relativePath),
1378
+ paths: skipped,
1382
1379
  advice: ["Nothing above was modified. Re-run with --force to replace them."],
1383
1380
  }),
1384
1381
  );
1385
1382
  }
1386
1383
 
1387
- lines.push(...expressiveAdapterBlocks({ adapters, harnesses, theme }));
1384
+ lines.push(...expressiveAdapterBlocks({ outcome, theme }));
1388
1385
 
1389
1386
  if (customTools.length > 0) lines.push(...customToolLines(customTools));
1390
1387
 
1391
- const failureCount = result.errors.length + adapters.result.errors.length;
1392
- // What actually wants a human: a contested path, or an adapter pointing at a
1393
- // skill that is gone. Skipped files are deliberately *not* counted here.
1394
- // A re-run over an existing install skips every file by design, and calling
1395
- // thirty-six routine skips "things to look at" would turn the one number that
1396
- // should mean something into noise nobody reads twice.
1397
- const attention = adapters.blocked
1398
- ? 0
1399
- : adapters.plan.filter((item) => item.action === "conflict" || item.action === "orphan").length;
1400
-
1401
- 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) {
1402
1392
  lines.push("");
1403
1393
  lines.push(
1404
1394
  ...readyBlock({
1405
1395
  theme,
1406
- headline: endingHeadline({ theme, written, adapters, attention, options }),
1407
- detail: endingDetail({ written, adapters, harnesses, attention, options }),
1396
+ headline: endingHeadline({ theme, outcome, options }),
1397
+ detail: endingDetail({ outcome, harnesses, options }),
1408
1398
  }),
1409
1399
  );
1410
1400
  }
@@ -1430,7 +1420,7 @@ function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd,
1430
1420
 
1431
1421
  out(lines.join("\n") + "\n");
1432
1422
 
1433
- const failures = [...result.errors, ...adapters.result.errors];
1423
+ const { failures } = outcome;
1434
1424
  if (failures.length > 0) {
1435
1425
  // `bad`, not `warn`, and the distinction is the whole point of having both:
1436
1426
  // everything above is an outcome somebody may want to know about, and this
@@ -1480,29 +1470,21 @@ function pathList(paths) {
1480
1470
  }
1481
1471
 
1482
1472
  /** The per-harness summary counts, on the gutter, each at its own severity. */
1483
- function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1473
+ function expressiveAdapterLines({ outcome, options, theme }) {
1484
1474
  const mark = theme.glyph;
1485
- if (harnesses.length === 0) return [];
1486
1475
 
1487
- 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) {
1488
1480
  return [
1489
1481
  railed(theme, theme.bad(`${mark.bad} No adapters were generated`) + theme.dim(" (the kit copy did not finish)")),
1490
1482
  ];
1491
1483
  }
1492
1484
 
1493
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1494
1485
  const lines = [];
1495
1486
 
1496
- for (const harness of harnesses) {
1497
- const mine = adapters.plan.filter(
1498
- (item) => item.harness === harness && !failed.has(item.relativePath),
1499
- );
1500
- const count = (action) => mine.filter((item) => item.action === action).length;
1501
-
1502
- const generated = count("write");
1503
- const replaced = count("replace");
1504
- const unchanged = count("up-to-date");
1505
-
1487
+ for (const { harness, generated, replaced, unchanged, conflicts, orphans } of outcome.harnessRows) {
1506
1488
  lines.push(
1507
1489
  railed(
1508
1490
  theme,
@@ -1525,9 +1507,6 @@ function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1525
1507
  );
1526
1508
  }
1527
1509
 
1528
- const conflicts = mine.filter((item) => item.action === "conflict");
1529
- const orphans = mine.filter((item) => item.action === "orphan");
1530
-
1531
1510
  if (conflicts.length > 0) {
1532
1511
  lines.push(
1533
1512
  railed(
@@ -1553,20 +1532,12 @@ function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
1553
1532
  }
1554
1533
 
1555
1534
  /** The conflict and orphan detail blocks, with their paths kept pasteable. */
1556
- function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1557
- if (harnesses.length === 0 || adapters.blocked) return [];
1535
+ function expressiveAdapterBlocks({ outcome, theme }) {
1536
+ if (outcome.blocked) return [];
1558
1537
 
1559
- const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
1560
1538
  const blocks = [];
1561
1539
 
1562
- for (const harness of harnesses) {
1563
- const mine = adapters.plan.filter(
1564
- (item) => item.harness === harness && !failed.has(item.relativePath),
1565
- );
1566
-
1567
- const conflicts = mine.filter((item) => item.action === "conflict");
1568
- const orphans = mine.filter((item) => item.action === "orphan");
1569
-
1540
+ for (const { harness, conflicts, orphans } of outcome.harnessRows) {
1570
1541
  if (conflicts.length > 0) {
1571
1542
  const one = conflicts.length === 1;
1572
1543
  blocks.push(
@@ -1574,7 +1545,7 @@ function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1574
1545
  theme,
1575
1546
  word: "Conflict",
1576
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`,
1577
- paths: conflicts.map((item) => item.relativePath),
1548
+ paths: conflicts,
1578
1549
  advice: [
1579
1550
  `Re-run with --force to replace ${one ? "it" : "them"} ${theme.glyph.dash} note that --force also`,
1580
1551
  "overwrites Pathfinder kit files you have edited.",
@@ -1590,7 +1561,7 @@ function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
1590
1561
  theme,
1591
1562
  word: "Orphan",
1592
1563
  summary: `${orphans.length} ${harness.label} adapter${plural(orphans.length)} delegat${one ? "es" : "e"} to a skill this version no longer ships`,
1593
- paths: orphans.map((item) => item.relativePath),
1564
+ paths: orphans,
1594
1565
  advice: [
1595
1566
  `Left in place. Delete ${one ? "it" : "them"} yourself if you want ${one ? "it" : "them"} gone.`,
1596
1567
  ],
package/src/install.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join, relative, sep } from "node:path";
18
18
 
19
- import { COPY_LIST, isExcluded } from "./kit.mjs";
19
+ import { COPY_LIST, isExcluded, neverShips } from "./kit.mjs";
20
20
  import {
21
21
  ADAPTER_STATE,
22
22
  adapterPath,
@@ -43,6 +43,10 @@ export function planInstall(kitRoot, targetRoot, { force = false } = {}) {
43
43
  for (const entry of COPY_LIST) {
44
44
  for (const source of walkFiles(join(kitRoot, entry))) {
45
45
  const relativePath = relative(kitRoot, source).split(sep).join("/");
46
+ // Filtered here rather than in walkFiles, which sees basenames only and
47
+ // would have to guess whether a `tracker.md` is *the* one.
48
+ if (neverShips(relativePath)) continue;
49
+
46
50
  const destination = join(targetRoot, relativePath);
47
51
  const exists = existsSync(destination);
48
52