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/setup.mjs CHANGED
@@ -5,36 +5,23 @@ import os from 'node:os';
5
5
  import {
6
6
  c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
7
7
  exists, readJSON, readJSONStrict, writeJSON,
8
+ writeProductConfig,
8
9
  } from './lib.mjs';
9
- import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
10
+ import { VERSION, IDE_TARGETS, IDE_AGENTS, DEFAULT_IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY , productConfigPath } from './manifest.mjs';
10
11
  import {
11
- moduleActions, repoActions, hubActions, hookActions, authorsActions,
12
+ moduleActions, repoActions, productActions, hookActions,
12
13
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
14
  safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
14
15
  } from './plan.mjs';
15
- import { validateLogin, rolesForScope } from './platform.mjs';
16
+ import { modeFields, modeOf } from './mode.mjs';
17
+ import { recordActor } from './skip.mjs';
18
+ import { loadSkillBindings, stepSkills } from './epic-state.mjs';
16
19
 
17
20
  // Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
18
21
  export function parseList(s) {
19
22
  return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
20
23
  }
21
24
 
22
- // Parse a per-scope roles spec — `"hub=owner,reviewer backend=domain-owner"` — into the roster's
23
- // per-scope map `{ hub: ['owner','reviewer'], backend: ['domain-owner'] }`. Tokens are whitespace
24
- // separated; each is `scope=role[,role...]`. Malformed tokens (no `=`, empty scope/roles) are skipped.
25
- export function parseRolesSpec(s) {
26
- const out = {};
27
- for (const tok of (s || '').split(/\s+/).map((x) => x.trim()).filter(Boolean)) {
28
- const eq = tok.indexOf('=');
29
- if (eq < 0) continue;
30
- const scope = tok.slice(0, eq).trim();
31
- const roles = parseList(tok.slice(eq + 1));
32
- if (!scope || !roles.length) continue;
33
- out[scope] = [...new Set([...(out[scope] || []), ...roles])];
34
- }
35
- return out;
36
- }
37
-
38
25
  // Programmatic setup is strict; interactive setup keeps asking until it receives at least one valid
39
26
  // canonical target. Both paths return the same trimmed, ordered, deduplicated representation.
40
27
  export async function selectIdeTargets(root, provided, asker = ask) {
@@ -42,9 +29,14 @@ export async function selectIdeTargets(root, provided, asker = ask) {
42
29
  const detected = detectedIdeTargetStateFor(root);
43
30
  const present = detected.targets;
44
31
  for (const unsafe of detected.unsafe) warn(`${unsafe.message}; excluded from IDE defaults`);
45
- const def = (present.length ? present : ['.claude']).join(',');
32
+ // Detected directories win: a project that already has `.cursor/` gets it offered back, and is not
33
+ // talked into a default it did not choose. Only a project with NO agent directory sees the default.
34
+ const def = (present.length ? present : DEFAULT_IDE_TARGETS).join(',');
46
35
  for (;;) {
47
- const answer = await asker(`IDE targets to install ${c.dim('(comma-separated: ' + IDE_TARGETS.join(', ') + ')')}`, def);
36
+ // Each target is listed with the agents that actually read it, because the directory name does not
37
+ // say. A Codex or Gemini CLI user shown a bare `.agents` has no way to know it is the one for them.
38
+ const menu = IDE_TARGETS.map((t) => `${t} = ${(IDE_AGENTS[t] || []).join(', ')}`).join('; ');
39
+ const answer = await asker(`IDE targets to install ${c.dim('(comma-separated — ' + menu + ')')}`, def);
48
40
  if (answer === undefined || answer === null) throw new Error('IDE target selection ended before a valid choice was provided');
49
41
  const values = String(answer ?? '').split(',').map((s) => s.trim()).filter(Boolean);
50
42
  try {
@@ -63,22 +55,22 @@ export function detectPlatform(remoteUrl = '') {
63
55
  }
64
56
  export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
65
57
 
66
- // Containment: a repo path must live inside the WORKSPACE — the hub root's parent. The standard
67
- // multi-repo layout puts the code repos BESIDE the hub, not under it (project/{product,backend,frontend}),
68
- // so `../backend` has to register; containing to the hub root instead forced separate git repos to nest
69
- // inside the hub's own repo (issue #129). A nested path (demo-repos/api) still works.
58
+ // Containment: a repo path must live inside the WORKSPACE — the Product root's parent. The standard
59
+ // multi-repo layout puts the code repos BESIDE the Product, not under it (project/{product,backend,frontend}),
60
+ // so `../backend` has to register; containing to the Product root instead forced separate git repos to nest
61
+ // inside the Product's own repo (issue #129). A nested path (demo-repos/api) still works.
70
62
  //
71
63
  // The bound stays real: the registry path is later joined and executed against (repomix cwd,
72
64
  // .coderabbit.yaml + CI wiring), and even the read-only remote probe must not run against an arbitrary
73
65
  // outside path — so `../../elsewhere` and absolute-outside paths are still rejected. The path.sep-suffixed
74
66
  // compare avoids the /project vs /project-evil prefix trap, now one level up at the workspace.
75
- // A sibling of the hub (../product-evil) is, correctly, indistinguishable from ../backend: both are
76
- // ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the hub, so
77
- // registering it as a code repo would point repomix and the CI writes at the whole tree. Only the hub
67
+ // A sibling of the Product (../product-evil) is, correctly, indistinguishable from ../backend: both are
68
+ // ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the Product, so
69
+ // registering it as a code repo would point repomix and the CI writes at the whole tree. Only the Product
78
70
  // root itself (a monorepo, `.`) and strict descendants of the workspace pass.
79
71
  //
80
- // Place the hub one level below the workspace root (project/product), not directly in $HOME — the
81
- // workspace is the trust boundary, and a shallow hub makes every sibling of it registerable.
72
+ // Place the Product one level below the workspace root (project/product), not directly in $HOME — the
73
+ // workspace is the trust boundary, and a shallow Product makes every sibling of it registerable.
82
74
  export function insideWorkspace(root, rpath) {
83
75
  const projectRoot = path.resolve(root);
84
76
  const parent = path.dirname(projectRoot);
@@ -87,145 +79,19 @@ export function insideWorkspace(root, rpath) {
87
79
  return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
88
80
  }
89
81
 
90
- // Validate + record one code repo into the registry (the testable half of the connect loop).
91
- // A path that is not a git repository is rejected and NOTHING is written — a registry entry with
92
- // syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
93
- // Grant per-repo roles to roster members by writing into each person's `roles[<repo>]` array in
94
- // hub.json. `grants` maps a role -> the yad names that hold it for this repo. A name that is not in
95
- // the roster is warned about and skipped (the roster is the source of identity). Idempotent.
96
- export function addRepoRoles(root, repo, grants = {}) {
97
- const hubPath = path.join(root, PROJECT_FILES.hubConfig);
98
- const hub = readJSON(hubPath, null);
99
- if (!hub || !Array.isArray(hub.roster)) return;
100
- const byName = new Map(hub.roster.map((e) => [e.name, e]));
101
- let touched = false;
102
- for (const [role, names] of Object.entries(grants)) {
103
- for (const nm of names) {
104
- const entry = byName.get(nm);
105
- if (!entry) { warn(`'${nm}' is not in the roster — skipped ${role} for ${repo}`); continue; }
106
- // Normalize to the per-scope map, migrating the legacy shapes: a flat array or a single
107
- // `role` string both become hub roles so nothing is lost.
108
- if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
109
- const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
110
- entry.roles = hub.length ? { hub } : {};
111
- delete entry.role;
112
- }
113
- const list = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
114
- if (!list.includes(role)) { list.push(role); touched = true; }
115
- entry.roles[repo] = list;
116
- }
117
- }
118
- if (touched) writeJSON(hubPath, hub);
119
- }
120
-
121
- // Normalize a roster entry's roles in place to the per-scope map, migrating the two legacy shapes
122
- // (a flat array, or a single `role` string) into `roles.hub` so nothing is lost. Mirrors the
123
- // migration addRepoRoles does; pulled out so upsert/remove share it.
124
- function normalizeRoles(entry) {
125
- if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
126
- const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
127
- entry.roles = hub.length ? { hub } : {};
128
- delete entry.role;
129
- }
130
- return entry.roles;
131
- }
132
-
133
- // Build the hub.json object for a (re)configure write, preserving user-owned identity data the wizard
134
- // does not re-collect: top-level `verified_authors` (and any other existing fields), plus a previously
135
- // populated `roster` when this run collected none (solo re-runs skip the reviewer loop). Never blanks a
136
- // non-empty roster; never drops verified_authors. Mirrors the safe { ...cur } merge on the keep path.
137
- // Trade-off (deliberate, fail-safe): a reconfigure can no longer EMPTY a populated roster — collecting
138
- // zero reviewers keeps the existing entries. To actually remove members, use `yad roster remove` (or
139
- // edit hub.json directly); this flow only ever grows or replaces the roster, never silently clears it.
82
+ // Build the hub.json object for a (re)configure write: the fields this run collected, laid over the
83
+ // existing file so nothing the wizard does not ask about is dropped. Mirrors the safe { ...cur } merge
84
+ // on the keep path. That includes a `roster` or `verified_authors` an older release wrote (E62): the
85
+ // wizard no longer collects or reads either, and it never deletes them — `yad doctor` names them as
86
+ // unused instead.
140
87
  export function buildReconfiguredHub(cur, fields) {
141
- const { roster, ...rest } = fields;
142
- const keptRoster = (Array.isArray(roster) && roster.length)
143
- ? roster
144
- : (Array.isArray(cur?.roster) ? cur.roster : []);
145
- return { ...(cur || {}), ...rest, roster: keptRoster };
146
- }
147
-
148
- // Upsert one roster member into hub.json, keyed by `login`. Deep-merges the per-scope `roles` map so
149
- // scopes the caller did not name are preserved; sets `name`/`email` when given; validates the login
150
- // against the hub (warn-only — a miss flags `unverified`, `checked:false` skips silently). Creates the
151
- // hub.json shell if absent. Returns { entry, created }.
152
- export function upsertRosterEntry(root, { login, name, email, roles = {}, platform } = {}) {
153
- if (!login) { warn('roster upsert needs a login — skipped'); return { entry: null, created: false }; }
154
- const hubPath = path.join(root, PROJECT_FILES.hubConfig);
155
- const hub = readJSON(hubPath, null) || { platform: platform && platform !== 'none' ? platform : null, bridge_enabled: false, bridge: false, default_branch: 'main', roster: [] };
156
- if (!Array.isArray(hub.roster)) hub.roster = [];
157
- let entry = hub.roster.find((e) => e.login === login);
158
- const created = !entry;
159
- if (!entry) { entry = { login, name: name || login, roles: {} }; hub.roster.push(entry); }
160
- if (name) entry.name = name;
161
- if (email) entry.email = email;
162
- normalizeRoles(entry);
163
- for (const [scope, list] of Object.entries(roles || {})) {
164
- const cur = Array.isArray(entry.roles[scope]) ? entry.roles[scope] : [];
165
- entry.roles[scope] = [...new Set([...cur, ...list])];
166
- }
167
- if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
168
- const plat = platform || hub.platform;
169
- if (plat && plat !== 'none') {
170
- const v = validateLogin(plat, login);
171
- if (v.checked && !v.exists) { warn(`'${login}' not found on ${plat} — saved as unverified`); entry.unverified = true; }
172
- else if (v.checked && v.exists) { ok(`verified ${login} on ${plat}`); delete entry.unverified; }
173
- }
174
- writeJSON(hubPath, hub);
175
- return { entry, created };
88
+ return { ...(cur || {}), ...fields };
176
89
  }
177
90
 
178
- // Inverse of addRepoRoles: drop the named role(s) from a member's `roles[<repo>]` scope, removing the
179
- // scope key when it empties. Member is found by yad `name` (matching addRepoRoles). Idempotent.
180
- export function removeRepoRole(root, name, repo, roles = []) {
181
- const hubPath = path.join(root, PROJECT_FILES.hubConfig);
182
- const hub = readJSON(hubPath, null);
183
- if (!hub || !Array.isArray(hub.roster)) return;
184
- const entry = hub.roster.find((e) => e.name === name);
185
- if (!entry) { warn(`'${name}' is not in the roster — nothing to revoke for ${repo}`); return; }
186
- normalizeRoles(entry);
187
- const cur = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
188
- const next = cur.filter((r) => !roles.includes(r));
189
- if (next.length === cur.length) return; // nothing removed
190
- if (next.length) entry.roles[repo] = next; else delete entry.roles[repo];
191
- writeJSON(hubPath, hub);
192
- }
193
-
194
- // Keep repos.json `domain_owners` in sync when a domain-owner role is granted/revoked via the roster,
195
- // so the gate's per-repo reviewer-routing and the derivation fallback never drift from hub.json. Adds
196
- // or removes the yad `name` and mirrors `domain_owner = domain_owners[0]`. No-op + warn if the repo is
197
- // not registered. Returns true when the registry was written.
198
- export function setRepoDomainOwners(root, repo, name, { add = true } = {}) {
199
- const regPath = path.join(root, PROJECT_FILES.reposRegistry);
200
- const registry = readJSON(regPath, { repos: [] });
201
- const entry = (registry.repos || []).find((r) => r.name === repo);
202
- if (!entry) { warn(`repo '${repo}' is not registered (.sdlc/repos.json) — domain_owners not synced`); return false; }
203
- const owners = Array.isArray(entry.domain_owners) ? [...entry.domain_owners] : (entry.domain_owner ? [entry.domain_owner] : []);
204
- const has = owners.includes(name);
205
- let next;
206
- if (add) { if (has) return false; next = [...owners, name]; }
207
- else { if (!has) return false; next = owners.filter((o) => o !== name); }
208
- entry.domain_owners = next;
209
- entry.domain_owner = next[0] || '';
210
- writeJSON(regPath, registry);
211
- return true;
212
- }
213
-
214
- // Reconcile one repo's roles for a member to exactly `want`: grant what is new, revoke what is gone,
215
- // and mirror domain-owner changes into repos.json. `current` is the member's existing roles for the
216
- // repo. Shared by the `yad roster` walk and the `yad setup` per-repo role step. Idempotent.
217
- export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
218
- const toAdd = want.filter((r) => !current.includes(r));
219
- const toRemove = current.filter((r) => !want.includes(r));
220
- if (!toAdd.length && !toRemove.length) { info(` ${repo}: unchanged`); return; }
221
- if (toAdd.length) addRepoRoles(root, repo, Object.fromEntries(toAdd.map((r) => [r, [name]])));
222
- if (toRemove.length) removeRepoRole(root, name, repo, toRemove);
223
- if (toAdd.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: true });
224
- if (toRemove.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: false });
225
- ok(` ${repo}: ${want.length ? want.join(', ') : 'cleared'}`);
226
- }
227
-
228
- export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
91
+ // Validate + record one code repo into the registry (the testable half of the connect loop).
92
+ // A path that is not a git repository is rejected and NOTHING is written — a registry entry with
93
+ // syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
94
+ export function registerRepo(root, registry, { name, rpath, platform, default_branch = 'main', today = null, pack = true }) {
229
95
  if (!insideWorkspace(root, rpath)) {
230
96
  warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
231
97
  return null;
@@ -240,12 +106,11 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
240
106
  if (plat) warn(`unknown platform '${platform}' — using ${detected}`);
241
107
  plat = detected;
242
108
  }
243
- // A repo can have multiple domain owners. `domain_owners` is the array of record; `domain_owner`
244
- // is kept as the first element so anything still reading the legacy single-owner field works.
245
- const owners = (domain_owners && domain_owners.length) ? domain_owners : (domain_owner ? [domain_owner] : []);
109
+ // No `domain_owner` / `domain_owners` (E62): people are not stored. A registry entry an older release
110
+ // wrote may still carry them; nothing reads them, and `yad doctor` names them as unused.
246
111
  const repo = {
247
112
  name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
248
- domain_owner: owners[0] || '', domain_owners: owners, default_branch,
113
+ default_branch,
249
114
  connectedAt: today, lastSyncedAt: today,
250
115
  // Only claim a synced HEAD when a pack is actually produced. The greenfield path skips packing
251
116
  // (pack:false), so leave syncedHead null — the repo then reads as "needs an initial pack" in
@@ -263,7 +128,7 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
263
128
  // Record the project's design-tool connection into .sdlc/design.json (the deterministic half of the
264
129
  // connect loop; MCP detection itself is an AI step, handed off to `yad-connect-design`). An unknown tool
265
130
  // falls back to the primary adapter rather than being rejected — mirrors registerRepo's platform
266
- // fallback and the hub step. `none` is the explicit markdown-only choice.
131
+ // fallback and the Product step. `none` is the explicit markdown-only choice.
267
132
  export function registerDesign(root, { tool, project_url = null, files = null, today = null } = {}) {
268
133
  // Idempotent re-connect: carry the original first-connect date forward (the schema defines
269
134
  // connectedAt as "first connect"); only lastSyncedAt moves. Mirrors repo.mjs refresh.
@@ -345,6 +210,18 @@ export function registerLearning(root, { tool, kb = null, today = null } = {}) {
345
210
  return learning;
346
211
  }
347
212
 
213
+ // The solo switch as setup writes it: `solo` and `mode` in step, through the same `modeFields` `yad mode` uses
214
+ // (E10). Setup records `mode_set` only when it CHANGES the mode of a Product that already had a config — a
215
+ // first run is not a switch. It takes no reason, because an interview answer or a `--solo` / `--team` flag
216
+ // is the choice itself; the record names `yad setup` so the change is still attributable.
217
+ export function setupModeFields(root, cur, solo, opts = {}) {
218
+ const had = !!cur && typeof cur === 'object' && Object.keys(cur).length > 0;
219
+ const { mode_set: set, ...fields } = modeFields(cur, solo ? 'solo' : 'team', {
220
+ by: had ? recordActor(root) : null, date: opts.today ?? null, reason: 'yad setup',
221
+ });
222
+ return had && set ? { ...fields, mode_set: set } : fields;
223
+ }
224
+
348
225
  function applyActions(actions, { force = false } = {}) {
349
226
  let changed = 0;
350
227
  for (const a of actions) {
@@ -357,7 +234,7 @@ function applyActions(actions, { force = false } = {}) {
357
234
  if (a.status === 'ok' && !force) continue;
358
235
  a.apply();
359
236
  changed++;
360
- info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
237
+ info(`${a.status === 'missing' || a.status === 'new' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
361
238
  }
362
239
  if (!changed) info('already up to date');
363
240
  return changed;
@@ -368,19 +245,23 @@ function applyActions(actions, { force = false } = {}) {
368
245
  // otherwise we prompt with a default. Pure of side effects — it only reads. Returns
369
246
  // { solo, team_size, codebase, repo_layout, configureTools }.
370
247
  export async function resolveProfile(root, opts = {}) {
371
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
248
+ const hub = readJSON(productConfigPath(root), null);
372
249
  const prev = (hub && hub.profile) || {};
373
250
 
374
251
  // 1. Solo or team (+ size). --solo / --team <n> win; else carry hub.solo forward; else ask.
375
252
  let solo, team_size;
376
253
  if (opts.solo) { solo = true; team_size = 1; }
377
254
  else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
378
- else if (typeof hub?.solo === 'boolean') { solo = hub.solo; team_size = prev.team_size ?? (solo ? 1 : 2); }
255
+ // Either spelling carries forward: the older `review_gate.solo: true` also waives the gates, and asking again
256
+ // would default a configured team Product to solo and switch approvals off with nobody choosing it (E10).
257
+ else if (typeof hub?.solo === 'boolean' || hub?.review_gate?.solo === true) { solo = modeOf(hub) === 'solo'; team_size = prev.team_size ?? (solo ? 1 : 2); }
379
258
  else {
380
- // Default from any existing roster: a hub already carrying reviewers is a team; otherwise solo.
381
- const rosterN = Array.isArray(hub?.roster) ? hub.roster.length : 0;
382
- solo = !(await ask('Solo or team?', rosterN > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
383
- team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(rosterN || 2)), 10) || 2);
259
+ // No mode recorded. The default used to come from the roster's size; it now comes from the team size
260
+ // setup recorded, and a Product that exists with neither defaults to TEAM (E62). Solo waives every
261
+ // approval, so it must be a choice, never what a scripted re-run of an old Product falls into.
262
+ const known = prev.team_size ?? (hub ? 2 : 1);
263
+ solo = !(await ask('Solo or team?', known > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
264
+ team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(Math.max(2, known))), 10) || 2);
384
265
  }
385
266
 
386
267
  // 2. Greenfield (new code) or brownfield (existing code).
@@ -406,14 +287,14 @@ export async function resolveProfile(root, opts = {}) {
406
287
  }
407
288
 
408
289
  // The guided, idempotent first-run wizard: a Step 0 profile interview (resolveProfile) that branches
409
- // the remaining steps — install, hub + roster, optional tools, repos, wiring — and persists the profile.
290
+ // the remaining steps — install, Product platform, optional tools, repos, wiring — and persists the profile.
410
291
  export async function runSetup(root, opts = {}) {
411
292
  log(c.bold(`\nSDLC Workflow setup ${c.dim('v' + VERSION)}`));
412
293
  log(c.dim(`target: ${root}`));
413
294
 
414
295
  // 0. Profile interview — branch the wizard to the user's situation (solo/team, code, repo layout).
415
296
  const { solo, team_size, codebase, repo_layout, configureTools } = await resolveProfile(root, opts);
416
- // Steps: interview, preflight, install, hub, tools (1 if deferred else 3), repos, wire, coderabbit, done.
297
+ // Steps: interview, preflight, install, Product, tools (1 if deferred else 3), repos, wire, coderabbit, done.
417
298
  const total = 8 + (configureTools ? 3 : 1);
418
299
  let _n = 0;
419
300
  const S = (title) => step(++_n, total, title);
@@ -437,9 +318,9 @@ export async function runSetup(root, opts = {}) {
437
318
  if (!has('npx')) warn('npx not found — repomix packing will be skipped');
438
319
 
439
320
  // Install the module
440
- S('Install the module (skills + _bmad registration)');
321
+ S('Install the module (skills + .sdlc/config.yaml)');
441
322
  guide([
442
- 'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
323
+ 'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code, Codex CLI, Cursor, Gemini CLI, Copilot, Zencoder or opencode.',
443
324
  'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
444
325
  ]);
445
326
  const ideTargets = await selectIdeTargets(root, opts.ideTargets);
@@ -480,89 +361,76 @@ export async function runSetup(root, opts = {}) {
480
361
  }
481
362
  }
482
363
 
483
- // Detect hub platform + roster
484
- S(solo ? 'Hub platform (solo — no roster)' : 'Hub platform & reviewer roster');
364
+ // Detect Product platform. No people are collected (E62): anyone with access approves on the platform.
365
+ S('Product platform');
485
366
  guide(solo
486
367
  ? [
487
- 'Your hub is this repo on GitHub/GitLab (or none for a file-only gate).',
488
- 'Solo: no roster needed — you review by merging your own PR (approval waived).',
368
+ 'Your hub is this repo on GitHub/GitLab (or none for a local gate).',
369
+ 'Solo: you review by merging your own PR (approval waived).',
489
370
  ]
490
371
  : [
491
372
  'Your hub is this repo on GitHub/GitLab; reviewers approve artifacts there.',
492
- `Add your ${team_size}-person roster: platform login → yad name → hub role (owner/reviewer).`,
493
- 'An owner + 1 reviewer is required to pass a gate; skip now and add later with `yad roster add`.',
373
+ 'yad keeps no list of people: anyone with access to the repo can approve, and the platform records who did.',
374
+ 'A gate needs one approval, which should not come from the author (GitHub blocks self-approval; GitLab only if its settings do).',
375
+ 'It also reports how many people it would like: 3 on a contract review, 2 where auth or payments is touched, 1 elsewhere. That extra number is advisory for now.',
494
376
  ]);
495
- const hubPath = path.join(root, PROJECT_FILES.hubConfig);
496
- if (exists(hubPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
377
+ const productPath = productConfigPath(root);
378
+ if (exists(productPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
497
379
  info('keeping existing .sdlc/hub.json');
498
380
  } else {
499
381
  const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: root });
500
382
  if (!remote.ok && exists(path.join(root, '.git'))) info('no origin remote — platform detection skipped');
501
383
  let platform = detectPlatform(remote.ok ? remote.stdout : '');
502
- platform = (await ask('Hub platform (github/gitlab/none)', platform || 'none')).toLowerCase();
384
+ platform = (await ask('Product platform (github/gitlab/none)', platform || 'none')).toLowerCase();
503
385
  if (!['github', 'gitlab', 'none'].includes(platform)) {
504
- warn(`unknown platform '${platform}' — using none (file-only gate)`);
386
+ warn(`unknown platform '${platform}' — using none (local gate)`);
505
387
  platform = 'none';
506
388
  }
507
- const roster = [];
508
- // Solo mode needs no roster — the lone developer is owner and reviewer-by-merge.
509
- if (!solo && await askYesNo('Add reviewers to the roster now?', true)) {
510
- for (;;) {
511
- const login = await ask(' reviewer platform login (blank to finish)', '');
512
- if (!login) break;
513
- const name = await ask(' yad name', login);
514
- const email = await ask(' commit email (committer→login lookup + verified-commits gate; blank to skip)', '');
515
- // Per-scope roles: capture the hub roles here; per-repo roles are added in step 7 when the
516
- // repo is connected. A person can hold several roles (owner reviewer) at once.
517
- const hubRoles = parseList(await ask(' hub roles (owner/reviewer, space-separated)', 'reviewer'));
518
- const entry = { login, name, ...(email ? { email } : {}), roles: { hub: hubRoles } };
519
- // Validate the login exists on the hub — warn-only (fail-open): a miss is flagged unverified
520
- // but still saved. `checked:false` (no CLI/auth) skips the check silently.
521
- if (platform !== 'none') {
522
- const v = validateLogin(platform, login);
523
- if (v.checked && !v.exists) { warn(`'${login}' not found on ${platform} — saved as unverified`); entry.unverified = true; }
524
- else if (v.checked && v.exists) ok(`verified ${login} on ${platform}`);
525
- }
526
- if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
527
- roster.push(entry);
528
- }
529
- }
530
- const default_branch = platform === 'none' ? 'main' : await ask('Hub default branch', 'main');
531
- // `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
532
- // for anything that still reads it.
389
+ const default_branch = platform === 'none' ? 'main' : await ask('Product default branch', 'main');
390
+ // `ledger` is the canonical switch (shape 2): "verified" = CI writes the ledger, "local" = this
391
+ // machine does. The two booleans below say the same thing in the older spelling and are written
392
+ // ALONGSIDE it, not instead of it — add before you remove (rule 3). They are what a check gate
393
+ // that has not been refreshed by `yad update` yet still reads, and what a Product that is rolled back
394
+ // to a 3.x CLI would fall back to. They go in a later major, once nothing on either side reads them.
533
395
  const enabled = platform !== 'none';
534
- // Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the bridge/PR flow
396
+ // Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the verified ledger/PR flow
535
397
  // needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
536
398
  const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
537
- // Merge into the existing file, never clobber: roster + verified_authors are user-owned identity
538
- // data (the verified-commits gate's allowlist derives from them). A reconfigure that collects no
539
- // reviewers (e.g. solo mode skips the loop) must NOT blank a populated roster or drop verified_authors.
540
- // Read strict so a corrupt hub aborts here (YAD-STATE-001) rather than fail-open to `{}` and rewrite
541
- // the file with identity stripped — the same silent-loss hole, just triggered by a parse failure.
542
- const cur = readJSONStrict(hubPath, {}) || {};
399
+ // Merge into the existing file, never clobber: a key this wizard does not collect is kept as it is.
400
+ // Read strict so a corrupt Product aborts here (YAD-STATE-001) rather than fail-open to `{}` and rewrite
401
+ // the file with everything it did not collect stripped.
402
+ const cur = readJSONStrict(productPath, {}) || {};
403
+ // `ledger` belongs to shape 2. On a project still on shape 1 — one that has not run
404
+ // `yad migrate` yet — writing it would leave a file DECLARING shape 1 while carrying a shape-2
405
+ // field, which is rule 1 read backwards and makes `yad doctor`'s drift report a lie about the
406
+ // one file this shape change is about. The old booleans below say the same thing and are what
407
+ // the reader falls back to when `ledger` is absent, so nothing is lost by waiting: `yad migrate`
408
+ // adds the key, and the setting it computes is the one these booleans just recorded.
409
+ const onNewShape = (cur.schemaVersion ?? 1) >= 2 || !Object.keys(cur).length;
543
410
  const next = buildReconfiguredHub(cur, {
544
- platform: enabled ? platform : null, git_url, bridge_enabled: enabled, bridge: enabled,
545
- default_branch, roster, solo, profile: { codebase, repo_layout, team_size },
411
+ platform: enabled ? platform : null, git_url,
412
+ ...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
413
+ bridge_enabled: enabled, bridge: enabled,
414
+ default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
546
415
  });
547
- if (!roster.length && Array.isArray(cur.roster) && cur.roster.length) {
548
- info(`kept existing roster (${cur.roster.length} member(s)) — reconfigure collected none`);
549
- }
550
- if (cur.verified_authors?.length) info(`preserved ${cur.verified_authors.length} verified_authors entry(ies)`);
551
- writeJSON(hubPath, next);
552
- ok(`wrote ${PROJECT_FILES.hubConfig} (${next.roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
416
+ writeProductConfig(root, next);
417
+ ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.hubConfig}${solo ? ' (solo mode)' : ''}`);
553
418
  }
554
419
  // Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
555
420
  // flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
556
421
  // Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
557
- if (exists(hubPath)) {
558
- // Strict read for the same reason as the reconfigure write above: a corrupt hub must abort, never
559
- // fail-open to `{}` and get rewritten with roster/verified_authors stripped on a plain re-run.
560
- const cur = readJSONStrict(hubPath, {}) || {};
422
+ if (exists(productPath)) {
423
+ // Strict read for the same reason as the reconfigure write above: a corrupt Product must abort, never
424
+ // fail-open to `{}` and get rewritten with everything else stripped on a plain re-run.
425
+ const cur = readJSONStrict(productPath, {}) || {};
561
426
  const backfillUrl = (cur.platform && !cur.git_url)
562
427
  ? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
563
428
  : null;
564
- if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
565
- writeJSON(hubPath, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), solo, profile: { codebase, repo_layout, team_size } });
429
+ const mode = setupModeFields(root, cur, solo, opts);
430
+ const modeMoved = Object.keys(mode).some((k) => JSON.stringify(cur[k]) !== JSON.stringify(mode[k]));
431
+ if (modeMoved || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
432
+ writeProductConfig(root, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), ...mode, profile: { codebase, repo_layout, team_size } });
433
+ if (mode.mode_set) info(`mode: ${mode.mode_set.from} → ${mode.mode_set.to} (recorded as mode_set)`);
566
434
  if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
567
435
  else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
568
436
  }
@@ -652,13 +520,13 @@ export async function runSetup(root, opts = {}) {
652
520
  S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
653
521
  guide(repo_layout === 'monorepo'
654
522
  ? [
655
- 'One repo holds all the code; the contract lives in the hub and stories tag this single repo.',
656
- codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the front phases see what already exists.',
523
+ 'One repo holds all the code; the contract lives in the Product and stories tag this single repo.',
524
+ codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the Shape phases see what already exists.',
657
525
  ]
658
526
  : [
659
527
  'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
660
- 'Per repo: name → path (inside this project) → platform → domain owner(s).',
661
- codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the front phases see what already exists.',
528
+ 'Per repo: name → path (inside this project) → platform → default branch.',
529
+ codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the Shape phases see what already exists.',
662
530
  ]);
663
531
  const regPath = path.join(root, PROJECT_FILES.reposRegistry);
664
532
  const registry = readJSON(regPath, { repos: [] });
@@ -670,20 +538,14 @@ export async function runSetup(root, opts = {}) {
670
538
  const name = await ask(' repo name (blank to finish)', '');
671
539
  if (!name) break;
672
540
  if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
673
- // Siblings of the hub are the common layout (project/{product,backend}) — `../backend` is valid.
541
+ // Siblings of the Product are the common layout (project/{product,backend}) — `../backend` is valid.
674
542
  const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
675
543
  if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
676
544
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
677
545
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
678
- // Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
679
- // skip these prompts — there is no second person to route to.
680
- const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
681
- const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
682
- const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
683
546
  const default_branch = await ask(' default branch', 'main');
684
- const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null, pack: !greenfield });
547
+ const repo = registerRepo(root, registry, { name, rpath, platform, default_branch, today: opts.today ?? null, pack: !greenfield });
685
548
  if (!repo) continue;
686
- addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
687
549
  known.add(name);
688
550
  ok(`registered ${name}`);
689
551
  if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
@@ -692,26 +554,8 @@ export async function runSetup(root, opts = {}) {
692
554
  }
693
555
  }
694
556
 
695
- // Assign/update roles for ALREADY-connected repos. Skipped in solo mode (no roster). The connect loop
696
- // above only prompts for repos you add now; this closes the gap so a member's role on a repo connected
697
- // in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
698
- const hub7 = readJSON(hubPath, null);
699
- if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
700
- && await askYesNo('Assign/update roles for connected repos?', false)) {
701
- for (const member of hub7.roster) {
702
- if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
703
- for (const repo of registry.repos) {
704
- const cur = rolesForScope(member, repo.name);
705
- if (!(await askYesNo(` set ${member.name}'s role on ${repo.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
706
- const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
707
- const want = parseList(input).filter((x) => ['owner', 'reviewer', 'domain-owner'].includes(x));
708
- reconcileRepoRoles(root, member.name, repo.name, cur, want);
709
- }
710
- }
711
- }
712
-
713
- // Wire each connected repo + the hub itself
714
- S('Wire connected repos + the hub (CI gates, PR template, gate-sync)');
557
+ // Wire each connected repo + the Product itself
558
+ S('Wire connected repos + the Product (CI gates, PR template, gate-sync)');
715
559
  guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
716
560
  if (registry.repos.length === 0) info('no repos to wire');
717
561
  // Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
@@ -726,16 +570,16 @@ export async function runSetup(root, opts = {}) {
726
570
  // same-named file is never touched.
727
571
  applyActions(legacyRepoActions(root, repo), { force: true });
728
572
  }
729
- // the hub: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
730
- const hubWiring = hubActions(root);
573
+ // the Product: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
574
+ const hubWiring = productActions(root);
731
575
  if (hubWiring.length) {
732
576
  log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
733
577
  applyActions(hubWiring, { force: true });
734
578
  wired.push(...hubWiring);
735
579
  }
736
580
  applyActions(legacyHubActions(root), { force: true });
737
- // the hub, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
738
- // the moment it tries it rather than by a failed pipeline later (#171). Bridge-gated like the CI
581
+ // the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
582
+ // the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
739
583
  // above — with no bridge the ledger is locally owned and the guard would be wrong.
740
584
  const hookWiring = hookActions(root, ideTargets);
741
585
  if (hookWiring.length) {
@@ -746,8 +590,6 @@ export async function runSetup(root, opts = {}) {
746
590
  // After every write to a managed path has landed (including the legacy renames), so the recorded
747
591
  // sha is the file's final state.
748
592
  recordManagedWrites(wired);
749
- // author allowlists for the verified-commits gate (hub + every repo), from the roster emails
750
- applyActions(authorsActions(root, registry.repos), { force: true });
751
593
 
752
594
  // Optional CodeRabbit
753
595
  S('AI review (CodeRabbit)');
@@ -768,17 +610,20 @@ export async function runSetup(root, opts = {}) {
768
610
  log('');
769
611
  // Tailored fastest path to the first epic, by profile.
770
612
  log(c.bold('Next:'));
613
+ // Which skill authors the epic is the PROJECT's setting (E6), and `yad setup` re-runs on a project
614
+ // that already has one — so naming the catalogue default here would send a team to a skill their own
615
+ // `yad next` never mentions again. `yad-backfill` stays literal: waking a brownfield anchor is the
616
+ // engine's own promote verb, not a step on any chain.
617
+ const epicSkill = stepSkills('epic', loadSkillBindings(root))[0] || 'yad-epic';
771
618
  if (codebase === 'brownfield' && registry.repos.length) {
772
- hand('capture what already exists first: run `yad-backfill`, then your first epic with `yad-epic`');
619
+ hand(`capture what already exists first: run \`yad-backfill\`, then your first epic with \`${epicSkill}\``);
773
620
  } else {
774
- hand('author your first epic: run `yad-epic`');
621
+ hand(`author your first epic: run \`${epicSkill}\``);
775
622
  }
776
623
  hand('your single next action, anytime: `yad next`');
777
- if (!solo && !(readJSON(hubPath, null)?.roster || []).length) {
778
- hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
779
- }
624
+ if (!solo) hand('reviewers need no setup: anyone with access approves on the platform; a gate needs one approval (not the author\'s own), resolved threads and the merge');
780
625
  log('');
781
- log(c.bold('Then — AI-only steps (run in Claude Code):'));
626
+ log(c.bold('Then — AI-only steps (run in your AI agent):'));
782
627
  if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
783
628
  const design = readJSON(designPath, null);
784
629
  if (design && design.tool && design.tool !== 'none') {
@@ -794,11 +639,18 @@ export async function runSetup(root, opts = {}) {
794
639
  }
795
640
  log('');
796
641
  log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
642
+ // The --json answer (E1). A --json setup never prompts: every answer comes from a flag, or the run is
643
+ // refused (YAD-CLI-001) at the first question it would have asked — which can come after an earlier
644
+ // step has written, so a scripted setup passes every flag (or SDLC_NONINTERACTIVE for the defaults).
645
+ return {
646
+ profile: { solo, team_size, codebase, repo_layout }, ideTargets,
647
+ repos: registry.repos.map((r) => r.name ?? null), next: epicSkill,
648
+ };
797
649
  }
798
650
 
799
- // The repomix pack is a large, regenerable artifact — the hub tracks the AI-authored code-map, not the
651
+ // The repomix pack is a large, regenerable artifact — the Product tracks the AI-authored code-map, not the
800
652
  // pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
801
- // this makes that assumption true in every hub, so a regenerated pack never strands as a dirty tree.
653
+ // this makes that assumption true in every Product, so a regenerated pack never strands as a dirty tree.
802
654
  export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
803
655
 
804
656
  // The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
@@ -810,7 +662,7 @@ export const PACK_IGNORE_BLOCK = [
810
662
  PACK_IGNORE_GLOB,
811
663
  ];
812
664
 
813
- // Idempotently ensure the hub `.gitignore` ignores the repomix pack. No-op (returns false) if the line
665
+ // Idempotently ensure the Product `.gitignore` ignores the repomix pack. No-op (returns false) if the line
814
666
  // is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
815
667
  // and returns true.
816
668
  export function ensurePackIgnored(root) {
@@ -832,7 +684,7 @@ export function packRepo(root, repo) {
832
684
  ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
833
685
  info(`${repo.name}: packing with repomix …`);
834
686
  const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
835
- if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in Claude Code (yad-connect-repos)`); return true; }
687
+ if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
836
688
  fail(`${repo.name}: repomix failed — ${r.stderr.split('\n')[0] || 'unknown error'}`);
837
689
  return false;
838
690
  }