yadflow 4.0.0-next.3 → 4.0.0

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 (128) hide show
  1. package/CHANGELOG.md +699 -0
  2. package/README.md +85 -22
  3. package/bin/commands.mjs +3 -1
  4. package/bin/yad.mjs +183 -29
  5. package/cli/capture.mjs +4 -2
  6. package/cli/checkpoint.mjs +10 -10
  7. package/cli/claims.mjs +4 -4
  8. package/cli/commit.mjs +48 -12
  9. package/cli/docs.mjs +15 -13
  10. package/cli/doctor.mjs +557 -141
  11. package/cli/epic-state.mjs +41 -18
  12. package/cli/errors.mjs +3 -2
  13. package/cli/find-product.mjs +197 -0
  14. package/cli/fold.mjs +4 -4
  15. package/cli/gate-local.mjs +13 -13
  16. package/cli/gate.mjs +111 -103
  17. package/cli/history.mjs +13 -13
  18. package/cli/hook.mjs +201 -30
  19. package/cli/index-command.mjs +6 -6
  20. package/cli/lib.mjs +91 -29
  21. package/cli/manifest.mjs +171 -104
  22. package/cli/migrate.mjs +208 -17
  23. package/cli/mode.mjs +22 -22
  24. package/cli/next.mjs +6 -6
  25. package/cli/openpr.mjs +32 -24
  26. package/cli/people.mjs +22 -10
  27. package/cli/plan.mjs +718 -45
  28. package/cli/platform.mjs +13 -7
  29. package/cli/product-index.mjs +4 -4
  30. package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
  31. package/cli/protection.mjs +19 -9
  32. package/cli/reconcile.mjs +98 -23
  33. package/cli/repo-publish.mjs +22 -16
  34. package/cli/repo.mjs +128 -33
  35. package/cli/report.mjs +4 -2
  36. package/cli/review.mjs +26 -19
  37. package/cli/setup.mjs +105 -36
  38. package/cli/ship.mjs +1 -1
  39. package/cli/skip.mjs +4 -4
  40. package/cli/tidy.mjs +7 -7
  41. package/cli/update-commit.mjs +22 -19
  42. package/cli/workspace.mjs +495 -0
  43. package/package.json +7 -4
  44. package/skills/sdlc/config.yaml +14 -13
  45. package/skills/sdlc/module-help.csv +8 -8
  46. package/skills/yad-analysis/SKILL.md +6 -4
  47. package/skills/yad-architecture/SKILL.md +4 -3
  48. package/skills/yad-backfill/SKILL.md +11 -5
  49. package/skills/yad-backfill/references/backfill.md +33 -3
  50. package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
  51. package/skills/yad-change/SKILL.md +1 -1
  52. package/skills/yad-checks/SKILL.md +48 -27
  53. package/skills/yad-checks/references/check-gates.md +213 -53
  54. package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
  55. package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
  56. package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
  57. package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
  58. package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
  59. package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
  60. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
  61. package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
  62. package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
  63. package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
  64. package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
  65. package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
  66. package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
  67. package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
  68. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
  69. package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
  70. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
  71. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
  72. package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
  73. package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
  74. package/skills/yad-commit/SKILL.md +18 -7
  75. package/skills/yad-connect-design/SKILL.md +1 -1
  76. package/skills/yad-connect-design/references/design-registry.md +1 -1
  77. package/skills/yad-connect-docs/SKILL.md +16 -11
  78. package/skills/yad-connect-docs/references/docs-registry.md +8 -8
  79. package/skills/yad-connect-learning/SKILL.md +1 -1
  80. package/skills/yad-connect-learning/references/learning-registry.md +1 -1
  81. package/skills/yad-connect-repos/SKILL.md +31 -8
  82. package/skills/yad-connect-repos/references/code-context.md +1 -1
  83. package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
  84. package/skills/yad-connect-repos/references/repos-registry.md +1 -1
  85. package/skills/yad-connect-testing/SKILL.md +1 -1
  86. package/skills/yad-connect-testing/references/testing-registry.md +1 -1
  87. package/skills/yad-discovery/SKILL.md +2 -2
  88. package/skills/yad-discovery/references/discovery-schema.md +1 -1
  89. package/skills/yad-docs/SKILL.md +1 -1
  90. package/skills/yad-docs/references/data-mapping.md +1 -1
  91. package/skills/yad-docs/templates/app/package-lock.json +8 -8
  92. package/skills/yad-docs/templates/app/package.json +1 -1
  93. package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
  94. package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
  95. package/skills/yad-engineer-review/SKILL.md +2 -2
  96. package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
  97. package/skills/yad-epic/SKILL.md +6 -4
  98. package/skills/yad-epic/references/state-schema.md +13 -12
  99. package/skills/yad-open-pr/SKILL.md +1 -1
  100. package/skills/yad-pair-review/SKILL.md +5 -5
  101. package/skills/yad-pair-review/references/session-state.md +2 -2
  102. package/skills/yad-pr-template/SKILL.md +14 -12
  103. package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
  104. package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
  105. package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
  106. package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
  107. package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
  108. package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
  109. package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
  110. package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
  111. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
  112. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
  113. package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
  114. package/skills/yad-report/SKILL.md +1 -1
  115. package/skills/yad-review-companion/SKILL.md +3 -3
  116. package/skills/yad-review-gate/SKILL.md +22 -23
  117. package/skills/yad-review-gate/references/gating.md +13 -13
  118. package/skills/yad-run/SKILL.md +2 -2
  119. package/skills/yad-run/references/run-loop.md +2 -2
  120. package/skills/yad-spec/SKILL.md +2 -1
  121. package/skills/yad-spec/references/spec-handoff.md +19 -10
  122. package/skills/yad-stories/SKILL.md +2 -2
  123. package/skills/yad-sync-repos/SKILL.md +19 -3
  124. package/skills/yad-test-cases/SKILL.md +2 -2
  125. package/skills/yad-ui/SKILL.md +2 -2
  126. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
  127. package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
  128. package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/gate.mjs CHANGED
@@ -7,17 +7,18 @@ import path from 'node:path';
7
7
  import {
8
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, isVerifiedLedger , productConfigPath } from './manifest.mjs';
10
+ import { OWNING_COMMIT_ENV } from './hook.mjs';
11
+ import { PROJECT_FILES, isVerifiedLedger , productConfigPath, settingsEditHint } from './manifest.mjs';
11
12
  import {
12
13
  epicIds, epicRel, epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, acceptedHashes, isStaleHash, gatePredicate, printable,
13
14
  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,
15
+ upsertProductPr, stateInvariants, repairState, DISCOVERY_FILES, FOUNDATION_REQUIRED, unwrittenSections,
16
+ canonicalApprovals, canonicalComments, canonicalProductPrs, optionalStepsFor, isSkippableStep, writeState, routeLacksStep,
16
17
  isPassed, stepStatus, claimsSkipped, claimsInherited, DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC, staleFoundationGuards,
17
18
  } from './epic-state.mjs';
18
19
  import { activePeople, activeSum, activeBasis, approverCount, printTeamHint, soloTeamHint } from './people.mjs';
19
20
  import { applyProductMove, planProductMove } from './migrate.mjs';
20
- import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
21
+ import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './productcommit.mjs';
21
22
  import {
22
23
  readPr, mapApprovers, createPr, platformLogin, actorName, ambiguousLegacyNames, prNumberFromUrl,
23
24
  getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
@@ -32,8 +33,8 @@ import { err } from './errors.mjs';
32
33
  // (`actorName`). On CI that is usually the bot's git name — a job token cannot read `/user`. Best-effort,
33
34
  // like every record's `by`: attribution never blocks a gate. Kept here rather than imported from
34
35
  // skip.mjs, which imports this file.
35
- function closingActor(root, hub) {
36
- return actorName(root, hub?.platform);
36
+ function closingActor(root, productConfig) {
37
+ return actorName(root, productConfig?.platform);
37
38
  }
38
39
 
39
40
  // One line for a step's closing record — `yad gate status` prints it under a review step (E18), and
@@ -145,38 +146,38 @@ export function warnIncompleteDiscovery(epicDir, artifact) {
145
146
  // Fail fast on a corrupt or wrong-shape Product config: a silently-defaulted hub.json would degrade
146
147
  // every gate to local without anyone noticing, and a typo'd platform would read as a local ledger.
147
148
  export function loadProduct(root) {
148
- const hubFile = productConfigPath(root);
149
+ const productFile = productConfigPath(root);
149
150
  const regFile = path.join(root, PROJECT_FILES.reposRegistry);
150
151
  // Distinguish an ABSENT hub.json (null default → fine, local gate) from one that exists but
151
152
  // holds literal `null` (malformed — must not silently downgrade to local).
152
- const hub = readJSONStrict(hubFile, null);
153
- if (hub === null && fs.existsSync(hubFile)) {
154
- throw err('YAD-STATE-002', `${hubFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
153
+ const productConfig = readJSONStrict(productFile, null);
154
+ if (productConfig === null && fs.existsSync(productFile)) {
155
+ throw err('YAD-STATE-002', `${productFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
155
156
  }
156
- if (hub !== null) {
157
- if (typeof hub !== 'object' || Array.isArray(hub)) throw err('YAD-STATE-002', `${hubFile}: expected a JSON object`, 'fix the file or re-run `yad setup`');
158
- if (![null, undefined, 'github', 'gitlab'].includes(hub.platform)) {
159
- throw err('YAD-CFG-001', `${hubFile}: unknown platform '${hub.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
157
+ if (productConfig !== null) {
158
+ if (typeof productConfig !== 'object' || Array.isArray(productConfig)) throw err('YAD-STATE-002', `${productFile}: expected a JSON object`, 'fix the file or re-run `yad setup`');
159
+ if (![null, undefined, 'github', 'gitlab'].includes(productConfig.platform)) {
160
+ throw err('YAD-CFG-001', `${productFile}: unknown platform '${productConfig.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
160
161
  }
161
162
  }
162
163
  const registry = readJSONStrict(regFile, { repos: [] });
163
164
  if (!Array.isArray(registry?.repos)) throw err('YAD-STATE-002', `${regFile}: expected a \`repos\` array`, 'fix the file or re-run `yad setup`');
164
- return { hub, repos: registry.repos };
165
+ return { productConfig, repos: registry.repos };
165
166
  }
166
167
 
167
168
  // Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
168
169
  // PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
169
170
  // merge advances the step). Recorded per-project in hub.json by `yad setup`.
170
- export const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
171
+ export const isSolo = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
171
172
 
172
173
  // Verified mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
173
174
  // defined once in manifest.mjs (`isVerifiedLedger`) and shared with plan.mjs's wiring and the ledger
174
175
  // hook, so no two readers can disagree about who owns the ledger (#186).
175
176
 
176
- // requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
177
+ // requireEngagement (config `review.requireEngagement` in the Product settings): when on, the predicate counts only
177
178
  // approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
178
179
  // but is recorded `engagement: none` and draws the friendly nudge.
179
- export const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement === true));
180
+ export const requireEngagement = (productConfig) => !!(productConfig && (productConfig.review?.requireEngagement === true));
180
181
 
181
182
  // `legacyLogins` LIVES in cli/epic-state.mjs now (E71): the active-people reader needs the same table to
182
183
  // recognise an older record, and importing it from here would have made a cycle (gate.mjs prints the
@@ -566,8 +567,8 @@ function recordComments(comments, { artifact, stepId, today, blocking, aliases =
566
567
  // the PR for this artifact's review branch. Without that confirmation a typo'd number naming some
567
568
  // unrelated merged-and-approved PR would have its reviewers bound to this artifact's hash and satisfy
568
569
  // the gate.
569
- function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd }) {
570
- const recorded = hubPrs.filter((p) => !artifact || p.artifact === artifact);
570
+ function resolveTargets(productPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd }) {
571
+ const recorded = productPrs.filter((p) => !artifact || p.artifact === artifact);
571
572
  const named = number == null || number === '' ? null : Number(number);
572
573
  if (named !== null && (!Number.isInteger(named) || named <= 0)) {
573
574
  return { targets: [], discovered: false, reason: `--pr must be a positive integer, got '${number}'` };
@@ -577,7 +578,7 @@ function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finde
577
578
  const step = findReviewStep(state, artifact);
578
579
  if (!step) return { targets: [], discovered: false, reason: `no review step for ${artifact}` };
579
580
  const branch = `review/${epic}/${base(artifact)}`;
580
- // `upsertHubPr` replaces the whole entry for an artifact, so a record built from scratch DROPS
581
+ // `upsertProductPr` replaces the whole entry for an artifact, so a record built from scratch DROPS
581
582
  // whatever the recorded one carried. That matters when `--pr` names the PR already on file: `nudged`
582
583
  // is the idempotency set for the engagement nudge, so losing it makes the next writer run
583
584
  // re-@-mention every bare approver on the PR — a platform write, not just a ledger one — and `url`
@@ -633,31 +634,31 @@ function stageIndexIfClean(root, git, commits = null) {
633
634
  }
634
635
 
635
636
  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: [] }; }
638
- const platform = hub.platform;
639
- const aliases = legacyLogins(hub);
640
- const clashed = ambiguousLegacyNames(hub);
641
- const solo = isSolo(hub);
642
- const reqEng = requireEngagement(hub);
637
+ const { productConfig } = loadProduct(root);
638
+ if (!productConfig?.platform) { warn(`no Product platform configured (${settingsEditHint(root)}) — local gate, nothing to sync`); return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
639
+ const platform = productConfig.platform;
640
+ const aliases = legacyLogins(productConfig);
641
+ const clashed = ambiguousLegacyNames(productConfig);
642
+ const solo = isSolo(productConfig);
643
+ const reqEng = requireEngagement(productConfig);
643
644
  // Local invocation in verified mode is ADVISORY: CI is the sole ledger writer, so a human run reads
644
645
  // the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
645
646
  // With a local ledger (platform but no gate-sync CI) the local command stays the writer.
646
647
  // dryRun forces the same read-only behavior regardless of the ledger — used for the Path B pre-merge
647
648
  // evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
648
- const readOnly = (local && isVerifiedLedger(hub)) || dryRun;
649
+ const readOnly = (local && isVerifiedLedger(productConfig)) || dryRun;
649
650
  // 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);
651
+ const by = readOnly ? null : closingActor(root, productConfig);
651
652
  const epicDir = epicRoot(root, epic);
652
653
  const ledger = loadLedger(epicDir);
653
654
  if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
654
655
 
655
- let { approvals, comments, hubPrs, state } = ledger;
656
+ let { approvals, comments, productPrs, state } = ledger;
656
657
  // Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
657
658
  // so it can never be told apart from one arriving on a replacement PR — and on GitLab, with no
658
659
  // submittedAt either, the other proof is unavailable too. The pointer recorded here IS the PR those
659
660
  // approvals came from, so stamp them before anything replaces it.
660
- for (const p of hubPrs) {
661
+ for (const p of productPrs) {
661
662
  const s = p.number != null ? findReviewStep(state, p.artifact) : null;
662
663
  if (s) stampLegacyPr(approvals, s.id, p.number);
663
664
  }
@@ -674,14 +675,14 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
674
675
  }
675
676
  approvals = stampedOld.approvals;
676
677
  comments = stampedOld.comments;
677
- const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
678
+ const resolved = resolveTargets(productPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
678
679
  // Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
679
680
  // it closes, so syncing two passing gates out of chain order rewinds the epic: closing
680
681
  // architecture-review first (next: ui-design) and epic-review second (next: architecture) reopens the
681
682
  // already-done `architecture` author step and points currentStep backward — the YAD-STATE-005 chain
682
683
  // inconsistency `yad gate repair` exists to undo. This used to hold only by accident, because
683
684
  // hub-prs.json happened to be in insertion order; now that the file is written sorted by artifact
684
- // (see canonicalHubPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
685
+ // (see canonicalProductPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
685
686
  // either way — it always names a single artifact.
686
687
  const stepIndex = (p) => {
687
688
  const s = findReviewStep(state, p.artifact);
@@ -703,7 +704,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
703
704
  // A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
704
705
  // verified mode this run is advisory and writes nothing, so the human never ends up with a gate-state
705
706
  // file in their working tree for the ledger-guard check to reject.
706
- if (resolved.discovered && !readOnly) hubPrs = upsertHubPr(hubPrs, targets[0]);
707
+ if (resolved.discovered && !readOnly) productPrs = upsertProductPr(productPrs, targets[0]);
707
708
 
708
709
  let synced = 0;
709
710
  let advanced = 0;
@@ -874,16 +875,16 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
874
875
  // here, so the first sweep after the upgrade converges the file once and never churns it again.
875
876
  approvals = canonicalApprovals(approvals);
876
877
  comments = canonicalComments(comments);
877
- hubPrs = canonicalHubPrs(hubPrs);
878
+ productPrs = canonicalProductPrs(productPrs);
878
879
  if (stampedOld.stamped) info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
879
880
  writeJSON(ledger.files.approvals, approvals);
880
881
  writeJSON(ledger.files.comments, comments);
881
- writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, hubPrs);
882
+ writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, productPrs);
882
883
  writeState(ledger.files.state, state);
883
884
  refreshApprovalRecord(epicDir, open, approvals, today); // the dated side file lists them in the same order
884
885
  // Only a PERSON's sync (`local`): `gateCi` calls this too, and rebuilds the index itself, inside the
885
886
  // one allowlisted commit it makes on a merge — and never in its read-only pre-merge run.
886
- if (local) refreshIndexAfterWrite(root, hub);
887
+ if (local) refreshIndexAfterWrite(root, productConfig);
887
888
  return { epic, synced, advanced, written: true, currentStep: state.currentStep ?? null, gates };
888
889
  }
889
890
 
@@ -919,8 +920,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
919
920
  // branch: `dirty` — uncommitted or untracked files under either folder BEFORE this run began (the move
920
921
  // copies what is on disk, so they would be committed and pushed to the default branch), and a HEAD that
921
922
  // 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;
923
+ export function convertProductLevel(root, productConfig, { git = (...a) => run('git', a, { cwd: root }), defaultBranch = 'main', dirty = false } = {}) {
924
+ if (!isVerifiedLedger(productConfig)) return null;
924
925
  const move = planProductMove(root, { verified: true, ci: true });
925
926
  if (!move) return null;
926
927
  const out = (r) => (r?.ok ? String(r.stdout || '').trim() : '');
@@ -957,17 +958,17 @@ export function convertProductLevel(root, hub, { git = (...a) => run('git', a, {
957
958
  }
958
959
 
959
960
  export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
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 }; }
961
+ const { productConfig } = loadProduct(root);
962
+ if (!productConfig?.platform) { warn(`no Product platform configured (${settingsEditHint(root)}) — nothing to sync`); return { synced: 0, committed: false, pushed: false }; }
962
963
  const git = (...args) => run('git', args, { cwd: root });
963
- const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
964
+ const defaultBranch = productConfig.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
964
965
  // Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
965
966
  // not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
966
967
 
967
968
  // Whether the old product-level folder (or foundation/) held a person's uncommitted work BEFORE this
968
969
  // run writes anything — read now, because the sync below legitimately modifies that ledger. Only asked
969
970
  // 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
+ const legacyDirtyBefore = (merged || !branch) && isVerifiedLedger(productConfig)
971
972
  && fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
972
973
  ? (() => {
973
974
  const st = git('status', '--porcelain', '--untracked-files=all', '--', epicRel(DISCOVERY_EPIC), FOUNDATION_DIR);
@@ -1002,7 +1003,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1002
1003
  continue;
1003
1004
  }
1004
1005
  if (!ledger.state) continue;
1005
- for (const p of ledger.hubPrs || []) {
1006
+ for (const p of ledger.productPrs || []) {
1006
1007
  const step = findReviewStep(ledger.state, p.artifact);
1007
1008
  if (!step || isPassed(step)) continue;
1008
1009
  jobs.push({ epic: e, base: base(p.artifact), artifact: p.artifact, branch: p.branch, pr: p.number });
@@ -1022,7 +1023,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1022
1023
  // history once PER open review PR; reading it above the early exits (no platform, an unparseable
1023
1024
  // branch, no jobs) would pay for a 360-day `git log --all` on every push and throw it away. It is
1024
1025
  // 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;
1026
+ const sweepCount = jobs.length ? activePeople(root, { today: today || undefined, aliases: legacyLogins(productConfig) }) : null;
1026
1027
  for (const job of jobs) {
1027
1028
  const epicDir = epicRoot(root, job.epic);
1028
1029
  // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
@@ -1047,7 +1048,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1047
1048
 
1048
1049
  // The merge event may fire before any hub-prs record exists (Path B never wrote one pre-merge) —
1049
1050
  // build the entry from the event itself so the advance commit carries it onto the default branch.
1050
- const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
1051
+ const existing = (ledger.productPrs || []).find((x) => x.artifact === job.artifact);
1051
1052
  const number = Number(job.pr) || existing?.number || null;
1052
1053
  // Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
1053
1054
  // recorded before replacing it, or a re-review on the replacement PR can never be told from a
@@ -1068,11 +1069,11 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1068
1069
  }
1069
1070
  }
1070
1071
  if (!existing || existing.number !== number || existing.branch !== job.branch) {
1071
- ledger.hubPrs = upsertHubPr(ledger.hubPrs, {
1072
- step: step.id, artifact: job.artifact, platform: hub.platform, number,
1072
+ ledger.productPrs = upsertProductPr(ledger.productPrs, {
1073
+ step: step.id, artifact: job.artifact, platform: productConfig.platform, number,
1073
1074
  url: existing?.url ?? null, branch: job.branch, lastSyncedAt: existing?.lastSyncedAt ?? null,
1074
1075
  });
1075
- writeMirrored(ledger.files.productPrs, ledger.files.hubPrs, ledger.hubPrs);
1076
+ writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, ledger.productPrs);
1076
1077
  }
1077
1078
 
1078
1079
  // No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
@@ -1116,8 +1117,8 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1116
1117
  // not stop the review that merged.
1117
1118
  let stampedCount = 0;
1118
1119
  if (merged) {
1119
- const aliases = legacyLogins(hub);
1120
- const clashed = ambiguousLegacyNames(hub);
1120
+ const aliases = legacyLogins(productConfig);
1121
+ const clashed = ambiguousLegacyNames(productConfig);
1121
1122
  for (const e of aliases.size ? epicIds(root) : []) {
1122
1123
  if (touched.has(e) || failedEpics.has(e)) continue;
1123
1124
  const dirty = git('status', '--porcelain', '--untracked-files=all', '--', path.join(epicRel(e), '.sdlc'), path.join(epicRel(e), 'reviews'));
@@ -1141,7 +1142,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1141
1142
  }
1142
1143
  }
1143
1144
  const moved = (merged || !branch)
1144
- ? convertProductLevel(root, hub, { git, defaultBranch, dirty: legacyDirtyBefore })
1145
+ ? convertProductLevel(root, productConfig, { git, defaultBranch, dirty: legacyDirtyBefore })
1145
1146
  : null;
1146
1147
  if (!touched.size && !moved) return { synced, committed: false, pushed: false };
1147
1148
 
@@ -1211,7 +1212,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1211
1212
  // (E19 review). `stageIndexIfClean` keeps it out of a commit that does not hold what it read.
1212
1213
  const onDefault = (() => {
1213
1214
  const head = git('rev-parse', '--abbrev-ref', 'HEAD');
1214
- return head.ok && head.stdout === resolveDefaultBranch(git, hub);
1215
+ return head.ok && head.stdout === resolveDefaultBranch(git, productConfig);
1215
1216
  })();
1216
1217
  const indexStaged = onDefault ? stageIndexIfClean(root, git) : false;
1217
1218
  if (!onDefault) info(`${INDEX_FILE} not rebuilt — this checkout is not on the default branch, the only place it is written`);
@@ -1230,29 +1231,32 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
1230
1231
  const subject = moved
1231
1232
  ? `chore(gate): move the product level to ${FOUNDATION_DIR}/ (shape 8) [skip ci]`
1232
1233
  : `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).`] : []));
1234
+ // The owning command's own ledger commit: the git pre-commit guard (E48) refuses HAND commits to these
1235
+ // files, so it is told this one is not. In CI no clone has that hook; a person re-running this locally does.
1236
+ const cm = run('git', ['commit', '-m', subject, ...(moved && touched.size ? ['-m', `Also: ${sync}.`] : []),
1237
+ ...(stampedCount ? ['-m', `Also: recorded the platform login on ${stampedCount} older approval/comment record(s) (E64).`] : [])],
1238
+ { cwd: root, env: OWNING_COMMIT_ENV() });
1235
1239
  if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced, committed: false, pushed: false }; }
1236
1240
  ok(`committed gate update: ${c.dim(subject)}`);
1237
1241
  if (!push) return { synced, committed: true, pushed: false };
1238
1242
 
1239
1243
  if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced, committed: true, pushed: true }; }
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`);
1244
+ fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-product-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
1241
1245
  process.exitCode = 1;
1242
1246
  return { synced, committed: true, pushed: false };
1243
1247
  }
1244
1248
 
1245
1249
  export async function gateComments(root, { epic, artifact, today, reader = readPr } = {}) {
1246
- const { hub } = loadProduct(root);
1250
+ const { productConfig } = loadProduct(root);
1247
1251
  // The --json answer (E1): each review PR, and what still blocks it.
1248
1252
  const prs = [];
1249
- if (!hub?.platform) { warn('no Product platform configured — nothing to fetch'); return { epic, prs }; }
1253
+ if (!productConfig?.platform) { warn('no Product platform configured — nothing to fetch'); return { epic, prs }; }
1250
1254
  const epicDir = epicRoot(root, epic);
1251
1255
  const ledger = loadLedger(epicDir);
1252
- const targets = (ledger.hubPrs || []).filter((p) => !artifact || p.artifact === artifact);
1256
+ const targets = (ledger.productPrs || []).filter((p) => !artifact || p.artifact === artifact);
1253
1257
  if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return { epic, prs }; }
1254
1258
  for (const pr of targets) {
1255
- const pull = reader(hub.platform, pr.number, { cwd: root });
1259
+ const pull = reader(productConfig.platform, pr.number, { cwd: root });
1256
1260
  if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); prs.push({ artifact: pr.artifact, pr: pr.number, read: false, reason: pull.reason ?? null }); continue; }
1257
1261
  const cr = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
1258
1262
  const unresolved = (pull.threads || []).filter((t) => !t.resolved);
@@ -1278,9 +1282,9 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
1278
1282
  const epicDir = epicRoot(root, epic);
1279
1283
  const ledger = loadLedger(epicDir);
1280
1284
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
1281
- const { hub } = loadProduct(root);
1282
- const solo = isSolo(hub);
1283
- const reqEng = requireEngagement(hub);
1285
+ const { productConfig } = loadProduct(root);
1286
+ const solo = isSolo(productConfig);
1287
+ const reqEng = requireEngagement(productConfig);
1284
1288
  const optional = optionalStepsFor(ledger.state); // which steps THIS epic's route allows to be skipped
1285
1289
  // E71 — read ONCE for the whole view, not once per step: it is a Product-wide fact, and a per-step
1286
1290
  // read would walk every connected repo's history once for every gate on the screen.
@@ -1289,7 +1293,7 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
1289
1293
  // wrong one.
1290
1294
  // `given` is a count the caller already read, as `gateSync` takes one — the CLI passes none; a test
1291
1295
  // 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) });
1296
+ const headCount = given || activePeople(root, { aliases: legacyLogins(productConfig) });
1293
1297
  log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${printable(ledger.state.currentStep) ?? '(none)'}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
1294
1298
  // Printed in solo mode too, exactly as the per-step count is: someone who later switches to team mode
1295
1299
  // can see the number their gates will be capped against (E72), before it holds anything for them.
@@ -1297,7 +1301,7 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
1297
1301
  note(c.dim(activeBasis(headCount)));
1298
1302
  // E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
1299
1303
  // An unknown count is already said by `activeSum` just above, so only a known suggestion prints.
1300
- printTeamHint(soloTeamHint(root, hub, { solo, headCount }));
1304
+ printTeamHint(soloTeamHint(root, productConfig, { solo, headCount }));
1301
1305
  const reachSeen = new Set(); // E73: each warning line once per view, not once per step
1302
1306
  const gates = []; // the --json answer (E1): each review step as this view reads it
1303
1307
  for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
@@ -1423,7 +1427,10 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
1423
1427
  const indexRebuilt = push ? false : refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null));
1424
1428
  ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
1425
1429
  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)`);
1430
+ // On a verified Product a hand commit of state.json is what the git pre-commit guard refuses (E48), and
1431
+ // that refusal sends the person back here: so the way on is `--push`, whose commit is the owning one.
1432
+ if (isVerifiedLedger(readJSON(productConfigPath(root), null))) hand('re-run `yad doctor` to confirm, then re-run with --push — on a verified Product that commit is the one yad makes; a hand commit of state.json is refused');
1433
+ else hand(`re-run \`yad doctor\` to confirm, then commit epics/*/.sdlc/state.json${indexRebuilt ? ` and ${INDEX_FILE}` : ''} (or re-run with --push)`);
1427
1434
  return did(true);
1428
1435
  }
1429
1436
 
@@ -1431,8 +1438,8 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
1431
1438
  preflightGuardReadiness(root);
1432
1439
  const git = productGit(root);
1433
1440
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
1434
- const defaultBranch = resolveDefaultBranch(git, loadProduct(root).hub);
1435
- if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return did(true);
1441
+ const defaultBranch = resolveDefaultBranch(git, loadProduct(root).productConfig);
1442
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair', root })) return did(true);
1436
1443
 
1437
1444
  const spec = path.relative(root, ledger.files.state);
1438
1445
  if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return did(true); }
@@ -1444,7 +1451,8 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
1444
1451
  const specs = [spec, ...(branch === defaultBranch && !isVerifiedLedger(readJSON(productConfigPath(root), null)) && stageIndexIfClean(root, git, [spec]) ? [INDEX_FILE] : [])];
1445
1452
 
1446
1453
  const message = buildRepairMessage({ epic, steps: closed });
1447
- const cm = git('commit', '-m', message, '--', ...specs);
1454
+ // `yad gate repair` IS the command the git pre-commit guard (E48) sends a person to, so its commit passes it.
1455
+ const cm = run('git', ['commit', '-m', message, '--', ...specs], { cwd: root, env: OWNING_COMMIT_ENV() });
1448
1456
  if (!cm.ok) {
1449
1457
  git('reset', '-q', '--', ...specs); // never leave them staged for an unrelated commit to sweep up
1450
1458
  fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
@@ -1466,7 +1474,7 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
1466
1474
  // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
1467
1475
  // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
1468
1476
  export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists, today = new Date().toISOString().slice(0, 10) } = {}) {
1469
- const { hub } = loadProduct(root);
1477
+ const { productConfig } = loadProduct(root);
1470
1478
  const epicDir = epicRoot(root, epic);
1471
1479
  const ledger = loadLedger(epicDir);
1472
1480
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
@@ -1492,7 +1500,7 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1492
1500
  warnUnlockedContract(epicDir, artifact);
1493
1501
  warnIncompleteDiscovery(epicDir, artifact);
1494
1502
 
1495
- const verified = isVerifiedLedger(hub);
1503
+ const verified = isVerifiedLedger(productConfig);
1496
1504
  // The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
1497
1505
  // pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
1498
1506
  // local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
@@ -1502,7 +1510,7 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1502
1510
  //
1503
1511
  // Checked BEFORE any state is written: marking the step in_review and then refusing would leave the
1504
1512
  // ledger claiming a review is open that was never opened.
1505
- if (!head && hub?.platform) {
1513
+ if (!head && productConfig?.platform) {
1506
1514
  const present = hasBranch(root, branch);
1507
1515
  if (present === false) {
1508
1516
  fail(`review branch '${branch}' is not on origin`);
@@ -1520,11 +1528,11 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1520
1528
  const author = ledger.state.steps.find((s) => s?.type === 'author' && s.artifact === step.artifact && s.id !== step.id);
1521
1529
  const closesAuthor = !verified && !!author && !isPassed(author) && !author.closed;
1522
1530
  if (!verified) {
1523
- ledger.state = markInReview(ledger.state, step, { by: closingActor(root, hub), date: today, hash: artifactHash(epicDir, step.artifact) });
1531
+ ledger.state = markInReview(ledger.state, step, { by: closingActor(root, productConfig), date: today, hash: artifactHash(epicDir, step.artifact) });
1524
1532
  writeState(ledger.files.state, ledger.state);
1525
- refreshIndexAfterWrite(root, hub);
1533
+ refreshIndexAfterWrite(root, productConfig);
1526
1534
  }
1527
- if (!hub?.platform) {
1535
+ if (!productConfig?.platform) {
1528
1536
  warn('no Product platform — marked in_review locally (no PR opened)');
1529
1537
  ok(`${step.id} → in_review`);
1530
1538
  return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: true };
@@ -1533,9 +1541,9 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1533
1541
  // Open the PR. In verified mode CI records the hub-prs entry (and advances) on the default branch at
1534
1542
  // merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
1535
1543
  // CI writes nothing pre-merge. With a local ledger the local command records the PR itself (no CI will).
1536
- const body = fillHubTemplate({
1544
+ const body = fillProductTemplate({
1537
1545
  epic, artifact, step, owner: ownerOf(epicDir), domains,
1538
- active: activePeople(root, { aliases: legacyLogins(hub) }).capacity.active,
1546
+ active: activePeople(root, { aliases: legacyLogins(productConfig) }).capacity.active,
1539
1547
  // Does this epic's ROUTE have an architecture step? Asked of the recorded route and never of the
1540
1548
  // chain (`routeLacksStep`): a truncated legacy chain has no `architecture` row and is not on a
1541
1549
  // short lane, and telling its reviewer in writing that it is would be a false claim in a record
@@ -1548,11 +1556,11 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1548
1556
  // roles, and there is no stored list to pick them from any more. The team requests them on the PR,
1549
1557
  // and E68 will suggest them from history. Said on the way out, so nobody waits for a request that
1550
1558
  // was never sent.
1551
- const committer = hub.platform === 'gitlab' ? platformLogin(root, hub.platform) : null;
1559
+ const committer = productConfig.platform === 'gitlab' ? platformLogin(root, productConfig.platform) : null;
1552
1560
  const assignees = committer ? [committer] : [];
1553
1561
  const labels = domains.map((d) => `domain:${d}`); // empty unless the step names its repos (touchedDomains)
1554
- info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
1555
- const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, assignees, labels, cwd: root });
1562
+ info(`opening review ${productConfig.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
1563
+ const r = creator(productConfig.platform, { title: `review: ${artifact} (${epic})`, body, base: productConfig.default_branch || 'main', head: branch, assignees, labels, cwd: root });
1556
1564
  if (!r.ok) {
1557
1565
  warn(`could not open PR (${r.reason || 'unknown'})${verified ? ' — open it manually; CI records the gate on merge' : '; step is in_review locally'}`);
1558
1566
  return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: !verified, reason: r.reason || null };
@@ -1566,21 +1574,21 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1566
1574
  // sync would stamp THAT number on them, so an approval given again on the new PR could never be told
1567
1575
  // from a re-read of the old one and, on GitLab (whose older records hold no submission time), would stay
1568
1576
  // stale for good.
1569
- const previous = (ledger.hubPrs || []).find((x) => x.artifact === artifact)?.number ?? null;
1577
+ const previous = (ledger.productPrs || []).find((x) => x.artifact === artifact)?.number ?? null;
1570
1578
  // Also when the new URL carries no number: the old pointer is about to be overwritten either way.
1571
1579
  if (previous != null && previous !== opened && stampLegacyPr(ledger.approvals, step.id, previous)) {
1572
1580
  writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
1573
1581
  }
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);
1582
+ ledger.productPrs = upsertProductPr(ledger.productPrs, { step: step.id, artifact, platform: productConfig.platform, number: opened, url: r.url, branch, lastSyncedAt: null });
1583
+ writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, ledger.productPrs);
1576
1584
  // The record was written before the PR existed, and the first close wins, so no later sync can add
1577
1585
  // 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;
1586
+ const number = ledger.productPrs.find((p) => p.artifact === artifact)?.number ?? null;
1579
1587
  const closedHere = closesAuthor ? ledger.state.steps.find((s) => s.id === author.id) : null;
1580
1588
  if (number != null && closedHere?.closed?.via === 'review-opened' && closedHere.closed.pr == null) {
1581
1589
  closedHere.closed = closingRecord({ ...closedHere.closed, pr: number });
1582
1590
  writeState(ledger.files.state, ledger.state);
1583
- refreshIndexAfterWrite(root, hub);
1591
+ refreshIndexAfterWrite(root, productConfig);
1584
1592
  }
1585
1593
  }
1586
1594
  ok(`opened ${r.url}`);
@@ -1597,24 +1605,24 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
1597
1605
  // consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
1598
1606
  // Assemble (but don't print) the Shape grounding bundle. Shared by `review` and `walkthrough` so the
1599
1607
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
1600
- // Returns { error } when there is no epic state, else { bundle, epicDir, hub }.
1608
+ // Returns { error } when there is no epic state, else { bundle, epicDir, productConfig }.
1601
1609
  function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
1602
- const { hub, repos } = loadProduct(root);
1610
+ const { productConfig, repos } = loadProduct(root);
1603
1611
  const epicDir = epicRoot(root, epic);
1604
1612
  const ledger = loadLedger(epicDir);
1605
1613
  if (!ledger.state) return { error: `no epic state at ${epicDir}` };
1606
- const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
1614
+ const pr = (ledger.productPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
1607
1615
  const art = artifact || pr?.artifact || null;
1608
1616
  const step = art ? findReviewStep(ledger.state, art) : null;
1609
1617
  // E71 — the live capacity count. Its own read, and still once per command: `reviewBundle` has exactly
1610
1618
  // two callers, `gate review` and `gate walkthrough`, and neither runs alongside `gate status` or
1611
1619
  // `gate sync`. Read before the bundle because the step's cap (E72) is computed from it. `headCount`
1612
1620
  // 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) });
1621
+ const counted = headCount || activePeople(root, { aliases: legacyLogins(productConfig) });
1614
1622
  const bundle = {
1615
1623
  epic,
1616
1624
  artifact: art,
1617
- platform: hub?.platform || null,
1625
+ platform: productConfig?.platform || null,
1618
1626
  pr: pr ? { number: pr.number, url: pr.url } : null,
1619
1627
  // `gateRule` is E7's per-step rule — the number of distinct approvers the count asks for and the
1620
1628
  // arithmetic behind it. `escalated` says the step carries a risk tag that raises the count. `cap`
@@ -1634,13 +1642,13 @@ function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
1634
1642
  name: r.name,
1635
1643
  codeMap: r.name ? path.join(root, '.sdlc/code-context', r.name, 'code-map.md') : null,
1636
1644
  })),
1637
- requireEngagement: requireEngagement(hub),
1645
+ requireEngagement: requireEngagement(productConfig),
1638
1646
  markers: {
1639
1647
  trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
1640
1648
  engagementVerified: '<!-- yad:engagement verified -->', pair: '<!-- yad:pair -->',
1641
1649
  },
1642
1650
  };
1643
- return { bundle, epicDir, hub };
1651
+ return { bundle, epicDir, productConfig };
1644
1652
  }
1645
1653
 
1646
1654
  export async function gateReview(root, { epic, artifact, headCount = null } = {}) {
@@ -1656,8 +1664,8 @@ export async function gateReview(root, { epic, artifact, headCount = null } = {}
1656
1664
  export async function gateWalkthrough(root, { epic, artifact, runner = run } = {}) {
1657
1665
  const r = reviewBundle(root, { epic, artifact });
1658
1666
  if (r.error) { refuse(r.error, null, { json: true }); return; }
1659
- const { bundle, hub } = r;
1660
- const defaultBranch = hub?.default_branch || 'main';
1667
+ const { bundle, productConfig } = r;
1668
+ const defaultBranch = productConfig?.default_branch || 'main';
1661
1669
  let stops = [];
1662
1670
  if (bundle.artifactPath) {
1663
1671
  const rel = path.relative(root, bundle.artifactPath) || bundle.artifact;
@@ -1679,17 +1687,17 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
1679
1687
  // briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
1680
1688
  // delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
1681
1689
  export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
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 }; }
1690
+ const { productConfig } = loadProduct(root);
1691
+ if (!productConfig?.platform) { warn('no Product platform configured — the trailer posts to the PR/MR (local has none)'); return { number: null, posted: false }; }
1684
1692
  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; }
1685
1693
  const epicDir = epicRoot(root, epic);
1686
1694
  const ledger = loadLedger(epicDir);
1687
- const pr = (ledger.hubPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
1695
+ const pr = (ledger.productPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
1688
1696
  const n = number || pr?.number;
1689
1697
  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 }; }
1690
- const cur = getBody(hub.platform, n, { cwd: root });
1698
+ const cur = getBody(productConfig.platform, n, { cwd: root });
1691
1699
  if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
1692
- const r = editBody(hub.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
1700
+ const r = editBody(productConfig.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
1693
1701
  if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
1694
1702
  ok(`trailer posted to PR #${n}`);
1695
1703
  return { number: n, posted: true };
@@ -1701,7 +1709,7 @@ const base = (artifact) => artifactBase(artifact);
1701
1709
  // `active` is the caller's already-read count (E71, `activePeople`), or null when it did not read one.
1702
1710
  // Passed in rather than read here for the same reason the predicate takes it: this builds a string and
1703
1711
  // must stay callable from a test without a Product on disk.
1704
- export function fillHubTemplate({ epic, artifact, step, owner, domains, hasArchitecture = true, active = null }) {
1712
+ export function fillProductTemplate({ epic, artifact, step, owner, domains, hasArchitecture = true, active = null }) {
1705
1713
  const rule = gateRuleFor(step);
1706
1714
  const cap = gateCapFor(rule, active);
1707
1715
  // What the count asks for. Only the base holds (until E108); the cap (E72) is shown when it lowered the
@@ -1736,8 +1744,8 @@ export function fillHubTemplate({ epic, artifact, step, owner, domains, hasArchi
1736
1744
  '- **Approve** to record your approval; **comment / request changes** to hold the gate.',
1737
1745
  '- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
1738
1746
  '',
1739
- // Required by the Product `pr-template` gate (check_hub_body). Mirrors the Checklist block of the
1740
- // committed static template (yad-pr-template/templates/hub/<platform>/) so the generated body
1747
+ // Required by the Product `pr-template` gate (check_product_body). Mirrors the Checklist block of the
1748
+ // committed static template (yad-pr-template/templates/product/<platform>/) so the generated body
1741
1749
  // passes on the first CI run.
1742
1750
  '## Checklist',
1743
1751
  '- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)',