yadflow 3.19.0-next.2 → 4.0.0-next.3

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