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/ship.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `yad ship` — commit the staged atomic change AND open its task PR/MR, in one step (build half).
1
+ // `yad ship` — commit the staged atomic change AND open its task PR/MR, in one step (Build).
2
2
  // A thin orchestration over the two existing engines: `yad commit` then `yad open-pr`. It holds no
3
3
  // commit/PR logic of its own — it reuses runCommit/runOpenPr so the conventions stay in one place.
4
4
  // The PR step runs ONLY when the commit actually lands: a failed commit, a tripped atomic guard, or a
@@ -17,14 +17,14 @@ export async function runShip(root, opts = {}) {
17
17
  contractChange: opts.contractChange, dryRun: opts.dryRun, force: opts.force,
18
18
  });
19
19
 
20
- if (opts.dryRun) { info('dry run — not committed, PR/MR not opened'); return committed; }
20
+ if (opts.dryRun) { info('dry run — not committed, PR/MR not opened'); return { ...committed, pr: null }; }
21
21
 
22
22
  // runCommit signals failure by setting process.exitCode (not by throwing) — honour it and abort the
23
23
  // PR step so we never open a PR for a branch whose commit did not land.
24
- if (process.exitCode) { info('commit did not land — skipping open-pr'); return committed; }
24
+ if (process.exitCode) { info('commit did not land — skipping open-pr'); return committed && { ...committed, pr: null }; }
25
25
 
26
- // Step 2 — open the task PR/MR from the committed template (pushes the branch, auto-assigns the
27
- // repo-scoped roster). Pass ONLY an explicit --title: when omitted, runOpenPr derives the title
26
+ // Step 2 — open the task PR/MR from the committed template (pushes the branch, assigns the committer,
27
+ // requests no reviewers — E62). Pass ONLY an explicit --title: when omitted, runOpenPr derives the title
28
28
  // from the committed subject (the full `<type>: …` form), which the pr-title gate expects — passing
29
29
  // the bare --message here would override that with a type-less title and fail the gate.
30
30
  const opened = await runOpenPr(root, {
@@ -33,5 +33,6 @@ export async function runShip(root, opts = {}) {
33
33
  risk: opts.risk, contractChange: opts.contractChange,
34
34
  });
35
35
 
36
- return { ...committed, ...opened };
36
+ // The commit's keys, and the PR open-pr opened (or null when it opened none).
37
+ return { ...committed, pr: opened ?? null };
37
38
  }
package/cli/skill.mjs ADDED
@@ -0,0 +1,180 @@
1
+ // `yad skill` — which skill runs which step (E6).
2
+ //
3
+ // The engine ships a default for every step (the `skill` column of the step catalogue in
4
+ // epic-state.mjs). A project that wants a different one records it in `.sdlc/skills.json`, and every
5
+ // surface that names a skill — `yad next`, `yad epic new` — reads the project's answer first.
6
+ //
7
+ // yad skill list [--json] every step, its bound skill(s), and where that came from
8
+ // yad skill bind <step> <skill> [<skill>…] bind one step; several skills run in the order given
9
+ // yad skill unbind <step> drop the binding and fall back to the engine's default
10
+ //
11
+ // WHY A COMMAND AND NOT JUST A FILE. The file stays hand-editable — it is read by
12
+ // `loadSkillBindings`, and `yad doctor` reports a line that binds nothing rather than correcting it.
13
+ // But a file the engine never writes is a file with no `schemaVersion` in it, and `yad doctor` would
14
+ // then tell the user to migrate the config file it had just asked them to write. Writing it through
15
+ // `writeJSON` stamps the shape like every other engine-written file, and gives the cost warning a
16
+ // place to appear at the moment somebody opts into a chain.
17
+ import path from 'node:path';
18
+ import { c, exists, fail, hand, info, log, ok, readJSONStrict, warn, writeJSON, emitJSON } from './lib.mjs';
19
+ import { loadSkillBindings, stepDef, stepSkills, STEPS } from './epic-state.mjs';
20
+ import { PROJECT_FILES, SCHEMA_VERSION } from './manifest.mjs';
21
+
22
+ const bail = (message, hint) => { fail(message); if (hint) hand(hint); process.exitCode = 1; };
23
+
24
+ // Every step the engine actually runs a skill for — the ones worth binding. Shape review gates are
25
+ // driven by `yad gate` and are deliberately not listed: offering to bind one would promise something
26
+ // that never runs.
27
+ const bindableSteps = () => STEPS.filter((s) => s.skill).map((s) => s.id);
28
+
29
+ const skillsFile = (root) => path.join(root, PROJECT_FILES.skillsConfig);
30
+
31
+ // Read the file as it is on disk, so a write preserves keys this release does not know about (E50 and
32
+ // E51 add some). `normalizeBindings` is for READING a binding; this is for editing the document.
33
+ //
34
+ // STRICT, unlike the resolver. `loadSkillBindings` treats a broken file as an empty one because
35
+ // `yad next` must still answer; this is a read-modify-write, and doing the same here would rebuild
36
+ // the document from nothing and delete every binding the file held — silently, with a green tick, on
37
+ // the file the docs tell people to hand-edit. Returns null when the bytes do not parse; the caller
38
+ // refuses rather than writing.
39
+ // Returns `{ doc }`, or `{ error }` naming which of the two failures it is. The two are reported with
40
+ // different codes by `yad doctor` on the same bytes, and saying "does not parse" about a file that
41
+ // parses perfectly and is simply a JSON array sends the reader looking for a missing comma.
42
+ const readRaw = (root) => {
43
+ const file = skillsFile(root);
44
+ if (!exists(file)) return { doc: {} };
45
+ let raw;
46
+ try { raw = readJSONStrict(file, null); } catch { return { error: 'does not parse [YAD-STATE-001]' }; }
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'has the wrong shape [YAD-STATE-002]' };
48
+ return { doc: raw };
49
+ };
50
+
51
+ // An existing document plus this edit, ready to write.
52
+ //
53
+ // `schemaVersion` is set OUTRIGHT rather than carried through. `readJSON` back-stamps an unstamped
54
+ // file as shape 1 (rule 1: a file with no version IS shape 1), and `writeJSON` then preserves what it
55
+ // was handed — so writing the document back unchanged would stamp a hand-authored file as shape 1 and
56
+ // `yad doctor` would immediately report it as behind. This command is the file's engine writer; like
57
+ // `writeState`, its write IS the file's migration.
58
+ const withSteps = (raw, steps) => ({ ...raw, schemaVersion: SCHEMA_VERSION, steps });
59
+
60
+ const brokenFile = (error) => bail(`${PROJECT_FILES.skillsConfig} ${error}`,
61
+ 'fix the file (or restore it from git) first — writing over it would delete every binding it holds');
62
+
63
+ // A step id has the shape every catalogue id has. An id this release does not KNOW is allowed through
64
+ // with a warning (the file wins, rule 3), so this is not an allowlist — it is a shape guard, and the
65
+ // one thing it has to stop is `__proto__`: assigning that key to the document sets the object's
66
+ // prototype instead of adding a line, `JSON.stringify` then drops it, and the command would report a
67
+ // binding it did not write. `yad epic new` guards its slug the same way.
68
+ const STEP_ID = /^[a-z][a-z0-9-]*$/;
69
+
70
+ // One row per step the engine can run a skill for: what runs it now, and whether that is the project's
71
+ // choice or the engine's. `source` is the field worth having — "yad-stories" alone never says whether
72
+ // someone chose it.
73
+ // `written` is the set of step ids the FILE mentions, whether or not the value was usable. It is what
74
+ // separates "this project left the step alone" from "this project wrote a line here and the line does
75
+ // nothing" — the second is invisible otherwise, which is the failure `yad skill list` exists to end.
76
+ export function skillRows(root, bindings = loadSkillBindings(root), written = null) {
77
+ return bindableSteps().map((id) => {
78
+ const bound = Object.hasOwn(bindings.steps, id) ? bindings.steps[id] : null;
79
+ return {
80
+ step: id,
81
+ phase: stepDef(id).phase,
82
+ skills: stepSkills(id, bindings),
83
+ source: bound?.length ? 'project' : (written?.has(id) ? 'ignored' : 'engine'),
84
+ default: stepDef(id).skill,
85
+ };
86
+ });
87
+ }
88
+
89
+ export function runSkillList(root, { json = false } = {}) {
90
+ const bindings = loadSkillBindings(root);
91
+ const { doc, error } = readRaw(root);
92
+ const written = new Set(error ? [] : Object.keys(doc.steps && typeof doc.steps === 'object' ? doc.steps : {}));
93
+ const rows = skillRows(root, bindings, written);
94
+ // Bindings on ids the catalogue does not know are listed too, and marked. They are the ones a person
95
+ // most needs to see: `yad next` never looks them up, so without this line they are invisible.
96
+ const extra = [...written]
97
+ .filter((id) => !stepDef(id))
98
+ .map((step) => ({
99
+ step, phase: null,
100
+ skills: stepSkills(step, bindings),
101
+ source: Object.hasOwn(bindings.steps, step) ? 'project' : 'ignored',
102
+ default: null,
103
+ }));
104
+
105
+ if (json) return emitJSON({ ok: true, file: PROJECT_FILES.skillsConfig, steps: [...rows, ...extra] });
106
+
107
+ if (error) warn(`${PROJECT_FILES.skillsConfig} ${error} — showing the engine's defaults`);
108
+ log(`\n ${c.bold('step')} ${c.bold('skill(s)')}`);
109
+ for (const r of [...rows, ...extra]) {
110
+ const mark = r.source === 'project' ? c.cyan('•') : (r.source === 'ignored' ? c.red('!') : ' ');
111
+ const notes = [];
112
+ if (r.phase === null) notes.push('not a step this yadflow runs');
113
+ if (r.source === 'ignored') notes.push('the file has a line for this step that names no skill');
114
+ const note = notes.length ? c.dim(` (${notes.join('; ')})`) : '';
115
+ log(` ${mark} ${r.step.padEnd(18)} ${r.skills.join(c.dim(' → ')) || c.dim('(none)')}${note}`);
116
+ }
117
+ const all = [...rows, ...extra];
118
+ const chosen = all.filter((r) => r.source === 'project').length;
119
+ const ignored = all.filter((r) => r.source === 'ignored').length;
120
+ info(chosen
121
+ ? `${c.cyan('•')} = bound by this project in ${PROJECT_FILES.skillsConfig}; the rest are the engine's defaults`
122
+ : `every step is on the engine's default — bind one with \`yad skill bind <step> <skill>\``);
123
+ if (ignored) info(`${c.red('!')} = a line in that file that binds nothing — \`yad doctor\` says which`);
124
+ }
125
+
126
+ export function runSkillBind(root, { step, skills = [] } = {}) {
127
+ const names = skills.map((s) => String(s || '').trim()).filter(Boolean);
128
+ if (!step || !names.length) {
129
+ return bail('usage: yad skill bind <step> <skill> [<skill> …]',
130
+ `bindable steps: ${bindableSteps().join(' · ')}`);
131
+ }
132
+ if (!STEP_ID.test(step)) {
133
+ return bail(`\`${step}\` is not a step id`, 'a step id is lower-case letters, digits and dashes — for example `architecture` or `ui-design`');
134
+ }
135
+ const def = stepDef(step);
136
+ // A review gate is refused rather than warned about: nothing would ever invoke the binding, so
137
+ // writing it would record a decision that silently never happens.
138
+ if (def && !def.skill) {
139
+ return bail(`\`${step}\` is a review gate — no skill runs it`,
140
+ `it is driven by \`yad gate open\` / \`yad gate sync\`. Bind the step it reviews instead${def.reviews ? `: \`${def.reviews}\`` : ''}`);
141
+ }
142
+ // An UNKNOWN id is allowed through with a warning, not refused. The file wins for this whole major
143
+ // (rule 3), and a project may legitimately hold a step from a newer release than the CLI in hand.
144
+ const { doc, error } = readRaw(root);
145
+ if (error) return brokenFile(error);
146
+ const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
147
+ steps[step] = names.length === 1 ? names[0] : names;
148
+ writeJSON(skillsFile(root), withSteps(doc, steps));
149
+
150
+ ok(`${step} → ${names.join(' → ')}`);
151
+ if (!def) {
152
+ info(`\`${step}\` is not a step this yadflow runs — the binding is recorded but nothing will invoke it`);
153
+ }
154
+ // Closed decision 7: extra skills are a chain, never a panel, and every extra one costs another run.
155
+ if (names.length > 1) {
156
+ info(`${names.length} skills run for this step, one after another — each one costs tokens`);
157
+ info('they chain: each sees what the one before it produced, and the last output is the artifact');
158
+ }
159
+ hand(`written to ${PROJECT_FILES.skillsConfig} — \`yad next\` names it from now on (undo with \`yad skill unbind ${step}\`)`);
160
+ return { step, skills: names, known: !!def, file: PROJECT_FILES.skillsConfig };
161
+ }
162
+
163
+ export function runSkillUnbind(root, { step } = {}) {
164
+ if (!step) return bail('usage: yad skill unbind <step>');
165
+ const { doc, error } = readRaw(root);
166
+ if (error) return brokenFile(error);
167
+ const steps = doc.steps && typeof doc.steps === 'object' && !Array.isArray(doc.steps) ? { ...doc.steps } : {};
168
+ if (!(step in steps)) {
169
+ return bail(`${step} is not bound in ${PROJECT_FILES.skillsConfig}`, 'see `yad skill list` for what is bound');
170
+ }
171
+ delete steps[step];
172
+ // The file is left behind, holding an empty `steps`, rather than deleted. Deleting a file the user
173
+ // may have hand-authored — with comments-by-convention, or keys a later release reads — to undo one
174
+ // line would throw away more than was asked for.
175
+ writeJSON(skillsFile(root), withSteps(doc, steps));
176
+ // What runs it now: the engine's default, or nothing at all if this engine does not know the step.
177
+ const fallback = stepSkills(step, null);
178
+ ok(`${step} unbound${fallback.length ? ` — back to the engine's default (${fallback.join(' → ')})` : ' — this yadflow runs no skill for it'}`);
179
+ return { step, skills: fallback, file: PROJECT_FILES.skillsConfig };
180
+ }
package/cli/skip.mjs CHANGED
@@ -1,45 +1,226 @@
1
- // `yad skip <epic> <step> --reason "<why>"` (and `--undo`) — mark an OPTIONAL front step N/A for one
2
- // epic. Today only `ui-design` is skippable: an epic with no user-facing surface (backend/API, data,
3
- // infra) does not need a UI-design artifact + review gate. The skip stays VISIBLE and auditable — the
4
- // step is pre-marked `done` with a recorded reason (and actor/date), short-circuited at the gate — and
5
- // is reversible with `--undo` until the stories review opens. All state logic is the pure
6
- // `skipStep`/`unskipStep` in epic-state.mjs; this is the thin file-load/save + attribution wrapper.
7
- import { ok, info, hand, fail, run, writeJSON } from './lib.mjs';
8
- import { epicRoot, loadLedger, skipStep, unskipStep } from './epic-state.mjs';
9
- import { loadHub } from './gate.mjs';
10
- import { resolveCommitterLogin } from './platform.mjs';
11
-
12
- // Best-effort auditable actor for `skippedBy`: the roster login for the local git identity, else the
13
- // raw git user.name, else null. A malformed/absent hub degrades to the raw name — attribution is a
14
- // nicety on the audit trail, never a gate, so it must not block the skip.
15
- function skipActor(root) {
16
- let roster = [];
17
- try { roster = loadHub(root)?.hub?.roster || []; } catch { /* no hub / malformed — attribute by raw git name */ }
18
- return resolveCommitterLogin(root, roster)
19
- || (run('git', ['config', 'user.name'], { cwd: root }).stdout || '').trim()
20
- || null;
1
+ // `yad skip <epic> <step> --reason "<why>"` / `yad unskip <epic> <step>` (E35, E36) and
2
+ // `yad defer <epic> <step> --reason "<why>"` / `yad undefer <epic> <step>` (E37) — set an OPTIONAL Shape
3
+ // step aside for one epic, or put it back. A skip says the step does not apply; a deferral says it does,
4
+ // later. `yad skip … --undo` and `yad defer … --undo` are the same as the undo verbs. `yad defer --debt`
5
+ // (E41) marks the deferral owed back, and `yad undefer` re-opens a deferral even after later work finished.
6
+ // Which steps qualify is a fact about the epic's ROUTE, read from the lifecycle profile it is on (E35,
7
+ // `optionalStepsFor`), not a list this engine keeps: on `classic` and `analysis-first` that is
8
+ // `ui-design` and its gate. E40's short lanes mark NOTHING optional — they drop the steps they do not
9
+ // need from the chain instead — so both verbs are refused outright on one, and `notOptional` says which
10
+ // of the two reasons applies rather than reporting every empty answer as a broken chain.
11
+ // The too-late checks name no step (E36): a route that marks another step optional gets the same verbs
12
+ // with the same guards, measured against whatever steps follow the pair on that route.
13
+ // Putting a step back asks no route at all: restoring a step to the chain can never let a gate pass, and
14
+ // `yad unskip` is the remedy `yad doctor`'s `skip:not-optional` recommends on an epic whose route forbids
15
+ // the skip. Either way the step stays VISIBLE and auditable — marked with a recorded reason (and
16
+ // actor/date), short-circuited at the gate. All state logic is the pure `skipStep` / `unskipStep` /
17
+ // `deferStep` / `undeferStep` in epic-state.mjs; this is the thin file-load/save + attribution wrapper.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { ok, info, hand, fail, readJSON, readJSONStrict, warn, writeJSON } from './lib.mjs';
21
+ import { epicRel, epicRoot, epicStories, loadLedger, skipLane, skipStep, unskipLane, unskipStep, deferStep, undeferStep, unblockStep, writeState, isReopenedStep, stepStatus } from './epic-state.mjs';
22
+ import { epicFiles, isVerifiedLedger, productConfigPath } from './manifest.mjs';
23
+ import { refreshIndexAfterWrite } from './product-index.mjs';
24
+ import { readShips } from './ledger.mjs';
25
+ import { loadProduct } from './gate.mjs';
26
+ import { seededSlugs } from './hook.mjs';
27
+ import { actorName } from './platform.mjs';
28
+
29
+ // Best-effort auditable actor for a record's `by` — who WROTE the record: the platform login the CLI
30
+ // reports (`actorName`), else the raw git user.name, else null. A malformed/absent Product has no
31
+ // platform to ask and degrades to the raw name — attribution is a nicety on the audit trail, never a
32
+ // gate, so it must not block the verb.
33
+ export function recordActor(root) {
34
+ let platform = null;
35
+ try { platform = loadProduct(root)?.hub?.platform || null; } catch { /* no Product / malformed — attribute by raw git name */ }
36
+ return actorName(root, platform);
37
+ }
38
+
39
+ // The `--json` answer (E1) of every verb here: the step as the file now holds it.
40
+ function stepAnswer(state, epic, step) {
41
+ const s = (Array.isArray(state.steps) ? state.steps : []).find((x) => x?.id === step) || null;
42
+ return { epic, step, state: s ? stepStatus(s) : null, debt: s?.debt === true, record: s?.record ?? null, currentStep: state.currentStep ?? null };
43
+ }
44
+
45
+ // What differs between the two verbs, as a person reads it.
46
+ const VERBS = {
47
+ skip: {
48
+ set: skipStep, restore: unskipStep, undo: 'unskip', done: 'marked N/A', undone: 'un-skipped', state: 'skipped',
49
+ gate: 'its review gate is short-circuited',
50
+ reasonNote: '--reason is not used when un-skipping: the skip record is removed with the skip',
51
+ },
52
+ defer: {
53
+ set: deferStep, restore: undeferStep, undo: 'undefer', done: 'deferred', undone: 'un-deferred', state: 'deferred',
54
+ gate: 'the chain continues past it; its review is still owed',
55
+ reasonNote: '--reason is not used when un-deferring: the record is removed with the deferral',
56
+ },
57
+ };
58
+
59
+ // On a verified Product, an epic's `state.json` is CI's alone once it is on the default branch (rule 9):
60
+ // `ledger-guard` rejects every other commit that changes it, and `gate ci` has no step for a skip, a
61
+ // deferral or an unblock. A write here could never land, and the ledger on this machine would drift from
62
+ // the one CI keeps — so these verbs refuse, before anything is written. The question is the one the
63
+ // `ledger-guard` hook asks (`seededSlugs`, cli/hook.mjs), so the hook and the verb never disagree:
64
+ // - an epic whose ledger is NOT on the base yet still writes. Its seed rides the first review PR (#162):
65
+ // the epic review on `classic`, the analysis review on `analysis-first`. A skip or deferral written
66
+ // then is one way once that PR merges, so the verb says so.
67
+ // - a base that cannot be read is unknown, and allowed with a warning, as the hook allows it. The CI gate
68
+ // is the one that fails closed.
69
+ // `hub.json` is read leniently, as the hook reads it. A broken `hub.json` or `repos.json` must not stop
70
+ // these verbs on a local ledger (`yad doctor` reports both), and the hook and the verb must give the same
71
+ // answer on the same file.
72
+ function ciOwnsLedger(root, { epic, command, undoCommand = null, runner }) {
73
+ const hub = readJSON(productConfigPath(root), null);
74
+ if (!isVerifiedLedger(hub)) return false;
75
+ const seeded = seededSlugs(root, hub, runner);
76
+ if (seeded === null) {
77
+ warn(`this Product's ledger is verified, and origin could not be read — cannot tell whether CI already owns ${epicRel(epic)}/.sdlc/state.json. If the epic's first review PR has merged, ledger-guard will reject this change`);
78
+ return false;
79
+ }
80
+ if (!seeded.has(epic.toLowerCase())) {
81
+ if (undoCommand) {
82
+ info(`on this verified Product ${epicRel(epic)}/.sdlc/state.json is yours to write only until the epic's first review PR merges; after that \`${undoCommand}\` is refused until CI has a step for it (checked against origin as last fetched — run \`git fetch origin\` if that PR may have merged)`);
83
+ }
84
+ return false;
85
+ }
86
+ fail(`\`${command}\` is refused: ${epicRel(epic)}/.sdlc/state.json is on the default branch, and on a verified Product only CI writes it — nothing is written`);
87
+ hand(`ledger-guard rejects any commit to it that CI did not make, and CI has no step for \`${command}\` yet. An epic's ledger is still yours to write while the epic is new, before its first review PR merges`);
88
+ process.exitCode = 1;
89
+ return true;
21
90
  }
22
91
 
23
- export async function runSkip(root, { epic, step, reason, undo = false, today } = {}) {
92
+ async function runSetAside(root, verb, { epic, step, reason, debt = false, undo = false, today, runner } = {}) {
93
+ const V = VERBS[verb];
24
94
  const epicDir = epicRoot(root, epic);
25
95
  const ledger = loadLedger(epicDir);
26
96
  if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
27
- if (!step) { fail('usage: yad skip <epic> <step> --reason "<why>" (or: yad skip <epic> <step> --undo)'); process.exitCode = 1; return; }
97
+ if (!step) {
98
+ fail(undo ? `usage: yad ${V.undo} <epic> <step>` : `usage: yad ${verb} <epic> <step> --reason "<why>" (undo it with: yad ${V.undo} <epic> <step>)`);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ if (ciOwnsLedger(root, { epic, command: `yad ${undo ? V.undo : verb}`, undoCommand: undo ? null : `yad ${V.undo} ${epic} ${step}`, runner })) return;
28
103
 
29
104
  // Guard violations throw a YadError (YAD-STATE-004) with a hint — the top-level catch in bin/yad.mjs
30
105
  // renders those. Here we only handle the happy path + the two plain-arg checks above.
31
106
  if (undo) {
32
- unskipStep(ledger.state, step);
33
- writeJSON(ledger.files.state, ledger.state);
34
- ok(`${step} un-skipped — back in the chain`);
107
+ V.restore(ledger.state, step);
108
+ // Putting a step back deletes its record, so a reason given here would be recorded nowhere. Say so
109
+ // rather than accept it in silence.
110
+ if (reason != null && reason !== true) info(V.reasonNote);
111
+ // The same for `--debt`: putting a step back is how a debt is PAID, so the flag means nothing here (E41).
112
+ if (debt === true) info('--debt is not used when putting a step back: a debt is set with `yad defer --debt`, and putting the step back starts paying it');
113
+ writeState(ledger.files.state, ledger.state);
114
+ refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19: the default branch of a local Product only
115
+ // A deferral put back after later work finished RE-OPENS beside that work (E41), and `currentStep`
116
+ // stays where the chain is — so "back in the chain" and a currentStep line would both mislead.
117
+ const answer = { ...stepAnswer(ledger.state, epic, step), changed: true, reopened: isReopenedStep(ledger.state, step) };
118
+ if (answer.reopened) {
119
+ ok(`${step} ${V.undone} — re-opened beside the work already finished after it, which stays done`);
120
+ hand(`currentStep stays ${ledger.state.currentStep}; see the re-opened lane with: yad next ${epic}`);
121
+ return answer;
122
+ }
123
+ ok(`${step} ${V.undone} — back in the chain`);
35
124
  hand(`currentStep is now ${ledger.state.currentStep}`);
125
+ return answer;
126
+ }
127
+
128
+ const by = recordActor(root);
129
+ // A repeat on a step already set aside this way changes nothing but, with `--debt`, the flag — and keeps
130
+ // the ORIGINAL record. Printing this run's actor, date and reason would misstate who set it aside and why.
131
+ const steps = Array.isArray(ledger.state.steps) ? ledger.state.steps : [];
132
+ const before = steps.find((s) => s?.id === step);
133
+ const already = stepStatus(before) === V.state;
134
+ const owedBefore = before?.debt === true;
135
+ V.set(ledger.state, step, { reason, by, at: today, debt: debt === true });
136
+ writeState(ledger.files.state, ledger.state);
137
+ refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19, as above
138
+ const after = ledger.state.steps.find((s) => s?.id === step);
139
+ const owed = after?.debt === true;
140
+ if (already) {
141
+ const r = after?.record || {};
142
+ ok(`${step} was already ${V.done}${r.by ? ` by ${r.by}` : ''}${r.date ? ` on ${r.date}` : ''}${owed && !owedBefore ? ' — now marked as debt' : ' — nothing changed'}`);
143
+ if (r.reason) info(`reason: ${r.reason}`);
144
+ return { ...stepAnswer(ledger.state, epic, step), changed: owed && !owedBefore };
145
+ }
146
+ ok(`${step} ${V.done}${owed ? ' as debt' : ''}${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
147
+ info(`reason: ${String(reason).trim()}`);
148
+ hand(`${V.gate}; currentStep is now ${ledger.state.currentStep} (reverse with \`yad ${V.undo} ${epic} ${step}\`)`);
149
+ return { ...stepAnswer(ledger.state, epic, step), changed: true };
150
+ }
151
+
152
+ // `yad skip <epic> <story> --repo <name> --reason "<why>"` / `yad unskip <epic> <story> --repo <name>`
153
+ // (E39) — set a whole Build LANE aside: this story needs no change in this repo. The rules are the pure
154
+ // `skipLane` / `unskipLane` in epic-state.mjs; this reads the story and the ships, and writes
155
+ // `build-state/<story>.json`, which `yad checkpoint --push` commits. There is no lane deferral: a lane that
156
+ // is owed later is simply not driven yet.
157
+ export async function runLaneSkip(root, { epic, story, repo, reason, undo = false, today } = {}) {
158
+ const epicDir = epicRoot(root, epic);
159
+ const ledger = loadLedger(epicDir);
160
+ if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
161
+ if (!repo || repo === true) {
162
+ fail(`usage: yad ${undo ? 'unskip' : 'skip'} ${epic} ${story} --repo <name>${undo ? '' : ' --reason "<why>"'}`);
163
+ process.exitCode = 1;
36
164
  return;
37
165
  }
166
+ const file = path.join(epicFiles(epicDir).buildStateDir, `${story}.json`);
167
+ const current = readJSONStrict(file, null);
168
+
169
+ // Putting a lane back asks nothing but that it was skipped — not even that the story file still exists,
170
+ // so a story renamed or removed after the skip can still have its skip undone (E39 review).
171
+ if (undo) {
172
+ const { buildState, empty } = unskipLane(current, { story, repo });
173
+ if (empty) fs.rmSync(file);
174
+ else writeJSON(file, buildState);
175
+ if (reason != null && reason !== true) info('--reason is not used when un-skipping: the skip record is removed with the skip');
176
+ ok(`${story} / ${repo} un-skipped — the lane is owed again`);
177
+ hand(`yad-run adds the lane the next time ${story} is driven in ${repo}; commit this with \`yad checkpoint --push\``);
178
+ return { epic, story, repo, skipped: false, changed: true, record: null, file: path.relative(root, file) };
179
+ }
38
180
 
39
- const by = skipActor(root);
40
- skipStep(ledger.state, step, { reason, by, at: today });
41
- writeJSON(ledger.files.state, ledger.state);
42
- ok(`${step} marked N/A${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
181
+ const entry = epicStories(epicDir).find((st) => st.id === story);
182
+ if (!entry) { fail(`no story ${story} under ${epicRel(epic)}/stories/`); process.exitCode = 1; return; }
183
+ // An EARNED stories review only: `done` here, or `satisfied` (reviewed in the parent epic) — the rule E36
184
+ // gives for "Build can run". `isPassed` would also accept a hand-typed `skipped` or `deferred` review, and
185
+ // a lane skip over stories nobody approved is what this refusal exists to stop (E39 review).
186
+ const storiesReview = ledger.state.steps.find((st) => st?.id === 'stories-review');
187
+ const shippedRepos = readShips(epicDir).filter((sh) => sh.story === story).map((sh) => sh.repo);
188
+ const by = recordActor(root);
189
+ const { buildState, already } = skipLane(current, {
190
+ story, repo, reason, by, date: today, declared: entry.repos, shippedRepos, storiesPassed: ['done', 'satisfied'].includes(stepStatus(storiesReview)),
191
+ });
192
+ if (already) {
193
+ const r = current.repos[repo].record || {};
194
+ ok(`${story} / ${repo} was already skipped${r.by ? ` by ${r.by}` : ''}${r.date ? ` on ${r.date}` : ''} — nothing changed`);
195
+ if (r.reason) info(`reason: ${r.reason}`);
196
+ return { epic, story, repo, skipped: true, changed: false, record: current.repos[repo].record ?? null, file: path.relative(root, file) };
197
+ }
198
+ writeJSON(file, buildState);
199
+ ok(`${story} / ${repo} lane skipped — N/A${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
43
200
  info(`reason: ${String(reason).trim()}`);
44
- hand(`its review gate is short-circuited; currentStep is now ${ledger.state.currentStep} (reverse with \`yad skip ${epic} ${step} --undo\`)`);
201
+ hand(`the feature can ship without it; commit this with \`yad checkpoint --push\` (reverse with \`yad unskip ${epic} ${story} --repo ${repo}\`)`);
202
+ return { epic, story, repo, skipped: true, changed: true, record: buildState.repos?.[repo]?.record ?? null, file: path.relative(root, file) };
203
+ }
204
+
205
+ export const runSkip = (root, opts) => runSetAside(root, 'skip', opts);
206
+ export const runDefer = (root, opts) => runSetAside(root, 'defer', opts);
207
+
208
+ // `yad unblock <epic> <step>` (E37) — clear a recorded blocker once the wait is over. The state logic is
209
+ // the pure `unblockStep`; `build-state/<story>.json` belongs to the skills and is never touched here.
210
+ export async function runUnblock(root, { epic, step, runner } = {}) {
211
+ const epicDir = epicRoot(root, epic);
212
+ const ledger = loadLedger(epicDir);
213
+ if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
214
+ if (!step) { fail('usage: yad unblock <epic> <step>'); process.exitCode = 1; return; }
215
+ if (ciOwnsLedger(root, { epic, command: 'yad unblock', runner })) return;
216
+ // Read the reason BEFORE the write removes it, so the line can say what was cleared.
217
+ const steps = Array.isArray(ledger.state.steps) ? ledger.state.steps : [];
218
+ const was = steps.find((s) => s?.id === step)?.record?.reason || null;
219
+ unblockStep(ledger.state, step);
220
+ writeState(ledger.files.state, ledger.state);
221
+ refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null)); // E19, as above
222
+ ok(`${step} unblocked — now ${ledger.state.steps.find((s) => s?.id === step).status}`);
223
+ if (was) info(`cleared: ${was}`);
224
+ hand(`see what to do now: yad next ${epic}`);
225
+ return { ...stepAnswer(ledger.state, epic, step), changed: true, cleared: was };
45
226
  }
package/cli/thread.mjs CHANGED
@@ -4,10 +4,10 @@
4
4
  // this only DISCOVERS, exactly as yad-docs-sync flags and the build gates block. Node built-ins only.
5
5
  import path from 'node:path';
6
6
  import fs from 'node:fs';
7
- import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
7
+ import { c, log, ok, info, warn, hand, readJSON, exists, emitJSON, refuse } from './lib.mjs';
8
8
  import { readShips } from './ledger.mjs';
9
9
  import {
10
- epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic, kindNoun,
10
+ epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic, typeNoun, currentPhase, isProductLevel,
11
11
  resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
12
12
  } from './epic-state.mjs';
13
13
 
@@ -24,7 +24,7 @@ export const loadBuildLog = (root, epic) => ({ epic, ships: readShips(epicRoot(r
24
24
 
25
25
  // An epic is SEALED once every authored story is `shipped` (config.yaml change.seal_on). A sealed epic
26
26
  // refuses new behaviour (epic-open.sh) — a further change must open a new threaded change-epic, which is
27
- // what keeps the front artifacts from going stale. An epic with no stories is NOT sealed (nothing built).
27
+ // what keeps the Shape artifacts from going stale. An epic with no stories is NOT sealed (nothing built).
28
28
  export function sealedEpic(root, epic) {
29
29
  const dir = path.join(epicRoot(root, epic), 'stories');
30
30
  if (!exists(dir)) return false;
@@ -54,8 +54,18 @@ export function threadSummary(root, threadOrEpic) {
54
54
  const state = readJSON(path.join(epicRoot(root, id), '.sdlc', 'state.json'), null);
55
55
  const change = loadChange(root, id);
56
56
  return {
57
- id, kind: lin.kind, parent: lin.parent, inherits: lin.inherits,
57
+ // `type` is the word from shape 5 on; `kind` is the same value under the name this key has
58
+ // always had. Both are emitted for one major so a script reading either keeps working.
59
+ id, type: lin.type, kind: lin.type, parent: lin.parent, inherits: lin.inherits,
60
+ // The free grouping tag from epic.md (E31), null when unset. This is the only machine-readable
61
+ // surface that carries it: `yad next --json` is frozen by the golden test and cannot gain a key.
62
+ theme: lin.theme,
58
63
  currentStep: state?.currentStep || 'unseeded',
64
+ // Which of the six phases this epic is in — the same answer `yad next` prints, from the same
65
+ // function. Null is a real answer, not a gap: a stub and any step id this release does not
66
+ // recognise have no phase, and neither is guessed at. (The product level never reaches here —
67
+ // a thread is built from epics with an `epic.md` — but the same predicate is asked anyway.)
68
+ phase: currentPhase(state?.currentStep, { product: isProductLevel(state) }),
59
69
  sealed: sealedEpic(root, id),
60
70
  stub: isStubEpic(root, id),
61
71
  depth: change?.depth || null,
@@ -73,16 +83,18 @@ export function threadSummary(root, threadOrEpic) {
73
83
  };
74
84
  }
75
85
 
76
- // Colour a node's kind noun for the tree render. The noun words live in one place (`kindNoun`); this
77
- // only layers the per-kind colour on top, so the two never drift. Unknown kind → uncoloured noun.
78
- const KIND_COLOR = { feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red };
79
- const kindTag = (kind) => (KIND_COLOR[kind] || ((s) => s))(kindNoun(kind));
86
+ // Colour a node's type noun for the tree render. The noun words live in one place (`typeNoun`); this
87
+ // only layers the per-type colour on top, so the two never drift. Unknown type → uncoloured noun.
88
+ const TYPE_COLOR = {
89
+ feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red, chore: c.dim,
90
+ };
91
+ const typeTag = (t) => (TYPE_COLOR[t] || ((s) => s))(typeNoun(t));
80
92
 
81
93
  export async function runThread(root, { epic, json = false } = {}) {
82
94
  if (!epic) {
83
95
  // List every distinct thread root in the project.
84
96
  const dir = path.join(root, 'epics');
85
- if (!exists(dir)) { log(c.red('no epics/ directory')); process.exitCode = 1; return; }
97
+ if (!exists(dir)) return refuse('no epics/ directory', null, { json });
86
98
  const roots = new Set();
87
99
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
88
100
  if (e.isDirectory() && isValidEpicId(e.name) && exists(path.join(dir, e.name, 'epic.md'))) {
@@ -90,27 +102,37 @@ export async function runThread(root, { epic, json = false } = {}) {
90
102
  }
91
103
  }
92
104
  log(c.bold('\nFeature threads'));
105
+ // The --json answer (E1): one line per thread, as the list prints it.
106
+ const threads = [];
93
107
  for (const r of [...roots].sort()) {
94
108
  const s = threadSummary(root, r);
109
+ threads.push({ thread: r, theme: s.nodes[0]?.theme ?? null, epics: s.nodes.length, stub: !!s.nodes[0]?.stub, openDebt: s.openDebt.length });
95
110
  const debt = s.openDebt.length ? c.red(` ⚠ ${s.openDebt.length} open reconcile-debt`) : '';
96
111
  const stub = s.nodes[0]?.stub ? c.yellow(' [stub · backfill pending]') : '';
97
- log(` ${c.bold(r)} ${c.dim(`${s.nodes.length} epic(s)`)}${stub}${debt}`);
112
+ // The genesis epic's grouping theme (E31). This list is where a person looks to see which
113
+ // threads belong together, so it is the one place the tag earns its keep most.
114
+ const theme = s.nodes[0]?.theme ? c.dim(` #${s.nodes[0].theme}`) : '';
115
+ log(` ${c.bold(r)}${theme} ${c.dim(`${s.nodes.length} epic(s)`)}${stub}${debt}`);
98
116
  }
99
117
  log(c.dim('\n yad thread <epic> show one thread in full'));
100
- return;
118
+ if (json) return emitJSON({ threads });
119
+ return { threads };
101
120
  }
102
- if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic}`)); process.exitCode = 1; return; }
121
+ if (!isValidEpicId(epic)) return refuse(`invalid epic id: ${epic}`, null, { json });
103
122
  const s = threadSummary(root, epic);
104
- if (json) { log(JSON.stringify(s, null, 2)); return; }
123
+ if (json) { emitJSON(s); return; }
105
124
 
106
125
  log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
107
126
  if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
108
127
  for (const n of s.nodes) {
109
- const tag = kindTag(n.kind);
128
+ const tag = typeTag(n.type);
110
129
  const seal = n.sealed ? c.dim(' [sealed]') : '';
111
130
  const stub = n.stub ? c.yellow(' [stub · backfill pending]') : '';
112
131
  const dep = n.depth ? c.dim(` ${n.depth}`) : '';
113
- log(` • ${c.bold(n.id)} ${tag}${dep} ${c.dim('@ ' + n.currentStep)}${seal}${stub}`);
132
+ // The grouping theme prints only when there is one. An epic with no theme is normal, and an
133
+ // empty `theme: —` on every line would be noise on a surface people read top to bottom.
134
+ const theme = n.theme ? c.dim(` #${n.theme}`) : '';
135
+ log(` • ${c.bold(n.id)} ${tag}${dep}${theme} ${c.dim('@ ' + n.currentStep)}${seal}${stub}`);
114
136
  if (n.parent) log(c.dim(` parent: ${n.parent} inherits: [${n.inherits.join(', ') || '—'}]`));
115
137
  if (n.defect) log(c.dim(` defect: ${n.defect.severity || '?'} · escaped@${n.defect.escape_stage || '?'} · ${n.defect.root_cause || ''}`));
116
138
  if (n.brokenThread) log(c.red(` ✗ ${n.brokenThread}`));
@@ -150,16 +172,18 @@ function threadRoots(root) {
150
172
 
151
173
  export async function runReconcile(root, { action = 'check', thread = null } = {}) {
152
174
  const roots = thread ? [resolveThread(root, thread).rootId] : threadRoots(root);
153
- if (!roots.length) { info('no feature threads found (no epics with epic.md yet)'); return; }
175
+ if (!roots.length) { info('no feature threads found (no epics with epic.md yet)'); return { action, flags: 0, threads: [] }; }
154
176
 
155
177
  log(c.bold(`\nChange reconcile ${c.dim(action)}`));
156
178
  let flags = 0;
179
+ const threads = []; // the --json answer (E1)
157
180
  for (const r of roots) {
158
181
  const s = threadSummary(root, r);
159
182
  const issues = [];
160
183
  if (s.broken) issues.push(`broken lineage: ${s.broken}`);
161
184
  for (const n of s.nodes) if (n.brokenThread) issues.push(`${n.id}: ${n.brokenThread}`);
162
185
  for (const d of s.openDebt) issues.push(`open reconcile debt on ${d.epicId} — next change blocked until paid`);
186
+ threads.push({ thread: r, clean: !issues.length, issues });
163
187
  if (!issues.length) { ok(`${r} — clean`); continue; }
164
188
  flags += issues.length;
165
189
  warn(`${r}`);
@@ -168,7 +192,7 @@ export async function runReconcile(root, { action = 'check', thread = null } = {
168
192
 
169
193
  if (action === 'refresh') {
170
194
  log('');
171
- info('refresh is advisory: open a reconcile change-epic with `yad-change` (kind: change) threaded to');
195
+ info('refresh is advisory: open a reconcile change-epic with `yad-change` (type change) threaded to');
172
196
  info('the affected feature, then pay any open debt (update artifacts + add a regression test).');
173
197
  info('for shipped brownfield code with NO epic at all, anchor it first with `yad-stub`, then thread');
174
198
  info('the change/defect off that stub (and run `yad-backfill` to make the anchor real).');
@@ -182,4 +206,5 @@ export async function runReconcile(root, { action = 'check', thread = null } = {
182
206
  log('');
183
207
  if (flags) { warn(`${flags} item(s) need attention — reconcile is advisory; the gates block at merge`); }
184
208
  else { ok('all threads reconciled — no drift, no open debt'); }
209
+ return { action, flags, threads };
185
210
  }