yadflow 3.18.1 → 4.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
package/cli/gate.mjs CHANGED
@@ -1,29 +1,67 @@
1
- // `yad gate open|sync|comments|status` — the PR/MR-driven front-half review gate.
1
+ // `yad gate open|sync|comments|status` — the PR/MR-driven Shape review gate.
2
2
  // The platform PR/MR is the review UI; this command syncs its state into the file ledger and, when
3
3
  // the gate passes (approvals satisfied + all comment threads resolved + PR merged), auto-advances the
4
- // step. The merge click is the human approval act, so front steps still never machine_advance.
4
+ // step. The merge click is the human approval act, so Shape steps still never machine_advance.
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import {
8
- c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
8
+ c, log, ok, info, warn, hand, fail, note, readJSON, readJSONStrict, writeJSON, run, pushWithRebase, isPlainObject, writeMirrored, emitJSON, refuse, collectWarning,
9
9
  } from './lib.mjs';
10
- import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
10
+ import { PROJECT_FILES, isVerifiedLedger , productConfigPath } from './manifest.mjs';
11
11
  import {
12
- epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
13
- advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
- upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
15
- canonicalApprovals, canonicalComments, canonicalHubPrs,
12
+ epicIds, epicRel, epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, acceptedHashes, isStaleHash, gatePredicate, printable,
13
+ advanceState, closingRecord, markInReview, isEscalated, gateRuleFor, gateCapFor, gateReach, uniqueReach, peopleWord, capSeat, gateRuleSum, gateRuleEnforced, parseReviewBranch, artifactFromBase, legacyLogins,
14
+ upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES, FOUNDATION_REQUIRED, unwrittenSections,
15
+ canonicalApprovals, canonicalComments, canonicalHubPrs, optionalStepsFor, isSkippableStep, writeState, routeLacksStep,
16
+ isPassed, stepStatus, claimsSkipped, claimsInherited, DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC, staleFoundationGuards,
16
17
  } from './epic-state.mjs';
17
- import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
18
+ import { activePeople, activeSum, activeBasis, approverCount, printTeamHint, soloTeamHint } from './people.mjs';
19
+ import { applyProductMove, planProductMove } from './migrate.mjs';
20
+ import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
18
21
  import {
19
- readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
22
+ readPr, mapApprovers, createPr, platformLogin, actorName, ambiguousLegacyNames, prNumberFromUrl,
20
23
  getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
21
24
  } from './platform.mjs';
22
25
  import { isNoBlock, upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
23
26
  import { sequenceDiff } from './walkthrough.mjs';
24
27
  import { syncStatuses } from './artifact-status.mjs';
28
+ import { writeIndex, refreshIndexAfterWrite, uncommittedIndexInputs, INDEX_FILE } from './product-index.mjs';
25
29
  import { err } from './errors.mjs';
26
30
 
31
+ // Who WRITES a closing record (E18): the platform login, else the raw git user.name, else null
32
+ // (`actorName`). On CI that is usually the bot's git name — a job token cannot read `/user`. Best-effort,
33
+ // like every record's `by`: attribution never blocks a gate. Kept here rather than imported from
34
+ // skip.mjs, which imports this file.
35
+ function closingActor(root, hub) {
36
+ return actorName(root, hub?.platform);
37
+ }
38
+
39
+ // One line for a step's closing record — `yad gate status` prints it under a review step (E18), and
40
+ // `yad history show` under every step (E20), so the two views say it in the same words.
41
+ //
42
+ // The record is a file a person can edit, and it is printed on other people's terminals: a value is used
43
+ // only when it is text (made safe by `printable`), and a PR only when it is a whole number (E20 review —
44
+ // an object printed as `[object Object]`, and `pr: "9; rm"` as `PR #9; rm`).
45
+ // `prText`: a PR number as the text to print, or null. Text, not a number — a number written `"007"`
46
+ // prints as written. (Not `prNumber`: two functions below take a parameter of that name.)
47
+ export const prText = (v) => (Number.isInteger(v) && v >= 0 ? String(v) : typeof v === 'string' && /^\d+$/.test(v) ? v : null);
48
+ export function closedLine(closed) {
49
+ const [via, mergedBy, commit, date, by, pr] = [closed.via, closed.mergedBy, closed.commit, closed.date, closed.by].map(printable).concat(prText(closed.pr));
50
+ const how = via === 'merge'
51
+ ? `merged${mergedBy ? ` by ${mergedBy}` : ''}${pr !== null ? ` (PR #${pr})` : ''}${commit ? ` at ${commit.slice(0, 7)}` : ''}`
52
+ : `via ${via || 'an unknown path'}${pr !== null ? ` (PR #${pr})` : ''}`;
53
+ const waivedText = printable(closed.waived);
54
+ const waived = waivedText === 'solo' ? '; approvals waived (solo mode)' : waivedText ? `; approvals waived (${waivedText})` : '';
55
+ // E72 — a count the capacity cap lowered. Read strictly: the record is a file a person can edit, and
56
+ // a line built from half a record would state a cap nobody applied. All three numbers, and a `to`
57
+ // of at least 1 (the base) and below `needed` (a cap only ever lowers), or nothing.
58
+ const k = closed.capped;
59
+ const whole = (n) => Number.isInteger(n) && n >= 0;
60
+ const capped = k && typeof k === 'object' && !Array.isArray(k) && whole(k.needed) && whole(k.to) && whole(k.active) && k.to >= 1 && k.to < k.needed
61
+ ? `; count capped from ${k.needed} to ${k.to} (${k.active} active ${peopleWord(k.active)})` : '';
62
+ return `closed${date ? ` on ${date}` : ''} — ${how}${waived}${capped}${by ? `; recorded by ${by}` : ''}`;
63
+ }
64
+
27
65
  // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
28
66
  function frontmatter(file) {
29
67
  if (!fs.existsSync(file)) return {};
@@ -39,11 +77,11 @@ function frontmatter(file) {
39
77
  return out;
40
78
  }
41
79
 
42
- // Touched domains, resolved from files (gating.md): architecture => epic.repos; stories => union of
43
- // every story's repos; otherwise none.
80
+ // Touched domains, resolved from files (gating.md): stories => union of every story's repos; a step
81
+ // carrying a risk tag (architecture's `contract`) => epic.repos; otherwise none. The stories clause is
82
+ // checked FIRST and on its own: it used to live inside `isEscalated`, which E62 narrowed to risk tags.
44
83
  export function touchedDomains(epicDir, step) {
45
- if (!isEscalated(step)) return [];
46
- if (step.id === 'stories-review') {
84
+ if (step?.id === 'stories-review') {
47
85
  const dir = path.join(epicDir, 'stories');
48
86
  if (!fs.existsSync(dir)) return [];
49
87
  const set = new Set();
@@ -52,11 +90,13 @@ export function touchedDomains(epicDir, step) {
52
90
  }
53
91
  return [...set];
54
92
  }
93
+ if (!isEscalated(step)) return [];
55
94
  return frontmatter(path.join(epicDir, 'epic.md')).repos || [];
56
95
  }
57
96
 
58
- // The artifact owner shown in the review PR/MR body. Feature epics carry it in epic.md; the discovery
59
- // front-zero (EP-discovery) has no epic.md, so fall back to roadmap.md's frontmatter owner.
97
+ // The artifact owner shown in the review PR/MR body. Feature epics carry it in epic.md; the Product
98
+ // level has no epic.md, so fall back to roadmap.md's frontmatter owner — a section the Foundation and
99
+ // its old `EP-discovery` spelling both have, under that same name, in their own folder.
60
100
  const ownerOf = (epicDir) =>
61
101
  frontmatter(path.join(epicDir, 'epic.md')).owner
62
102
  || frontmatter(path.join(epicDir, 'roadmap.md')).owner
@@ -73,23 +113,42 @@ function warnUnlockedContract(epicDir, artifact) {
73
113
  }
74
114
  }
75
115
 
76
- // A null discovery hash means the discovery set is incomplete (a required artifact is missing), so the
77
- // review is not yet reviewable and an approval would not be hash-bound. Name the missing files so the
78
- // owner can complete the set before the gate is opened/advanced (mirrors warnUnlockedContract).
79
- function warnIncompleteDiscovery(epicDir, artifact) {
80
- if (artifactBase(artifact) !== 'discovery') return;
81
- if (artifactHash(epicDir, artifact) !== null) return;
82
- const missing = DISCOVERY_FILES.filter((f) => !fs.existsSync(path.join(epicDir, f)));
83
- warn(`discovery set incomplete — missing ${missing.join(', ')}; review is not yet reviewable (approvals will not be hash-bound until the full set exists)`);
116
+ // A null hash on a product-level set means it is incomplete (a required file is missing), so the review
117
+ // is not yet reviewable and an approval would not be hash-bound. Name the missing files so the owner can
118
+ // complete the set before the gate is opened/advanced (mirrors warnUnlockedContract). Two sets: the
119
+ // Foundation's required sections (E75 — its optional ones never make it incomplete) and the six files
120
+ // of the old `discovery` spelling.
121
+ //
122
+ // A complete Foundation can still be an EMPTY one (E76): every section present and holding only its
123
+ // template headings has a fingerprint, so reviewers could approve it. That is named too — a warning,
124
+ // never a refusal, the stance E75 took on the Foundation's gate itself. The old `discovery` spelling
125
+ // is not asked: its templates were never read this way, and it is only ever converted, not authored.
126
+ export function warnIncompleteDiscovery(epicDir, artifact) {
127
+ const b = artifactBase(artifact);
128
+ const required = b === 'foundation' ? FOUNDATION_REQUIRED : b === 'discovery' ? DISCOVERY_FILES : null;
129
+ if (!required) return;
130
+ if (artifactHash(epicDir, artifact) === null) {
131
+ const missing = required.filter((f) => !fs.existsSync(path.join(epicDir, f)));
132
+ const label = b === 'foundation' ? 'Foundation incomplete' : 'discovery set incomplete';
133
+ warn(`${label} — missing ${missing.join(', ')}; review is not yet reviewable (approvals will not be hash-bound until the full set exists)`);
134
+ }
135
+ if (b !== 'foundation') return;
136
+ const empty = unwrittenSections(epicDir);
137
+ if (empty.length) {
138
+ // Worded for both callers: `gate open` before any approval, and `gate sync` on a review that may
139
+ // already have passed — so it says what an approval of this content means, not that one is coming.
140
+ const one = empty.length === 1;
141
+ warn(`Foundation not written yet — ${empty.join(', ')} ${one ? 'holds nothing but its' : 'hold nothing but their'} template, so an approval of ${one ? 'it approves an empty section' : 'them approves empty sections'}`);
142
+ }
84
143
  }
85
144
 
86
- // Fail fast on a corrupt or wrong-shape hub config: a silently-defaulted hub.json would degrade
87
- // every gate to file-only without anyone noticing, and a typo'd platform would read as "no bridge".
88
- export function loadHub(root) {
89
- const hubFile = path.join(root, PROJECT_FILES.hubConfig);
145
+ // Fail fast on a corrupt or wrong-shape Product config: a silently-defaulted hub.json would degrade
146
+ // every gate to local without anyone noticing, and a typo'd platform would read as a local ledger.
147
+ export function loadProduct(root) {
148
+ const hubFile = productConfigPath(root);
90
149
  const regFile = path.join(root, PROJECT_FILES.reposRegistry);
91
- // Distinguish an ABSENT hub.json (null default → fine, file-only gate) from one that exists but
92
- // holds literal `null` (malformed — must not silently downgrade to file-only).
150
+ // Distinguish an ABSENT hub.json (null default → fine, local gate) from one that exists but
151
+ // holds literal `null` (malformed — must not silently downgrade to local).
93
152
  const hub = readJSONStrict(hubFile, null);
94
153
  if (hub === null && fs.existsSync(hubFile)) {
95
154
  throw err('YAD-STATE-002', `${hubFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
@@ -99,9 +158,6 @@ export function loadHub(root) {
99
158
  if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) {
100
159
  throw err('YAD-CFG-001', `${hubFile}: unknown platform '${hub.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
101
160
  }
102
- if (hub.roster !== undefined && !Array.isArray(hub.roster)) {
103
- throw err('YAD-STATE-002', `${hubFile}: expected \`roster\` to be an array`, 'fix the file or re-run `yad setup`');
104
- }
105
161
  }
106
162
  const registry = readJSONStrict(regFile, { repos: [] });
107
163
  if (!Array.isArray(registry?.repos)) throw err('YAD-STATE-002', `${regFile}: expected a \`repos\` array`, 'fix the file or re-run `yad setup`');
@@ -113,16 +169,21 @@ export function loadHub(root) {
113
169
  // merge advances the step). Recorded per-project in hub.json by `yad setup`.
114
170
  export const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
115
171
 
116
- // Bridge mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
117
- // defined once in manifest.mjs (`isBridgeHub`) and shared with plan.mjs's wiring and the ledger
172
+ // Verified mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
173
+ // defined once in manifest.mjs (`isVerifiedLedger`) and shared with plan.mjs's wiring and the ledger
118
174
  // hook, so no two readers can disagree about who owns the ledger (#186).
119
- const isBridge = isBridgeHub;
120
175
 
121
176
  // requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
122
177
  // approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
123
178
  // but is recorded `engagement: none` and draws the friendly nudge.
124
179
  export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
125
180
 
181
+ // `legacyLogins` LIVES in cli/epic-state.mjs now (E71): the active-people reader needs the same table to
182
+ // recognise an older record, and importing it from here would have made a cycle (gate.mjs prints the
183
+ // count that reader returns). This re-export stays — rule 3, the new name beside the old — and is the
184
+ // one every older reader here and in cli/usage.mjs still calls.
185
+ export { legacyLogins };
186
+
126
187
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
127
188
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
128
189
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
@@ -130,53 +191,194 @@ export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngageme
130
191
  // `closed`: the step already advanced. Drop-and-re-add is what makes a dismissal or revocation vanish
131
192
  // idempotently on an OPEN step — the platform is the live source of truth there. On a CLOSED step it
132
193
  // is destructive instead: the gate passed, and the approvals that passed it are the audit record of
133
- // why. A roster edit, a GitLab approval reset, or any degraded-but-`ok` read yields an empty `recs`
194
+ // why. A GitLab approval reset, or any degraded-but-`ok` read yields an empty `recs`
134
195
  // and would erase them, leaving `done` with zero approvals — the very state issue #156 is about,
135
196
  // reached from the other side. So a closed step's record is only ever added to or refreshed in place.
136
- function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNumber = null, closed = false }) {
137
- const keyOf = (name, role, domain) => `${stepId}|${name}|${role}|${domain || ''}`;
138
- const prior = new Map(
139
- approvals.filter((a) => a.step === stepId && a.source === 'bridge')
140
- .map((a) => [keyOf(a.approver, a.role, a.domain), a]),
141
- );
142
- const seen = new Set(recs.map((r) => keyOf(r.name, r.role, r.domain)));
197
+ //
198
+ // ONE RECORD PER PERSON (E62). An approval is keyed by who gave it and nothing else. Before E62 the key
199
+ // also held the role and domain the roster gave that person, so one approval could be several records;
200
+ // such a person's older records are replaced by one, which keeps the fingerprint and dates they carried
201
+ // — a STALE one when their records disagree, so the merge leans stale. Stale means not among
202
+ // `accepted` (`acceptedHashes`), never merely "not today's exact hash": every approval a released
203
+ // yadflow wrote carries an older fingerprint form the gate still reads as live, and treating those as
204
+ // stale picked the wrong record and passed a gate on a stale approval (E62 review). The
205
+ // `role`/`domain` fields are not carried forward — nothing reads them any more.
206
+ //
207
+ // AN OLDER RECORD NAMES THE PERSON AS THE ROSTER DID (`alice`), and the platform now reports their LOGIN
208
+ // (`al`). Unmatched, the first sync after the upgrade treated the approval as new and bound it to
209
+ // TODAY's content — an approval of the old text read as approval of the edited one, the hole #156 is
210
+ // about — and on a closed step it listed one person twice. `aliases` is the roster's own name → login
211
+ // table (`legacyLogins`), read for this recognition ONLY; it decides nothing about the gate. With it the
212
+ // match is exact — unless the roster itself is ambiguous (a name held twice is dropped by `legacyLogins`;
213
+ // a name equal to another entry's login `yad doctor` warns about). Without it (the roster was deleted) an
214
+ // older record is matched only when nothing else could be it: the same submission time on GitHub, or, on
215
+ // an OPEN step, the single unmatched approval against the single unmatched older approver of this PR on
216
+ // GitLab. A record an older release marked `unverified` names a login already and is never translated. Guessing by order is how two people's fingerprints got
217
+ // swapped. Where it stays ambiguous, a closed step adds nothing (its record is history, and a second
218
+ // entry for the same review would count one person twice), and an open step records the approval
219
+ // against a STALE fingerprint from those older records when one exists — the approval must be given
220
+ // again, on a new review, rather than pass on content one of those people may never have seen.
221
+ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNumber = null, closed = false, aliases = new Map(), clashed = new Map(), accepted = null }) {
222
+ const live = accepted ?? (curHash ? [curHash] : []);
223
+ const stale = (a) => !!a.artifactHash && isStaleHash(a.artifactHash, live);
224
+ // A submission TIME, not a date. Every GitLab record written before E64 read `approved_at` holds the
225
+ // day it was synced (`today`), and as text `2026-09-15T10:00:00Z` sorts after `2026-09-15` — so a
226
+ // date-only value compared as a time made the first E64 sync read every same-day GitLab approval as a
227
+ // newer review and bind it to today's content (#156). A date is "time unknown".
228
+ const hasTime = (t) => typeof t === 'string' && t.includes('T') && !Number.isNaN(Date.parse(t));
229
+ const isLegacy = (a) => a.role !== undefined || a.domain !== undefined;
230
+ // An `unverified` record already names a LOGIN — an older release wrote one for a reviewer the roster did
231
+ // not list — so it is never translated through the roster's names, where it could collide with a name.
232
+ // A record under a roster name two logins share is keyed apart from every login (a NUL-prefixed key no
233
+ // platform login can equal), so it is never name-matched and, on a closed step, stays as history. The
234
+ // key also carries the submission time: an older release wrote one record per login under that one
235
+ // name, so a single key would lump two people together, and continuing one would delete the other.
236
+ const personOf = (a) => {
237
+ if (!isLegacy(a) || a.unverified) return a.approver;
238
+ if (aliases.has(a.approver)) return aliases.get(a.approver);
239
+ return clashed.has(a.approver) ? `\u0000${a.approver}\u0000${a.approvedAt ?? ''}` : a.approver;
240
+ };
241
+ // Who could own a group: anyone for a record no name places; only the logins the roster gives that
242
+ // name for a shared-name group — a login that merely EQUALS the shared name is someone else.
243
+ const nameOfKey = (k) => (k.startsWith('\u0000') ? k.split('\u0000')[1] : null);
244
+ const eligible = (k, r) => { const n = nameOfKey(k); return n === null || !!clashed.get(n)?.has(r.name); };
245
+ const bridge = approvals.filter((a) => a.step === stepId && a.source === 'bridge');
246
+ const groups = new Map(); // person -> their records
247
+ for (const a of bridge) {
248
+ const k = personOf(a);
249
+ if (!groups.has(k)) groups.set(k, []);
250
+ groups.get(k).push(a);
251
+ }
252
+ const repOf = (list) => list.find(stale) || list[0];
253
+ const seen = new Set(recs.map((r) => r.name));
254
+ const matchOf = new Map(); // rec -> the record it continues
255
+ const replaced = new Set(); // people some of whose records a rec continues
256
+ const claimed = new Set(); // the older records a rec continues — per REVIEW, not per person (see below)
257
+ const claim = (r, k, list) => { matchOf.set(r, repOf(list)); for (const a of list) claimed.add(a); replaced.add(k); };
258
+ // An older release's record, or one `stampLegacyLogins` moved onto a login through the name table.
259
+ const olderRec = (a) => (isLegacy(a) && !a.unverified) || a.rosterName !== undefined;
260
+ // AN EXACT SUBMISSION TIME BEATS THE NAME TABLE. On GitHub every review carries the second it was
261
+ // submitted, and an older record kept it as `approvedAt`; when exactly one older group holds that
262
+ // second, it is the same review whatever name the roster now gives it. The name table can be wrong —
263
+ // a team that renamed one of two people sharing a name hands the older records to whoever kept it,
264
+ // and trusting the name then dropped the right record and passed the gate on an approval of old content
265
+ // (E62 upgrade simulation). Only older groups are matched this way: role-bearing records, and records
266
+ // `stampLegacyLogins` moved onto a login through that same name table, which keep the name in
267
+ // `rosterName` (E64) so that stamping them does not take this correction away.
268
+ //
269
+ // The time claims THE RECORDS OF THAT REVIEW, not the whole name group. Two people an older release wrote
270
+ // under one name sit in one group, each with their own submission time; claiming the group for whichever
271
+ // approval matched first, and continuing from the group's first stale record, handed one person the
272
+ // other's record — the later time then read as a newer review and both approvals were bound to today's
273
+ // content, passing a gate on approvals of content that had changed (E64 upgrade simulation). So each
274
+ // approval continues from the records at its own time, and it does so even when the name matches too, or
275
+ // the name match would pick from both people's records. A submission time is unique among these approvals
276
+ // (a shared one is skipped above), so no two claim the same records and the platform's order cannot matter.
277
+ for (const r of recs) {
278
+ if (!r.submittedAt || recs.some((x) => x !== r && x.submittedAt === r.submittedAt)) continue;
279
+ // A group matches on an OLDER record holding that second; the review is then every record of the group
280
+ // holding it — an older release could write one review twice (under the roster name, and `unverified`
281
+ // under the login), and leaving one behind kept it as a second person on a closed step.
282
+ const atTime = (k) => groups.get(k).filter((a) => a.approvedAt === r.submittedAt);
283
+ const byTime = [...groups.keys()].filter((k) => atTime(k).some(olderRec));
284
+ if (byTime.length === 1) claim(r, byTime[0], atTime(byTime[0]));
285
+ }
286
+ // By name, from what no time claimed — and still per review: when some of those records hold this
287
+ // approval's own time, those are its review; the rest may be another person's under the same name.
288
+ for (const r of recs) {
289
+ if (matchOf.has(r) || !groups.has(r.name)) continue;
290
+ const rest = groups.get(r.name).filter((a) => !claimed.has(a));
291
+ const own = r.submittedAt ? rest.filter((a) => a.approvedAt === r.submittedAt) : [];
292
+ if (rest.length) claim(r, r.name, own.length ? own : rest);
293
+ }
294
+ // Older approvers nobody could name: legacy records with no alias, recorded against THIS review (a
295
+ // record with no `pr` predates PR provenance and is taken as the pointer's, as `stampLegacyPr` does).
296
+ // A group an exact time already continued is not an orphan: claiming it again through the one-to-one or
297
+ // time rule below handed the same records to a second approval (E64 upgrade simulation).
298
+ const orphans = prNumber == null ? [] : [...groups.keys()].filter((k) => !seen.has(k) && !replaced.has(k)
299
+ && groups.get(k).every((a) => isLegacy(a) && !a.unverified && !aliases.has(a.approver) && (a.pr == null || a.pr === prNumber)));
300
+ // (a clashed-name group's key starts with NUL, so `seen` never holds it and it is always eligible here)
301
+ const unmatched = recs.filter((r) => !matchOf.has(r));
302
+ // Every approval is judged against the SAME set of older approvers, and only then are the matches taken:
303
+ // deciding them one at a time made the result depend on the order the platform lists reviews, so an
304
+ // unchanged re-sync could add a record the first sync had dropped. A CLOSED step continues an older
305
+ // record only on an exact submission time — the GitLab one-to-one guess there could hand one person's
306
+ // history to another. Two approvals that would continue the same older record continue neither.
307
+ const picks = new Map();
308
+ for (const r of unmatched) {
309
+ const mine = orphans.filter((k) => eligible(k, r));
310
+ // Match by time only when the older records hold one to match. A GitLab record from before E64 holds a
311
+ // date, so an approval that now carries `approved_at` still takes the one-to-one rule against it.
312
+ const byTimeOnly = !!r.submittedAt && mine.some((k) => groups.get(k).some((a) => hasTime(a.approvedAt)));
313
+ if (closed && !byTimeOnly) continue;
314
+ const candidates = byTimeOnly
315
+ ? mine.filter((k) => groups.get(k).some((a) => a.approvedAt === r.submittedAt))
316
+ : (mine.length === 1 ? mine : []);
317
+ const rivals = byTimeOnly
318
+ ? unmatched.filter((x) => x.submittedAt === r.submittedAt && candidates.some((k) => eligible(k, x))).length
319
+ : unmatched.filter((x) => candidates.some((k) => eligible(k, x))).length;
320
+ if (candidates.length === 1 && rivals === 1) picks.set(r, candidates[0]);
321
+ }
322
+ const claims = new Map();
323
+ for (const k of picks.values()) claims.set(k, (claims.get(k) || 0) + 1);
324
+ for (const [r, k] of picks) {
325
+ if (claims.get(k) > 1) { picks.delete(r); continue; }
326
+ claim(r, k, groups.get(k));
327
+ }
328
+ const unclaimed = orphans.filter((k) => !replaced.has(k));
329
+ // Ambiguous: an unmatched approval that one of the older approvers still left could be. Each is bound to
330
+ // a stale fingerprint from the records IT could be, never from someone it cannot be.
331
+ const couldBe = (r) => unclaimed.filter((k) => eligible(k, r));
332
+ const ambiguous = new Set(unmatched.filter((r) => !picks.has(r) && couldBe(r).length));
333
+ const staleFor = (r) => couldBe(r).flatMap((k) => groups.get(k)).find(stale) || null;
143
334
  const kept = approvals.filter((a) => {
144
335
  if (!(a.step === stepId && a.source === 'bridge')) return true;
145
- // Closed step: keep a prior approval the platform no longer reports. It is history, not state.
146
- return closed && !seen.has(keyOf(a.approver, a.role, a.domain));
336
+ if (claimed.has(a)) return false;
337
+ // Closed step: keep a prior approval the platform no longer reports. It is history, not state. That
338
+ // includes a record left in a name group some of whose records a review continued: it is another
339
+ // review — under a shared name, another person's — and dropping the whole group deleted their history.
340
+ return closed && (!seen.has(personOf(a)) || replaced.has(personOf(a)));
147
341
  });
148
342
  for (const r of recs) {
149
- const was = prior.get(keyOf(r.name, r.role, r.domain));
150
- let artHash = curHash; // first time we see this approval => bind to current content
343
+ if (closed && ambiguous.has(r)) continue;
344
+ const was = matchOf.get(r);
345
+ // first time we see this approval => bind to current content; an ambiguous one => to the stale print
346
+ const staleOrphan = ambiguous.has(r) ? staleFor(r) : null;
347
+ let artHash = staleOrphan ? staleOrphan.artifactHash : curHash;
151
348
  let approvedAt = r.submittedAt || today;
152
349
  let recordedOn = today;
153
350
  if (was) {
154
351
  // We only adopt the new hash when the platform PROVES a genuinely newer review. Otherwise —
155
352
  // the same review read again — we KEEP the hash they originally approved, so a later artifact
156
353
  // change still revokes the approval. Two independent proofs, because one platform lacks each:
157
- // - a later submittedAt (GitHub; GitLab approvals carry no timestamp at all), or
354
+ // - a later submission TIME on both sides (GitHub always; GitLab when it sends `approved_at` and the
355
+ // record already holds a time — a date-only record is "time unknown", see `hasTime`), or
158
356
  // - a DIFFERENT PR/MR than the one this approval was recorded against. A re-opened review is
159
357
  // always a new PR, so an approval arriving on it cannot be the old one read again. Without
160
358
  // this, a GitLab re-review after a re-lock re-recorded the pre-edit hash and stayed
161
359
  // permanently stale — the step read `done` with zero live approvals (issue #156).
162
- const newerReview = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
360
+ const newerReview = hasTime(r.submittedAt) && hasTime(was.approvedAt) && Date.parse(r.submittedAt) > Date.parse(was.approvedAt);
163
361
  const newerPr = prNumber != null && was.pr != null && was.pr !== prNumber;
164
362
  if (!newerReview && !newerPr) {
165
363
  artHash = was.artifactHash ?? curHash;
166
- approvedAt = was.approvedAt ?? approvedAt;
364
+ // A record holding only a date takes the platform's time once (E64) and keeps its fingerprint: the
365
+ // time is evidence of when, the fingerprint is what the gate decides on. Then it is byte-stable.
366
+ approvedAt = !hasTime(was.approvedAt) && hasTime(r.submittedAt) ? r.submittedAt : (was.approvedAt ?? approvedAt);
167
367
  // Same review, re-read: keep the date it was RECORDED too, so re-syncing an unchanged
168
368
  // approval is a byte-identical no-op instead of a daily one-line ledger commit.
169
369
  recordedOn = was.date ?? recordedOn;
170
370
  }
171
371
  }
172
372
  kept.push({
173
- artifact, step: stepId, approver: r.name, role: r.role,
174
- ...(r.domain ? { domain: r.domain } : {}),
373
+ artifact, step: stepId, approver: r.name,
175
374
  status: 'approved', date: recordedOn, source: 'bridge',
176
375
  artifactHash: artHash, approvedAt,
177
376
  ...(prNumber != null ? { pr: prNumber } : {}),
377
+ // The platform's evidence for this review, when it gave any (E64, see mapApprovers). Audit only.
378
+ ...(r.commit ? { commit: r.commit } : {}),
379
+ ...(r.url ? { url: r.url } : {}),
380
+ ...(r.reviewId ? { reviewId: r.reviewId } : {}),
178
381
  engagement: r.engagement === 'verified' ? 'verified' : 'none',
179
- ...(r.unverified ? { unverified: true } : {}),
180
382
  });
181
383
  }
182
384
  // Canonical order, not insertion order: this function re-appends at the tail, so without it the
@@ -200,6 +402,101 @@ export function stampLegacyPr(approvals, stepId, prNumber) {
200
402
  return n;
201
403
  }
202
404
 
405
+ // Record the platform login on every older approval and comment record the roster can place (E64), so no
406
+ // later sync needs the roster at all. E62's handoff: without this, a roster deleted before the first sync
407
+ // after the upgrade left an open review's older approvals matchable only when nothing else could be them.
408
+ //
409
+ // An older record is one an older release wrote with a `role` or `domain`, naming the person by the
410
+ // roster's `name`. It is stamped exactly as `upsertBridge` rewrites a record it continues: `approver` /
411
+ // `commenter` becomes the login, and `role`, `domain` and `unverified` are removed — a role left on a
412
+ // login-named record would make the next sync look its login up as a roster name, which can be another
413
+ // person's. The dated `reviews/*--approved.md` keeps the roles.
414
+ //
415
+ // It stamps only what the name table places for certain, and leaves the rest for `upsertBridge` to judge
416
+ // with the platform's evidence in hand:
417
+ // - an `unverified` record already names a login, and a name two logins share (`clashed`) or a name the
418
+ // roster does not hold is not in `aliases` — none is touched;
419
+ // - ONE RECORD PER PERSON, BUT ONLY FOR ONE REVIEW. An older release wrote one record per role, so one
420
+ // approval can be several records. Several records for one login on one step are merged only when they
421
+ // provably are one review: every one holds the same submission TIME and the same `pr`. Anything less is
422
+ // not proof — a GitLab record from before E64 holds the day it was synced, so two people a renamed
423
+ // roster now gives one name, synced the same day, look identical; a hand-written record holds no time at
424
+ // all. Merging those deleted one person's approval and passed a gate on the other's approval of old
425
+ // content (E64 upgrade simulation). Such a group is left as it is (`unplaced`). When records for one
426
+ // review disagree on the fingerprint, the stale one (outside `acceptedHashes`) is kept, as E62 does;
427
+ // - a stamped approval keeps the roster name it had in `rosterName`. `upsertBridge` still lets an exact
428
+ // submission time beat that name, as it did while the record carried a role — so a renamed shared name
429
+ // stamped onto the wrong login is corrected by the platform's time, not locked in;
430
+ // - a comment round where two records would name one login is left as it is: which count is right is
431
+ // not knowable, and the cost of leaving it is one extra round.
432
+ // Pure: returns new arrays and the counts; `acceptedFor(artifact)` gives that artifact's live fingerprints.
433
+ // `unplaced` counts older records the roster was still needed for and the stamp could not place.
434
+ export function stampLegacyLogins({ approvals = [], comments = [] } = {}, { aliases = new Map(), clashed = new Map(), acceptedFor = () => [] } = {}) {
435
+ const isOld = (x) => x.role !== undefined || x.domain !== undefined;
436
+ // A ledger is a list a person can edit, so an entry may be anything. One this cannot read is left exactly
437
+ // as it is: the stamp runs over every epic on a merge run, and a bad record in an epic nobody is syncing
438
+ // must not stop the review that merged (E64 review).
439
+ const readable = (x, who) => isPlainObject(x) && typeof x[who] === 'string' && x[who] !== '';
440
+ const hasTime = (t) => typeof t === 'string' && t.includes('T') && !Number.isNaN(Date.parse(t));
441
+ const accepted = new Map();
442
+ const live = (artifact) => { if (!accepted.has(artifact)) accepted.set(artifact, acceptedFor(artifact) || []); return accepted.get(artifact); };
443
+ let stamped = 0;
444
+ let unplaced = 0;
445
+
446
+ const groups = new Map();
447
+ approvals.forEach((a, i) => {
448
+ if (!readable(a, 'approver') || typeof a.step !== 'string' || !readable(a, 'artifact')) return;
449
+ const old = isOld(a) && !a.unverified;
450
+ if (old && !aliases.has(a.approver)) { if (clashed.has(a.approver)) unplaced++; return; }
451
+ const login = old ? aliases.get(a.approver) : (isOld(a) ? null : a.approver);
452
+ if (login == null) return;
453
+ const k = JSON.stringify([a.step, a.artifact, a.source ?? null, login]);
454
+ if (!groups.has(k)) groups.set(k, { login, list: [] });
455
+ groups.get(k).list.push({ a, i, old });
456
+ });
457
+ const out = approvals.slice();
458
+ const drop = new Set();
459
+ for (const { login, list } of groups.values()) {
460
+ const olds = list.filter((x) => x.old);
461
+ if (!olds.length) continue;
462
+ const oneReview = list.length === 1
463
+ || (list.every((x) => hasTime(x.a.approvedAt)) && new Set(list.map((x) => `${x.a.approvedAt}|${x.a.pr ?? ''}`)).size === 1);
464
+ if (!oneReview) { unplaced += olds.length; continue; }
465
+ // Which fingerprints are live cannot always be computed from a hand-edited record (an `artifact` naming a
466
+ // folder throws). Then which record is stale is unknown, and choosing one could keep the live print, so
467
+ // the group is left as it is (E64 review).
468
+ let staleRec;
469
+ try { staleRec = list.find((x) => !!x.a.artifactHash && isStaleHash(x.a.artifactHash, live(x.a.artifact))); } catch { unplaced += olds.length; continue; }
470
+ const rep = staleRec || list.find((x) => !x.old) || olds[0];
471
+ const { role, domain, unverified, ...rest } = rep.a; // eslint-disable-line no-unused-vars
472
+ out[rep.i] = { ...rest, approver: login, rosterName: rep.old ? rep.a.approver : (rep.a.rosterName ?? olds[0].a.approver) };
473
+ for (const x of list) if (x !== rep) drop.add(x.i);
474
+ stamped += olds.length;
475
+ }
476
+
477
+ const rounds = new Map();
478
+ comments.forEach((cm, i) => {
479
+ if (!readable(cm, 'commenter')) return;
480
+ const old = isOld(cm);
481
+ if (old && !aliases.has(cm.commenter)) { if (clashed.has(cm.commenter)) unplaced++; return; }
482
+ const login = old ? aliases.get(cm.commenter) : cm.commenter;
483
+ const k = JSON.stringify([cm.step, cm.round ?? null, login]);
484
+ if (!rounds.has(k)) rounds.set(k, { login, list: [] });
485
+ rounds.get(k).list.push({ cm, i, old });
486
+ });
487
+ const cOut = comments.slice();
488
+ for (const { login, list } of rounds.values()) {
489
+ const olds = list.filter((x) => x.old);
490
+ if (!olds.length) continue;
491
+ if (list.length > 1) { unplaced += olds.length; continue; }
492
+ const { role, domain, ...rest } = olds[0].cm; // eslint-disable-line no-unused-vars
493
+ cOut[olds[0].i] = { ...rest, commenter: login, rosterName: olds[0].cm.commenter };
494
+ stamped++;
495
+ }
496
+
497
+ return { approvals: out.filter((_, i) => !drop.has(i)), comments: cOut, stamped, unplaced };
498
+ }
499
+
203
500
  function writeComments(epicDir, base, today, blocking) {
204
501
  if (!blocking.length) return;
205
502
  const file = path.join(epicDir, 'reviews', `${base}--${today}--comments.md`);
@@ -216,10 +513,19 @@ function writeComments(epicDir, base, today, blocking) {
216
513
  // Upsert machine-readable participation records into the comments ledger (the counterpart to the
217
514
  // markdown side file) so the ledger — not just reviews/*.md — reflects platform thread state. One
218
515
  // record per (step, commenter, round); `round` is the count of prior synced rounds for the step.
219
- function recordComments(comments, { artifact, stepId, today, roster, blocking }) {
516
+ // The commenter is the platform login (E62) — there is no stored list to turn it into another name.
517
+ // A round an older release wrote names the roster's name for a person; `aliases` (see `legacyLogins`)
518
+ // reads it as their login, so the first sync after the upgrade does not open a new round for the same
519
+ // threads.
520
+ function recordComments(comments, { artifact, stepId, today, blocking, aliases = new Map(), clashed = new Map() }) {
220
521
  if (!blocking.length) return comments;
221
- const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
222
- const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
522
+ const byName = (login) => login || 'reviewer';
523
+ // As `upsertBridge` reads approvals: a shared roster name is nobody's login, so its round is not "the same".
524
+ const personOf = (cm) => {
525
+ if (cm.role === undefined) return cm.commenter;
526
+ if (aliases.has(cm.commenter)) return aliases.get(cm.commenter);
527
+ return clashed.has(cm.commenter) ? `\u0000${cm.commenter}` : cm.commenter;
528
+ };
223
529
  const counts = new Map();
224
530
  for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
225
531
  // A round is a CHANGE in the thread state, not a sync. Allocating max+1 on every call made an
@@ -232,21 +538,23 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
232
538
  const latest = rounds.reduce((m, cm) => Math.max(m, cm.round || 0), 0);
233
539
  const prior = rounds.filter((cm) => cm.round === latest);
234
540
  const now = new Map([...counts].map(([login, count]) => [byName(login), count]));
235
- const same = latest > 0 && prior.length === now.size && prior.every((cm) => now.get(cm.commenter) === cm.count);
541
+ // The same people, each once, with the same counts — two older records that map to one person are not.
542
+ const same = latest > 0 && prior.length === now.size && new Set(prior.map(personOf)).size === prior.length
543
+ && prior.every((cm) => now.get(personOf(cm)) === cm.count);
236
544
  const round = same ? latest : latest + 1;
237
545
  const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
238
546
  for (const [login, count] of counts) {
239
547
  // An unchanged round keeps its original date, so re-syncing it is byte-identical rather than a
240
548
  // daily one-line churn (the same rule upsertBridge applies to an unchanged approval).
241
- const was = prior.find((cm) => cm.commenter === byName(login));
242
- kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: (same && was?.date) || today });
549
+ const was = prior.find((cm) => personOf(cm) === byName(login));
550
+ kept.push({ artifact, step: stepId, commenter: byName(login), round, count, date: (same && was?.date) || today });
243
551
  }
244
552
  return canonicalComments(kept); // same drop-and-re-append churn as approvals — see canonicalApprovals
245
553
  }
246
554
 
247
555
  // ---- actions ------------------------------------------------------------------------------------
248
556
 
249
- // The review PR/MR(s) to sync. Normally the ledger's own pointer — but under the bridge the ledger
557
+ // The review PR/MR(s) to sync. Normally the ledger's own pointer — but with a verified ledger the record
250
558
  // records that pointer only at merge (CI is the sole writer), so a review a human needs to push
251
559
  // through by hand has NO recorded pointer at all. Fall back to the PR number the caller named
252
560
  // (`--pr`), else resolve it from the review branch on the platform. Without this, `gate sync` reported
@@ -296,23 +604,53 @@ function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finde
296
604
  return { targets: entry(found.number, found.url), discovered: true };
297
605
  }
298
606
 
299
- export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false } = {}) {
300
- const { hub, repos } = loadHub(root);
301
- if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
607
+ // Rebuild the index and stage it — but ONLY when every file it reads is exactly what the commit will
608
+ // hold. The index hashes the files ON DISK, so in a checkout carrying someone's half-written epic.md (the
609
+ // documented manual recovery, `yad gate ci … --merged` run by a person) it would describe work that is
610
+ // not in the commit, and read as behind on every clean checkout after it. Then it stays out and says so.
611
+ // Derived, so a failure is said and never stops the commit it would have ridden.
612
+ // `commits`: the paths a pathspec-limited commit will hold (`gate repair`), or null for a commit of
613
+ // everything staged (`gate ci`). The check itself is `uncommittedIndexInputs` (cli/product-index.mjs).
614
+ function stageIndexIfClean(root, git, commits = null) {
615
+ const dirty = uncommittedIndexInputs(root, commits);
616
+ if (dirty === null || dirty.length) {
617
+ warn(`${INDEX_FILE} left out of this commit: ${dirty === null ? 'git could not list the working tree' : `${dirty.join(', ')} ${dirty.length === 1 ? 'is' : 'are'} not in it as on disk`} — run \`yad index\` on a clean checkout`);
618
+ return false;
619
+ }
620
+ try {
621
+ writeIndex(root);
622
+ } catch (e) {
623
+ warn(`${INDEX_FILE} was not rebuilt (${e.code || e.message}) — the ledger is committed without it`);
624
+ return false;
625
+ }
626
+ // Staged when it differs from what is committed — not "when this write changed the bytes": a caller
627
+ // may have rebuilt it a moment earlier (`gate repair`), and that rebuild still belongs in the commit.
628
+ if (!git('add', '--', INDEX_FILE).ok) {
629
+ warn(`${INDEX_FILE} could not be staged — the ledger is committed without it`);
630
+ return false;
631
+ }
632
+ return !git('diff', '--cached', '--quiet', '--', INDEX_FILE).ok;
633
+ }
634
+
635
+ export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false, headCount = null } = {}) {
636
+ const { hub } = loadProduct(root);
637
+ if (!hub?.platform) { warn('no Product platform configured (.sdlc/hub.json) — local gate, nothing to sync'); return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
302
638
  const platform = hub.platform;
303
- const roster = hub.roster || [];
304
- const defaultReviewers = 1;
639
+ const aliases = legacyLogins(hub);
640
+ const clashed = ambiguousLegacyNames(hub);
305
641
  const solo = isSolo(hub);
306
642
  const reqEng = requireEngagement(hub);
307
- // Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
643
+ // Local invocation in verified mode is ADVISORY: CI is the sole ledger writer, so a human run reads
308
644
  // the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
309
- // Without the bridge (platform but no gate-sync CI) the local command stays the writer.
310
- // dryRun forces the same read-only behavior regardless of bridge — used for the Path B pre-merge
645
+ // With a local ledger (platform but no gate-sync CI) the local command stays the writer.
646
+ // dryRun forces the same read-only behavior regardless of the ledger — used for the Path B pre-merge
311
647
  // evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
312
- const readOnly = (local && isBridge(hub)) || dryRun;
648
+ const readOnly = (local && isVerifiedLedger(hub)) || dryRun;
649
+ // Who writes this run's closing records (E18). Not asked on a read-only run, which writes nothing.
650
+ const by = readOnly ? null : closingActor(root, hub);
313
651
  const epicDir = epicRoot(root, epic);
314
652
  const ledger = loadLedger(epicDir);
315
- if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
653
+ if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
316
654
 
317
655
  let { approvals, comments, hubPrs, state } = ledger;
318
656
  // Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
@@ -323,6 +661,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
323
661
  const s = p.number != null ? findReviewStep(state, p.artifact) : null;
324
662
  if (s) stampLegacyPr(approvals, s.id, p.number);
325
663
  }
664
+ // E64: record the login on every older record the roster can place, every step of this epic, before
665
+ // anything else reads them — after the `pr` backfill above, so one review's role records agree on `pr` and
666
+ // merge on this write rather than the next. In memory like everything here: a read-only run writes
667
+ // nothing, and the writer path below persists it with the rest. A failure here is named and the sync goes
668
+ // on with the records as they were: the stamp must never stop a merge.
669
+ let stampedOld = { approvals, comments, stamped: 0 };
670
+ try {
671
+ stampedOld = stampLegacyLogins({ approvals, comments }, { aliases, clashed, acceptedFor: (a) => acceptedHashes(epicDir, a) });
672
+ } catch (e) {
673
+ warn(`${epic}: older records not stamped — ${e.message}`);
674
+ }
675
+ approvals = stampedOld.approvals;
676
+ comments = stampedOld.comments;
326
677
  const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
327
678
  // Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
328
679
  // it closes, so syncing two passing gates out of chain order rewinds the epic: closing
@@ -338,18 +689,41 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
338
689
  };
339
690
  const targets = [...resolved.targets].sort((a, b) => stepIndex(a) - stepIndex(b));
340
691
  if (!targets.length) {
692
+ // The stamp still lands (E64): an epic with no review PR on file is exactly one no later sync would
693
+ // write, so `yad doctor`'s "run `yad gate sync <epic>`" would otherwise repeat for ever.
694
+ if (stampedOld.stamped && !readOnly) {
695
+ writeJSON(ledger.files.approvals, canonicalApprovals(approvals));
696
+ writeJSON(ledger.files.comments, canonicalComments(comments));
697
+ info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
698
+ }
341
699
  warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
342
700
  hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
343
- return { synced: 0 };
701
+ return { epic, synced: 0, advanced: 0, written: false, gates: [] };
344
702
  }
345
703
  // A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
346
- // bridge mode this run is advisory and writes nothing, so the human never ends up with a gate-state
704
+ // verified mode this run is advisory and writes nothing, so the human never ends up with a gate-state
347
705
  // file in their working tree for the ledger-guard check to reject.
348
706
  if (resolved.discovered && !readOnly) hubPrs = upsertHubPr(hubPrs, targets[0]);
349
707
 
350
708
  let synced = 0;
351
709
  let advanced = 0;
352
- // Targets whose step is still open. The dated approval-roster file is regenerated only for these —
710
+ const gates = [];
711
+ // E71 — said ONCE, before the per-artifact lines, because it is a fact about the PRODUCT and not
712
+ // about any one gate (rule 6: say the arithmetic, not just the verdict). Since E72 it caps the count
713
+ // each gate below ASKS for — reported, and not enforced until E108.
714
+ // E71 — ONE Product-wide count per command, read here: before the per-step loop, so N steps cannot
715
+ // mean N walks of every repo's history, and AFTER the early exits above, so a run that bails out for
716
+ // a missing ledger or no targets never pays for a git walk at all. `today` is the one this command
717
+ // was given, so the count and the records it reports on are measured from the same day.
718
+ //
719
+ // `yad gate ci` is why it can be HANDED IN. Its sweep builds one job per (epic, open review PR) and
720
+ // calls this function once per job, so reading it here would walk every repo once PER PR — the very
721
+ // thing the sentence above promises it does not do. The sweep reads it once and passes it down.
722
+ const people = headCount || activePeople(root, { today: today || undefined, aliases });
723
+ log(` ${c.dim(activeSum(people))}`);
724
+ note(c.dim(activeBasis(people)));
725
+ const reachSeen = new Set(); // E73: each warning line once per command, not once per PR
726
+ // Targets whose step is still open. The dated approval-record file is regenerated only for these —
353
727
  // an already-done step is re-synced for its approvals alone, and would otherwise drop a new
354
728
  // reviews/<artifact>--<today>--approved.md every time the scheduled sweep re-visits it.
355
729
  const open = [];
@@ -357,24 +731,23 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
357
731
  const step = findReviewStep(state, pr.artifact);
358
732
  if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
359
733
  // A step that already advanced is never advanced AGAIN (that would reset the next step's status /
360
- // currentStep backward) — the gate is one-way per step. But it is still SYNCED: in bridge mode
734
+ // currentStep backward) — the gate is one-way per step. But it is still SYNCED: in verified mode
361
735
  // nothing ever moves a step back to in_review (CI is the sole ledger writer), so a re-opened
362
736
  // review — surface re-locked, fresh PR, fresh approvals, merged — used to hit a blanket skip here
363
737
  // and write nothing but the PR pointer. The step then read `done` while its approvals were all
364
738
  // stale: work proceeded on an audit trail saying the re-review never happened (issue #156).
365
- const alreadyDone = step.status === 'done';
366
- const domains = touchedDomains(epicDir, step);
739
+ const alreadyDone = isPassed(step);
367
740
  const pull = reader(platform, pr.number, { cwd: root });
368
741
  // A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
369
742
  // (the wired workflow's reconcile/sweep aggregates this exit) instead of silently not advancing.
370
- if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} — skipping (file-only)`); process.exitCode = 1; continue; }
743
+ if (!pull.ok) { fail(`${pr.artifact}: ${pull.reason} — skipping (local)`); process.exitCode = 1; continue; }
371
744
 
372
745
  const curHash = artifactHash(epicDir, pr.artifact);
373
746
  warnUnlockedContract(epicDir, pr.artifact);
374
747
  warnIncompleteDiscovery(epicDir, pr.artifact);
375
748
  const approvalsBefore = JSON.stringify(approvals);
376
- const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
377
- approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone });
749
+ const recs = mapApprovers(pull.reviews, { headOid: pull.headOid });
750
+ approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone, aliases, clashed, accepted: acceptedHashes(epicDir, pr.artifact) });
378
751
 
379
752
  const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
380
753
  // 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
@@ -396,7 +769,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
396
769
  // That is the same churn the resource_group fix exists to stop, so it must not be reintroduced here.
397
770
  if (!alreadyDone) {
398
771
  if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
399
- comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
772
+ comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, blocking, aliases, clashed });
400
773
  }
401
774
 
402
775
  // Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
@@ -413,25 +786,74 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
413
786
  }
414
787
 
415
788
  const pred = gatePredicate({
416
- step, approvals, currentHash: curHash, touchedDomains: domains,
417
- defaultReviewers, threadsResolved, merged: pull.merged, solo, requireEngagement: reqEng,
789
+ // E72: caps the reported ask at `active − 1`; `null` (not counted) gives no cap. Only the base holds.
790
+ active: people.capacity.active,
791
+ step, approvals, currentHash: curHash, acceptedHashes: acceptedHashes(epicDir, pr.artifact),
792
+ threadsResolved, merged: pull.merged, solo, requireEngagement: reqEng,
793
+ // Which steps may be skipped is a fact about THIS epic's route (E35), so it is resolved from the
794
+ // ledger here rather than from a module-level set that answered the same for every epic.
795
+ optional: optionalStepsFor(state),
418
796
  });
419
797
 
420
- log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
798
+ // Say the arithmetic, not just the verdict (rule 6): the count, the cap when the people were counted,
799
+ // and which half of it holds the gate — only the base until E108 — labelled so nobody reads a number
800
+ // the gate is not enforcing as the reason it did or did not pass.
801
+ // `have: null` is a step whose approvals were never counted (inherited from a parent epic, or
802
+ // skipped): there is no head count to report and no requirement to report either.
803
+ const count = pred.have === null
804
+ ? 'approvals not counted here'
805
+ : `${pred.have} approved; count: ${gateRuleSum(pred.gateRule)}${gateRuleEnforced(pred.gateRule, pred.cap)}${pred.short ? ` — ${pred.short} short` : ''}`;
806
+ log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule}, ${count})`)}`);
807
+ // The --json answer (E1): each gate this run read, as the predicate judged it.
808
+ gates.push({
809
+ step: step.id, artifact: pr.artifact, pr: pr.number ?? null, merged: !!pull.merged, rule: pred.rule,
810
+ passed: !!pred.passed, alreadyDone: !!alreadyDone, have: pred.have, gateRule: pred.gateRule ?? null,
811
+ cap: pred.cap ?? null, short: pred.short ?? null, missing: pred.missing, staleDropped: pred.staleDropped ?? 0,
812
+ });
813
+ // E73: why this gate may not pass — reported only, never enforced. Only on a gate that counted
814
+ // (`rule: 'count'`, so never in solo mode) and is still open: not one already done (`alreadyDone` —
815
+ // a deferred step, or a done step whose approvals went stale, is not reopened by a sync) and not one
816
+ // that passed on this run. A LOCAL sync while the review is open shows it before the merge, when
817
+ // approvals can still come; wired CI does not — it runs only on merged PRs, and usually cannot count
818
+ // people. Each distinct line once per command (`uniqueReach`).
819
+ if (!alreadyDone && !pred.passed && pred.rule === 'count' && pred.have !== null) {
820
+ for (const why of uniqueReach(gateReach(pred.gateRule, pred.cap, { have: pred.have, nameOnly: people.capacity.nameOnly, approvers: approverCount(people), days: people.capacity.days }), reachSeen)) warn(why);
821
+ }
421
822
  if (alreadyDone) {
422
823
  // The step keeps its `done` status and the chain is untouched — re-advancing would reset the
423
824
  // next step, and moving it back to in_review would un-ship work already built on it. What this
424
825
  // pass DOES do is record the approvals that arrived, so `gate status` tells the truth about how
425
826
  // many of them are live against the current artifact.
426
- const verdict = pred.passed ? 'the rule still holds' : `the rule no longer holds${pred.staleDropped ? ` (${pred.staleDropped} stale)` : ''}`;
427
- info(`${step.id} already done — approvals re-synced, chain not re-advanced; ${verdict}`);
428
- for (const m of pred.missing) hand(`recorded gap: ${m}`);
827
+ // A DEFERRED step reads as passed, so it lands here too — but nothing was approved, and "already
828
+ // done … the rule no longer holds" would describe a pass that never happened. Say what is true: the
829
+ // review is still owed, and what it still needs (E37). The approvals themselves are kept either way:
830
+ // a sync of a step that reads as passed only ever adds to its record (`upsertBridge`'s `closed`).
831
+ if (stepStatus(step) === 'deferred') {
832
+ info(`${step.id} is deferred — approvals re-synced, chain not re-advanced; its review is still owed`);
833
+ for (const m of pred.missing) hand(`still owed: ${m}`);
834
+ } else {
835
+ const verdict = pred.passed ? 'the rule still holds' : `the rule no longer holds${pred.staleDropped ? ` (${pred.staleDropped} stale)` : ''}`;
836
+ info(`${step.id} already done — approvals re-synced, chain not re-advanced; ${verdict}`);
837
+ for (const m of pred.missing) hand(`recorded gap: ${m}`);
838
+ }
429
839
  } else if (pred.passed) {
430
- state = advanceState(state, step);
840
+ // The merge is the close (E18): its date, not the day this sweep happened to run, when the platform
841
+ // says so.
842
+ state = advanceState(state, step, {
843
+ by, date: (typeof pull.mergedAt === 'string' && pull.mergedAt.slice(0, 10)) || today,
844
+ pr: pr.number ?? null, commit: pull.mergeCommit || null, hash: curHash, mergedBy: pull.mergedBy || null,
845
+ // Solo mode passed this gate without counting approvals, and the record says so (E10).
846
+ waived: solo ? 'solo' : null,
847
+ // Every cap is recorded (E72): a team gate that passed while the cap LOWERED its ask says so, with
848
+ // the full count, the capped ask and the count of people it read. Only on a gate that COUNTED
849
+ // (`rule: 'count'`): not in solo mode, and not on a skipped or inherited step that passed by its
850
+ // shortcut — nothing was asked there, so nothing was lowered.
851
+ capped: pred.rule === 'count' && pred.cap?.capped ? { needed: pred.gateRule.needed, to: pred.cap.to, active: pred.cap.active } : null,
852
+ });
431
853
  advanced++;
432
854
  ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
433
855
  } else {
434
- state = markInReview(state, step);
856
+ state = markInReview(state, step, { by, date: today, pr: pr.number ?? null, hash: curHash });
435
857
  for (const m of pred.missing) hand(`still needed: ${m}`);
436
858
  }
437
859
  // Stamp when this run actually learned something: an open step every time, and a closed one only
@@ -444,8 +866,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
444
866
  }
445
867
 
446
868
  if (readOnly) {
447
- info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
448
- return { synced, advanced };
869
+ info('verified mode: advisory view — CI owns the ledger, nothing written locally');
870
+ return { epic, synced, advanced, written: false, gates };
449
871
  }
450
872
  // Belt-and-braces: the upserts above already return canonical order, but a ledger this run only
451
873
  // READ (no matching target, or a pre-canonical file written by an older release) still gets sorted
@@ -453,15 +875,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
453
875
  approvals = canonicalApprovals(approvals);
454
876
  comments = canonicalComments(comments);
455
877
  hubPrs = canonicalHubPrs(hubPrs);
878
+ if (stampedOld.stamped) info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
456
879
  writeJSON(ledger.files.approvals, approvals);
457
880
  writeJSON(ledger.files.comments, comments);
458
- writeJSON(ledger.files.hubPrs, hubPrs);
459
- writeJSON(ledger.files.state, state);
460
- refreshRoster(epicDir, open, approvals, today); // the dated side file lists them in the same order
461
- return { synced, advanced };
881
+ writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, hubPrs);
882
+ writeState(ledger.files.state, state);
883
+ refreshApprovalRecord(epicDir, open, approvals, today); // the dated side file lists them in the same order
884
+ // Only a PERSON's sync (`local`): `gateCi` calls this too, and rebuilds the index itself, inside the
885
+ // one allowlisted commit it makes on a merge — and never in its read-only pre-merge run.
886
+ if (local) refreshIndexAfterWrite(root, hub);
887
+ return { epic, synced, advanced, written: true, currentStep: state.currentStep ?? null, gates };
462
888
  }
463
889
 
464
- // `yad gate ci` — the self-sufficient entry point hub CI calls on platform events. Path B: CI
890
+ // `yad gate ci` — the self-sufficient entry point Product CI calls on platform events. Path B: CI
465
891
  // never writes the ledger to the review branch — during review the platform PR/MR is the source of
466
892
  // truth, and the ledger is reconciled onto the default branch at merge.
467
893
  //
@@ -478,47 +904,125 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
478
904
  // CI is the SOLE writer of the ledger and only ever commits to the default branch; humans never
479
905
  // commit gate-state files (enforced by the ledger-guard check). Sweep mode (no --branch) advances
480
906
  // merged-but-stuck reviews found in the locally checked-out default-branch ledgers.
907
+ // Shape 8 on a VERIFIED Product. `yad migrate` refuses to move `epics/EP-discovery/` there, because CI
908
+ // owns the ledger and the ledger guard rejects a person's commit that moves it. The gate bot is the one
909
+ // writer allowed to, so the move happens here — the same rule as every other verified-mode shape change:
910
+ // the gate write IS the migration. Returns the paths it wrote, or null when nothing moved.
911
+ //
912
+ // It refuses, and moves nothing, on everything `yad migrate` refuses (an open review, two product levels,
913
+ // a collision, an unreadable ledger) and on one thing more: checks committed in the repo that predate the
914
+ // Foundation. Moving the ledger under those would put it where CI stops nobody from hand-editing it, so
915
+ // the project keeps the old spelling — which every command still reads — until `yad update` lands.
916
+ //
917
+ // Two refusals are about the CHECKOUT, not the project, and exist for the documented manual recovery
918
+ // (`yad gate ci … --merged` run by a person), since CI's own checkout is always fresh and on the default
919
+ // branch: `dirty` — uncommitted or untracked files under either folder BEFORE this run began (the move
920
+ // copies what is on disk, so they would be committed and pushed to the default branch), and a HEAD that
921
+ // is not the default branch (the push goes to the default branch whatever is checked out).
922
+ export function convertProductLevel(root, hub, { git = (...a) => run('git', a, { cwd: root }), defaultBranch = 'main', dirty = false } = {}) {
923
+ if (!isVerifiedLedger(hub)) return null;
924
+ const move = planProductMove(root, { verified: true, ci: true });
925
+ if (!move) return null;
926
+ const out = (r) => (r?.ok ? String(r.stdout || '').trim() : '');
927
+ const head = out(git('rev-parse', 'HEAD'));
928
+ const onDefault = out(git('rev-parse', '--abbrev-ref', 'HEAD')) === defaultBranch
929
+ || (!!head && head === out(git('rev-parse', '--verify', '-q', `origin/${defaultBranch}`)));
930
+ if (!onDefault) {
931
+ info(`the product level stays in ${move.from}/ — this checkout is not on ${defaultBranch}, and the move is only ever made there`);
932
+ return null;
933
+ }
934
+ if (dirty) {
935
+ warn(`the product level stays in ${move.from}/ — this checkout has uncommitted changes under ${move.from}/ or ${move.to}/, and the move would commit them; commit or discard them first`);
936
+ return null;
937
+ }
938
+ if (move.action !== 'move') {
939
+ info(`the product level stays in ${move.from}/ for now — ${move.detail}`);
940
+ return null;
941
+ }
942
+ const stale = staleFoundationGuards(root);
943
+ if (stale.length) {
944
+ warn(`the product level stays in ${move.from}/ — the wired checks predate the Foundation (${stale.join(', ')}); run \`yad update\` and commit the refreshed checks, and the next gate run moves it`);
945
+ return null;
946
+ }
947
+ try {
948
+ const written = applyProductMove(root, move, { backup: false });
949
+ ok(`the product level moved: ${move.from}/ → ${move.to}/ as ${FOUNDATION_EPIC} (shape 8)`);
950
+ return written;
951
+ } catch (e) {
952
+ // `applyProductMove` has already put everything back. Red, not quiet: nothing here should fail.
953
+ fail(`the product level was not moved — ${e.message}`);
954
+ process.exitCode = 1;
955
+ return null;
956
+ }
957
+ }
958
+
481
959
  export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
482
- const { hub } = loadHub(root);
483
- if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
960
+ const { hub } = loadProduct(root);
961
+ if (!hub?.platform) { warn('no Product platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0, committed: false, pushed: false }; }
484
962
  const git = (...args) => run('git', args, { cwd: root });
485
963
  const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
486
964
  // Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
487
965
  // not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
488
966
 
967
+ // Whether the old product-level folder (or foundation/) held a person's uncommitted work BEFORE this
968
+ // run writes anything — read now, because the sync below legitimately modifies that ledger. Only asked
969
+ // when a move could happen at all; an unreadable status counts as dirty (refuse rather than guess).
970
+ const legacyDirtyBefore = (merged || !branch) && isVerifiedLedger(hub)
971
+ && fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
972
+ ? (() => {
973
+ const st = git('status', '--porcelain', '--untracked-files=all', '--', epicRel(DISCOVERY_EPIC), FOUNDATION_DIR);
974
+ return !st.ok || String(st.stdout || '').trim() !== '';
975
+ })()
976
+ : false;
977
+
489
978
  // Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
490
979
  const jobs = [];
491
980
  if (branch) {
492
981
  const parsed = parseReviewBranch(branch);
493
- if (!parsed) { warn(`${branch} is not a review/EP-*/<artifact> branch — nothing to sync`); return { synced: 0 }; }
494
- jobs.push({ epic: parsed.epic, base: parsed.base, artifact: artifactFromBase(parsed.base), branch, pr });
982
+ if (!parsed) { warn(`${branch} is not a review/EP-*/<artifact> branch — nothing to sync`); return { synced: 0, committed: false, pushed: false }; }
983
+ // A review branch named for the OLD spelling whose ledger has already moved (shape 8) is the
984
+ // Foundation's review: its steps keep `artifact: "discovery/"`, so the job resolves to them there.
985
+ // Without this, that merge finds no ledger at `epics/EP-discovery/` and is dropped.
986
+ const moved8 = parsed.epic === DISCOVERY_EPIC
987
+ && !fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
988
+ && fs.existsSync(path.join(epicRoot(root, FOUNDATION_EPIC), '.sdlc', 'state.json'));
989
+ jobs.push({ epic: moved8 ? FOUNDATION_EPIC : parsed.epic, base: parsed.base, artifact: artifactFromBase(parsed.base), branch, pr });
495
990
  } else {
496
- const epicsDir = path.join(root, 'epics');
497
- for (const e of fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).sort() : []) {
991
+ // `epicIds`, not a listing of `epics/`: the Foundation's ledger lives in `foundation/` (E75), and a
992
+ // sweep that missed it would leave a merged Foundation review stranded, un-advanced, for ever.
993
+ for (const e of epicIds(root)) {
498
994
  // Sweep mode isolates per-epic failures: one corrupt ledger must not block the other epics'
499
995
  // syncs in an unattended CI run. The run still exits non-zero so the bad file gets fixed.
500
996
  let ledger;
501
997
  try {
502
998
  ledger = loadLedger(epicRoot(root, e));
503
999
  } catch (err) {
504
- warn(`${e}: ${err.message} — skipping this epic`);
1000
+ fail(`${e}: ${err.message} — skipping this epic`);
505
1001
  process.exitCode = 1;
506
1002
  continue;
507
1003
  }
508
1004
  if (!ledger.state) continue;
509
1005
  for (const p of ledger.hubPrs || []) {
510
1006
  const step = findReviewStep(ledger.state, p.artifact);
511
- if (!step || step.status === 'done') continue;
1007
+ if (!step || isPassed(step)) continue;
512
1008
  jobs.push({ epic: e, base: base(p.artifact), artifact: p.artifact, branch: p.branch, pr: p.number });
513
1009
  }
514
1010
  }
515
- if (!jobs.length) { info('no open review PRs to sync'); return { synced: 0 }; }
1011
+ // No early return: a sweep with nothing to sync may still have the product level to move (below).
1012
+ if (!jobs.length) info('no open review PRs to sync');
516
1013
  }
517
1014
 
518
1015
  let synced = 0;
519
1016
  const touched = new Set();
1017
+ const failedEpics = new Set();
520
1018
  const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
521
1019
  const statusFiles = new Map(); // epic -> the artifact files syncStatuses rewrote (staging allowlist)
1020
+ // E71 — read ONCE for the whole sweep, and only once there is something to sweep. `gateSync` is
1021
+ // called per job, so letting each call read it would walk the Product and every connected repo's
1022
+ // history once PER open review PR; reading it above the early exits (no platform, an unparseable
1023
+ // branch, no jobs) would pay for a 360-day `git log --all` on every push and throw it away. It is
1024
+ // one fact about the Product, so every job also prints the same number.
1025
+ const sweepCount = jobs.length ? activePeople(root, { today: today || undefined, aliases: legacyLogins(hub) }) : null;
522
1026
  for (const job of jobs) {
523
1027
  const epicDir = epicRoot(root, job.epic);
524
1028
  // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
@@ -527,12 +1031,15 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
527
1031
  ledger = loadLedger(epicDir);
528
1032
  } catch (err) {
529
1033
  if (branch) throw err;
530
- warn(`${job.epic}: ${err.message} — skipping this epic`);
1034
+ fail(`${job.epic}: ${err.message} — skipping this epic`);
531
1035
  process.exitCode = 1;
532
1036
  continue;
533
1037
  }
534
1038
  if (!ledger.state) {
535
- warn(`${job.epic}: no epic state on the checked-out branch — the review branch is cut from the default branch, so it should carry it`);
1039
+ // Red on a named merge: that review's approval would otherwise be dropped by a run that ends green,
1040
+ // and the scheduled reconcile would repeat the same silent no-op for as long as it looks back.
1041
+ (branch && merged ? fail : warn)(`${job.epic}: no epic state on the checked-out branch — the review branch is cut from the default branch, so it should carry it`);
1042
+ if (branch && merged) process.exitCode = 1;
536
1043
  continue;
537
1044
  }
538
1045
  const step = findReviewStep(ledger.state, job.artifact);
@@ -565,7 +1072,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
565
1072
  step: step.id, artifact: job.artifact, platform: hub.platform, number,
566
1073
  url: existing?.url ?? null, branch: job.branch, lastSyncedAt: existing?.lastSyncedAt ?? null,
567
1074
  });
568
- writeJSON(ledger.files.hubPrs, ledger.hubPrs);
1075
+ writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, ledger.hubPrs);
569
1076
  }
570
1077
 
571
1078
  // No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
@@ -574,7 +1081,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
574
1081
  try {
575
1082
  // A branch event that is not a merge can never advance (the predicate requires merged), so it
576
1083
  // is read-only under Path B — run it as a dry sync that persists nothing to the working tree.
577
- const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged });
1084
+ const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged, headCount: sweepCount });
578
1085
  synced += r.synced;
579
1086
  // When the step actually ADVANCED (the merge phase, or a swept merge the schedule observed),
580
1087
  // reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
@@ -589,14 +1096,54 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
589
1096
  }
590
1097
  } catch (err) {
591
1098
  if (branch) throw err; // event mode: one epic — surface the failure
592
- warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
1099
+ fail(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
593
1100
  process.exitCode = 1;
594
1101
  failed = true;
595
1102
  }
596
- if (failed) continue; // a failed epic's partial state must not be committed by this run
1103
+ if (failed) { failedEpics.add(job.epic); continue; } // a failed epic's partial state must not be committed by this run
597
1104
  touched.add(job.epic);
598
1105
  }
599
- if (!touched.size) return { synced };
1106
+ // The product-level move runs AFTER the jobs, so an event for `review/EP-discovery/…` resolved and
1107
+ // advanced its ledger in the old folder first, and only on the DEFAULT branch — a merge run or a sweep,
1108
+ // never a review head, where CI writes nothing (Path B).
1109
+ // E64: the login stamp for every OTHER epic — one with no open review, which the job list never reaches.
1110
+ // An epic this run synced was already stamped by `gateSync`, and a failed one is left alone. It is a
1111
+ // write in its own right, so it can be the reason this run commits, exactly as the product-level move is.
1112
+ // It runs after the jobs, so a job that throws leaves no stamp behind uncommitted. Only on a `--merged`
1113
+ // run — the merge event, and the wired reconcile that re-runs it — never pre-merge (Path B). An epic with
1114
+ // uncommitted changes under `.sdlc` or `reviews` is skipped: the commit below stages both, and must not
1115
+ // carry a person's edit along with the stamp. One epic's unreadable ledger is named and skipped; it does
1116
+ // not stop the review that merged.
1117
+ let stampedCount = 0;
1118
+ if (merged) {
1119
+ const aliases = legacyLogins(hub);
1120
+ const clashed = ambiguousLegacyNames(hub);
1121
+ for (const e of aliases.size ? epicIds(root) : []) {
1122
+ if (touched.has(e) || failedEpics.has(e)) continue;
1123
+ const dirty = git('status', '--porcelain', '--untracked-files=all', '--', path.join(epicRel(e), '.sdlc'), path.join(epicRel(e), 'reviews'));
1124
+ if (!dirty.ok || String(dirty.stdout || '').trim() !== '') continue;
1125
+ const dir = epicRoot(root, e);
1126
+ try {
1127
+ const led = loadLedger(dir);
1128
+ if (!led.state) continue;
1129
+ const st = stampLegacyLogins({ approvals: led.approvals, comments: led.comments }, { aliases, clashed, acceptedFor: (a) => acceptedHashes(dir, a) });
1130
+ if (!st.stamped) continue;
1131
+ const approvalsNow = canonicalApprovals(st.approvals);
1132
+ const commentsNow = canonicalComments(st.comments);
1133
+ if (JSON.stringify(approvalsNow) !== JSON.stringify(canonicalApprovals(led.approvals))) writeJSON(led.files.approvals, approvalsNow);
1134
+ if (JSON.stringify(commentsNow) !== JSON.stringify(canonicalComments(led.comments))) writeJSON(led.files.comments, commentsNow);
1135
+ touched.add(e);
1136
+ stampedCount += st.stamped;
1137
+ info(`${e}: recorded the platform login on ${st.stamped} older approval/comment record(s) that named a roster name`);
1138
+ } catch (err) {
1139
+ warn(`${e}: older records not stamped — ${err.message}`);
1140
+ }
1141
+ }
1142
+ }
1143
+ const moved = (merged || !branch)
1144
+ ? convertProductLevel(root, hub, { git, defaultBranch, dirty: legacyDirtyBefore })
1145
+ : null;
1146
+ if (!touched.size && !moved) return { synced, committed: false, pushed: false };
600
1147
 
601
1148
  // Path B: CI never writes the ledger to the review branch. A held step that did not advance is
602
1149
  // read-only here — during review the platform PR/MR is the source of truth (native approvals/
@@ -604,7 +1151,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
604
1151
  // is what stops an approval from being dismissed and required checks from stranding. Correctness is
605
1152
  // unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
606
1153
  const advancedAny = advancedEpics.size > 0;
607
- if (!merged && !advancedAny) {
1154
+ if (!merged && !advancedAny && !moved) {
608
1155
  // EVENT mode (--branch) pre-merge is read-only (Path B): the gate was evaluated with a dry sync
609
1156
  // that persists nothing, so the one working-tree write is the hub-prs.json seed above (which let
610
1157
  // the dry sync find the PR). Restore exactly that file per epic so the checkout stays clean —
@@ -617,13 +1164,21 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
617
1164
  // and reviews/*.md may be modified and are deliberately left alone: reverting a sync that genuinely
618
1165
  // ran would discard platform state the run just recorded. A bare `yad gate ci` that advances
619
1166
  // nothing therefore leaves those files dirty for the operator to inspect and commit (or discard).
1167
+ // (A sweep that MOVES the product level does not come here: it commits, and those sync writes are
1168
+ // recorded with the move rather than left behind.)
1169
+ // BOTH names of the PR ledger. It is written under its new name and its old one (see
1170
+ // MIRRORED_FILES, cli/manifest.mjs), so restoring only one leaves the other behind as an
1171
+ // untracked file — a read-only run that dirties the checkout, which is exactly what this block
1172
+ // exists to prevent.
620
1173
  for (const e of touched) {
621
- const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
622
- git('checkout', '-q', '--', hp); // restore it if it was tracked
623
- git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
1174
+ for (const name of ['product-prs.json', 'hub-prs.json']) {
1175
+ const hp = path.join(epicRel(e), '.sdlc', name);
1176
+ git('checkout', '-q', '--', hp); // restore it if it was tracked
1177
+ git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
1178
+ }
624
1179
  }
625
1180
  info('pre-merge: gate evaluated; the ledger reconciles on the default branch at merge — nothing pushed');
626
- return { synced };
1181
+ return { synced, committed: false, pushed: false };
627
1182
  }
628
1183
  const target = defaultBranch; // CI only ever commits the ledger to the default branch
629
1184
 
@@ -640,39 +1195,72 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
640
1195
  // straight to the default branch under a `chore(gate)` subject with [skip ci] — unreviewed, and
641
1196
  // contradicting the "CI commits only the ledger" contract every doc in this repo states.
642
1197
  for (const e of touched) {
643
- git('add', '-A', '--', path.join('epics', e, '.sdlc'));
644
- git('add', '-A', '--', path.join('epics', e, 'reviews'));
1198
+ git('add', '-A', '--', path.join(epicRel(e), '.sdlc'));
1199
+ git('add', '-A', '--', path.join(epicRel(e), 'reviews'));
645
1200
  for (const f of statusFiles.get(e) || []) git('add', '--', f);
646
1201
  }
647
- if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
1202
+ // The move: exactly the files it wrote, by name, and the old folder's removal — the same allowlist rule.
1203
+ if (moved) {
1204
+ for (const f of moved) git('add', '--', f);
1205
+ git('add', '-A', '--', epicRel(DISCOVERY_EPIC));
1206
+ }
1207
+ // E19: the Product index, rebuilt from the ledger this run just staged and carried in the same commit —
1208
+ // the one writer, on the default branch, as the user's decision asks. Only here, past the read-only
1209
+ // pre-merge return above, and only when HEAD really IS the default branch: `defaultBranch` above falls
1210
+ // back to the current branch when hub.json names none, and a person may run this from any checkout
1211
+ // (E19 review). `stageIndexIfClean` keeps it out of a commit that does not hold what it read.
1212
+ const onDefault = (() => {
1213
+ const head = git('rev-parse', '--abbrev-ref', 'HEAD');
1214
+ return head.ok && head.stdout === resolveDefaultBranch(git, hub);
1215
+ })();
1216
+ const indexStaged = onDefault ? stageIndexIfClean(root, git) : false;
1217
+ if (!onDefault) info(`${INDEX_FILE} not rebuilt — this checkout is not on the default branch, the only place it is written`);
1218
+ if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced, committed: false, pushed: false }; }
648
1219
  // [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
649
1220
  // sibling workflows. CI never pushes the review branch (Path B), so there is no synchronize loop.
650
- const subject = !branch
651
- ? 'chore(gate): scheduled gate sync [skip ci]' // sweep is a batch; one subject for the run
652
- : `chore(gate): advance ${jobs[0].epic}/${jobs[0].base} on merge [skip ci]`;
653
- const cm = git('commit', '-m', subject);
654
- if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
1221
+ // Only the index staged (the ledger this run wrote was already committed): say that, not an advance.
1222
+ const indexOnly = indexStaged && git('diff', '--cached', '--name-only').stdout.split('\n').filter(Boolean).every((f) => f === INDEX_FILE);
1223
+ const sync = indexOnly
1224
+ ? 'rebuild the Product index'
1225
+ : !branch
1226
+ ? 'scheduled gate sync' // sweep is a batch; one subject for the run
1227
+ : `advance ${jobs[0].epic}/${jobs[0].base} on merge`;
1228
+ // The move, when there is one, is the subject: it is the change a reader of the default branch most
1229
+ // needs to find, and a sync subject would name a folder the same commit deletes. The sync goes in the body.
1230
+ const subject = moved
1231
+ ? `chore(gate): move the product level to ${FOUNDATION_DIR}/ (shape 8) [skip ci]`
1232
+ : `chore(gate): ${sync} [skip ci]`;
1233
+ const cm = git('commit', '-m', subject, ...(moved && touched.size ? ['-m', `Also: ${sync}.`] : []),
1234
+ ...(stampedCount ? ['-m', `Also: recorded the platform login on ${stampedCount} older approval/comment record(s) (E64).`] : []));
1235
+ if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced, committed: false, pushed: false }; }
655
1236
  ok(`committed gate update: ${c.dim(subject)}`);
656
- if (!push) return { synced };
1237
+ if (!push) return { synced, committed: true, pushed: false };
657
1238
 
658
- if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced }; }
1239
+ if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced, committed: true, pushed: true }; }
659
1240
  fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
660
1241
  process.exitCode = 1;
661
- return { synced };
1242
+ return { synced, committed: true, pushed: false };
662
1243
  }
663
1244
 
664
1245
  export async function gateComments(root, { epic, artifact, today, reader = readPr } = {}) {
665
- const { hub } = loadHub(root);
666
- if (!hub?.platform) { warn('no hub platform configured — nothing to fetch'); return; }
1246
+ const { hub } = loadProduct(root);
1247
+ // The --json answer (E1): each review PR, and what still blocks it.
1248
+ const prs = [];
1249
+ if (!hub?.platform) { warn('no Product platform configured — nothing to fetch'); return { epic, prs }; }
667
1250
  const epicDir = epicRoot(root, epic);
668
1251
  const ledger = loadLedger(epicDir);
669
1252
  const targets = (ledger.hubPrs || []).filter((p) => !artifact || p.artifact === artifact);
670
- if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return; }
1253
+ if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return { epic, prs }; }
671
1254
  for (const pr of targets) {
672
1255
  const pull = reader(hub.platform, pr.number, { cwd: root });
673
- if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); continue; }
1256
+ if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); prs.push({ artifact: pr.artifact, pr: pr.number, read: false, reason: pull.reason ?? null }); continue; }
674
1257
  const cr = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
675
1258
  const unresolved = (pull.threads || []).filter((t) => !t.resolved);
1259
+ prs.push({
1260
+ artifact: pr.artifact, pr: pr.number, read: true,
1261
+ changesRequested: cr.map((r) => r.login ?? null),
1262
+ unresolved: unresolved.map((t) => ({ login: t.login ?? null, body: (t.body || '').split('\n')[0] })),
1263
+ });
676
1264
  log(`\n ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number})`)}`);
677
1265
  if (!cr.length && !unresolved.length) { ok('no unresolved comments — clear to approve/merge'); continue; }
678
1266
  for (const r of cr) hand(`${r.login}: changes requested ${c.red('(blocking)')}`);
@@ -683,25 +1271,119 @@ export async function gateComments(root, { epic, artifact, today, reader = readP
683
1271
  ]);
684
1272
  hand('address them in the artifact, reply on the PR, then ask reviewers to resolve their threads');
685
1273
  }
1274
+ return { epic, prs };
686
1275
  }
687
1276
 
688
- export async function gateStatus(root, { epic } = {}) {
1277
+ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
689
1278
  const epicDir = epicRoot(root, epic);
690
1279
  const ledger = loadLedger(epicDir);
691
1280
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
692
- const solo = isSolo(loadHub(root).hub);
693
- log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
1281
+ const { hub } = loadProduct(root);
1282
+ const solo = isSolo(hub);
1283
+ const reqEng = requireEngagement(hub);
1284
+ const optional = optionalStepsFor(ledger.state); // which steps THIS epic's route allows to be skipped
1285
+ // E71 — read ONCE for the whole view, not once per step: it is a Product-wide fact, and a per-step
1286
+ // read would walk every connected repo's history once for every gate on the screen.
1287
+ // NOT `counted`: the per-step loop below already binds that name to this step's engagement-filtered
1288
+ // approvals, and two different meanings of one word in one function is how a later edit reads the
1289
+ // wrong one.
1290
+ // `given` is a count the caller already read, as `gateSync` takes one — the CLI passes none; a test
1291
+ // passes one so the cap (E72) can be seen on a fixture that has no git history of its own.
1292
+ const headCount = given || activePeople(root, { aliases: legacyLogins(hub) });
1293
+ log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${printable(ledger.state.currentStep) ?? '(none)'}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
1294
+ // Printed in solo mode too, exactly as the per-step count is: someone who later switches to team mode
1295
+ // can see the number their gates will be capped against (E72), before it holds anything for them.
1296
+ log(` ${c.dim(activeSum(headCount))}`);
1297
+ note(c.dim(activeBasis(headCount)));
1298
+ // E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
1299
+ // An unknown count is already said by `activeSum` just above, so only a known suggestion prints.
1300
+ printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
1301
+ const reachSeen = new Set(); // E73: each warning line once per view, not once per step
1302
+ const gates = []; // the --json answer (E1): each review step as this view reads it
694
1303
  for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
695
- const cur = artifactHash(epicDir, s.artifact);
696
- const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash && cur && a.artifactHash !== cur));
697
- const stale = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && a.artifactHash && cur && a.artifactHash !== cur).length;
1304
+ const accepted = acceptedHashes(epicDir, s.artifact);
1305
+ const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !isStaleHash(a.artifactHash, accepted));
1306
+ const stale = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && isStaleHash(a.artifactHash, accepted)).length;
698
1307
  const tags = `${isEscalated(s) ? ', escalated' : ''}${stale ? `, ${stale} stale (revoked)` : ''}`;
699
- log(` ${s.status === 'done' ? c.green('✓') : c.yellow('•')} ${s.id} ${c.dim(`— ${s.status}, ${live.length} approval(s)${tags}`)}`);
1308
+ // E7's count, per step, from the step's own risk tags, capped by E72 when the people were counted —
1309
+ // only its base holds the gate until E108, and it is labelled so.
1310
+ // Distinct PEOPLE, which is why it can differ from the approval count beside it: two approvals from
1311
+ // one person are one approver. Printed in solo mode too, where approvals are waived, so a reader who
1312
+ // later switches to team mode can see what each gate will then ask for.
1313
+ //
1314
+ // COUNTED THE WAY THE PREDICATE COUNTS. With `requireEngagement` on, an approval carrying no
1315
+ // verified engagement signal is dropped BEFORE people are counted, so a line built from every live
1316
+ // approval would report a head count the engine does not recognise. The dropped ones are named
1317
+ // rather than hidden, because "two people approved and neither counts" is the fact a reader needs.
1318
+ //
1319
+ // NO COUNT ON A WAIVED STEP. An inherited step's approvals live under the parent epic and a skipped
1320
+ // step was never reviewed, so printing what the count asks for would read as an audit failure on a
1321
+ // step the engine deliberately waives.
1322
+ const counted = reqEng ? live.filter((a) => a.engagement === 'verified') : live;
1323
+ const unengaged = live.length - counted.length;
1324
+ const people = new Set(counted.filter((a) => typeof a.approver === 'string' && a.approver.trim()).map((a) => a.approver)).size; // as gatePredicate counts
1325
+ const from = `from ${people} ${peopleWord(people)}${unengaged ? `, ${unengaged} not engagement-verified (not counted)` : ''}`;
1326
+ // A `skipped` flag is honoured here on exactly the terms `gatePredicate` honours it: only on a step
1327
+ // THIS epic's route marks optional (`isSkippableStep`). Without that guard a hand-edited
1328
+ // `skipped: true` on a required step would read as waived in `gate status` while `gate sync` fell
1329
+ // through to the real rule — two read-only views of one ledger disagreeing, and the misleading one
1330
+ // is the view a human checks first.
1331
+ // `claimsInherited` / `claimsSkipped`, not the canonical state: this note mirrors what
1332
+ // `gatePredicate` honours, and the predicate reads the claim. Reading the canonical state here
1333
+ // instead would make `gate status` and `gate sync` disagree about a half-stamped step — two
1334
+ // read-only views of one ledger, and a human checks this one first.
1335
+ const state = stepStatus(s);
1336
+ const waived = claimsInherited(s)
1337
+ ? `; inherited from ${printable(s.inheritedFrom) ?? 'the parent epic'}`
1338
+ : (claimsSkipped(s) && isSkippableStep(s.id, optional)) ? '; skipped (N/A)'
1339
+ : (state === 'deferred' && isSkippableStep(s.id, optional)) ? `; deferred (still owed${s.debt === true ? ', as debt' : ''})` : '';
1340
+ // The shortfall, the same number `gatePredicate` returns as `short`: against the capped count when
1341
+ // the people were counted, the full count when they were not. Printed here because this is the
1342
+ // surface people read when they want to know where a gate stands, and a count with no distance to it
1343
+ // is half the fact.
1344
+ const rule = gateRuleFor(s);
1345
+ const cap = gateCapFor(rule, headCount.capacity.active);
1346
+ const short = Math.max(0, (cap ? cap.to : rule.needed) - people);
1347
+ const count = waived || `; count: ${gateRuleSum(rule)}${gateRuleEnforced(rule, cap)}${short && !solo ? ` — ${short} short` : ''}`;
1348
+ // The CANONICAL state, not the raw field: a pre-shape-7 chain says `blocked` where it means
1349
+ // `todo`, and printing the file's word in the one view people read to see where a gate stands
1350
+ // would make the old vocabulary outlive the model. A status this release cannot name falls back
1351
+ // to the raw string with a mark, because inventing a name for it would hide the finding
1352
+ // `yad doctor` reports as `step:unknown-status`.
1353
+ // A deferred step lets the chain continue, but nobody has reviewed it: a green tick beside "still
1354
+ // owed" would read as reviewed, so it keeps the open-step mark (E37).
1355
+ // A debt being paid back (E41) is no longer `deferred`, so the tag above does not show; say it here,
1356
+ // until the review passes and clears the flag.
1357
+ const paying = s.debt === true && state !== 'deferred' ? '; owed as debt — being paid back' : '';
1358
+ log(` ${isPassed(s) && state !== 'deferred' ? c.green('✓') : c.yellow('•')} ${printable(s.id) ?? '(no id)'} ${c.dim(`— ${state || `${printable(s.status) ?? 'no state'} (unknown)`}, ${live.length} approval(s) ${from}${tags}${count}${paying}`)}`);
1359
+ if (s.closed && typeof s.closed === 'object' && !Array.isArray(s.closed)) log(` ${c.dim(closedLine(s.closed))}`);
1360
+ // E73: why this gate may not pass — reported only, never enforced. Not in solo mode, not on a waived
1361
+ // (inherited, skipped, deferred) step, and not on one that passed. Every other review step counts,
1362
+ // including one not reached yet — so a line about the whole Product (the base, one person) prints
1363
+ // once, under the first step it applies to (`uniqueReach`), not under every step after it.
1364
+ const reach = [];
1365
+ if (!solo && !waived && !isPassed(s)) {
1366
+ for (const why of uniqueReach(gateReach(rule, cap, { have: people, nameOnly: headCount.capacity.nameOnly, approvers: approverCount(headCount), days: headCount.capacity.days }), reachSeen)) {
1367
+ reach.push(why);
1368
+ log(` ${c.yellow('!')} ${c.dim(why)}`);
1369
+ }
1370
+ }
1371
+ gates.push({
1372
+ step: s.id ?? null, artifact: s.artifact ?? null, state: state || null, passed: isPassed(s),
1373
+ approvals: live.length, people, stale, notEngaged: unengaged, escalated: isEscalated(s),
1374
+ // No count on a waived step, as the prose prints none (`have: null` is E7's "nothing counted here").
1375
+ waived: waived ? (claimsInherited(s) ? 'inherited' : claimsSkipped(s) ? 'skipped' : 'deferred') : null,
1376
+ gateRule: waived ? null : rule, cap: waived ? null : (cap ?? null), short: waived ? null : short,
1377
+ debt: s.debt === true, closed: s.closed && typeof s.closed === 'object' && !Array.isArray(s.closed) ? s.closed : null, reach,
1378
+ });
700
1379
  }
1380
+ // The count, not the people: the names behind it are `yad history`'s and the platform's to show.
1381
+ const { days, from, active, basis } = headCount.capacity;
1382
+ return { epic, currentStep: ledger.state.currentStep ?? null, solo, active: { active, days, from, basis, unknown: headCount.unknown }, gates };
701
1383
  }
702
1384
 
703
1385
  // PURE — the audit-trail commit message for a state repair (mirrors buildCheckpointMessage). The
704
- // subject passes the hub commit-message gate (valid type `chore`, scope `gate`, no trailing period)
1386
+ // subject passes the Product commit-message gate (valid type `chore`, scope `gate`, no trailing period)
705
1387
  // and carries [skip ci]: the repair lands on the default branch, where a re-triggered gate workflow
706
1388
  // would have nothing to do. No Task trailer and no Co-Authored-By footer — this is machine state a
707
1389
  // human corrected, not an authored code change.
@@ -719,70 +1401,98 @@ export function buildRepairMessage({ epic, steps }) {
719
1401
  // sweep up an unrelated edit. It lands on the DEFAULT branch, where `ledger-guard` (which polices the
720
1402
  // machine-written ledger on review PRs) does not apply — so this stays compatible with "CI is the sole
721
1403
  // writer of the ledger" during review.
722
- export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false } = {}) {
1404
+ export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false, today = new Date().toISOString().slice(0, 10) } = {}) {
723
1405
  const epicDir = epicRoot(root, epic);
724
1406
  const ledger = loadLedger(epicDir);
725
- if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { closed: [] }; }
1407
+ if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, closed: [], dryRun, written: false, committed: false, pushed: false }; }
726
1408
 
727
1409
  log(c.bold(`\nyad gate repair ${c.dim(epic)}`));
728
1410
  const violations = stateInvariants(ledger.state);
729
- if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { closed: [] }; }
1411
+ if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { epic, closed: [], dryRun, written: false, committed: false, pushed: false }; }
730
1412
  for (const v of violations) warn(`${v.message} [${v.code}]`);
731
1413
 
732
- const closed = repairState(ledger.state);
733
- if (dryRun) { info('dry run — nothing written'); return { closed }; }
734
- writeJSON(ledger.files.state, ledger.state);
1414
+ // Read leniently: a repair heals a broken ledger, and a broken hub.json must not stop it (E18).
1415
+ const closed = repairState(ledger.state, { by: closingActor(root, readJSON(productConfigPath(root), null)), date: today });
1416
+ // The --json answer (E1): what this run did, so a dry run and a real one never read the same.
1417
+ const did = (written, committed = false, pushed = false) => ({ epic, closed, dryRun, written, committed, pushed });
1418
+ if (dryRun) { info('dry run — nothing written'); return did(false); }
1419
+ writeState(ledger.files.state, ledger.state);
1420
+ // Lenient, as above: a broken hub.json reads as a local Product, and must not stop the repair. With
1421
+ // --push the rebuild happens in `stageIndexIfClean` below, which checks the commit will hold what it
1422
+ // reads — rebuilding here too would leave a half-true index on disk when it cannot (E19 review).
1423
+ const indexRebuilt = push ? false : refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null));
735
1424
  ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
736
- if (!push) { hand('re-run `yad doctor` to confirm, then commit epics/*/.sdlc/state.json (or re-run with --push)'); return { closed }; }
1425
+ if (!push) {
1426
+ hand(`re-run \`yad doctor\` to confirm, then commit epics/*/.sdlc/state.json${indexRebuilt ? ` and ${INDEX_FILE}` : ''} (or re-run with --push)`);
1427
+ return did(true);
1428
+ }
737
1429
 
738
1430
  // --- publish: narrow, default-branch-only commit of the one repaired file ---
739
1431
  preflightGuardReadiness(root);
740
- const git = hubGit(root);
1432
+ const git = productGit(root);
741
1433
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
742
- const defaultBranch = resolveDefaultBranch(git, loadHub(root).hub);
743
- if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return { closed };
1434
+ const defaultBranch = resolveDefaultBranch(git, loadProduct(root).hub);
1435
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return did(true);
744
1436
 
745
1437
  const spec = path.relative(root, ledger.files.state);
746
- if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return { closed }; }
747
- if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return { closed }; }
1438
+ if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return did(true); }
1439
+ if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return did(true); }
1440
+ // The index rides the same narrow commit (E19): it summarizes this very file — when the working tree
1441
+ // holds nothing else it reads (`stageIndexIfClean`), on the default branch only (`--allow-branch`
1442
+ // commits the repair elsewhere, and the index is never written on a branch), and never on a verified
1443
+ // Product, where CI is the index's one writer.
1444
+ const specs = [spec, ...(branch === defaultBranch && !isVerifiedLedger(readJSON(productConfigPath(root), null)) && stageIndexIfClean(root, git, [spec]) ? [INDEX_FILE] : [])];
748
1445
 
749
1446
  const message = buildRepairMessage({ epic, steps: closed });
750
- const cm = git('commit', '-m', message, '--', spec);
1447
+ const cm = git('commit', '-m', message, '--', ...specs);
751
1448
  if (!cm.ok) {
752
- git('reset', '-q', '--', spec); // never leave it staged for an unrelated commit to sweep up
1449
+ git('reset', '-q', '--', ...specs); // never leave them staged for an unrelated commit to sweep up
753
1450
  fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
754
1451
  process.exitCode = 1;
755
- return { closed };
1452
+ return did(true);
756
1453
  }
757
1454
  ok(`committed the repair: ${c.dim(message.split('\n')[0])}`);
758
1455
  // Push HEAD to its OWN branch — with --allow-branch we are not on the default branch, and pushing
759
1456
  // HEAD:defaultBranch would publish a WIP branch straight to it.
760
- if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { closed }; }
1457
+ if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return did(true, true, true); }
761
1458
  fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
762
1459
  hand('run `git pull --rebase` and re-run `yad gate repair <epic> --push`');
763
1460
  process.exitCode = 1;
764
- return { closed };
1461
+ return did(true, true, false);
765
1462
  }
766
1463
 
767
1464
  // `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
768
1465
  // the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
769
1466
  // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
770
1467
  // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
771
- export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists } = {}) {
772
- const { hub, repos } = loadHub(root);
1468
+ export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists, today = new Date().toISOString().slice(0, 10) } = {}) {
1469
+ const { hub } = loadProduct(root);
773
1470
  const epicDir = epicRoot(root, epic);
774
1471
  const ledger = loadLedger(epicDir);
775
1472
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
776
1473
  if (!artifact) { fail('artifact is required: `yad gate open <epic> <artifact>`'); process.exitCode = 1; return; }
777
1474
  const step = findReviewStep(ledger.state, artifact);
778
1475
  if (!step) { fail(`no review step for ${artifact}`); process.exitCode = 1; return; }
1476
+ // The step's own spelling from here on: `stories` and `stories/` name one review, and a pointer
1477
+ // recorded under the other spelling would be a second pointer that nothing stamps or replaces.
1478
+ artifact = step.artifact || artifact;
1479
+ // A step SET ASIDE — skipped or deferred — has no review to open. The chain already walks past it, so
1480
+ // `markInReview` would leave the ledger alone while the PR opened anyway: a live review of a step the
1481
+ // chain has walked past, which no merge can advance. Put the step back first (E37).
1482
+ const aside = stepStatus(step);
1483
+ if (aside === 'skipped' || aside === 'deferred') {
1484
+ fail(`${step.id} is ${aside} — there is no review to open`);
1485
+ hand(`put it back in the chain first: yad ${aside === 'skipped' ? 'unskip' : 'undefer'} ${epic} ${step.id.replace(/-review$/, '')}`);
1486
+ process.exitCode = 1;
1487
+ return;
1488
+ }
779
1489
  const b = base(artifact);
780
1490
  const branch = head || `review/${epic}/${b}`;
781
1491
  const domains = touchedDomains(epicDir, step);
782
1492
  warnUnlockedContract(epicDir, artifact);
783
1493
  warnIncompleteDiscovery(epicDir, artifact);
784
1494
 
785
- const bridge = isBridge(hub);
1495
+ const verified = isVerifiedLedger(hub);
786
1496
  // The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
787
1497
  // pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
788
1498
  // local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
@@ -804,69 +1514,119 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
804
1514
  if (present === null) warn(`could not verify that '${branch}' is on origin — opening the PR against it anyway`);
805
1515
  }
806
1516
 
807
- // Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
808
- // ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
809
- if (!bridge) {
810
- ledger.state = markInReview(ledger.state, step);
811
- writeJSON(ledger.files.state, ledger.state);
1517
+ // Outside verified mode (local, OR a platform with no gate-sync CI) there is no CI to write the
1518
+ // ledger, so the local command marks the step in_review. In verified mode CI is the sole writer.
1519
+ // The author step this run closes, if any: its record gets the PR number once the PR exists (below).
1520
+ const author = ledger.state.steps.find((s) => s?.type === 'author' && s.artifact === step.artifact && s.id !== step.id);
1521
+ const closesAuthor = !verified && !!author && !isPassed(author) && !author.closed;
1522
+ if (!verified) {
1523
+ ledger.state = markInReview(ledger.state, step, { by: closingActor(root, hub), date: today, hash: artifactHash(epicDir, step.artifact) });
1524
+ writeState(ledger.files.state, ledger.state);
1525
+ refreshIndexAfterWrite(root, hub);
812
1526
  }
813
1527
  if (!hub?.platform) {
814
- warn('no hub platform — marked in_review file-only (no PR opened)');
1528
+ warn('no Product platform — marked in_review locally (no PR opened)');
815
1529
  ok(`${step.id} → in_review`);
816
- return;
1530
+ return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: true };
817
1531
  }
818
1532
 
819
- // Open the PR. In bridge mode CI records the hub-prs entry (and advances) on the default branch at
1533
+ // Open the PR. In verified mode CI records the hub-prs entry (and advances) on the default branch at
820
1534
  // merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
821
- // CI writes nothing pre-merge. Without the bridge, the local command records the PR itself (no CI will).
822
- const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
823
- // Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
824
- // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
825
- // to review their own artifact). Scope is the hub plus every touched domain.
826
- const committer = resolveCommitterLogin(root, hub.roster || []);
827
- const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer, repos });
1535
+ // CI writes nothing pre-merge. With a local ledger the local command records the PR itself (no CI will).
1536
+ const body = fillHubTemplate({
1537
+ epic, artifact, step, owner: ownerOf(epicDir), domains,
1538
+ active: activePeople(root, { aliases: legacyLogins(hub) }).capacity.active,
1539
+ // Does this epic's ROUTE have an architecture step? Asked of the recorded route and never of the
1540
+ // chain (`routeLacksStep`): a truncated legacy chain has no `architecture` row and is not on a
1541
+ // short lane, and telling its reviewer in writing that it is would be a false claim in a record
1542
+ // people act on. This repo's own e2e fixture is exactly that shape.
1543
+ hasArchitecture: !routeLacksStep(ledger.state, 'architecture'),
1544
+ });
1545
+ // Assignee = whoever opens the review PR: `@me` on GitHub, which `gh` resolves on the right host
1546
+ // (buildPrArgs sends it when no assignee is named), and the login `glab` reports on GitLab.
1547
+ // NO REVIEWERS ARE REQUESTED (E62): they used to come from the roster's reviewer and domain-owner
1548
+ // roles, and there is no stored list to pick them from any more. The team requests them on the PR,
1549
+ // and E68 will suggest them from history. Said on the way out, so nobody waits for a request that
1550
+ // was never sent.
1551
+ const committer = hub.platform === 'gitlab' ? platformLogin(root, hub.platform) : null;
828
1552
  const assignees = committer ? [committer] : [];
829
- const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
1553
+ const labels = domains.map((d) => `domain:${d}`); // empty unless the step names its repos (touchedDomains)
830
1554
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
831
- const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
832
- if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
833
- // Surface routing: who was assigned as a reviewer, who was @-mentioned (GitLab field cap), and any
834
- // login the platform could not add (dropped) so a partial roster is visible, not silent.
835
- if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
836
- if (r.dropped?.length) warn(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
1555
+ const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, assignees, labels, cwd: root });
1556
+ if (!r.ok) {
1557
+ warn(`could not open PR (${r.reason || 'unknown'})${verified ? ' — open it manually; CI records the gate on merge' : '; step is in_review locally'}`);
1558
+ return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: !verified, reason: r.reason || null };
1559
+ }
837
1560
 
838
- if (!bridge) {
839
- ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null, url: r.url, branch, lastSyncedAt: null });
840
- writeJSON(ledger.files.hubPrs, ledger.hubPrs);
1561
+ if (!verified) {
1562
+ // The PR number from its path segment — the first number in the URL can be the repo (`acme/2048/pull/9`).
1563
+ const opened = Number(prNumberFromUrl(r.url)) || null;
1564
+ // A re-opened review replaces the pointer. Stamp the OLD number on this step's approvals that predate
1565
+ // PR provenance first, exactly as `gateCi` does at merge: once the pointer names the new PR, the next
1566
+ // sync would stamp THAT number on them, so an approval given again on the new PR could never be told
1567
+ // from a re-read of the old one and, on GitLab (whose older records hold no submission time), would stay
1568
+ // stale for good.
1569
+ const previous = (ledger.hubPrs || []).find((x) => x.artifact === artifact)?.number ?? null;
1570
+ // Also when the new URL carries no number: the old pointer is about to be overwritten either way.
1571
+ if (previous != null && previous !== opened && stampLegacyPr(ledger.approvals, step.id, previous)) {
1572
+ writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
1573
+ }
1574
+ ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: opened, url: r.url, branch, lastSyncedAt: null });
1575
+ writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, ledger.hubPrs);
1576
+ // The record was written before the PR existed, and the first close wins, so no later sync can add
1577
+ // the number. Add it here, to the record this run wrote and to nothing older (E18).
1578
+ const number = ledger.hubPrs.find((p) => p.artifact === artifact)?.number ?? null;
1579
+ const closedHere = closesAuthor ? ledger.state.steps.find((s) => s.id === author.id) : null;
1580
+ if (number != null && closedHere?.closed?.via === 'review-opened' && closedHere.closed.pr == null) {
1581
+ closedHere.closed = closingRecord({ ...closedHere.closed, pr: number });
1582
+ writeState(ledger.files.state, ledger.state);
1583
+ refreshIndexAfterWrite(root, hub);
1584
+ }
841
1585
  }
842
1586
  ok(`opened ${r.url}`);
843
- hand(bridge
1587
+ hand('no reviewers were requested — ask them on the PR itself');
1588
+ hand(verified
844
1589
  ? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
845
1590
  : `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
846
- return { url: r.url };
1591
+ return { epic, step: step.id, artifact, branch, opened: true, url: r.url, markedInReview: !verified };
847
1592
  }
848
1593
 
849
1594
  // `yad gate review <epic> [artifact]` — assemble + print the grounding bundle the companion skill uses
850
1595
  // to generate the 60-sec trailer / swipe cards and to run the grounded chat (artifact + risk tags +
851
1596
  // contract + PR + repo code-maps). The CLI never calls an LLM; the skill (yad-review-companion)
852
1597
  // consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
853
- // Build (but don't print) the front-half grounding bundle. Shared by `review` and `walkthrough` so the
1598
+ // Assemble (but don't print) the Shape grounding bundle. Shared by `review` and `walkthrough` so the
854
1599
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
855
1600
  // Returns { error } when there is no epic state, else { bundle, epicDir, hub }.
856
- function reviewBundle(root, { epic, artifact } = {}) {
857
- const { hub, repos } = loadHub(root);
1601
+ function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
1602
+ const { hub, repos } = loadProduct(root);
858
1603
  const epicDir = epicRoot(root, epic);
859
1604
  const ledger = loadLedger(epicDir);
860
1605
  if (!ledger.state) return { error: `no epic state at ${epicDir}` };
861
1606
  const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
862
1607
  const art = artifact || pr?.artifact || null;
863
1608
  const step = art ? findReviewStep(ledger.state, art) : null;
1609
+ // E71 — the live capacity count. Its own read, and still once per command: `reviewBundle` has exactly
1610
+ // two callers, `gate review` and `gate walkthrough`, and neither runs alongside `gate status` or
1611
+ // `gate sync`. Read before the bundle because the step's cap (E72) is computed from it. `headCount`
1612
+ // is a count the caller already read, as `gateSync` and `gateStatus` take one — the CLI passes none.
1613
+ const counted = headCount || activePeople(root, { aliases: legacyLogins(hub) });
864
1614
  const bundle = {
865
1615
  epic,
866
1616
  artifact: art,
867
1617
  platform: hub?.platform || null,
868
1618
  pr: pr ? { number: pr.number, url: pr.url } : null,
869
- step: step ? { id: step.id, riskTags: step.risk_tags || [], escalated: isEscalated(step) } : null,
1619
+ // `gateRule` is E7's per-step rule — the number of distinct approvers the count asks for and the
1620
+ // arithmetic behind it. `escalated` says the step carries a risk tag that raises the count. `cap`
1621
+ // is E72's `gateCapFor` object for the count read above, or null when the people could not be
1622
+ // counted — then no cap applies and only the base holds.
1623
+ step: step
1624
+ ? { id: step.id, riskTags: step.risk_tags || [], escalated: isEscalated(step), gateRule: gateRuleFor(step), cap: gateCapFor(gateRuleFor(step), counted.capacity.active) }
1625
+ : null,
1626
+ // E71 — the live capacity count, as an OBJECT and never as the sentence, so a consumer reads the
1627
+ // number rather than parsing prose (the same discipline `gateRule` follows). `active: null` means a
1628
+ // source could not be read, which is NOT the same fact as "few people": `unknown` says which.
1629
+ activePeople: { active: counted.capacity.active, windowDays: counted.capacity.days, basis: counted.capacity.basis, unknown: counted.unknown },
870
1630
  artifactPath: art ? path.join(epicDir, art) : null,
871
1631
  contractPath: art && base(art) === 'architecture' ? path.join(epicDir, 'contract.md') : null,
872
1632
  touchedDomains: step ? touchedDomains(epicDir, step) : [],
@@ -883,19 +1643,19 @@ function reviewBundle(root, { epic, artifact } = {}) {
883
1643
  return { bundle, epicDir, hub };
884
1644
  }
885
1645
 
886
- export async function gateReview(root, { epic, artifact } = {}) {
887
- const r = reviewBundle(root, { epic, artifact });
888
- if (r.error) { fail(r.error); process.exitCode = 1; return; }
889
- log(JSON.stringify(r.bundle, null, 2));
1646
+ export async function gateReview(root, { epic, artifact, headCount = null } = {}) {
1647
+ const r = reviewBundle(root, { epic, artifact, headCount });
1648
+ if (r.error) { refuse(r.error, null, { json: true }); return; }
1649
+ emitJSON(r.bundle);
890
1650
  return r.bundle;
891
1651
  }
892
1652
 
893
- // `yad gate walkthrough <epic> [artifact]` — the front-half pair-review grounding: the same bundle PLUS
1653
+ // `yad gate walkthrough <epic> [artifact]` — the Shape pair-review grounding: the same bundle PLUS
894
1654
  // an ordered `stops[]` from the artifact's review diff (highest-risk first). The skill (yad-pair-review)
895
1655
  // walks the stops and runs the two-way teaching session. Deterministic sequencing only — no LLM here.
896
1656
  export async function gateWalkthrough(root, { epic, artifact, runner = run } = {}) {
897
1657
  const r = reviewBundle(root, { epic, artifact });
898
- if (r.error) { fail(r.error); process.exitCode = 1; return; }
1658
+ if (r.error) { refuse(r.error, null, { json: true }); return; }
899
1659
  const { bundle, hub } = r;
900
1660
  const defaultBranch = hub?.default_branch || 'main';
901
1661
  let stops = [];
@@ -905,11 +1665,11 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
905
1665
  if (diff.ok && diff.stdout.trim()) {
906
1666
  stops = sequenceDiff(diff.stdout, { contractPath: bundle.contractPath });
907
1667
  } else if (!diff.ok) {
908
- note(`could not read the artifact diff (${defaultBranch}...HEAD) in ${root} — is the review branch checked out and the base correct?`);
1668
+ { const w = `could not read the artifact diff (${defaultBranch}...HEAD) in ${root} — is the review branch checked out and the base correct?`; collectWarning(w); note(w); }
909
1669
  }
910
1670
  }
911
1671
  const out = { ...bundle, stops };
912
- log(JSON.stringify(out, null, 2));
1672
+ emitJSON(out);
913
1673
  // Diagnostics to STDERR so STDOUT stays pure JSON (the skill / e2e parse it).
914
1674
  if (!stops.length) note('no stops from the artifact diff — walk the artifact by section (see yad-pair-review)');
915
1675
  return out;
@@ -919,26 +1679,35 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
919
1679
  // briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
920
1680
  // delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
921
1681
  export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
922
- const { hub } = loadHub(root);
923
- if (!hub?.platform) { warn('no hub platform configured — the trailer posts to the PR/MR (file-only has none)'); return; }
1682
+ const { hub } = loadProduct(root);
1683
+ if (!hub?.platform) { warn('no Product platform configured — the trailer posts to the PR/MR (local has none)'); return { number: null, posted: false }; }
924
1684
  if (!body || !String(body).trim()) { fail('trailer body is required: `yad gate trailer <epic> <artifact> --body <text>` (the companion generates it)'); process.exitCode = 1; return; }
925
1685
  const epicDir = epicRoot(root, epic);
926
1686
  const ledger = loadLedger(epicDir);
927
1687
  const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
928
1688
  const n = number || pr?.number;
929
- if (!n) { warn('no PR number — pass `--pr <n>` (in bridge mode the PR is recorded in the ledger only at merge)'); return; }
1689
+ if (!n) { warn('no PR number — pass `--pr <n>` (in verified mode the PR is recorded in the ledger only at merge)'); return { number: null, posted: false }; }
930
1690
  const cur = getBody(hub.platform, n, { cwd: root });
931
1691
  if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
932
1692
  const r = editBody(hub.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
933
1693
  if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
934
1694
  ok(`trailer posted to PR #${n}`);
935
- return { number: n };
1695
+ return { number: n, posted: true };
936
1696
  }
937
1697
 
938
1698
  // ---- helpers ------------------------------------------------------------------------------------
939
1699
  const base = (artifact) => artifactBase(artifact);
940
1700
 
941
- export function fillHubTemplate({ epic, artifact, step, owner, domains }) {
1701
+ // `active` is the caller's already-read count (E71, `activePeople`), or null when it did not read one.
1702
+ // Passed in rather than read here for the same reason the predicate takes it: this builds a string and
1703
+ // must stay callable from a test without a Product on disk.
1704
+ export function fillHubTemplate({ epic, artifact, step, owner, domains, hasArchitecture = true, active = null }) {
1705
+ const rule = gateRuleFor(step);
1706
+ const cap = gateCapFor(rule, active);
1707
+ // What the count asks for. Only the base holds (until E108); the cap (E72) is shown when it lowered the
1708
+ // ask, dated, because this body is written once and the count can differ when the gate decides.
1709
+ const capped = cap?.capped ? `, capped to ${cap.to} for ${cap.active} active ${peopleWord(cap.active)} when this PR was opened` : '';
1710
+ const needed = `${rule.base} (enforced) · full count ${gateRuleSum(rule)}${capped}${rule.riskStep ? ' (the risk step is advisory)' : ''}`;
942
1711
  return [
943
1712
  '## Artifact under review',
944
1713
  `- Epic: \`${epic}\``,
@@ -949,23 +1718,47 @@ export function fillHubTemplate({ epic, artifact, step, owner, domains }) {
949
1718
  '## Impact & Risk (front-half)',
950
1719
  `- **Domains / repos touched:** ${domains.join(', ') || 'n/a'}`,
951
1720
  `- **Risk tags:** ${(step.risk_tags || []).join(', ') || 'none'}`,
1721
+ // What the count asks for, stated on the artifact people are about to review rather than left for
1722
+ // them to discover later (rule 6). See `needed` above.
1723
+ `- **Approvals needed:** ${needed}`,
1724
+ // E71 — how many people could give those approvals, stated beside the ask so a reviewer can see a
1725
+ // gate that asks for more people than the team has BEFORE they start. E72 caps the reported ask with it.
1726
+ //
1727
+ // THIS IS THE ONE SURFACE WHERE THE COUNT BECOMES A LASTING RECORD. Everywhere else it is printed
1728
+ // live and gone; a PR description is written once, at `gate open`, and read for as long as the PR
1729
+ // exists. The number can be different an hour later, and on a different machine: `gate open` run
1730
+ // from a laptop that has not cloned the connected repos reads "not counted" and would leave that
1731
+ // word in the body for good. So the line dates itself. It does NOT go quiet on an unknown (Part 3),
1732
+ // it just says WHEN it could not count, which is the only honest thing a frozen line can say.
1733
+ `- **Active people:** ${active === null ? 'not counted when this PR was opened (an unreadable source is never read as few people — `yad gate status` counts it live)' : `${active} when this PR was opened (${rule.riskStep && cap ? `the cap is ${cap.limit} (${capSeat(active)}), so this gate's count is ${cap.to}; ` : ''}\`yad gate status\` counts it live)`}`,
952
1734
  '',
953
1735
  '## How to review (this drives the gate)',
954
1736
  '- **Approve** to record your approval; **comment / request changes** to hold the gate.',
955
1737
  '- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
956
1738
  '',
957
- // Required by the hub `pr-template` gate (check_hub_body). Mirrors the Checklist block of the
1739
+ // Required by the Product `pr-template` gate (check_hub_body). Mirrors the Checklist block of the
958
1740
  // committed static template (yad-pr-template/templates/hub/<platform>/) so the generated body
959
1741
  // passes on the first CI run.
960
1742
  '## Checklist',
961
1743
  '- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)',
962
- '- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)',
963
- '- [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)',
1744
+ // The contract item asks about re-locking a surface. An epic on a short lane (E40) has no
1745
+ // architecture step and therefore no `contract.md` and no lock, ever — so the box can never be
1746
+ // ticked and never needs to be. A checklist carrying an item nobody on this route can act on
1747
+ // teaches people to tick without reading, which costs more than the line is worth. Replaced
1748
+ // rather than dropped, so the reviewer is told the surface is out of scope instead of finding a
1749
+ // gap where a contract line used to be. `hasArchitecture` defaults true: every caller outside
1750
+ // this file predates the flag, and the classic wording is the safe answer for an unknown chain.
1751
+ hasArchitecture
1752
+ ? '- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)'
1753
+ : '- [ ] Contract surface unchanged — this epic is on a short lane with no architecture gate, so it may consume the shared surface but never change it',
1754
+ '- [ ] Risk tags reflect the real surface touched (contract/auth/payments raise the approval count)',
964
1755
  '- [ ] No secrets or tokens in the artifact or this description',
965
1756
  ].join('\n');
966
1757
  }
967
1758
 
968
- function refreshRoster(epicDir, targets, approvals, today) {
1759
+ // The dated, human-readable approval record beside the ledger. An older approval still carries the
1760
+ // role the roster gave it (E62 left those fields on disk), and it is printed as recorded.
1761
+ function refreshApprovalRecord(epicDir, targets, approvals, today) {
969
1762
  for (const pr of targets) {
970
1763
  const stepApprovals = approvals.filter((a) => a.step === pr.step && a.status === 'approved');
971
1764
  const file = path.join(epicDir, 'reviews', `${base(pr.artifact)}--${today}--approved.md`);
@@ -973,7 +1766,7 @@ function refreshRoster(epicDir, targets, approvals, today) {
973
1766
  const lines = [
974
1767
  `# Approval record — ${pr.artifact} — ${today}`, '',
975
1768
  '## Approved by',
976
- ...stepApprovals.map((a) => `- ${a.approver} — ${a.role}${a.domain ? ` (${a.domain})` : ''} — approved ${a.date}${a.source ? ` (${a.source})` : ''}`),
1769
+ ...stepApprovals.map((a) => `- ${a.approver}${a.role ? ` — ${a.role}` : ''}${a.domain ? ` (${a.domain})` : ''} — approved ${a.date}${a.source ? ` (${a.source})` : ''}`),
977
1770
  '',
978
1771
  ];
979
1772
  fs.writeFileSync(file, lines.join('\n') + '\n');