yadflow 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
- ## [2.4.2](https://github.com/abdelrahmannasr/yadflow/compare/v2.4.1...v2.4.2) (2026-06-14)
1
+ # [2.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.4.2...v2.5.0) (2026-06-14)
2
2
 
3
3
 
4
- ### Bug Fixes
4
+ ### Features
5
5
 
6
- * route GitLab CI gate jobs to tag-locked runners via $YAD_RUNNER_TAGS ([a0311c5](https://github.com/abdelrahmannasr/yadflow/commit/a0311c5af647f63968f3f34e8e6e6fa48b7423d8)), closes [#50](https://github.com/abdelrahmannasr/yadflow/issues/50)
6
+ * per-scope roster roles + auto assignee/reviewer on PRs ([5ff066b](https://github.com/abdelrahmannasr/yadflow/commit/5ff066b2a83f63ddf25353ddaf0a088b91a6adb0))
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/cli/doctor.mjs CHANGED
@@ -8,7 +8,7 @@ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSO
8
8
  import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
9
9
  import { loadLedger, epicRoot } from './epic-state.mjs';
10
10
  import { gitHead } from './setup.mjs';
11
- import { cliFor } from './platform.mjs';
11
+ import { cliFor, validateLogin } from './platform.mjs';
12
12
 
13
13
  const MIN_NODE = 18;
14
14
 
@@ -70,7 +70,18 @@ export function projectChecks(checks, root) {
70
70
  if (cli) {
71
71
  if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
72
72
  else if (!run(cli, ['auth', 'status']).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated [YAD-ENV-002]`, `run \`${cli} auth login\``);
73
- else check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
73
+ else {
74
+ check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
75
+ // Re-validate each roster login against the hub (warn-only). Skips when a login is already
76
+ // flagged unverified by setup; reports any that no longer resolve.
77
+ const bad = [];
78
+ for (const e of hub.roster || []) {
79
+ const v = validateLogin(hub.platform, e.login);
80
+ if (v.checked && !v.exists) bad.push(e.login);
81
+ }
82
+ if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
83
+ else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
84
+ }
74
85
  }
75
86
  }
76
87
  }
package/cli/gate.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
14
  artifactPaths, upsertHubPr,
15
15
  } from './epic-state.mjs';
16
- import { readPr, mapApprovers, createPr } from './platform.mjs';
16
+ import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
17
17
  import { err } from './errors.mjs';
18
18
 
19
19
  // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
@@ -412,10 +412,15 @@ export async function gateOpen(root, { epic, artifact } = {}) {
412
412
  if (!hub?.platform) { warn('no hub platform — marked in_review file-only (no PR opened)'); ok(`${step.id} → in_review`); return; }
413
413
 
414
414
  const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
415
- const reviewers = (hub.roster || []).filter((r) => r.role !== 'owner').map((r) => r.login);
415
+ // Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
416
+ // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
417
+ // to review their own artifact). Scope is the hub plus every touched domain.
418
+ const committer = resolveCommitterLogin(root, hub.roster || []);
419
+ const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer });
420
+ const assignees = committer ? [committer] : [];
416
421
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
417
422
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
418
- const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, labels, cwd: root });
423
+ const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
419
424
  if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
420
425
 
421
426
  const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
package/cli/openpr.mjs CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
8
8
  import { PROJECT_FILES } from './manifest.mjs';
9
- import { detectPlatform, createPr } from './platform.mjs';
9
+ import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
10
10
  import { taskFromBranch } from './commit.mjs';
11
11
 
12
12
  // Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
@@ -57,7 +57,17 @@ export async function runOpenPr(root, opts = {}) {
57
57
  task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
58
58
  });
59
59
 
60
- const r = createPr(platform, { title, body, base: baseBranch, head: branch, cwd: repoRoot });
60
+ // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
61
+ // local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
62
+ // Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
63
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
64
+ const roster = hub.roster || [];
65
+ const committer = resolveCommitterLogin(repoRoot, roster);
66
+ const scope = meta?.name ? [meta.name] : [];
67
+ const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer });
68
+ const assignees = committer ? [committer] : [];
69
+
70
+ const r = createPr(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
61
71
  if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
62
72
  ok(`opened ${r.url}`);
63
73
  if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
package/cli/platform.mjs CHANGED
@@ -23,18 +23,102 @@ export function platformReady(platform) {
23
23
  return !!cli && has(cli);
24
24
  }
25
25
 
26
+ // ---- roster role model (per-scope map, with legacy back-compat) ---------------------------------
27
+ // A roster entry's roles live in a per-scope map: `roles: { hub: ["owner","reviewer"], <repo>: [...] }`.
28
+ // `rolesForScope` normalizes the three shapes a roster entry can take on disk:
29
+ // 1. new object map — `entry.roles = { hub: [...], backend: [...] }`
30
+ // 2. flat array variant — `entry.roles = ["owner","reviewer"]` (treated as hub roles)
31
+ // 3. legacy single role — `entry.role = "owner"` (a hub role; pre per-scope schema)
32
+ // The legacy `repos.json` `domain_owner` field is handled separately by resolveLogin's fallback.
33
+ export function rolesForScope(entry, scope) {
34
+ if (!entry) return [];
35
+ const r = entry.roles;
36
+ if (r && typeof r === 'object' && !Array.isArray(r)) return Array.isArray(r[scope]) ? r[scope] : [];
37
+ if (Array.isArray(r)) return scope === 'hub' ? r : [];
38
+ if (typeof entry.role === 'string' && entry.role) return scope === 'hub' ? [entry.role] : [];
39
+ return [];
40
+ }
41
+
42
+ // True when the entry holds any of `wanted` roles across any of `scopes`.
43
+ export function hasAnyRole(entry, scopes = [], wanted = []) {
44
+ for (const scope of scopes) {
45
+ for (const role of rolesForScope(entry, scope)) {
46
+ if (wanted.includes(role)) return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
53
+ // or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
54
+ export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null } = {}) {
55
+ const out = [];
56
+ for (const entry of roster) {
57
+ if (!entry.login || entry.login === excludeLogin) continue;
58
+ if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner']) && !out.includes(entry.login)) out.push(entry.login);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // The committer/PR-opener's platform login, resolved from local git identity through the roster.
64
+ // Match on commit email first (the stable key), then fall back to name/login. null when unresolved.
65
+ export function resolveCommitterLogin(cwd, roster = []) {
66
+ const email = (run('git', ['config', 'user.email'], { cwd }).stdout || '').trim().toLowerCase();
67
+ const name = (run('git', ['config', 'user.name'], { cwd }).stdout || '').trim();
68
+ if (email) {
69
+ const byEmail = roster.find((r) => (r.email || '').toLowerCase() === email);
70
+ if (byEmail) return byEmail.login || null;
71
+ }
72
+ if (name) {
73
+ const byName = roster.find((r) => r.name === name || r.login === name);
74
+ if (byName) return byName.login || null;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // Does this platform login exist on the hub? Warn-only (never throws): `checked:false` when the CLI
80
+ // is absent/unauthenticated so callers can distinguish "not a user" from "couldn't check".
81
+ export function validateLogin(platform, login) {
82
+ const cli = cliFor(platform);
83
+ if (!cli || !has(cli) || !login) return { ok: false, exists: false, checked: false };
84
+ if (platform === 'github') {
85
+ const r = run('gh', ['api', `users/${login}`]);
86
+ return { ok: r.ok, exists: r.ok, checked: true };
87
+ }
88
+ // gitlab: users?username=<login> returns an array; empty array => no such user.
89
+ const r = run('glab', ['api', `users?username=${encodeURIComponent(login)}`]);
90
+ if (!r.ok) return { ok: false, exists: false, checked: true };
91
+ let exists = false;
92
+ try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { exists = false; }
93
+ return { ok: exists, exists, checked: true };
94
+ }
95
+
26
96
  // ---- login -> yad identity (roster + derived domain-owner) -------------------------------------
27
- // Returns the records this login's APPROVED review contributes. A roster reviewer who owns a touched
28
- // repo's domain contributes BOTH a base record and a domain-owner record (bridge.md "Login -> role").
97
+ // Returns the records this login's APPROVED review contributes. Roles are read from the per-scope
98
+ // map: hub roles plus, for each touched domain, that repo's scoped roles (domain-owner carries the
99
+ // `domain` tag). The legacy `repos.json` `domain_owner === name` mapping is kept as a fallback so
100
+ // pre per-scope projects still resolve domain owners.
29
101
  export function resolveLogin(login, roster = [], repos = [], touchedDomains = []) {
30
102
  const entry = roster.find((r) => r.login === login);
31
103
  if (!entry) return [{ name: login, role: 'reviewer', unverified: true }];
32
- const records = [{ name: entry.name, role: entry.role }];
33
- for (const repo of repos) {
34
- if (repo.domain_owner === entry.name && touchedDomains.includes(repo.name)) {
35
- records.push({ name: entry.name, role: 'domain-owner', domain: repo.name });
104
+ const records = [];
105
+ const push = (rec) => {
106
+ if (!records.some((x) => x.name === rec.name && x.role === rec.role && x.domain === rec.domain)) records.push(rec);
107
+ };
108
+ for (const role of rolesForScope(entry, 'hub')) push({ name: entry.name, role });
109
+ for (const d of touchedDomains) {
110
+ for (const role of rolesForScope(entry, d)) {
111
+ push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
36
112
  }
113
+ // Legacy fallback: a repo whose domain_owner is this name confers domain-owner for that domain.
114
+ const legacy = repos.find((repo) => repo.name === d && repo.domain_owner === entry.name);
115
+ if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
37
116
  }
117
+ // An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
118
+ // record so an approval from a known person is never silently dropped. An entry that DOES declare
119
+ // roles but none apply to these scopes contributes nothing here (it is scoped elsewhere).
120
+ const hasNoRoleInfo = !entry.role && !(entry.roles && (Array.isArray(entry.roles) ? entry.roles.length : Object.keys(entry.roles).length));
121
+ if (!records.length && hasNoRoleInfo) push({ name: entry.name, role: 'reviewer' });
38
122
  return records;
39
123
  }
40
124
 
@@ -134,18 +218,28 @@ export function readPr(platform, n, opts = {}) {
134
218
  }
135
219
 
136
220
  // ---- create a PR/MR -----------------------------------------------------------------------------
137
- export function createPr(platform, { title, body, base, head, reviewers = [], labels = [], cwd } = {}) {
138
- if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
221
+ // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
222
+ // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
223
+ // empty assignee list falls back to `@me` so the opener still self-assigns even without a roster.
224
+ // Pure argv builder for the create command — exported so the reviewer/assignee/label wiring is
225
+ // unit-testable without shelling out. gh always self-assigns (@me) when no assignee resolved.
226
+ export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
139
227
  if (platform === 'gitlab') {
140
228
  const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
141
229
  if (reviewers.length) args.push('--reviewer', reviewers.join(','));
230
+ if (assignees.length) args.push('--assignee', assignees.join(','));
142
231
  if (labels.length) args.push('--label', labels.join(','));
143
- const r = run('glab', args, { cwd });
144
- return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
232
+ return args;
145
233
  }
146
234
  const args = ['pr', 'create', '--title', title, '--body', body, '--base', base, '--head', head];
147
235
  if (reviewers.length) args.push('--reviewer', reviewers.join(','));
236
+ args.push('--assignee', assignees.length ? assignees.join(',') : '@me');
148
237
  if (labels.length) args.push('--label', labels.join(','));
149
- const r = run('gh', args, { cwd });
238
+ return args;
239
+ }
240
+
241
+ export function createPr(platform, opts = {}) {
242
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
243
+ const r = run(cliFor(platform), buildPrArgs(platform, opts), { cwd: opts.cwd });
150
244
  return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
151
245
  }
package/cli/setup.mjs CHANGED
@@ -11,6 +11,12 @@ import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, legacyRepoActions, legacyHubActions,
13
13
  } from './plan.mjs';
14
+ import { validateLogin } from './platform.mjs';
15
+
16
+ // Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
17
+ function parseList(s) {
18
+ return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
19
+ }
14
20
 
15
21
  const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
16
22
 
@@ -34,7 +40,35 @@ export function insideRoot(root, rpath) {
34
40
  // Validate + record one code repo into the registry (the testable half of the connect loop).
35
41
  // A path that is not a git repository is rejected and NOTHING is written — a registry entry with
36
42
  // syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
37
- export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', default_branch = 'main', today = null }) {
43
+ // Grant per-repo roles to roster members by writing into each person's `roles[<repo>]` array in
44
+ // hub.json. `grants` maps a role -> the yad names that hold it for this repo. A name that is not in
45
+ // the roster is warned about and skipped (the roster is the source of identity). Idempotent.
46
+ export function addRepoRoles(root, repo, grants = {}) {
47
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
48
+ const hub = readJSON(hubPath, null);
49
+ if (!hub || !Array.isArray(hub.roster)) return;
50
+ const byName = new Map(hub.roster.map((e) => [e.name, e]));
51
+ let touched = false;
52
+ for (const [role, names] of Object.entries(grants)) {
53
+ for (const nm of names) {
54
+ const entry = byName.get(nm);
55
+ if (!entry) { warn(`'${nm}' is not in the roster — skipped ${role} for ${repo}`); continue; }
56
+ // Normalize to the per-scope map, migrating the legacy shapes: a flat array or a single
57
+ // `role` string both become hub roles so nothing is lost.
58
+ if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
59
+ const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
60
+ entry.roles = hub.length ? { hub } : {};
61
+ delete entry.role;
62
+ }
63
+ const list = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
64
+ if (!list.includes(role)) { list.push(role); touched = true; }
65
+ entry.roles[repo] = list;
66
+ }
67
+ }
68
+ if (touched) writeJSON(hubPath, hub);
69
+ }
70
+
71
+ export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
38
72
  if (!insideRoot(root, rpath)) {
39
73
  warn(`${rpath} resolves outside the project root — skipped`);
40
74
  return null;
@@ -49,8 +83,12 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
49
83
  if (plat) warn(`unknown platform '${platform}' — using ${detected}`);
50
84
  plat = detected;
51
85
  }
86
+ // A repo can have multiple domain owners. `domain_owners` is the array of record; `domain_owner`
87
+ // is kept as the first element so anything still reading the legacy single-owner field works.
88
+ const owners = (domain_owners && domain_owners.length) ? domain_owners : (domain_owner ? [domain_owner] : []);
52
89
  const repo = {
53
- name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat, domain_owner, default_branch,
90
+ name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
91
+ domain_owner: owners[0] || '', domain_owners: owners, default_branch,
54
92
  connectedAt: today, lastSyncedAt: today,
55
93
  syncedHead: head,
56
94
  contextPack: `.sdlc/code-context/${name}/pack.md`,
@@ -226,9 +264,20 @@ export async function runSetup(root, opts = {}) {
226
264
  const login = await ask(' reviewer platform login (blank to finish)', '');
227
265
  if (!login) break;
228
266
  const name = await ask(' yad name', login);
229
- const role = await ask(' role (owner/reviewer/domain-owner)', 'reviewer');
230
- const email = await ask(' commit email (verified-commits gate; blank to skip)', '');
231
- roster.push({ login, name, role, ...(email ? { email } : {}) });
267
+ const email = await ask(' commit email (committer→login lookup + verified-commits gate; blank to skip)', '');
268
+ // Per-scope roles: capture the hub roles here; per-repo roles are added in step 7 when the
269
+ // repo is connected. A person can hold several roles (owner reviewer) at once.
270
+ const hubRoles = parseList(await ask(' hub roles (owner/reviewer, space-separated)', 'reviewer'));
271
+ const entry = { login, name, ...(email ? { email } : {}), roles: { hub: hubRoles } };
272
+ // Validate the login exists on the hub — warn-only (fail-open): a miss is flagged unverified
273
+ // but still saved. `checked:false` (no CLI/auth) skips the check silently.
274
+ if (platform !== 'none') {
275
+ const v = validateLogin(platform, login);
276
+ if (v.checked && !v.exists) { warn(`'${login}' not found on ${platform} — saved as unverified`); entry.unverified = true; }
277
+ else if (v.checked && v.exists) ok(`verified ${login} on ${platform}`);
278
+ }
279
+ if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
280
+ roster.push(entry);
232
281
  }
233
282
  }
234
283
  const default_branch = platform === 'none' ? 'main' : await ask('Hub default branch', 'main');
@@ -306,10 +355,15 @@ export async function runSetup(root, opts = {}) {
306
355
  if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root — skipped`); continue; }
307
356
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
308
357
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
309
- const domain_owner = await ask(' domain owner', '');
358
+ // A repo can have one or more domain owners; reviewers/owners can also be scoped to it. Names
359
+ // refer to roster `name`s; each grant is written into that person's per-scope roles map.
360
+ const domain_owners = parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
361
+ const repoReviewers = parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
362
+ const repoOwners = parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
310
363
  const default_branch = await ask(' default branch', 'main');
311
- const repo = registerRepo(root, registry, { name, rpath, platform, domain_owner, default_branch, today: opts.today ?? null });
364
+ const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null });
312
365
  if (!repo) continue;
366
+ addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
313
367
  known.add(name);
314
368
  ok(`registered ${name}`);
315
369
  packRepo(root, repo);
package/docs/index.html CHANGED
@@ -475,7 +475,7 @@ each code repo/
475
475
  <div class="term"><strong><code>approvals.json</code></strong><span class="badge b-file">files</span><br>Who approved which review, with name + role. The gate reads this to decide whether the rule is met.</div>
476
476
  <div class="term"><strong><code>repos.json</code></strong><span class="badge b-file">files</span><br>The project-wide registry of connected code repos (path/URL, platform, domain owner, freshness sha).</div>
477
477
  <div class="term"><strong><code>hub.json</code></strong><span class="badge b-file">files</span><br>The hub’s platform record (GitHub or GitLab) plus the reviewer roster, used to run front-half reviews on real PRs/MRs.</div>
478
- <div class="term"><strong>Roster</strong><span class="badge b-role">roles</span><br>The mapping from each reviewer’s GitHub/GitLab login to their SDLC name + role (owner / reviewer). Domain owners are derived from each repo’s <code>domain_owner</code> in <code>repos.json</code>, not retyped.</div>
478
+ <div class="term"><strong>Roster</strong><span class="badge b-role">roles</span><br>The mapping from each person’s GitHub/GitLab login + commit email to their SDLC name and a <strong>per-scope roles map</strong> (<code>roles: { hub: […], &lt;repo&gt;: […] }</code>). One person can be owner, reviewer <em>and</em> domain owner at once; one repo can have several people per role. Logins are validated against GitHub/GitLab at setup (warn-only). When a PR/MR is opened, the committer becomes the <strong>assignee</strong> and the scope’s reviewers + domain owners are <strong>auto-requested</strong>.</div>
479
479
  <div class="term"><strong>Code-map</strong><span class="badge b-file">files</span><br>A lightweight, AI-readable summary of a connected code repo — its existing endpoints, events, data models and modules — cached under <code>.sdlc/code-context/</code> so the front-half steps don’t contradict code that already exists.</div>
480
480
  <div class="term"><strong>Repomix pack</strong><span class="badge b-tool">tools</span><br>A compressed, secret-scanned snapshot of a repo’s code produced by the Repomix tool (<code>npx repomix</code>), cached alongside the code-map so AI can read the codebase.</div>
481
481
  <div class="term"><strong>Fresh / stale</strong><span class="badge b-file">files</span><br>A connected repo’s cached picture is <em>fresh</em> if the repo hasn’t moved since it was packed (tracked by HEAD sha) and <em>stale</em> if it has. Refreshing is a deliberate human decision (<code>yad repo refresh</code>) — skills warn about staleness but never silently re-pack.</div>
@@ -718,12 +718,12 @@ each code repo/
718
718
  <h4><code>yad-connect-repos</code></h4>
719
719
  <div class="row"><span class="lbl lbl-what">What</span> Introduces your code repos to the product hub so the “brain” knows what is already built. It registers each repo in <code>repos.json</code> and caches an AI-readable picture of it (a Repomix pack + a code-map of existing endpoints, events, data models and modules). Also sets up the hub’s own platform record and reviewer roster.</div>
720
720
  <div class="row"><span class="lbl lbl-how">How</span> Run it in the product hub, once per repo:</div>
721
- <pre><code>yad-connect-repos action: connect repo: backend path: ../backend-repo domain_owner: carol
721
+ <pre><code>yad-connect-repos action: connect repo: backend path: ../backend-repo domain_owners: carol,dave
722
722
  yad-connect-repos action: list # see every repo as fresh / stale
723
723
  yad-connect-repos action: refresh repo: backend # re-pack after the code moved
724
724
  yad-connect-repos action: disconnect repo: backend
725
725
  yad-connect-repos action: detect-hub # record the hub's GitHub/GitLab platform
726
- yad-connect-repos action: roster login: alice-gh name: alice role: owner # once per reviewer</code></pre>
726
+ yad-connect-repos action: roster login: alice-gh name: alice email: alice@x.com roles: hub=owner,reviewer # validated vs the hub</code></pre>
727
727
  <div class="row"><span class="lbl lbl-when">When</span> During one-time setup, and again any time you add a new code repo. Re-run <code>refresh</code> when a skill warns a repo is stale. <strong>Greenfield with no code yet? Skip it entirely</strong> — the brain proceeds without it.</div>
728
728
  </div>
729
729
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, repo). A BMAD module + 22 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -30,10 +30,13 @@ epic's approvals. It only writes the project-wide registry and the per-repo cont
30
30
 
31
31
  - `action` — `connect` | `refresh` | `list` | `disconnect` | `detect-hub` | `roster` (default `connect`).
32
32
  - `repo` — the repo's short name (the key used in stories' `repos:` tag, e.g. `backend`).
33
- - `login`, `name`, `role` — for `roster` (set the hub's reviewer-roster mapping login name role).
33
+ - `login`, `name`, `email`, `roles` — for `roster` (map login name + commit email + the per-scope
34
+ `roles` map, e.g. `roles: hub=owner,reviewer backend=domain-owner`). Validate the login against the
35
+ hub (`gh api users/<login>` / `glab api users?username=`); a miss is flagged `unverified` (warn-only).
34
36
  - `path` — local path to the code repo (relative to `{project-root}` or absolute). For local repos.
35
37
  - `git_url` — optional remote (SSH or HTTPS; GitHub or GitLab). Used when the repo is not yet on disk.
36
- - `domain_owner` — the engineer who owns this repo's domain (drives per-repo review routing later).
38
+ - `domain_owners` — the engineer(s) who own this repo's domain (a repo may have several; drives per-repo
39
+ review routing). Each name is also written into that person's `roles[<repo>]` map in `hub.json`.
37
40
 
38
41
  ## On Activation
39
42
 
@@ -83,7 +86,8 @@ HEAD sha as `syncedHead` (this drives staleness):
83
86
  "path": "<path rel. to project-root>",
84
87
  "git_url": "<url or null>",
85
88
  "platform": "github|gitlab|null",
86
- "domain_owner": "<owner or null>",
89
+ "domain_owners": ["<owner>", "…"],
90
+ "domain_owner": "<domain_owners[0] — legacy mirror>",
87
91
  "default_branch": "<branch>",
88
92
  "connectedAt": "<YYYY-MM-DD>",
89
93
  "lastSyncedAt": "<YYYY-MM-DD>",
@@ -122,10 +126,13 @@ write only `{project-root}/.sdlc/hub.json` (`config.yaml` `hub.config`) — neve
122
126
  repos: `github.com` → `github`, GitLab host → `gitlab`, no remote → `platform: null`. Record
123
127
  `git_url`, `default_branch`, `detectedAt`, and `bridge_enabled: true` (preserve an existing roster).
124
128
  Auth is the local user's own `gh`/`glab`/git; **store no tokens**. Idempotent — safe to re-run.
125
- - **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `role`
126
- (`owner` | `reviewer`). Upsert by `login`. `domain-owner` is **not** set here — it is derived when a
127
- roster `name` equals a repo's `domain_owner` in `repos.json` (see `references/hub-config.md`). An
128
- unmapped login degrades to a plain `reviewer`, never auto-promoted to owner/domain-owner.
129
+ - **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `email` + a per-scope
130
+ `roles` map (`roles: { hub: ["owner","reviewer"], <repo>: ["domain-owner", …] }`). Upsert by `login`;
131
+ a person may hold several roles across several scopes, and a repo several people per role. Validate the
132
+ `login` against the hub (warn-only; flag `unverified` on a miss). `domain-owner` is written into
133
+ `roles[<repo>]` (and still **derived** as a fallback when a roster `name` equals a repo's
134
+ `domain_owner`/`domain_owners` in `repos.json` — see `references/hub-config.md`). An unmapped login
135
+ degrades to a plain `reviewer`, never auto-promoted to owner/domain-owner.
129
136
 
130
137
  If the hub has no remote (`platform: null`) or the bridge is disabled, the front-half gate runs
131
138
  file-only with no error — the bridge is purely additive.
@@ -21,27 +21,36 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
21
21
  "bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
22
22
  "detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
23
23
  "roster": [
24
- { "login": "abdelrahmannasr", "name": "alice", "role": "owner" },
25
- { "login": "bob-gh", "name": "bob", "role": "reviewer" }
24
+ { "login": "abdelrahmannasr", "name": "alice", "email": "alice@example.com",
25
+ "roles": { "hub": ["owner", "reviewer"] } },
26
+ { "login": "carol-gh", "name": "carol", "email": "carol@example.com",
27
+ "roles": { "hub": ["reviewer"], "backend": ["domain-owner", "owner"], "payments": ["reviewer"] } }
26
28
  ]
27
29
  }
28
30
  ```
29
31
 
30
- ## The roster — login → name → role
32
+ ## The roster — login → name → per-scope roles
31
33
 
32
- The roster is how a platform identity (a GitHub/GitLab **login**) becomes an SDLC **name + role** in the
33
- file ledger (`approvals.json` / `comments.json`). Roles are the same three the gate already uses:
34
+ The roster is how a platform identity (a GitHub/GitLab **login**) becomes an SDLC **name + role(s)** in
35
+ the file ledger (`approvals.json` / `comments.json`). Roles are the same three the gate uses:
34
36
  `owner | reviewer | domain-owner`.
35
37
 
36
38
  - **`login`** — the platform username whose PR review / approval is being mapped.
37
39
  - **`name`** — the SDLC name written into the ledger (the same names used across `approvals.json`,
38
40
  `comments.json`, and `epic.md` `owner`). Keep it stable.
39
- - **`role`** — the person's default role: `owner` or `reviewer`.
40
-
41
- **`domain-owner` is DERIVED, never duplicated here.** A roster entry whose `name` equals a repo's
42
- `domain_owner` in `repos.json` is treated as that repo's domain-owner **when that repo is a touched
43
- domain for the step under review**. `repos.json` stays the single source of domain ownership; the roster
44
- only resolves the login → name link so the derivation can run.
41
+ - **`email`** — the commit email; drives the **committer → login** reverse lookup that auto-assigns PRs.
42
+ - **`roles`** — a **per-scope map**: scope (`hub`, or a connected repo name) → the roles held there. A
43
+ person can be **owner + reviewer + domain-owner at once** and across scopes; a repo gets **several**
44
+ people per role by appearing in several entries' maps. Validated against the hub during `yad setup` /
45
+ `yad doctor`; a login that does not resolve is flagged `unverified` (warn-only, never blocks).
46
+
47
+ **Back-compat:** readers also accept a flat array `"roles": ["owner","reviewer"]` (treated as `hub`
48
+ roles) and the legacy single `"role": "owner"` (a `hub` role).
49
+
50
+ **`domain-owner` may also be DERIVED from `repos.json`.** A roster entry whose `name` equals a repo's
51
+ `domain_owner`/`domain_owners` in `repos.json` is treated as that repo's domain-owner **when that repo is
52
+ a touched domain for the step under review** — kept as a fallback so pre per-scope projects still resolve.
53
+ New setups write the grant directly into the person's `roles[<repo>]` map.
45
54
 
46
55
  - **Unmapped login fallback.** A login absent from the roster maps to `name: <login>`, `role: reviewer`,
47
56
  and is flagged `<!-- unverified login: <login> -->` in the review record (mirrors the code-map
@@ -21,7 +21,8 @@ not under any `epics/EP-<slug>/.sdlc/`.
21
21
  "path": "demo-repos/backend", // path to the code repo, rel. to {project-root} (or absolute)
22
22
  "git_url": "git@github.com:org/backend.git", // optional remote; SSH or HTTPS; GitHub or GitLab; null if local-only
23
23
  "platform": "github", // github | gitlab (from the URL host); null when local-only
24
- "domain_owner": "carol", // engineer who owns this repo's domain (review routing)
24
+ "domain_owners": ["carol", "dave"], // engineers who own this repo's domain (review routing); a repo may have several
25
+ "domain_owner": "carol", // legacy single-owner mirror = domain_owners[0] (kept for back-compat readers)
25
26
  "default_branch": "main",
26
27
  "connectedAt": "2026-06-08", // first connect (YYYY-MM-DD)
27
28
  "lastSyncedAt": "2026-06-08", // last connect/refresh
@@ -7,19 +7,33 @@ in `../../yad-connect-repos/references/hub-config.md`; this file covers how the
7
7
  ## Entry
8
8
 
9
9
  ```json
10
- { "login": "abdelrahmannasr", "name": "alice", "role": "owner" }
10
+ {
11
+ "login": "abdelrahmannasr",
12
+ "name": "alice",
13
+ "email": "alice@example.com",
14
+ "roles": { "hub": ["owner", "reviewer"], "backend": ["domain-owner"] }
15
+ }
11
16
  ```
12
17
 
13
18
  - `login` — the GitHub/GitLab username whose review/approval is being mapped.
14
19
  - `name` — the SDLC name written to `approvals.json` / `comments.json` (the same names as `epic.md`
15
20
  `owner` and `repos.json` `domain_owner`). Keep it stable.
16
- - `role` — the person's default role: `owner` or `reviewer`. **Not** `domain-owner` that is derived.
21
+ - `email` — the commit email; drives the **committer login** reverse lookup used to auto-assign PRs.
22
+ - `roles` — a **per-scope map** from a scope (`hub`, or a connected repo name) to the roles held there
23
+ (`owner` / `reviewer` / `domain-owner`). A person can hold several roles in one scope (owner **and**
24
+ reviewer **and** domain-owner at once), and several scopes; a repo gets several owners/reviewers/
25
+ domain-owners by being listed in several people's maps.
26
+
27
+ **Back-compat (read on all three shapes):** the per-scope object above; a flat array
28
+ `"roles": ["owner","reviewer"]` (treated as `hub` roles); and the legacy single `"role": "owner"`
29
+ (a `hub` role). The legacy `repos.json` `domain_owner` field is still honored (see Resolution step 2).
17
30
 
18
31
  ## Resolution
19
32
 
20
- 1. **login → name + role** from the roster.
21
- 2. **domain-owner is derived:** if the resolved `name` equals a repo's `domain_owner` in `repos.json`,
22
- and that repo is a **touched domain** for the step under review, the bridge also emits a
33
+ 1. **login → name + roles** from the roster. The `hub` roles map straight to records; each touched
34
+ domain `R` contributes the roles in `roles[R]` (a `domain-owner` role carries `domain: R`).
35
+ 2. **Legacy domain-owner fallback:** if the resolved `name` equals a repo's `domain_owner` in
36
+ `repos.json`, and that repo is a **touched domain** for the step under review, the bridge also emits a
23
37
  `domain-owner` approval scoped to that repo (`domain: <repo>`). One person owning several repos yields
24
38
  several `domain-owner` records with different `domain` values — exactly what the gate predicate allows.
25
39
  3. **Unmapped login → reviewer (flagged).** A login not in the roster maps to `name: <login>`,
@@ -27,6 +41,19 @@ in `../../yad-connect-repos/references/hub-config.md`; this file covers how the
27
41
  reviewer but is **never** auto-promoted to owner/domain-owner, so a stranger can never satisfy the
28
42
  owner/domain-owner requirement. The marker prompts a human to add the login to the roster.
29
43
 
44
+ ## Auto-assignee / auto-reviewer on PR/MR open
45
+
46
+ When a review PR/MR is opened (hub `yad gate open`, or a code-repo `yad open-pr`):
47
+
48
+ - **Assignee = the committer/opener** — resolved from local git identity (`user.email`, then
49
+ `user.name`) through the roster (`email`/`name`/`login`). On GitHub an unresolved committer still
50
+ self-assigns via `@me`.
51
+ - **Reviewers = `reviewer` + `domain-owner`** for the touched scope(s) (`hub` plus every touched
52
+ domain for a hub review; the repo itself for a code PR), **minus the committer** — you do not review
53
+ your own PR. The artifact **owner/author is recorded, not requested.**
54
+ - Logins are validated against the hub during `yad setup` / `yad doctor` (`gh api users/<login>`,
55
+ `glab api users?username=<login>`); a miss is flagged `unverified` but never blocks (fail-open).
56
+
30
57
  ## Per-repo routing (stories review, and any escalated step)
31
58
 
32
59
  The stories review needs a `domain-owner` per repo in the **union of every story's `repos`**. On the
@@ -70,6 +70,11 @@ required reviewers:
70
70
  approval per touched domain — identical to `yad-review-gate`'s escalation. The actual approvals are
71
71
  recorded by the engineer review (Step E), via `yad-review-gate`.
72
72
 
73
+ When the PR/MR is actually opened with `yad open-pr`, these reviewers are **requested automatically**
74
+ from the repo-scoped roster (everyone with `reviewer`/`domain-owner` for the repo, minus the committer),
75
+ and the **committer is set as the assignee**. `risk-route.sh` remains the advisory printout of who the
76
+ gate will require.
77
+
73
78
  ### Step 4 — Stop (no auto-advance)
74
79
  Report what was committed (or the routing result). The template and routing are advisory inputs to the
75
80
  human review (Step E); they do not approve or merge. Do not touch the epic's `.sdlc/` state.
@@ -63,8 +63,14 @@ on a real PR/MR instead of (or as well as) the skill recording it directly. `act
63
63
  tagged `"source": "bridge"`. **The predicate above is unchanged**: it counts owner/reviewer/domain-owner
64
64
  approvals regardless of how they were recorded.
65
65
 
66
- - login → role via the roster; `domain-owner` derived when a roster `name` equals a repo's `domain_owner`
67
- and that repo is a touched domain; an unmapped login is a plain `reviewer`, never promoted.
66
+ - login → role(s) via the roster's **per-scope map** (`roles: { hub: [...], <repo>: [...] }`): a person
67
+ can hold owner + reviewer + domain-owner at once, and a repo can list several people per role. The
68
+ `hub` roles plus each touched domain's `roles[<repo>]` are emitted; `domain-owner` is also **derived**
69
+ when a roster `name` equals a repo's `domain_owner`/`domain_owners` (legacy fallback) and that repo is a
70
+ touched domain; an unmapped login is a plain `reviewer`, never promoted.
71
+ - On PR/MR open the assignee is the committer and reviewers are the scope's `reviewer` + `domain-owner`
72
+ members (minus the committer); the owner/author is recorded, not requested. See
73
+ `../yad-hub-bridge/references/login-roster.md`.
68
74
  - `sync` is idempotent (upsert by `(step, approver, role, domain)`; supersede revoked; key comments on
69
75
  comment id) and never touches **manual** approvals.
70
76
  - The architecture+contract staleness rule applies to bridge approvals too: a re-lock discards bridge