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