yadflow 3.18.1 → 4.0.0-next.1

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 (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
package/cli/next.mjs CHANGED
@@ -1,50 +1,76 @@
1
1
  // `yad next` — the unified next-step driver. Read-only: it never writes state or acts. It reads the
2
2
  // file ledger and prints the ONE concrete, copy-pasteable next action (and a one-line why), so a user
3
3
  // never has to remember which of the 38 skills / gate commands comes next. "Guide, don't act" — the
4
- // front half still never auto-advances. Once an epic is `ready-for-build`, it reads each story's
4
+ // Shape still never auto-advances. Once an epic is `ready-for-build`, it reads each story's
5
5
  // build-state and prints the next BUILD sub-step per repo (spec → tasks → implement → checks → engineer-review)
6
- // plus the remaining chain — so the build half is guided too, not just hinted at.
6
+ // plus the remaining chain — so Build is guided too, not just hinted at.
7
7
  //
8
8
  // yad next general orientation across the whole project
9
9
  // yad next <epic> the single next action for one epic
10
10
  // yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
11
11
  // yad next --all every active epic's next action at once
12
12
  // yad next [<epic>] --json the same answer as an action object, for an agent or CI
13
- import fs from 'node:fs';
14
13
  import path from 'node:path';
15
- import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
16
- import { PROJECT_FILES, VERSION } from './manifest.mjs';
17
- import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, epicLineage, kindNoun, DISCOVERY_EPIC } from './epic-state.mjs';
14
+ import { c, log, ok, info, warn, hand, fail, readJSON, exists, emitJSON } from './lib.mjs';
15
+ import { PROJECT_FILES, isVerifiedLedger, productConfigPath, stepAdvance } from './manifest.mjs';
16
+ import { printTeamHint, soloTeamHint } from './people.mjs';
17
+ import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS } from './epic-state.mjs';
18
18
 
19
19
  // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
20
20
  // missing/old hub.json never breaks the driver.
21
+ const soloOf = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
21
22
  function isSolo(root) {
22
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
23
- return !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
23
+ return soloOf(readJSON(productConfigPath(root), null));
24
24
  }
25
- // The setup profile recorded by `yad setup` (codebase / repo_layout / team_size), or null.
26
- const profileOf = (root) => readJSON(path.join(root, PROJECT_FILES.hubConfig), null)?.profile || null;
27
- // Has `yad setup` run here? True once the version stamp or hub config exists.
28
- const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(path.join(root, PROJECT_FILES.hubConfig));
25
+ // Has `yad setup` run here? True once the version stamp or Product config exists.
26
+ const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(productConfigPath(root));
29
27
 
30
- // Every epic that has a state ledger, in directory order.
28
+ // Every epic that has a state ledger, the Foundation included, in id order.
31
29
  function listEpics(root) {
32
- const dir = path.join(root, 'epics');
33
- if (!exists(dir)) return [];
34
- return fs.readdirSync(dir, { withFileTypes: true })
35
- .filter((e) => e.isDirectory() && isValidEpicId(e.name))
36
- .map((e) => e.name)
37
- .filter((id) => exists(path.join(dir, id, '.sdlc', 'state.json')))
38
- .sort();
30
+ return epicIds(root).filter((id) => exists(path.join(epicRoot(root, id), '.sdlc', 'state.json')));
39
31
  }
40
32
 
41
- // The action object for ONE epic, with its lineage kind attached. The single shape both surfaces
33
+ // The action object for ONE epic, with its work-item type attached. The single shape both surfaces
42
34
  // consume — `printAction` renders it, `--json` emits it verbatim — so the prose and the machine
43
35
  // answer can never drift apart.
44
- const actionFor = (root, id) => ({
45
- ...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
46
- lineageKind: epicLineage(root, id).kind,
47
- });
36
+ //
37
+ // The key stays `lineageKind` even though the word is now `type`, and there is no `lineageType`
38
+ // beside it. `yad next --json` is deep-equalled by the golden test (cli/test-golden.mjs), which
39
+ // rule 6 says never changes — and a deep-equal breaks on an ADDED key just as hard as a renamed
40
+ // one. The value is the same either way; only the label is old.
41
+ //
42
+ // The lineage may be passed in. `epicLineage` opens and parses `epic.md`, and every printed surface
43
+ // needs the grouping theme off the SAME read — without this the file would be parsed twice per epic
44
+ // on every `yad next`. The default keeps the `--json` path a one-liner, where the theme is not wanted.
45
+ // `bindings` is passed in for the same reason `lin` is: every loop below renders many epics, and the
46
+ // project's skill bindings are ONE file for all of them. The default keeps the single-epic callers a
47
+ // one-liner; a loop reads the file once and hands the same object to every row.
48
+ const actionFor = (root, id, lin = epicLineage(root, id), bindings = loadSkillBindings(root)) => {
49
+ const ledger = loadLedger(epicRoot(root, id));
50
+ return {
51
+ ...nextAction(ledger, { epic: id, bindings }),
52
+ lineageKind: lin.type,
53
+ // Which ROUTE this epic RECORDED, carried so `phaseLine` can mark a phase that route never enters
54
+ // (E40). The recorded key ONLY — never matched from the chain, which is the one place in this file
55
+ // that deliberately asks less than `epicProfileId` does.
56
+ //
57
+ // A chain is allowed to be short without its route being short: every hand-written and pre-shape-6
58
+ // ledger is a truncated `classic`, and matching would read `[epic, epic-review]` as the chore lane
59
+ // and grey out two phases the epic is going to walk. Matching is the right fallback where the
60
+ // answer only has to be the best available one — what a step may skip, what `yad doctor` reports.
61
+ // Here it would be a visible claim about an epic's future, made from a guess, and it would flip
62
+ // the moment the next gate write stamped `classic` on the same file. An epic that never says
63
+ // which route it is on simply gets the line it got before the short lanes existed.
64
+ route: lifecycleProfile(ledger?.state?.profile) ? ledger.state.profile : null,
65
+ };
66
+ };
67
+
68
+ // One `epic.md` read, both things that come out of it: the action to print and the tag to print
69
+ // beside it. Every printed path in this file goes through here.
70
+ const rowFor = (root, id, bindings = loadSkillBindings(root)) => {
71
+ const lin = epicLineage(root, id);
72
+ return { action: actionFor(root, id, lin, bindings), theme: lin.theme };
73
+ };
48
74
 
49
75
  // EP-checkout-S03 → S03 (the compact lane label for the roll-up). Falls back to the full id.
50
76
  const shortStory = (s) => (s && s.match(/S\d+$/i)?.[0]) || s || '(story)';
@@ -52,114 +78,330 @@ const shortStory = (s) => (s && s.match(/S\d+$/i)?.[0]) || s || '(story)';
52
78
  // Every per-repo lane across the build, flattened with its story id attached.
53
79
  const buildLanes = (builds = []) => builds.flatMap((b) => b.repos.map((r) => ({ ...r, story: b.story })));
54
80
 
55
- // The dial note for a build lane: a machine_advance lane is driven by yad-run; everything else stops
81
+ // The dial note for a build lane: an `advance: auto` lane is driven by yad-run; everything else stops
56
82
  // for a human (the locked engineer-review always does).
57
- function dialNote(r) {
58
- if (r.locked) return c.dim('human merge gate');
59
- return r.automation === 'machine_advance'
60
- ? c.dim('machine_advance — yad-run auto-drives')
61
- : c.dim('human_approve');
83
+ //
84
+ // This is the sentence a person LEARNS the vocabulary from, so it says the new words even though the
85
+ // `--json` key beside it still says `automation` (see buildNextForRepo). Different audiences: the key
86
+ // is read by scripts and is frozen by the golden test; this line is read by people.
87
+ //
88
+ // The kill switch (E34) holds an `auto` lane at human, and the line says so: a lane that reads `auto` while
89
+ // nothing drives it would send a person to wait for a run that is never coming.
90
+ //
91
+ // The gate test is the engine's (`isGateStep`), not `locked` alone: since E34 a `locked` Build author step is
92
+ // not held at human, so calling it "a human merge gate" would say the opposite of what runs.
93
+ function dialNote(r, automation = null) {
94
+ if (isGateStep({ id: r.step, locked: r.locked })) return c.dim('human merge gate');
95
+ if (stepAdvance(r) !== 'auto') return c.dim('advance: human');
96
+ // A broken file holds every step at human too, and `yad unkill` cannot fix a file it cannot read.
97
+ if (automation?.error) return c.dim(`advance: auto — held at human: .sdlc/automation.json ${automation.error} (yad doctor)`);
98
+ return killSwitchOn(automation)
99
+ ? c.dim('advance: auto — held at human by the kill switch (yad unkill)')
100
+ : c.dim('advance: auto — yad-run auto-drives');
101
+ }
102
+
103
+ // ---- how a step's skills read on a line (E6) -----------------------------------------------------
104
+ //
105
+ // A step's skill is no longer one string the engine chose: `.sdlc/skills.json` can bind a different
106
+ // one, or several. The resolver puts the first in `skill` and, ONLY when there is more than one, the
107
+ // whole chain in `skills` — so everything here reads `skills` when it is there and falls back to the
108
+ // single field, and a project that binds nothing prints exactly what it printed before.
109
+ const skillList = (a) => a?.skills || (a?.skill ? [a.skill] : []);
110
+
111
+ // "the yad-architecture skill", or for a bound chain "the shape-it skill, then yad-stories". Closed
112
+ // decision 7: several skills on one step are a CHAIN, not a panel — they run in the order given, each
113
+ // seeing what the one before it produced, and the last output is the artifact.
114
+ function skillPhrase(a, fallback = null) {
115
+ const ids = skillList(a);
116
+ if (!ids.length) return `the ${c.bold(fallback || '(no skill)')} skill`;
117
+ const [first, ...rest] = ids;
118
+ const then = rest.length ? `, then ${rest.map((s) => c.bold(s)).join(', then ')}` : '';
119
+ return `the ${c.bold(first)} skill${then}`;
62
120
  }
63
121
 
122
+ // The cost warning decision 7 asks for. Extra skills are opt-in, and each one is another model run —
123
+ // said here, where the chain is actually about to be invoked, rather than in a document nobody reopens
124
+ // after binding. Null for the normal one-skill step, so the common case gains no noise.
125
+ const costNote = (a) => {
126
+ const n = skillList(a).length;
127
+ return n > 1 ? `${n} skills run for this step, one after another — each one costs tokens` : null;
128
+ };
129
+
64
130
  // The detailed per-story/per-repo build lanes for `printAction`. Each open lane is a 2-line block:
65
131
  // a header naming the active step + dial, then the actionable `▸` skill line with the remaining chain.
66
- function printBuildLanes(builds) {
132
+ function printBuildLanes(builds, automation = null) {
67
133
  for (const lane of buildLanes(builds)) {
68
134
  const where = `${c.cyan(lane.story || '(story)')} / ${c.bold(lane.repo)}`;
69
135
  if (lane.shipped) { log(` ${where} ${c.green('— shipped ✓')}`); continue; }
136
+ // A lane skipped whole (E39): nothing to drive, and the reason is the point of the record.
137
+ if (lane.status === 'skipped') { log(` ${where} ${c.dim(`— skipped (N/A)${lane.record?.reason ? `: ${lane.record.reason}` : ''}`)}`); continue; }
70
138
  // No resolvable next skill (an empty/half-seeded build-state file): show it as not-started, no ▸.
71
139
  if (!lane.skill) { log(` ${where} ${c.dim('— not started yet (no build steps recorded)')}`); continue; }
72
- log(` ${where} ${c.dim('—')} ${c.bold(lane.step)} (${dialNote(lane)})`);
73
- const rest = (lane.chain || []).slice(1);
140
+ log(` ${where} ${c.dim('—')} ${c.bold(lane.step)} (${dialNote(lane, automation)})`);
141
+ // `chain` is every skill still to run in this lane, the active step's own included — so drop this
142
+ // step's own off the front, or a bound chain would print its second skill again as if it came
143
+ // later. Folded through the SAME helper the chain was built with: `chain` collapses consecutive
144
+ // duplicates, so a step bound to one skill twice has two entries here and one there, and slicing
145
+ // by the raw count would swallow the next step's skill.
146
+ const rest = (lane.chain || []).slice(dedupeConsecutive(skillList(lane)).length);
74
147
  const then = rest.length ? ` ${c.dim(`then → ${rest.join(' → ')}`)}` : '';
75
- hand(`invoke the ${c.bold(lane.skill)} skill${then}`);
148
+ hand(`invoke ${skillPhrase(lane)}${then}`);
149
+ const note = costNote(lane);
150
+ if (note) info(c.dim(note));
76
151
  }
77
152
  }
78
153
 
79
154
  // A short, copy-pasteable line for one action — the `▸` line a user can act on directly.
80
- function actionLine(a, { solo } = {}) {
155
+ //
156
+ // `bindings` arrives here as well as on the action object, because two of these lines name a skill
157
+ // the RESOLVER never put on the action: the `discovery-done` hand-off to the epic step, and the Build
158
+ // fallback printed when no story has a build-state yet. Both are the engine naming a step's skill in
159
+ // prose, so both must name the project's choice — otherwise `yad next` and `yad skill list` disagree
160
+ // about the same project.
161
+ function actionLine(a, { solo, bindings = null, verified = false } = {}) {
162
+ const runs = (stepId, fallback) => stepSkills(stepId, bindings)[0] || fallback;
81
163
  switch (a.kind) {
82
164
  case 'new':
83
- return `invoke the ${c.bold(a.skill)} skill ${c.dim('(author the epic)')}`;
165
+ return `invoke ${skillPhrase(a)} ${c.dim('(author the epic)')}`;
84
166
  case 'author':
85
- return `invoke the ${c.bold(a.skill || ('yad-' + a.step))} skill ${c.dim(`(author ${a.artifact})`)}`;
167
+ return `invoke ${skillPhrase(a, `yad-${a.step}`)} ${c.dim(`(author ${a.artifact})`)}`;
86
168
  case 'review-open':
87
169
  case 'review-sync':
88
170
  return `${c.bold(a.command)}${solo ? c.dim(' (solo: no approval needed — just merge your own PR)') : ''}`;
89
171
  case 'build': {
90
- // In the build half: compact the lanes to "N lane(s) in build — next: <skill> @ <story>/<repo>".
172
+ // In Build: compact the lanes to "N lane(s) in build — next: <skill> @ <story>/<repo>".
91
173
  if (a.builds?.length) {
92
- const open = buildLanes(a.builds).filter((r) => !r.shipped);
93
- if (!open.length) return c.dim('build half — every lane shipped');
94
- // Headline the first lane with a resolvable next skill; if none, the half is started but unspecced.
95
- const first = open.find((r) => r.skill);
96
- if (!first) return c.dim(`${open.length} lane(s) in build — not specced yet`);
97
- return `${c.dim(`${open.length} lane(s) in build — next:`)} ${c.bold(first.skill)} ${c.dim(`@ ${shortStory(first.story)}/${first.repo}`)}`;
174
+ const all = buildLanes(a.builds);
175
+ const open = all.filter((r) => !r.shipped && r.status !== 'skipped');
176
+ if (open.length) {
177
+ // Headline the first lane with a resolvable next skill; if none, the half is started but unspecced.
178
+ const first = open.find((r) => r.skill);
179
+ if (!first) return c.dim(`${open.length} lane(s) in build — not specced yet`);
180
+ return `${c.dim(`${open.length} lane(s) in build — next:`)} ${c.bold(first.skill)} ${c.dim(`@ ${shortStory(first.story)}/${first.repo}`)}`;
181
+ }
182
+ if (all.some((r) => r.shipped)) return c.dim(all.some((r) => r.status === 'skipped') ? 'Build — every lane shipped or skipped' : 'Build — every lane shipped');
183
+ // Every recorded lane is SKIPPED and none shipped (E39 review): a skip written before Build began
184
+ // creates the build-state file on its own. Build has not started, so fall through to the yad-run hint.
98
185
  }
99
- return `${c.bold('yad-run')} ${c.dim('(or per story: yad-spec → yad-implement → yad ship → yad-engineer-review)')}`;
186
+ return `${c.bold('yad-run')} ${c.dim(`(or per story: ${runs('spec', 'yad-spec')} → ${runs('implement', 'yad-implement')} → yad ship → ${runs('engineer-review', 'yad-engineer-review')})`)}`;
100
187
  }
188
+ case 'foundation-done':
101
189
  case 'discovery-done':
102
- return `invoke the ${c.bold('yad-epic')} skill ${c.dim('(seed a feature epic from roadmap.md)')}`;
190
+ return `invoke the ${c.bold(runs('epic', 'yad-epic'))} skill ${c.dim('(seed the next feature from roadmap.md — `yad foundation status` shows which are started)')}`;
103
191
  case 'backfill-pending':
104
192
  return `invoke the ${c.bold('yad-backfill')} skill ${c.dim('(document the code, then `yad-backfill promote` — or thread bugs now with yad-change)')}`;
105
193
  case 'backfill-done':
106
194
  return `invoke the ${c.bold('yad-change')} skill ${c.dim('(documented anchor — evolve it by threading a change/defect off it)')}`;
195
+ // A blocked step (E38) has no command to run, and that is the point: the thing in the way is not in
196
+ // this tool. The line says who recorded the block and when, and how to clear it once the wait is
197
+ // over (E37); the reason itself is on the line above. It used to print `by` as "waiting on", but
198
+ // `by` is who WROTE the record, on every record — the `yad-run` skill writes the login the run ran as —
199
+ // so it named the wrong party.
200
+ case 'blocked': {
201
+ const who = a.record?.by ? ` — recorded by ${a.record.by}${a.record.date ? ` on ${a.record.date}` : ''}` : '';
202
+ // On a verified Product the verb refuses once the ledger is on the default branch, and CI has no step
203
+ // for it yet: naming it there would send the reader to a refusal (E34 review).
204
+ const clear = verified
205
+ ? 'on a verified Product only CI can clear it once the epic\'s ledger is on the default branch, and CI has no step for that yet (yad unblock refuses)'
206
+ : `clear it with yad unblock ${a.epicId} ${a.step} once resolved`;
207
+ return c.dim(`blocked${who} — ${clear}`);
208
+ }
107
209
  default:
108
210
  return c.dim('nothing to do');
109
211
  }
110
212
  }
111
213
 
112
214
  // Full, friendly printout for a single epic.
113
- function printAction(a, { solo } = {}) {
114
- // Prefix the id with the kind noun (Defect / Change request / Hotfix / Epic) so a glance says what
115
- // kind of work this is. The discovery front-zero is not a feature epic — leave it un-prefixed.
116
- const noun = a.lineageKind && a.epicId !== DISCOVERY_EPIC ? `${kindNoun(a.lineageKind)} ` : '';
117
- log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)} ${c.dim(`— ${a.why}`)}`);
118
- // In the build half with live lanes, print each story/repo's next sub-step + remaining chain instead
215
+ //
216
+ // The grouping theme arrives as an argument rather than as another field on the action object:
217
+ // `printAction` renders `a` and `--json` emits the SAME `a` verbatim, and that JSON is deep-equalled
218
+ // by the golden test, which an added key breaks (see actionFor). It comes from `rowFor`, off the same
219
+ // `epic.md` read the action's own `lineageKind` came from.
220
+ function printAction(a, { solo, theme: tag = null, bindings = null, automation = null, verified = false } = {}) {
221
+ // Prefix the id with the type noun (Defect / Change request / Hotfix / Chore / Epic) so a glance
222
+ // says what kind of work this is. The product level is not a feature — leave it un-prefixed.
223
+ const noun = a.lineageKind && !PRODUCT_EPICS.includes(a.epicId) ? `${typeNoun(a.lineageKind)} ` : '';
224
+ // The free grouping tag, printed only when the epic has one — most do not, and an empty marker on
225
+ // every line would cost more attention than it pays back.
226
+ const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
227
+ log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)}${theme} ${c.dim(`— ${a.why}`)}`);
228
+ // In Build with live lanes, print each story/repo's next sub-step + remaining chain instead
119
229
  // of the single static hint; otherwise the one actionable line.
120
- if (a.kind === 'build' && a.builds?.length) printBuildLanes(a.builds);
121
- else hand(actionLine(a, { solo }));
122
- if (a.kind === 'review-sync') info(c.dim(`unresolved comments? ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`));
123
- if (a.parallel) hand(`parallel track: invoke the ${c.bold(a.parallel.skill)} skill ${c.dim(`(author ${a.parallel.artifact})`)}`);
230
+ if (a.kind === 'build' && a.builds?.length) {
231
+ printBuildLanes(a.builds, automation);
232
+ // Every lane printed is a skip and nothing has started (E39 review): the lanes alone name no command,
233
+ // so the one actionable line is printed too.
234
+ const lanes = buildLanes(a.builds);
235
+ if (lanes.length && lanes.every((r) => r.status === 'skipped')) hand(actionLine(a, { solo, bindings, verified }));
236
+ } else {
237
+ hand(actionLine(a, { solo, bindings, verified }));
238
+ const note = costNote(a);
239
+ if (note) info(c.dim(note));
240
+ // A Shape author step the team set to `auto` (E34). Recorded, not acted on: say so, or the line reads
241
+ // as a promise that something will run.
242
+ if (a.kind === 'author' && automation?.steps?.[a.step] === 'auto') {
243
+ info(c.dim(killSwitchOn(automation)
244
+ ? 'advance: auto — held at human by the kill switch (yad unkill)'
245
+ : 'advance: auto — recorded; nothing drives a Shape step on its own yet'));
246
+ }
247
+ }
248
+ // Not `c.dim(... c.bold(...) ...)`: `paint` closes with a full reset, so a bold word inside a dim
249
+ // string ends the dim and everything after it reads bright. Painted per segment instead.
250
+ if (a.kind === 'review-sync') info(`${c.dim('unresolved comments?')} ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`);
251
+ if (a.parallel) {
252
+ hand(`parallel track: invoke ${skillPhrase(a.parallel)} ${c.dim(`(author ${a.parallel.artifact})`)}`);
253
+ const note = costNote(a.parallel);
254
+ if (note) info(c.dim(note));
255
+ }
256
+ // A step re-opened behind finished work (E41) is a lane of its own beside the chain, like the
257
+ // test-cases track above: it gets its own line, with the same words a chain step would get.
258
+ for (const lane of a.reopened || []) hand(`re-opened lane: ${actionLine({ ...lane, epicId: a.epicId }, { solo, bindings, verified })}`);
259
+ // DEBT is reminded on every run until it is paid (E41). The line says what is owed and the one command
260
+ // that starts paying it — or, once that has started, what finishes it.
261
+ for (const d of a.debt || []) warn(debtLine(a.epicId, d, verified));
262
+ phaseLine(a);
263
+ }
264
+
265
+ // One reminder line for a step owed as debt (E41).
266
+ function debtLine(epicId, d, verified = false) {
267
+ const author = d.step.replace(/-review$/, '');
268
+ if (d.status === 'deferred') {
269
+ const r = d.record || {};
270
+ const when = [r.by ? `by ${r.by}` : '', r.date ? `on ${r.date}` : ''].filter(Boolean).join(' ');
271
+ return `owed (debt): ${author} — deferred${when ? ` ${when}` : ''}${r.reason ? `: ${r.reason}` : ''} — pay it back with yad undefer ${epicId} ${author}${verified ? ' (on a verified Product it is refused once the epic\'s ledger is on the default branch, until CI has a step for it)' : ''}`;
272
+ }
273
+ // Put back, but waiting behind an earlier step that has not passed: say so, rather than "being paid
274
+ // back" about a step nobody can work on yet.
275
+ if (d.status === 'todo') return `owed (debt): ${author} — back in the chain, waiting for an earlier step; the debt clears when ${author}-review passes`;
276
+ return `owed (debt): ${author} — being paid back (${d.step} is ${d.status}); the debt clears when ${author}-review passes`;
277
+ }
278
+
279
+ // Where this epic sits in the lifecycle: the six phases, with the current one marked.
280
+ //
281
+ // `phaseOf` is given the same two facts every renderer needs — the epic's current step and whether it
282
+ // is the product level — so `yad next` and `yad thread` cannot answer this differently. It returns
283
+ // null for a stub and for any step id this release does not recognise, and this line then stays away
284
+ // entirely: a wrong phase is worse than no phase.
285
+ //
286
+ // The two unbuilt phases are shown, greyed, rather than hidden. Someone reading this needs to see
287
+ // that the lifecycle does not stop at merge; a list that ended at Build would say it does.
288
+ //
289
+ // THE PRODUCT LEVEL GETS ITS OWN LINE (E75), not the six-phase strip. The Foundation runs once per
290
+ // product and never walks Design, Plan or Build, so printing those six with nothing marked would claim
291
+ // a journey it does not take — which is why this line used to be absent for `EP-discovery` altogether.
292
+ // It now has a phase of its own, on a ladder of its own, and says so in words.
293
+ function phaseLine(a) {
294
+ const here = phaseOf(a.step, { product: PRODUCT_EPICS.includes(a.epicId) });
295
+ if (!here) return;
296
+ if (here.level === 'product') {
297
+ info(`${c.dim('phase:')} ${c.bold(here.name)} ${c.dim('— the Product level, once per product, before any feature epic')}`);
298
+ return;
299
+ }
300
+ // A phase this epic's ROUTE never enters is marked, not hidden (E40). A `chore` epic has no
301
+ // architecture or UI-design step, so it never sees Design — and printing Design exactly as a
302
+ // `classic` epic prints it makes a lane that will never go there look like one that has already
303
+ // been. Marked rather than dropped for the same reason the two unbuilt phases are: a person reading
304
+ // this needs the whole shape, with their own position in it, not an edited version of the lifecycle.
305
+ // Build is never marked: it is where every feature route ends up, and the Build steps live per story
306
+ // in `build-state/` rather than in this chain, so their absence here means nothing.
307
+ const onRoute = a.route ? profileSteps(a.route) : null;
308
+ const covered = onRoute?.length
309
+ ? new Set(PHASES.filter((p) => p.id === 'build'
310
+ || onRoute.some((id) => stepPhase(id) === p.id)).map((p) => p.id))
311
+ : null;
312
+ const rendered = PHASES.map((p) => {
313
+ if (p.id === here.id) return c.bold(p.name);
314
+ if (!p.built) return c.dim(`${p.name} (planned)`);
315
+ if (covered && !covered.has(p.id)) return c.dim(`${p.name} (not on this route)`);
316
+ return c.dim(p.name);
317
+ });
318
+ // Coloured PER SEGMENT, never nested: `paint` closes with a full reset (\x1b[0m), so a bold word
319
+ // inside a dim string ends the dim for everything after it and the rest of the line reads bright.
320
+ // The current phase is named in WORDS as well as marked in bold, because bold is not always there:
321
+ // `useColor` is off whenever output is not a terminal or NO_COLOR is set, which is every pipe, every
322
+ // log file and every CI job. Without this the line would list six phases and give no way to tell
323
+ // which one you are in — the one thing it exists to say. The part is added only when it is a
324
+ // different word; "now: Build (Build part)" tells nobody anything.
325
+ const part = here.name === here.part ? '' : ` (${here.part} part)`;
326
+ info(`${c.dim('phase:')} ${rendered.join(c.dim(' · '))} ${c.dim(`— now: ${here.name}${part}`)}`);
124
327
  }
125
328
 
126
329
  // `yad next` with no epic: orient across the whole project, always ending on ONE thing to do.
127
- function generalNext(root, { all } = {}) {
330
+ function generalNext(root, { all, headCount = null } = {}) {
128
331
  if (!isSetUp(root)) {
129
332
  log(`\n ${c.bold('Project not set up yet.')}`);
130
333
  hand(`run ${c.bold('yad setup')} ${c.dim('(then come back to `yad next`)')}`);
131
334
  return;
132
335
  }
133
- const solo = isSolo(root);
134
- const brownfield = profileOf(root)?.codebase === 'brownfield';
135
- // The project front-zero (EP-discovery / "epic zero") is not a feature epic — split it out so it is
136
- // surfaced on its own line and never mixed into the feature-epic roll-up.
336
+ // The Product config, read ONCE for this view.
337
+ const hub = readJSON(productConfigPath(root), null);
338
+ const solo = soloOf(hub);
339
+ // E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
340
+ // Printed first, so it is not lost under a long list of epics. An unknown count is not said here —
341
+ // `yad doctor` and `yad mode` say it (the user's choice, 2026-09-22). `headCount` is for tests.
342
+ printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
343
+ // `profile` here is the SETUP profile `yad setup` records (codebase / repo_layout / team_size), not the
344
+ // lifecycle profile an epic walks (`LIFECYCLE_PROFILES`, E5): two things share the word.
345
+ const brownfield = hub?.profile?.codebase === 'brownfield';
346
+ // The PRODUCT level (the Foundation, or a ledger still in its old `discovery` spelling) is not a
347
+ // feature epic — split it out so it is surfaced on its own line and never mixed into the roll-up.
137
348
  const allEpics = listEpics(root);
138
- const hasDiscovery = allEpics.includes(DISCOVERY_EPIC);
139
- const featureEpics = allEpics.filter((id) => id !== DISCOVERY_EPIC);
140
- const discoveryAction = hasDiscovery ? actionFor(root, DISCOVERY_EPIC) : null;
141
- const discoveryOpen = !!discoveryAction && discoveryAction.kind !== 'discovery-done';
349
+ // One read of `.sdlc/skills.json` for the whole roll-up, not one per epic.
350
+ const bindings = loadSkillBindings(root);
351
+ const automation = loadAutomation(root);
352
+ const verified = isVerifiedLedger(hub);
353
+ // The Foundation wins when both exist. Two product levels is a fault `yad doctor` fails on; until it
354
+ // is fixed the new spelling is the one worth acting on, and the old one is named so it is not lost.
355
+ const productIds = PRODUCT_EPICS.filter((id) => allEpics.includes(id));
356
+ const productId = productIds[0] || null;
357
+ const featureEpics = allEpics.filter((id) => !PRODUCT_EPICS.includes(id));
358
+ const productRow = productId ? rowFor(root, productId, bindings) : null;
359
+ const productOpen = !!productRow && !PRODUCT_DONE.includes(productRow.action.kind);
360
+ if (productIds.length > 1) {
361
+ warn(`two product levels: ${productIds.map((id) => epicRel(id)).join(' and ')} — ${productId} is the one used`);
362
+ hand(`run ${c.bold('yad doctor')} ${c.dim('(a product has one Foundation; `yad migrate` converts the old one when it can)')}`);
363
+ }
142
364
 
143
365
  if (!featureEpics.length) {
144
- if (discoveryOpen) { printAction(discoveryAction, { solo }); return; }
366
+ if (productOpen) { printAction(productRow.action, { solo, bindings, automation, verified, ...productRow }); return; }
145
367
  log(`\n ${c.bold('Set up — no feature epics yet.')}`);
146
368
  if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
147
- if (!hasDiscovery) hand(`frame the whole project (market, feasibility, roadmap): invoke the ${c.bold('yad-discovery')} skill ${c.dim('(optional front-zero)')}`);
148
- hand(`start your first epic: invoke the ${c.bold('yad-epic')} skill${hasDiscovery ? c.dim(' (it reads the approved roadmap.md)') : ''}`);
369
+ // Both name a STEP's skill, so both ask the project. `yad-backfill` above does not: waking a
370
+ // brownfield anchor is the engine's own promote verb, not a step on any chain.
371
+ if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindings)[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
372
+ hand(`start your first epic: invoke the ${c.bold(stepSkills('epic', bindings)[0] || 'yad-epic')} skill${productId ? c.dim(' (it reads the approved roadmap.md)') : ''}`);
149
373
  return;
150
374
  }
151
375
 
152
- const actions = featureEpics.map((id) => actionFor(root, id));
153
- if (discoveryOpen) printAction(discoveryAction, { solo }); // an unfinished discovery comes first
376
+ const rows = featureEpics.map((id) => rowFor(root, id, bindings));
377
+ if (productOpen) {
378
+ printAction(productRow.action, { solo, bindings, automation, verified, ...productRow }); // an unfinished Foundation comes first
379
+ // REPORTED, NOT ENFORCED (E75). The roadmap puts one approval on the Foundation "before feature
380
+ // work begins", and the decision for this release is to say so rather than to block on it: a
381
+ // project that began its epics first — every project older than this release — must keep working
382
+ // (rule 5), and a hard stop here would be the first thing that ever refused to show an epic.
383
+ info(c.dim(`${productId} has not passed its gate — ${featureEpics.length} feature epic(s) are going ahead of it (reported, not enforced)`));
384
+ }
154
385
 
155
386
  if (featureEpics.length === 1 || all) {
156
- for (const a of actions) printAction(a, { solo });
387
+ for (const r of rows) printAction(r.action, { solo, bindings, automation, verified, ...r });
157
388
  return;
158
389
  }
159
390
  // Several epics — list each with a one-liner, then point at the per-epic / --all views.
160
391
  log(`\n ${c.bold(`${featureEpics.length} epics`)} ${c.dim('— next action each:')}`);
161
- for (const a of actions) log(` ${c.cyan(`${kindNoun(a.lineageKind)} ${a.epicId}`)} ${actionLine(a, { solo })}`);
162
- info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
392
+ // The grouping theme rides this list too. This is the ONE `yad next` view that shows several epics
393
+ // side by side, so it is where seeing which of them belong together is worth most — and leaving it
394
+ // off would have meant bare `yad next`, the command people run by default, never showed the tag.
395
+ for (const { action: a, theme: tag } of rows) {
396
+ const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
397
+ // Debt is reminded here too (E41): the roll-up is the view people run most, and a reminder that only
398
+ // appears once you already look at the one epic is not a reminder.
399
+ const owed = a.debt?.length ? ` ${c.yellow(`· ${a.debt.length} owed as debt`)}` : '';
400
+ log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, verified })}${owed}`);
401
+ }
402
+ // Painted per segment, not dim-wrapping a bold: `paint` closes with a full reset, so the nested
403
+ // form loses the dim from the first bold word to the end of the line.
404
+ info(`${c.dim('detail:')} ${c.bold('yad next <epic>')} ${c.dim('• all at once:')} ${c.bold('yad next --all')}`);
163
405
  }
164
406
 
165
407
  // `yad next <epic> --check <step>`: the precondition guard. Exit 0 if runnable now, 1 otherwise.
@@ -178,20 +420,30 @@ function checkPrecondition(root, epic, stepId) {
178
420
  // ---- machine-readable output (`--json`) --------------------------------------------------------
179
421
  // `nextAction` already computes exactly what a caller needs; until now the ANSI prose renderer was
180
422
  // its only consumer, so anything driving yadflow had to regex coloured English. This emits the SAME
181
- // objects, unrendered. One envelope for every route, so a caller never has to branch on the shape:
423
+ // objects, unrendered, inside E1's envelope (`jsonVersion`, `version`, `command`, `ok`, `warnings`).
424
+ // One shape for every route, so a caller never has to branch on it:
182
425
  //
183
- // { version, ok: true, actions: [ <action>, … ] } next / next <epic>
184
- // { version, ok, check: { epic, step, ok, reason } } next <epic> --check <step>
185
- // { version, ok: true, setUp: false, actions: [] } the project has no `yad setup` yet
186
- // { version, ok: false, error } bad epic id / no state.json
426
+ // { …, ok: true, actions: [ <action>, … ] } next / next <epic>
427
+ // { …, ok, check: { epic, step, ok, reason } } next <epic> --check <step>
428
+ // { …, ok: true, setUp: false, actions: [] } the project has no `yad setup` yet
429
+ // { …, ok: false, error, code, hint } bad epic id / no state.json
187
430
  //
188
431
  // Exit codes are unchanged from the prose path — only the rendering differs.
189
- const emitJSON = (payload) => log(JSON.stringify({ version: VERSION, ...payload }, null, 2));
432
+ // `route` is a RENDERING input, not part of the answer: `phaseLine` uses it to mark a phase this
433
+ // epic's route never enters, and a script reading `yad next --json` gets the route from `state.json`'s
434
+ // own `profile` key, which is the record. Stripped here rather than
435
+ // threaded past the JSON path, because one strip is easier to keep right than three call sites — and
436
+ // `--json` is a machine contract, so a field added by accident is a field somebody starts depending on.
437
+ const forJSON = (a) => Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'route'));
438
+ const answer = (payload) => emitJSON({
439
+ ...payload,
440
+ ...(payload.actions ? { actions: payload.actions.map(forJSON) } : {}),
441
+ });
190
442
 
191
443
  // A JSON error still leaves stdout parseable: a caller that pipes us into a parser gets an object
192
444
  // explaining the failure, never half a document or a bare ANSI line.
193
445
  function jsonError(message) {
194
- emitJSON({ ok: false, error: message });
446
+ answer({ ok: false, error: message });
195
447
  process.exitCode = 1;
196
448
  }
197
449
 
@@ -200,26 +452,27 @@ function jsonError(message) {
200
452
  function jsonNext(root, { epic, check }) {
201
453
  if (epic && check) {
202
454
  const res = preconditionsMet(loadLedger(epicRoot(root, epic)).state, check);
203
- emitJSON({ ok: !!res.ok, check: { epic, step: check, ok: !!res.ok, ...(res.reason ? { reason: res.reason } : {}) } });
455
+ answer({ ok: !!res.ok, check: { epic, step: check, ok: !!res.ok, ...(res.reason ? { reason: res.reason } : {}) } });
204
456
  if (!res.ok) process.exitCode = 1;
205
457
  return;
206
458
  }
207
459
  if (epic) {
208
460
  if (!exists(path.join(epicRoot(root, epic), '.sdlc', 'state.json'))) {
209
- return jsonError(`no epic state at epics/${epic}/.sdlc/state.json`);
461
+ return jsonError(`no epic state at ${epicRel(epic)}/.sdlc/state.json`);
210
462
  }
211
- return emitJSON({ ok: true, actions: [actionFor(root, epic)] });
463
+ return answer({ ok: true, actions: [actionFor(root, epic)] });
212
464
  }
213
- if (!isSetUp(root)) return emitJSON({ ok: true, setUp: false, actions: [] });
214
- // Every epic that HAS a ledger, discovery included — its `kind` already says whether it is open
465
+ if (!isSetUp(root)) return answer({ ok: true, setUp: false, actions: [] });
466
+ // Every epic that HAS a ledger, discovery included — its ACTION kind already says whether it is open
215
467
  // (`discovery-*`) or finished, so filtering it out would hide a fact rather than clarify one.
216
468
  // `--all` is implied: an array always carries everything, so there is nothing left to expand.
217
- return emitJSON({ ok: true, actions: listEpics(root).map((id) => actionFor(root, id)) });
469
+ const bindings = loadSkillBindings(root);
470
+ return answer({ ok: true, actions: listEpics(root).map((id) => actionFor(root, id, undefined, bindings)) });
218
471
  }
219
472
 
220
473
  // Entry point for the `next` command: route to the precondition check, a single epic's action, or the
221
474
  // project-wide general view. Validates the epic id first.
222
- export async function runNext(root, { epic, check, all, json } = {}) {
475
+ export async function runNext(root, { epic, check, all, json, headCount = null } = {}) {
223
476
  if (epic && !isValidEpicId(epic)) {
224
477
  const message = `invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`;
225
478
  if (json) return jsonError(message);
@@ -229,7 +482,7 @@ export async function runNext(root, { epic, check, all, json } = {}) {
229
482
  }
230
483
  if (json) return jsonNext(root, { epic, check });
231
484
  if (epic && check) return checkPrecondition(root, epic, check);
232
- if (!epic) return generalNext(root, { all });
485
+ if (!epic) return generalNext(root, { all, headCount });
233
486
 
234
487
  const epicDir = epicRoot(root, epic);
235
488
  if (!exists(path.join(epicDir, '.sdlc', 'state.json'))) {
@@ -238,5 +491,11 @@ export async function runNext(root, { epic, check, all, json } = {}) {
238
491
  process.exitCode = 1;
239
492
  return;
240
493
  }
241
- printAction(actionFor(root, epic), { solo: isSolo(root) });
494
+ // One read, two readers: the resolver puts the bound skill on the action, and the renderer names it
495
+ // in the two prose lines no action carries.
496
+ const bindings = loadSkillBindings(root);
497
+ const automation = loadAutomation(root);
498
+ const verified = isVerifiedLedger(readJSON(productConfigPath(root), null));
499
+ const row = rowFor(root, epic, bindings);
500
+ printAction(row.action, { solo: isSolo(root), bindings, automation, verified, ...row });
242
501
  }