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/openpr.mjs CHANGED
@@ -1,16 +1,20 @@
1
- // `yad open-pr` — open a code-repo task PR/MR from the repo's platform template (build half).
1
+ // `yad open-pr` — open a code-repo task PR/MR from the repo's platform template (Build).
2
2
  // Detects the platform, pushes the current branch, and creates the PR/MR with Summary / Story-task /
3
- // Impact & Risk prefilled. Distinct from `yad gate open`, which opens a front-half artifact-review PR
4
- // on the product hub.
3
+ // Impact & Risk prefilled. Distinct from `yad gate open`, which opens a Shape artifact-review PR
4
+ // on the Product.
5
5
  import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, hand, fail, run, exists, readJSON } from './lib.mjs';
8
- import { PROJECT_FILES } from './manifest.mjs';
8
+ import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
9
9
  import {
10
- detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin, resolveBaseBranch,
10
+ detectPlatform, createPr, platformLogin, resolveBaseBranch,
11
11
  } from './platform.mjs';
12
12
  import { taskFromBranch } from './commit.mjs';
13
- import { parseReviewBranch, artifactFromBase } from './epic-state.mjs';
13
+ import { parseReviewBranch, artifactFromBase, gateCapFor, capLimit, peopleWord, gateRuleSum, gateRuleEnforced, legacyLogins } from './epic-state.mjs';
14
+ import { activePeople } from './people.mjs';
15
+ import { baseCodeowners, ownersFor, parseCodeowners } from './codeowners.mjs';
16
+ import { baseChangeLevel, changedSince, recentAuthorsFor, suggestedAuthorsFor } from './riskmap-command.mjs';
17
+ import { personLabel } from './riskmap.mjs';
14
18
  import { gateOpen } from './gate.mjs';
15
19
 
16
20
  // Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
@@ -23,26 +27,26 @@ function resolveRepo(root, { repo, dir }) {
23
27
  return { repoRoot: path.resolve(root, dir || '.'), meta: null };
24
28
  }
25
29
 
26
- // Which SDLC stage is this PR? The hub serves two vehicles; a code repo only one. Mirrors the
27
- // `--head` split the hub pattern gates (pr-title.sh/pr-template.sh) already apply:
28
- // code-repo — NOT the product hub (a registry repo via --repo, or root is not a hub).
29
- // hub-front — the hub itself AND head is a review/EP-* branch (artifact-review PR).
30
- // hub-tooling — the hub itself AND head is anything else (a tooling/CI change to the hub).
30
+ // Which SDLC stage is this PR? The Product serves two vehicles; a code repo only one. Mirrors the
31
+ // `--head` split the Product pattern gates (pr-title.sh/pr-template.sh) already apply:
32
+ // code-repo — NOT the Product (a registry repo via --repo, or root is not a Product).
33
+ // hub-shape — the Product itself AND head is a review/EP-* branch (artifact-review PR).
34
+ // hub-tooling — the Product itself AND head is anything else (a tooling/CI change to the Product).
31
35
  // `meta` (truthy when resolved from the repos registry via --repo) is a connected code repo, so it is
32
- // never the hub regardless of its path. Otherwise "is the hub" = repoRoot resolves to root AND root
36
+ // never the Product regardless of its path. Otherwise "is the Product" = repoRoot resolves to root AND root
33
37
  // carries .sdlc/hub.json. path.resolve normalises `--dir .` / trailing slashes.
34
38
  export function detectStage(root, repoRoot, head, meta) {
35
39
  if (meta) return 'code-repo';
36
40
  const isHub = path.resolve(repoRoot) === path.resolve(root)
37
- && exists(path.join(root, PROJECT_FILES.hubConfig));
41
+ && exists(productConfigPath(root));
38
42
  if (!isHub) return 'code-repo';
39
- return /^review\/EP-[a-z0-9-]+\//.test(head || '') ? 'hub-front' : 'hub-tooling';
43
+ return /^review\/EP-[a-z0-9-]+\//.test(head || '') ? 'hub-shape' : 'hub-tooling';
40
44
  }
41
45
 
42
46
  // The bundled code-task template — the same file `REPO_WIRING` installs into code repos, resolved
43
47
  // from the package (mirrors how manifest.mjs reads ../package.json). Used for a hub-tooling PR, whose
44
48
  // `.github/pull_request_template.md` is the ARTIFACT-REVIEW template (wrong shape for the code-task
45
- // hub gate). Falls back to a minimal body that still carries every section the gate requires.
49
+ // Product gate). Falls back to a minimal body that still carries every section the gate requires.
46
50
  function codeTaskTemplate(platform) {
47
51
  const rel = platform === 'gitlab'
48
52
  ? '../skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md'
@@ -65,8 +69,8 @@ function codeTaskTemplate(platform) {
65
69
  }
66
70
 
67
71
  export function templateBody(repoRoot, platform, { task, summary, risk, contract, domains, stage }) {
68
- // hub-tooling: the hub's own template is artifact-review — use the bundled code-task template so the
69
- // body matches the shape the hub `pr-template` gate demands for a non-review head.
72
+ // hub-tooling: the Product's own template is artifact-review — use the bundled code-task template so the
73
+ // body matches the shape the Product `pr-template` gate demands for a non-review head.
70
74
  let base;
71
75
  if (stage === 'hub-tooling') {
72
76
  base = codeTaskTemplate(platform);
@@ -96,6 +100,134 @@ export function templateBody(repoRoot, platform, { task, summary, risk, contract
96
100
  return out;
97
101
  }
98
102
 
103
+ // E66 — how many approvers this PR asks for, printed once it is open: the body's own level and contract
104
+ // answer (what `--risk` / `--contract-change` put there), and the risk map on the BASE branch — a change
105
+ // touching a `high` directory adds the high step. The largest step wins, so the map can raise what the
106
+ // body says and never lower it. The same sum `checks/risk-route.sh` prints from the PR body. Printed
107
+ // only, never written: the body keeps the author's level (the user's decision, 2026-09-18).
108
+ export function routeCount(repoRoot, baseBranch, opts = {}) {
109
+ const map = baseChangeLevel(repoRoot, `origin/${baseBranch}`);
110
+ const high = (map.dirs || []).filter((d) => d.level === 'high').map((d) => `${d.dir}${d.state === 'guessed' ? ' (guessed)' : ''}`);
111
+ let riskStep = 0;
112
+ let risk = 'normal';
113
+ if (opts.risk === 'high' || high.length) { riskStep = 1; risk = 'high'; }
114
+ if (opts.contractChange) { riskStep = 2; risk = 'contract'; }
115
+ const rule = { base: 1, riskStep, needed: 1 + riskStep, risk };
116
+ const lines = [];
117
+ if (map.unknown) lines.push([info, `risk map not counted — ${map.unknown}; the count below is the body's alone`]);
118
+ else if (map.noMap) lines.push([info, `risk map: none — ${map.noMap}`]);
119
+ else if (high.length) lines.push([info, `risk map on ${map.base}: high — ${high.join(', ')}`]);
120
+ // E67 — who can meet the ask a `high` directory makes: someone who has committed there in the last 30
121
+ // days, from the BASE branch's history, this change's own authors left out. Reported, never enforced.
122
+ if (high.length) {
123
+ const hist = recentAuthorsFor(repoRoot, `origin/${baseBranch}`, { entries: map.entries, changed: map.changed });
124
+ if (hist.unknown) lines.push([info, `who has worked there lately: not read — ${hist.unknown}`]);
125
+ else if (hist.authors.length) {
126
+ lines.push([hand, `this change asks for an approval from someone who has committed in ${high.join(', ')} in the last 30 days: ${hist.authors.map(personLabel).join(', ')}`]);
127
+ } else lines.push([info, `nobody else has committed in ${high.join(', ')} in the last 30 days — ask anyone who knows it`]);
128
+ }
129
+ if (high.length && opts.risk !== 'high') lines.push([warn, `the body says Risk level: ${opts.risk || 'low'}, but the risk map on ${map.base} marks ${high.join(', ')} high — the larger counts`]);
130
+ // E72 — the cap, SHOWN and never enforced here. The Build count never holds a merge: the platform's
131
+ // branch protection does (Part 9), and the user decided (2026-09-21) that the Build half stays
132
+ // reported even after E108 turns the Shape cap on. That is also E66's answer: the CI half of this count runs from the PR's own checkout of
133
+ // `checks/`, so a PR can edit the script that counts it — harmless only because nothing blocks on it.
134
+ // `gateRuleEnforced` is called WITHOUT the cap on purpose, so the line keeps saying "advisory".
135
+ const cap = gateCapFor(rule, opts.active);
136
+ const capped = cap?.capped ? `, capped to ${cap.to} for ${cap.active} active ${peopleWord(cap.active)}` : '';
137
+ lines.push([riskStep ? hand : info, `this PR asks for ${gateRuleSum(rule)}${capped}${gateRuleEnforced(rule)}; \`bash checks/risk-route.sh "<pr body>" ${map.base}\` prints the count without the cap`]);
138
+ // E71 — how many people are around to meet that ask. `opts.active` is the Product-wide count the
139
+ // CALLER already read: this function runs in a code repo, which has no Product of its own to read
140
+ // (the E65 finding that keeps `checks/*.sh` out of this entirely), so it is handed the number or
141
+ // nothing. `null` means no source could be read, which is never the same as "few people".
142
+ if (opts.active !== undefined) {
143
+ lines.push([info, opts.active === null
144
+ ? 'active people: not counted — no cap applies; reported only, and an unreadable source is never read as few people'
145
+ : `active people: ${opts.active} — caps the count at ${capLimit(opts.active)}; reported only, a Build merge is held by branch protection, not by this count`]);
146
+ }
147
+ return { rule, map, lines };
148
+ }
149
+
150
+ // E68 — a reviewer SUGGESTION, printed once the PR is open. Two separate hints, never merged: the people
151
+ // who have committed in the folders this change touches in the last 30 days (base branch, the change's
152
+ // own authors and robots left out), and what CODEOWNERS on the base lists for those files. It holds
153
+ // nothing, waives nothing and requests no one — E62 decided PRs request no reviewers, and a name here is
154
+ // a hint about who MAY know the code, never a claim about who owns it or who must approve. A git name
155
+ // and a CODEOWNERS login are joined only on exact evidence: a commit address that carries that login.
156
+ export const SUGGEST_SHOWN = 5;
157
+ // The host of a remote URL — `https://host/…`, `ssh://user@host:port/…` or scp-like `user@host:path` —
158
+ // lower-cased, or null.
159
+ export function remoteHost(url) {
160
+ const u = String(url || '').trim();
161
+ const m = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^/:]+)/i) || u.match(/^(?:[^@/]+@)?([^:/]+):(?!\/\/)/);
162
+ return m ? m[1].toLowerCase() : null;
163
+ }
164
+ // A noreply login is a github.com or gitlab.com account. It is evidence for a CODEOWNERS `@name` only
165
+ // on that same public host: a self-managed GitLab or GitHub Enterprise keeps its own accounts, and
166
+ // one spelled the same may be someone else.
167
+ export const PUBLIC_HOST = { github: 'github.com', gitlab: 'gitlab.com' };
168
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
169
+ export function suggestReviewers(repoRoot, baseBranch, platform, { remote } = {}) {
170
+ const base = `origin/${baseBranch}`;
171
+ const url = remote ?? run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
172
+ const sameHost = remoteHost(url) === PUBLIC_HOST[platform];
173
+ const lines = [];
174
+ const ch = changedSince(repoRoot, base);
175
+ if (ch.unknown) return { lines: [[info, `reviewer suggestion: not read — ${ch.unknown}`]] };
176
+ if (!ch.files.length) return { lines: [[info, `reviewer suggestion: none — this branch changes no file against ${base}`]] };
177
+
178
+ const hist = suggestedAuthorsFor(repoRoot, base, { changed: ch.files });
179
+ const where = hist.folders > 1 ? `the ${hist.folders} folders this change touches` : hist.folders === 1 ? 'the folder this change touches' : 'the folders this change touches';
180
+ const cut = hist.skipped ? ` (${plural(hist.skipped, 'more folder')} not asked about — the list stops at ${hist.folders})` : '';
181
+ const logins = new Set();
182
+ if (hist.unknown) lines.push([info, `who has committed in ${where} lately: not read — ${hist.unknown}`]);
183
+ else if (!hist.authors.length) lines.push([info, `nobody else has committed in ${where} in the last 30 days${cut}`]);
184
+ else {
185
+ // Only a login from THIS platform's noreply address is evidence for a CODEOWNERS `@name` here.
186
+ for (const a of hist.authors) if (a.login && a.loginHost === platform && sameHost) logins.add(a.login);
187
+ const shown = hist.authors.slice(0, SUGGEST_SHOWN).map((a) => `${personLabel(a)} — ${plural(a.commits, 'commit')}`);
188
+ const more = hist.authors.length > SUGGEST_SHOWN ? `, and ${hist.authors.length - SUGGEST_SHOWN} more` : '';
189
+ lines.push([hand, `may know this code — committed in ${where} in the last 30 days: ${shown.join(', ')}${more}${cut}`]);
190
+ }
191
+
192
+ const co = baseCodeowners(repoRoot, base, platform);
193
+ let listed = false;
194
+ if (co.unknown) lines.push([info, `CODEOWNERS: not read — ${co.unknown}`]);
195
+ else if (co.none) lines.push([info, `CODEOWNERS: none — ${co.none}`]);
196
+ else {
197
+ const parsed = parseCodeowners(co.text, platform);
198
+ const got = ownersFor(parsed, ch.files);
199
+ // A file with no owner: no line matched it, the matching line names nobody, or a GitLab `!` line
200
+ // excludes it — the same answer for a reviewer, so one count.
201
+ const unmatched = got.unmatched ? `; ${got.unmatched} of ${plural(ch.files.length, 'touched file')} ${got.unmatched === 1 ? 'has' : 'have'} no owner there` : '';
202
+ // A line this reader skipped could have been the last match for a file, so an answer built without it
203
+ // may be wrong — and "nobody" most of all, since the skipped line may be the only one that matched.
204
+ const skippedN = parsed.skipped.length ? plural(parsed.skipped.length, 'line') : '';
205
+ if (!got.owners.length) {
206
+ lines.push([info, skippedN
207
+ ? `CODEOWNERS on ${base} (${co.path}) lists nobody this reader could match for the files this change touches; ${skippedN} could not be read, so this may be wrong`
208
+ : `CODEOWNERS on ${base} (${co.path}) lists nobody for the files this change touches`]);
209
+ } else {
210
+ listed = true;
211
+ const tag = { team: ' (a team)', group: ' (a group)', role: ' (a role)' };
212
+ const shown = got.owners.slice(0, SUGGEST_SHOWN).map((o) => {
213
+ // "the 30-day history", not "above": the person may be one of the `and N more` not shown.
214
+ const also = (o.kind === 'user' || o.kind === 'name') && logins.has(o.text.slice(1)) ? ', also in the 30-day history' : '';
215
+ const notes = [tag[o.kind]?.slice(2, -1), o.optional ? 'optional section' : '', also.slice(2)].filter(Boolean);
216
+ return `${o.text}${notes.length ? ` (${notes.join(', ')})` : ''}`;
217
+ });
218
+ const more = got.owners.length > SUGGEST_SHOWN ? `, and ${got.owners.length - SUGGEST_SHOWN} more` : '';
219
+ const gl = platform === 'gitlab' && got.owners.some((o) => o.kind === 'name') ? '; on GitLab an @name can be a person or a group' : '';
220
+ const partial = skippedN ? `; ${skippedN} could not be read, so this list may be wrong` : '';
221
+ lines.push([hand, `CODEOWNERS on ${base} (${co.path}) lists for the touched files: ${shown.join(', ')}${more}${unmatched}${partial} — a hint only: these files go stale, and yad does not check that anyone listed still works here${gl}`]);
222
+ }
223
+ for (const s of parsed.skipped.slice(0, 3)) lines.push([warn, `CODEOWNERS line ${s.line} not read — ${s.why}`]);
224
+ if (parsed.skipped.length > 3) lines.push([warn, `CODEOWNERS: ${plural(parsed.skipped.length - 3, 'more line')} not read`]);
225
+ }
226
+ const both = listed && hist.authors?.length;
227
+ lines.push([info, `a suggestion only — nobody was asked to review, and no name above is an owner or a required approver${both ? '; the same person can appear in both lists under two names (yad joins them only when a commit address carries the exact @login)' : ''}`]);
228
+ return { lines };
229
+ }
230
+
99
231
  export async function runOpenPr(root, opts = {}) {
100
232
  log(c.bold('\nyad open-pr'));
101
233
  const { repoRoot, meta } = resolveRepo(root, opts);
@@ -108,11 +240,11 @@ export async function runOpenPr(root, opts = {}) {
108
240
  const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot }).stdout;
109
241
  const stage = detectStage(root, repoRoot, branch, meta);
110
242
 
111
- // hub-front: this is a front-half artifact-review PR (review/EP-*/<artifact> head on the hub). The
243
+ // hub-shape: this is a Shape artifact-review PR (review/EP-*/<artifact> head on the Product). The
112
244
  // artifact-review title, body, and ledger bookkeeping all live in `yad gate open` — delegate to it
113
- // rather than emit the code-task shape (which the hub gate would reject). Push first (gateOpen does
245
+ // rather than emit the code-task shape (which the Product gate would reject). Push first (gateOpen does
114
246
  // not push), then hand off; any --title/--message is dropped (gateOpen sets `review: …`).
115
- if (stage === 'hub-front') {
247
+ if (stage === 'hub-shape') {
116
248
  const parsed = parseReviewBranch(branch);
117
249
  if (!parsed) { fail(`could not parse review branch '${branch}' (expected review/EP-<slug>/<artifact>)`); process.exitCode = 1; return; }
118
250
  info(`pushing ${branch} …`);
@@ -121,19 +253,19 @@ export async function runOpenPr(root, opts = {}) {
121
253
  // Pass the branch we just pushed as the head so gateOpen opens the PR against it (its own
122
254
  // recompute would collapse a per-story base). gateOpen signals failure by returning no url —
123
255
  // mirror open-pr's own error contract so `ship` sees the non-zero exit and never reports success.
124
- // (On a platform-less hub gateOpen marks the step in_review file-only and returns no url; open-pr's
256
+ // (On a platform-less Product gateOpen marks the step in_review locally and returns no url; open-pr's
125
257
  // job is to open a PR, so "no PR opened" is a non-zero outcome here, unlike `yad gate open`.)
126
258
  const res = await gateOpen(root, { epic: parsed.epic, artifact: artifactFromBase(parsed.base), head: branch });
127
259
  if (!res?.url) process.exitCode = 1;
128
260
  return res;
129
261
  }
130
262
 
131
- // The hub roster + its default_branch. The latter only applies when the PR targets the hub ITSELF
132
- // (a hub-tooling branch) — for a connected code repo the hub's trunk belongs to a different repo and
133
- // must never leak in. Resolved AFTER the hub-front hand-off above, which delegates its own base to
263
+ // The Product's default_branch, which only applies when the PR targets the Product ITSELF
264
+ // (a hub-tooling branch) — for a connected code repo the Product's trunk belongs to a different repo and
265
+ // must never leak in. Resolved AFTER the hub-shape hand-off above, which delegates its own base to
134
266
  // `yad gate open`: resolving before it would spend a platform round-trip and print a base that the
135
267
  // delegated path then ignores.
136
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
268
+ const hub = readJSON(productConfigPath(root), {});
137
269
 
138
270
  // Resolve the base rather than assume it (#168). Hardcoding 'main' mis-based every PR on a repo
139
271
  // whose trunk is something else — and CodeRabbit decides auto-review eligibility from the base at
@@ -179,25 +311,45 @@ export async function runOpenPr(root, opts = {}) {
179
311
  task, summary, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name, stage,
180
312
  });
181
313
 
182
- // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
183
- // local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
184
- // Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
185
- const roster = hub.roster || [];
186
- const committer = resolveCommitterLogin(repoRoot, roster);
187
- const scope = meta?.name ? [meta.name] : [];
188
- // Pass the repo registry entry so a domain owner declared only in repos.json (not the roster roles
189
- // map) is still requested as a reviewer (BUG-1).
190
- const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer, repos: meta ? [meta] : [] });
314
+ // Assignee = the committer: `@me` on GitHub (buildPrArgs sends it when no assignee is named, and `gh`
315
+ // resolves it on the repo's own host), the login `glab` reports on GitLab. No reviewers are requested (E62): the roster that named them is gone. The team requests them
316
+ // on the PR; E68 prints a suggestion from history and CODEOWNERS below, and requests no one.
317
+ const committer = platform === 'gitlab' ? platformLogin(repoRoot, platform) : null;
191
318
  const assignees = committer ? [committer] : [];
192
319
 
193
320
  // `creator` is injectable (mirrors gateOpen's) so a test can assert the base that reaches the
194
321
  // platform CLI without shelling out to gh/glab.
195
322
  const creator = opts.creator || createPr;
196
- const r = creator(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
323
+ const r = creator(platform, { title, body, base: baseBranch, head: branch, assignees, cwd: repoRoot });
197
324
  if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
198
325
  ok(`opened ${r.url}`);
199
- if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
200
- if (r.dropped?.length) hand(`could not request as reviewer (unknown/non-collaborator login): ${r.dropped.join(', ')}`);
201
- if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
202
- return { url: r.url };
326
+ hand('no reviewers were requested — ask them on the PR itself');
327
+ // The Product-wide active count (E71), read ONCE here and handed down: `routeCount` works inside a
328
+ // code repo and must not go looking for a Product itself.
329
+ //
330
+ // ONLY WHEN `root` REALLY IS A PRODUCT. `root` is the working directory, and running this command
331
+ // from inside the code repo is a supported path — `detectStage` returns `code-repo` for exactly that
332
+ // case. Counting from there would have walked the CODE REPO's history and reported its committers as
333
+ // the whole team: no `epics/` (so no ledger and no `unknown`), no `.sdlc/repos.json` (which is a
334
+ // readable "no connected repos"), and one git log that works — a confident, smaller, wrong number,
335
+ // feeding the number E72 caps with. `null` is the honest answer there: we are not standing in a
336
+ // Product, so we did not count.
337
+ const active = (() => {
338
+ // Only the `code-repo` stage prints it (`routeCount`, below). On a `hub-tooling` PR this would
339
+ // otherwise walk the Product and every connected repo's history and throw the answer away.
340
+ if (stage !== 'code-repo') return null;
341
+ if (!exists(productConfigPath(root))) return null;
342
+ // `readJSON`, not `readJSONStrict`, and that is not the degradation cli/people.mjs warns about:
343
+ // losing the roster's name -> login table only stops OLDER records being joined to a login, so a
344
+ // person lands on two rows instead of one. That over-counts, the safe direction here. The count
345
+ // itself still refuses on every source it cannot read.
346
+ try { return activePeople(root, { aliases: legacyLogins(readJSON(productConfigPath(root), null)) }).capacity.active; } catch { return null; }
347
+ })();
348
+ const count = stage === 'code-repo' ? routeCount(repoRoot, baseBranch, { ...opts, active }) : null;
349
+ if (count) for (const [say, line] of count.lines) say(line);
350
+ else if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` to see how many approvers it asks for');
351
+ if (stage === 'code-repo') for (const [say, line] of suggestReviewers(repoRoot, baseBranch, platform, { remote }).lines) say(line);
352
+ // The --json answer (E1). The count and the reviewer suggestion are said in prose, and a warning among
353
+ // them reaches `warnings`; nothing here is a request — no reviewer is ever asked for (E62).
354
+ return { url: r.url, platform, stage, base: baseBranch, baseSource, branch, title, task: task || null };
203
355
  }