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/reconcile.mjs CHANGED
@@ -10,10 +10,10 @@ import {
10
10
  const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
11
11
 
12
12
  import { preflightGuardReadiness } from './hubcommit.mjs';
13
- import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX } from './manifest.mjs';
13
+ import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath } from './manifest.mjs';
14
14
  import {
15
- moduleActions, repoActions, hubActions, hookActions, authorsActions,
16
- legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
15
+ moduleActions, repoActions, productActions, hookActions,
16
+ legacyModuleActions, removedModuleActions, orphanHookActions, legacyRepoActions, legacyHubActions,
17
17
  ideTargetStateFor, recordManagedWrites,
18
18
  } from './plan.mjs';
19
19
  import { gitHead, packRepo } from './setup.mjs';
@@ -21,6 +21,9 @@ import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
21
21
 
22
22
  const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
23
23
 
24
+ // The --json answer's list (E1): every managed item, as the report groups it — never the apply step.
25
+ const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status }));
26
+
24
27
  export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
25
28
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
26
29
  log(c.dim(`target: ${root}\n`));
@@ -28,7 +31,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
28
31
  // --- missing one-time setup (needs the interactive wizard) ---
29
32
  const gaps = [];
30
33
  if (!exists(path.join(root, PROJECT_FILES.version))) gaps.push('module not installed (.sdlc/cli-version.json absent)');
31
- if (!exists(path.join(root, PROJECT_FILES.hubConfig))) gaps.push('hub not configured (.sdlc/hub.json absent)');
34
+ if (!exists(productConfigPath(root))) gaps.push('hub not configured (.sdlc/hub.json absent)');
32
35
  const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
33
36
  if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
34
37
 
@@ -43,13 +46,13 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
43
46
  writeJSON(stampPath, { ...record, version: VERSION, ideTargets });
44
47
  };
45
48
 
46
- // --- deterministic file actions (module + hub CI + author allowlists + every registered repo),
49
+ // --- deterministic file actions (module + Product CI + every registered repo),
47
50
  // plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
48
51
  // and purge of skills removed in a later release ('removed': delete the lingering install) ---
49
52
  const actions = [
50
53
  ...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
51
- ...hubActions(root), ...legacyHubActions(root), ...hookActions(root, ideTargets),
52
- ...authorsActions(root, registry.repos),
54
+ ...productActions(root), ...legacyHubActions(root), ...hookActions(root, ideTargets),
55
+ ...orphanHookActions(root, ideTargets),
53
56
  ];
54
57
  if (ideState.needsRepair) {
55
58
  actions.push({
@@ -71,7 +74,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
71
74
  if (head && repo.syncedHead && head !== repo.syncedHead) {
72
75
  staleRepos.push(repo);
73
76
  // packRepo writes the repomix cache under the HUB root (root/repo.contextPack, e.g.
74
- // .sdlc/code-context/<name>/pack.md), so the touched path belongs to the hub — and is commonly
77
+ // .sdlc/code-context/<name>/pack.md), so the touched path belongs to the Product — and is commonly
75
78
  // gitignored, in which case the push stage's check-ignore drops it. codeMap is AI-generated
76
79
  // later, not here, so only the pack is claimed.
77
80
  actions.push({
@@ -132,6 +135,26 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
132
135
  for (const m of modified) {
133
136
  warn(`${m.scope}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
134
137
  }
138
+ // A gate-sync fragment is kept like any other edited file — but it also decides which yadflow CI runs.
139
+ // It trusts a committed version only from its own major (`YAD_MAJOR`), and the version stamp is
140
+ // re-written below whatever happens to the fragment. So an edited fragment left on an older major
141
+ // rejects the stamp this update writes, and CI silently floats on that older engine against a project
142
+ // on a newer file shape. The generic "locally modified" line does not say that; this does.
143
+ const pinMajorOf = (file) => {
144
+ try {
145
+ const text = fs.readFileSync(file, 'utf8');
146
+ // `YAD_MAJOR=<n>` from this release on; before it, the major was written into the check itself.
147
+ const found = text.match(/^\s*YAD_MAJOR=(\d+)\s*$/m) || text.match(/grep -Eq ['"]\^(\d+)\\\./);
148
+ return found ? Number(found[1]) : null;
149
+ } catch { return null; }
150
+ };
151
+ for (const m of modified.filter((a) => a.managed && /yad-gate-sync\.ya?ml$/.test(a.item))) {
152
+ const kept = pinMajorOf(m.managed.dest);
153
+ const ships = pinMajorOf(m.managed.src);
154
+ if (kept === null || ships === null || kept === ships) continue;
155
+ warn(`${m.scope}/${m.item} trusts only yadflow ${kept}.x pins, but this release's fragment trusts ${ships}.x — kept as it is, CI will skip the new version stamp and run yadflow@${kept}`);
156
+ hand(`re-apply your edit on top of the new fragment, or replace it with \`yad update --overwrite-local\` (your copy is saved beside it as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
157
+ }
135
158
  if (modified.length && !overwriteLocal) {
136
159
  hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
137
160
  }
@@ -147,7 +170,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
147
170
  if (!fix) {
148
171
  if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
149
172
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
150
- return { counts, gaps, applied: 0, modified: modified.length };
173
+ return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [] };
151
174
  }
152
175
 
153
176
  // --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
@@ -187,9 +210,12 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
187
210
  if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
188
211
 
189
212
  // --- publish: commit each repo's applied changes and push directly to its default branch ---
213
+ // Each repo's `{ label, committed, pushed }`, for the --json answer (E1): a push that failed after a
214
+ // commit landed is said per repo.
215
+ let commits = [];
190
216
  if (push) {
191
217
  preflightGuardReadiness(root);
192
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), {});
218
+ const hub = readJSON(productConfigPath(root), {});
193
219
  const defByRoot = new Map([[root, hub?.default_branch]]);
194
220
  const platformByRoot = new Map([[root, hub?.platform]]);
195
221
  for (const repo of registry.repos) {
@@ -197,7 +223,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
197
223
  defByRoot.set(repoRoot, repo.default_branch);
198
224
  platformByRoot.set(repoRoot, repo.platform);
199
225
  }
200
- commitUpdates(root, groupByRoot(appliedActions), {
226
+ commits = commitUpdates(root, groupByRoot(appliedActions), {
201
227
  push: true, allowBranch,
202
228
  defaultBranchFor: (r) => defByRoot.get(r),
203
229
  // GitLab's yad-update-guard is an includable fragment: unlike a GitHub workflow it does nothing
@@ -211,5 +237,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
211
237
  },
212
238
  });
213
239
  }
214
- return { counts, gaps, applied, modified: modified.length };
240
+ return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits };
215
241
  }
@@ -1,26 +1,26 @@
1
1
  // `yad repo refresh --push` — after a repack + registry stamp, commit the connected-repo code-context
2
2
  // (the tracked `code-map.md` per repo + the registry `.sdlc/repos.json`) and push it straight to the
3
- // hub's default branch, so a code-map refresh "just lands" for teammates / CI / `yad status` on other
3
+ // Product's default branch, so a code-map refresh "just lands" for teammates / CI / `yad status` on other
4
4
  // machines instead of leaving a dirty tree for someone to hand-commit. This is the code-context
5
5
  // analogue of `yad checkpoint` (cli/checkpoint.mjs) and reuses its default-branch commit machinery.
6
6
  //
7
7
  // Invariants (shared with checkpoint):
8
8
  // 1. Commit an EXPLICIT allowlist via `git commit -- <paths>` (--only) — the tracked code-maps, the
9
- // registry, and (only when its change is the managed pack-ignore block alone) the hub `.gitignore`.
9
+ // registry, and (only when its change is the managed pack-ignore block alone) the Product `.gitignore`.
10
10
  // NEVER `git add -A` and NEVER a whole-index `git reset`: both would mutate unrelated staged work.
11
11
  // The repomix pack.md is gitignored (setup + publish scaffold the ignore via ensurePackIgnored) and
12
12
  // never committed as content. A pack committed BEFORE that ignore existed is self-healed here: its
13
13
  // on-disk file is held aside across the --only commit so the deletion is recorded (the regenerable
14
14
  // cache is restored right after), clearing the stranded working tree — see the commit block below.
15
- // 2. Commit ONLY on the hub's default branch (unless --allow-branch), so the `[skip ci]` audit
15
+ // 2. Commit ONLY on the Product's default branch (unless --allow-branch), so the `[skip ci]` audit
16
16
  // commit never enters a PR's base..HEAD range where it would strand required checks.
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { c, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
20
- import { PROJECT_FILES } from './manifest.mjs';
21
- import { loadHub } from './gate.mjs';
22
- import { resolveCommitterLogin } from './platform.mjs';
23
- import { hubGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
20
+ import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
21
+ import { loadProduct } from './gate.mjs';
22
+ import { platformLogin } from './platform.mjs';
23
+ import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
24
24
  import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
25
25
  import { checkpointAuthor } from './checkpoint.mjs';
26
26
 
@@ -51,7 +51,7 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
51
51
  }
52
52
 
53
53
  // PURE — each registered repo's on-disk pack path (scoped by `name` like codeMapPathspecs). These are
54
- // UNTRACK candidates, not content to stage: the pack is gitignored, but a hub that committed it before
54
+ // UNTRACK candidates, not content to stage: the pack is gitignored, but a Product that committed it before
55
55
  // the ignore existed would otherwise strand a dirty pack on every refresh. publishCodeContext keeps only
56
56
  // the still-tracked ones and records their removal in the audit commit (see the self-heal block there).
57
57
  export function packPathspecs(root, registry = { repos: [] }, name = null) {
@@ -65,11 +65,11 @@ export function packPathspecs(root, registry = { repos: [] }, name = null) {
65
65
  }
66
66
 
67
67
  // True iff committing `.gitignore` would carry ONLY the managed pack-ignore block (comments + glob) and
68
- // nothing else. Guards invariant 1: a hub whose `.gitignore` also has unrelated uncommitted edits must
68
+ // nothing else. Guards invariant 1: a Product whose `.gitignore` also has unrelated uncommitted edits must
69
69
  // keep them OUT of the `[skip ci]` audit commit. The publish commit is `git commit -- <paths>` (--only,
70
70
  // reads the WORKING TREE), so this compares the working tree — an untracked `.gitignore` must be wholly
71
71
  // managed; a tracked one must differ from HEAD by the managed block alone (added, nothing removed).
72
- // `git` is a hubGit-style accessor; `root` is the hub root. Mirrors checkpoint's stagedStoryIsStatusOnly.
72
+ // `git` is a productGit-style accessor; `root` is the Product root. Mirrors checkpoint's stagedStoryIsStatusOnly.
73
73
  export function ignoreChangeIsManagedOnly(git, root) {
74
74
  const gi = path.join(root, '.gitignore');
75
75
  if (!fs.existsSync(gi)) return false;
@@ -121,7 +121,7 @@ export function summarizeCodeContext(files = []) {
121
121
  return { label, basenames };
122
122
  }
123
123
 
124
- // PURE — the audit-trail commit message. Subject passes the hub commit-message gate (valid type
124
+ // PURE — the audit-trail commit message. Subject passes the Product commit-message gate (valid type
125
125
  // `chore`, scope `hub`, non-empty description, no trailing period). No Task trailer and no
126
126
  // Co-Authored-By: this is human-owned machine state, not an authored code change. `[skip ci]` mirrors
127
127
  // `yad checkpoint` — it lands on the default branch and needs no PR gate suite. `label`/`author` are
@@ -132,19 +132,19 @@ export function buildCodeMapMessage({ label, author, basenames = [] }) {
132
132
  return body ? `${subject}\n\n${body}` : subject;
133
133
  }
134
134
 
135
- // Commit the tracked code-context (and, with push, push it) on the hub's default branch. Mirrors
135
+ // Commit the tracked code-context (and, with push, push it) on the Product's default branch. Mirrors
136
136
  // `runCheckpoint`. Never throws; sets process.exitCode on a hard error so the CLI reports failure.
137
137
  export async function publishCodeContext(root, { push = false, allowBranch = false, name = null } = {}) {
138
138
  if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
139
- if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
140
- fail('no .sdlc/hub.json — --push publishes the hub code-context; run it from the product hub');
139
+ if (!exists(productConfigPath(root))) {
140
+ fail('no .sdlc/hub.json — --push publishes the Product code-context; run it from the Product');
141
141
  process.exitCode = 1;
142
142
  return;
143
143
  }
144
144
 
145
- const { hub, repos } = loadHub(root);
145
+ const { hub, repos } = loadProduct(root);
146
146
  const registry = { repos: repos || [] };
147
- const git = hubGit(root);
147
+ const git = productGit(root);
148
148
 
149
149
  const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
150
150
  const defaultBranch = resolveDefaultBranch(git, hub);
@@ -161,8 +161,8 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
161
161
  }
162
162
 
163
163
  // Make the "pack is gitignored" assumption true (idempotent; packRepo also does this on refresh) so a
164
- // hub whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
165
- // ONLY when the change is the managed pack-ignore block alone (invariant 1) — a hub whose `.gitignore`
164
+ // Product whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
165
+ // ONLY when the change is the managed pack-ignore block alone (invariant 1) — a Product whose `.gitignore`
166
166
  // also carries unrelated uncommitted edits keeps them OUT of this audit commit; the pack is still ignored
167
167
  // on disk and the human commits their own `.gitignore` edits through their own change. When we do carry
168
168
  // it, `git add` makes an untracked `.gitignore` known so the --only commit can include it.
@@ -204,17 +204,16 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
204
204
  const ahead = rev.ok ? (Number(rev.stdout) || 0) : 1; // no upstream ref yet -> attempt the push
205
205
  if (!ahead) { info('code-context unchanged and already published — nothing to do'); return; }
206
206
  info(`code-context unchanged — pushing ${ahead} already-committed change(s) not yet on origin/${branch}`);
207
- pushHead();
208
- return;
207
+ return { message: null, committed: false, pushed: pushHead() };
209
208
  }
210
209
 
211
210
  // Only relevant when we are about to push a commit straight to the default branch: warn (never block)
212
- // if signing/allowlisting would make the yad-update-guard reject it. Gated on `push` and deferred to
211
+ // if unsigned commits would make the yad-update-guard reject it. Gated on `push` and deferred to
213
212
  // here so it isn't noise on a guard-refused branch or a nothing-to-commit run.
214
213
  if (push) preflightGuardReadiness(root);
215
214
 
216
215
  const { label, basenames } = summarizeCodeContext(fileset);
217
- const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
216
+ const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
218
217
  const message = buildCodeMapMessage({ label, author, basenames });
219
218
 
220
219
  // Untrack the packs by holding their bytes and removing the files across the --only commit, then
@@ -232,14 +231,13 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
232
231
  git('reset', '-q', '--', ...pathspecs, ...ignoreSpec); // unstage only OUR allowlist for a clean retry
233
232
  fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
234
233
  process.exitCode = 1;
235
- return { message };
234
+ return { message, committed: false, pushed: false };
236
235
  }
237
236
  } finally {
238
237
  for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
239
238
  }
240
239
  ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
241
240
 
242
- if (!push) return { message };
243
- pushHead();
244
- return { message };
241
+ if (!push) return { message, committed: true, pushed: false };
242
+ return { message, committed: true, pushed: pushHead() };
245
243
  }
package/cli/repo.mjs CHANGED
@@ -38,55 +38,64 @@ function defaultBranch(cwd, repo) {
38
38
 
39
39
  export async function runRepo(root, { action = 'list', name, today, push = false, allowBranch = false } = {}) {
40
40
  const { regPath, registry } = load(root);
41
- if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { repos: 0 }; }
41
+ if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { action, repos: [] }; }
42
42
 
43
43
  if (action === 'list') {
44
44
  log(c.bold('\nconnected repos'));
45
45
  let staleCount = 0;
46
+ const rows = []; // the --json answer (E1): each repo, as the list reads it
46
47
  for (const repo of registry.repos) {
47
- const { stale, unknown, neverPacked } = staleness(root, repo);
48
+ const { head, stale, unknown, neverPacked } = staleness(root, repo);
49
+ rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: head || null, syncedHead: repo.syncedHead ?? null,
50
+ state: unknown ? 'unreadable' : neverPacked ? 'no-pack' : stale ? 'stale' : 'fresh' });
48
51
  if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
49
52
  if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
50
53
  else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
51
54
  else ok(`${repo.name} ${c.dim('— fresh')}`);
52
55
  }
53
56
  if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
54
- return { repos: registry.repos.length, stale: staleCount };
57
+ return { action, repos: rows, stale: staleCount };
55
58
  }
56
59
 
57
60
  if (action === 'refresh') {
58
61
  const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
59
- if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { refreshed: 0 }; }
62
+ if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
60
63
  let refreshed = 0;
64
+ let published = null;
65
+ const rows = [];
61
66
  for (const repo of targets) {
62
67
  const { head, unknown } = staleness(root, repo);
63
- if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); continue; }
68
+ if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); rows.push({ name: repo.name ?? null, refreshed: false, head: null }); continue; }
64
69
  log(` ${c.bold(repo.name)}`);
65
- if (packRepo(root, repo)) {
70
+ const packed = !!packRepo(root, repo);
71
+ if (packed) {
66
72
  repo.syncedHead = head;
67
73
  if (today) repo.lastSyncedAt = today; // always stamp when a date is supplied (the CLI passes today)
68
74
  refreshed++;
69
75
  }
76
+ rows.push({ name: repo.name ?? null, refreshed: packed, head: head || null });
70
77
  }
71
78
  writeJSON(regPath, registry);
72
79
  refreshed ? ok(`refreshed ${refreshed} repo(s)`) : info('nothing refreshed');
73
80
  if (push) {
74
81
  // Publish whatever tracked code-context now differs (the AI-regenerated code-maps + the stamped
75
- // registry) straight to the hub's default branch as one audit-trail commit. The pack itself is
82
+ // registry) straight to the Product's default branch as one audit-trail commit. The pack itself is
76
83
  // gitignored (packRepo scaffolds the ignore; publish self-heals a pre-ignore tracked pack); the
77
84
  // code-map is regenerated by the AI (yad-connect-repos) — run that first if a repo's map is stale,
78
85
  // then `yad repo refresh --push` lands it.
79
- await publishCodeContext(root, { push: true, allowBranch, name });
86
+ published = (await publishCodeContext(root, { push: true, allowBranch, name })) ?? null;
80
87
  } else {
81
- hand('regenerate the code-map in Claude Code (yad-connect-repos) — the pack is cached, the map is the AI step');
82
- hand('then publish it to the hub default branch with `yad repo refresh --push`');
88
+ hand('regenerate the code-map in your AI agent (yad-connect-repos) — the pack is cached, the map is the AI step');
89
+ hand('then publish it to the Product default branch with `yad repo refresh --push`');
83
90
  }
84
- return { refreshed };
91
+ // `published`: what `--push` did (`{ message, committed, pushed }`), or null when it committed nothing
92
+ // — nothing to publish, or a refusal, which the answer's `error` then names.
93
+ return { action, refreshed, repos: rows, published };
85
94
  }
86
95
 
87
96
  if (action === 'sync') {
88
97
  const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
89
- if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { synced: 0 }; }
98
+ if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
90
99
  log(c.bold('\nsync connected repos'));
91
100
  let synced = 0, skipped = 0;
92
101
  for (const repo of targets) {
@@ -118,10 +127,10 @@ export async function runRepo(root, { action = 'list', name, today, push = false
118
127
  const staleCount = registry.repos.filter((r) => staleness(root, r).stale).length;
119
128
  info(`synced ${synced}, skipped ${skipped}`);
120
129
  if (staleCount) hand(`${staleCount} repo(s) now have a stale code-context pack — \`yad repo refresh\` to repack`);
121
- return { synced, skipped, stale: staleCount };
130
+ return { action, synced, skipped, stale: staleCount };
122
131
  }
123
132
 
124
133
  fail(`unknown repo action: ${action} (list | refresh | sync)`);
125
134
  process.exitCode = 1;
126
- return {};
135
+ return { action };
127
136
  }
package/cli/report.mjs CHANGED
@@ -4,15 +4,14 @@
4
4
  // PRIVACY IS THE POINT: the issue posts to a PUBLIC repo, so this module is allowlist-first. It
5
5
  // assembles ONLY a known-safe set of fields (version, node/os, tool booleans, platform enum, error
6
6
  // code/hint, a path-scrubbed message, and command + flag NAMES) and actively strips everything else —
7
- // no absolute paths, hostnames, git URLs, repo names, roster logins/emails, epic/story IDs, branch
7
+ // no absolute paths, hostnames, git URLs, repo names, logins/emails, epic/story IDs, branch
8
8
  // names, or flag values ever leave the machine. The user sees the exact payload and confirms before
9
9
  // anything is posted. See memory: no-private-data-in-reports.
10
- import path from 'node:path';
11
- import { c, log, info, ok, warn, note, ask, askYesNo, has, readJSON, run } from './lib.mjs';
12
- import { VERSION, UPSTREAM_REPO, PROJECT_FILES } from './manifest.mjs';
10
+ import { c, log, info, ok, warn, note, ask, askYesNo, has, readJSON, run, inJSON } from './lib.mjs';
11
+ import { VERSION, UPSTREAM_REPO , productConfigPath } from './manifest.mjs';
13
12
  import { createIssue, searchIssues, issueUrl, platformAuthed } from './platform.mjs';
14
13
 
15
- // The upstream lives on GitHub — file there regardless of the user's own hub platform.
14
+ // The upstream lives on GitHub — file there regardless of the user's own Product platform.
16
15
  const UPSTREAM_PLATFORM = 'github';
17
16
  const PLACEHOLDER = '‹redacted›';
18
17
 
@@ -39,13 +38,16 @@ export function scrub(s = '') {
39
38
  }
40
39
 
41
40
  // The verbs `yad` understands (top-level commands + their sub-actions). Anything NOT in this set —
42
- // roster logins, repo names, roles, epic IDs, filenames, and every flag VALUE — is dropped, so the
41
+ // logins, repo names, roles, epic IDs, filenames, and every flag VALUE — is dropped, so the
43
42
  // reported command carries only structure, never data.
44
43
  const SAFE_VERBS = new Set([
45
44
  'setup', 'check', 'update', 'doctor', 'report', 'sync-status', 'next', 'gate', 'review', 'commit',
45
+ // `roster` (and its `add`/`grant`/`revoke`/`remove`) stays: the command was removed in E62 but still
46
+ // answers with a notice, and a report about that notice must keep its structure.
46
47
  'open-pr', 'ship', 'repo', 'roster', 'docs', 'thread', 'reconcile',
47
48
  'open', 'sync', 'comments', 'status', 'walkthrough', 'trailer', 'ci', 'context', 'chat', 'cards',
48
49
  'nudge', 'list', 'add', 'grant', 'revoke', 'remove', 'build', 'deploy', 'refresh', 'wire',
50
+ 'risk-map', 'draft', 'codeowners',
49
51
  ]);
50
52
 
51
53
  // Reduce an argv to a safe command line: the leading verb chain (at most `command subcommand`, and
@@ -72,8 +74,8 @@ export function sanitizeArgv(argv = []) {
72
74
  // Build the safe, postable context — the ONLY data that can reach the issue. Reads doctor for tool
73
75
  // state but keeps just the booleans, never the raw checks (which carry names + paths).
74
76
  export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2) } = {}) {
75
- const hub = readJSON(path.join(dir, PROJECT_FILES.hubConfig), null);
76
- const platform = hub && ['github', 'gitlab'].includes(hub.platform) ? hub.platform : 'file-only';
77
+ const hub = readJSON(productConfigPath(dir), null);
78
+ const platform = hub && ['github', 'gitlab'].includes(hub.platform) ? hub.platform : 'local';
77
79
  // Derive tool auth from doctor's checks without keeping any check text.
78
80
  const toolState = (cli, p) => (has(cli) ? (platformAuthed(p) ? 'present + authenticated' : 'present, not authenticated') : 'not installed');
79
81
  const ctx = {
@@ -82,7 +84,7 @@ export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2
82
84
  os: process.platform,
83
85
  git: has('git') ? 'present' : 'not found',
84
86
  gh: toolState('gh', 'github'),
85
- // The hub CLI's auth is a useful diagnostic for a GitLab user (issues still file to GitHub upstream).
87
+ // The Product CLI's auth is a useful diagnostic for a GitLab user (issues still file to GitHub upstream).
86
88
  ...(platform === 'gitlab' ? { glab: toolState('glab', 'gitlab') } : {}),
87
89
  platform,
88
90
  command: sanitizeArgv(argv) || '(none)',
@@ -154,7 +156,8 @@ export async function runReport(dir, opts = {}) {
154
156
  opener = openUrl,
155
157
  authed = () => platformAuthed(UPSTREAM_PLATFORM),
156
158
  argv = process.argv.slice(2),
157
- interactive = !process.env.SDLC_NONINTERACTIVE,
159
+ // A --json run never prompts, and so never posts (E1): it answers with the payload and the prefilled URL.
160
+ interactive = !process.env.SDLC_NONINTERACTIVE && !inJSON(),
158
161
  } = opts;
159
162
 
160
163
  try {
@@ -174,15 +177,17 @@ export async function runReport(dir, opts = {}) {
174
177
  // Dedup — search open issues by the error code (or the first summary word). Advisory: a failed
175
178
  // search just skips this step, it never blocks filing.
176
179
  const query = ctx.error?.code || (summary.split(/\s+/)[0] || '').replace(/[^\w-]/g, '');
180
+ let related = [];
177
181
  if (query) {
178
182
  const { ok: searchedOk, matches } = searcher(UPSTREAM_PLATFORM, UPSTREAM_REPO, `${query} in:title`);
183
+ if (searchedOk) related = matches.map((m) => ({ number: m.number, title: m.title, url: m.url }));
179
184
  if (searchedOk && matches.length) {
180
185
  log(c.bold(`\nFound ${matches.length} possibly-related open issue(s):`));
181
186
  for (const m of matches) info(`#${m.number} — ${m.title} ${c.dim(m.url)}`);
182
187
  if (interactive && await prompter('Open an existing issue instead of filing a new one?', true)) {
183
188
  opener(matches[0].url);
184
189
  log(`\n → ${c.cyan(matches[0].url)}`);
185
- return { filed: false, url: matches[0].url, deduped: true };
190
+ return { filed: false, url: matches[0].url, deduped: true, title, body, labels, related };
186
191
  }
187
192
  }
188
193
  }
@@ -201,7 +206,8 @@ export async function runReport(dir, opts = {}) {
201
206
  const url = issueUrl(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
202
207
  note(interactive ? 'Not posted. You can file it yourself here (prefilled):' : 'Non-interactive — not posted. File it here (prefilled):');
203
208
  log(` → ${c.cyan(url)}`);
204
- return { filed: false, url };
209
+ // The payload exactly as it would be posted, for a --json caller to show or file itself.
210
+ return { filed: false, url, title, body, labels, related };
205
211
  }
206
212
 
207
213
  // File directly when the CLI is authenticated; otherwise fall back to the prefilled URL.
@@ -209,7 +215,7 @@ export async function runReport(dir, opts = {}) {
209
215
  const r = filer(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
210
216
  if (r.ok) {
211
217
  ok(`Issue filed: ${c.cyan(r.url)}`);
212
- return { filed: true, url: r.url };
218
+ return { filed: true, url: r.url, title, body, labels, related };
213
219
  }
214
220
  warn(`Could not file automatically: ${r.reason || 'unknown error'}`);
215
221
  } else {
@@ -218,10 +224,10 @@ export async function runReport(dir, opts = {}) {
218
224
  const url = issueUrl(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
219
225
  opener(url);
220
226
  log(` → ${c.cyan(url)}`);
221
- return { filed: false, url };
227
+ return { filed: false, url, title, body, labels, related };
222
228
  } catch (e) {
223
229
  // Reporting itself must never crash the CLI.
224
230
  note(`could not complete the report (${e?.message || e}) — please file manually at https://github.com/${UPSTREAM_REPO}/issues`);
225
- return { filed: false, url: null, failed: true };
231
+ return { filed: false, url: null, title: null, body: null, labels: [], related: [], failed: true };
226
232
  }
227
233
  }
package/cli/review.mjs CHANGED
@@ -1,13 +1,13 @@
1
- // `yad review trailer|context|nudge|reconcile` — the BACK-HALF Review Companion + bridge for code
2
- // PR/MRs (the analogue of `yad gate …` for the front half). The fun process (trailer/cards/chat/nudge)
3
- // makes the engineer review easy and visible; the bridge process (reconcile) maps the code PR's review
1
+ // `yad review trailer|context|nudge|reconcile` — the Build Review Companion + bridge for code
2
+ // PR/MRs (the analogue of `yad gate …` for Shape). The fun process (trailer/cards/chat/nudge)
3
+ // makes the engineer review easy and visible; the bridge to code PR/MRs (reconcile) maps the code PR's review
4
4
  // state — including the engagement signal — into the build ledger (build-log.json) at merge.
5
5
  //
6
6
  // The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
7
7
  // trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
8
8
  import path from 'node:path';
9
- import { log, ok, info, warn, fail, note, run, readJSON } from './lib.mjs';
10
- import { PROJECT_FILES } from './manifest.mjs';
9
+ import { log, ok, info, warn, fail, note, run, readJSON, emitJSON, refuse, inJSON, collectWarning } from './lib.mjs';
10
+ import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
11
11
  import { updateShip } from './ledger.mjs';
12
12
  import { epicRoot } from './epic-state.mjs';
13
13
  import {
@@ -19,7 +19,7 @@ import { sequenceDiff } from './walkthrough.mjs';
19
19
 
20
20
  const NUDGE_CMD = 'yad review chat';
21
21
 
22
- // Resolve the target code repo: --repo <name> from the registry (platform + path + roles), else cwd.
22
+ // Resolve the target code repo: --repo <name> from the registry (platform + path), else cwd.
23
23
  // An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
24
24
  // would operate on the wrong repo). Returns { error } in that case for the caller to surface.
25
25
  function resolveRepo(root, { repo, dir }) {
@@ -35,10 +35,10 @@ function resolveRepo(root, { repo, dir }) {
35
35
  function platformOf(root, repoRoot, meta) {
36
36
  if (meta?.platform) return meta.platform;
37
37
  const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
38
- return detectPlatform(remote) || readJSON(path.join(root, PROJECT_FILES.hubConfig), {}).platform || null;
38
+ return detectPlatform(remote) || readJSON(productConfigPath(root), {}).platform || null;
39
39
  }
40
40
 
41
- // Build (but don't print) the back-half grounding bundle. Shared by `context` and `walkthrough` so the
41
+ // Assemble (but don't print) the Build grounding bundle. Shared by `context` and `walkthrough` so the
42
42
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
43
43
  // Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
44
44
  function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
@@ -73,8 +73,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
73
73
  // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
74
74
  export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
75
75
  const r = contextBundle(root, { repo, dir, pr, runner });
76
- if (r.error) { fail(r.error); process.exitCode = 1; return; }
77
- log(JSON.stringify(r.bundle, null, 2));
76
+ if (r.error) { refuse(r.error, null, { json: true }); return; }
77
+ emitJSON(r.bundle);
78
78
  return r.bundle;
79
79
  }
80
80
 
@@ -84,15 +84,15 @@ export async function reviewContext(root, { repo, dir, pr, runner = run } = {})
84
84
  // the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
85
85
  export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
86
86
  const r = contextBundle(root, { repo, dir, pr, runner });
87
- if (r.error) { fail(r.error); process.exitCode = 1; return; }
87
+ if (r.error) { refuse(r.error, null, { json: true }); return; }
88
88
  const { bundle, repoRoot, base } = r;
89
89
  const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
90
90
  // Diagnostics go to STDERR so STDOUT stays pure JSON (the skill / e2e parse it). The empty `stops: []`
91
91
  // in the bundle already signals "nothing to walk".
92
- if (!diff.ok) note(`could not read the diff (${base}...HEAD) in ${repoRoot} — is the branch pushed and the base correct?`);
92
+ if (!diff.ok) { const w = `could not read the diff (${base}...HEAD) in ${repoRoot} — is the branch pushed and the base correct?`; collectWarning(w); note(w); }
93
93
  const stops = sequenceDiff(diff.ok ? diff.stdout : '', { contractPath: bundle.contract });
94
94
  const out = { ...bundle, stops };
95
- log(JSON.stringify(out, null, 2));
95
+ emitJSON(out);
96
96
  if (!stops.length) note('no stops — the diff is empty (nothing to walk through)');
97
97
  return out;
98
98
  }
@@ -112,7 +112,7 @@ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPr
112
112
  const r = editBody(platform, pr, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: repoRoot });
113
113
  if (!r.ok) { fail(`could not update PR #${pr}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
114
114
  ok(`trailer posted to ${platform === 'gitlab' ? 'MR' : 'PR'} #${pr}`);
115
- return { number: pr };
115
+ return { number: pr, posted: true };
116
116
  }
117
117
 
118
118
  // `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
@@ -125,7 +125,7 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
125
125
  const platform = platformOf(root, repoRoot, meta);
126
126
  if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
127
127
  const pull = reader(platform, pr, { cwd: repoRoot });
128
- if (!pull.ok) { warn(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
128
+ if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
129
129
  let nudged = 0;
130
130
  for (const rv of pull.reviews) {
131
131
  if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login) continue;
@@ -136,7 +136,7 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
136
136
  return { nudged };
137
137
  }
138
138
 
139
- // `yad review reconcile --epic <id> --repo <r> --pr <n>` — the back-half BRIDGE: read the code PR's
139
+ // `yad review reconcile --epic <id> --repo <r> --pr <n>` — the Build BRIDGE: read the code PR's
140
140
  // approvals (with the engagement signal) and stamp them onto the matching build-log.json ship record,
141
141
  // so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
142
142
  // ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
@@ -149,22 +149,18 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
149
149
  const { repoRoot, meta } = rr;
150
150
  const platform = platformOf(root, repoRoot, meta);
151
151
  if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
152
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
153
- const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
154
- const domain = meta?.name ? [meta.name] : [];
155
152
  const pull = reader(platform, pr, { cwd: repoRoot });
156
153
  if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
157
154
 
158
- // Map platform approvals → engineer_review entries, deduped by (approver, role, domain), carrying the
159
- // engagement signal read from the approve body/note.
160
- const recs = mapApprovers(pull.reviews, { roster: hub.roster || [], repos: registry.repos || [], touchedDomains: domain, headOid: pull.headOid });
155
+ // Map platform approvals → engineer_review entries, one per approving login (E62: no roster, so no
156
+ // role or domain to attach), carrying the engagement signal read from the approve body/note.
157
+ const recs = mapApprovers(pull.reviews, { headOid: pull.headOid });
161
158
  const seen = new Set();
162
159
  const engineerReview = [];
163
160
  for (const r of recs) {
164
- const key = `${r.name}|${r.role}|${r.domain || ''}`;
165
- if (seen.has(key)) continue;
166
- seen.add(key);
167
- engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
161
+ if (seen.has(r.name)) continue;
162
+ seen.add(r.name);
163
+ engineerReview.push({ approver: r.name, engagement: r.engagement === 'verified' ? 'verified' : 'none' });
168
164
  }
169
165
 
170
166
  // Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
@@ -177,7 +173,8 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
177
173
  );
178
174
  if (!res.found) {
179
175
  warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
180
- log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
176
+ // The block to attach, for a person to copy. Under --json the answer carries it as `engineerReview`.
177
+ if (!inJSON()) log(` ${JSON.stringify({ engineer_review: engineerReview })}`);
181
178
  return { engineerReview, written: false };
182
179
  }
183
180
  ok(`stamped engagement onto ${epic} ship ${res.ship.story || ''}${res.ship.task ? '/' + res.ship.task : ''} (PR #${pr})`);