yadflow 3.19.0-next.2 → 4.0.0-next.3

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 (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. 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, captureHookActions,
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, ledger: 'local', 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 };
176
- }
177
-
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'}`);
88
+ return { ...(cur || {}), ...fields };
226
89
  }
227
90
 
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,69 +361,45 @@ 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
368
  'Your hub is this repo on GitHub/GitLab (or none for a local gate).',
488
- 'Solo: no roster needed — you review by merging your own PR (approval waived).',
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
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');
389
+ const default_branch = platform === 'none' ? 'main' : await ask('Product default branch', 'main');
531
390
  // `ledger` is the canonical switch (shape 2): "verified" = CI writes the ledger, "local" = this
532
391
  // machine does. The two booleans below say the same thing in the older spelling and are written
533
392
  // ALONGSIDE it, not instead of it — add before you remove (rule 3). They are what a check gate
534
- // that has not been refreshed by `yad update` yet still reads, and what a hub that is rolled back
393
+ // that has not been refreshed by `yad update` yet still reads, and what a Product that is rolled back
535
394
  // to a 3.x CLI would fall back to. They go in a later major, once nothing on either side reads them.
536
395
  const enabled = platform !== 'none';
537
396
  // Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the verified ledger/PR flow
538
397
  // needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
539
398
  const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
540
- // Merge into the existing file, never clobber: roster + verified_authors are user-owned identity
541
- // data (the verified-commits gate's allowlist derives from them). A reconfigure that collects no
542
- // reviewers (e.g. solo mode skips the loop) must NOT blank a populated roster or drop verified_authors.
543
- // Read strict so a corrupt hub aborts here (YAD-STATE-001) rather than fail-open to `{}` and rewrite
544
- // the file with identity stripped — the same silent-loss hole, just triggered by a parse failure.
545
- 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, {}) || {};
546
403
  // `ledger` belongs to shape 2. On a project still on shape 1 — one that has not run
547
404
  // `yad migrate` yet — writing it would leave a file DECLARING shape 1 while carrying a shape-2
548
405
  // field, which is rule 1 read backwards and makes `yad doctor`'s drift report a lie about the
@@ -554,27 +411,26 @@ export async function runSetup(root, opts = {}) {
554
411
  platform: enabled ? platform : null, git_url,
555
412
  ...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
556
413
  bridge_enabled: enabled, bridge: enabled,
557
- default_branch, roster, solo, profile: { codebase, repo_layout, team_size },
414
+ default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
558
415
  });
559
- if (!roster.length && Array.isArray(cur.roster) && cur.roster.length) {
560
- info(`kept existing roster (${cur.roster.length} member(s)) — reconfigure collected none`);
561
- }
562
- if (cur.verified_authors?.length) info(`preserved ${cur.verified_authors.length} verified_authors entry(ies)`);
563
- writeJSON(hubPath, next);
564
- 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)' : ''}`);
565
418
  }
566
419
  // Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
567
420
  // flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
568
421
  // Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
569
- if (exists(hubPath)) {
570
- // Strict read for the same reason as the reconfigure write above: a corrupt hub must abort, never
571
- // fail-open to `{}` and get rewritten with roster/verified_authors stripped on a plain re-run.
572
- 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, {}) || {};
573
426
  const backfillUrl = (cur.platform && !cur.git_url)
574
427
  ? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
575
428
  : null;
576
- if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
577
- 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)`);
578
434
  if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
579
435
  else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
580
436
  }
@@ -664,12 +520,12 @@ export async function runSetup(root, opts = {}) {
664
520
  S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
665
521
  guide(repo_layout === 'monorepo'
666
522
  ? [
667
- 'One repo holds all the code; the contract lives in the hub and stories tag this single repo.',
523
+ 'One repo holds all the code; the contract lives in the Product and stories tag this single repo.',
668
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.',
669
525
  ]
670
526
  : [
671
527
  'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
672
- 'Per repo: name → path (inside this project) → platform → domain owner(s).',
528
+ 'Per repo: name → path (inside this project) → platform → default branch.',
673
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.',
674
530
  ]);
675
531
  const regPath = path.join(root, PROJECT_FILES.reposRegistry);
@@ -682,20 +538,14 @@ export async function runSetup(root, opts = {}) {
682
538
  const name = await ask(' repo name (blank to finish)', '');
683
539
  if (!name) break;
684
540
  if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
685
- // 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.
686
542
  const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
687
543
  if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
688
544
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
689
545
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
690
- // Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
691
- // skip these prompts — there is no second person to route to.
692
- const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
693
- const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
694
- const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
695
546
  const default_branch = await ask(' default branch', 'main');
696
- 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 });
697
548
  if (!repo) continue;
698
- addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
699
549
  known.add(name);
700
550
  ok(`registered ${name}`);
701
551
  if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
@@ -704,26 +554,8 @@ export async function runSetup(root, opts = {}) {
704
554
  }
705
555
  }
706
556
 
707
- // Assign/update roles for ALREADY-connected repos. Skipped in solo mode (no roster). The connect loop
708
- // above only prompts for repos you add now; this closes the gap so a member's role on a repo connected
709
- // in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
710
- const hub7 = readJSON(hubPath, null);
711
- if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
712
- && await askYesNo('Assign/update roles for connected repos?', false)) {
713
- for (const member of hub7.roster) {
714
- if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
715
- for (const repo of registry.repos) {
716
- const cur = rolesForScope(member, repo.name);
717
- if (!(await askYesNo(` set ${member.name}'s role on ${repo.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
718
- const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
719
- const want = parseList(input).filter((x) => ['owner', 'reviewer', 'domain-owner'].includes(x));
720
- reconcileRepoRoles(root, member.name, repo.name, cur, want);
721
- }
722
- }
723
- }
724
-
725
- // Wire each connected repo + the hub itself
726
- 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)');
727
559
  guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
728
560
  if (registry.repos.length === 0) info('no repos to wire');
729
561
  // Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
@@ -738,15 +570,15 @@ export async function runSetup(root, opts = {}) {
738
570
  // same-named file is never touched.
739
571
  applyActions(legacyRepoActions(root, repo), { force: true });
740
572
  }
741
- // the hub: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
742
- 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);
743
575
  if (hubWiring.length) {
744
576
  log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
745
577
  applyActions(hubWiring, { force: true });
746
578
  wired.push(...hubWiring);
747
579
  }
748
580
  applyActions(legacyHubActions(root), { force: true });
749
- // the hub, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
581
+ // the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
750
582
  // the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
751
583
  // above — with no bridge the ledger is locally owned and the guard would be wrong.
752
584
  const hookWiring = hookActions(root, ideTargets);
@@ -755,11 +587,17 @@ export async function runSetup(root, opts = {}) {
755
587
  applyActions(hookWiring, { force: true });
756
588
  wired.push(...hookWiring);
757
589
  }
590
+ // The post-edit capture hook (E43): every change to an artifact is snapshotted onto the person's private
591
+ // `yad/wip/<name>/<epic>` branches. Both ledger modes; `"capture": false` in the Product config turns it off.
592
+ const captureWiring = captureHookActions(root, ideTargets);
593
+ if (captureWiring.length) {
594
+ log(` ${c.bold('hub')} ${c.dim('(wip capture)')}`);
595
+ applyActions(captureWiring, { force: true });
596
+ wired.push(...captureWiring);
597
+ }
758
598
  // After every write to a managed path has landed (including the legacy renames), so the recorded
759
599
  // sha is the file's final state.
760
600
  recordManagedWrites(wired);
761
- // author allowlists for the verified-commits gate (hub + every repo), from the roster emails
762
- applyActions(authorsActions(root, registry.repos), { force: true });
763
601
 
764
602
  // Optional CodeRabbit
765
603
  S('AI review (CodeRabbit)');
@@ -780,17 +618,20 @@ export async function runSetup(root, opts = {}) {
780
618
  log('');
781
619
  // Tailored fastest path to the first epic, by profile.
782
620
  log(c.bold('Next:'));
621
+ // Which skill authors the epic is the PROJECT's setting (E6), and `yad setup` re-runs on a project
622
+ // that already has one — so naming the catalogue default here would send a team to a skill their own
623
+ // `yad next` never mentions again. `yad-backfill` stays literal: waking a brownfield anchor is the
624
+ // engine's own promote verb, not a step on any chain.
625
+ const epicSkill = stepSkills('epic', loadSkillBindings(root))[0] || 'yad-epic';
783
626
  if (codebase === 'brownfield' && registry.repos.length) {
784
- hand('capture what already exists first: run `yad-backfill`, then your first epic with `yad-epic`');
627
+ hand(`capture what already exists first: run \`yad-backfill\`, then your first epic with \`${epicSkill}\``);
785
628
  } else {
786
- hand('author your first epic: run `yad-epic`');
629
+ hand(`author your first epic: run \`${epicSkill}\``);
787
630
  }
788
631
  hand('your single next action, anytime: `yad next`');
789
- if (!solo && !(readJSON(hubPath, null)?.roster || []).length) {
790
- hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
791
- }
632
+ 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');
792
633
  log('');
793
- log(c.bold('Then — AI-only steps (run in Claude Code):'));
634
+ log(c.bold('Then — AI-only steps (run in your AI agent):'));
794
635
  if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
795
636
  const design = readJSON(designPath, null);
796
637
  if (design && design.tool && design.tool !== 'none') {
@@ -806,11 +647,18 @@ export async function runSetup(root, opts = {}) {
806
647
  }
807
648
  log('');
808
649
  log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
650
+ // The --json answer (E1). A --json setup never prompts: every answer comes from a flag, or the run is
651
+ // refused (YAD-CLI-001) at the first question it would have asked — which can come after an earlier
652
+ // step has written, so a scripted setup passes every flag (or SDLC_NONINTERACTIVE for the defaults).
653
+ return {
654
+ profile: { solo, team_size, codebase, repo_layout }, ideTargets,
655
+ repos: registry.repos.map((r) => r.name ?? null), next: epicSkill,
656
+ };
809
657
  }
810
658
 
811
- // The repomix pack is a large, regenerable artifact — the hub tracks the AI-authored code-map, not the
659
+ // The repomix pack is a large, regenerable artifact — the Product tracks the AI-authored code-map, not the
812
660
  // pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
813
- // this makes that assumption true in every hub, so a regenerated pack never strands as a dirty tree.
661
+ // this makes that assumption true in every Product, so a regenerated pack never strands as a dirty tree.
814
662
  export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
815
663
 
816
664
  // The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
@@ -822,7 +670,7 @@ export const PACK_IGNORE_BLOCK = [
822
670
  PACK_IGNORE_GLOB,
823
671
  ];
824
672
 
825
- // Idempotently ensure the hub `.gitignore` ignores the repomix pack. No-op (returns false) if the line
673
+ // Idempotently ensure the Product `.gitignore` ignores the repomix pack. No-op (returns false) if the line
826
674
  // is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
827
675
  // and returns true.
828
676
  export function ensurePackIgnored(root) {
@@ -844,7 +692,7 @@ export function packRepo(root, repo) {
844
692
  ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
845
693
  info(`${repo.name}: packing with repomix …`);
846
694
  const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
847
- if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in Claude Code (yad-connect-repos)`); return true; }
695
+ 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; }
848
696
  fail(`${repo.name}: repomix failed — ${r.stderr.split('\n')[0] || 'unknown error'}`);
849
697
  return false;
850
698
  }