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
@@ -4,17 +4,382 @@
4
4
  import path from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import fs from 'node:fs';
7
- import { readJSON, readJSONStrict, writeJSON, fileSha } from './lib.mjs';
7
+ import { isPlainObject, readJSON, readJSONStrict, writeJSON, fileSha } from './lib.mjs';
8
8
  import { err } from './errors.mjs';
9
- import { epicFiles } from './manifest.mjs';
9
+ import {
10
+ ADVANCE_FROM_AUTOMATION, AUTOMATION_FROM_ADVANCE, DRIVER_FROM_ASSISTANCE, epicFiles, preferring,
11
+ PROJECT_FILES, SCHEMA_VERSION, stepAdvance,
12
+ } from './manifest.mjs';
10
13
 
11
14
  const RISK_ESCALATORS = ['contract', 'auth', 'payments'];
12
15
 
16
+ // ---- the per-step gate rule (E7) -----------------------------------------------------------------
17
+ //
18
+ // HOW MANY HUMAN APPROVALS a step asks for, as a NUMBER derived from the step's own recorded data.
19
+ // `needed = base + risk step` — the roadmap's tier-1 rule (docs/roadmap-idea-1.md, Part 3). There is
20
+ // no name and no role anywhere in it, which is the whole point: a rule that names a person, a role or
21
+ // a step goes stale the moment the team changes, and "repository access is the roster" replaces it.
22
+ //
23
+ // WHAT HOLDS THE GATE. Part 3's rule is ONE formula: `needed = base + risk_step`, CAPPED at
24
+ // `active − 1`, floor 1 in team mode. `active` is the live count of people (E71, cli/people.mjs), read
25
+ // once per command by the caller. E72 computes the cap (`gateCapFor` below), prints it wherever a gate
26
+ // reports itself and records it on the closing record — but ONLY THE BASE HOLDS THE GATE, still.
27
+ //
28
+ // WHY THE CAP IS SHOWN AND NOT ENFORCED (the user's decision, 2026-09-21, after review). The count of
29
+ // people errs HIGH on purpose (E71), and in the NORMAL case, not an edge: a commit is keyed by its git
30
+ // NAME unless its address is a platform `noreply` one, an approval by its platform LOGIN, and E71 never
31
+ // joins the two without exact evidence. So a two-person team whose members commit with work addresses
32
+ // and approve on GitHub reads as FOUR, the cap lowers nothing, and an enforced contract gate would ask
33
+ // for three approvals from one person who is not the author — E7's deadlock, back. Rule 7 needs a way
34
+ // out before any count holds a gate, and that is `yad gate lower --reason`. E73 was to turn the capped
35
+ // count on together with it; the user then kept E73 to DETECTION (2026-09-22, `gateReach` below), and
36
+ // enforcement plus the escape hatch moved to E108, which waits for the count to be accurate. Until then
37
+ // `short` is measured against the capped ask and printed, and never enforced.
38
+ //
39
+ // WHEN THE COUNT IS UNKNOWN (`active: null`) no cap is computed at all: an unknown is never a small
40
+ // number (E71). Product CI is usually that case — it checks out only the hub, so connected repos are
41
+ // not on disk — and the surfaces say so.
42
+ //
43
+ // Until E62 the base was not what held a gate either: a ROLE rule read from the roster did (1 owner,
44
+ // 1 reviewer, a domain owner per touched repo). E62 removed the roster, and the base took its place.
45
+ //
46
+ // THE RISK STEP comes from the step's risk tags — `contract` +2, `auth` / `payments` +1 (the "high"
47
+ // tier), nothing +0. Two inferences the roadmap leaves open, recorded here so the next reader does not
48
+ // have to guess: `auth` and `payments` ARE the high tier (they are the other two escalators, and the
49
+ // roadmap gives the contract surface its own larger step), and a step carrying several tags takes the
50
+ // MAXIMUM step, never the sum — a gate is one decision about the riskiest thing it touches, and summing
51
+ // would let three tags ask for five approvals that no small team can produce.
52
+ //
53
+ // THE TAGS ARE READ FROM THE EPIC, not from the catalogue: `seedState` copies `risk_tags` into
54
+ // `state.json`, and a team may add `auth` to a step by hand. Change-safety rule 3 — the file wins — so
55
+ // the rule asks the step in front of it, exactly as `optionalStepsFor` asks the epic's recorded route.
56
+ //
57
+ // THE BASE IS 1: one human who is not the author. That is what Part 3 says the gate actually needs
58
+ // ("did a human approve; was it someone other than the author; were there enough of them"), and the
59
+ // platform already enforces the "not the author" half — on GitHub you cannot approve your own PR. The
60
+ // roadmap never fixes the number; E62 enforced it as the floor, and the cap (E72) never goes below it.
61
+ const GATE_BASE = 1;
62
+ // Tag -> { step, tier }. The TIER NAME comes from the winning tag rather than from the number, so a
63
+ // future change to a tag's step cannot make an `auth`-only gate print "contract risk".
64
+ const RISK_TIERS = {
65
+ contract: { step: 2, tier: 'contract' },
66
+ auth: { step: 1, tier: 'high' },
67
+ payments: { step: 1, tier: 'high' },
68
+ };
69
+
70
+ // The gate rule for ONE step: how many distinct human approvals it asks for, and the arithmetic that
71
+ // says why. Pure, data-only, and safe on a step from a newer yadflow — an unknown tag adds nothing, and
72
+ // a hand-edited `risk_tags: "contract"` (a string where the shape says array) is read as no tags rather
73
+ // than crashing the gate that was about to report on it.
74
+ export function gateRuleFor(step) {
75
+ const tags = Array.isArray(step?.risk_tags) ? step.risk_tags : [];
76
+ let riskStep = 0;
77
+ let risk = 'normal';
78
+ for (const t of tags) {
79
+ const hit = RISK_TIERS[t];
80
+ if (hit && hit.step > riskStep) { riskStep = hit.step; risk = hit.tier; }
81
+ }
82
+ return { base: GATE_BASE, riskStep, needed: GATE_BASE + riskStep, risk };
83
+ }
84
+
85
+ // THE CAP (E72): the number of approvals a gate asks for once the live count of people is known.
86
+ //
87
+ // to = min(needed, max(1, active − 1)) — never below the base, which is 1
88
+ //
89
+ // `active` is the capacity count from `activePeople` (cli/people.mjs). Anything that is not a whole
90
+ // number — `null` when a source could not be read, or a caller that has no count — gives NO cap (`null`):
91
+ // the cap is never applied from an unknown, so the risk step stays advisory and only the base holds.
92
+ //
93
+ // THE `− 1` IS ONE SEAT, NOT A CHECK. It leaves room for the author, who wrote the review branch in the
94
+ // Product and so is always among the people counted. The engine does not know who the author is and does
95
+ // not check whose approval it is counting: that is the PLATFORM's rule (E62 decision h — GitHub never
96
+ // allows self-approval; GitLab only when its settings say so; a local ledger checks nothing).
97
+ //
98
+ // The floor of 1 means the cap can only ever trim the RISK STEP. The base is 1, so `to` is at least the
99
+ // base. Until E108 the cap is REPORTED: the base alone holds every team gate (see `gateRuleFor`).
100
+ //
101
+ // Shape: { active, limit, to, capped } — `limit` is `max(1, active − 1)`, `to` the number asked for,
102
+ // `capped` true only when the cap LOWERED the count (rule 6: every cap is said, and recorded on the
103
+ // step's closing record by the caller that closes it).
104
+ export function gateCapFor(rule, active) {
105
+ if (!Number.isInteger(active) || active < 0) return null;
106
+ const limit = capLimit(active);
107
+ const to = Math.max(rule.base, Math.min(rule.needed, limit));
108
+ return { active, limit, to, capped: to < rule.needed };
109
+ }
110
+
111
+ // The cap's arithmetic and its wording, ONE copy each. Every surface that prints a cap reads these, so
112
+ // E108 — which changes what those surfaces say — has one place to change, not five.
113
+ // capLimit the most approvals a count of `active` people can give: one seat is left for the author,
114
+ // and never below 1 (a known count of 0 or 1 people still asks for the base).
115
+ // peopleWord `person` or `people`.
116
+ // capSeat why the limit is what it is: `one seat is left for the author` from 2 people up; at 0 or
117
+ // 1 people the floor decides, and the phrase says `never below 1` instead of subtracting.
118
+ // capWho the reason a capped line gives, e.g. `2 active people, less one seat for the author`.
119
+ export const capLimit = (active) => Math.max(1, active - 1);
120
+ export const peopleWord = (n) => (n === 1 ? 'person' : 'people');
121
+ const capByFloor = (active) => active - 1 < 1;
122
+ export const capSeat = (active) => (capByFloor(active) ? 'never below 1' : 'one seat is left for the author');
123
+ export const capWho = (active) => (capByFloor(active)
124
+ ? `${active} active ${peopleWord(active)} (never below 1)`
125
+ : `${active} active ${peopleWord(active)}, less one seat for the author`);
126
+
127
+ // UNMEETABLE-GATE DETECTION (E73): lines saying why a team gate may not pass. REPORTED, and never
128
+ // enforced — nothing here holds a gate, changes `passed`, `missing` or `short`, or writes a file.
129
+ // Enforcing the risk step, and `yad gate lower --reason` as its way out, is a later row (E108), which
130
+ // waits for the count of people to be accurate. These lines are the evidence that row needs.
131
+ //
132
+ // TWO KINDS OF LINE, because two different things can be said, and mixing them made lines untrue:
133
+ // `may not be met: …` TODAY's rule — the base, one approval — may have nobody to
134
+ // give it.
135
+ // `if the risk step were enforced: …` a WHAT-IF — what enforcing more than the base would do. Today
136
+ // the gate still passes on the base, and the line says so.
137
+ //
138
+ // Every claim is about the PEOPLE COUNTED, and is hedged ("may", "if"), because the count is wrong both
139
+ // ways, and neither is an edge:
140
+ // too LOW a reviewer who has not committed or approved inside the counting window is not counted —
141
+ // the second person on a brand-new Product reads as absent until their first approval;
142
+ // too HIGH a commit is keyed by its git name and an approval by its platform login, and E71 never joins
143
+ // the two without exact evidence, so a two-person team can read as four (E72, case a).
144
+ //
145
+ // WHAT AN APPROVAL SHOWS. `have` is the approvals on THIS step and `approvers` the people counted with an
146
+ // approval in the counting window. Any such approval shows that approvals can be given here, so the two
147
+ // TODAY lines speak only when there is none in the window. For the what-if lines an approval is also a
148
+ // lower bound on the team: the approvers on this step plus the author (`have + 1`), and at least two
149
+ // people once anyone approved anything. That assumes the platform keeps authors from approving their own
150
+ // work: always on GitHub, on GitLab only when its settings say so, never on a local ledger (E62 decision
151
+ // h). Where it is wrong the bound is too high, so a what-if line may stay quiet — never a false alarm.
152
+ //
153
+ // The checks, each only while its own ask is unmet:
154
+ // base 0 or 1 active person counted, and no approval in the window.
155
+ // one person exactly two people counted, one NOT MATCHED to a login (a git name, or an approval record
156
+ // that proves no login — an older hand-written one, or an engineer-review record, which
157
+ // carries none) and one login, and no approval in the window: they may be one person. (A solo
158
+ // developer who commits with a work address AND through GitHub's web editor, whose noreply
159
+ // address carries the login, reads as two people; only this check sees it.)
160
+ // full (what-if) the cap lowered the ask: the full count is more than the people counted can
161
+ // give. Not said when a today line is, nor when the approvals show more people than counted.
162
+ // names (what-if) some people are not matched to a login and at least one is a login, and the
163
+ // smallest possible team cannot give the ask. That team is the larger of the logins and the
164
+ // names (each name may be one of the logins, and distinct names are taken as distinct
165
+ // people, as the count takes them), and never fewer than the approvals prove. Taking only the
166
+ // logins made a four-person team with one web-editor commit read as "as small as 1".
167
+ // "No approval" always names the capacity window (`days`): the reader never sees an older approval.
168
+ // A Product whose records are all names (no platform) gets no name line — there is no login for a name
169
+ // to be the same person as. Two spellings of one NAME still count twice; that is E108's, with the join.
170
+ //
171
+ // An unknown count (`cap: null`) or an unknown `nameOnly` says nothing: an unknown is never a number.
172
+ // Pure. The caller decides where it applies — never in solo mode, never on a step that passed or was
173
+ // waived — and prints each distinct line once per command (`uniqueReach`).
174
+ // The SMALLEST POSSIBLE TEAM behind a count: the larger of the logins and the names not matched to a
175
+ // login (each name may be a second row for one of the logins), and never fewer than `floor`, the people
176
+ // the approvals prove. One rule, read by E73's what-if line (`gateReach`) and E74's `teamHint`, so the
177
+ // two can never disagree about how small a team may be.
178
+ export const smallestTeam = (logins, names, floor = 0) => Math.max(logins, names, floor);
179
+ export const REACH_TODAY = 'may not be met';
180
+ export const REACH_IF_ENFORCED = 'if the risk step were enforced';
181
+ const WAY_OUT = "Another person's approval settles it, or use the recorded way out, `yad mode solo --reason`";
182
+ const whole = (n) => (Number.isInteger(n) && n > 0 ? n : 0);
183
+ export function gateReach(rule, cap, { have = 0, nameOnly = null, approvers = 0, days = null } = {}) {
184
+ if (!cap) return [];
185
+ const lines = [];
186
+ const got = whole(have);
187
+ const approved = got > 0 || whole(approvers) > 0;
188
+ // The fewest people the approvals prove: this step's approvers plus the author, and two once anyone
189
+ // approved anything (the approver, and the author of what they approved).
190
+ const floor = Math.max(got > 0 ? got + 1 : 0, approved ? 2 : 0);
191
+ const names = Number.isInteger(nameOnly) && nameOnly > 0 && nameOnly < cap.active;
192
+ const logins = names ? cap.active - nameOnly : null;
193
+ const today = (t) => lines.push(`${REACH_TODAY}: ${t}`);
194
+ const ifEnforced = (t) => lines.push(`${REACH_IF_ENFORCED}: ${t}. Nothing beyond the one enforced approval is needed today`);
195
+ // The count only sees the capacity window, so every claim about "no approval" names it: an approval
196
+ // older than the window is on record, and "no approval yet" would be untrue.
197
+ const window = whole(days) ? `in the last ${days} days` : 'in the counting window';
198
+ let base = false;
199
+ if (!approved && cap.active <= 1) {
200
+ base = true;
201
+ today(`only ${cap.active} active ${peopleWord(cap.active)} counted and no approval ${window}, so if nobody but the author can approve, this gate cannot pass. Someone who has not committed or approved ${window} is not counted. ${WAY_OUT}`);
202
+ }
203
+ // Only the two-row shape (one name, one login): the solo developer who commits two ways. With more
204
+ // names beside one login, distinct names are distinct people (as the count takes them), so the team is
205
+ // at least that many — one person is not a reading the count allows.
206
+ if (!approved && names && cap.active === 2) {
207
+ base = true;
208
+ today(`1 of the 2 people counted is not matched to a platform login and may be the same person as the one login, and there is no approval ${window}, so the team may be one person. Then, if nobody but the author can approve, this gate cannot pass. ${WAY_OUT}`);
209
+ }
210
+ // `floor <= cap.active` also covers "the full count is already met" and "only one person counted":
211
+ // either way the approvals prove more people than were counted, and the line would contradict them.
212
+ if (!base && cap.capped && floor <= cap.active) {
213
+ ifEnforced(`with no cap, the full count of ${rule.needed} is more than ${cap.active} active people can give (${capSeat(cap.active)}), so if nobody else joins, this gate could not pass`);
214
+ }
215
+ if (names && !base) {
216
+ const smallest = smallestTeam(logins, nameOnly, floor);
217
+ const room = capLimit(smallest);
218
+ if (room < cap.to) {
219
+ const why = floor > Math.max(logins, nameOnly) ? ` (the approvals already recorded show at least ${smallest} people)` : '';
220
+ // At most as many names as there are logins can be a login's second row, so say how many.
221
+ const overlap = Math.min(nameOnly, logins);
222
+ const logWord = logins === 1 ? 'the one login' : 'the logins';
223
+ // One name beside one login is two people counted, where the ask is 1 and never exceeds the room —
224
+ // so a lone name always sits beside several logins here. `overlap` is 1 only with a single login.
225
+ const same = nameOnly === 1
226
+ ? 'and may be the same person as one of the logins'
227
+ : `and ${overlap === 1 ? 'one of them' : `up to ${overlap} of them`} may be the same ${overlap === 1 ? 'person' : 'people'} as ${logWord}`;
228
+ ifEnforced(`${nameOnly} of the ${cap.active} people counted ${nameOnly === 1 ? 'is' : 'are'} not matched to a platform login, ${same}, so the team may be as small as ${smallest}${why}. That leaves room for ${room} of the ${cap.to} approvals asked${cap.capped ? ' after the cap' : ''}, so if the team is that small, this gate could not pass`);
229
+ }
230
+ }
231
+ return lines;
232
+ }
233
+
234
+ // Each line once. A reason about the whole Product (the base, one person) is true of every open step
235
+ // at once; printed under each, it buries the step lines around it. `seen` is kept by the caller for one
236
+ // command, so a line prints under the first step (or PR) it applies to and nowhere after.
237
+ export const uniqueReach = (lines, seen) => lines.filter((l) => !seen.has(l) && seen.add(l));
238
+
239
+ // The rule as one human-readable sum — `3 approvers = base 1 + contract risk 2`. Defined here, beside
240
+ // the rule, because several surfaces print it (`gate sync`, `gate status`, the generated review-PR body
241
+ // and `yad open-pr`) and several copies of the arithmetic would eventually disagree.
242
+ export const gateRuleSum = (rule) => {
243
+ const people = `${rule.needed} approver${rule.needed === 1 ? '' : 's'}`;
244
+ return rule.riskStep ? `${people} = base ${rule.base} + ${rule.risk} risk ${rule.riskStep}` : `${people} = base ${rule.base}`;
245
+ };
246
+
247
+ // Which part of that sum holds the gate, said wherever the sum is printed. Empty when the step carries
248
+ // no risk step: then the base IS the whole count, and the cap can never lower it. Otherwise the base
249
+ // holds and the risk step is advisory (until E108 — see `gateRuleFor`), and a cap that lowered the ask
250
+ // is named first, so the shortfall printed after it reads against the capped number.
251
+ export const gateRuleEnforced = (rule, cap = null) => {
252
+ if (!rule.riskStep) return '';
253
+ const capped = cap?.capped ? ` — capped to ${cap.to}: ${capWho(cap.active)}` : '';
254
+ return `${capped} — base enforced, risk step advisory`;
255
+ };
256
+
257
+ // The name -> login pairs of a roster an older release left on disk (E62). Read for ONE job: recognising
258
+ // the person an older approval or comment record names, so the first sync after the upgrade continues
259
+ // that record instead of guessing. The roster decides nothing else and nothing writes it; `yad doctor`
260
+ // still names it as unused. The user chose this over matching by order (2026-09-16), which swapped two
261
+ // people's fingerprints on GitLab. A name the roster gives to two logins cannot be told apart and is
262
+ // left out.
263
+ //
264
+ // It sat in cli/gate.mjs until E71, which needs it in the active-people reader that gate.mjs prints —
265
+ // a cycle. It is pure and data-only, so it belongs here beside `gateRuleFor`; gate.mjs re-exports it.
266
+ export function legacyLogins(hub) {
267
+ const out = new Map();
268
+ const clash = new Set();
269
+ for (const e of Array.isArray(hub?.roster) ? hub.roster : []) {
270
+ if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
271
+ if (out.has(e.name) && out.get(e.name) !== e.login) clash.add(e.name);
272
+ out.set(e.name, e.login);
273
+ }
274
+ for (const n of clash) out.delete(n);
275
+ return out;
276
+ }
277
+
278
+ // The platform LOGIN an approval or comment record names, or null when the record only ever knew a
279
+ // roster name. Shared by `yad usage` (the report) and the active-people reader (E71), because the two
280
+ // must place the same record on the same person: if they disagreed, one of them would split a person
281
+ // into two rows or — worse for a count that must never shrink — fold two people into one.
282
+ //
283
+ // Four cases, in the order they are decided:
284
+ // * `source: 'bridge'` -> the record was written from the PLATFORM's own answer. `mapApprovers`
285
+ // builds it as `name: r.login` (cli/platform.mjs), so the name IS a platform login — exact
286
+ // evidence, recorded by the writer, not a guess about what the string looks like. E62 decision 4
287
+ // is about a record's `by`, which falls back to git `user.name` when there is no platform; this
288
+ // marker is what tells the two apart, and it is why "a post-E62 record names a login" is true of
289
+ // bridge records and NOT true in general.
290
+ // * `rosterName` present -> a gate write already recorded the login from the roster (E64), so the
291
+ // name the record carries IS the login.
292
+ // * an OLDER record (it carries the `role` or `domain` the roster gave it, and is not `unverified`)
293
+ // -> translate through the roster's name -> login table, which is exact or absent (`legacyLogins`).
294
+ // An `unverified` record already named a login, and translating it through a roster name it
295
+ // collided with passed a gate on an outsider's old approval once (E62) — hence the exclusion.
296
+ // * anything else -> no login is proven. The caller falls back to the name as written.
297
+ export function ledgerPersonLogin(rec, rawName, aliases = new Map()) {
298
+ if (rec && rec.source === 'bridge') return rawName || null;
299
+ if (rec && rec.rosterName !== undefined) return rawName || null;
300
+ const older = !!rec && (rec.role !== undefined || rec.domain !== undefined) && !rec.unverified;
301
+ return older && aliases.has(rawName) ? aliases.get(rawName) : null;
302
+ }
303
+
13
304
  // Epic ids are EP-<slug> with [a-z0-9-] only — anything else (uppercase, dots, slashes) is
14
305
  // rejected before it can become a path segment under epics/.
15
306
  export const isValidEpicId = (epic) => /^EP-[a-z0-9-]+$/.test(epic || '');
16
307
 
17
- export const epicRoot = (root, epic) => path.join(root, 'epics', epic);
308
+ // ---- the Product level: Foundation (E75) --------------------------------------------------------
309
+ //
310
+ // A product has ONE product-level ledger. Its id is `EP-foundation` and it lives in a folder of its
311
+ // own at the top of the project, `foundation/` — not under `epics/`, because it is not an epic: it
312
+ // runs once per product, has no `epic.md`, no work-item type and no Build.
313
+ //
314
+ // THE ID KEEPS THE `EP-` PREFIX ON PURPOSE. Everything that routes a review already matches it: the
315
+ // review branch is `review/EP-foundation/foundation` (`parseReviewBranch`), and both gate-sync
316
+ // workflows committed in users' repos fire on `review/EP-*`. A prefix-less id would have made every
317
+ // Foundation review silently fail to sync on every project wired before this release.
318
+ //
319
+ // THE FOLDER IS THE ONE THING THAT DIFFERS, and it is answered in exactly one place: `epicRoot` below.
320
+ // Every command that takes an epic id resolves its directory through it, so `yad gate open
321
+ // EP-foundation foundation/` works with no special case at the call site.
322
+ export const FOUNDATION_EPIC = 'EP-foundation';
323
+ export const FOUNDATION_DIR = 'foundation';
324
+
325
+ // The project-relative POSIX path of an epic's directory — for git pathspecs and printed paths, where
326
+ // `epicRoot`'s absolute path is the wrong thing.
327
+ export const epicRel = (epic) => (epic === FOUNDATION_EPIC ? FOUNDATION_DIR : `epics/${epic}`);
328
+ export const epicRoot = (root, epic) => (epic === FOUNDATION_EPIC ? path.join(root, FOUNDATION_DIR) : path.join(root, 'epics', epic));
329
+
330
+ // Every epic id that has a directory: the valid ids under `epics/`, plus `EP-foundation` once
331
+ // `foundation/.sdlc/` exists. Sorted. The ONE enumerator a walker should use, so a sweep cannot see the
332
+ // feature epics and miss the product level.
333
+ //
334
+ // Keyed on `foundation/.sdlc/`, not on `foundation/`: a product repo may well hold an unrelated folder
335
+ // of that name (design tokens, a docs section), and reporting it as an unseeded ledger would be a false
336
+ // finding about somebody else's files. An `epics/EP-foundation/` directory is NOT listed — that id's
337
+ // directory is `foundation/`, so listing it would read the real Foundation twice under one id;
338
+ // `yad doctor` reports the stray directory instead.
339
+ export function epicIds(root) {
340
+ const dir = path.join(root, 'epics');
341
+ const ids = fs.existsSync(dir)
342
+ ? fs.readdirSync(dir, { withFileTypes: true })
343
+ .filter((e) => e.isDirectory() && isValidEpicId(e.name) && e.name !== FOUNDATION_EPIC)
344
+ .map((e) => e.name)
345
+ : [];
346
+ if (fs.existsSync(path.join(root, FOUNDATION_DIR, '.sdlc'))) ids.push(FOUNDATION_EPIC);
347
+ return ids.sort();
348
+ }
349
+
350
+ // The folders under `epics/` that hold a `.sdlc/` but that `epicIds` did not name: a name that is not a
351
+ // valid id, a symlinked epic (`Dirent.isDirectory()` is false for a symlink), and `epics/EP-foundation`.
352
+ // That guard is right for an enumerator that turns a name into a path segment, and wrong for anything
353
+ // that must not lose what is inside — a count (E71) or the index (E19) — so both REPORT these. Sorted.
354
+ // Throws when `epics/` cannot be listed; the caller decides what that means.
355
+ export function unlistedLedgerDirs(root, ids = epicIds(root)) {
356
+ const epicsDir = path.join(root, 'epics');
357
+ if (!fs.existsSync(epicsDir)) return [];
358
+ const named = new Set(ids);
359
+ return fs.readdirSync(epicsDir)
360
+ .filter((e) => !(named.has(e) && e !== FOUNDATION_EPIC) && fs.existsSync(path.join(epicsDir, e, '.sdlc')))
361
+ .sort();
362
+ }
363
+
364
+ // The wired checks that protect `foundation/`, and the line each one needs to know the folder. They are
365
+ // committed in the user's repo and refreshed by `yad update` — not by `yad migrate` — so a copy older
366
+ // than E75 guards `epics/` only. One list for every place that asks, so `yad doctor`, `yad foundation
367
+ // new` and the CI conversion can never disagree about whether a Foundation would land unguarded.
368
+ const FOUNDATION_GUARD_ARMS = {
369
+ 'checks/ledger-guard.sh': ` ${FOUNDATION_DIR}/*)`,
370
+ 'checks/pr-title.sh': `^(epics|${FOUNDATION_DIR})/`,
371
+ 'checks/pr-template.sh': `^(epics|${FOUNDATION_DIR})/`,
372
+ };
373
+
374
+ // The wired checks that are present but predate the Foundation. A check that is not wired at all is not
375
+ // listed: that is `yad check`'s finding, and a Product with no checks has nothing to refresh.
376
+ export function staleFoundationGuards(root) {
377
+ return Object.keys(FOUNDATION_GUARD_ARMS).filter((rel) => {
378
+ const file = path.join(root, rel);
379
+ if (!fs.existsSync(file)) return false;
380
+ try { return !fs.readFileSync(file, 'utf8').includes(FOUNDATION_GUARD_ARMS[rel]); } catch { return false; }
381
+ });
382
+ }
18
383
 
19
384
  // epic.md -> "epic"; architecture.md -> "architecture"; stories/ -> "stories";
20
385
  // stories/EP-x-S01.md -> "stories-S01".
@@ -39,6 +404,7 @@ export function parseReviewBranch(branch = '') {
39
404
  export function artifactFromBase(base) {
40
405
  if (base === 'stories' || /^stories-S\d+$/i.test(base)) return 'stories/';
41
406
  if (base === 'discovery') return 'discovery/';
407
+ if (base === 'foundation') return 'foundation/';
42
408
  return `${base}.md`;
43
409
  }
44
410
 
@@ -49,6 +415,9 @@ export function artifactPaths(base) {
49
415
  if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
50
416
  if (base === 'stories') return ['stories'];
51
417
  if (base === 'discovery') return [...DISCOVERY_FILES];
418
+ // Every section, optional ones included: this names what a Foundation review COVERS, and a section
419
+ // that is absent today is still one a reviewer may be asked to look at tomorrow.
420
+ if (base === 'foundation') return [...FOUNDATION_FILES];
52
421
  return [`${base}.md`];
53
422
  }
54
423
 
@@ -122,19 +491,78 @@ export function contractSurfaceHash(epicDir) {
122
491
  return 'sha256:' + createHash('sha256').update(body.join('\n') + '\n').digest('hex');
123
492
  }
124
493
 
494
+ // ---- what an approval's fingerprint covers (shape 9) ---------------------------------------------
495
+ //
496
+ // An approval is bound to a fingerprint of what the reviewers read. The frontmatter `status:` line is
497
+ // NOT part of that. It is lifecycle bookkeeping that the engine and the skills rewrite ON PURPOSE after
498
+ // the review: the gate's own merge run flips `draft` to `approved` (`syncStatuses`,
499
+ // cli/artifact-status.mjs) straight after it records the approvals, and Build flips a story to
500
+ // `in-build` / `shipped` (the yad-engineer-review skill). Hashing the whole file made every one of
501
+ // those writes revoke the approvals it was reporting on — `yad gate status` printed "stale (revoked)",
502
+ // `yad doctor` warned, and the sweep said the rule no longer held, on every epic, right after a clean
503
+ // merge. The contract surface already hashed only what was reviewed; this does the same for the rest.
504
+ //
505
+ // The block is found with the regex `setFrontmatterStatus` uses, shared from here, so the line the
506
+ // fingerprint leaves out is the line that writer changes. The line itself is matched up to its newline
507
+ // — never across it — so an empty `status:` cannot swallow the key below it.
508
+ export const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
509
+ const STATUS_LINE = /^status:[^\n]*(?:\n|$)/m;
510
+
511
+ // The file's text with its frontmatter `status:` line rewritten by `edit`, or null when there is no such
512
+ // line (then the bytes are used as they are, so a file without one keeps its old fingerprint exactly).
513
+ function withStatusLine(text, edit) {
514
+ const fm = text.match(FRONTMATTER_BLOCK);
515
+ if (!fm || !STATUS_LINE.test(fm[1])) return null;
516
+ return `---\n${fm[1].replace(STATUS_LINE, edit)}${text.slice(4 + fm[1].length)}`;
517
+ }
518
+
519
+ const shaOf = (data) => 'sha256:' + createHash('sha256').update(data).digest('hex');
520
+
521
+ // The fingerprint of one file as reviewed: its bytes without the frontmatter `status:` line.
522
+ export function reviewedSha(p) {
523
+ if (!fs.existsSync(p)) return null;
524
+ const bytes = fs.readFileSync(p);
525
+ const stripped = withStatusLine(bytes.toString('utf8'), '');
526
+ return shaOf(stripped === null ? bytes : stripped);
527
+ }
528
+
529
+ // The fingerprints releases before shape 9 recorded, for reading only (rule 2 — read old, write new).
530
+ // They hashed the whole file, so an approval recorded then matches one of these:
531
+ // - the file's bytes as they are, when nothing has flipped since; or
532
+ // - the bytes with `status:` set back to what it said when the approval was recorded. That is exact,
533
+ // not a guess: the gate flips only FROM `draft` or `in-review` (the ladder in artifact-status.mjs),
534
+ // a re-opened review is approved over `approved` — or, for stories, over Build's `in-build` /
535
+ // `shipped` — and one run flips a whole set to one value. The line is rebuilt the way
536
+ // `setFrontmatterStatus` writes it (`status: <value>`), keeping the original line ending, `\r` included.
537
+ // Without the second kind, an approval that is already reported stale today would stay stale for ever,
538
+ // and this fix would help only reviews merged after it. A set whose files had DIFFERENT statuses when it
539
+ // was approved is not rebuilt (every combination is too many), and docs/migrations/shape-9.md says so.
540
+ const LEGACY_STATUSES = ['draft', 'in-review', 'approved', 'in-build', 'shipped'];
541
+ const LEGACY_FORMS = [
542
+ fileSha,
543
+ ...LEGACY_STATUSES.map((value) => (p) => {
544
+ if (!fs.existsSync(p)) return null;
545
+ const bytes = fs.readFileSync(p);
546
+ const text = withStatusLine(bytes.toString('utf8'), (line) => `status: ${value}${line.match(/\r?\n?$/)[0]}`);
547
+ return shaOf(text === null ? bytes : text);
548
+ }),
549
+ ];
550
+
125
551
  // Deterministic fingerprint of the whole stories/ set: hash each story file, sort, combine. Lets an
126
- // edit to any story revoke prior stories-review approvals (the escalated, per-repo gate).
127
- export function storiesHash(epicDir) {
552
+ // edit to any story revoke prior stories-review approvals.
553
+ // `sha` is how one file is fingerprinted — `reviewedSha` for the record, a legacy form for reading.
554
+ export function storiesHash(epicDir, sha = reviewedSha) {
128
555
  const dir = path.join(epicDir, 'stories');
129
556
  if (!fs.existsSync(dir)) return null;
130
557
  const parts = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort()
131
- .map((f) => `${f}:${fileSha(path.join(dir, f))}`);
558
+ .map((f) => `${f}:${sha(path.join(dir, f))}`);
132
559
  if (!parts.length) return null;
133
560
  return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
134
561
  }
135
562
 
136
- // The reserved id of the project front-zero ("epic zero"). yad-discovery seeds it; yad-epic /
137
- // yad-analysis must never pick this slug for a feature.
563
+ // The reserved id of the Product level in its OLD spelling ("epic zero", `epics/EP-discovery/`), written
564
+ // by every release before E75 and still read. yad-epic / yad-analysis must never pick this slug — or
565
+ // `EP-foundation` — for a feature.
138
566
  export const DISCOVERY_EPIC = 'EP-discovery';
139
567
 
140
568
  // The project-discovery artifact set (EP-discovery / "epic zero"). The `discovery-review` step binds
@@ -149,28 +577,356 @@ export const DISCOVERY_FILES = [
149
577
  'roadmap.md',
150
578
  ];
151
579
 
580
+ // The Foundation's sections, one Markdown file each, in the order the roadmap lists them (Part 1,
581
+ // "Foundation"). Two are optional there — Market and Risks — and optional here: a Foundation without
582
+ // them is complete and reviewable.
583
+ //
584
+ // Written as rows rather than two parallel lists so a section cannot be added to one and forgotten in
585
+ // the other. The files sit directly in `foundation/`, beside its `.sdlc/` ledger, exactly as the
586
+ // discovery set sat directly in `epics/EP-discovery/`.
587
+ export const FOUNDATION_SECTIONS = [
588
+ { file: 'purpose.md' },
589
+ { file: 'market.md', optional: true },
590
+ { file: 'scope.md' },
591
+ { file: 'mvp.md' },
592
+ { file: 'roadmap.md' },
593
+ { file: 'stack.md' },
594
+ { file: 'repos.md' },
595
+ { file: 'risks.md', optional: true },
596
+ ];
597
+ export const FOUNDATION_FILES = FOUNDATION_SECTIONS.map((s) => s.file);
598
+ export const FOUNDATION_REQUIRED = FOUNDATION_SECTIONS.filter((s) => !s.optional).map((s) => s.file);
599
+
600
+ // The Foundation's fingerprint. Null until every REQUIRED section exists — the same "not reviewable
601
+ // yet" answer `discoveryHash` gives for an incomplete set. An OPTIONAL section counts once it exists,
602
+ // so adding `risks.md` after an approval, or deleting it, revokes that approval like any other edit:
603
+ // the reviewers approved the Foundation as it stood, and a new risk section is part of what they did
604
+ // not see.
605
+ export function foundationHash(dir, sections = FOUNDATION_SECTIONS, sha = reviewedSha) {
606
+ const has = (f) => fs.existsSync(path.join(dir, f));
607
+ if (!sections.filter((s) => !s.optional).every((s) => has(s.file))) return null;
608
+ const parts = sections.map((s) => s.file).filter(has).map((f) => `${f}:${sha(path.join(dir, f))}`);
609
+ return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
610
+ }
611
+
612
+ // Is a Foundation section still only its template (E76)? `foundationHash` asks whether the files EXIST,
613
+ // so six files holding nothing but their headings are a complete, reviewable Foundation — and an
614
+ // approval on it approves nothing. This is the other half: does a section say anything yet?
615
+ //
616
+ // What does NOT count as writing is exactly what the templates in
617
+ // `skills/yad-discovery/references/foundation-schema.md` are made of, and a test reads those templates
618
+ // to keep the two in step: the frontmatter, headings, HTML comments, blank lines, and an empty table —
619
+ // its header row, its `|---|` rule and rows whose cells are all empty). Anything else is a person's words.
620
+ //
621
+ // "Headings" means `#` and `##` only — the level the templates use. A `### TypeScript and React` under
622
+ // `## Languages and frameworks` is an answer, not a template line.
623
+ //
624
+ // A table rule and a header row must each carry a `|`. Without that, a bare `---` divider matched the
625
+ // rule, and the line above it — a real sentence, or a filled table row — was read as a header, so real
626
+ // content came out unwritten (the E76 review). A divider on its own is ignored, and marks nothing else.
627
+ //
628
+ // A heuristic, and it errs one way on purpose: a single real sentence makes a section written. It is
629
+ // only ever used to WARN (`yad gate open`, `yad gate sync`, `yad doctor`), never to refuse, so a false
630
+ // "written" costs a missing reminder and a false "unwritten" would cost a wrong one.
631
+ const TABLE_RULE = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?$/;
632
+ const isTableRule = (l) => l.includes('|') && TABLE_RULE.test(l);
633
+ const DIVIDER = /^(-{3,}|\*{3,}|_{3,})$/;
634
+ export function isUnwrittenSection(text) {
635
+ const lines = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').replace(FRONTMATTER_BLOCK, '')
636
+ .replace(/<!--[\s\S]*?-->/g, '').split('\n').map((l) => l.trim());
637
+ return lines.every((l, i) => !l
638
+ || /^#{1,2}(\s|$)/.test(l)
639
+ || DIVIDER.test(l)
640
+ || isTableRule(l)
641
+ || (l.includes('|') && isTableRule(lines[i + 1] ?? '')) // a table's header row
642
+ || (l.startsWith('|') && l.split('|').every((cell) => !cell.trim())));
643
+ }
644
+
645
+ // The sections that exist and are still only their template, in section order. Optional ones are
646
+ // asked too: once `risks.md` exists it is part of what reviewers approve, empty or not. A section that
647
+ // does not exist is not listed — missing is `foundationHash`'s answer, and the gate names it separately.
648
+ export function unwrittenSections(dir, sections = FOUNDATION_SECTIONS) {
649
+ return sections.map((s) => s.file).filter((f) => {
650
+ const p = path.join(dir, f);
651
+ return fs.existsSync(p) && isUnwrittenSection(fs.readFileSync(p, 'utf8'));
652
+ });
653
+ }
654
+
655
+ // ---- the roadmap's features, and how far each has got (E76 follow-up) ---------------------------
656
+ // `roadmap.md` lists the product's features with a proposed epic id each. It used to carry a `Status`
657
+ // column people bumped by hand (`planned` → `epic-started` → `shipped`). That table is part of what the
658
+ // Foundation's reviewers approved, so the hand edit changed the Foundation's fingerprint and made its
659
+ // approvals read as stale. The user's decision (recorded in the E76 row): stop the edit, and READ the
660
+ // status from the epic ledgers instead. Nothing here writes `roadmap.md`.
661
+ //
662
+ // The table reader. A feature table is any Markdown table whose header row has a `Proposed epic id`
663
+ // cell — in any column, because the old `discovery` spelling put `Requirements` between the id and the
664
+ // status. The heading above a table is its phase. Rows run until the first line with no `|`. Comments
665
+ // are never read, so the template's example row (kept in a comment) is never read as a feature.
666
+ // Cells split on UNESCAPED pipes only: `\|` inside a cell is a literal pipe, as GitHub renders it.
667
+ const tableCells = (line) => line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
668
+ .split(/(?<!\\)\|/).map((cell) => cell.replace(/\\\|/g, '|').trim());
669
+ const HEADING = /^(#{1,6})\s+(.*)$/;
670
+ const FENCE = /^(```|~~~)/;
671
+ export function roadmapFeatures(text) {
672
+ // A line that held ONLY a comment is dropped whole, not left blank: the template keeps its example
673
+ // row in a comment directly under the `|---|` rule, and a blank line there would end the table before
674
+ // the rows a person adds beneath it.
675
+ const lines = String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').replace(FRONTMATTER_BLOCK, '')
676
+ .replace(/<!--[\s\S]*?-->/g, '\u0000').split('\n')
677
+ .filter((l) => !(l.includes('\u0000') && !l.split('\u0000').join('').trim()))
678
+ .map((l) => l.split('\u0000').join('').trim());
679
+ const out = [];
680
+ let phase = null;
681
+ let fence = null; // the marker of an open code fence — a table shown as an example is not a feature
682
+ for (let i = 0; i < lines.length; i++) {
683
+ const mark = lines[i].match(FENCE);
684
+ if (fence) { if (mark && mark[1] === fence) fence = null; continue; }
685
+ if (mark) { fence = mark[1]; continue; }
686
+ const heading = lines[i].match(HEADING);
687
+ // The PHASE is the nearest `#` or `##` heading — the level the template's phases use. A `###` under a
688
+ // phase (`### Must have`) groups rows inside it; it does not start a new one.
689
+ if (heading) { if (heading[1].length <= 2) phase = heading[2].trim(); continue; }
690
+ if (!lines[i].includes('|') || !isTableRule(lines[i + 1] ?? '')) continue;
691
+ const head = tableCells(lines[i]).map((cell) => cell.toLowerCase().replace(/\s+/g, ' '));
692
+ const idCol = head.findIndex((cell) => cell.includes('proposed epic id'));
693
+ if (idCol === -1) continue;
694
+ const statusCol = head.indexOf('status');
695
+ const named = head.indexOf('feature');
696
+ const featureCol = named !== -1 ? named : head.findIndex((_, n) => n !== idCol && n !== statusCol);
697
+ let r = i + 2;
698
+ // A heading or a fence ends a table, even one with a `|` in it (`## Phase 2 | later`), as GitHub ends it.
699
+ for (; r < lines.length && lines[r].includes('|') && !HEADING.test(lines[r]) && !FENCE.test(lines[r]); r++) {
700
+ const cells = tableCells(lines[r]);
701
+ if (cells.every((cell) => !cell)) continue;
702
+ out.push({
703
+ phase,
704
+ feature: cells[featureCol] || '',
705
+ // `EP-x`, **EP-x** and EP-x are the same id.
706
+ epicId: (cells[idCol] || '').replace(/[`*]/g, '').trim(),
707
+ written: statusCol === -1 ? null : (cells[statusCol] || null),
708
+ });
709
+ }
710
+ i = r - 1; // the line that ended the table may be the next heading
711
+ }
712
+ return out;
713
+ }
714
+
715
+ // How far a feature has got, read from its epic's ledger — the words the artifact-status ladder already
716
+ // uses for Build (`in-build`, `shipped`), plus `planned` and `in-shape` for before it:
717
+ // planned no ledger — nobody has seeded the epic yet
718
+ // in-shape seeded, and still walking Shape (epic, architecture, UI, stories)
719
+ // in-build Shape is done; Build has not shipped every story in every repo yet
720
+ // shipped every Build lane of every story is shipped — or a brownfield anchor (`yad-stub`), which
721
+ // exists precisely because the feature shipped before the Product did
722
+ // It reuses `nextAction`, the same reader `yad next` prints, so the two can never disagree about an epic.
723
+ export const FEATURE_STATUSES = ['planned', 'in-shape', 'in-build', 'shipped'];
724
+ export function featureStatus(ledger, { stories = [] } = {}) {
725
+ if (!ledger?.state) return 'planned';
726
+ const a = nextAction(ledger);
727
+ if (a.kind === 'build') return buildShipped(a.builds || [], stories) ? 'shipped' : 'in-build';
728
+ if (a.kind === 'backfill-pending' || a.kind === 'backfill-done') return 'shipped';
729
+ return 'in-shape';
730
+ }
731
+
732
+ // Shipped means EVERY lane the epic owes, not every lane that happens to exist. A story's build-state
733
+ // file appears only once that story starts Build (the `yad-run` skill creates it), so the lanes on disk
734
+ // can all be shipped while two stories have not begun — the E76 follow-up review found exactly that.
735
+ // So, when the epic's stories are known: each one needs a build-state, and each repo it declares needs a
736
+ // lane in it. `buildNextForRepo` already refuses a false "shipped" for an empty lane; this is the same
737
+ // refusal one level up. With no stories to compare against, the lanes on disk are all there is to read.
738
+ function buildShipped(builds, stories) {
739
+ const lanes = builds.flatMap((b) => b.repos);
740
+ // A SKIPPED lane (E39) is owed nothing, so it counts as finished — but a feature where every lane was
741
+ // skipped shipped nothing, so at least one lane must really have shipped.
742
+ if (!lanes.some((lane) => lane.shipped) || !lanes.every((lane) => lane.shipped || lane.status === 'skipped')) return false;
743
+ return stories.every((story) => {
744
+ const b = builds.find((x) => x.story === story.id);
745
+ return !!b && story.repos.every((repo) => b.repos.some((lane) => lane.repo === repo));
746
+ });
747
+ }
748
+
749
+ // The stories an epic expects Build to ship, from its `stories/` folder: each file's frontmatter `id`
750
+ // (its file name without `.md` when that is missing — the name `yad-stories` gives it) and its `repos`.
751
+ export function epicStories(epicDir) {
752
+ const dir = path.join(epicDir, 'stories');
753
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
754
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort().map((f) => {
755
+ const fm = readFrontmatter(path.join(dir, f));
756
+ return { id: fm.id || f.replace(/\.md$/, ''), repos: declaredRepos(fm) };
757
+ });
758
+ }
759
+
760
+ // The repos a story's frontmatter declares — `repos: [api, web]` or `repos: api`. ONE parse, shared by
761
+ // every reader that asks which lanes a story owes: `epicStories` (so `featureStatus`), the lane skip
762
+ // below, and `yad checkpoint --retro-ship`. Two parses of one list is how a trimmed name and an untrimmed
763
+ // one come to disagree about whether a repo is declared.
764
+ export const declaredRepos = (fm = {}) => {
765
+ const v = fm?.repos;
766
+ // Duplicates folded: `[web, web]` declares one lane, and counting it twice would make a skip of it read
767
+ // as "the last lane left" (E39 review).
768
+ return [...new Set((Array.isArray(v) ? v : v ? [v] : []).map((r) => String(r).trim()).filter(Boolean))];
769
+ };
770
+
771
+ // ---- skipping a whole Build lane (E39) ----------------------------------------------------------
772
+ // E39 is "skip at three levels: profile · epic · story/repo". Two of the three already existed: the
773
+ // PROFILE level is E35 (a route marks a step `optional`, in this engine's code — the user's decision was
774
+ // not to add a project file for it, which belongs with E51/E54), and the EPIC level is `yad skip` /
775
+ // `yad defer` (E36/E37). This is the third: one story in one repo — a LANE — needs no change.
776
+ //
777
+ // A LANE, NEVER A STEP. The user's decision: no single Build step may be skipped. `spec`, `tasks`,
778
+ // `implement` and `checks` are the work and its safety checks, and `engineer-review` is the Build gate
779
+ // (rule 2). What is legitimately "does not apply" is a whole lane — a story declared `repos: [api, web]`
780
+ // and `web` turned out to need nothing. `yad doctor` reports a single Build step set aside by hand.
781
+ //
782
+ // WHERE IT IS WRITTEN. `build-state/<story>.json`, as `repos.<repo> = { status: 'skipped', record }`.
783
+ // * Not the story's `repos:` frontmatter — that list is under the stories-review fingerprint
784
+ // (`storiesHash`), so editing it after approval makes the approvals read as stale (the trap #242
785
+ // closed for the roadmap column). Before the review passes, editing the list IS the right answer,
786
+ // so the skip is refused then and says so.
787
+ // * Not `state.json` — on a verified Product CI is its only writer, and an epic at `ready-for-build`
788
+ // has no review PR left to carry the write.
789
+ // * `build-state/` is machine state `yad checkpoint --push` commits on the default branch, and the
790
+ // `yad-run` skill updates it in place, so a skip written here survives its next run. The skill is
791
+ // told never to drive a skipped lane.
792
+ // A shape-10 yadflow reads such an entry as a lane with no steps — "not started yet" — which drives
793
+ // nothing, so no file shape moves.
794
+ //
795
+ // PURE: returns the new build-state; the caller reads and writes the file. Refusals throw YAD-STATE-004.
796
+ const laneIsSkipped = (lane) => isPlainObject(lane) && lane.status === 'skipped';
797
+
798
+ // Has work STARTED in a lane? Any step carrying a status that is not `todo` — a halt (`blocked` with a
799
+ // record) and a status word this release does not know both count, because E36 pinned "an unknown word
800
+ // counts as started (fail closed)" and `stepStatus` answers `null` for one. A step with NO status at all
801
+ // is a seeded step nobody has begun, which is how `buildNextForRepo` has always read it.
802
+ //
803
+ // ONE predicate for the three readers that ask: `skipLane` (refuse to skip over work),
804
+ // `buildNextForRepo` (a `skipped` word over real work is not honoured — the claim is not walked past on
805
+ // its own, E38's rule), and `yad doctor`'s `lane:…:contradiction`.
806
+ export const laneStarted = (lane) => isPlainObject(lane) && Array.isArray(lane.steps)
807
+ && lane.steps.some((st) => isPlainObject(st) && st.status != null && stepStatus(st) !== 'todo');
808
+ export function skipLane(buildState, { story, repo, reason, by = null, date = null, declared = [], shippedRepos = [], storiesPassed = false } = {}) {
809
+ const bs = isPlainObject(buildState) ? buildState : { story, repos: {} };
810
+ const repos = isPlainObject(bs.repos) ? bs.repos : {};
811
+ const lane = repos[repo];
812
+ if (!storiesPassed) {
813
+ throw err('YAD-STATE-004', `the stories review has not passed, so ${story} is not approved yet — there is no lane to skip`,
814
+ `if ${story} does not need ${repo}, remove it from the story's \`repos:\` instead: nothing is approved yet, so that edit revokes nothing`);
815
+ }
816
+ if (!declared.includes(repo)) {
817
+ throw err('YAD-STATE-004', `${story} does not declare ${repo}, so it owes no lane there`,
818
+ declared.length ? `its repos: ${declared.join(', ')} (names are case-sensitive)` : 'the story declares no repos at all');
819
+ }
820
+ // A file this cannot read as a lane map is refused, never overwritten: replacing it would destroy content
821
+ // the skills wrote (E39 review).
822
+ if (buildState != null && !isPlainObject(buildState)) {
823
+ throw err('YAD-STATE-004', `build-state/${story}.json is not a JSON object`, 'fix the file by hand — nothing here overwrites content it cannot read');
824
+ }
825
+ if (isPlainObject(buildState) && 'repos' in buildState && !isPlainObject(buildState.repos)) {
826
+ throw err('YAD-STATE-004', `build-state/${story}.json has a \`repos\` that is not an object`, 'fix the file by hand — nothing here overwrites content it cannot read');
827
+ }
828
+ // Idempotent BEFORE the reason check, like a Shape skip: a repeat keeps the original record.
829
+ if (laneIsSkipped(lane)) return { buildState: bs, already: true };
830
+ if (shippedRepos.includes(repo)) {
831
+ throw err('YAD-STATE-004', `${story} already has a ship recorded in ${repo}`, 'a lane that shipped was not skipped — there is nothing to set aside');
832
+ }
833
+ // "Started" the way E36 counts it: any step past `todo`, a halt (`blocked` with a record) included —
834
+ // a skip says the lane does not apply, and work in it says otherwise.
835
+ if (laneStarted(lane)) {
836
+ throw err('YAD-STATE-004', `work has started in ${story} / ${repo}`,
837
+ 'a skip says the lane does not apply; finish it, or clear the halt and drive it with yad-run');
838
+ }
839
+ if (!declared.some((r) => r !== repo && !laneIsSkipped(repos[r]))) {
840
+ throw err('YAD-STATE-004', `${repo} is the last lane ${story} has left to build`,
841
+ 'a story that needs no change in any repo is a question for its stories review, not a lane skip');
842
+ }
843
+ if (reason == null || reason === true || !String(reason).trim()) {
844
+ throw err('YAD-STATE-004', 'a lane skip needs a reason', `yad skip <epic> ${story} --repo ${repo} --reason "<why it needs no change>"`);
845
+ }
846
+ // A lane the skill SEEDED but never began is replaced whole, and with it any per-step dial a person set
847
+ // with `yad-run set-dial`. `yad unskip` does not bring those back: `yad-run` re-seeds the lane from the
848
+ // `back_steps` defaults, which is what an unstarted lane would have been driven with anyway.
849
+ return {
850
+ buildState: { ...bs, story: bs.story || story, repos: { ...repos, [repo]: { status: 'skipped', record: stepRecord({ reason, by, date }) } } },
851
+ already: false,
852
+ };
853
+ }
854
+
855
+ // Putting a lane back asks nothing but that it was skipped — restoring owed work can never let anything
856
+ // pass, the same reason `yad unskip` on a Shape step asks no route. The entry is removed, so `yad-run`
857
+ // seeds the lane fresh from its defaults. `empty` is true when nothing is left but what a skip wrote,
858
+ // so the caller can remove a file the skip created rather than leave an empty one behind.
859
+ export function unskipLane(buildState, { story, repo } = {}) {
860
+ const repos = isPlainObject(buildState?.repos) ? buildState.repos : {};
861
+ if (!laneIsSkipped(repos[repo])) {
862
+ throw err('YAD-STATE-004', `${story} / ${repo} is not skipped`, 'nothing to put back — see the lanes with `yad next <epic>`');
863
+ }
864
+ const rest = { ...repos };
865
+ delete rest[repo];
866
+ const next = { ...buildState, repos: rest };
867
+ const empty = !Object.keys(rest).length && Object.keys(next).every((k) => ['story', 'repos', 'schemaVersion'].includes(k));
868
+ return { buildState: next, empty };
869
+ }
870
+
871
+ // The two spellings of the product level. `foundation` is the one this release writes; `discovery` is
872
+ // what every release before it wrote, under `epics/EP-discovery/`, and it is still READ (rule 2) — a
873
+ // verified project cannot be converted by `yad migrate`, because CI owns its ledger, so the old
874
+ // spelling has to keep working for this whole major.
875
+ //
876
+ // ONE predicate, and every reader that used to ask `state.kind === 'discovery'` asks this instead. Two
877
+ // spellings read by two different checks is how one of them gets forgotten.
878
+ export const PRODUCT_KINDS = ['foundation', 'discovery'];
879
+ export const PRODUCT_EPICS = [FOUNDATION_EPIC, DISCOVERY_EPIC];
880
+ export const isProductLevel = (state) => PRODUCT_KINDS.includes(state?.kind);
881
+
882
+ // Does a step's recorded artifact agree with its catalogue row? The same artifact always does. One
883
+ // other answer is right, and only for the Foundation's two steps: `discovery/`. A Foundation that
884
+ // `yad migrate` converted from the old spelling keeps binding to the six files it was approved
885
+ // against, so its fingerprint — and every approval on it — survives the move (shape 8). Reporting that
886
+ // as "bound to the wrong file" would be false, and it would fire on every converted project.
887
+ export const artifactAgrees = (def, artifact) => artifactBase(artifact) === artifactBase(def.artifact)
888
+ || (def.level === 'product' && artifactBase(def.artifact) === 'foundation' && artifactBase(artifact) === 'discovery');
889
+ // The terminal sentinel of each spelling — also the `yad next` action kind that says "finished".
890
+ export const PRODUCT_DONE = PRODUCT_KINDS.map((k) => `${k}-done`);
891
+
152
892
  // Deterministic fingerprint of the discovery set: hash every file in the fixed DISCOVERY_FILES order,
153
893
  // combine. The WHOLE set is the reviewable unit — if any required artifact is missing the discovery is
154
894
  // incomplete and NON-REVIEWABLE, so this returns null (no hash to bind an approval to), the same
155
895
  // "nothing to lock" signal storiesHash/contractSurfaceHash give for an absent/malformed surface. Once
156
896
  // the full set exists, an edit (or deletion) of any file changes the hash and revokes prior approvals.
157
- export function discoveryHash(epicDir) {
897
+ export function discoveryHash(epicDir, sha = reviewedSha) {
158
898
  if (!DISCOVERY_FILES.every((f) => fs.existsSync(path.join(epicDir, f)))) return null;
159
- const parts = DISCOVERY_FILES.map((f) => `${f}:${fileSha(path.join(epicDir, f))}`);
899
+ const parts = DISCOVERY_FILES.map((f) => `${f}:${sha(path.join(epicDir, f))}`);
160
900
  return 'sha256:' + createHash('sha256').update(parts.join('\n')).digest('hex');
161
901
  }
162
902
 
163
903
  // The content fingerprint an approval is bound to. For architecture the fingerprint is the locked
164
904
  // contract surface (a re-lock => stale); for stories it is the whole stories/ set; for discovery it is
165
- // the whole discovery file set; for every other artifact it is the file's bytes.
166
- export function artifactHash(epicDir, artifact) {
905
+ // the whole discovery file set; for every other artifact it is the file. Every file is fingerprinted
906
+ // without its frontmatter `status:` line (see `reviewedSha`). `sha` swaps that for a legacy form —
907
+ // only `acceptedHashes` passes one.
908
+ export function artifactHash(epicDir, artifact, sha = reviewedSha) {
167
909
  const b = artifactBase(artifact);
168
910
  if (b === 'architecture') return contractSurfaceHash(epicDir);
169
- if (b === 'stories') return storiesHash(epicDir);
170
- if (b === 'discovery') return discoveryHash(epicDir);
171
- return fileSha(path.join(epicDir, artifact.replace(/\/$/, '')));
911
+ if (b === 'stories') return storiesHash(epicDir, sha);
912
+ if (b === 'discovery') return discoveryHash(epicDir, sha);
913
+ if (b === 'foundation') return foundationHash(epicDir, FOUNDATION_SECTIONS, sha);
914
+ return sha(path.join(epicDir, artifact.replace(/\/$/, '')));
915
+ }
916
+
917
+ // Every fingerprint that still means "the content these reviewers approved": today's first, then the
918
+ // legacy forms (see LEGACY_FORMS). Empty when there is nothing to fingerprint — the same "no claim"
919
+ // answer a null `artifactHash` gives. What is WRITTEN is always `artifactHash`; this is for reading.
920
+ export function acceptedHashes(epicDir, artifact) {
921
+ const current = artifactHash(epicDir, artifact);
922
+ if (current === null) return [];
923
+ return [...new Set([current, ...LEGACY_FORMS.map((sha) => artifactHash(epicDir, artifact, sha))].filter(Boolean))];
172
924
  }
173
925
 
926
+ // Is a recorded fingerprint stale against the accepted ones? Never when nothing was recorded (a manual
927
+ // approval with no hash) or nothing can be fingerprinted — both are "no claim", as they always were.
928
+ export const isStaleHash = (recorded, accepted) => !!recorded && accepted.length > 0 && !accepted.includes(recorded);
929
+
174
930
  // Shape checks for the ledger files. Fail fast with the exact file named — a wrong-shape ledger
175
931
  // silently treated as a default would be rewritten by the next sync, destroying the real data.
176
932
  const badShape = (file, what) => err('YAD-STATE-002', `${file}: ${what}`, 'fix the file or restore it from git');
@@ -191,7 +947,7 @@ function validateState(state, file) {
191
947
  return state;
192
948
  }
193
949
 
194
- // Every build-state/<story>.json under the epic, story-sorted. Missing dir = the build half hasn't
950
+ // Every build-state/<story>.json under the epic, story-sorted. Missing dir = Build hasn't
195
951
  // started yet, a normal state → []. The per-story files drive `yad next`'s build sub-step guidance —
196
952
  // advisory, read-only hints, NOT a source-of-truth ledger. So a corrupt file is skipped (non-throwing
197
953
  // `readJSON`), not fatal: `yad next` (and especially the all-epics roll-up) must still orient the user
@@ -203,6 +959,483 @@ function loadBuildStates(dir) {
203
959
  .filter((bs) => bs && typeof bs === 'object' && !Array.isArray(bs));
204
960
  }
205
961
 
962
+ // ---- the step-state model (E38) -------------------------------------------------------------------
963
+ //
964
+ // WHAT A STEP'S `status` MEANS, in one table, for the first time. Until now the answer was four words
965
+ // hard-coded in about thirty places with two boolean flags beside them, and every reader decided for
966
+ // itself what each one implied.
967
+ //
968
+ // The roadmap's model (docs/roadmap-idea-1.md, Part 1, "Step states") names SEVEN states. The table
969
+ // below has EIGHT rows, because `in_review` is kept beside them (see the note at the end of this
970
+ // comment). The two questions those thirty readers were really asking are its two columns:
971
+ //
972
+ // passed may the chain continue past this step, and does a later step see it as satisfied?
973
+ // authored was the artifact actually WRITTEN HERE? (a skip and an inheritance are not)
974
+ //
975
+ // Those are not one question, and running them together is the bug this table exists to stop. Both
976
+ // used to be spelled `status === 'done'`, which is why a skipped step had to be pre-marked `done` to
977
+ // get past the first group and carry a flag to be excluded from the second. Which reader wants which:
978
+ //
979
+ // isPassed `preconditionsMet`'s blocker scan · `restoreStep`'s `priorAllDone` ·
980
+ // `closeAuthorStep`'s "already finished" guard (stamping `done` over a SKIPPED author
981
+ // step would destroy the provenance of the skip) · `gate sync`'s `alreadyDone` ·
982
+ // `buildNextForRepo`'s active-step scan · `artifact-status`'s "approved"
983
+ // isAuthored `setAsideStep`'s "already authored" refusal
984
+ // == 'done' `stateInvariants` on the REVIEW side and `yad doctor`'s gate-blind check — both ask
985
+ // "did this gate genuinely complete here", which `isAuthored` answers for an author
986
+ // step and this answers for a gate
987
+ // set aside the three step-over scans (`advanceState`, `setAsideStep`, `restoreStep`) walk past a
988
+ // step whose STATUS is `skipped` or `deferred` (`isSetAside`, E37). They read the status,
989
+ // never a legacy flag alone, so `skipped: true` on an unfinished step is not walked past.
990
+ // A status word typed by hand onto a required step IS walked past, as a hand-typed
991
+ // `skipped` always was: the file wins, and `yad doctor` reports it (`skip:not-optional`)
992
+ //
993
+ // A THIRD QUESTION, and it is not either column: what the file CLAIMS about itself. `claimsSkipped` /
994
+ // `claimsInherited` below answer that, for the readers that apply their own guard to the claim.
995
+ //
996
+ // THE NAME COLLISION THIS TASK HAD TO SETTLE. Every release up to shape 6 wrote `blocked` to mean
997
+ // "waiting on an earlier step" — which is what the roadmap calls `todo`. The roadmap gives `blocked` a
998
+ // DIFFERENT meaning: cannot proceed, and not by our choice. One string, two meanings.
999
+ //
1000
+ // The disambiguator is the RECORD, not the schema version:
1001
+ //
1002
+ // `blocked` + no `record` == todo — everything written before shape 7
1003
+ // `blocked` + a `record` == blocked — genuinely stuck; the record says on whom or what
1004
+ //
1005
+ // That is unambiguous BY CONSTRUCTION, because no release has ever written a `record`. Keying it on
1006
+ // `schemaVersion` instead was the obvious alternative and is worse: `yad migrate` never rewrites
1007
+ // `state.json` on a VERIFIED project — CI is its only writer, so the file is reported `ci-owned` and
1008
+ // skipped — which leaves such a project at shape 6 until its next gate write, and several readers
1009
+ // (`cli/doctor.mjs`, `cli/artifact-status.mjs`) load the file raw and have no shape number in hand.
1010
+ // A meaning that depends on a number in another key is also a meaning a hand-edit can flip.
1011
+ //
1012
+ // Shape 7 writes `todo` going forward, and `blocked`-with-no-record stays READ as `todo` for this
1013
+ // whole major — rule 2, read old and write new.
1014
+ //
1015
+ // THE CONVERSE: clearing a `blocked` must move `status` OFF `blocked`. Deleting only the record turns the
1016
+ // step silently back into a `todo`. `unblockStep` (`yad unblock`, E37) is that verb, and it does both in
1017
+ // one write.
1018
+ //
1019
+ // `in_review` is not in the roadmap's table and stays on purpose. It is `in_progress` on a
1020
+ // `review+approve` step, and `markInReview`, `advanceState` and `cli/artifact-status.mjs` all key on
1021
+ // that difference — an artifact whose gate is open reads `in-review`, one still being written reads
1022
+ // `draft`. The table's `in-progress` hyphen is prose: the hyphen appears nowhere in code and in no
1023
+ // file on disk, where the word has always been `in_progress`.
1024
+ //
1025
+ // WHO WRITES THE RECORDED STATES. `yad skip` writes `skipped` (E35, E36) and `yad defer` writes
1026
+ // `deferred` (E37), both THROUGH this table rather than beside it. Nothing in the CLI writes `blocked`:
1027
+ // a person does, by hand, and the `yad-run` skill does for a halted Build lane. `yad unblock` clears one
1028
+ // (E37). `cli/test.mjs` still constructs a chain carrying every row of this table and pins each reader
1029
+ // against it, because a state the fixtures happen not to use is untested.
1030
+ //
1031
+ // `debt: true` (E41) is a FLAG BESIDE a state, not a ninth row: it changes nothing any column above
1032
+ // answers. `yad defer --debt` writes it on a deferred pair — work the team set aside under pressure and
1033
+ // owes back — and it only decides what REMINDS (`owedSteps`: `yad next`, `yad doctor`). It stays on the
1034
+ // pair while the step is paid back, and `advanceState` removes it when the step's review passes.
1035
+ export const STEP_STATES = [
1036
+ { id: 'todo', passed: false, authored: false, record: false, meaning: 'not started' },
1037
+ { id: 'in_progress', passed: false, authored: false, record: false, meaning: 'being worked on' },
1038
+ { id: 'in_review', passed: false, authored: false, record: false, meaning: 'its review gate is open' },
1039
+ { id: 'done', passed: true, authored: true, record: false, meaning: 'completed here' },
1040
+ { id: 'skipped', passed: true, authored: false, record: true, meaning: 'consciously chose not to' },
1041
+ { id: 'deferred', passed: true, authored: false, record: true, meaning: 'will do it, later' },
1042
+ { id: 'satisfied', passed: true, authored: false, record: true, meaning: 'done elsewhere' },
1043
+ { id: 'blocked', passed: false, authored: false, record: true, meaning: 'cannot proceed, not our choice' },
1044
+ ];
1045
+
1046
+ const STATE_BY_ID = new Map(STEP_STATES.map((s) => [s.id, s]));
1047
+
1048
+ // The row for a canonical state id, or null for one this release does not know.
1049
+ export const stepStateDef = (id) => STATE_BY_ID.get(String(id || '')) || null;
1050
+
1051
+ // Which states must carry a `record` to be meaningful — the four the roadmap gives a "Must record"
1052
+ // column. Read by `yad doctor`, which reports a recorded state with nothing recorded on it.
1053
+ export const RECORDED_STEP_STATES = STEP_STATES.filter((s) => s.record).map((s) => s.id);
1054
+
1055
+ // The `record` a recorded state carries: `{ reason, by, date, link? }` — why, who, when, and (for
1056
+ // `satisfied`) where the work actually happened. Only `reason` is required. `by` and `date` are
1057
+ // best-effort and may be null, exactly as `skippedBy` / `skippedAt` have always allowed: attribution
1058
+ // is a nicety on the audit trail, never a gate, so a missing git identity must not block a skip.
1059
+ export const isStepRecord = (r) => isPlainObject(r) && typeof r.reason === 'string' && !!r.reason.trim();
1060
+
1061
+ // A record object, from the fields `yad skip` and the threading seed already collect.
1062
+ export const stepRecord = ({ reason, by = null, date = null, link = null }) => ({
1063
+ reason: String(reason || '').trim(),
1064
+ by: by || null,
1065
+ date: date || null,
1066
+ ...(link ? { link } : {}),
1067
+ });
1068
+
1069
+ // THE ONE READER. A step's canonical state, whatever encoding its file happens to use.
1070
+ //
1071
+ // Three legacy encodings are translated HERE and nowhere else:
1072
+ // * `inherited: true` BESIDE `status: 'done'` -> `satisfied` — Phase 6 threading: the artifact
1073
+ // is carried by reference and was reviewed in the parent, which is exactly "done elsewhere".
1074
+ // * `skipped: true` BESIDE `status: 'done'` -> `skipped` — E35's skip stamp.
1075
+ // * `blocked` with no `record` -> `todo` — every file written before shape 7.
1076
+ //
1077
+ // THE PAIRING IS LOAD-BEARING, and a first cut of this function got it wrong by reading the flag
1078
+ // alone. Both writers have always stamped the flag AND pre-marked the step `done`, and it is the
1079
+ // `done` that made the chain move past it. A flag on its own is a hand edit — and reading that as a
1080
+ // finished step would let `skipped: true`, typed into a file by someone with no route permission to
1081
+ // skip anything, unblock every step behind it. So `status` stays the field that decides, and a flag
1082
+ // only says which KIND of finish it was. `claimsSkipped` / `claimsInherited` below are for the
1083
+ // separate question of what the file CLAIMS, which is not the same as what the chain concludes.
1084
+ //
1085
+ // BOTH FLAGS ARE STILL WRITTEN as well as read — rule 3, add before you remove — so a 3.x reader
1086
+ // keying on `.skipped` or `.inherited` keeps working for this whole major.
1087
+ //
1088
+ // An UNRECOGNISED status is NOT an error. A project may legitimately hold a step written by a newer
1089
+ // yadflow, and for this major THE FILE WINS — the same discipline as `workItemType` and the step
1090
+ // catalogue. It reads as `null`, which `isPassed` and `isAuthored` both answer false to (fail
1091
+ // closed: an unknown state never lets a chain past it), and which `yad doctor` reports as
1092
+ // `step:unknown-status` and changes nothing about.
1093
+ export function stepStatus(step) {
1094
+ if (!isPlainObject(step)) return null;
1095
+ const raw = typeof step.status === 'string' ? step.status : '';
1096
+ // Checked in the order `gatePredicate` short-circuits the two legacy FLAGS, so a step carrying both
1097
+ // of those (which no writer produces) reads one way here and the same way there. The new spellings
1098
+ // are not covered by that: `{ status: 'skipped', inherited: true }` reads `skipped` here and takes
1099
+ // the inherited branch there. No writer produces that either, and neither reading is wrong — it is
1100
+ // simply not a promise this function can keep across two fields that cannot both be `status`.
1101
+ if (raw === 'done' && step.inherited) return 'satisfied';
1102
+ if (raw === 'done' && step.skipped) return 'skipped';
1103
+ if (raw === 'blocked' && !isStepRecord(step.record)) return 'todo';
1104
+ return STATE_BY_ID.has(raw) ? raw : null;
1105
+ }
1106
+
1107
+ // WHAT THE FILE CLAIMS, in either spelling — the shape-7 `status` or the legacy flag beside it. A
1108
+ // different question from `stepStatus`, and the two must not be run together:
1109
+ //
1110
+ // * `stepStatus` answers what the CHAIN concludes, so it needs the flag to be paired with the
1111
+ // `done` that every writer put beside it.
1112
+ // * these answer what the step SAYS ABOUT ITSELF, whatever state it is in — which is what a
1113
+ // reader wants when it is about to apply its own guard to the claim. `gatePredicate` honours a
1114
+ // skip only on a step this epic's ROUTE marks optional (`isSkippableStep`), and `yad doctor`
1115
+ // reports the ones it does not — both need to see a claim before they can refuse it, and a
1116
+ // half-stamped or hand-edited claim is exactly the one worth reporting.
1117
+ export const claimsSkipped = (step) => step?.skipped === true || step?.status === 'skipped';
1118
+ // ASYMMETRY WORTH NAMING: `gatePredicate` puts a ROUTE guard on the skip claim (`isSkippableStep`) and
1119
+ // none on this one, so a hand-edited `inherited` passes any gate. That is not an oversight — a skip is
1120
+ // a decision THIS epic makes about its own chain, so the route is what says whether it may; an
1121
+ // inheritance is a claim about a DIFFERENT epic, and the thing that validates it is `boundHash`
1122
+ // against the parent's live artifact, which the predicate already checks. A forged claim with no
1123
+ // hash has nothing to drift from, exactly as a stub parent's does not — the check that catches it is
1124
+ // `yad doctor`'s lineage walk, not the gate. Shape 7 widens the hand-editable surface from one
1125
+ // spelling to two and changes nothing else about that.
1126
+ export const claimsInherited = (step) => step?.inherited === true || step?.status === 'satisfied';
1127
+
1128
+ // May the chain continue past this step — and does a step after it see it as satisfied? True for
1129
+ // `done`, and for the three states that finish a step without authoring it (`skipped`, `deferred`,
1130
+ // `satisfied`). False for anything still outstanding, and for a state this release cannot name.
1131
+ export const isPassed = (step) => !!stepStateDef(stepStatus(step))?.passed;
1132
+
1133
+ // Was this step's artifact actually written HERE? True for `done` alone. A skip has nothing to show,
1134
+ // and an inherited artifact belongs to the parent epic — so neither can be audited for approvals or
1135
+ // repaired as a stranded author step.
1136
+ export const isAuthored = (step) => !!stepStateDef(stepStatus(step))?.authored;
1137
+
1138
+ // ---- the one writer of `state.json` --------------------------------------------------------------
1139
+ // Every save of an epic's step state goes through here, and there is exactly one of these on purpose.
1140
+ //
1141
+ // On a VERIFIED project `yad migrate` never rewrites `state.json` — CI is its only writer, so the
1142
+ // command reports the file as `ci-owned` and skips it (cli/migrate.mjs). That is correct, and it
1143
+ // means the shape-4 dials would otherwise never reach `state.json` on precisely the projects that
1144
+ // most need their records consistent: the Build state would gain `driver`/`advance` while the Shape
1145
+ // state kept only the old names, forever, with no command a user could run to close it.
1146
+ //
1147
+ // So the gate's own write carries the migration. Same rule as everywhere else in shape 4: ADD the new
1148
+ // name beside the old, translate rather than copy, and never write `advance: auto` onto a step a
1149
+ // human must sign off.
1150
+ //
1151
+ // Routing every writer through one function is the point. E30 shipped a reviewer who could be added
1152
+ // and never removed because one writer out of several was left on the old key, and the test suite was
1153
+ // green. A single writer cannot be half-updated.
1154
+ export function stampStepDials(state) {
1155
+ if (!state || typeof state !== 'object' || !Array.isArray(state.steps)) return state;
1156
+ let moved = false;
1157
+ const steps = state.steps.map((s) => {
1158
+ if (!isPlainObject(s)) return s;
1159
+ const out = { ...s };
1160
+ // Object.hasOwn, not a bare lookup: a value like "toString" would otherwise resolve through the
1161
+ // prototype to a function, get assigned as the dial, and mark the state as changed.
1162
+ if (typeof s.assistance === 'string' && !('driver' in s) && Object.hasOwn(DRIVER_FROM_ASSISTANCE, s.assistance)) {
1163
+ out.driver = DRIVER_FROM_ASSISTANCE[s.assistance];
1164
+ moved = true;
1165
+ }
1166
+ if (typeof s.automation === 'string' && !('advance' in s) && Object.hasOwn(ADVANCE_FROM_AUTOMATION, s.automation)) {
1167
+ out.advance = isGateStep(s) ? 'human' : ADVANCE_FROM_AUTOMATION[s.automation];
1168
+ moved = true;
1169
+ }
1170
+ return out;
1171
+ });
1172
+ // Return the SAME object when nothing changed. `writeJSON` short-circuits on identical bytes, and
1173
+ // handing back a fresh object either way would make that comparison the only thing standing between
1174
+ // a read-only gate path and a spurious write.
1175
+ return moved ? { ...state, steps } : state;
1176
+ }
1177
+
1178
+ // Copy the work-item type into `state.json`. Shape 5.
1179
+ //
1180
+ // The type is AUTHORED in `epic.md` frontmatter and copied here, because `state.json` is the file CI
1181
+ // writes and the gates read. Opening a hand-edited markdown file from inside a gate decision would
1182
+ // make the ledger depend on something the engine does not own. `yad epic new` (E17) seeds a whole
1183
+ // step chain from the type, and it needs the answer in the ledger.
1184
+ //
1185
+ // TWO OTHER WORDS IN THIS FILE LOOK LIKE THIS ONE AND ARE NOT IT. Neither is touched here:
1186
+ // * top-level `kind` in state.json is the lifecycle marker `"stub"` / `"discovery"`. A stub epic
1187
+ // legitimately carries `kind: "stub"` AND `type: "feature"` at once.
1188
+ // * `steps[].type` is `author` / `review+approve` — which kind of STEP, not which kind of work.
1189
+ //
1190
+ // Add-only, and idempotent, so a second `yad migrate` and every later gate write are no-ops:
1191
+ // * a state that already records a `type` is returned untouched, whatever it says;
1192
+ // * so is one whose epic has no `epic.md` to read a type from. That is the discovery front-zero
1193
+ // (`EP-discovery`, `kind: "discovery"`), which is not a work item on the ladder and has no
1194
+ // frontmatter to copy. Inventing `feature` for it would put a fifth thing on the ladder that
1195
+ // nobody authored.
1196
+ //
1197
+ // ONE of these, called from `writeState` below and from the 4 -> 5 step in cli/migrate.mjs. E28
1198
+ // shipped two dial stampers and they drifted — one guarded array-valued steps, the other did not.
1199
+ export function stampWorkItemType(state, epicDir) {
1200
+ // ANY `type` key already present is left alone, not just a string one. Overwriting a value
1201
+ // somebody wrote — even `null`, even a number — would be this function deciding what their file
1202
+ // meant, during an upgrade they ran to be safe. `yad doctor` is what reports a value it does not
1203
+ // understand; silently correcting one is the behaviour the whole shape-5 design refuses.
1204
+ if (!isPlainObject(state) || 'type' in state) return state;
1205
+ const md = path.join(epicDir, 'epic.md');
1206
+ if (!fs.existsSync(md)) return state;
1207
+ const type = workItemType(readFrontmatter(md));
1208
+ // Placed at the TOP of the file, not appended. `{ ...state, type }` is the one-line version and it
1209
+ // puts the key last, which on a real state.json means `"type": "feature"` dangling at the bottom
1210
+ // directly beneath a `steps` array whose every entry carries its own `"type"` — the exact two-axis
1211
+ // confusion the rest of this change exists to prevent. JSON key order IS the file's bytes, so the
1212
+ // object is rebuilt in order rather than spread and assigned.
1213
+ //
1214
+ // Idempotent either way: a state that already records a type returns above, keeping the position it
1215
+ // has, so a file seeded by a skill is never reordered by a later engine write.
1216
+ //
1217
+ // `profile` is in the anchor list, and that is what makes the canonical order `type, profile`
1218
+ // unconditional rather than a happy accident of the order `writeState` calls the two stampers in.
1219
+ // The migration chain adds `type` at 4 -> 5 and `profile` at 5 -> 6, so it always produces that
1220
+ // order. A gate write does not always have both to add at the same moment: an epic whose ledger
1221
+ // reached shape 5 before its `epic.md` existed (seeded by `yad-analysis`, migrated during the
1222
+ // upgrade, the epic authored afterwards) gets `profile` from the migration and `type` only from a
1223
+ // LATER gate write. Without this anchor that write would append `type` after `profile`, and a
1224
+ // verified project would hold a key order no local project can produce.
1225
+ const out = {};
1226
+ for (const [k, v] of Object.entries(state)) {
1227
+ if ((k === 'profile' || k === 'currentStep' || k === 'steps') && !('type' in out)) out.type = type;
1228
+ out[k] = v;
1229
+ }
1230
+ if (!('type' in out)) out.type = type;
1231
+ return out;
1232
+ }
1233
+
1234
+ // Record which lifecycle profile the chain came from. Shape 6.
1235
+ //
1236
+ // A PROFILE is the route an epic takes through the lifecycle — the named, ordered chain of catalogue
1237
+ // steps E5 wrote down. Until now nothing on disk said which one an epic was on, and every reader
1238
+ // worked it out by matching the chain. That is a fine answer for a report and a bad one for a seed:
1239
+ // `yad epic new` has to know the route BEFORE there is a chain to match, and an epic that legitimately
1240
+ // dropped steps reads as a shorter route than the one it was started on.
1241
+ //
1242
+ // DERIVED, NEVER DEFAULTED, for an epic that already exists. The value comes from
1243
+ // `matchLifecycleProfile`, which returns null for a chain that fits no route — and a null means NO
1244
+ // KEY, not `classic`. Stamping a guess would put a route on the record that nobody chose, and
1245
+ // `yad doctor` would then stop reporting the chain as off-route because the file would finally agree
1246
+ // with itself. `yad doctor` reports the gap; an upgrade never invents an answer for it.
1247
+ //
1248
+ // Add-only and idempotent, exactly like `stampWorkItemType`: ANY `profile` key already present is
1249
+ // left alone, whatever it says. A project may carry a route from a newer yadflow, and a hand-written
1250
+ // chain is allowed to be ahead of the tool reading it (rule 3 — the file wins for this whole major).
1251
+ //
1252
+ // ONE of these, called from `writeState` below and from the 5 -> 6 step in cli/migrate.mjs, because
1253
+ // on a VERIFIED Product `yad migrate` never rewrites `state.json` — CI is its only writer, so the
1254
+ // gate's own write is the only path a shape change has into that file.
1255
+ //
1256
+ // MATCHED AGAINST A FROZEN ROUTE SET, not against `LIFECYCLE_PROFILES`. A migration answers a question
1257
+ // about the PAST — "which of the routes that existed when shape 6 landed is this chain on" — and
1258
+ // reading the live table lets a route added later change that answer.
1259
+ //
1260
+ // `matchLifecycleProfile` breaks ties on the SHORTEST fitting route, and E40 adds two routes shorter
1261
+ // than `classic`. Every chain drawn only from `epic`, `epic-review`, `stories` and `stories-review`
1262
+ // therefore changes route the day the chore lane lands: a chain that stops at `epic-review`, or one
1263
+ // hand-edited down to those steps, is the shape this catches. Against the live table an upgrade would
1264
+ // relabel such an epic, and `optionalStepsFor` would answer from the new route for good, because the
1265
+ // key just written is the one every later reader prefers over matching.
1266
+ //
1267
+ // NO SEED yadflow HAS EVER SHIPPED IS AFFECTED — every one of them carries `architecture`, which no
1268
+ // short lane has, so a seeded chain still matches `classic` either way. HAND-WRITTEN CHAINS ARE, and
1269
+ // this repo holds two: the `EP-e2e` and `EP-cici` fixtures in test/e2e/run.sh are `[epic, epic-review]`
1270
+ // with no `profile` key, which the live table now places on `chore`. `yad gate ci` stamps them through
1271
+ // `writeState`, so they are a live exercise of this function, and without the freeze they would come
1272
+ // out labelled `chore`. Rule 3 is the general form of the same point: a hand-written chain is allowed
1273
+ // to be ahead of the tool reading it, and a migration that is correct only because of what today's
1274
+ // data happens to contain is one release away from being wrong.
1275
+ //
1276
+ // The freeze pins the SET OF ROUTES shape 6 may choose from, not the step rows inside them. Editing
1277
+ // `classic`'s own chain would still move what fits it. That is the right scope — a route's steps are
1278
+ // its definition, and a migration reading a stale copy of them would be the drift this file spends
1279
+ // most of its comments avoiding — but it means the guarantee is against ADDED routes, not all change.
1280
+ //
1281
+ // `shape6Routes()` is the freeze and `profiles` is the seam the tests drive it through. Epics seeded
1282
+ // by `yad epic new` are unaffected either way: `seedState` writes `profile` itself, and the first line
1283
+ // here leaves any key already present alone — so a chore-lane epic keeps its own route.
1284
+ //
1285
+ // The freeze is about STAMPING, not about reading. An epic whose chain genuinely is chore-shaped and
1286
+ // which carries no key gets `classic` here, and `yad doctor` then reports `profile:disagree` against
1287
+ // the live table. That is the right split: an upgrade records what was true, and the report is what
1288
+ // tells a person the label wants correcting. The remedy is to set `profile` by hand, once.
1289
+ export function stampProfile(state, profiles = shape6Routes()) {
1290
+ if (!isPlainObject(state) || 'profile' in state) return state;
1291
+ const profile = matchLifecycleProfile(state.steps, profiles);
1292
+ if (!profile) return state;
1293
+ // Placed beside `type` at the TOP, for the same reason: `{ ...state, profile }` would leave
1294
+ // `"profile": "classic"` dangling under the `steps` array, where the eye reads it as a property of
1295
+ // the last step rather than of the epic. JSON key order IS the file's bytes, so the object is
1296
+ // rebuilt in order rather than spread and assigned.
1297
+ //
1298
+ // No trailing "if it still is not there, append it" guard, unlike `stampWorkItemType`. That guard is
1299
+ // reachable there because that stamper runs on states with no `steps` at all; here a profile is only
1300
+ // ever derived FROM `steps`, so reaching this loop means `steps` is present and the `k === 'steps'`
1301
+ // arm has already placed the key. A line that cannot run is a line no test can cover.
1302
+ const out = {};
1303
+ for (const [k, v] of Object.entries(state)) {
1304
+ if ((k === 'currentStep' || k === 'steps') && !('profile' in out)) out.profile = profile;
1305
+ out[k] = v;
1306
+ }
1307
+ return out;
1308
+ }
1309
+
1310
+ // Move a `state.json`'s recorded shape up to this engine's, AFTER both stampers above have run.
1311
+ //
1312
+ // This is the other half of the verified-mode gap, and without it the first half is a half-truth.
1313
+ // On a verified Product `yad migrate` reports `state.json` as `ci-owned` and never writes it, so the
1314
+ // stampers are what carry a shape change into that file. They carried the FIELDS. They did not carry
1315
+ // the NUMBER, because `writeShape` (cli/lib.mjs) preserves a stamp that is already there — the right
1316
+ // rule for every other caller, and the wrong one for the one file no migration will ever reach.
1317
+ //
1318
+ // The effect was permanent and silent. yadflow 3.18.1 stamps `schemaVersion: 1`, so every verified
1319
+ // project on disk today holds a `state.json` recording shape 1. Its fields would be brought fully up
1320
+ // to date by the next gate write while the number stayed at 1 for ever, and `yad doctor` would keep
1321
+ // reporting "N are CI-owned and behind" with the hint "CI owns these files and moves them on its next
1322
+ // gate sync" — a warning that could never clear, attached to a sentence that was not true.
1323
+ //
1324
+ // WHAT MAKES THIS CORRECT is an invariant, not an assumption: for `state.json`, running the whole
1325
+ // migration chain must produce exactly what these stampers produce — in the same ORDER, down to the
1326
+ // bytes. Two things hold that up and both are load-bearing. The stampers are called here in the
1327
+ // migration's own order (dials, type, profile), and each inserts its key in front of every key a LATER
1328
+ // shape adds, so a gate write that supplies one of them long after the other still lands it in the
1329
+ // canonical position. Without the second, a ledger that reached shape 5 before its `epic.md` existed
1330
+ // would come out `profile, type` while every migrated project reads `type, profile`. It holds today — shapes 2 and 3
1331
+ // change `hub.json` and the roster, shape 4 is the dials (`stampStepDials`), shape 5 is the work-item
1332
+ // type (`stampWorkItemType`). cli/test-migrate.mjs pins it by migrating one project, gate-writing
1333
+ // another, and comparing the two files byte for byte. A future shape that changes `state.json`
1334
+ // without adding a stamper here fails that test loudly rather than mis-stamping a file quietly.
1335
+ //
1336
+ // Never DOWN. A file recording a shape newer than this engine understands is left exactly as it is:
1337
+ // `yad migrate` reports that one `ahead` and refuses it for the same reason, and lowering the number
1338
+ // would have this release claim it wrote something it cannot read.
1339
+ const atEngineShape = (state) => (
1340
+ isPlainObject(state) && Number.isInteger(state.schemaVersion) && state.schemaVersion < SCHEMA_VERSION
1341
+ ? { ...state, schemaVersion: SCHEMA_VERSION }
1342
+ : state
1343
+ );
1344
+
1345
+ // Move every step's `status` onto the step-state model. Shape 7 (E38).
1346
+ //
1347
+ // Three rewrites, and every one is a TRANSLATION of something the file already said — never a new
1348
+ // fact, which is the same discipline as the dial stamp above and the type/profile copies below:
1349
+ //
1350
+ // `blocked` with no `record` -> `todo` `blocked` changes meaning in shape 7, so the old
1351
+ // spelling of "not started" is written out here, while
1352
+ // the file still says it unambiguously.
1353
+ // `skipped: true` -> `skipped` the step's own state, with a `record` assembled from
1354
+ // the `skipReason` / `skippedBy` / `skippedAt` the
1355
+ // skip already recorded. Nothing is invented.
1356
+ // `inherited: true` -> `satisfied` done elsewhere: the artifact is carried by reference
1357
+ // and was reviewed in the parent, which `record.link`
1358
+ // names from the `inheritedFrom` already on the step.
1359
+ //
1360
+ // EVERY LEGACY FIELD IS KEPT — `skipped`, `skipReason`, `skippedBy`, `skippedAt`, `inherited`,
1361
+ // `inheritedFrom`, `boundHash`. Rule 3, add before you remove, and the fourth time this engine makes
1362
+ // that choice. `stepStatus` translates the flags either way, so a chain written by a skill that has
1363
+ // not been refreshed by `yad update` reads exactly like a migrated one.
1364
+ //
1365
+ // `boundHash` is deliberately NOT folded into the record. It is evidence the gate compares against a
1366
+ // live hash (`gatePredicate`), not provenance a person reads — a record is why/who/when/where.
1367
+ //
1368
+ // WHAT THIS DOES NOT TOUCH, and the one place the shape-7 story is genuinely awkward.
1369
+ // `build-state/<story>.json` holds the same status words and is written by the `yad-run` and
1370
+ // `yad-implement` SKILLS, not by the engine — so a rewrite here would be undone by their next write,
1371
+ // and it is left alone. The awkward part is that those skills are the one existing writer of
1372
+ // `blocked` in the NEW sense: `yad-run` halts a lane on a failed check, a scope overrun or a contract
1373
+ // touch and marks the step `blocked`, meaning exactly "cannot proceed, not our choice". They now
1374
+ // write a `record` with the halt cause alongside it (see their step-4 HALT branch), which is what
1375
+ // keeps that halt distinguishable from a lane nobody started.
1376
+ //
1377
+ // A lane halted by an OLDER yad-run carries a bare `blocked` and therefore reads as `todo`. Nothing
1378
+ // advances past it either way — `isPassed` is false for both — so what is lost is the word, until
1379
+ // the next run rewrites the file. That is the cost of not migrating a file whose writer is a skill,
1380
+ // and it is smaller than rewriting a file the next write would undo. `buildNextForRepo` goes through
1381
+ // `stepStatus` like every other reader.
1382
+ //
1383
+ // Add-only and idempotent: a step whose `status` already IS its canonical state is returned
1384
+ // untouched, so a second `yad migrate` and every later gate write are no-ops. A status this release
1385
+ // cannot name is left exactly as it stands — the file wins (rule 3) — and `yad doctor` reports it as
1386
+ // `step:unknown-status` rather than this function guessing what it meant.
1387
+ //
1388
+ // ONE of these, called from `writeState` below and from the 6 -> 7 step in cli/migrate.mjs. Both
1389
+ // paths must produce byte-identical files (cli/test-migrate.mjs pins it), which is why the rewrite
1390
+ // keeps `status` in the position it already held (spreading then overriding an existing key does not
1391
+ // move it) and appends `record` at the end.
1392
+ export function stampStepStates(state) {
1393
+ if (!isPlainObject(state) || !Array.isArray(state.steps)) return state;
1394
+ let moved = false;
1395
+ const steps = state.steps.map((s) => {
1396
+ if (!isPlainObject(s)) return s;
1397
+ const canonical = stepStatus(s);
1398
+ if (!canonical || canonical === s.status) return s;
1399
+ const out = { ...s, status: canonical };
1400
+ // ANY record already on the step is left alone — `isPlainObject`, deliberately not `isStepRecord`.
1401
+ // It is the team's own sentence about their own step, and an upgrade rewriting it would be this
1402
+ // function deciding what they meant. That includes a MALFORMED one: `{ by: '@al', note: 'ticket
1403
+ // 4412' }` has no `reason` and is not a valid record, and replacing it with a generated one would
1404
+ // throw away the fields they did write. `yad doctor` reports it as `step:no-record` instead, which
1405
+ // is the same split this whole file keeps — report, never correct.
1406
+ if (!isPlainObject(out.record)) {
1407
+ if (canonical === 'skipped') {
1408
+ out.record = stepRecord({ reason: s.skipReason, by: s.skippedBy, date: s.skippedAt });
1409
+ } else if (canonical === 'satisfied') {
1410
+ out.record = stepRecord({
1411
+ reason: `carried by reference from ${s.inheritedFrom || 'the parent epic'}`,
1412
+ link: s.inheritedFrom || null,
1413
+ });
1414
+ }
1415
+ }
1416
+ moved = true;
1417
+ return out;
1418
+ });
1419
+ // The SAME object when nothing changed, for the reason `stampStepDials` gives: `writeJSON`
1420
+ // short-circuits on identical bytes, and a fresh object every time would defeat that on every
1421
+ // read-only gate path.
1422
+ return moved ? { ...state, steps } : state;
1423
+ }
1424
+
1425
+ export function writeState(file, state) {
1426
+ // `file` is <epicDir>/.sdlc/state.json, so the epic's own directory is two levels up — that is
1427
+ // where `epic.md` lives, and the stamper needs it to read the type the author wrote.
1428
+ const epicDir = path.dirname(path.dirname(file));
1429
+ // Same ORDER as the migration chain in cli/migrate.mjs — dials (4), type (5), profile (6), step
1430
+ // states (7). The two paths must produce byte-identical files (cli/test-migrate.mjs pins it), and
1431
+ // both `type` and `profile` insert themselves in front of `currentStep`, so running them out of
1432
+ // order would swap two keys and make a migrated project's bytes differ from a gate-written one's.
1433
+ // The step-state stamp goes LAST for the same reason and for one of its own: it reads a step's
1434
+ // `status` and its legacy flags, and both are settled by the time the earlier three have run.
1435
+ const stamped = stampStepStates(stampProfile(stampWorkItemType(stampStepDials(state), epicDir)));
1436
+ return writeJSON(file, atEngineShape(stamped));
1437
+ }
1438
+
206
1439
  export function loadLedger(epicDir) {
207
1440
  const f = epicFiles(epicDir);
208
1441
  return {
@@ -210,7 +1443,14 @@ export function loadLedger(epicDir) {
210
1443
  state: validateState(readJSONStrict(f.state, null), f.state),
211
1444
  approvals: requireArray(readJSONStrict(f.approvals, []), f.approvals),
212
1445
  comments: requireArray(readJSONStrict(f.comments, []), f.comments),
213
- hubPrs: requireArray(readJSONStrict(f.hubPrs, []), f.hubPrs),
1446
+ // Read the OLD name while it exists, the new one otherwise — the same tie-break as the settings
1447
+ // file (`preferring`, cli/manifest.mjs). Both are written on every
1448
+ // save (see gateSync / gateOpen), so they agree unless someone edited one by hand — which
1449
+ // `yad doctor` reports rather than leaving to be discovered.
1450
+ hubPrs: (() => {
1451
+ const f2 = preferring(f.productPrs, f.hubPrs);
1452
+ return requireArray(readJSONStrict(f2, []), f2);
1453
+ })(),
214
1454
  contractLock: readJSONStrict(f.contractLock, null),
215
1455
  buildStates: loadBuildStates(f.buildStateDir),
216
1456
  };
@@ -225,8 +1465,14 @@ export function findReviewStep(state, artifact) {
225
1465
  ) || null;
226
1466
  }
227
1467
 
1468
+ // Does this step carry a risk tag that raises its count (`contract`, `auth`, `payments`)? A fact read
1469
+ // from the step's own tags and nothing else. It used to be the trigger of the roster-era rule — a
1470
+ // domain owner per touched repo — and it named `stories-review` BY ID for that rule's per-repo half.
1471
+ // E62 removed that rule with the roster, and the step name went with it. What still reads this: the
1472
+ // `escalated` flag `yad gate status` and the review bundle print, and `touchedDomains` (cli/gate.mjs),
1473
+ // which decides which repos a review PR names and labels.
228
1474
  export const isEscalated = (step) =>
229
- (step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t)) || step?.id === 'stories-review';
1475
+ (step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t));
230
1476
 
231
1477
  // The authoring step paired with a review gate: `stories-review` -> `stories`. Resolved BY ID (the
232
1478
  // same `-review` suffix convention `isSkippableStep` uses), never positionally — a chain may legally
@@ -234,23 +1480,120 @@ export const isEscalated = (step) =>
234
1480
  // at an unrelated step. Returns null when the chain has no such step.
235
1481
  export function authorStepFor(state, reviewStep) {
236
1482
  const id = String(reviewStep?.id || '');
1483
+ // The catalogue names the author step a gate reviews (E4), so a gate that reviews NOTHING says so by
1484
+ // carrying no `reviews` — which is how `engineer-review` stays what it is, the last step of Build
1485
+ // rather than the review of a step called `engineer`. Before this it worked only by accident: the
1486
+ // strip produced `engineer`, and nothing was found because no chain has such a step.
1487
+ const def = stepDef(id);
1488
+ if (def) {
1489
+ if (!def.reviews) return null;
1490
+ return state?.steps?.find((s) => s.id === def.reviews) || null;
1491
+ }
1492
+ // An id this release does not carry — a step from a newer yadflow or a future profile. Fall back to
1493
+ // the convention every gate in the catalogue follows, so an unknown `<x>-review` still finds `<x>`.
237
1494
  if (!id.endsWith('-review')) return null;
238
- return state?.steps?.find((s) => s.id === id.replace(/-review$/, '')) || null;
1495
+ const base = id.replace(/-review$/, '');
1496
+ // …but never against a base the catalogue knows to be something OTHER than a gated Shape step.
1497
+ // `checks-review` is not a step, and `checks` is a Build step nothing gates: resolving it would let
1498
+ // a chain that lists both make `stateInvariants` demand a repair, and `yad gate repair` would then
1499
+ // flip `checks` to done as though the merge gate had reviewed it. Same discipline as `stepPhase`.
1500
+ const baseDef = stepDef(base);
1501
+ if (baseDef && (baseDef.phase === 'build' || baseDef.kind !== 'author')) return null;
1502
+ return state?.steps?.find((s) => s.id === base) || null;
239
1503
  }
240
1504
 
1505
+ // Is this step a GATE — one a human signs off, which may never advance on its own (rule 1)? (E34)
1506
+ //
1507
+ // Three answers, in order. `type: review+approve` is how the Shape chain marks one. For a step the
1508
+ // catalogue knows, its `kind` decides — that is how a Build `engineer-review`, which carries no `type`, is
1509
+ // recognised. And `locked: true` counts ONLY on an id the catalogue does not know, a step from a newer
1510
+ // yadflow, where it is the one signal left.
1511
+ //
1512
+ // `locked` alone used to decide it, and that was wrong for Shape: every seeded Shape step carries
1513
+ // `locked: true`, author steps included, so every Shape author step was pinned to `advance: human`. Since
1514
+ // E34 an author step's advance dial is the team's to set; `locked` on a step the catalogue knows to be
1515
+ // an author step decides nothing. No file changes — only what the readers conclude from it.
1516
+ //
1517
+ // `type` is checked before the catalogue on purpose: a step that SAYS it is a review is treated as one
1518
+ // even if its id names an author step. Of the two ways to be wrong, a gate read as an author step is the
1519
+ // one that breaks rule 1.
1520
+ export const isGateStep = (step) => {
1521
+ if (!isPlainObject(step)) return false;
1522
+ if (step.type === 'review+approve') return true;
1523
+ const def = stepDef(step.id);
1524
+ if (def) return def.kind === 'review';
1525
+ return step.locked === true;
1526
+ };
1527
+
241
1528
  // Closing a review gate implies its artifact was authored — so the CLI, not the authoring skill, is
242
1529
  // what makes `<step>.status = done` true. Without this an author step left at `in_progress` strands
243
1530
  // forever: `preconditionsMet` requires every PRIOR step done, so the parallel `test-cases` track (and
244
1531
  // every later step) stays blocked behind a review that already passed. Idempotent; a no-op on an
245
1532
  // absent step and on a `skipped` one (already `done`, carrying its skip provenance).
246
- // Returns the id it closed, or null.
247
- function closeAuthorStep(state, reviewStep) {
1533
+ // Returns the id it closed, or null. `closed` (E18) is the closing record to stamp on the step it closes.
1534
+ function closeAuthorStep(state, reviewStep, closed = null) {
248
1535
  const author = authorStepFor(state, reviewStep);
249
- if (!author || author.status === 'done') return null;
1536
+ // `isPassed`, not `status === 'done'`: a skipped or inherited author step is already finished, and
1537
+ // stamping `done` over it would erase the reason it never needed authoring.
1538
+ if (!author || isPassed(author)) return null;
250
1539
  author.status = 'done';
1540
+ stampClosed(author, closed);
251
1541
  return author.id;
252
1542
  }
253
1543
 
1544
+ // ---- closing records (E18) -----------------------------------------------------------------------
1545
+ //
1546
+ // A step that becomes `done` says how it closed. Before this, `done` was the one state that recorded
1547
+ // nothing: `skipped`, `deferred`, `satisfied` and `blocked` all carry a `record`, while a passed review
1548
+ // said only `status: "done"`, and who closed it, when and on which PR had to be pieced together from
1549
+ // approvals.json and the PR ledger.
1550
+ //
1551
+ // It is `closed`, NOT `record`. `record` means why a step is not done, and `blocked` is read by whether
1552
+ // it has one, so a second meaning under that key would blur both. The fields:
1553
+ //
1554
+ // by who WROTE the record, as on every record: the local git identity, or CI's on a verified
1555
+ // Product. Never the merger — that is `mergedBy`.
1556
+ // date when the step closed: the merge date for a merge, otherwise the day the command ran.
1557
+ // via how it closed — CLOSED_VIA below.
1558
+ // pr the review PR/MR number, when there is one.
1559
+ // commit the merge commit, when the platform reports it.
1560
+ // hash the artifact hash the step closed on (the one approvals bind to).
1561
+ // mergedBy the platform login that merged the PR, when the platform reports it.
1562
+ // run the trust-log run id, for a Build lane step the `yad-run` skill moved past.
1563
+ // waived `solo` when a REVIEW step passed while solo mode waived its approvals (E10). Absent on a
1564
+ // gate that counted approvals. Never on the author step it closes: the approvals were
1565
+ // waived, the authoring was not, so `closeAuthorStep` is handed its fields one by one.
1566
+ // capped `{ needed, to, active }` when a REVIEW step passed in team mode while the capacity cap
1567
+ // LOWERED its ask (E72): the full count, the capped ask, and the count of people it read.
1568
+ // The ask is reported until E108, so this records what the gate asked, not what held it.
1569
+ // Absent when no cap applied — the count of people was unknown, or it lowered nothing — and
1570
+ // in solo mode, where nothing was counted. Never on the author step, as `waived`.
1571
+ //
1572
+ // FIRST CLOSE WINS: a step that already carries one keeps it. Nothing moves a `done` step back today,
1573
+ // so nothing has to remove one; a writer that ever does must take `closed` with it, as `record` goes
1574
+ // with `blocked`. No shape change: an older release ignores a key it does not know, and nothing reads
1575
+ // `done` differently because this is present. Steps finished before it carry none, and nothing asks.
1576
+ // `approved` is the one no engine command writes: the `yad-review-gate` skill passes a gate by hand on a
1577
+ // Product with no platform, where `advanceState` has no caller and nothing merges.
1578
+ export const CLOSED_VIA = ['merge', 'approved', 'review-passed', 'review-opened', 'repair', 'auto', 'human'];
1579
+
1580
+ export const closingRecord = ({ by = null, date = null, via, pr = null, commit = null, hash = null, mergedBy = null, run = null, waived = null, capped = null } = {}) => ({
1581
+ by: by || null,
1582
+ date: date || null,
1583
+ via,
1584
+ ...(pr != null ? { pr } : {}),
1585
+ ...(commit ? { commit } : {}),
1586
+ ...(hash ? { hash } : {}),
1587
+ ...(mergedBy ? { mergedBy } : {}),
1588
+ ...(run ? { run } : {}),
1589
+ ...(waived ? { waived } : {}),
1590
+ ...(isPlainObject(capped) ? { capped } : {}),
1591
+ });
1592
+
1593
+ function stampClosed(step, closed) {
1594
+ if (isPlainObject(step) && isPlainObject(closed) && !isPlainObject(step.closed)) step.closed = closed;
1595
+ }
1596
+
254
1597
  const uniqueBy = (arr, key) => {
255
1598
  const seen = new Set();
256
1599
  return arr.filter((x) => (seen.has(x[key]) ? false : seen.add(x[key])));
@@ -259,30 +1602,65 @@ const uniqueBy = (arr, key) => {
259
1602
  // PURE gate predicate. Given the step, its approvals, the current content hash, the PR thread/merge
260
1603
  // state and the touched domains, decide whether the gate passes — and exactly what is missing.
261
1604
  // `currentHash` drops any approval bound to a different hash (revoke-on-change). `merged` /
262
- // `threadsResolved` come from the platform; with no bridge they default to the "advance" intent.
1605
+ // `threadsResolved` come from the platform; with a local ledger they default to the "advance" intent.
1606
+ //
1607
+ // ONE APPROVAL RULE (E62). No role and no name is inspected anywhere in it:
1608
+ // * the BASE — `gateRuleFor(step).base` distinct approvers (one). This is what decides `passed`.
1609
+ // * the RISK STEP — the rest of `needed`, capped at `active − 1` (floor 1) when the count of people is
1610
+ // known (E72, `gateCapFor`). Computed, returned as `gateRule`/`cap`/`have`/`short` and printed
1611
+ // wherever a gate reports itself, and it holds NOTHING until E108 lands its escape hatch. See the
1612
+ // long note on `gateRuleFor`: the count of people errs high in the normal case, so an enforced cap
1613
+ // would still lock a small team out with no recorded way out (rule 7).
1614
+ // Approvals are counted as distinct PEOPLE: two approvals from one person are one approver.
1615
+ // Before E62 a role rule read from the roster held the gate (1 owner, 1 reviewer, a domain owner per
1616
+ // touched repo). The `role` and `domain` fields older approvals carry are left on disk and never read.
263
1617
  export function gatePredicate({
264
1618
  step,
265
1619
  approvals,
266
1620
  currentHash = null,
267
- touchedDomains = [],
268
- defaultReviewers = 1,
1621
+ // Every fingerprint that still counts as the reviewed content — `acceptedHashes(epicDir, artifact)`,
1622
+ // which puts `currentHash` first and adds the forms older releases recorded. A caller that passes only
1623
+ // `currentHash` gets exactly the old comparison against that one value.
1624
+ acceptedHashes = null,
269
1625
  threadsResolved = true,
270
1626
  merged = true,
271
1627
  solo = false,
272
1628
  requireEngagement = false,
1629
+ // How many people are ACTIVE right now, Product-wide (E71, `activePeople` in cli/people.mjs), or null
1630
+ // when no source could be read. PASSED IN, never computed here: this function is pure and is called
1631
+ // once per step, while the count is one fact about the whole Product that the CALLER reads once per
1632
+ // command. Computing it here would also walk git from inside the golden fixture — which lives inside
1633
+ // yadflow's own work tree — and fold this repo's committers into a frozen snapshot.
1634
+ //
1635
+ // E72 caps the reported ask with it (`gateCapFor`); `null` never becomes a small number — no cap.
1636
+ active = null,
1637
+ // Which author steps THIS epic's route marks optional — `optionalStepsFor(state)`. Empty means
1638
+ // "no step on this chain may be skipped", and that is the right default for a gate: a caller that
1639
+ // forgets to pass it fails closed, and a `skipped: true` nobody can justify falls through to the
1640
+ // real approvals instead of passing on the strength of the flag alone.
1641
+ optional = [],
273
1642
  }) {
274
1643
  // Phase 6: an INHERITED step (a change-epic carrying a parent artifact by reference) is satisfied
275
1644
  // without re-review — its approval lives upstream in the thread, recorded as an `inherited` provenance
276
1645
  // entry. It is pre-marked `done` in state.json, so the gate is normally never invoked on it; this
277
1646
  // short-circuit makes a direct call safe and surfaces a corrupted boundHash (a referenced artifact
278
1647
  // cannot change under the child, so a mismatch is corruption — re-thread, do not silently pass).
279
- if (step?.inherited) {
280
- const drift = step.boundHash && currentHash && step.boundHash !== currentHash;
1648
+ // COMPARE AGAINST THE OWNER'S COPY. `boundHash` is the hash of the artifact in `inheritedFrom`, and the
1649
+ // child's folder is not always empty where that artifact's name would be: a change-epic writes its own
1650
+ // `epic.md` (the change brief), so hashes taken from the CHILD's folder for a carried `epic-review`
1651
+ // would read as drift that is not there. No caller runs the predicate on a carried step today.
1652
+ const accepted = acceptedHashes ?? (currentHash ? [currentHash] : []);
1653
+ if (claimsInherited(step)) {
1654
+ const drift = isStaleHash(step.boundHash, accepted);
281
1655
  return {
282
1656
  approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
283
1657
  passed: !drift,
284
1658
  missing: drift ? [`inherited artifact drifted from ${step.inheritedFrom || 'parent'} — re-thread`] : [],
285
1659
  rule: 'inherited',
1660
+ // The step's own rule is a fact about the step, so it is reported even where nothing was counted
1661
+ // against it — here the approvals live upstream in the thread, under the parent epic. `have: null`
1662
+ // says "not counted", which is not the same fact as zero approvals.
1663
+ gateRule: gateRuleFor(step), have: null, short: 0, active, cap: gateCapFor(gateRuleFor(step), active),
286
1664
  };
287
1665
  }
288
1666
 
@@ -293,16 +1671,23 @@ export function gatePredicate({
293
1671
  // GUARD: only honour the flag on a genuinely skippable step (the author step or its `-review` gate).
294
1672
  // A corrupted/hand-edited `skipped: true` on a non-optional step (e.g. `stories-review`) must NOT
295
1673
  // bypass approvals — it falls through to the real predicate below and fails for lack of approvals.
296
- if (step?.skipped && isSkippableStep(step.id)) {
1674
+ if (claimsSkipped(step) && isSkippableStep(step.id, optional)) {
297
1675
  return {
298
1676
  approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
299
1677
  passed: true, missing: [], rule: 'skipped',
1678
+ // Nothing is counted on a step nobody reviewed; the rule and its cap are still facts about the step.
1679
+ gateRule: gateRuleFor(step), have: null, short: 0, active, cap: gateCapFor(gateRuleFor(step), active),
300
1680
  };
301
1681
  }
302
1682
 
1683
+ // A DEFERRED step gets NO short-circuit here. That is E38's decision, and E37 keeps it on purpose: the
1684
+ // chain walks past a deferred step (`isPassed`), but its review is still owed. A skip's gate will never
1685
+ // be asked again; a deferral's will. So the predicate answers honestly — not passed, and what is still
1686
+ // missing — which is the true report of what the team owes.
1687
+
303
1688
  const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
304
1689
  // Revoke-on-change: an approval bound to a stale content hash no longer counts.
305
- const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
1690
+ const stale = forStep.filter((a) => isStaleHash(a.artifactHash, accepted));
306
1691
  const live = forStep.filter((a) => !stale.includes(a));
307
1692
 
308
1693
  // requireEngagement (config `hub.review.requireEngagement`, soft-off by default): only an approval
@@ -310,24 +1695,25 @@ export function gatePredicate({
310
1695
  // cost of a bare rubber-stamp, it does not claim to prove a human read the artifact.
311
1696
  const counted = requireEngagement ? live.filter((a) => a.engagement === 'verified') : live;
312
1697
  const unengaged = requireEngagement ? live.filter((a) => a.engagement !== 'verified').length : 0;
313
- const owners = uniqueBy(counted.filter((a) => a.role === 'owner'), 'approver');
314
- const reviewers = uniqueBy(counted.filter((a) => a.role === 'reviewer'), 'approver');
315
- const domainOwners = counted.filter((a) => a.role === 'domain-owner');
316
1698
 
317
- const escalate = isEscalated(step);
1699
+ // How many DISTINCT humans approved. An older record may name one person several times, once per
1700
+ // role the roster gave them; that is still one approver. A record naming nobody is nobody: before E62
1701
+ // it carried no role and counted for nothing, and with only the base enforced one such hand-written
1702
+ // line would otherwise pass a team gate on its own (`mapApprovers` refuses one the same way).
1703
+ const approvers = uniqueBy(counted.filter((a) => typeof a.approver === 'string' && a.approver.trim()), 'approver').length;
1704
+ const gateRule = gateRuleFor(step);
1705
+ // E72. `asks` is the count the gate asks for — capped when the count of people is known, the full
1706
+ // count otherwise. Only the BASE holds the gate until E108 (see `gateRuleFor`).
1707
+ const cap = gateCapFor(gateRule, active);
1708
+ const asks = cap ? cap.to : gateRule.needed;
1709
+
318
1710
  const missing = [];
319
1711
  // Solo mode waives the APPROVAL requirements entirely (you can't approve your own PR on GitHub) —
320
- // merge + resolved threads are what advance the step. Team mode is unchanged.
1712
+ // merge + resolved threads are what advance the step.
321
1713
  if (!solo) {
322
- if (owners.length < 1) missing.push('1 owner approval');
323
- if (reviewers.length < defaultReviewers) {
324
- missing.push(`${defaultReviewers - reviewers.length} reviewer approval(s)`);
325
- }
326
- if (escalate) {
327
- for (const d of touchedDomains) {
328
- if (!domainOwners.some((a) => a.domain === d)) missing.push(`domain-owner for ${d}`);
329
- }
330
- }
1714
+ // Only the BASE holds the gate. The rest of the ask rides out as `short`, which is what the surfaces
1715
+ // print and what E108 turns into a `missing` entry, beside `yad gate lower --reason`.
1716
+ if (approvers < gateRule.base) missing.push(`${gateRule.base - approvers} approval(s)`);
331
1717
  }
332
1718
  const approvalsSatisfied = missing.length === 0;
333
1719
  // Surface engagement-gated approvals that did not count (only when requireEngagement holds the gate).
@@ -346,27 +1732,55 @@ export function gatePredicate({
346
1732
  staleDropped: stale.length,
347
1733
  passed: approvalsSatisfied && threadsResolved && merged,
348
1734
  missing,
349
- rule: solo ? 'solo' : escalate ? (step.id === 'stories-review' ? 'per-repo' : 'escalated') : 'base',
1735
+ // `rule` names which kind of rule was applied. A team gate is `count`; it used to be one of three
1736
+ // roster-era labels (`base`, `escalated`, `per-repo`), which E62 retired with the rule they named.
1737
+ rule: solo ? 'solo' : 'count',
1738
+ // The rule and what was counted against it. `have` is the number of distinct approvers and `short`
1739
+ // how many more the gate ASKS for — the capped count when `active` is known, the full count when it
1740
+ // is not. A gate can pass while `short` is not 0: only the base is enforced until E108.
1741
+ gateRule,
1742
+ have: approvers,
1743
+ short: solo ? 0 : Math.max(0, asks - approvers),
1744
+ // The live capacity count as the caller read it, and the cap it gives (`null` when unknown). Solo
1745
+ // mode still reports the cap, so a reader who later switches to team mode sees what it will ask.
1746
+ active,
1747
+ cap,
350
1748
  };
351
1749
  }
352
1750
 
353
1751
  // Advance the step in state.json once the predicate passes. Mirrors yad-review-gate Step 3:
354
1752
  // mark this review step done, unblock the next step, or set `ready-for-build` for the last one.
355
1753
  //
356
- // `test-cases` is a PARALLEL, non-blocking track so the build half can start while the tester works:
357
- // approving `stories-review` makes the epic `ready-for-build` (the build half keys off this) AND opens
1754
+ // `test-cases` is a PARALLEL, non-blocking track so Build can start while the tester works:
1755
+ // approving `stories-review` makes the epic `ready-for-build` (Build keys off this) AND opens
358
1756
  // `test-cases` for the tester; completing `test-cases-review` never pulls `currentStep` back from
359
1757
  // `ready-for-build`. Both rules degrade safely for an old chain that has no test-cases steps.
360
- export function advanceState(state, step) {
1758
+ // `close` (E18) is what the caller knows about the merge — `{ by, date, pr, commit, hash, mergedBy }`. With
1759
+ // it, the review step is stamped `closed` via `merge`, and an author step closed here via `review-passed`.
1760
+ // Without it nothing is stamped, so a caller that knows nothing invents nothing.
1761
+ export function advanceState(state, step, close = null) {
361
1762
  const i = state.steps.findIndex((s) => s.id === step.id);
362
- state.steps[i] = { ...state.steps[i], status: 'done' };
1763
+ // A step that was BLOCKED and has now passed is no longer waiting on anybody, so the record goes
1764
+ // with the state it belonged to — otherwise the step reads `done` while still naming who we wait
1765
+ // for. ONLY `blocked`: a skip's record is the reason that step passed and has to survive (and the
1766
+ // shape-7 stamp would put it straight back from `skipReason` anyway).
1767
+ const closing = { ...state.steps[i], status: 'done' };
1768
+ if (stepStatus(state.steps[i]) === 'blocked') delete closing.record;
1769
+ state.steps[i] = closing;
1770
+ if (close) stampClosed(closing, closingRecord({ ...close, via: 'merge' }));
363
1771
  // Defensive: `markInReview` normally closed the author step when the gate opened, but the CI bridge
364
1772
  // advances on a merge event without ever running it locally. Close it here too, so a passed gate can
365
- // never leave its author step behind (issue #131).
366
- closeAuthorStep(state, step);
1773
+ // never leave its author step behind (issue #131). The merge closed it, but nothing here knows who
1774
+ // wrote it, so its record carries the PR and the hash and no `mergedBy` or `commit`.
1775
+ closeAuthorStep(state, step, close ? closingRecord({ by: close.by, date: close.date, pr: close.pr, hash: close.hash, via: 'review-passed' }) : null);
1776
+ // DEBT IS PAID when the review passes (E41) — not when the step is put back, which only starts paying
1777
+ // it. `closeAuthorStep` above has already closed the author step, so the pair is paid together.
1778
+ delete state.steps[i].debt;
1779
+ const paid = authorStepFor(state, step);
1780
+ if (paid) delete paid.debt;
367
1781
  if (step.id === 'stories-review') {
368
1782
  const tc = state.steps.find((s) => s.id === 'test-cases');
369
- if (tc && tc.status === 'blocked') tc.status = 'in_progress';
1783
+ if (tc && stepStatus(tc) === 'todo') tc.status = 'in_progress';
370
1784
  state.currentStep = 'ready-for-build';
371
1785
  return state;
372
1786
  }
@@ -374,21 +1788,42 @@ export function advanceState(state, step) {
374
1788
  state.currentStep = 'ready-for-build';
375
1789
  return state;
376
1790
  }
377
- // Discovery is the project front-zero ("epic zero"): it has no build half, so its review terminates
378
- // at a `discovery-done` sentinel rather than `ready-for-build` (which would make `yad next` claim the
379
- // build half can run). The roadmap it approved is the input the real feature epics read.
1791
+ // The product level has no Build part, so its review terminates at its own `-done` sentinel rather
1792
+ // than `ready-for-build` (which would make `yad next` claim the Build can run). The roadmap it
1793
+ // approved is the input the real feature epics read. Two spellings, each ending on its own word, so
1794
+ // a legacy discovery ledger that has not been converted keeps writing what its readers expect.
1795
+ if (step.id === 'foundation-review') {
1796
+ state.currentStep = 'foundation-done';
1797
+ return state;
1798
+ }
380
1799
  if (step.id === 'discovery-review') {
381
1800
  state.currentStep = 'discovery-done';
382
1801
  return state;
383
1802
  }
384
- // Step over any SKIPPED steps (an optional step marked N/A for this epic — e.g. a skipped
385
- // `ui-design`/`ui-design-review` pair). They are pre-marked `done`, so the next runnable step is the
386
- // first later step that is not skipped. When the whole tail is skipped, fall through to ready-for-build.
1803
+ // Step over any step SET ASIDE — a skipped or a deferred pair (`isSetAside`). Neither is waiting to be
1804
+ // opened by the gate in front of it: a skip does not apply, and a deferral comes back through
1805
+ // `yad undefer`. Opening one here would write `in_progress` over the deferral and lose its record
1806
+ // (E37). The next runnable step is the first later step not set aside; when the whole tail is set
1807
+ // aside, fall through to ready-for-build.
1808
+ // A GATE THAT PASSED BEHIND THE CHAIN — a step re-opened after later work finished (E41), whose
1809
+ // `currentStep` is already past it or at `ready-for-build` — opens nothing and moves nothing. The step
1810
+ // after it is finished work: re-opening it would undo that work, and pointing `currentStep` back at it
1811
+ // would pull the epic out of Build.
1812
+ const cur = state.steps.findIndex((s) => s?.id === state.currentStep);
1813
+ if (state.currentStep === 'ready-for-build' || cur > i) return state;
1814
+ // Step over every step that has already PASSED — not only a skipped or deferred pair. A change-epic's
1815
+ // inherited (`satisfied`) steps are passed too, and landing `currentStep` on one made `yad next` say to
1816
+ // author an artifact the parent epic owns (found in the E41 review; older releases wrote `in_progress`
1817
+ // over it instead, which was worse).
387
1818
  let j = i + 1;
388
- while (state.steps[j]?.skipped) j++;
1819
+ while (state.steps[j] && isPassed(state.steps[j])) j++;
389
1820
  const next = state.steps[j];
390
1821
  if (next) {
391
- next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
1822
+ // Only a `todo` step is opened. A BLOCKED one is not: a gate passing is not somebody saying the wait
1823
+ // is over, and writing `in_progress` over it kept the record while hiding the block (E37). Nor is one
1824
+ // already started or finished, or in a state this release cannot name (the file wins). `currentStep`
1825
+ // still moves there, so `yad next` shows where the chain stands.
1826
+ if (stepStatus(next) === 'todo') next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
392
1827
  state.currentStep = next.id;
393
1828
  } else {
394
1829
  state.currentStep = 'ready-for-build';
@@ -396,75 +1831,269 @@ export function advanceState(state, step) {
396
1831
  return state;
397
1832
  }
398
1833
 
399
- // The front steps that may be marked N/A ("skipped") for an epic that does not need them. Only the
400
- // UI-design step is optional today: an epic with no user-facing surface (backend/API, data, infra)
401
- // can skip it. A skip carries a recorded reason and stays VISIBLE in the chain (both the author step
402
- // and its review gate pre-marked `done`, short-circuited by `gatePredicate`) — the auditable,
403
- // reversible counterpart to omitting `analysis` from the chain entirely.
404
- export const SKIPPABLE_STEPS = new Set(['ui-design']);
1834
+ // Which steps may be marked N/A ("skipped") is a fact about the epic's ROUTE, and the route is
1835
+ // declared with the lifecycle profiles below (E35). `optionalStepsFor(state)` is the one answer; every
1836
+ // function here takes it as an argument rather than reading a module-level set, so the guard is about
1837
+ // THIS epic rather than about every route at once.
1838
+ //
1839
+ // That distinction is invisible today — both feature routes mark the same pair optional, so a union
1840
+ // across the routes and this epic's own route give the identical answer — and an invisible rule is an
1841
+ // untested rule. The resolvers below take the route list as a seam so a test can pass routes that
1842
+ // disagree, the same reason `matchLifecycleProfile` takes one.
405
1843
 
406
1844
  // True for a genuinely skippable step id — the author step (`ui-design`) OR its paired review gate
407
- // (`ui-design-review`). Used to gate the `gatePredicate` skip short-circuit so a corrupted/hand-edited
408
- // `skipped: true` on a non-optional step cannot bypass its real approvals.
409
- export function isSkippableStep(id) {
410
- return SKIPPABLE_STEPS.has(String(id || '').replace(/-review$/, ''));
1845
+ // (`ui-design-review`) — given the author steps this epic's route marks optional. Used to gate the
1846
+ // `gatePredicate` skip short-circuit so a corrupted/hand-edited `skipped: true` on a step this epic's
1847
+ // route requires cannot bypass its real approvals.
1848
+ export function isSkippableStep(id, optional = []) {
1849
+ return [...(optional || [])].includes(String(id || '').replace(/-review$/, ''));
411
1850
  }
412
1851
 
413
- // Strip the skip-provenance fields off a step — the inverse of the stamp `skipStep` applies.
414
- function withoutSkip(step) {
1852
+ // The message every refusal to skip shares. An epic whose chain is on no route has NO optional steps —
1853
+ // never a default set — because guessing a route here would let a step be skipped on the strength of a
1854
+ // route nobody chose. `yad doctor` already reports that chain as `step:off-route`, and this says so
1855
+ // rather than pretending the step is simply required.
1856
+ // A chain to work on, or a YadError saying there is not one.
1857
+ //
1858
+ // Both verbs below are reachable from `yad skip`, which a person types at a corrupt ledger, and both
1859
+ // index straight into `state.steps` from their second line on. Without this they answer a malformed
1860
+ // `state.json` with a raw `TypeError: Cannot read properties of undefined`, which names no file and
1861
+ // suggests no fix. `cli/skip.mjs` already refuses a MISSING ledger; this is the one that exists and
1862
+ // is wrong inside.
1863
+ const requireChain = (state, verb) => {
1864
+ if (isPlainObject(state) && Array.isArray(state.steps)) return state.steps;
1865
+ throw err('YAD-STATE-004', `this epic has no step chain to ${verb}`,
1866
+ '`.sdlc/state.json` is missing its `steps` array or does not hold an object — restore it from git, then run `yad doctor`');
1867
+ };
1868
+
1869
+ // THREE REASONS A STEP IS NOT SKIPPABLE, and they need three different sentences because the remedy
1870
+ // differs. Until E40 there were two, because every feature route marked exactly one step optional
1871
+ // (`ui-design`) — so "this epic has nothing optional" could only mean "this epic is on no route", and
1872
+ // one branch covered both. The short lanes end that: `chore` and `spike` are routes the release fully
1873
+ // recognises, on which NOTHING is optional, because they dropped the optional steps from the chain
1874
+ // rather than marking them skippable. Sending that user to `yad doctor` for a `step:off-route` finding
1875
+ // that will never fire is worse than saying nothing — it is a remedy for a fault they do not have.
1876
+ // The same three sentences answer `yad defer` (E37), which asks the same route the same question.
1877
+ const notOptional = (stepId, optional, route, participle = 'skipped') => err(
1878
+ 'YAD-STATE-004',
1879
+ `step '${stepId}' is not optional on this epic's route`,
1880
+ optional.length
1881
+ ? `only these steps may be ${participle} here: ${optional.join(', ')}`
1882
+ : route
1883
+ ? `this epic is on the \`${route}\` route, and no step on it is optional — a short lane leaves the steps it does not need OUT of the chain rather than making them skippable, so there is nothing to mark N/A. If this epic needs '${stepId}', it is on the wrong route: start it again on a route that carries the step`
1884
+ : 'this epic is on no lifecycle route this release knows — it records none, and its chain matches none — so nothing on it is optional. Run `yad doctor` and look for `step:off-route`',
1885
+ );
1886
+
1887
+ // Strip the set-aside fields off a step — the inverse of the stamp `setAsideStep` applies: a skip's four
1888
+ // legacy fields, and the record either verb writes.
1889
+ function withoutSetAside(step) {
415
1890
  const rest = { ...step };
416
1891
  delete rest.skipped;
417
1892
  delete rest.skipReason;
418
1893
  delete rest.skippedBy;
419
1894
  delete rest.skippedAt;
1895
+ // And the shape-7 record. Leaving it behind would be worse than untidy: the caller sets `status` to
1896
+ // `todo`, and a `todo` carrying a stale reason is a step that claims to be un-started and explains
1897
+ // why it was set aside.
1898
+ delete rest.record;
420
1899
  return rest;
421
1900
  }
422
1901
 
423
- // PURE. Mark a skippable step (its author step + paired `<id>-review` gate) N/A for this epic: pre-mark
424
- // both `done` with a recorded reason, and — if currentStep is sitting on the pair — advance currentStep
425
- // past them to the next non-skipped step. Idempotent on an already-skipped step. Refuses once the step
426
- // was authored, once its review gate has opened, or once its downstream `stories` has started — the
427
- // step is optional only up to authoring it. Throws on a non-skippable id or a malformed (unpaired) chain.
428
- export function skipStep(state, stepId, { reason, by = null, at = null } = {}) {
429
- if (!SKIPPABLE_STEPS.has(stepId)) {
430
- throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
431
- }
432
- const ai = state.steps.findIndex((s) => s.id === stepId);
433
- if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to skip');
434
- const author = state.steps[ai];
435
- // Idempotent BEFORE the reason check: a repeat skip on an already-N/A step is a no-op that keeps the
436
- // original reason/actor, so it must not fail merely for lacking a fresh --reason.
437
- if (author.skipped) return state;
438
- if (!reason || !String(reason).trim()) {
439
- throw err('YAD-STATE-004', 'a skip needs a reason', 'pass a reason, e.g. "backend-only epic, no UI"');
1902
+ // SET ASIDE: a step that passed without anybody working on it here, because a person chose that —
1903
+ // `skipped` (E35/E36: it does not apply) or `deferred` (E37: it applies, later). The chain walks past
1904
+ // both, the gate passes both on a step the route marks optional, and neither verb turns one into the
1905
+ // other. `satisfied` is not in this set: an inherited step was worked on, in the parent epic, and
1906
+ // nobody here set it aside. `stepStatus`, never the claim: a hand-typed flag on an unfinished step must
1907
+ // not be walked past (E38).
1908
+ const isSetAside = (step) => {
1909
+ const st = stepStatus(step);
1910
+ return st === 'skipped' || st === 'deferred';
1911
+ };
1912
+
1913
+ // The index of the first step after `index` that is not itself set aside: the step a skip or a deferral
1914
+ // opens. Past the end of the chain when the whole rest of it is set aside. Both verbs below refuse a
1915
+ // hole after the pair (`refuseHoleAfter`) before they ask this, so it never has to decide what a null
1916
+ // entry means.
1917
+ const firstLiveAfter = (steps, index) => {
1918
+ let j = index + 1;
1919
+ while (steps[j] && isSetAside(steps[j])) j++;
1920
+ return j;
1921
+ };
1922
+
1923
+ // Has work begun on this step IN THIS CHAIN — is it open (`in_progress` / `in_review`) or completed
1924
+ // here (`done`)? That is the only thing that makes a skip or an un-skip too late, because it is the only
1925
+ // thing built on the skip. The recorded states are not work done here: `skipped`, `satisfied` (carried
1926
+ // from a parent epic), `deferred` and `blocked` all describe a step nobody worked on in this chain, so
1927
+ // counting them would refuse an un-skip because a later step was inherited. A status word this release
1928
+ // cannot name counts as started: refusing is the safe reading of a file a newer release wrote.
1929
+ const hasStarted = (step) => {
1930
+ const st = stepStatus(step);
1931
+ return st === null || st === 'in_progress' || st === 'in_review' || st === 'done';
1932
+ };
1933
+
1934
+ // RE-OPENED BEHIND FINISHED WORK (E41): is the step at `index` unfinished while a later step, before
1935
+ // `end`, was COMPLETED HERE? That is what a deferral resumed late looks like — `ui-design` open again
1936
+ // after `stories` is done — and such a step runs BESIDE the chain, the way `test-cases` does: it must
1937
+ // not block the finished work in front of it, and passing its gate must not re-open that work.
1938
+ //
1939
+ // READ OFF THE CHAIN, NEVER A FLAG. A `reopened: true` that `preconditionsMet` honoured would be one
1940
+ // hand-typed word that unblocks a chain — the exact hole E38 closed for `skipped: true`.
1941
+ //
1942
+ // `done` only, not `isPassed`: a later step that was skipped, deferred or carried from a parent epic
1943
+ // (`satisfied`) is not work built here, and counting it would let an unfinished `architecture` stop
1944
+ // blocking `stories` just because `ui-design` between them was skipped. The step's OWN review gate does
1945
+ // not count either: a `done` gate over an unwritten author step is the damage `stateInvariants` reports
1946
+ // (#131), not a re-open.
1947
+ const behindFinishedWork = (steps, index, end = steps.length) => {
1948
+ const ownGate = `${steps[index]?.id}-review`;
1949
+ return steps.slice(index + 1, end).some((s) => isPlainObject(s) && s.id !== ownGate && stepStatus(s) === 'done');
1950
+ };
1951
+
1952
+ // A hole in the chain after the pair makes "has the chain moved on?" unanswerable either way — reading
1953
+ // it as `todo` would let a skip through past a started step, reading it as started would refuse for a
1954
+ // step that does not exist. Refuse it, the way a pair with no `-review` gate is refused.
1955
+ const refuseHoleAfter = (steps, index, verb) => {
1956
+ if (steps.slice(index + 1).some((s) => !isPlainObject(s))) {
1957
+ throw err('YAD-STATE-004', `malformed chain: an entry after the step to ${verb} is not a step`, 'restore state.json from git, then run `yad doctor`');
440
1958
  }
441
- // A skippable step must carry its paired `-review` gate — the change keeps BOTH in the chain. A
442
- // missing gate is a malformed chain; refuse rather than half-stamp only the author step.
443
- const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
444
- if (ri === -1) throw err('YAD-STATE-004', `malformed chain: ${stepId} has no ${stepId}-review gate`, 'restore state.json from git');
445
- const review = state.steps[ri];
446
- if (author.status === 'done') {
447
- throw err('YAD-STATE-004', `${stepId} is already authored`, 'cannot skip a step whose artifact was already written');
1959
+ };
1960
+
1961
+ // The two ways to set a step aside, and everything that differs between them: the words, the command
1962
+ // that puts the step back, what the step claims, and the stamp. Everything else — which steps qualify,
1963
+ // when it is too late, what happens to `currentStep` — is ONE rule, in `setAsideStep` / `restoreStep`.
1964
+ const SET_ASIDE = {
1965
+ skipped: {
1966
+ verb: 'skip', undo: 'un-skip', undoCommand: 'unskip', noun: 'skip',
1967
+ needsReason: 'a skip needs a reason', reasonHint: 'say why the step does not apply',
1968
+ // What the step CLAIMS, in either spelling — the shape-7 status or the legacy flag beside `done`.
1969
+ claims: (step) => claimsSkipped(step),
1970
+ // `status: 'skipped'` is the step's own state from shape 7 on (E38), and the four legacy fields
1971
+ // stay beside it — rule 3, add before you remove. `skipped: true` is what a 3.x reader keys on and
1972
+ // what `stepStatus` still translates, so both vocabularies describe the same step for this major.
1973
+ stamp: ({ reason, by, at }) => ({
1974
+ skipped: true, skipReason: String(reason).trim(), skippedBy: by, skippedAt: at,
1975
+ status: 'skipped', record: stepRecord({ reason, by, date: at }),
1976
+ }),
1977
+ },
1978
+ deferred: {
1979
+ verb: 'defer', undo: 'un-defer', undoCommand: 'undefer', noun: 'deferral',
1980
+ needsReason: 'a deferral needs a reason', reasonHint: 'say why it waits, and who is waiting for it',
1981
+ // `deferred` has no older spelling, so the status word IS the claim.
1982
+ claims: (step) => stepStatus(step) === 'deferred',
1983
+ // No legacy fields: `deferred` was born in shape 7, so there is no older reader to keep fed. The
1984
+ // record's `by` is who WROTE it, as on every record; who is waiting for the step belongs in the reason.
1985
+ // `debt` (E41) only when asked for: a plain deferral is planned, a debt is owed back and reminded.
1986
+ stamp: ({ reason, by, at, debt }) => ({ status: 'deferred', record: stepRecord({ reason, by, date: at }), ...(debt ? { debt: true } : {}) }),
1987
+ carriesDebt: true,
1988
+ },
1989
+ };
1990
+ const otherWay = (as) => (as === 'skipped' ? 'deferred' : 'skipped');
1991
+
1992
+ // PURE. Set a step aside for this epic: mark its author step and paired `<id>-review` gate `skipped`
1993
+ // (`skipStep`) or `deferred` (`deferStep`) with a recorded reason, and — if currentStep is sitting on the
1994
+ // pair — advance currentStep past them to the next step not set aside. Idempotent on a step already set
1995
+ // aside the same way. Refuses once the step was authored, once its review gate has opened, or once work
1996
+ // has started on any later step — the step is optional only up to authoring it. Refuses a step set aside
1997
+ // the OTHER way: each has its own record and its own way back, and converting in place would lose one.
1998
+ // Throws on a step the route does not mark optional, or a malformed (unpaired) chain. Which step ids
1999
+ // qualify is the epic's route (`optionalStepsFor`), and nothing below names one.
2000
+ //
2001
+ // WHY DEFERRING IS NO WIDER THAN SKIPPING (E37). A deferred step lets the chain continue exactly as a
2002
+ // skipped one does, so it needs the same permission from the route. Deferring a required step would carry
2003
+ // the chain past its review gate with no approvals on it, which rule 2 forbids however the reason is
2004
+ // worded. The gate itself is not waived: `gatePredicate` still reports a deferred step's review as owed.
2005
+ function setAsideStep(state, stepId, as, { reason, by = null, at = null, debt = false, profiles = LIFECYCLE_PROFILES } = {}) {
2006
+ const V = SET_ASIDE[as];
2007
+ // DEBT IS OWED WORK, and a skip says the step does not apply (E36/E37), so nothing is owed back. Asked
2008
+ // first, before any chain question: it is a wrong command, whatever state the chain is in.
2009
+ if (debt && !V.carriesDebt) {
2010
+ throw err('YAD-STATE-004', 'a skip cannot carry debt',
2011
+ `a skip says ${stepId} does not apply, so nothing is owed back. If the step is owed, defer it instead: \`yad defer <epic> ${stepId} --reason "<why>" --debt\``);
448
2012
  }
449
- // Once the review gate has opened (in_review / done), the UI work is effectively committed — skipping
450
- // then would orphan a live review PR. Refuse; the step is optional only up to authoring it.
451
- if (review.status !== 'blocked') {
452
- throw err('YAD-STATE-004', `cannot skip ${stepId} — its review has already opened`, 'skip the UI step before its review begins');
2013
+ const steps = requireChain(state, `${V.verb} a step in`);
2014
+ const optional = optionalStepsFor(state, profiles);
2015
+ if (!optional.includes(stepId)) throw notOptional(stepId, optional, epicProfileId(state, profiles), as);
2016
+ // `s?.id` throughout: a hand-edited chain can hold a null entry, and a crash on one would be the
2017
+ // same unhelpful answer `requireChain` exists to replace.
2018
+ const ai = steps.findIndex((s) => s?.id === stepId);
2019
+ if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, `nothing to ${V.verb}`);
2020
+ const author = steps[ai];
2021
+ // Idempotent BEFORE the reason check: a repeat on a step already set aside this way is a no-op that
2022
+ // keeps the original reason/actor, so it must not fail merely for lacking a fresh --reason.
2023
+ // One addition is allowed: `--debt` on a deferral already made marks it owed, on both steps of the
2024
+ // pair, and keeps its record. The reverse is not offered — debt is cleared by paying it back (E41).
2025
+ if (V.claims(author)) {
2026
+ if (debt) for (const s of [author, steps.find((x) => x?.id === `${stepId}-review`)]) if (isPlainObject(s) && V.claims(s)) s.debt = true;
2027
+ return state;
2028
+ }
2029
+ const O = SET_ASIDE[otherWay(as)];
2030
+ if (O.claims(author)) {
2031
+ throw err('YAD-STATE-004', `${stepId} is already ${otherWay(as)}`,
2032
+ `put it back with \`yad ${O.undoCommand} <epic> ${stepId}\` first, then ${V.verb} it — changing it in place would lose the ${O.noun}'s record`);
2033
+ }
2034
+ // A BLOCKED step is waiting on somebody outside the workflow, and its record says who. Setting it aside
2035
+ // would replace that record with this verb's own, and putting the step back would then delete it — the
2036
+ // blocker would vanish without anybody clearing it. Refuse, and name the verb that does clear it. This
2037
+ // was a hole in `yad skip` from E35 on; `yad unblock` (E37) is what makes the refusal answerable. It
2038
+ // also replaces a wrong answer: a blocked REVIEW gate used to be refused as "its review has already
2039
+ // opened".
2040
+ const blockedStep = [author, steps.find((s) => s?.id === `${stepId}-review`)].find((s) => stepStatus(s) === 'blocked');
2041
+ if (blockedStep) {
2042
+ throw err('YAD-STATE-004', `${blockedStep.id} is blocked — ${blockedStep.record?.reason || 'no reason recorded'}`,
2043
+ `clear the blocker first with \`yad unblock <epic> ${blockedStep.id}\`, then ${V.verb} ${stepId} — setting it aside now would lose the record of who it waits on`);
2044
+ }
2045
+ // A step OWED AS DEBT cannot be skipped (E41). A skip says nothing is owed, and a skipped gate reads as
2046
+ // passed, so its review would never run again and nothing could ever clear the flag. Only a passing
2047
+ // review pays a debt. Deferring it again is allowed, and keeps the debt.
2048
+ if (as === 'skipped' && [author, steps.find((s) => s?.id === `${stepId}-review`)].some((s) => s?.debt === true)) {
2049
+ throw err('YAD-STATE-004', `${stepId} is owed as debt`,
2050
+ `a skip says nothing is owed, so it cannot close a debt — only passing ${stepId}-review does. Put it aside again with \`yad defer <epic> ${stepId} --reason "<why>"\`, which keeps the debt`);
2051
+ }
2052
+ if (!reason || !String(reason).trim()) {
2053
+ throw err('YAD-STATE-004', V.needsReason, `${V.reasonHint}, e.g. \`yad ${V.verb} <epic> ${stepId} --reason "<why>"\``);
2054
+ }
2055
+ // A skippable step must carry its paired `-review` gate — the change keeps BOTH in the chain. A
2056
+ // missing gate is a malformed chain; refuse rather than half-stamp only the author step.
2057
+ const ri = steps.findIndex((s) => s?.id === `${stepId}-review`);
2058
+ if (ri === -1) throw err('YAD-STATE-004', `malformed chain: ${stepId} has no ${stepId}-review gate`, 'restore state.json from git');
2059
+ const review = steps[ri];
2060
+ if (isAuthored(author)) {
2061
+ throw err('YAD-STATE-004', `${stepId} is already authored`, `cannot ${V.verb} a step whose artifact was already written`);
453
2062
  }
454
- const stories = state.steps.find((s) => s.id === 'stories');
455
- if (stories && stories.status !== 'blocked') {
456
- throw err('YAD-STATE-004', `cannot skip ${stepId} — stories have already started`, 'skip the UI step before stories begin');
2063
+ // An INHERITED author step is not authored here and not skippable either — its artifact belongs to
2064
+ // the parent epic. Without this it falls through to the review guard below and is refused for the
2065
+ // wrong reason ("its review has already opened"), sending someone to look for a review PR that was
2066
+ // never opened in this epic.
2067
+ if (claimsInherited(author)) {
2068
+ throw err('YAD-STATE-004', `${stepId} is inherited from ${author.inheritedFrom || 'the parent epic'}`,
2069
+ `a step carried by reference is already satisfied upstream — there is nothing here to ${V.verb}. Re-thread the change if it should be re-authored`);
457
2070
  }
458
- const stamp = { skipped: true, skipReason: String(reason).trim(), skippedBy: by, skippedAt: at, status: 'done' };
2071
+ // Once the review gate has opened (in_review / done), the work is effectively committed — setting it
2072
+ // aside then would orphan a live review PR. Refuse; the step is optional only up to authoring it.
2073
+ if (stepStatus(review) !== 'todo') {
2074
+ throw err('YAD-STATE-004', `cannot ${V.verb} ${stepId} — its review has already opened`, `${V.verb} ${stepId} before its review begins`);
2075
+ }
2076
+ // No step past the pair may have started. This used to name `stories`, which was right only because
2077
+ // `ui-design` was the one step any route marked optional and `stories` is what follows it. On a route
2078
+ // that marks `architecture` optional, the step after the pair is `ui-design`, and a literal `stories`
2079
+ // would let the skip through with ui-design already under way (E36).
2080
+ refuseHoleAfter(steps, ri, V.verb);
2081
+ const started = steps.slice(ri + 1).find(hasStarted);
2082
+ // A step RE-OPENED behind finished work (a late `yad undefer`, E41) may be deferred again: later work
2083
+ // is expected to have started — that is what re-opened it — and without this a mistaken late undefer
2084
+ // could not be undone. A skip stays refused there: the finished work was built without the step.
2085
+ if (started && !(as === 'deferred' && behindFinishedWork(steps, ai))) {
2086
+ throw err('YAD-STATE-004', `cannot ${V.verb} ${stepId} — ${started.id} is already '${started.status ?? '(no status)'}'`,
2087
+ `${V.verb} ${stepId} before ${started.id} begins`);
2088
+ }
2089
+ const stamp = V.stamp({ reason, by, at, debt });
459
2090
  state.steps[ai] = { ...author, ...stamp };
460
2091
  state.steps[ri] = { ...review, ...stamp };
461
- // If currentStep was on the pair we just skipped, move it to the next non-skipped step.
2092
+ // If currentStep was on the pair we just set aside, move it to the next step not set aside.
462
2093
  if (state.currentStep === stepId || state.currentStep === `${stepId}-review`) {
463
- let j = ri + 1;
464
- while (state.steps[j]?.skipped) j++;
465
- const next = state.steps[j];
2094
+ const next = state.steps[firstLiveAfter(state.steps, ri)];
466
2095
  if (next) {
467
- if (next.status === 'blocked') next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
2096
+ if (stepStatus(next) === 'todo') next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
468
2097
  state.currentStep = next.id;
469
2098
  } else {
470
2099
  state.currentStep = 'ready-for-build';
@@ -473,85 +2102,1008 @@ export function skipStep(state, stepId, { reason, by = null, at = null } = {}) {
473
2102
  return state;
474
2103
  }
475
2104
 
476
- // PURE. Reverse a skip: clear the N/A stamp on the pair and restore the chain. Allowed only while the
477
- // downstream `stories-review` has not opened (state-only signal for "stories authoring is under way").
478
- // If every earlier step is done, the restored author step becomes the active step again (and a
479
- // downstream that the skip auto-opened is pushed back to `blocked` behind it); otherwise it just
480
- // returns to `blocked`. Throws if the step is not skipped or it is too late.
481
- export function unskipStep(state, stepId) {
482
- if (!SKIPPABLE_STEPS.has(stepId)) {
483
- throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
484
- }
485
- const ai = state.steps.findIndex((s) => s.id === stepId);
486
- if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to un-skip');
487
- if (!state.steps[ai].skipped) throw err('YAD-STATE-004', `${stepId} is not skipped`, 'nothing to un-skip');
488
- const storiesReview = state.steps.find((s) => s.id === 'stories-review');
489
- if (storiesReview && storiesReview.status !== 'blocked') {
490
- throw err('YAD-STATE-004', `cannot un-skip ${stepId} — the stories review has already opened`, 'un-skip before the stories review begins');
491
- }
492
- const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
493
- const priorAllDone = state.steps.slice(0, ai).every((s) => s.status === 'done');
494
- state.steps[ai] = { ...withoutSkip(state.steps[ai]), status: priorAllDone ? 'in_progress' : 'blocked' };
495
- if (ri !== -1) state.steps[ri] = { ...withoutSkip(state.steps[ri]), status: 'blocked' };
2105
+ // PURE. Put a set-aside step back: clear the stamp on the pair and restore the chain (`unskipStep`,
2106
+ // `undeferStep`). Allowed while the step after the pair — the one setting it aside opened — is at most
2107
+ // under way: once it is finished, or anything PAST it has started (on `classic`, once `stories` is done
2108
+ // or `stories-review` opens), the chain has built on the step being absent, and it is too late.
2109
+ // If every earlier step has passed, the restored author step becomes the active step again (and the
2110
+ // downstream that was auto-opened is pushed back to `todo` behind it); otherwise it just returns to
2111
+ // `todo`. Throws if the step is not set aside this way, or it is too late.
2112
+ //
2113
+ // FOR A DEFERRAL THERE IS NO CLOSING WINDOW (E41). For a skip the window is the meaning: stories finished
2114
+ // on the assumption that there is no UI were built without one, so an un-skip after that is refused. A
2115
+ // deferral promised to come back, so stories finished before the UI are expected. Once the chain has
2116
+ // built past a deferred pair, `yad undefer` RE-OPENS it behind that work instead of refusing: the author
2117
+ // step opens (or waits, if an earlier step has not passed), its gate goes to `todo`, and nothing after the
2118
+ // pair and nothing about `currentStep` moves. The re-opened step then runs beside the chain — see
2119
+ // `behindFinishedWork` for how `preconditionsMet`, `advanceState`, `markInReview` and `yad next` tell.
2120
+ // This is also how DEBT is paid back: a debt is a deferral, and its flag stays on until the review passes.
2121
+ function restoreStep(state, stepId, as) {
2122
+ // NO ROUTE GUARD HERE, and that asymmetry with `setAsideStep` is deliberate. Setting a step aside needs
2123
+ // the route's permission because it makes a gate pass without approvals. Putting it back only returns
2124
+ // a step to the chain — it can never let anything through, so refusing it has no safety value and one
2125
+ // real cost: `yad doctor`'s `skip:not-optional` names exactly the epics whose skip the route does not
2126
+ // allow, and its remedy is this command. With the guard, the one command the finding recommends was
2127
+ // the one command guaranteed to throw in the state that produced the finding.
2128
+ const V = SET_ASIDE[as];
2129
+ const steps = requireChain(state, `${V.undo} a step in`);
2130
+ const ai = steps.findIndex((s) => s?.id === stepId);
2131
+ if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, `nothing to ${V.undo}`);
2132
+ if (!V.claims(steps[ai])) {
2133
+ const O = SET_ASIDE[otherWay(as)];
2134
+ throw err('YAD-STATE-004', `${stepId} is not ${as}`,
2135
+ O.claims(steps[ai]) ? `it is ${otherWay(as)} — put it back with \`yad ${O.undoCommand} <epic> ${stepId}\`` : `nothing to ${V.undo}`);
2136
+ }
2137
+ const ri = steps.findIndex((s) => s?.id === `${stepId}-review`);
2138
+ // The step after the pair may be under way — setting it aside opened it, and putting it back pushes it
2139
+ // back — but not FINISHED: a `done` step was authored while the set-aside step was absent (stories
2140
+ // written with no UI), and putting that step back would leave the work built without it standing as
2141
+ // complete. Work started on anything BEYOND it means the same. This used to be a literal
2142
+ // `stories-review` — the same rule read off the one route shape that existed (E36) — and asking only
2143
+ // past the step after the pair missed a finished `stories` whose review was then blocked.
2144
+ const tail = ri !== -1 ? ri : ai;
2145
+ refuseHoleAfter(steps, tail, V.undo);
2146
+ const j = firstLiveAfter(steps, tail);
2147
+ const after = steps[j];
2148
+ const beyond = (stepStatus(after) === 'done' ? after : null) || steps.slice(j + 1).find(hasStarted);
2149
+ const priorAllDone = steps.slice(0, ai).every((s) => isPassed(s));
2150
+ // Only when later work is FINISHED here (`behindFinishedWork`). Work that has merely started, or a status
2151
+ // this release cannot name, is not something to re-open beside — refuse, as before E41.
2152
+ if (beyond && as === 'deferred' && behindFinishedWork(steps, ai)) {
2153
+ // LATE RESUME (E41): re-open the pair behind the work built past it. `withoutSetAside` keeps `debt`,
2154
+ // which is still owed until the review passes.
2155
+ steps[ai] = { ...withoutSetAside(steps[ai]), status: priorAllDone ? 'in_progress' : 'todo' };
2156
+ if (ri !== -1) steps[ri] = { ...withoutSetAside(steps[ri]), status: 'todo' };
2157
+ return state;
2158
+ }
2159
+ if (beyond) {
2160
+ throw err('YAD-STATE-004', `cannot ${V.undo} ${stepId} — ${beyond.id} is already '${beyond.status ?? '(no status)'}'`,
2161
+ `${V.undo} before ${beyond.id} ${beyond === after ? 'is finished' : 'begins'} — the chain has built on the ${V.noun} since`);
2162
+ }
2163
+ steps[ai] = { ...withoutSetAside(steps[ai]), status: priorAllDone ? 'in_progress' : 'todo' };
2164
+ if (ri !== -1) steps[ri] = { ...withoutSetAside(steps[ri]), status: 'todo' };
496
2165
  if (priorAllDone) {
497
- // The restored author step is the active step again. Push the downstream the skip auto-opened
498
- // back to `blocked` (it must wait behind the now-live step), and re-point currentStep here. Scan
499
- // past any still-skipped steps (mirrors skipStep's step-over) and reset whether it was opened as
500
- // an author step (`in_progress`) or a review gate (`in_review`).
501
- let j = (ri !== -1 ? ri : ai) + 1;
502
- while (state.steps[j]?.skipped) j++;
503
- const after = state.steps[j];
504
- if (after && (after.status === 'in_progress' || after.status === 'in_review')) after.status = 'blocked';
505
- state.currentStep = stepId;
2166
+ // The restored author step is the active step again. Push the downstream that was auto-opened back
2167
+ // to `todo` (it must wait behind the now-live step), whether it was opened as an author step
2168
+ // (`in_progress`) or a review gate (`in_review`).
2169
+ const afterState = stepStatus(after);
2170
+ if (afterState === 'in_progress' || afterState === 'in_review') after.status = 'todo';
2171
+ // Re-point currentStep here — EXCEPT when the epic EARNED `ready-for-build`: its `stories-review`
2172
+ // passed a review — `done` here, or `satisfied`, reviewed in the parent epic — and sits before this
2173
+ // step. A step restored after that gate runs beside Build the way `test-cases` does, and must not
2174
+ // pull the epic out of Build (`markInReview` keeps the same rule; `advanceState` names the same
2175
+ // gate). Anywhere else `ready-for-build` was reached by setting steps aside, and restoring the step
2176
+ // takes the claim back with it: on a chore lane whose stories pair was skipped by hand, keeping it
2177
+ // would leave "Build can run" over unapproved stories.
2178
+ const gate = steps.findIndex((s) => s?.id === 'stories-review');
2179
+ const earnedBuild = state.currentStep === 'ready-for-build' && gate !== -1 && gate < ai && ['done', 'satisfied'].includes(stepStatus(steps[gate]));
2180
+ if (!earnedBuild) state.currentStep = stepId;
506
2181
  }
507
2182
  return state;
508
2183
  }
509
2184
 
2185
+ // The four verbs. `yad skip` / `yad unskip` (E35, E36) say a step does not apply to this epic;
2186
+ // `yad defer` / `yad undefer` (E37) say it does, later.
2187
+ export function skipStep(state, stepId, opts) { return setAsideStep(state, stepId, 'skipped', opts); }
2188
+ export function unskipStep(state, stepId) { return restoreStep(state, stepId, 'skipped'); }
2189
+ export function deferStep(state, stepId, opts) { return setAsideStep(state, stepId, 'deferred', opts); }
2190
+ export function undeferStep(state, stepId) { return restoreStep(state, stepId, 'deferred'); }
2191
+
2192
+ // PURE. Clear a recorded blocker once the wait is over (`yad unblock`, E37 — the E38 row gave this verb
2193
+ // to E37). A `blocked` step is waiting on somebody outside the workflow, so nothing in the chain ever
2194
+ // clears it; a person says the wait is over.
2195
+ //
2196
+ // STATUS AND RECORD GO TOGETHER. Deleting only the record would turn the step silently back into a
2197
+ // `todo` (a `blocked` with no record IS the older spelling of `todo`), and moving only the status would
2198
+ // leave a record explaining a wait that is over. So one write does both: an author step whose earlier
2199
+ // steps have all passed goes back to `in_progress`, and anything else to `todo` — a review gate included,
2200
+ // because opening a review is `yad gate open`'s job, not this verb's. `currentStep` is not touched: the
2201
+ // chain already points wherever it pointed while the step was blocked.
2202
+ //
2203
+ // `state.json` only. A halted Build lane is `blocked` in `build-state/<story>.json`, and that file is
2204
+ // the `yad-run` skill's: a write here would be undone by its next run (the E38 row).
2205
+ export function unblockStep(state, stepId) {
2206
+ const steps = requireChain(state, 'unblock a step in');
2207
+ const i = steps.findIndex((s) => s?.id === stepId);
2208
+ if (i === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to unblock');
2209
+ const step = steps[i];
2210
+ const st = stepStatus(step);
2211
+ if (st !== 'blocked') {
2212
+ // The one case worth its own sentence: `blocked` with no record is the pre-shape-7 word for "not
2213
+ // started". Nobody is being waited on, so there is no blocker to clear — and saying "not blocked"
2214
+ // about a step whose file says `blocked` would read as a bug.
2215
+ if (step.status === 'blocked') {
2216
+ throw err('YAD-STATE-004', `${stepId} has no blocker recorded`,
2217
+ 'a `blocked` step with no `record` is the older spelling of `todo` — it is not waiting on anyone, so there is nothing to clear. `yad migrate --apply` rewrites the word to `todo` (on a verified project, the next gate write does)');
2218
+ }
2219
+ throw err('YAD-STATE-004', `${stepId} is not blocked`, `it is '${st ?? step.status ?? '(no status)'}' — nothing to unblock`);
2220
+ }
2221
+ const cleared = { ...step };
2222
+ delete cleared.record;
2223
+ const earlierPassed = steps.slice(0, i).every((s) => isPassed(s));
2224
+ cleared.status = earlierPassed && step.type === 'author' ? 'in_progress' : 'todo';
2225
+ steps[i] = cleared;
2226
+ return state;
2227
+ }
2228
+
510
2229
  // Mark a step in-review (idempotent) and point currentStep at it — EXCEPT once the epic is
511
- // `ready-for-build`: the parallel `test-cases` track must not pull currentStep back (the build half
2230
+ // `ready-for-build`: the parallel `test-cases` track must not pull currentStep back (Build
512
2231
  // runs alongside the tester, and only the test-cases review is in flight at that point).
513
- export function markInReview(state, step) {
2232
+ // `close` (E18): `{ by, date, pr?, hash? }` for the author step this closes, stamped via `review-opened`.
2233
+ export function markInReview(state, step, close = null) {
514
2234
  const i = state.steps.findIndex((s) => s.id === step.id);
515
- if (state.steps[i].status !== 'done') state.steps[i].status = 'in_review';
2235
+ // A BLOCKED step is not opened by a review (E38). The blocker is not this workflow's to clear, so
2236
+ // writing `in_review` over `blocked` would drop the record naming who we are waiting on while
2237
+ // changing nothing about the wait — and `gate sync` reaches here on exactly the path where the
2238
+ // gate did NOT pass. It still reports what is missing; the step keeps saying why it cannot move.
2239
+ // `st &&` is the unknown-status case: a word this release cannot name belongs to the file (rule 3),
2240
+ // and writing `in_review` over it would be this function deciding what a newer release meant.
2241
+ const st = stepStatus(state.steps[i]);
2242
+ if (st && st !== 'blocked' && !isPassed(state.steps[i])) state.steps[i].status = 'in_review';
516
2243
  // Opening a review gate means the artifact was authored — close the paired author step rather than
517
2244
  // trusting the authoring skill to have hand-edited state.json (issue #131).
518
- closeAuthorStep(state, step);
519
- if (state.currentStep !== 'ready-for-build') state.currentStep = step.id;
2245
+ // A BLOCKED review is not opened (above), so its author step is closed but not labelled `review-opened`.
2246
+ closeAuthorStep(state, step, close && st !== 'blocked' ? closingRecord({ ...close, via: 'review-opened' }) : null);
2247
+ // `currentStep` only moves FORWARD. Opening the review of a step re-opened behind finished work (E41)
2248
+ // must not point the chain back at it, just as the parallel `test-cases` track must not.
2249
+ const cur = state.steps.findIndex((s) => s?.id === state.currentStep);
2250
+ if (state.currentStep !== 'ready-for-build' && cur <= i) state.currentStep = step.id;
520
2251
  return state;
521
2252
  }
522
2253
 
523
- // The front authoring step a `yad next` action maps to — the skill the user invokes for that step.
524
- // Review (review+approve) steps are driven by the `yad gate` CLI, not a skill, so they are not here.
525
- export const STEP_SKILL = {
526
- discovery: 'yad-discovery',
527
- analysis: 'yad-analysis',
528
- epic: 'yad-epic',
529
- architecture: 'yad-architecture',
530
- 'ui-design': 'yad-ui',
531
- stories: 'yad-stories',
532
- 'test-cases': 'yad-test-cases',
2254
+ // ---- the step catalogue (E4) ---------------------------------------------------------------------
2255
+ //
2256
+ // ONE place that says what a step IS. Before this, the answer was spread across five tables in this
2257
+ // file, an order array, two artifact helpers, and a hand-written `state.json` seed copied into five
2258
+ // skill files — so "which phase is `stories` in", "which skill authors it", "what does it produce"
2259
+ // and "what reviews it" were four separate lookups that nothing held together.
2260
+ //
2261
+ // Everything below this constant is DERIVED from it. `STEP_SKILL`, `BUILD_STEP_SKILL`,
2262
+ // `BUILD_STEP_ORDER`, `SHAPE_STEP_PHASE` and `BUILD_STEP_PHASE` are still exported under the names
2263
+ // they had, because ~20 call sites and the golden test use them — but they are now VIEWS of this
2264
+ // table, not sources beside it. A test deep-equals each one against what the catalogue says, so a
2265
+ // step added here and forgotten there cannot happen, and neither can the reverse.
2266
+ //
2267
+ // WHAT THIS TABLE IS NOT. The catalogue is the data structure the rest of Wave 2b keys off, and each
2268
+ // of those is its own task: which steps an epic walks and in what order is a lifecycle profile (E5,
2269
+ // below), seeding a chain from one is `yad epic new` (E17, cli/epic.mjs); how many approvals each step's
2270
+ // gate ASKS FOR — the base enforced, the risk step reported and capped by the active people (E72) until
2271
+ // E108 — is `gateRuleFor` and `gateCapFor` at the top of this file (E7, E62, E72), which read
2272
+ // the `risk_tags` a seed copies from the row below into the epic's own `state.json`;
2273
+ // the fuller step-state model is E38. The `skill` column stays here as the shipped DEFAULT, and a
2274
+ // project overrides it in `.sdlc/skills.json` (E6, below) — E51 later slides a per-profile default
2275
+ // between the two, once E50 can detect which skills are installed. Three of the five
2276
+ // skills that used to hand-write a seed now run `yad epic new` instead (E17b), `yad-discovery` runs
2277
+ // `yad foundation new` (E75), and `yad-change` runs `yad epic new --parent` for a threaded change-epic
2278
+ // (E42).
2279
+ //
2280
+ // IT IS CODE, NOT A FILE. Nothing here is written to disk, so no file shape changes and there is
2281
+ // nothing to migrate. When a project's `state.json` disagrees with the catalogue, THE FILE WINS for
2282
+ // this whole major (rule 3): a project may hold a step from a newer yadflow, and a hand-written chain
2283
+ // is allowed to be ahead of the tool reading it. `yad doctor` reports the disagreement and changes
2284
+ // nothing — the same discipline as `workItemType`.
2285
+ //
2286
+ // The row fields, each of which had a home somewhere before:
2287
+ // id the step id as it appears in `state.json` `steps[]`
2288
+ // phase which of the six phases it belongs to (was SHAPE_STEP_PHASE / BUILD_STEP_PHASE)
2289
+ // kind 'author' (produces an artifact) or 'review' (a gate on one)
2290
+ // artifact what it produces or reviews, relative to the epic dir (was only in the skill seeds)
2291
+ // skill the skill that runs it (was STEP_SKILL / BUILD_STEP_SKILL). Review gates in Shape are
2292
+ // driven by the `yad gate` CLI and have none; `engineer-review` is a Build step in its own
2293
+ // right — the locked human merge gate — and does have one.
2294
+ // reviews for a review gate, the id of the author step it gates. This replaces stripping
2295
+ // `-review` off a string, which is what made `checks-review` look resolvable.
2296
+ // level 'feature' (a step on the Epic ladder) or 'product' (the Foundation, `EP-foundation`, and
2297
+ // its older spelling `EP-discovery` — product-level, never on the feature lifecycle, and
2298
+ // placed in the Foundation phase, which belongs to no Part (E75)). This field was called
2299
+ // `chain` when E4 landed and was renamed before any release carried it — `chain` is the
2300
+ // word for an ordered LIST of steps, which is what a lifecycle profile holds (E5).
2301
+ // risk_tags the DEFAULT tags a seed gives this step, copied into the epic's `state.json` where a team
2302
+ // may add to them by hand. `architecture-review` carries `contract`, which does two separate
2303
+ // things: it marks the step escalated (`isEscalated`, so its review PR names the touched
2304
+ // repos), and it sets the step's E7 RISK STEP — `contract` +2,
2305
+ // `auth` / `payments` +1, nothing +0, the maximum across the tags and never their sum. The
2306
+ // rule that turns those tags into a number of approvals is `gateRuleFor`, at the top of this
2307
+ // file; it reads the epic's recorded tags, not this row.
2308
+ //
2309
+ // `spec` and `tasks` are two legs of the same yad-spec ceremony (run-loop.md) and share a skill; the
2310
+ // chain renderer collapses the consecutive duplicate. `ready-for-build` and the other SENTINELS are
2311
+ // not steps and are not here.
2312
+ export const STEPS = [
2313
+ // Foundation — the Product level (E75). `yad-discovery` stays the skill name: renaming a skill folder
2314
+ // is its own change, with an install migration of its own (LEGACY_SKILLS), and it is not this one.
2315
+ { id: 'foundation', phase: 'foundation', kind: 'author', artifact: 'foundation/', skill: 'yad-discovery', level: 'product', risk_tags: [] },
2316
+ { id: 'foundation-review', phase: 'foundation', kind: 'review', artifact: 'foundation/', reviews: 'foundation', level: 'product', risk_tags: [] },
2317
+ // The OLD spelling of the same level, kept for this whole major (rule 3): a project that has not
2318
+ // converted still carries these ids, and dropping the rows would report every step of it as
2319
+ // `phase:unknown`. They move into the Foundation phase with the level they always were.
2320
+ { id: 'discovery', phase: 'foundation', kind: 'author', artifact: 'discovery/', skill: 'yad-discovery', level: 'product', risk_tags: [] },
2321
+ { id: 'discovery-review', phase: 'foundation', kind: 'review', artifact: 'discovery/', reviews: 'discovery', level: 'product', risk_tags: [] },
2322
+ // Discover
2323
+ { id: 'analysis', phase: 'discover', kind: 'author', artifact: 'analysis.md', skill: 'yad-analysis', level: 'feature', risk_tags: [] },
2324
+ { id: 'analysis-review', phase: 'discover', kind: 'review', artifact: 'analysis.md', reviews: 'analysis', level: 'feature', risk_tags: [] },
2325
+ { id: 'epic', phase: 'discover', kind: 'author', artifact: 'epic.md', skill: 'yad-epic', level: 'feature', risk_tags: [] },
2326
+ { id: 'epic-review', phase: 'discover', kind: 'review', artifact: 'epic.md', reviews: 'epic', level: 'feature', risk_tags: [] },
2327
+ // Design
2328
+ { id: 'architecture', phase: 'design', kind: 'author', artifact: 'architecture.md', skill: 'yad-architecture', level: 'feature', risk_tags: [] },
2329
+ { id: 'architecture-review', phase: 'design', kind: 'review', artifact: 'architecture.md', reviews: 'architecture', level: 'feature', risk_tags: ['contract'] },
2330
+ { id: 'ui-design', phase: 'design', kind: 'author', artifact: 'ui-design.md', skill: 'yad-ui', level: 'feature', risk_tags: [] },
2331
+ { id: 'ui-design-review', phase: 'design', kind: 'review', artifact: 'ui-design.md', reviews: 'ui-design', level: 'feature', risk_tags: [] },
2332
+ // Plan
2333
+ { id: 'stories', phase: 'plan', kind: 'author', artifact: 'stories/', skill: 'yad-stories', level: 'feature', risk_tags: [] },
2334
+ { id: 'stories-review', phase: 'plan', kind: 'review', artifact: 'stories/', reviews: 'stories', level: 'feature', risk_tags: [] },
2335
+ { id: 'test-cases', phase: 'plan', kind: 'author', artifact: 'test-cases.md', skill: 'yad-test-cases', level: 'feature', risk_tags: [] },
2336
+ { id: 'test-cases-review', phase: 'plan', kind: 'review', artifact: 'test-cases.md', reviews: 'test-cases', level: 'feature', risk_tags: [] },
2337
+ // Build — these run per story per code repo, recorded in build-state/, not in the epic's steps[]
2338
+ { id: 'spec', phase: 'build', kind: 'author', artifact: null, skill: 'yad-spec', level: 'feature', risk_tags: [] },
2339
+ { id: 'tasks', phase: 'build', kind: 'author', artifact: null, skill: 'yad-spec', level: 'feature', risk_tags: [] },
2340
+ { id: 'implement', phase: 'build', kind: 'author', artifact: null, skill: 'yad-implement', level: 'feature', risk_tags: [] },
2341
+ { id: 'checks', phase: 'build', kind: 'author', artifact: null, skill: 'yad-checks', level: 'feature', risk_tags: [] },
2342
+ // Not the review of a step called `engineer` — a step in its own right, and the one Build step that
2343
+ // is a gate. It carries no `reviews`, which is what keeps `checks-review` from resolving.
2344
+ { id: 'engineer-review', phase: 'build', kind: 'review', artifact: null, skill: 'yad-engineer-review', level: 'feature', risk_tags: [] },
2345
+ ];
2346
+
2347
+ // ---- lifecycle profiles (E5) ---------------------------------------------------------------------
2348
+ //
2349
+ // A PROFILE is a named, ordered chain of catalogue steps — the route an epic takes through the
2350
+ // lifecycle. The catalogue above says what each step IS; a profile says which ones an epic walks and
2351
+ // in what order. Two epics can now take different routes without the engine pretending every piece of
2352
+ // work is the same size.
2353
+ //
2354
+ // THE WORD. The roadmap calls this a profile, so that is the word (rule: the roadmap's vocabulary
2355
+ // wins). `hub.json` already carries an unrelated `profile` — the SETUP answers `{ codebase,
2356
+ // repo_layout, team_size }` that `yad setup` records — so nothing here is called plain `profile` in
2357
+ // code, and `next.mjs` reads the other one through `setupProfileOf`. Two different things under one
2358
+ // word is a trap for whoever reads this next; two clearly different names is not.
2359
+ //
2360
+ // THE FIRST THREE WERE NOT NEW ROUTES. They predate this table — they were seeded by hand, in five
2361
+ // skill files, until `yad epic new` took over the two feature ones. `yad-analysis`'s own description already calls them "the 12-step chain" and "the 10-step
2362
+ // chain". E5 wrote them down in one place and checks projects against them; it did not change what
2363
+ // any epic does, and nothing here is written to disk.
2364
+ //
2365
+ // classic the 10-step chain, seeded by yad-epic, yad-stub and yad-change. `epic` first.
2366
+ // analysis-first the 12-step chain, seeded by yad-analysis, which puts `analysis` before `epic`
2367
+ // when a feature is shaped by the analyst before it becomes an epic.
2368
+ // chore the upkeep lane (E40). Four steps: `epic` then `stories`.
2369
+ // spike the investigation lane (E40). Six steps: `analysis` in front of the chore lane.
2370
+ // discovery the product front-zero (`EP-discovery`), two steps and no Build. The OLD spelling
2371
+ // of the Product level, still read for this major; `yad migrate` converts it.
2372
+ // foundation the Product level (E75), `EP-foundation` in `foundation/`. Two steps, no Build.
2373
+ //
2374
+ // THE SHORT LANES ARE NEW, and they are the first routes here that nobody has ever walked. E40 adds
2375
+ // them because the engine had exactly one shape of work: until now a dependency bump was seeded on the
2376
+ // same 10-step chain as a payments rewrite, and the way through was to skip steps — which `classic`
2377
+ // does not permit, since only `ui-design` is optional on it. Making the whole chain optional to fit
2378
+ // upkeep would have made it optional for everything. A shorter ROUTE says the same thing honestly, and
2379
+ // says it once, at seed time, where a person chooses it.
2380
+ //
2381
+ // WHAT THE TWO LANES DROP, and why each is ABSENT rather than `optional`. An optional step stays in
2382
+ // the chain, pre-marked done with a recorded reason (E35) — that is the right shape for a step the
2383
+ // route genuinely has and this epic happens not to need. A step the route never has is not that:
2384
+ // writing `ui-design` into the chore lane as optional would make every chore epic carry a skip record
2385
+ // for a screen nobody was ever going to draw, and the audit trail would fill with noise that means
2386
+ // nothing.
2387
+ //
2388
+ // architecture / architecture-review Both lanes drop them, and dropping the architecture gate
2389
+ // drops the CONTRACT with it — no `contract.md`, no `contract-lock.json`. That is the point:
2390
+ // these lanes are for work that does not move the shared cross-repo surface. Work that does move
2391
+ // it belongs on `classic`, whatever its size. `yad doctor`'s contract-lock check is silent on an
2392
+ // epic with no lock file, which is the normal pre-lock state and also the permanent state here.
2393
+ // The gap this leaves in the check gates is written up on the E40 row of the roadmap.
2394
+ // ui-design / ui-design-review `chore` is "upkeep, no user-visible change" by the
2395
+ // definition of the work-item type, so there is no screen; a spike's prototype is thrown away,
2396
+ // so designing one would be work the lane exists to avoid.
2397
+ // test-cases / test-cases-review Neither lane introduces behaviour to pin. A chore must not
2398
+ // change what the product does, and a spike's output is a finding, not a shipped feature. The
2399
+ // tests that guard the code a chore touches already exist and its Build gates still run them.
2400
+ //
2401
+ // WHAT NEITHER LANE DROPS is `epic` / `epic-review` and `stories` / `stories-review`, and that is not
2402
+ // a matter of taste. `epic.md` is where the work-item type and the `parent:` lineage are authored —
2403
+ // `workItemType` reads it, `lineage-check.sh` reads it inside the user's repo, and `yad thread` skips
2404
+ // an epic that has none entirely, so a lane without it would produce work that is invisible to every
2405
+ // rollup. `stories` is what tags the repos Build runs in, and `stories-review` is the step
2406
+ // `advanceState` turns into `ready-for-build`. A lane ending anywhere else would leave `currentStep`
2407
+ // at a sentinel claiming a Build that has nothing in it.
2408
+ //
2409
+ // WHY `spike` IS `chore` PLUS `analysis`. The distinction is whether the answer is known. A chore is
2410
+ // upkeep somebody has already decided on: bump the dependency, move the CI job. A spike is a
2411
+ // timeboxed investigation — the analyst's brief is the first artifact because reducing the
2412
+ // uncertainty IS the work. Everything after that is the same short route, which is why one is a
2413
+ // strict ordered subset of the other. `matchLifecycleProfile` breaks the resulting tie on the shorter
2414
+ // route, so a chore-shaped chain reads as `chore` and not as a spike that skipped its analysis.
2415
+ //
2416
+ // WHAT IS AND IS NOT HERE. Seeding a chain from a profile is `seedState` below, driven by
2417
+ // `yad epic new` (E17, cli/epic.mjs); shape 6 is where an epic first RECORDS which profile it is on,
2418
+ // and an epic seeded before that field existed still has its route derived by matching its chain
2419
+ // (`matchLifecycleProfile`). Which steps are optional per route already lives here and is read per
2420
+ // EPIC (E35, `optionalStepsFor`); the short chore and spike lanes are E40. A PROJECT-wide skill
2421
+ // binding already exists (E6, below); E51 adds a per-profile one between it and the catalogue
2422
+ // default, so two routes can run different skills for the same step.
2423
+ export const LIFECYCLE_PROFILES = [
2424
+ {
2425
+ id: 'classic',
2426
+ title: 'the 10-step chain',
2427
+ level: 'feature',
2428
+ steps: [
2429
+ 'epic', 'epic-review',
2430
+ 'architecture', 'architecture-review',
2431
+ { id: 'ui-design', optional: true }, { id: 'ui-design-review', optional: true },
2432
+ 'stories', 'stories-review',
2433
+ 'test-cases', 'test-cases-review',
2434
+ ],
2435
+ },
2436
+ {
2437
+ id: 'analysis-first',
2438
+ title: 'the 12-step chain',
2439
+ level: 'feature',
2440
+ steps: [
2441
+ 'analysis', 'analysis-review',
2442
+ 'epic', 'epic-review',
2443
+ 'architecture', 'architecture-review',
2444
+ { id: 'ui-design', optional: true }, { id: 'ui-design-review', optional: true },
2445
+ 'stories', 'stories-review',
2446
+ 'test-cases', 'test-cases-review',
2447
+ ],
2448
+ },
2449
+ {
2450
+ id: 'chore',
2451
+ title: 'the upkeep lane',
2452
+ level: 'feature',
2453
+ steps: [
2454
+ 'epic', 'epic-review',
2455
+ 'stories', 'stories-review',
2456
+ ],
2457
+ },
2458
+ {
2459
+ id: 'spike',
2460
+ title: 'the investigation lane',
2461
+ level: 'feature',
2462
+ steps: [
2463
+ 'analysis', 'analysis-review',
2464
+ 'epic', 'epic-review',
2465
+ 'stories', 'stories-review',
2466
+ ],
2467
+ },
2468
+ {
2469
+ id: 'discovery',
2470
+ title: 'the product front-zero',
2471
+ level: 'product',
2472
+ steps: ['discovery', 'discovery-review'],
2473
+ },
2474
+ {
2475
+ id: 'foundation',
2476
+ title: 'the Product Foundation',
2477
+ level: 'product',
2478
+ steps: ['foundation', 'foundation-review'],
2479
+ },
2480
+ ];
2481
+
2482
+ // One profile's steps as plain rows: `{ id, optional }`, in chain order.
2483
+ //
2484
+ // `optional` is the only per-step field a profile carries today, and it is carried because something
2485
+ // READS it — `optionalStepsOf` below, which is what decides whether an epic on this route may skip a
2486
+ // step (E35). The parallel `test-cases` track is deliberately NOT recorded here: `advanceState`
2487
+ // decides it from the step id, and a flag nothing reads would be a second copy of a rule that lives
2488
+ // somewhere else — free to drift, with every test still green. Moving that rule into the profile is
2489
+ // worth doing, and it belongs to whichever task takes it, not to a field added on spec here.
2490
+ const profileRows = (p) => p.steps.map((x) => (typeof x === 'string' ? { id: x } : x))
2491
+ .map((x) => ({ id: x.id, optional: !!x.optional }));
2492
+
2493
+ const PROFILE_BY_ID = new Map(LIFECYCLE_PROFILES.map((p) => [p.id, { ...p, rows: profileRows(p) }]));
2494
+
2495
+ // A profile by id, or null for one this release does not carry.
2496
+ export const lifecycleProfile = (id) => PROFILE_BY_ID.get(String(id || '')) || null;
2497
+
2498
+ // The step ids of a profile, in chain order.
2499
+ export const profileSteps = (id) => (lifecycleProfile(id)?.rows || []).map((r) => r.id);
2500
+
2501
+ // WHICH profile a chain is on, worked out from the chain itself — and NOT from the `profile` key
2502
+ // shape 6 records. Two readers with two jobs: the recorded key says which route the epic was STARTED
2503
+ // on, and this says which route its steps are on NOW. `yad doctor` compares them, so a function that
2504
+ // read the key would compare it with itself; and `stampProfile` calls this to fill the key in the
2505
+ // first place, for every epic that existed before shape 6. A chain that has never been recorded is
2506
+ // the normal case for those, and this is the only answer available for it.
2507
+ //
2508
+ // A chain MATCHES a profile when every step it carries belongs to that profile and they appear in the
2509
+ // profile's order. Missing steps are allowed — an epic with no screens legitimately drops `ui-design`,
2510
+ // and a chain seeded before a step existed simply lacks it. Extra or out-of-order steps are not: those
2511
+ // mean a different route, or a broken one.
2512
+ //
2513
+ // The most specific match wins. A 10-step `classic` chain is a subset of `analysis-first` in the right
2514
+ // order too, so the SHORTEST fitting route breaks the tie — otherwise every classic epic would read as
2515
+ // an analysis-first epic that skipped its first two steps, and shape 6 would record that wrong route
2516
+ // on every one of them during an upgrade.
2517
+ //
2518
+ // `profiles` is a seam for the tests, and it is here because the tie-break is otherwise invisible:
2519
+ // with today's three routes the shortest fit also happens to be declared first, so dropping the sort
2520
+ // changes no answer and no test could tell. E40 adds the chore and spike lanes — shorter routes,
2521
+ // declared last — and on that day declaration order would quietly become the rule. A test passes the
2522
+ // list reversed and asserts the answer does not move.
2523
+ export function matchLifecycleProfile(steps, profiles = LIFECYCLE_PROFILES) {
2524
+ if (!Array.isArray(steps)) return null;
2525
+ const ids = steps.map((s) => s?.id).filter((x) => typeof x === 'string' && x);
2526
+ if (!ids.length) return null;
2527
+ // Read the rows off the profile that was HANDED IN, never by resolving its id — a caller-supplied
2528
+ // route is not in the module's own index, so `profileSteps(p.id)` would come back empty and the
2529
+ // route would silently never fit. That is a wrong answer with no error, and the one thing a seam
2530
+ // for the tests must not quietly do differently from the real call.
2531
+ const orderOf = (p) => profileRows(p).map((r) => r.id);
2532
+ const fits = profiles.filter((p) => {
2533
+ const order = orderOf(p);
2534
+ let at = -1;
2535
+ return ids.every((id) => {
2536
+ const i = order.indexOf(id);
2537
+ if (i <= at) return false; // not in this profile, or out of order
2538
+ at = i;
2539
+ return true;
2540
+ });
2541
+ });
2542
+ if (!fits.length) return null;
2543
+ return fits.sort((a, b) => orderOf(a).length - orderOf(b).length)[0].id;
2544
+ }
2545
+
2546
+ // ---- the routes shape 6 may stamp, frozen ---------------------------------------------------------
2547
+ //
2548
+ // The three routes that existed the day shape 6 landed, written out BY ID rather than derived, which
2549
+ // is the whole point: a list computed from `LIFECYCLE_PROFILES` would grow every time a route is
2550
+ // added, and then it would not be frozen. `stampProfile` explains what breaks without this.
2551
+ //
2552
+ // Adding a route NEVER belongs here, and the reason is NOT that nothing new can reach the stamper —
2553
+ // `writeState` runs it on every save, so a chain hand-written today with no `profile` key is stamped
2554
+ // the same as one from before the field existed. It is that shape 6 is a fixed question with a fixed
2555
+ // answer: "which of the routes that existed when this shape landed is this chain on". A chain that is
2556
+ // really on a newer route and says nothing gets the closest OLD answer, and `yad doctor` reports the
2557
+ // disagreement — which is a label to correct, not a migration to rewrite. The only edit this list ever
2558
+ // takes is a future shape adding its OWN frozen list beside it, never a line appended to this one.
2559
+ export const SHAPE_6_ROUTE_IDS = ['classic', 'analysis-first', 'discovery'];
2560
+
2561
+ // Filtered rather than rebuilt, so the frozen set carries each route's REAL rows. Writing the three
2562
+ // chains out again here would be a second copy of `classic` free to drift from the first, and it would
2563
+ // drift silently: shape 6 would keep stamping against a chain nobody maintains.
2564
+ //
2565
+ // A route named here that this release no longer carries simply drops out RATHER THAN THROWING: a
2566
+ // removed route is not one a chain can be matched onto, and a migration is the worst place to raise on
2567
+ // a condition the user cannot act on. The silent shrink is still a bug in the release that caused it —
2568
+ // shape 6 would quietly stop placing chains it used to place — so `cli/test-migrate.mjs` asserts every
2569
+ // id here still resolves. Tolerated at runtime, caught in CI; the two are not in tension.
2570
+ export const shape6Routes = (profiles = LIFECYCLE_PROFILES) =>
2571
+ profiles.filter((p) => SHAPE_6_ROUTE_IDS.includes(p.id));
2572
+
2573
+ // ---- which steps an epic may skip (E35) ----------------------------------------------------------
2574
+ //
2575
+ // A step may be marked N/A ("skipped") when the epic does not need it. UI-design is the only step any
2576
+ // route marks optional today, and only `classic` and `analysis-first` mark it: an epic with no
2577
+ // user-facing surface (backend/API, data, infra) can skip it. The short lanes mark NOTHING optional —
2578
+ // they left the steps they do not need out of the chain instead, which is a different mechanism with a
2579
+ // different audit trail, so "this epic has no optional steps" no longer implies its chain is broken
2580
+ // (see `notOptional`). A skip carries a recorded reason and stays VISIBLE in the chain — both the author step and its review gate
2581
+ // pre-marked `done`, short-circuited by `gatePredicate` — the auditable, reversible counterpart to
2582
+ // omitting `analysis` from the chain entirely.
2583
+ //
2584
+ // IT IS A FACT ABOUT THE ROUTE, NOT ABOUT THE ENGINE. Until E35 this was one module-level set, the
2585
+ // union of every route's `optional` marks, and it answered the same for every epic in the project.
2586
+ // That is wrong the moment two routes disagree, and E40 made them disagree in the sharper of the two
2587
+ // possible ways. A union across routes answers with every route's marks pooled, so the moment ANY
2588
+ // route marked a step optional, every epic in the project could skip it — silently, because a union
2589
+ // never says which route its answer came from. The short lanes are the opposite case and still break
2590
+ // it: they mark nothing, so pooling would hand a `chore` epic `classic`'s optional `ui-design` and let
2591
+ // it skip a step its chain does not even contain.
2592
+ //
2593
+ // The union is gone. `optionalStepsFor(state)` asks THIS epic's route and nothing else.
2594
+
2595
+ // The optional AUTHOR steps of ONE route, in chain order. Author steps only — `isSkippableStep` pairs
2596
+ // each with its `-review` gate, which is the shape every caller expects. A route this release does not
2597
+ // carry has none, which is the honest answer rather than an error: the chain is what it is.
2598
+ //
2599
+ // The rows are read off the route that was HANDED IN, never by resolving its id, for the reason
2600
+ // `matchLifecycleProfile` spells out — a caller-supplied route is not in the module's own index, so
2601
+ // resolving would come back empty and every synthetic route in a test would silently have no optional
2602
+ // steps, agreeing with a broken implementation.
2603
+ export const optionalStepsOf = (profileId, profiles = LIFECYCLE_PROFILES) => {
2604
+ const route = profiles.find((p) => p.id === String(profileId || ''));
2605
+ return route ? profileRows(route).filter((r) => r.optional && !r.id.endsWith('-review')).map((r) => r.id) : [];
2606
+ };
2607
+
2608
+ // WHICH route an epic is on, for the purpose of asking what it may skip.
2609
+ //
2610
+ // The RECORDED key wins whenever it names a route this release carries — including when the CHAIN no
2611
+ // longer fits that route. Two separate reasons, and the second is the load-bearing one:
2612
+ //
2613
+ // * Guessing is worse. `matchLifecycleProfile` picks the shortest fitting route, so a `classic` epic
2614
+ // that legitimately dropped a step can read as a shorter lane; E40 adds two shorter lanes. Shape 6
2615
+ // exists precisely so the route stops being guessed. Matching is the fallback for an epic seeded
2616
+ // before the key existed, not the first answer.
2617
+ // * A chain this release cannot place is not a broken chain. Rule 3: the file wins. An epic written
2618
+ // by a NEWER yadflow carries a step this one has never heard of, so it fits no route HERE — and
2619
+ // reading that as "no route" would strip its optional steps, which means an already-skipped
2620
+ // `ui-design` stops short-circuiting and its gate starts asking for approvals nobody gave. An
2621
+ // older CLI silently downgrading a newer project is exactly what rule 3 forbids.
2622
+ //
2623
+ // A recorded route the chain contradicts is `profile:disagree`, and `yad doctor` reports it. Reporting
2624
+ // is the remedy; refusing to answer is not.
2625
+ //
2626
+ // When NEITHER resolves there is NO route, never a default one. Stamping `classic` on an epic that
2627
+ // records nothing and matches nothing would let a step be skipped on the strength of a route nobody
2628
+ // chose, quietly — the same reason `stampProfile` declines to invent the key in the first place.
2629
+ export const epicProfileId = (state, profiles = LIFECYCLE_PROFILES) => {
2630
+ const recorded = String(state?.profile || '');
2631
+ if (profiles.some((p) => p.id === recorded)) return recorded;
2632
+ return matchLifecycleProfile(state?.steps, profiles);
533
2633
  };
534
2634
 
535
- // The skill that runs each BACK-half (build) step — the build-state analogue of STEP_SKILL. `spec`
2635
+ // ---- "this epic's route never had that step" — ONE rule, three readers (E40) -----------------------
2636
+ //
2637
+ // Asked by `yad next`'s phase line, by the review-PR checklist, and by the thread's artifact-ownership
2638
+ // map. All three used to work it out from the CHAIN, and all three were wrong in the same way.
2639
+ //
2640
+ // A CHAIN IS ALLOWED TO BE SHORT WITHOUT ITS ROUTE BEING SHORT. Every hand-written and pre-shape-6
2641
+ // ledger is a truncated `classic` — `matchLifecycleProfile` says as much ("a chain seeded before a
2642
+ // step existed simply lacks it"), and this repo's own e2e fixtures are `[epic, epic-review]`. Reading
2643
+ // "no `architecture` row" as "this route has no architecture step" tells a `classic` epic it is on a
2644
+ // short lane, and strips a legacy epic of artifacts that are sitting on its disk. Inventing an owner
2645
+ // and losing one are the same error.
2646
+ //
2647
+ // So the answer comes from the RECORDED route and nothing else, and it is deliberately conservative:
2648
+ // FALSE unless the epic itself says which route it is on AND this release carries that route AND that
2649
+ // route has no such step. No key, an unknown key, or an unreadable ledger all mean "assume it has the
2650
+ // step", which is the answer every one of these readers gave before the short lanes existed.
2651
+ //
2652
+ // This is narrower than `epicProfileId`, on purpose. A best-available guess is right for deciding what
2653
+ // a step MAY skip — a wrong guess there is a refusal a person immediately sees. It is not right for a
2654
+ // silent claim about an epic's future or its provenance, where a wrong guess is believed.
2655
+ //
2656
+ // A SKIPPED STEP IS NOT A MISSING ONE, and asking the route rather than the chain gets that for free:
2657
+ // `ui-design` skipped on `classic` is still a step the route has, so the epic still owns the decision.
2658
+ // That is the distinction E35 exists to draw, and it needs no special case here.
2659
+ export const routeLacksStep = (state, stepId, profiles = LIFECYCLE_PROFILES) => {
2660
+ const recorded = String(state?.profile || '');
2661
+ const route = profiles.find((p) => p.id === recorded);
2662
+ return !!route && !profileRows(route).some((r) => r.id === stepId);
2663
+ };
2664
+
2665
+ // The author steps THIS epic may skip. The one answer every skip guard asks.
2666
+ export const optionalStepsFor = (state, profiles = LIFECYCLE_PROFILES) =>
2667
+ optionalStepsOf(epicProfileId(state, profiles), profiles);
2668
+
2669
+ // Does the route an epic RECORDS disagree with the route its chain is on?
2670
+ //
2671
+ // One rule, two readers, so they cannot drift apart: `yad doctor` reports it as `profile:disagree`,
2672
+ // and `skip:not-optional` stays SILENT on an epic this already names. Both findings come from the same
2673
+ // stale label, and the file's own discipline is that one fault is named once — two messages with two
2674
+ // different remedies, one of which does not address the cause, is how people learn to stop reading
2675
+ // warnings.
2676
+ //
2677
+ // False for an epic that records nothing (there is no label to be stale) and for one whose chain fits
2678
+ // no route (`step:off-route` owns that), so it fires only where the two answers genuinely differ.
2679
+ export const recordedRouteDisagrees = (state, profiles = LIFECYCLE_PROFILES) => {
2680
+ if (!isPlainObject(state) || !('profile' in state)) return false;
2681
+ const recorded = String(state.profile || '');
2682
+ if (!profiles.some((p) => p.id === recorded)) return false;
2683
+ const matched = matchLifecycleProfile(state.steps, profiles);
2684
+ return !!matched && matched !== recorded;
2685
+ };
2686
+
2687
+ // ---- seeding a chain FROM a profile (E17) --------------------------------------------------------
2688
+ //
2689
+ // The routes a `yad epic new` may seed: the FEATURE-level ones. Read off the profiles, never written
2690
+ // out again — a route added to `LIFECYCLE_PROFILES` is seedable the day it lands, and E40's chore and
2691
+ // spike lanes need no edit here.
2692
+ //
2693
+ // The two PRODUCT routes, `foundation` and its old spelling `discovery`, are excluded because the
2694
+ // Product level is not a work item on the Epic ladder. Its ledger carries a top-level `kind` marker the
2695
+ // engine keys off, its id is fixed, and it has no `epic.md` and therefore no work-item type. A seed that
2696
+ // produced its chain without those would be a broken product level, not a plain one — so the command
2697
+ // refuses both and sends the user to `yad foundation new` (`seedFoundationState`, below).
2698
+ export const seedableProfiles = (profiles = LIFECYCLE_PROFILES) =>
2699
+ profiles.filter((p) => p.level === 'feature').map((p) => p.id);
2700
+
2701
+ // The `state.json` an epic starts life with, built from a profile and the catalogue. PURE — it
2702
+ // returns an object and writes nothing; `runEpicNew` (cli/epic.mjs) is what puts it on disk, through
2703
+ // `writeState` like every other save.
2704
+ //
2705
+ // WHY THE FIRST STEP IS `in_progress` AND NOT `done`. The skill templates this replaced wrote the
2706
+ // first author step `done` and its gate `in_review`, because by the time a skill seeded, it had
2707
+ // already WRITTEN the artifact — the seed was a record of work finished. The engine seeds before any
2708
+ // work exists, so the honest chain is: the first author step open, everything after it blocked, and
2709
+ // `currentStep` on the first step. `yad next` then names that step and the skill that authors it,
2710
+ // which is the whole point of seeding ahead of the author instead of behind it.
2711
+ //
2712
+ // FIELD ORDER IS THE FILE'S BYTES, and it is the order those deleted templates used. Every chain on
2713
+ // disk in every existing project was written that way, so ordering the keys differently here would
2714
+ // churn the bytes of any epic gate-written after an upgrade — on a verified Product, one CI commit per
2715
+ // epic saying nothing. It is also still the order `yad-discovery` and `yad-change` write, which are
2716
+ // the two seeds the engine deliberately does not own.
2717
+ //
2718
+ // BOTH DIAL NAMES ARE WRITTEN — `assistance`/`automation` beside `driver`/`advance`. The old pair is
2719
+ // still the one every reader outside this repo uses (rule 3, add before remove), and a seed that
2720
+ // wrote only the new names would produce an epic that a user's un-updated check gates cannot read.
2721
+ //
2722
+ // A STUB is the one variation, and it is a variation of the STATUSES, not of the chain. `yad-stub`
2723
+ // mints an anchor for a feature that was built before the Product existed, so a defect can thread off
2724
+ // it today: the same `classic` chain, every step `blocked`, a top-level `kind: "stub"` marker and the
2725
+ // `backfill-pending` sentinel as `currentStep`. Nothing is runnable until `yad-backfill promote` wakes
2726
+ // it — `preconditionsMet` refuses every step of an anchor, and `nextAction` routes it to the backfill
2727
+ // skill rather than to authoring. Modelled here rather than in a second function because it differs
2728
+ // from a plain seed in three fields, and two functions sharing a chain is how the five skill copies
2729
+ // drifted in the first place. `yad-stub` Step 5 is one line now: run `yad epic new --stub`.
2730
+ export function seedState({ epic, profile, type, today, stub = false }) {
2731
+ const p = lifecycleProfile(profile);
2732
+ if (!p || !seedableProfiles().includes(p.id)) {
2733
+ throw err('YAD-STATE-007', `cannot seed the '${profile}' lifecycle profile`,
2734
+ `pick one of ${seedableProfiles().join(' · ')}`);
2735
+ }
2736
+ const steps = seedChain(p, { stub });
2737
+ // Key order mirrors what the stampers produce, so a seeded file and a migrated one are the same
2738
+ // bytes. `schemaVersion` is deliberately absent: `writeJSON` stamps this engine's shape onto an
2739
+ // object that was never read from disk (writeShape, cli/lib.mjs), and naming the number here would
2740
+ // be a second place to forget to change.
2741
+ // `kind` sits between `type` and `profile`, which is where the `yad-stub` template has always put it
2742
+ // and where `stampProfile` would insert on a stub that lacked one. The two words look alike and are
2743
+ // different axes: `kind` is the LIFECYCLE marker (`stub`, `foundation`), `type` is the work item.
2744
+ return {
2745
+ epicId: epic,
2746
+ createdAt: today,
2747
+ type,
2748
+ ...(stub ? { kind: 'stub' } : {}),
2749
+ profile: p.id,
2750
+ currentStep: stub ? 'backfill-pending' : steps[0].id,
2751
+ steps,
2752
+ };
2753
+ }
2754
+
2755
+ // The routes that seed the PRODUCT level. A separate predicate rather than a loosened
2756
+ // `seedableProfiles`: a product route has a fixed id, a `kind` marker and no work-item type, so letting
2757
+ // `seedState` produce one would mint a product level missing all three.
2758
+ export const productProfiles = (profiles = LIFECYCLE_PROFILES) =>
2759
+ profiles.filter((p) => p.level === 'product').map((p) => p.id);
2760
+
2761
+ // The Foundation's `state.json` (E75) — what `yad foundation new` writes. PURE, like `seedState`.
2762
+ //
2763
+ // Only the `foundation` route is seeded. `discovery` is a product route too, and it is the OLD
2764
+ // spelling: this release reads it and converts it, and writing a fresh one would be minting the very
2765
+ // ledger `yad migrate` exists to retire.
2766
+ //
2767
+ // No `type`: the Foundation is not a work item on the ladder, and inventing `feature` for it would put
2768
+ // a thing on the ladder nobody authored — the same reason `stampWorkItemType` leaves it alone.
2769
+ export function seedFoundationState({ today }) {
2770
+ const steps = seedChain(lifecycleProfile('foundation'));
2771
+ return {
2772
+ epicId: FOUNDATION_EPIC,
2773
+ createdAt: today,
2774
+ kind: 'foundation',
2775
+ profile: 'foundation',
2776
+ currentStep: steps[0].id,
2777
+ steps,
2778
+ };
2779
+ }
2780
+
2781
+ // The step rows a seed writes, off a profile and the catalogue — shared by the feature and product
2782
+ // seeds so the two cannot drift in field order, dial names or statuses.
2783
+ function seedChain(p, { stub = false } = {}) {
2784
+ return p.rows.map((row, i) => {
2785
+ const def = stepDef(row.id);
2786
+ return {
2787
+ id: def.id,
2788
+ type: def.kind === 'review' ? 'review+approve' : 'author',
2789
+ artifact: def.artifact,
2790
+ assistance: 'review',
2791
+ driver: 'pair',
2792
+ automation: 'human_approve',
2793
+ advance: 'human',
2794
+ locked: true,
2795
+ // `todo`, not `blocked` (E38). This is the engine's own seed, so it writes the model's word
2796
+ // for "not started" directly — `blocked` now means "cannot proceed, not our choice", and a
2797
+ // freshly seeded chain is not waiting on anybody.
2798
+ status: !stub && i === 0 ? 'in_progress' : 'todo',
2799
+ risk_tags: [...def.risk_tags],
2800
+ };
2801
+ });
2802
+ }
2803
+
2804
+ // The catalogue keyed by id. `stepDef(id)` is null for an id this release does not know — a real
2805
+ // answer, not a gap, and the reason every reader below has a fallback.
2806
+ const STEP_BY_ID = new Map(STEPS.map((s) => [s.id, s]));
2807
+ export const stepDef = (id) => STEP_BY_ID.get(String(id || '')) || null;
2808
+
2809
+ // Split by the one line that matters: Build steps run per story per code repo out of `build-state/`,
2810
+ // everything else runs at the epic level. Expressed as `=== 'build'` / `!== 'build'` rather than as a
2811
+ // list of Shape phases, so the first `release` step (E32) lands in the epic-level table by default
2812
+ // instead of falling silently out of BOTH and leaving `yad next` with no skill to name.
2813
+ const catalogueSkills = (inBuild) => Object.fromEntries(
2814
+ STEPS.filter((s) => s.skill && (s.phase === 'build') === inBuild).map((s) => [s.id, s.skill]),
2815
+ );
2816
+
2817
+ // The Shape authoring step a `yad next` action maps to — the skill the user invokes for that step.
2818
+ // Review (review+approve) steps are driven by the `yad gate` CLI, not a skill, so they are not here.
2819
+ // A VIEW of the catalogue; `cli/test-threads.mjs` deep-equals it against one.
2820
+ //
2821
+ // This is the engine's DEFAULT, not the final answer: a project can bind a different skill to a step
2822
+ // in `.sdlc/skills.json` (E6, below). Every caller that asks "which skill runs this step" goes through
2823
+ // `stepSkills`, which consults the project first and falls back to here.
2824
+ export const STEP_SKILL = catalogueSkills(false);
2825
+
2826
+ // The skill that runs each Build (build) step — the build-state analogue of STEP_SKILL. `spec`
536
2827
  // and `tasks` are the two legs of the SAME yad-spec ceremony (run-loop.md), so both map to yad-spec;
537
2828
  // the chain renderer collapses the consecutive duplicate. `engineer-review` is the human merge gate.
538
- export const BUILD_STEP_SKILL = {
539
- spec: 'yad-spec',
540
- tasks: 'yad-spec',
541
- implement: 'yad-implement',
542
- checks: 'yad-checks',
543
- 'engineer-review': 'yad-engineer-review',
544
- };
2829
+ // Like STEP_SKILL, this is the DEFAULT a project overrides in `.sdlc/skills.json` — read it through
2830
+ // `stepSkills`, not directly, or the binding is silently ignored.
2831
+ export const BUILD_STEP_SKILL = catalogueSkills(true);
2832
+
2833
+ // The fixed Build order. Used to derive the "remaining chain" from the active step onward even if a
2834
+ // repo's `steps` array is partial or out of order. The catalogue's own order IS this order.
2835
+ const BUILD_STEP_ORDER = STEPS.filter((s) => s.phase === 'build').map((s) => s.id);
2836
+
2837
+ // ---- skill binding (E6) --------------------------------------------------------------------------
2838
+ //
2839
+ // WHICH skill runs a step is a project's choice, not the engine's. The catalogue above still ships a
2840
+ // default for every step — that is what `STEP_SKILL` / `BUILD_STEP_SKILL` hold, and a project that
2841
+ // binds nothing behaves exactly as it did before. What changes here is that the default is no longer
2842
+ // the only possible answer: `.sdlc/skills.json` can name a different skill for a step, or several.
2843
+ //
2844
+ // WHY THIS IS A FILE AND THE CATALOGUE IS NOT. The catalogue says what a step IS — its phase, its
2845
+ // artifact, what reviews it — and those are facts about the lifecycle this engine implements, so they
2846
+ // stay in code where they cannot drift. WHO does the work is a different kind of fact: it depends on
2847
+ // which skills a team has installed and which harness they run. E3 deleted the BMAD personas from the
2848
+ // engine, and E11 supports harnesses other than Claude Code; neither is possible while the only
2849
+ // answer to "who authors the architecture" is a string compiled into this file.
2850
+ //
2851
+ // THE FILE WINS, AND THE DOCTOR ONLY REPORTS. Same discipline as `workItemType` and the lifecycle
2852
+ // profile: a project may bind a skill this engine has never heard of — that is the whole point, since
2853
+ // the engine cannot know what a team installed. So nothing here validates a skill NAME. `yad doctor`
2854
+ // reports a binding on a step id the catalogue does not know, and changes nothing. Detecting which
2855
+ // skills are actually installed is E50; per-profile defaults are E51.
2856
+ //
2857
+ // SEVERAL SKILLS PER STEP IS A CHAIN, NEVER A PANEL (closed decision 7). They run in the order given,
2858
+ // each one seeing what the one before it produced, and the LAST output is the artifact. A panel — run
2859
+ // three, pick one — needs a picking step, which is either a human reading three architectures or an AI
2860
+ // judge nobody should trust inside a governance tool. One skill is the default; more is opt-in, and
2861
+ // every surface that prints a chain of more than one says it costs more.
2862
+ //
2863
+ // The file, all of it optional:
2864
+ //
2865
+ // {
2866
+ // "schemaVersion": 6,
2867
+ // "steps": {
2868
+ // "architecture": "my-architecture-skill",
2869
+ // "stories": ["shape-the-stories", "yad-stories"]
2870
+ // }
2871
+ // }
2872
+ //
2873
+ // A value may be one skill or a list of them; both are read back as a list, so nothing downstream has
2874
+ // to handle two shapes. The wrapper object exists so that E50 and E51 can add keys beside `steps`
2875
+ // without the file changing shape.
2876
+
2877
+ // The catalogue's own answer for a step, Shape or Build. One lookup, because a step id belongs to
2878
+ // exactly one of the two tables (`catalogueSkills` splits them on the same `phase === 'build'` rule),
2879
+ // and a caller asking "which skill runs `implement`" should not have to know which half it is in.
2880
+ // `__proto__: null` because this is looked up by a step id read out of a project's `state.json`, and
2881
+ // on a normal object literal `Object.prototype` answers for `constructor`, `toString` and friends. A
2882
+ // chain carrying a step called `constructor` would otherwise resolve its "skill" to a function.
2883
+ const CATALOGUE_SKILL = { __proto__: null, ...STEP_SKILL, ...BUILD_STEP_SKILL };
2884
+
2885
+ // Read a raw parsed `.sdlc/skills.json` into `{ steps: { <id>: [skill, …] } }`.
2886
+ //
2887
+ // Junk is DROPPED here rather than rejected, and that is deliberate: this runs inside `yad next`,
2888
+ // which must keep working on a project whose config file someone mistyped. An empty string, an empty
2889
+ // list, a number, a nested object — each simply leaves that step on its catalogue default, and
2890
+ // `yad doctor` is what tells the user their line did nothing. A command that refuses to say what to do
2891
+ // next because a config file has a stray comma would be the worse failure.
2892
+ // Written with `defineProperty`, not `out[id] = …`. A plain assignment of the key `__proto__` sets
2893
+ // the object's PROTOTYPE instead of adding a key, so a binding spelled that way would vanish
2894
+ // completely — unlisted by `yad skill list` and unreported by all three doctor checks, which is
2895
+ // exactly the invisible binding those checks exist to catch. The object keeps its normal prototype;
2896
+ // `stepSkills` guards the LOOKUP side with `Object.hasOwn` instead.
2897
+ //
2898
+ // Consecutive duplicates are collapsed, because running the same skill twice in a row means nothing
2899
+ // and the two surfaces would disagree about it: the rendered Build chain folds them (that is what
2900
+ // `dedupeConsecutive` has always done for spec+tasks) while the cost note counted them, so a step
2901
+ // bound to `["a", "a"]` would be billed for two runs and shown as one.
2902
+ export function normalizeBindings(raw) {
2903
+ const out = {};
2904
+ const steps = isPlainObject(raw) ? raw.steps : null;
2905
+ if (isPlainObject(steps)) {
2906
+ for (const [id, value] of Object.entries(steps)) {
2907
+ const list = dedupeConsecutive((Array.isArray(value) ? value : [value])
2908
+ .filter((s) => typeof s === 'string' && s.trim())
2909
+ .map((s) => s.trim()));
2910
+ if (list.length) Object.defineProperty(out, id, { value: list, enumerable: true, writable: true, configurable: true });
2911
+ }
2912
+ }
2913
+ return { steps: out };
2914
+ }
2915
+
2916
+ // The project's bindings, or the empty set when the file is absent or unreadable.
2917
+ export const loadSkillBindings = (root) =>
2918
+ normalizeBindings(readJSON(path.join(root, PROJECT_FILES.skillsConfig), null));
2919
+
2920
+ // Which skills run a step, in order. The project's binding when it has one, else the catalogue's
2921
+ // single default, else nothing at all — a Shape review gate is driven by `yad gate`, not by a skill,
2922
+ // and an unbound one honestly has none.
2923
+ //
2924
+ // `bindings` is a PARAMETER, not a read from disk, so a test can hand this function a binding that
2925
+ // disagrees with the catalogue. A resolver that opened the file itself would be untestable against
2926
+ // any project but the one the test happens to be standing in.
2927
+ export function stepSkills(stepId, bindings = null) {
2928
+ // `Object.hasOwn`, not a bare lookup. The id comes out of a project's `state.json`, and on a plain
2929
+ // object `Object.prototype` answers for `constructor`, `toString` and friends — so a chain carrying
2930
+ // a step called `constructor` resolved its "skill" to a function, and `yad next` threw trying to
2931
+ // spread it. The catalogue map below is `__proto__: null` for the same reason.
2932
+ const steps = bindings?.steps;
2933
+ const boundTo = (id) => (isPlainObject(steps) && Object.hasOwn(steps, id) ? steps[id] : null);
2934
+ const bound = boundTo(stepId);
2935
+ if (Array.isArray(bound) && bound.length) return [...bound];
2936
+ // A step that took over from an OLD id inherits the old id's binding until it is given one of its
2937
+ // own (rule 3). A project that bound its own skill to `discovery` before E75 renamed the product
2938
+ // step to `foundation` would otherwise be switched back to the default without anyone deciding it.
2939
+ const legacy = Object.hasOwn(BINDING_SUCCEEDS, stepId) ? boundTo(BINDING_SUCCEEDS[stepId]) : null;
2940
+ if (Array.isArray(legacy) && legacy.length) return [...legacy];
2941
+ const fallback = CATALOGUE_SKILL[stepId];
2942
+ return typeof fallback === 'string' ? [fallback] : [];
2943
+ }
2944
+
2945
+ // new step id -> the old step id whose binding it inherits while it has none of its own.
2946
+ const BINDING_SUCCEEDS = { __proto__: null, foundation: 'discovery' };
2947
+
2948
+ // The two keys every action object carries for its skill, from one resolved list.
2949
+ //
2950
+ // `skill` STAYS A STRING and is the first of the chain, because ~20 call sites and `yad next --json`
2951
+ // read it that way and rule 3 keeps an old name working for a whole major. `skills` is added only
2952
+ // when the chain is longer than one — NOT always-present-null like `parallel` beside it. That is a
2953
+ // deliberate departure from the neighbouring field: `cli/test-golden.mjs` deep-equals this output
2954
+ // against a frozen v3 project, and rule 6 says a frozen project's answers never change, so a key that
2955
+ // appeared on every action would break it. A project that binds nothing gets byte-identical output.
2956
+ export const skillFields = (ids) => ({
2957
+ skill: ids[0] ?? null,
2958
+ ...(ids.length > 1 ? { skills: ids } : {}),
2959
+ });
2960
+
2961
+ // ---- the six phases ------------------------------------------------------------------------------
2962
+ //
2963
+ // Between the three PARTS (Shape · Build · Run) and the individual steps sits one more rung: the
2964
+ // phase. Six of them, in order, each belonging to exactly one part:
2965
+ //
2966
+ // Discover Shape discovery · analysis · epic, each with its gate
2967
+ // Design Shape architecture (with the locked contract) · ui-design, each with its gate
2968
+ // Plan Shape stories · test-cases, each with its gate
2969
+ // Build Build spec · tasks · implement · checks · engineer-review
2970
+ // Release Run PLANNED — release notes · version · deploy record · gate
2971
+ // Operate Run PLANNED — defects · feedback · retrospective · improvements
2972
+ //
2973
+ // Release and Operate are NAMED and not built. No step, skill or gate exists for either, and none is
2974
+ // invented: `built: false` is the whole of what is known about them. (`skills/sdlc/config.yaml`
2975
+ // carries the same table for the skills to read, and a test holds the two together row for row.) They are listed
2976
+ // rather than left out because a person has to be able to see that the lifecycle does not stop at
2977
+ // merge — and a renderer that shows them as planned tells the truth, while one that hides them
2978
+ // implies the work ends at Build.
2979
+ //
2980
+ // DERIVED, NOT STORED. A step's phase is a pure function of its id — it comes off the step catalogue
2981
+ // (E4, above), so nothing is written into `state.json` and there is no file-shape change. Storing it
2982
+ // would put a second copy of the catalogue on disk, and the two copies would then need a doctor check
2983
+ // to catch a drift that cannot happen while there is only one answer.
2984
+ //
2985
+ // "PHASE" IS A CROWDED WORD IN THIS REPOSITORY, and none of the other three mean this one:
2986
+ // * `cli/gate.mjs` says "merge phase" for a stage INSIDE a single gate run;
2987
+ // * the `build-state` notes say "Phase 4a/4b", from the old build-plan numbering;
2988
+ // * `docs/phase-N-build-plan.md` are the development phases of yadflow itself.
2989
+ // So the phase NUMBER is deliberately never printed: "Phase 4" already means something else to anyone
2990
+ // who has read those. The name is what is shown.
2991
+ export const PHASES = [
2992
+ { id: 'discover', name: 'Discover', part: 'Shape', built: true },
2993
+ { id: 'design', name: 'Design', part: 'Shape', built: true },
2994
+ { id: 'plan', name: 'Plan', part: 'Shape', built: true },
2995
+ { id: 'build', name: 'Build', part: 'Build', built: true },
2996
+ { id: 'release', name: 'Release', part: 'Run', built: false },
2997
+ { id: 'operate', name: 'Operate', part: 'Run', built: false },
2998
+ ];
545
2999
 
546
- // The fixed back-half order. Used to derive the "remaining chain" from the active step onward even if a
547
- // repo's `steps` array is partial or out of order.
548
- const BUILD_STEP_ORDER = ['spec', 'tasks', 'implement', 'checks', 'engineer-review'];
3000
+ // The PRODUCT level's phase (E75). Kept out of `PHASES` on purpose, because the roadmap's two levels
3001
+ // are two different ladders: a feature epic walks the six phases above, once per epic, and the
3002
+ // Foundation runs once per product, before any of them. It belongs to no Part — Shape, Build and Run
3003
+ // are the rhythms of FEATURE work — so it has no `part`, and a renderer printing the six-phase strip
3004
+ // for a Foundation would claim a journey it never takes.
3005
+ export const PRODUCT_PHASES = [
3006
+ { id: 'foundation', name: 'Foundation', level: 'product', built: true },
3007
+ ];
3008
+
3009
+ // Which phase each step belongs to. Split in two on purpose, because the `-review` rule differs.
3010
+ //
3011
+ // SHAPE steps each have a review gate named `<id>-review`, and that gate belongs to the phase of the
3012
+ // artifact it reviews — `epic-review` is Discover beside `epic`. BUILD steps have no such gates:
3013
+ // `engineer-review` is a step in its own right, the locked human merge gate, not the review of a step
3014
+ // called `engineer`. So the suffix is only ever stripped against the Shape table, which is what keeps
3015
+ // `checks-review` — a step that does not exist — from quietly resolving into Build.
3016
+ //
3017
+ // Every id in STEP_SKILL and BUILD_STEP_SKILL above appears in one of these two, and a test asserts
3018
+ // membership directly rather than through `stepPhase`, so a step added to one table and forgotten
3019
+ // here fails rather than resolving by accident.
3020
+ //
3021
+ // The roadmap also names a `feasibility` step in Discover. Nothing implements it — no skill, no step
3022
+ // id, no gate — so it is deliberately absent from the catalogue rather than declared and dead. A new
3023
+ // step is added by adding a row to `STEPS`; RENAMING one means adding the new row beside the old and
3024
+ // keeping both for a major (rule 3), because a project still carrying the old id would otherwise trip
3025
+ // `phase:unknown` on every `yad doctor` run until it is migrated.
3026
+ //
3027
+ // Both tables are now VIEWS of the catalogue (E4). A row is listed under its phase unless it is a
3028
+ // review gate that gates ANOTHER step — that is, unless it carries `reviews`. That one rule is what
3029
+ // keeps `epic-review` out (it is the gate on `epic`, which is already listed) while keeping
3030
+ // `engineer-review` in (it gates nothing; it is the last step of Build in its own right), and it is
3031
+ // the same distinction the `-review` strip below depends on.
3032
+ const phaseTable = (rows) => Object.fromEntries(rows.filter((r) => !r.reviews).map((r) => [r.id, r.phase]));
3033
+ const SHAPE_STEP_PHASE = phaseTable(STEPS.filter((r) => r.phase !== 'build'));
3034
+ const BUILD_STEP_PHASE = phaseTable(STEPS.filter((r) => r.phase === 'build'));
3035
+ const STEP_PHASE = { ...SHAPE_STEP_PHASE, ...BUILD_STEP_PHASE };
3036
+
3037
+ // `currentStep` markers that are NOT steps: they never appear in `steps[]`, and no phase claims them.
3038
+ // `ready-for-build` is the one with a phase anyway — see `currentPhase` below, which is where an epic's
3039
+ // position is decided rather than a step's.
3040
+ export const SENTINELS = ['ready-for-build', 'backfill-pending', 'backfill-done', 'discovery-done', 'foundation-done'];
3041
+
3042
+ // The phase a STEP id belongs to, or null for anything this engine does not recognise — a sentinel, a
3043
+ // step from a future profile, a typo. Null, never a guess: a renderer showing the wrong phase is worse
3044
+ // than one showing none.
3045
+ //
3046
+ // THE CATALOGUE IS ASKED FIRST, and that is load-bearing rather than an optimisation. Strip `-review`
3047
+ // first and `engineer-review` resolves to `engineer`, which nothing claims, so the last step of Build
3048
+ // falls out of every phase. The strip below is reached only by an id the catalogue does not carry.
3049
+ export function stepPhase(id) {
3050
+ const s = String(id || '');
3051
+ // The catalogue answers for every id this release knows, review gates included — no string surgery.
3052
+ const def = stepDef(s);
3053
+ if (def) return def.phase;
3054
+ // Only reached by an id the catalogue does not carry: a step from a newer yadflow or a future
3055
+ // profile. `<known-shape-step>-review` still resolves, so a project ahead of this release keeps its
3056
+ // gates placed. Stripped against the SHAPE table only, which is what stops `checks-review` — a step
3057
+ // that does not exist — from quietly resolving into Build.
3058
+ const base = s.replace(/-review$/, '');
3059
+ return Object.hasOwn(SHAPE_STEP_PHASE, base) ? SHAPE_STEP_PHASE[base] : null;
3060
+ }
3061
+
3062
+ // The phase an EPIC is in. Different question from `stepPhase`, and this is the one every renderer
3063
+ // asks — so there is one of it, shared by `yad next` and `yad thread`.
3064
+ //
3065
+ // TWO THINGS A STEP LOOKUP ALONE GETS WRONG:
3066
+ //
3067
+ // * `ready-for-build` is a marker, not a step, so `stepPhase` gives it nothing — and it is the
3068
+ // `currentStep` for the WHOLE of Build. `nextAction` never reports a concrete build step id at the
3069
+ // epic level either; the real ones (`spec`, `implement`, …) live per story per repo in
3070
+ // `build-state`. So a step lookup alone can never place an epic in Build at all, which is the
3071
+ // phase with the most steps in it. The marker means "Shape is approved, Build can run" — that is
3072
+ // Build, and it is resolved here rather than in `stepPhase`, which stays a pure id lookup.
3073
+ // * The PRODUCT level (`EP-foundation`, or its old spelling `EP-discovery`) is not a work item on the
3074
+ // feature ladder. It is in the Foundation phase for its whole life — while authoring, in review,
3075
+ // and once its `-done` sentinel is reached — and it never enters Design, Plan or Build. So the
3076
+ // answer is decided by the LEVEL, which the caller knows from the ledger's `kind`, and not by the
3077
+ // step id: a sentinel has no phase of its own, and asking `stepPhase` would lose the Foundation the
3078
+ // moment it was approved.
3079
+ //
3080
+ // A stub epic (`backfill-pending` / `backfill-done`) has no phase either, and needs no special case:
3081
+ // neither marker is a step.
3082
+ export function currentPhase(currentStep, { product = false } = {}) {
3083
+ if (product) return 'foundation';
3084
+ const cur = String(currentStep || '');
3085
+ if (cur === 'ready-for-build') return 'build';
3086
+ return stepPhase(cur);
3087
+ }
3088
+
3089
+ // The phase record for an epic's current step, or null. `.name` is the word a person reads. Both
3090
+ // ladders are searched: a Foundation step placed by `stepPhase` alone must still name its phase.
3091
+ export const phaseOf = (currentStep, opts) =>
3092
+ [...PHASES, ...PRODUCT_PHASES].find((p) => p.id === currentPhase(currentStep, opts)) || null;
3093
+
3094
+ // Every step id this engine knows in a phase, in chain order. Empty for a phase nothing implements
3095
+ // yet, which is exactly what `built: false` says.
3096
+ export const phaseSteps = (phaseId) => Object.keys(STEP_PHASE).filter((s) => STEP_PHASE[s] === phaseId);
549
3097
 
550
3098
  // Collapse consecutive identical skills (spec+tasks → one yad-spec) so the rendered chain reads
551
- // yad-spec → yad-implement → yad-checks → yad-engineer-review, matching the build-half mental model.
3099
+ // yad-spec → yad-implement → yad-checks → yad-engineer-review, matching the Build mental model.
552
3100
  // Folds against the last KEPT element (not the raw neighbor) so a dropped null between duplicates can't
553
3101
  // reintroduce one.
554
- function dedupeConsecutive(skills) {
3102
+ //
3103
+ // Exported because `yad next` has to fold the SAME way to know how much of `chain` belongs to the
3104
+ // active step. A second copy of this rule there would agree until the first step bound to the same
3105
+ // skill twice, which folds to one entry in the chain and two in the step's own list.
3106
+ export function dedupeConsecutive(skills) {
555
3107
  const out = [];
556
3108
  for (const s of skills) if (s && s !== out[out.length - 1]) out.push(s);
557
3109
  return out;
@@ -561,30 +3113,51 @@ function dedupeConsecutive(skills) {
561
3113
  // remaining chain. The active step is `currentStep`'s entry, or the first step not yet `done`. Returns
562
3114
  // `shipped: true` only when there ARE steps and every one is `done`; an empty/missing steps array is
563
3115
  // `unknown` (not-started), NEVER shipped — otherwise a half-seeded file would render a false "shipped ✓".
564
- export function buildNextForRepo(repoState = {}) {
3116
+ export function buildNextForRepo(repoState = {}, { bindings = null } = {}) {
3117
+ // A whole lane SKIPPED (E39): nothing to drive and nothing shipped. A lane on disk carries a `status` only
3118
+ // when it is skipped; this RESULT always has a `status`, so the skip reuses that key, and `record` is added
3119
+ // ONLY here, which keeps a frozen v3 lane's output byte-identical. The word is honoured only over a lane
3120
+ // with no work in it (`laneStarted`): `skipped` beside real steps — a hand edit, or an older `yad-run`
3121
+ // filling in what looked like a half-seeded lane — is read as the work, and `yad doctor` fails the file.
3122
+ if (repoState?.status === 'skipped' && !laneStarted(repoState)) {
3123
+ return { step: null, status: 'skipped', shipped: false, skill: null, automation: null, locked: false, chain: [],
3124
+ ...(isPlainObject(repoState.record) ? { record: repoState.record } : {}) };
3125
+ }
565
3126
  const steps = Array.isArray(repoState.steps) ? repoState.steps : [];
566
3127
  const byId = new Map(steps.map((s) => [s.id, s]));
567
3128
  // Empty/half-seeded file ⇒ unknown (not-started), NEVER shipped. Every step done ⇒ shipped.
568
3129
  if (!steps.length) {
569
3130
  return { step: null, status: 'unknown', shipped: false, skill: null, automation: null, locked: false, chain: [] };
570
3131
  }
571
- if (steps.every((s) => s.status === 'done')) {
3132
+ if (steps.every((s) => isPassed(s))) {
572
3133
  return { step: null, status: 'done', shipped: true, skill: null, automation: null, locked: false, chain: [] };
573
3134
  }
574
3135
  // Active = the orchestrator's currentStep when it isn't already done, else the first not-done step
575
3136
  // (guaranteed to exist here — not every step is done). currentStep authority, with a done-step skip.
576
3137
  const cur = byId.get(repoState.currentStep);
577
- const active = cur && cur.status !== 'done' ? cur : steps.find((s) => s.status !== 'done');
3138
+ const active = cur && !isPassed(cur) ? cur : steps.find((s) => !isPassed(s));
578
3139
  // The remaining chain: the active step + every later step in the canonical order, mapped to skills.
579
3140
  const from = BUILD_STEP_ORDER.indexOf(active.id);
580
3141
  const tail = from === -1 ? [active.id] : BUILD_STEP_ORDER.slice(from);
581
- const chain = dedupeConsecutive(tail.map((id) => BUILD_STEP_SKILL[id] || null));
3142
+ const chain = dedupeConsecutive(tail.flatMap((id) => stepSkills(id, bindings)));
582
3143
  return {
583
3144
  step: active.id,
584
- status: active.status || 'blocked',
585
- automation: active.automation || 'human_approve',
3145
+ // The CANONICAL state (E38), so a Build lane reports itself in the same vocabulary as a Shape
3146
+ // step. `build-state/<story>.json` is written by the `yad-run` / `yad-implement` skills and is
3147
+ // deliberately not migrated, which cuts two ways here: a lane HALTED by those skills is `blocked`
3148
+ // with a record and reports itself that way, while one halted by an older yad-run carries a bare
3149
+ // `blocked` — the pre-shape-7 spelling of "not started" — and reads as `todo` until the next run
3150
+ // rewrites it. Nothing advances past it either way (`isPassed` is false for both), so what the
3151
+ // record buys is the word. A step with no status at all is `todo` for the same reason, and a word
3152
+ // this release cannot name falls through to itself rather than being renamed to something it is not.
3153
+ status: stepStatus(active) || active.status || 'todo',
3154
+ // Read either spelling (old wins), then say it the OLD way. `cli/test-golden.mjs` deep-equals
3155
+ // this output against a frozen v3 snapshot, and rule 6 says a frozen project's answers never
3156
+ // change — so even ADDING a key here would break it. The output follows the field in the major
3157
+ // that removes `automation`; until then a step carrying only `advance` still reports correctly.
3158
+ automation: AUTOMATION_FROM_ADVANCE[stepAdvance(active)] || 'human_approve',
586
3159
  locked: !!active.locked,
587
- skill: BUILD_STEP_SKILL[active.id] || null,
3160
+ ...skillFields(stepSkills(active.id, bindings)),
588
3161
  shipped: false,
589
3162
  chain,
590
3163
  };
@@ -592,11 +3165,11 @@ export function buildNextForRepo(repoState = {}) {
592
3165
 
593
3166
  // PURE: map every parsed build-state object → its per-repo next sub-steps. `buildStates` is the array
594
3167
  // `loadLedger` reads from build-state/*.json. Repos are sorted for a stable, machine-independent order.
595
- export function buildNextActions(buildStates = []) {
3168
+ export function buildNextActions(buildStates = [], { bindings = null } = {}) {
596
3169
  return buildStates.map((bs) => ({
597
3170
  story: bs.story || null,
598
3171
  repos: Object.keys(bs.repos || {}).sort()
599
- .map((repo) => ({ repo, ...buildNextForRepo(bs.repos[repo]) })),
3172
+ .map((repo) => ({ repo, ...buildNextForRepo(bs.repos[repo], { bindings }) })),
600
3173
  }));
601
3174
  }
602
3175
 
@@ -618,13 +3191,14 @@ export function backfillAnchorKind(state) {
618
3191
  // (the Phase B rail) and by the driver. No FS / network.
619
3192
  export function preconditionsMet(state, stepId) {
620
3193
  if (!state || !Array.isArray(state.steps)) {
621
- const ok = stepId === 'epic' || stepId === 'analysis' || stepId === 'discovery';
3194
+ const ok = stepId === 'epic' || stepId === 'analysis' || stepId === 'foundation' || stepId === 'discovery';
622
3195
  return { ok, blockedBy: null, reason: ok ? 'entry step (no state seeded yet)' : `start with yad-epic — no epic state for '${stepId}'` };
623
3196
  }
624
- // A stub anchor (backfill-pending) or a light-promoted anchor (backfill-done) has NO runnable front
625
- // step: its front chain is intentionally left `blocked`. It evolves via `yad-backfill promote` / a
3197
+ // A stub anchor (backfill-pending) or a light-promoted anchor (backfill-done) has NO runnable Shape
3198
+ // step: its Shape chain is intentionally left un-started — `todo` since shape 7, `blocked` in every
3199
+ // file written before it, which reads as the same thing. It evolves via `yad-backfill promote` / a
626
3200
  // threaded `yad-change`, never by authoring `epic` against the anchor itself — so the precondition
627
- // guard must not green-light one (its blocked steps would otherwise read as "entry step ready").
3201
+ // guard must not green-light one (its un-started steps would otherwise read as "entry step ready").
628
3202
  const anchorKind = backfillAnchorKind(state);
629
3203
  if (anchorKind) {
630
3204
  const anchor = anchorKind === 'documented';
@@ -634,9 +3208,25 @@ export function preconditionsMet(state, stepId) {
634
3208
  : `${stepId} is not runnable — this is a stub (backfill pending); run yad-backfill then promote, or thread a change with yad-change` };
635
3209
  }
636
3210
  const i = state.steps.findIndex((s) => s.id === stepId);
637
- if (i === -1) return { ok: false, blockedBy: null, reason: `unknown step '${stepId}'` };
638
- if (state.steps[i].status === 'done') return { ok: false, blockedBy: null, reason: `${stepId} is already done` };
639
- const blocker = state.steps.slice(0, i).find((s) => s.status !== 'done');
3211
+ // TWO WAYS A STEP IS NOT IN THE CHAIN, and they read very differently to a person. Before E40 the
3212
+ // only one was a typo or an id from a newer release, so "unknown step" covered it. A short lane
3213
+ // makes the other one ordinary: `architecture` is a step the catalogue knows perfectly well and this
3214
+ // epic's ROUTE does not carry. Telling someone their step is unknown when `yad skill list` shows it
3215
+ // sends them looking for a misspelling that is not there.
3216
+ if (i === -1) {
3217
+ const known = !!stepDef(stepId);
3218
+ const route = epicProfileId(state);
3219
+ return { ok: false, blockedBy: null,
3220
+ reason: known
3221
+ ? `${stepId} is not on this epic's route${route ? ` (\`${route}\`)` : ''} — the step exists, this chain does not carry it`
3222
+ : `unknown step '${stepId}'` };
3223
+ }
3224
+ // Name the state that finished it. `yad next --check ui-design` answering "already done" for a deferred
3225
+ // step would tell somebody the UI exists (E37).
3226
+ if (isPassed(state.steps[i])) return { ok: false, blockedBy: null, reason: `${stepId} is already ${stepStatus(state.steps[i])}` };
3227
+ // A step re-opened BEHIND finished work (E41) is not a blocker of anything past that work: it runs
3228
+ // beside the chain. It still blocks the steps between it and that work, its own gate included.
3229
+ const blocker = state.steps.slice(0, i).find((s, b) => !isPassed(s) && !behindFinishedWork(state.steps, b, i));
640
3230
  if (blocker) return { ok: false, blockedBy: blocker.id, reason: `${blocker.id} has not passed yet` };
641
3231
  return { ok: true, blockedBy: null, reason: 'ready' };
642
3232
  }
@@ -654,9 +3244,15 @@ export function stateInvariants(state) {
654
3244
  if (!state || !Array.isArray(state.steps)) return [];
655
3245
  const violations = [];
656
3246
  for (const step of state.steps) {
657
- if (step.type !== 'review+approve' || step.status !== 'done') continue;
3247
+ // `=== 'done'` on the gate, `isPassed` on the author, and the asymmetry is the rule itself: only a
3248
+ // gate that genuinely COMPLETED here implies its artifact was written. A skipped or satisfied gate
3249
+ // pairs with a skipped or satisfied author and is no violation — and `deferred` (E37) is the one
3250
+ // that makes this load-bearing rather than theoretical, because it is a PER-STEP state with no
3251
+ // pairing rule. `isPassed` here would read a deferred gate above a `todo` author as a violation,
3252
+ // and `repairState` below would then stamp that author `done` — claiming an artifact nobody wrote.
3253
+ if (step.type !== 'review+approve' || stepStatus(step) !== 'done') continue;
658
3254
  const author = authorStepFor(state, step);
659
- if (!author || author.status === 'done') continue;
3255
+ if (!author || isPassed(author)) continue;
660
3256
  violations.push({
661
3257
  code: 'YAD-STATE-005',
662
3258
  reviewStep: step.id,
@@ -669,11 +3265,16 @@ export function stateInvariants(state) {
669
3265
 
670
3266
  // Apply the repair `stateInvariants` describes: close every author step stranded behind a done review
671
3267
  // gate. Mutates `state` and returns the ids it closed (empty when already consistent — idempotent).
672
- export function repairState(state) {
3268
+ // `close` (E18): `{ by, date }`. A repair is an escape hatch, so the step it closes says so (rule 7).
3269
+ export function repairState(state, close = null) {
673
3270
  const closed = [];
674
3271
  for (const v of stateInvariants(state)) {
675
3272
  const author = state.steps.find((s) => s.id === v.authorStep);
676
- if (author && author.status !== 'done') { author.status = 'done'; closed.push(author.id); }
3273
+ if (author && !isPassed(author)) {
3274
+ author.status = 'done';
3275
+ if (close) stampClosed(author, closingRecord({ ...close, via: 'repair' }));
3276
+ closed.push(author.id);
3277
+ }
677
3278
  }
678
3279
  return closed;
679
3280
  }
@@ -681,29 +3282,85 @@ export function repairState(state) {
681
3282
  // PURE next-action resolver for ONE epic's ledger — what `yad next <epic>` prints. Reads state + the
682
3283
  // recorded review PRs only. kind:
683
3284
  // 'new' — no epic state yet (seed one with yad-epic)
684
- // 'author' — invoke a front authoring skill (STEP_SKILL)
3285
+ // 'author' — invoke a Shape authoring skill (whatever `stepSkills` resolves for the step)
685
3286
  // 'review-open' — open the review PR/MR (`yad gate open`)
686
3287
  // 'review-sync' — a review PR/MR is open; sync its state (`yad gate sync`)
687
- // 'build' — front half approved (ready-for-build); the build half can run
688
- export function nextAction(ledger, { epic } = {}) {
3288
+ // 'build' — Shape approved (ready-for-build); Build can run
3289
+ //
3290
+ // Two keys are added only when they have something in them, so an epic with neither prints exactly the
3291
+ // JSON it printed before (the golden test deep-equals it):
3292
+ // reopened — a lane per step re-opened behind finished work (E41), each shaped like an action
3293
+ // debt — the steps still owed as debt (`owedSteps`), the reminder that repeats until they are paid
3294
+ export function nextAction(ledger, opts = {}) {
3295
+ const a = shapeNextAction(ledger, opts);
3296
+ const state = ledger?.state;
3297
+ if (!state || !Array.isArray(state.steps) || isProductLevel(state) || backfillAnchorKind(state)) return a;
3298
+ const lanes = reopenedLanes(ledger, { epicId: a.epicId, currentStep: state.currentStep, bindings: opts.bindings ?? null });
3299
+ const debt = owedSteps(state).map((s) => ({ step: s.id, status: stepStatus(s) ?? s.status, record: s.record || null }));
3300
+ return { ...a, ...(lanes.length ? { reopened: lanes } : {}), ...(debt.length ? { debt } : {}) };
3301
+ }
3302
+
3303
+ // The steps still OWED AS DEBT (E41): flagged `debt: true` and not yet written here. One entry per pair —
3304
+ // the author step while it is unfinished, then its gate — so one `yad defer --debt` reads as one debt.
3305
+ export function owedSteps(state) {
3306
+ if (!isPlainObject(state) || !Array.isArray(state.steps)) return [];
3307
+ const open = state.steps.filter((s) => isPlainObject(s) && typeof s.id === 'string' && s.debt === true && stepStatus(s) !== 'done');
3308
+ const ids = new Set(open.map((s) => s.id));
3309
+ return open.filter((s) => !(s.id.endsWith('-review') && ids.has(s.id.replace(/-review$/, ''))));
3310
+ }
3311
+
3312
+ // Is this step open again BEHIND finished work (E41)? For the words a command prints after `yad undefer`.
3313
+ export function isReopenedStep(state, stepId) {
3314
+ const steps = Array.isArray(state?.steps) ? state.steps : [];
3315
+ const i = steps.findIndex((s) => s?.id === stepId);
3316
+ return i !== -1 && !isPassed(steps[i]) && behindFinishedWork(steps, i);
3317
+ }
3318
+
3319
+ // A lane for each step re-opened behind finished work (E41) that can be worked on now — the same three
3320
+ // actions a chain step gets (author it, open its review, sync its review) or the blocker it waits on.
3321
+ function reopenedLanes(ledger, { epicId, currentStep, bindings }) {
3322
+ const { steps } = ledger.state;
3323
+ return steps.filter((s, b) => isPlainObject(s) && s.id !== currentStep && !isPassed(s)
3324
+ && behindFinishedWork(steps, b) && preconditionsMet(ledger.state, s.id).ok)
3325
+ .map((s) => {
3326
+ if (stepStatus(s) === 'blocked') return { step: s.id, kind: 'blocked', status: 'blocked', record: s.record || null };
3327
+ if (s.type === 'author') return { step: s.id, kind: 'author', status: s.status, ...skillFields(stepSkills(s.id, bindings)), artifact: s.artifact };
3328
+ const pr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(s.artifact));
3329
+ return { step: s.id, kind: pr ? 'review-sync' : 'review-open', status: s.status, artifact: s.artifact, pr: pr ? pr.number : null,
3330
+ command: `yad gate ${pr ? 'sync' : 'open'} ${epicId} ${s.artifact}` };
3331
+ });
3332
+ }
3333
+
3334
+ function shapeNextAction(ledger, { epic, bindings = null } = {}) {
689
3335
  const state = ledger?.state;
690
3336
  const epicId = epic || state?.epicId || null;
691
- if (!state) return { epicId, kind: 'new', skill: 'yad-epic', why: 'no epic state yet — seed it with yad-epic' };
692
-
693
- // EP-discovery ("epic zero") is the project front-zero: a 2-step author→review chain with no build
694
- // half and no parallel track. Resolve its action in isolation so the feature-epic logic below never
695
- // applies to it.
696
- if (state.kind === 'discovery') {
697
- if (state.currentStep === 'discovery-done') {
698
- return { epicId, kind: 'discovery-done', step: 'discovery-done', status: 'done',
699
- why: 'discovery approved — seed feature epics with yad-epic (each reads roadmap.md)' };
3337
+ // No ledger yet: the action is to author the `epic` step, so it names whatever runs that step here.
3338
+ if (!state) return { epicId, kind: 'new', ...skillFields(stepSkills('epic', bindings)), why: 'no epic state yet — seed it with yad-epic' };
3339
+
3340
+ // The PRODUCT level — the Foundation, or a ledger still in its old `discovery` spelling: a 2-step
3341
+ // author→review chain with no Build part and no parallel track. Resolve its action in isolation so
3342
+ // the feature-epic logic below never applies to it. Each spelling reports its own `-done` kind, so a
3343
+ // script reading `yad next --json` on an unconverted project sees exactly the word it saw before.
3344
+ if (isProductLevel(state)) {
3345
+ const done = `${state.kind}-done`;
3346
+ const label = state.kind === 'foundation' ? 'Foundation' : 'discovery';
3347
+ if (state.currentStep === done) {
3348
+ return { epicId, kind: done, step: done, status: 'done',
3349
+ why: `${label} approved — seed feature epics with yad-epic (each reads roadmap.md)` };
700
3350
  }
701
3351
  const dstep = state.steps.find((s) => s.id === state.currentStep)
702
- || state.steps.find((s) => s.status !== 'done');
703
- if (!dstep) return { epicId, kind: 'discovery-done', step: 'discovery-done', why: 'discovery is done' };
3352
+ || state.steps.find((s) => !isPassed(s));
3353
+ if (!dstep) return { epicId, kind: done, step: done, why: `${label} is done` };
3354
+ // The same refusal the feature path makes below: a BLOCKED step is not a step to run, and naming
3355
+ // its skill would tell someone to author an artifact that is waiting on somebody else.
3356
+ if (stepStatus(dstep) === 'blocked') {
3357
+ const reason = dstep.record?.reason;
3358
+ return { epicId, kind: 'blocked', step: dstep.id, status: 'blocked', record: dstep.record || null,
3359
+ why: `${dstep.id} is blocked${reason ? ` — ${reason}` : ' — no reason recorded'}` };
3360
+ }
704
3361
  if (dstep.type === 'author') {
705
3362
  return { epicId, kind: 'author', step: dstep.id, status: dstep.status,
706
- skill: STEP_SKILL[dstep.id] || null, artifact: dstep.artifact,
3363
+ ...skillFields(stepSkills(dstep.id, bindings)), artifact: dstep.artifact,
707
3364
  why: `${dstep.id} is ${dstep.status} — author ${dstep.artifact}` };
708
3365
  }
709
3366
  const dpr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(dstep.artifact));
@@ -721,52 +3378,71 @@ export function nextAction(ledger, { epic } = {}) {
721
3378
  // atomically (see state-schema.md), keeping this sentinel in step with `isStubEpic` (frontmatter).
722
3379
  const anchorKind = backfillAnchorKind(state);
723
3380
  if (anchorKind === 'stub') {
724
- // No build half until backfilled + promoted — route to yad-backfill (not to authoring the epic),
3381
+ // No Build until backfilled + promoted — route to yad-backfill (not to authoring the epic),
725
3382
  // and remind that bugs can thread off it now with yad-change.
726
3383
  return { epicId, kind: 'backfill-pending', step: 'backfill-pending', status: 'stub',
727
3384
  why: 'stub epic (backfill pending) — document the code with yad-backfill then `yad-backfill promote` to make it real; thread bugs now with yad-change' };
728
3385
  }
729
3386
  if (anchorKind === 'documented') {
730
- // `yad-backfill promote` documented the feature (verified) but did NOT wake the front chain (its docs
731
- // live in the backfill spec). Terminal like `discovery-done` — no build half runs directly; the
3387
+ // `yad-backfill promote` documented the feature (verified) but did NOT wake the Shape chain (its docs
3388
+ // live in the backfill spec). Terminal like `discovery-done` — Build never runs directly; the
732
3389
  // feature evolves by threading a change/defect off it.
733
3390
  return { epicId, kind: 'backfill-done', step: 'backfill-done', status: 'documented',
734
- why: 'backfilled anchor (documented) — no build half runs directly; evolve it by threading a change/defect with yad-change' };
3391
+ why: 'backfilled anchor (documented) — Build never runs directly; evolve it by threading a change/defect with yad-change' };
735
3392
  }
736
3393
 
737
3394
  // The parallel test-cases track stays workable even once the epic is ready-for-build.
738
3395
  const tc = state.steps.find((s) => s.id === 'test-cases');
739
- const tcOpen = !!tc && tc.status !== 'done' && tc.status !== 'blocked';
740
- const parallel = tcOpen ? { step: 'test-cases', skill: STEP_SKILL['test-cases'], artifact: tc.artifact } : null;
3396
+ const tcState = stepStatus(tc);
3397
+ const tcOpen = tcState === 'in_progress' || tcState === 'in_review';
3398
+ const parallel = tcOpen
3399
+ ? { step: 'test-cases', ...skillFields(stepSkills('test-cases', bindings)), artifact: tc.artifact }
3400
+ : null;
741
3401
 
742
3402
  if (state.currentStep === 'ready-for-build') {
743
- // Once stories enter the build half, surface each story/repo's CONCRETE next sub-step (spec →
744
- // implement → checks → engineer-review) from build-state, not one static "run the build half" hint.
745
- const builds = buildNextActions(ledger?.buildStates || []);
3403
+ // Once stories enter Build, surface each story/repo's CONCRETE next sub-step (spec →
3404
+ // implement → checks → engineer-review) from build-state, not one static "run Build" hint.
3405
+ const builds = buildNextActions(ledger?.buildStates || [], { bindings });
746
3406
  const lanes = builds.flatMap((b) => b.repos);
747
- const open = lanes.filter((r) => !r.shipped);
3407
+ const open = lanes.filter((r) => !r.shipped && r.status !== 'skipped');
748
3408
  if (builds.length) {
749
3409
  let why;
750
- if (!lanes.length) why = 'build half started — no repo lanes recorded yet';
751
- else if (!open.length) why = 'build half — every story/repo lane is shipped';
752
- else why = `build half in progress — ${open.length} story/repo lane(s) still moving`;
3410
+ if (!lanes.length) why = 'Build started — no repo lanes recorded yet';
3411
+ // Every recorded lane SKIPPED and none shipped (E39 review): a skip written before Build began creates
3412
+ // the build-state file on its own, and "every lane is shipped or skipped" would call Build finished at
3413
+ // its first step. Nothing has started, so say what was said before the skip.
3414
+ else if (!open.length && !lanes.some((r) => r.shipped)) why = 'Shape approved — Build can run (every recorded lane is skipped; nothing has started)';
3415
+ // The first wording is frozen golden bytes; the second is said only when a lane was skipped (E39).
3416
+ else if (!open.length) why = lanes.some((r) => r.status === 'skipped') ? 'Build — every story/repo lane is shipped or skipped' : 'Build — every story/repo lane is shipped';
3417
+ else why = `Build in progress — ${open.length} story/repo lane(s) still moving`;
753
3418
  return { epicId, kind: 'build', step: 'ready-for-build', status: 'ready-for-build', parallel, builds, why };
754
3419
  }
755
3420
  return { epicId, kind: 'build', step: 'ready-for-build', status: 'ready-for-build', parallel,
756
- why: 'front half approved — the build half can run' };
3421
+ why: 'Shape approved — Build can run' };
757
3422
  }
758
3423
 
759
3424
  const step = state.steps.find((s) => s.id === state.currentStep)
760
- || state.steps.find((s) => s.status !== 'done');
3425
+ || state.steps.find((s) => !isPassed(s));
761
3426
  if (!step) {
762
- const builds = buildNextActions(ledger?.buildStates || []);
3427
+ const builds = buildNextActions(ledger?.buildStates || [], { bindings });
763
3428
  return { epicId, kind: 'build', step: 'ready-for-build', parallel,
764
- builds: builds.length ? builds : undefined, why: 'all front steps are done' };
3429
+ builds: builds.length ? builds : undefined, why: 'all Shape steps are done' };
3430
+ }
3431
+
3432
+ // A genuinely BLOCKED step (E38) is not a step to run. `skipped`, `deferred` and `satisfied` all
3433
+ // read as passed, so this resolver never lands on one; `blocked` is the only recorded state that
3434
+ // stops the chain, and naming its skill would tell someone to author an artifact that is waiting on
3435
+ // a third party. The record is the one place that says on whom or what, so it is what gets printed.
3436
+ if (stepStatus(step) === 'blocked') {
3437
+ const reason = step.record?.reason;
3438
+ return { epicId, kind: 'blocked', step: step.id, status: 'blocked', parallel,
3439
+ record: step.record || null,
3440
+ why: `${step.id} is blocked${reason ? ` — ${reason}` : ' — no reason recorded'}` };
765
3441
  }
766
3442
 
767
3443
  if (step.type === 'author') {
768
3444
  return { epicId, kind: 'author', step: step.id, status: step.status, parallel,
769
- skill: STEP_SKILL[step.id] || null, artifact: step.artifact,
3445
+ ...skillFields(stepSkills(step.id, bindings)), artifact: step.artifact,
770
3446
  why: `${step.id} is ${step.status} — author ${step.artifact}` };
771
3447
  }
772
3448
 
@@ -785,7 +3461,13 @@ export function nextAction(ledger, { epic } = {}) {
785
3461
  // the thread helpers and the gate agree on the same parse; shared here as the lineage source.
786
3462
  export function readFrontmatter(file) {
787
3463
  if (!fs.existsSync(file)) return {};
788
- const m = fs.readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/);
3464
+ return parseFrontmatter(fs.readFileSync(file, 'utf8'));
3465
+ }
3466
+
3467
+ // The same parse, on text already read — for a caller that must parse the very bytes it hashed
3468
+ // (the index, E19), rather than read the file a second time.
3469
+ export function parseFrontmatter(text) {
3470
+ const m = String(text).match(/^---\n([\s\S]*?)\n---/);
789
3471
  if (!m) return {};
790
3472
  const out = {};
791
3473
  for (const line of m[1].split('\n')) {
@@ -799,27 +3481,196 @@ export function readFrontmatter(file) {
799
3481
 
800
3482
  const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
801
3483
 
802
- // The human-facing noun for a lineage kind. Presentation only — the artifact is always an epic
3484
+ // ---- the work-item type ------------------------------------------------------------------------
3485
+ // The ladder is Product -> Epic -> Story -> Task, and every work item on it also declares a TYPE.
3486
+ // The type is what stops everything being called an epic:
3487
+ //
3488
+ // feature new value
3489
+ // change a change to something already shipped
3490
+ // defect something is broken
3491
+ // hotfix broken and urgent
3492
+ // chore upkeep, no user-visible change
3493
+ //
3494
+ // The word for this used to be `kind`, and in `epic.md` frontmatter `kind:` is STILL the one that
3495
+ // counts. Shape 5 adds `type:` beside it and teaches every writer to write both; `kind:` stays
3496
+ // authoritative for this whole major, and is removed in the one after. The reason is the same as for
3497
+ // every other rename in this codebase: `epic.md` is hand-authored by people and by ~29 skills, and
3498
+ // `lineage-check.sh` reads `kind:` from inside the USER's repository, refreshed by `yad update` —
3499
+ // a separate act from `yad migrate` with no ordering between them. Make the new name win now and a
3500
+ // change-epic in a repo that migrated but did not update reads as a parent-free genesis, and the
3501
+ // lineage gate stops asking it for a parent.
3502
+ export const WORK_ITEM_TYPES = ['feature', 'change', 'defect', 'hotfix', 'chore'];
3503
+
3504
+ // Resolve the type from `epic.md` frontmatter. OLD name first (see above), then the new one, then
3505
+ // `feature` — an epic authored before types existed is the root of its own thread, which is what a
3506
+ // genesis is. An unrecognised value is returned AS WRITTEN rather than corrected to `feature`:
3507
+ // silently reading someone's typo as a genesis would drop the lineage gate. `yad doctor` reports it.
3508
+ export function workItemType(fm = {}) {
3509
+ if (typeof fm.kind === 'string' && fm.kind) return fm.kind;
3510
+ if (typeof fm.type === 'string' && fm.type) return fm.type;
3511
+ return 'feature';
3512
+ }
3513
+
3514
+ // Which types may stand alone with no `parent:` — the root of a thread.
3515
+ //
3516
+ // `feature` is the original genesis. `chore` joins it because upkeep frequently has no feature to
3517
+ // hang off: a dependency bump, a CI move, a lockfile refresh. Requiring a parent there would push
3518
+ // people to invent one, and an invented parent is worse than none — the thread rollups, the defect
3519
+ // report and the timeline all walk `parent:` and would attribute the upkeep to a feature it has
3520
+ // nothing to do with. Everything else (`change`, `defect`, `hotfix`) describes work ON something
3521
+ // that already exists, so it must name what.
3522
+ //
3523
+ // `templates/checks/lineage-check.sh` re-implements this in bash, because the check gates are
3524
+ // standalone by design and run inside the user's repo with no Node. `cli/test-checks.mjs` runs a
3525
+ // table of types through both and asserts they agree — two readers is two ways to drift.
3526
+ export const isGenesisType = (t) => t === 'feature' || t === 'chore';
3527
+
3528
+ // The human-facing noun for a work-item type. Presentation only — the artifact is always an epic
803
3529
  // (`EP-<slug>`); this just renders WHAT KIND of work it is so `yad next`/`yad thread`/`yad status`
804
3530
  // read as "Defect EP-…" / "Change request EP-…" instead of a generic "Epic". `feature` (and any
805
- // unknown/absent kind) falls back to "Epic". A bug is a defect (kind:defect) — no separate noun.
806
- export const KIND_NOUN = { feature: 'Epic', change: 'Change request', defect: 'Defect', hotfix: 'Hotfix' };
807
- export const kindNoun = (kind) => KIND_NOUN[kind] || 'Epic';
3531
+ // unknown/absent type) falls back to "Epic". A bug is a defect (`defect`) — no separate noun.
3532
+ export const TYPE_NOUN = {
3533
+ feature: 'Epic', change: 'Change request', defect: 'Defect', hotfix: 'Hotfix', chore: 'Chore',
3534
+ };
3535
+ export const typeNoun = (t) => TYPE_NOUN[t] || 'Epic';
3536
+
3537
+ // ---- the grouping theme (E31) --------------------------------------------------------------------
3538
+ // A `theme:` on an epic is a FREE grouping tag: any word or short phrase the team picks, shared by
3539
+ // however many epics belong together. It is what this engine has instead of an Initiative rung above
3540
+ // the Epic — the ladder stays Product -> Epic -> Story -> Task, and grouping is a label rather than a
3541
+ // level. Nothing enforces a vocabulary and nothing has to be registered first; an epic with no theme
3542
+ // is perfectly normal, which is why the answer is `null` rather than a default.
3543
+ //
3544
+ // It lives ONLY in `epic.md`. No copy in `state.json`, so no file shape changes and no migration:
3545
+ // every reader that wants the theme already has the epic's frontmatter open. `yad next --json` does
3546
+ // not carry it either — that answer is deep-equalled by the golden test (rule 6) and an ADDED key
3547
+ // breaks it as hard as a renamed one. `yad thread --json` is where a script reads it.
3548
+ //
3549
+ // One string, always. `readFrontmatter` turns `theme: [a, b]` into an array and keeps whatever else
3550
+ // someone typed, so the normalization is done HERE, once, and every caller shares it — a tag that is
3551
+ // a string in one reader and an array in the next groups nothing.
3552
+ export function themeOf(fm = {}) {
3553
+ return typeof fm?.theme === 'string' && fm.theme.trim() ? fm.theme.trim() : null;
3554
+ }
3555
+
3556
+ // Two themes that only differ in case, spacing or punctuation are the same idea typed twice, and they
3557
+ // split the group in silence. Folding to this key is how `yad doctor` finds them; it is NEVER stored
3558
+ // or displayed — the tag people wrote is the tag they see.
3559
+ //
3560
+ // Letters and digits in ANY script survive the fold (`\p{L}\p{N}`, not `a-z0-9`). A team writing its
3561
+ // themes in Arabic, Cyrillic or Chinese groups exactly as well as one writing them in English, and an
3562
+ // ASCII-only fold would flatten every one of their themes to the empty string — making them look
3563
+ // identical to each other and to a tag of pure punctuation. An empty fold therefore means one thing
3564
+ // only: there is nothing here to compare.
3565
+ export const themeKey = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
3566
+
3567
+ // ---- the title (E111) --------------------------------------------------------------------------
3568
+ // A work item's one-line name, for a reader that lists many (E20's `yad history list`, through E19's
3569
+ // index). The user's decisions (2026-09-24):
3570
+ // - It is the `title:` key in `epic.md` frontmatter, which `yad-epic`, `yad-change` and `yad-stub`
3571
+ // write. Not the first `# ` heading: the template has none, and a heading is prose, reworded freely.
3572
+ // - An item with no key falls back to the `title` its `change.json` holds — only change items have
3573
+ // one (`yad-change` has required a title from the start), so this covers the change items written
3574
+ // before the key existed. The frontmatter key wins when both exist.
3575
+ // - No title is `null`, never a guess (not the Goal's first sentence, not the id): a screen prints the
3576
+ // id in its place, and nothing warns. Approved `epic.md` files are NOT back-filled — the epic review
3577
+ // is bound to a hash of the file, so adding a line would drop the approval as stale.
3578
+ // - The Foundation has no `epic.md`, so its title is a constant.
3579
+ //
3580
+ // Normalized HERE, once, like the theme. It is ONE line: every run of whitespace, a newline included,
3581
+ // becomes one space (NEL, U+0085, is a line break too), because a list prints one title per row. Control
3582
+ // characters are dropped: a title is printed to other people's terminals (E20), and an ESC or a BEL
3583
+ // there would rewrite or ring them. So are the bidi controls, which reverse how the rest of a row is
3584
+ // SHOWN, so one title can pass for another. Zero-width characters (the joiners among them) are kept:
3585
+ // emoji and several scripts (Persian among them) need them.
3586
+ //
3587
+ // The frontmatter value is also unquoted, because `readFrontmatter` keeps the rest of the line as
3588
+ // written: `title: "Queue: untested"` is `Queue: untested`. A single-quoted value undoes YAML's one
3589
+ // escape (`'It''s done'` is `It's done`). A double-quoted value is read with JSON's escapes (`\"`, `\\`,
3590
+ // `\n`, `\t`, `\u00e9` …), which YAML's double-quoted style also has; one using any other escape is
3591
+ // kept as written (quotes and backslashes included), never half-read. Only a value that IS one quoted
3592
+ // string is unquoted — `"Login" is broken on "Safari"` is not, and stays as written, and so does
3593
+ // `"x" # note`. The `change.json` title is JSON, already unquoted, and is never touched this way.
3594
+ //
3595
+ // The ORDER: the value is made one clean line first, then checked for "no title", then unquoted, then
3596
+ // cleaned again (an escape such as `\n` or `\u001b` can add what was cleaned). So the check sees the
3597
+ // cleaned value — `nu<NUL>ll` is YAML's null, which YAML itself would reject anyway — and never the
3598
+ // unquoted one: a quoted `'null'` or `">"` is text, as in YAML. Three frontmatter values are no title at
3599
+ // all: a list (`readFrontmatter` turns any value that begins with `[` and ends with `]` into one), a YAML
3600
+ // block marker (`>` or `|` — the reader keeps one line, so the block's text is lost), and YAML's null
3601
+ // (`~`, `null`, `Null`, `NULL`, the whole value).
3602
+ export const FOUNDATION_TITLE = 'Foundation';
3603
+
3604
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
3605
+ const CONTROLS = /[\u0000-\u0008\u000e-\u001f\u007f-\u0084\u0086-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
3606
+ // Any value read from a committed file and PRINTED on a terminal, made safe the way a title is (E20
3607
+ // review — a theme, a reason, an approver name or a step id reaches other people's screens as much as a
3608
+ // title does): control and bidi characters dropped, each run of line breaks or tabs made one space
3609
+ // (a space beside it is kept), the ends trimmed. Ordinary spaces inside are KEPT, so two spellings `yad doctor` tells apart (a theme with
3610
+ // two spaces) still look different. Text only: anything else, or text with nothing left, is null — so a
3611
+ // caller prints a value only when it is words (`printable(v) ?? '(none)'`). `--json` never passes
3612
+ // through this.
3613
+ export const printable = (v) => {
3614
+ if (typeof v !== 'string') return null;
3615
+ return v.replace(CONTROLS, '').replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/gu, ' ').trim() || null;
3616
+ };
3617
+ // A title is stricter: every run of whitespace, a no-break space included, becomes one space.
3618
+ const oneLine = (t) => (printable(t) ?? '').replace(/\s+/gu, ' ').trim() || null;
3619
+
3620
+ // One quoted string, unquoted — or the value unchanged when it is not exactly one. It is given a value
3621
+ // `oneLine` has already cleaned, so no raw control character (which JSON refuses) is left to stop it.
3622
+ function unquoteYaml(t) {
3623
+ const single = t.match(/^'((?:[^']|'')*)'$/u);
3624
+ if (single) return single[1].replace(/''/g, "'");
3625
+ if (t[0] === '"') {
3626
+ try {
3627
+ return JSON.parse(t);
3628
+ } catch {
3629
+ return t;
3630
+ }
3631
+ }
3632
+ return t;
3633
+ }
808
3634
 
809
- // The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
810
- // so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
3635
+ const titleFromFrontmatter = (v) => {
3636
+ if (typeof v !== 'string') return null;
3637
+ const t = oneLine(v);
3638
+ if (t === null) return null;
3639
+ if (/^[>|][+-]?[0-9]?[+-]?$/.test(t) || /^(~|null|Null|NULL)$/.test(t)) return null;
3640
+ return oneLine(unquoteYaml(t));
3641
+ };
3642
+
3643
+ // `fm`: the item's `epic.md` frontmatter. `change`: its parsed `change.json`, or null — any JSON value is
3644
+ // safe, since only a string `title` counts.
3645
+ export function titleOf(fm = {}, change = null) {
3646
+ return titleFromFrontmatter(fm?.title) ?? (typeof change?.title === 'string' ? oneLine(change.title) : null);
3647
+ }
3648
+
3649
+ // The lineage of an epic from epic.md frontmatter. `type` defaults to `feature` (genesis) when
3650
+ // absent, so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
811
3651
  export function epicLineage(root, epic) {
812
- const fm = readFrontmatter(path.join(epicRoot(root, epic), 'epic.md'));
3652
+ return lineageFrom(readFrontmatter(path.join(epicRoot(root, epic), 'epic.md')));
3653
+ }
3654
+
3655
+ // The lineage from frontmatter already parsed (E19's index parses the bytes it hashed).
3656
+ export function lineageFrom(fm = {}) {
3657
+ const type = workItemType(fm);
813
3658
  return {
814
- kind: fm.kind || 'feature',
3659
+ type,
3660
+ // The older name for the same value, kept for one major — the same choice `yad thread --json`
3661
+ // makes. An out-of-tree caller reading `.kind` would otherwise get `undefined`, which reads as a
3662
+ // NON-genesis type: their parent-free feature epic starts being asked for a parent it has not got.
3663
+ kind: type,
815
3664
  parent: fm.parent || null,
816
3665
  thread: fm.thread || null,
817
3666
  inherits: asList(fm.inherits),
818
3667
  supersedes: asList(fm.supersedes),
3668
+ // The free grouping tag (E31). Null when unset, which is most epics.
3669
+ theme: themeOf(fm),
819
3670
  };
820
3671
  }
821
3672
 
822
- // Is this a STUB genesis epic (minted by yad-stub as a brownfield thread anchor)? A stub is kind:feature
3673
+ // Is this a STUB genesis epic (minted by yad-stub as a brownfield thread anchor)? A stub is type feature
823
3674
  // but carries `stub: backfill-pending` in epic.md frontmatter until `yad-backfill promote` flips it to a
824
3675
  // real, verified epic (which clears the marker). Missing/greenfield-safe. Read by yad thread / yad-status
825
3676
  // / the reconciler to render "stub (backfill pending)" and never treat it as a fully-specced feature.
@@ -853,7 +3704,7 @@ export function resolveThread(root, epicId) {
853
3704
  // A missing cache is corruption too — without it the bash gates' parent-walk is the only safety net,
854
3705
  // and a tool reading the field would mis-scope the thread.
855
3706
  if (!broken && tip.parent && !tip.thread) {
856
- broken = `missing thread cache on ${epicId} (kind:${tip.kind}, parent:${tip.parent}) — should be '${rootId}'`;
3707
+ broken = `missing thread cache on ${epicId} (type:${tip.type}, parent:${tip.parent}) — should be '${rootId}'`;
857
3708
  }
858
3709
  if (!broken && tip.thread && tip.thread !== rootId) {
859
3710
  broken = `thread cache '${tip.thread}' != computed root '${rootId}'`;
@@ -894,18 +3745,58 @@ const REPLACE_BASES = ['epic', 'architecture', 'contract', 'ui-design'];
894
3745
  // one epic would drop the parent's inherited stories/cases.
895
3746
  const ADDITIVE_BASES = ['stories', 'test-cases'];
896
3747
 
3748
+ // The catalogue step that PRODUCES each base. `contract` has no step of its own — `contract.md` is
3749
+ // authored by the architecture step, which `artifactFromBase` already says — so both map to it.
3750
+ //
3751
+ // Needed because `inherits` answers only half the question. It says "I did not re-author this", which
3752
+ // on every route that HAS the step means "I authored it myself". E40's short lanes break that: a
3753
+ // `chore` epic has no architecture step, never writes `inherits:`, and so claimed to be the
3754
+ // authoritative source for `architecture`, `contract`, `ui-design` and `test-cases` — four files that
3755
+ // will never exist. `yad thread` printed it, and `yad-change` reads exactly that map to choose what a
3756
+ // threaded defect inherits from, so the false claim became a forged `inheritedFrom`.
3757
+ const BASE_STEP = {
3758
+ epic: 'epic', architecture: 'architecture', contract: 'architecture',
3759
+ 'ui-design': 'ui-design', stories: 'stories', 'test-cases': 'test-cases',
3760
+ };
3761
+
3762
+ // Does this epic's chain carry the step that produces this base? Answered through `routeLacksStep`,
3763
+ // which is the ONE rule for "this epic's route never had that step" — see it for why the recorded
3764
+ // route is asked and the chain is not.
3765
+ const chainHasBase = (root, id, base) => {
3766
+ const step = BASE_STEP[base];
3767
+ if (!step) return true;
3768
+ let state;
3769
+ // THE STRICT-READ THROW IS CAUGHT ON PURPOSE, which is the opposite of what a ledger WRITE does.
3770
+ // `writeState` and the gate read strictly and refuse, because a read-modify-write against a file
3771
+ // they cannot parse destroys the contents. This is a pure read feeding a provenance display, and
3772
+ // `yad thread` is exactly the command someone runs to inspect a project that is already damaged —
3773
+ // crashing it on the corrupt file takes away the tool they are holding. `yad doctor` reports the
3774
+ // corruption, and still does. Before this rule the function never opened `state.json` at all, so
3775
+ // failing closed here would also be a new way for a read-only command to die on an old project.
3776
+ try { state = readJSONStrict(path.join(epicRoot(root, id), '.sdlc', 'state.json'), null); } catch { return true; }
3777
+ return !routeLacksStep(state, step);
3778
+ };
3779
+
897
3780
  // The owning epic per artifact base across a thread. REPLACE bases resolve to a single epic id (the
898
3781
  // latest re-author); ADDITIVE bases resolve to the ordered LIST of every epic that re-authored them
899
3782
  // (genesis-first) — use resolveCurrentStories for story-id-level ownership of the composed set.
900
3783
  export function resolveCurrentArtifacts(root, threadOrEpicId) {
901
- const members = threadEpics(root, threadOrEpicId); // genesis-first
3784
+ return ownersAlong(root, threadEpics(root, threadOrEpicId)); // genesis-first
3785
+ }
3786
+
3787
+ // The same map over an explicit genesis-first list of epics. `resolveCurrentArtifacts` passes the whole
3788
+ // thread; the threaded seed (E42) passes only the PARENT'S line, because a sibling branch off the same
3789
+ // genesis is not something the new epic builds on.
3790
+ function ownersAlong(root, members) {
902
3791
  const out = {};
903
3792
  for (const b of REPLACE_BASES) out[b] = null;
904
3793
  for (const b of ADDITIVE_BASES) out[b] = [];
905
3794
  for (const id of members) {
906
3795
  const { inherits } = epicLineage(root, id);
907
- for (const b of REPLACE_BASES) if (!inherits.includes(b)) out[b] = id;
908
- for (const b of ADDITIVE_BASES) if (!inherits.includes(b)) out[b].push(id);
3796
+ // Two conditions, not one: this epic did not inherit the base AND its chain carries the step that
3797
+ // produces it. An epic on a route without that step authored nothing to own.
3798
+ for (const b of REPLACE_BASES) if (!inherits.includes(b) && chainHasBase(root, id, b)) out[b] = id;
3799
+ for (const b of ADDITIVE_BASES) if (!inherits.includes(b) && chainHasBase(root, id, b)) out[b].push(id);
909
3800
  }
910
3801
  return out;
911
3802
  }
@@ -931,4 +3822,359 @@ export function resolveCurrentStories(root, threadOrEpicId) {
931
3822
  return owner;
932
3823
  }
933
3824
 
3825
+ // ---- threading a change-epic off its parent (E42) -----------------------------------------------
3826
+ //
3827
+ // Rule 5: you may skip AUTHORING a contract, never skip HAVING one. A change that does not move the
3828
+ // surface carries its parent's contract by reference — a pointer-lock holding the parent's hash
3829
+ // verbatim — instead of writing a second copy. Until E42 that chain was hand-written by the
3830
+ // `yad-change` skill. This is the engine writing it.
3831
+ //
3832
+ // WHAT IS DECIDED HERE AND WHAT IS NOT. Which bases a change inherits is the depth triage, a judgement
3833
+ // made with a person, and it stays in the skill. This takes the OUTCOME of that triage (`inherits`) and
3834
+ // writes the ledger it implies, refusing every outcome the ledger could not honestly record.
3835
+
3836
+ // The bases a change may carry by reference. The REPLACE bases, where one epic owns the current copy.
3837
+ // `stories` and `test-cases` are ADDITIVE — the thread's set is the union of every contributor, so
3838
+ // there is no single owner to bind a hash to — and every depth re-authors both anyway: `stories-review`
3839
+ // is the step that hands the epic to Build, so a change that inherited it would have nothing to build.
3840
+ export const INHERITABLE_BASES = ['epic', 'architecture', 'contract', 'ui-design'];
3841
+
3842
+ // The chain steps each base carries. `analysis` rides with `epic`: it is the brief the epic was written
3843
+ // from, it has no base of its own, and re-running it under a carried epic would review a brief for a
3844
+ // decision already made. `contract` has no step — the architecture step writes `contract.md`.
3845
+ const INHERIT_STEPS = { epic: ['analysis', 'epic'], architecture: ['architecture'], contract: ['architecture'], 'ui-design': ['ui-design'] };
3846
+
3847
+ const LOCK_HASH = /^sha256:[0-9a-f]{64}$/;
3848
+
3849
+ // Plan the ledger of a change-epic threaded off `parent`. Reads the Product; writes nothing.
3850
+ // Returns `{ ok: false, message, hint }` or `{ ok: true, state, approvals, lock, profile, owners, anchor, thread }`.
3851
+ export function planThreadedSeed(root, { epic, parent, inherits = [], type, today }) {
3852
+ const refuse = (message, hint) => ({ ok: false, message, hint });
3853
+ const bases = [...new Set(inherits.map((b) => String(b).trim()).filter(Boolean))];
3854
+
3855
+ const unknown = bases.filter((b) => !THREAD_ARTIFACT_BASES.includes(b));
3856
+ if (unknown.length) {
3857
+ return refuse(`unknown base in inherits: ${unknown.join(', ')}`,
3858
+ `a change carries some of ${INHERITABLE_BASES.join(' · ')}`);
3859
+ }
3860
+ const additive = bases.filter((b) => !INHERITABLE_BASES.includes(b));
3861
+ if (additive.length) {
3862
+ return refuse(`${additive.join(' and ')} cannot be inherited`,
3863
+ 'every change writes its own stories and test cases: the thread\'s set is the union of every epic that wrote some, and `stories-review` is what hands an epic to Build. Leave them out of inherits');
3864
+ }
3865
+ if (bases.includes('architecture') !== bases.includes('contract')) {
3866
+ return refuse('architecture and contract are inherited together, or not at all',
3867
+ 'the architecture step writes contract.md, so they are one step. To change the surface, inherit neither — the change re-authors architecture and re-locks');
3868
+ }
3869
+
3870
+ if (!isValidEpicId(parent)) return refuse(`invalid parent id: ${parent} (expected EP-<slug>)`);
3871
+ if (PRODUCT_EPICS.includes(parent)) {
3872
+ return refuse(`${parent} is the Product level, not an epic a change can thread off`,
3873
+ 'name the feature epic this change evolves');
3874
+ }
3875
+ if (parent === epic) return refuse(`${epic} cannot be its own parent`);
3876
+ const parentDir = epicRoot(root, parent);
3877
+ let parentState;
3878
+ try { parentState = readJSONStrict(path.join(parentDir, '.sdlc', 'state.json'), null); } catch (e) {
3879
+ return refuse(`${parent}: its state.json cannot be read — ${e.message}`, 'run `yad doctor` and fix the parent first');
3880
+ }
3881
+ if (!fs.existsSync(path.join(parentDir, 'epic.md')) || !isPlainObject(parentState) || !Array.isArray(parentState.steps)) {
3882
+ return refuse(`${parent} is not an epic with a lifecycle here`,
3883
+ 'a change threads off an epic that exists. For a feature built before it had one, mint an anchor with the yad-stub skill first');
3884
+ }
3885
+ const line = resolveThread(root, parent);
3886
+ if (line.broken) return refuse(`${parent}: its lineage is broken — ${line.broken}`, 'fix the parent\'s epic.md first');
3887
+
3888
+ // THE ROUTE IS THE PARENT'S. A short-lane parent gives a short child; writing `classic` over it would
3889
+ // mint inherited architecture steps for a review that never happened anywhere.
3890
+ const profile = epicProfileId(parentState);
3891
+ if (!profile || !seedableProfiles().includes(profile)) {
3892
+ return refuse(`${parent}: its chain is on no route this command can seed${profile ? ` ('${profile}')` : ''}`,
3893
+ 'run `yad doctor` on the parent — its recorded profile and its chain must agree');
3894
+ }
3895
+ const steps = seedChain(lifecycleProfile(profile));
3896
+ const onChain = new Set(steps.map((s) => s.id));
3897
+
3898
+ // Who owns each base, along the parent's line only.
3899
+ const owners = ownersAlong(root, line.chain);
3900
+ // One step writes both, so one epic must own both. They can split when an epic between here and the
3901
+ // genesis lists only one of the two in its `inherits:` — and carrying them from two owners would bind
3902
+ // the architecture steps to one surface and point the lock at another.
3903
+ if (bases.includes('architecture') && owners.architecture !== owners.contract) {
3904
+ return refuse(`along ${parent}'s line the architecture is owned by ${owners.architecture || 'no epic'} but the contract by ${owners.contract || 'no epic'}`,
3905
+ 'one step writes both, so one epic must own both. An epic between them lists only one of the two in its `inherits:` — list both there, or neither, then thread again');
3906
+ }
3907
+ const carried = new Map(); // step id -> { owner, boundHash }
3908
+ const ownerOf = {};
3909
+ let anchor = false;
3910
+ let lock = null;
3911
+ for (const base of bases) {
3912
+ const ids = INHERIT_STEPS[base].filter((id) => onChain.has(id));
3913
+ const owner = owners[base];
3914
+ if (!ids.length || !owner) {
3915
+ return refuse(`${parent}'s ${profile} route has no ${base} step — there is nothing to inherit`,
3916
+ `leave ${base} out of inherits. A short lane has no architecture or UI, and a change that moves the contract is a new epic on classic`);
3917
+ }
3918
+ ownerOf[base] = owner;
3919
+ const ownerDir = epicRoot(root, owner);
3920
+ let ownerState;
3921
+ try { ownerState = readJSONStrict(path.join(ownerDir, '.sdlc', 'state.json'), null); } catch { ownerState = null; }
3922
+ if (!isPlainObject(ownerState) || !Array.isArray(ownerState.steps)) {
3923
+ return refuse(`${owner} owns ${base}, but its state.json is missing or cannot be read`,
3924
+ 'nothing here can check that it was approved. Run `yad doctor` and fix it first');
3925
+ }
3926
+ // A brownfield anchor has an un-started chain and nothing locked: the base is carried with no hash
3927
+ // to drift from, and no pointer-lock is written — there is no surface to point at yet.
3928
+ if (backfillAnchorKind(ownerState)) {
3929
+ anchor = true;
3930
+ for (const id of ids) carried.set(id, { owner, boundHash: null });
3931
+ continue;
3932
+ }
3933
+ for (const id of ids) {
3934
+ // `analysis` was never named by the person typing the list, so say why it is being checked.
3935
+ const rides = id === base ? '' : ` (${id} rides with ${base})`;
3936
+ // THE SET-ASIDE RULE (the limit E37 and E41 left open). The thread's owner map names the epic that
3937
+ // DECIDED about a base, which is right for a skip: the decision is that epic's. It is not the same
3938
+ // as an artifact to carry. Only work written AND approved there is inherited.
3939
+ for (const sid of [id, `${id}-review`]) {
3940
+ const s = ownerState.steps.find((x) => x?.id === sid);
3941
+ const st = stepStatus(s);
3942
+ if (st === 'done') continue;
3943
+ if (claimsSkipped(s)) {
3944
+ return refuse(`${owner} skipped ${sid}${s.record?.reason ? ` (${s.record.reason})` : ''}${rides} — nothing was written to inherit`,
3945
+ `leave ${base} out of inherits, then \`yad skip\` it on this epic if it does not apply here either`);
3946
+ }
3947
+ if (st === 'deferred') {
3948
+ return refuse(`${owner} deferred ${sid}${rides} — that work is still owed, not written`,
3949
+ `leave ${base} out of inherits and author it on this epic, so the owed work follows the thread`);
3950
+ }
3951
+ // The owner map reads `inherits:` in epic.md; the ledger says the step came from further up. The
3952
+ // two records disagree, and choosing one would be guessing where the artifact really lives.
3953
+ if (claimsInherited(s)) {
3954
+ return refuse(`${owner}'s ledger carries ${sid} from ${s.inheritedFrom || 'its parent'}${rides}, but its epic.md does not list ${base} in inherits`,
3955
+ `add ${base} to ${owner}'s inherits (or re-author it there), then thread again`);
3956
+ }
3957
+ return refuse(`${owner} has not finished ${sid} (${s ? (st ?? s.status) : 'not on its chain'})${rides} — nothing approved to inherit yet`,
3958
+ `finish it on ${owner} first, or leave ${base} out of inherits and author it here`);
3959
+ }
3960
+ const boundHash = artifactHash(ownerDir, stepDef(id).artifact);
3961
+ if (!boundHash) {
3962
+ return refuse(`${owner} approved ${id}${rides}, but ${stepDef(id).artifact} is not there to bind to`,
3963
+ `restore it on ${owner}, or leave ${base} out of inherits`);
3964
+ }
3965
+ carried.set(id, { owner, boundHash });
3966
+ }
3967
+ if (base === 'architecture') {
3968
+ let ownerLock;
3969
+ try { ownerLock = readJSONStrict(path.join(ownerDir, '.sdlc', 'contract-lock.json'), null); } catch { ownerLock = null; }
3970
+ if (!isPlainObject(ownerLock) || !LOCK_HASH.test(String(ownerLock.hash || ''))) {
3971
+ return refuse(`${owner} approved its architecture but holds no usable contract lock`,
3972
+ `rule 5: a change may skip authoring a contract, never having one. Lock the surface on ${owner} (yad-architecture Step 5) first`);
3973
+ }
3974
+ // A pointer to a lock that no longer matches its surface would pass that drift down the thread.
3975
+ if (contractSurfaceHash(ownerDir) !== ownerLock.hash) {
3976
+ return refuse(`${owner}'s contract surface no longer matches its lock`,
3977
+ `run \`yad doctor\` — re-lock ${owner}'s surface before a change points at it`);
3978
+ }
3979
+ lock = {
3980
+ artifact: 'contract.md', hash: ownerLock.hash, lockedAt: today,
3981
+ inheritedFrom: owner, ref: `../../${owner}/.sdlc/contract-lock.json`,
3982
+ };
3983
+ }
3984
+ }
3985
+
3986
+ let opened = false;
3987
+ const chain = steps.map((row) => {
3988
+ const hit = carried.get(row.id.replace(/-review$/, ''));
3989
+ if (!hit) {
3990
+ const status = opened ? 'todo' : 'in_progress';
3991
+ opened = true;
3992
+ return { ...row, status };
3993
+ }
3994
+ const { risk_tags: riskTags, ...head } = row;
3995
+ return {
3996
+ ...head,
3997
+ status: 'satisfied',
3998
+ // The legacy flag is still written beside the new word (rule 3), so a 3.x reader keying on
3999
+ // `.inherited` keeps working for this whole major.
4000
+ inherited: true,
4001
+ inheritedFrom: hit.owner,
4002
+ boundHash: hit.boundHash,
4003
+ record: stepRecord({ reason: `carried by reference from ${hit.owner}`, date: today, link: hit.owner }),
4004
+ risk_tags: riskTags,
4005
+ };
4006
+ });
4007
+ const first = chain.find((s) => s.status === 'in_progress');
4008
+ const approvals = canonicalApprovals(chain.filter((s) => s.inherited && s.type === 'review+approve').map((s) => ({
4009
+ artifact: s.artifact, step: s.id, status: 'inherited', from: s.inheritedFrom, boundHash: s.boundHash, date: today,
4010
+ })));
4011
+ return {
4012
+ ok: true,
4013
+ profile,
4014
+ thread: line.rootId,
4015
+ owners: ownerOf,
4016
+ anchor,
4017
+ lock,
4018
+ approvals,
4019
+ state: { epicId: epic, createdAt: today, type, profile, currentStep: first.id, steps: chain },
4020
+ };
4021
+ }
4022
+
4023
+ // ---- the advance dial, set freely, and the kill switch (E34) ------------------------------------
4024
+ //
4025
+ // Automation used to be EARNED: the `yad-run` skill refused `advance: auto` until a step's trust log
4026
+ // cleared a threshold. E34 deletes that. The dial is the team's to set, `yad dial` shows the run record as
4027
+ // advice, and a recorded kill switch holds every step at `human`. What never moves is rule 1: a gate
4028
+ // (`isGateStep`) is `human`, whatever any file says.
4029
+ //
4030
+ // TWO HOMES, because the two parts are written by different hands. A Build lane's dial stays on the
4031
+ // lane, in `build-state/<story>.json`, where `yad-run` already reads it. A Shape author step's dial is
4032
+ // project-wide, in `.sdlc/automation.json` beside the kill switch: `state.json` belongs to CI on a
4033
+ // verified Product, so a dial written there could not be changed by the team it belongs to.
4034
+ //
4035
+ // A SHAPE `auto` IS RECORDED, NOT ACTED ON — yet. Nothing drives a Shape step on its own until the engine
4036
+ // runs agents (E26, Wave 3.5). The same pattern as E7's approver count: the value is real, stored and
4037
+ // shown, and the command that prints it says so.
4038
+ export const ADVANCE_VALUES = ['human', 'auto'];
4039
+
4040
+ const strOrNull = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
4041
+
4042
+ // `.sdlc/automation.json` as the readers see it: `{ kill, steps, error }`. An absent file is the
4043
+ // defaults — the switch off and every Shape step `human`. A file that is there and wrong carries an
4044
+ // `error`, and `killSwitchOn` reads an error as ON: of the two ways to be wrong about a safety switch,
4045
+ // holding everything at `human` is the one nobody gets hurt by. `steps` keeps its values as written, so
4046
+ // `yad doctor` can name a bad one; the readers honour only `auto`.
4047
+ export function normalizeAutomation(raw) {
4048
+ const out = { kill: null, steps: {}, error: null };
4049
+ if (raw == null) return out;
4050
+ if (!isPlainObject(raw)) return { ...out, error: 'is not a JSON object' };
4051
+ if (raw.kill != null) {
4052
+ if (!isPlainObject(raw.kill) || typeof raw.kill.on !== 'boolean') return { ...out, error: 'has a `kill` that is not { on, reason, by, date }' };
4053
+ out.kill = { on: raw.kill.on, reason: strOrNull(raw.kill.reason), by: strOrNull(raw.kill.by), date: strOrNull(raw.kill.date) };
4054
+ // The record this one replaced, one level deep (rule 7: turning the switch off must not erase who
4055
+ // turned it on, and why).
4056
+ const p = raw.kill.previous;
4057
+ if (isPlainObject(p) && typeof p.on === 'boolean') {
4058
+ out.kill.previous = { on: p.on, reason: strOrNull(p.reason), by: strOrNull(p.by), date: strOrNull(p.date) };
4059
+ }
4060
+ }
4061
+ if (raw.steps != null) {
4062
+ if (!isPlainObject(raw.steps)) return { ...out, error: 'has a `steps` that is not an object' };
4063
+ for (const [id, v] of Object.entries(raw.steps)) out.steps[id] = v;
4064
+ }
4065
+ return out;
4066
+ }
4067
+
4068
+ export function loadAutomation(root) {
4069
+ try {
4070
+ return normalizeAutomation(readJSONStrict(path.join(root, PROJECT_FILES.automationConfig), null));
4071
+ } catch {
4072
+ return { kill: null, steps: {}, error: 'does not parse' };
4073
+ }
4074
+ }
4075
+
4076
+ // What goes back on disk. Steps sorted, so the bytes depend on what is set, not on the order it was set.
4077
+ export const serializeAutomation = (a) => ({
4078
+ ...(a.kill ? { kill: a.kill } : {}),
4079
+ steps: Object.fromEntries(Object.entries(a.steps || {}).sort(([x], [y]) => cmp(x, y))),
4080
+ });
4081
+
4082
+ export const killSwitchOn = (a) => !!a && (a.error != null || a.kill?.on === true);
4083
+
4084
+ // A step's advance as it will actually be applied: `{ advance, set, why }`. `set` is what the team chose;
4085
+ // `advance` is what happens. `why` is `gate` (rule 1), `kill` (held by the switch), `project` (a Shape
4086
+ // step, from automation.json) or `lane` (a Build step, from its own row).
4087
+ export function effectiveAdvance(step, automation = null) {
4088
+ if (!isPlainObject(step)) return { advance: 'human', set: 'human', why: 'unknown' };
4089
+ if (isGateStep(step)) return { advance: 'human', set: 'human', why: 'gate' };
4090
+ const def = stepDef(step.id);
4091
+ // A step this release does not know is held at human: nothing here can say it is not a gate.
4092
+ if (!def) return { advance: 'human', set: 'human', why: 'unknown' };
4093
+ const shape = def.phase !== 'build';
4094
+ const set = shape
4095
+ ? (automation?.steps && Object.hasOwn(automation.steps, step.id) && automation.steps[step.id] === 'auto' ? 'auto' : 'human')
4096
+ : (stepAdvance(step) === 'auto' ? 'auto' : 'human');
4097
+ if (set === 'auto' && killSwitchOn(automation)) return { advance: 'human', set, why: 'kill' };
4098
+ return { advance: set, set, why: shape ? 'project' : 'lane' };
4099
+ }
4100
+
4101
+ const refusal = (message, hint) => ({ ok: false, message, hint });
4102
+ const shapeAuthorIds = () => STEPS.filter((d) => d.kind === 'author' && d.phase !== 'build' && d.level !== 'product').map((d) => d.id);
4103
+ const buildAuthorIds = () => STEPS.filter((d) => d.kind === 'author' && d.phase === 'build').map((d) => d.id);
4104
+
4105
+ // Set a Shape author step's dial for the whole project. PURE: takes and returns the automation object.
4106
+ export function planShapeDial(automation, { step, to }) {
4107
+ const def = stepDef(step);
4108
+ if (!def) return refusal(`unknown step: ${step}`, `a Shape step's dial is one of ${shapeAuthorIds().join(' · ')}`);
4109
+ // "Shape author steps" means a feature's chain. The Foundation is the Product level, reviewed once per
4110
+ // product; its dial is not the team's per-feature automation choice.
4111
+ if (def.level === 'product') {
4112
+ return refusal(`${step} is the Product level, not a step on a feature's Shape chain`,
4113
+ `a Shape step's dial is one of ${shapeAuthorIds().join(' · ')}`);
4114
+ }
4115
+ if (def.kind === 'review') {
4116
+ return refusal(`${step} is a review gate, and a gate is never auto`,
4117
+ 'a person clears every gate (rule 1). Set the dial of the step it reviews instead');
4118
+ }
4119
+ if (def.phase === 'build') {
4120
+ return refusal(`${step} is a Build step — its dial is set per lane`,
4121
+ `yad dial <epic> <story> --repo <name> ${step} --to ${to || 'auto'}`);
4122
+ }
4123
+ if (!ADVANCE_VALUES.includes(to)) return refusal(`--to must be human or auto, not ${to}`);
4124
+ const steps = { ...(automation?.steps || {}) };
4125
+ const before = steps[step] === 'auto' ? 'auto' : 'human';
4126
+ // `human` is the default, so it is written as the key's ABSENCE: the file only ever lists what a team
4127
+ // turned on.
4128
+ if (to === 'auto') steps[step] = 'auto';
4129
+ else delete steps[step];
4130
+ return { ok: true, before, changed: before !== to || (to === 'human' && Object.hasOwn(automation?.steps || {}, step)), automation: { kill: automation?.kill || null, steps } };
4131
+ }
4132
+
4133
+ // Set one Build step's dial on one lane. PURE: takes the parsed build-state, returns a new one.
4134
+ export function planLaneDial(buildState, { story, repo, step, to, declared = [] }) {
4135
+ const def = stepDef(step);
4136
+ if (def && def.phase !== 'build' && def.kind === 'author') {
4137
+ return refusal(`${step} is a Shape step — its dial is set for the whole project`, `yad dial ${step} --to ${to || 'auto'}`);
4138
+ }
4139
+ if (!def || def.phase !== 'build') return refusal(`unknown Build step: ${step}`, `a lane's dial is one of ${buildAuthorIds().join(' · ')}`);
4140
+ if (isGateStep({ id: step })) {
4141
+ return refusal(`${step} is the merge gate, and a gate is never auto`, 'a person clears every gate (rule 1)');
4142
+ }
4143
+ if (!ADVANCE_VALUES.includes(to)) return refusal(`--to must be human or auto, not ${to}`);
4144
+ if (!declared.includes(repo)) {
4145
+ return refusal(`${story} does not declare the repo ${repo}`, `its repos are ${declared.join(' · ') || '(none)'}`);
4146
+ }
4147
+ const lane = isPlainObject(buildState?.repos) ? buildState.repos[repo] : null;
4148
+ if (lane?.status === 'skipped') return refusal(`${story} / ${repo} is skipped — a skipped lane runs nothing`, `yad unskip <epic> ${story} --repo ${repo} first`);
4149
+ const row = Array.isArray(lane?.steps) ? lane.steps.find((x) => isPlainObject(x) && x.id === step) : null;
4150
+ if (!row) {
4151
+ return refusal(`${story} / ${repo} has no ${step} step yet`,
4152
+ 'yad-run writes every Build step onto a lane the first time it drives it. Set the dial after that');
4153
+ }
4154
+ const before = stepAdvance(row) === 'auto' ? 'auto' : 'human';
4155
+ // A deep copy: the caller's object is left exactly as it was read.
4156
+ const next = JSON.parse(JSON.stringify(buildState));
4157
+ const target = next.repos[repo].steps.find((x) => isPlainObject(x) && x.id === step);
4158
+ // BOTH names, the old one first in meaning: `automation` is still the one read (shape 4), and a row
4159
+ // carrying only `advance` is what `yad doctor` reports as `dials:new-only`.
4160
+ target.automation = AUTOMATION_FROM_ADVANCE[to];
4161
+ target.advance = to;
4162
+ return { ok: true, before, changed: row.automation !== target.automation || row.advance !== target.advance, buildState: next };
4163
+ }
4164
+
4165
+ // Turn the kill switch on or off. PURE. Turning it ON needs a reason (rule 7 — the escape hatch is
4166
+ // recorded); turning it off records who and when, and the reason when one is given.
4167
+ export function planKill(automation, { on, reason = null, by = null, date = null }) {
4168
+ const why = strOrNull(reason);
4169
+ if (on && !why) return refusal('the kill switch needs a reason', 'yad kill --reason "<why>" — the record is what tells the team when it is safe to turn it off');
4170
+ const already = (automation?.kill?.on === true) === on;
4171
+ if (already) return { ok: true, already: true, automation };
4172
+ // The record being replaced is kept as `previous`, without its own `previous` — one level is the audit
4173
+ // trail a person reads; the rest is git history.
4174
+ const replaced = automation?.kill ? (({ previous: _previous, ...rest }) => rest)(automation.kill) : null;
4175
+ return { ok: true, already: false, automation: { steps: { ...(automation?.steps || {}) }, kill: {
4176
+ on, reason: why, by: by || null, date: date || null, ...(replaced ? { previous: replaced } : {}),
4177
+ } } };
4178
+ }
4179
+
934
4180
  export { writeJSON };