yadflow 2.7.0 → 2.8.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,15 +1,9 @@
1
- # [2.7.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.6.0...v2.7.0) (2026-06-14)
2
-
3
-
4
- ### Bug Fixes
5
-
6
- * drop unused today param from runDocs (lint) ([1c255f1](https://github.com/abdelrahmannasr/yadflow/commit/1c255f1c848b9571502e29b142a07366612d638c))
7
- * publish per-epic docs sites in CI + check shell-version staleness ([9862646](https://github.com/abdelrahmannasr/yadflow/commit/9862646fbe910de8ad8248a5f1bb586a5604b18f))
1
+ # [2.8.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.7.0...v2.8.0) (2026-06-15)
8
2
 
9
3
 
10
4
  ### Features
11
5
 
12
- * add interactive documentation skills + yad docs CLI ([4bf7a25](https://github.com/abdelrahmannasr/yadflow/commit/4bf7a25c38e28ef47704a8e2f5acec2f724e4e29))
6
+ * add `yad roster` command to manage the reviewer roster any time ([#64](https://github.com/abdelrahmannasr/yadflow/issues/64)) ([4d78225](https://github.com/abdelrahmannasr/yadflow/commit/4d78225ec25579b50d24d917f217212f4820728f))
13
7
 
14
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
15
9
 
package/README.md CHANGED
@@ -90,6 +90,7 @@ with `npx` from your **product hub** repo — no clone needed.
90
90
  | `npx yadflow check --fix` | Reconcile: fill what is missing **and** update what changed — touches nothing already correct. |
91
91
  | `npx yadflow update` | Apply drift only (alias for `check --fix --scope=changed`). Also migrates a pre-2.0 install in place: `sdlc-*` skill copies and marker-owned `sdlc-*.yml` CI files are replaced by their `yad-*` names (a same-named file *you* authored is never touched). |
92
92
  | `npx yadflow doctor [--json]` | Environment + state health: tools on PATH and platform auth, config files parse and point at real repos, every epic ledger loads. Exit 1 on any failure; `--json` for CI and bug reports. |
93
+ | `yad roster list` / `yad roster add <login>` | Manage the reviewer roster + per-repo roles **any time** (not just at setup). `add` upserts a member then walks each connected repo asking for their role; `grant`/`revoke <name> <repo> <role>` and `remove <login>` round it out. A `domain-owner` grant keeps `repos.json` `domain_owners` in sync. |
93
94
  | `yad gate open <epic> <artifact>` | Open the front-half **review PR/MR** for an artifact and mark the step `in_review`. |
94
95
  | `yad gate sync <epic> [artifact]` | Pull the PR/MR's reviews + comment threads into the file ledger; **auto-advance** the step when approvals are satisfied, all threads are resolved, and the PR is merged. |
95
96
  | `yad gate comments <epic> [artifact]` | Fetch the unresolved review comments to address (then reply on the PR; reviewers resolve their threads). |
@@ -136,6 +137,7 @@ simultaneous advancements can be lost; the next event or scheduled sweep re-sync
136
137
  2. **Install the module** — copy all 29 `yad-*` skills into the IDE skill dirs you pick
137
138
  (`.claude/`, `.agents/`, `.zencoder/`, `.opencode/`) and register `_bmad/sdlc/`.
138
139
  3. **Hub platform & roster** — detect GitHub/GitLab from the remote; record reviewers → `.sdlc/hub.json`.
140
+ Edit the roster any time afterwards with `yad roster` (no need to re-run the whole wizard).
139
141
  4. **Connect a design tool** — record the design tool (Figma / pencil / none) → `.sdlc/design.json` so
140
142
  the UI step can materialize the design; the MCP itself is confirmed later by `yad-connect-design`.
141
143
  5. **Connect a testing tool** — record the testing tool (Playwright / cypress / pytest / none) →
@@ -234,7 +236,8 @@ directly. Each skill stops at a gate and never auto-advances unless a step has *
234
236
  contract lock. `generate` / `refresh` / `deploy`.
235
237
  - **`yad-docs-overview`** — Generates the project **SDLC-overview site** (`docs/sdlc-site/`) — every
236
238
  stage from setup → ship as flow paths / system components / stakeholder roles, reusing the same shell —
237
- superseding the hand-maintained `docs/index.html`.
239
+ superseding the hand-maintained `docs/index.html` (folded into the site as `public/report.html`, linked
240
+ from the nav).
238
241
  - **`yad-docs-sync`** — Keeps the sites fresh: detects staleness (a content hash of the authored
239
242
  artifacts + the connected repos' HEAD shas vs each site's build manifest), regenerates + redeploys, and
240
243
  can wire a CI job that rebuilds on push. Generalizes the rule that feature work must hand-update the
@@ -387,9 +390,10 @@ detailed sections below expand every phase. Invoke a skill by name in your agent
387
390
  `testing.json`, lets `yad-test-cases` implement automation), `yad-connect-learning action: connect`
388
391
  (DeepTutor-first → `learning.json`, powers the cross-cutting learning layer).
389
392
  7. **(Optional) Put the hub on a platform** so the front-half review runs through real PRs:
390
- `yad-connect-repos action: detect-hub`, then `action: roster` once per reviewer (login → SDLC
391
- name + role), and `yad-pr-template repo:hub action: wire` / `yad-review-comments repo:hub action:
392
- wire` / `yad-checks repo:hub action: wire`. With no hub platform the front gate just runs file-only.
393
+ `yad-connect-repos action: detect-hub`, then `yad roster add <login>` once per reviewer (login →
394
+ SDLC name + per-repo roles — the `add` walk asks for each connected repo's role; `yad roster grant`
395
+ sets one directly), and `yad-pr-template repo:hub action: wire` / `yad-review-comments repo:hub
396
+ action: wire` / `yad-checks repo:hub action: wire`. With no hub platform the front gate runs file-only.
393
397
  8. **Conventions:** commits and PR/MR titles follow Conventional Commits (lowercase after the type), the
394
398
  human author owns each commit with an optional per-commit `Co-Authored-By` AI trailer — see
395
399
  [`CONTRIBUTING.md`](CONTRIBUTING.md).
package/bin/yad.mjs CHANGED
@@ -10,6 +10,7 @@ import { runCommit } from '../cli/commit.mjs';
10
10
  import { runOpenPr } from '../cli/openpr.mjs';
11
11
  import { runShip } from '../cli/ship.mjs';
12
12
  import { runRepo } from '../cli/repo.mjs';
13
+ import { runRoster } from '../cli/roster.mjs';
13
14
  import { runDocs } from '../cli/docs.mjs';
14
15
  import { runDoctor } from '../cli/doctor.mjs';
15
16
 
@@ -24,6 +25,14 @@ ${c.bold('Setup & maintenance')}
24
25
  yad doctor [--json] Environment + state health: tools/auth, config files,
25
26
  repo paths, epic ledgers (exit 1 on any failure)
26
27
 
28
+ ${c.bold('Reviewer roster')}
29
+ yad roster list Show every member + their roles per scope (hub + each repo)
30
+ yad roster add <login> Add/edit a member, then walk the connected repos for their roles
31
+ (--name, --email, --roles "hub=owner,reviewer backend=domain-owner")
32
+ yad roster grant <name> <repo> <role...> Grant role(s) for a connected repo (domain-owner|reviewer|owner)
33
+ yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
34
+ yad roster remove <login> Delete a member from the roster
35
+
27
36
  ${c.bold('Review gate (front half)')}
28
37
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
29
38
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
@@ -66,7 +75,7 @@ ${c.bold('Options')}
66
75
  -h, --help Show this help
67
76
  -v, --version Print version`;
68
77
 
69
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic']);
78
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles']);
70
79
 
71
80
  function parseArgs(argv) {
72
81
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -147,6 +156,11 @@ async function main() {
147
156
  await runRepo(o.dir, { action: action || 'list', name, today });
148
157
  break;
149
158
  }
159
+ case 'roster': {
160
+ const [, action, ...rest] = o._;
161
+ await runRoster(o.dir, { action: action || 'list', args: rest, name: o.name, email: o.email, roles: o.roles, today });
162
+ break;
163
+ }
150
164
  case 'docs': {
151
165
  const [, action] = o._;
152
166
  if (o.epic && !isValidEpicId(o.epic)) { log(c.red(`invalid epic id: ${o.epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
package/cli/roster.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // `yad roster` — manage the reviewer roster (.sdlc/hub.json) and per-repo roles at any time.
2
+ // The roster maps a platform login -> SDLC name + a per-scope roles map; it is the only thing that lets
3
+ // the review gate attribute approvals and route per-repo domain-owner reviewers. This is the standalone
4
+ // counterpart of the `yad setup` roster step and the `yad-connect-repos action: roster` skill action.
5
+ // Repo-driven: `add`/`edit` walks the connected repos from repos.json so roles are assigned against what
6
+ // is actually connected, not against repo names the user has to remember. Granting/revoking a
7
+ // `domain-owner` keeps repos.json `domain_owners` in sync so the gate never drifts from the roster.
8
+ import path from 'node:path';
9
+ import { c, log, ok, info, warn, hand, fail, ask, askYesNo, readJSON, writeJSON } from './lib.mjs';
10
+ import { PROJECT_FILES } from './manifest.mjs';
11
+ import { rolesForScope } from './platform.mjs';
12
+ import { parseRolesSpec, upsertRosterEntry, addRepoRoles, removeRepoRole, setRepoDomainOwners, reconcileRepoRoles } from './setup.mjs';
13
+
14
+ const ROLES = ['owner', 'reviewer', 'domain-owner'];
15
+
16
+ const loadHub = (root) => readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
17
+ const loadRepos = (root) => readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] }).repos || [];
18
+ const ownersOf = (repo) => (Array.isArray(repo.domain_owners) ? repo.domain_owners : (repo.domain_owner ? [repo.domain_owner] : []));
19
+
20
+ // `list` — every member with their per-scope roles, plus a drift check between hub.json roles and
21
+ // repos.json domain_owners (the two should agree; the sync on grant/revoke keeps them aligned).
22
+ function rosterList(root) {
23
+ const hub = loadHub(root);
24
+ if (!hub || !Array.isArray(hub.roster) || !hub.roster.length) {
25
+ warn('no roster yet (.sdlc/hub.json) — add one with `yad roster add <login>` or `yad setup`');
26
+ return { members: 0 };
27
+ }
28
+ const repos = loadRepos(root);
29
+ log(c.bold('\nreviewer roster'));
30
+ for (const e of hub.roster) {
31
+ const flag = e.unverified ? c.yellow(' (unverified)') : '';
32
+ log(` ${c.bold(e.name || e.login)} ${c.dim(`@${e.login}`)}${e.email ? c.dim(` <${e.email}>`) : ''}${flag}`);
33
+ const hubRoles = rolesForScope(e, 'hub');
34
+ log(` hub: ${hubRoles.length ? hubRoles.join(', ') : c.dim('—')}`);
35
+ for (const r of repos) {
36
+ const rr = rolesForScope(e, r.name);
37
+ if (rr.length) log(` ${r.name}: ${rr.join(', ')}`);
38
+ }
39
+ }
40
+ const drift = [];
41
+ for (const r of repos) {
42
+ const owners = ownersOf(r);
43
+ for (const nm of owners) {
44
+ const e = hub.roster.find((x) => x.name === nm);
45
+ if (!e || !rolesForScope(e, r.name).includes('domain-owner')) {
46
+ drift.push(`${nm} owns ${r.name} in repos.json but has no domain-owner role in hub.json`);
47
+ }
48
+ }
49
+ for (const e of hub.roster) {
50
+ if (rolesForScope(e, r.name).includes('domain-owner') && !owners.includes(e.name)) {
51
+ drift.push(`${e.name} has domain-owner for ${r.name} in hub.json but is missing from repos.json domain_owners`);
52
+ }
53
+ }
54
+ }
55
+ if (drift.length) { log(''); for (const d of drift) warn(d); }
56
+ return { members: hub.roster.length, repos: repos.length, drift: drift.length };
57
+ }
58
+
59
+ // The repo-driven walk: for each connected repo show the member's current role and offer to set it.
60
+ async function repoWalk(root, entry) {
61
+ const repos = loadRepos(root);
62
+ if (!repos.length) { info('no connected repos to assign roles for (.sdlc/repos.json) — connect one with `yad setup`'); return; }
63
+ for (const r of repos) {
64
+ const cur = rolesForScope(entry, r.name);
65
+ if (!(await askYesNo(` set ${entry.name}'s role on ${r.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
66
+ const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
67
+ const want = input.split(/\s+/).map((x) => x.trim()).filter(Boolean);
68
+ const invalid = want.filter((x) => !ROLES.includes(x));
69
+ if (invalid.length) warn(`ignoring unknown role(s): ${invalid.join(', ')} (allowed: ${ROLES.join(', ')})`);
70
+ reconcileRepoRoles(root, entry.name, r.name, cur, want.filter((x) => ROLES.includes(x)));
71
+ }
72
+ }
73
+
74
+ // Mirror any domain-owner scopes from a non-interactive `--roles` upsert into repos.json.
75
+ function syncDomainOwners(root, entry) {
76
+ for (const [scope, list] of Object.entries(entry.roles || {})) {
77
+ if (scope !== 'hub' && Array.isArray(list) && list.includes('domain-owner')) setRepoDomainOwners(root, scope, entry.name, { add: true });
78
+ }
79
+ }
80
+
81
+ // `add`/`edit <login>` — upsert by login (from flags or interactive prompts), then either apply a
82
+ // `--roles` spec directly (scriptable) or run the repo-driven walk (interactive default).
83
+ async function rosterAdd(root, login, { name, email, roles } = {}) {
84
+ if (!login) { fail('usage: yad roster add <login> [--name N] [--email E] [--roles "hub=owner,reviewer backend=domain-owner"]'); process.exitCode = 1; return {}; }
85
+ const hub = loadHub(root);
86
+ const platform = hub ? hub.platform : null;
87
+ const existing = hub && Array.isArray(hub.roster) ? hub.roster.find((e) => e.login === login) : null;
88
+ const scripted = !!roles;
89
+ let nm = name;
90
+ let em = email;
91
+ let rolesMap;
92
+ if (scripted) {
93
+ rolesMap = parseRolesSpec(roles);
94
+ } else {
95
+ nm = nm || await ask(' yad name', (existing && existing.name) || login);
96
+ em = em || await ask(' commit email (blank to skip)', (existing && existing.email) || '');
97
+ const def = rolesForScope(existing, 'hub').join(' ') || 'reviewer';
98
+ const hubRoles = (await ask(' hub roles (owner/reviewer, space-separated)', def)).split(/\s+/).filter(Boolean);
99
+ rolesMap = hubRoles.length ? { hub: hubRoles } : {};
100
+ }
101
+ const { entry, created } = upsertRosterEntry(root, { login, name: nm, email: em || undefined, roles: rolesMap, platform });
102
+ if (!entry) return {};
103
+ ok(`${created ? 'added' : 'updated'} ${entry.name} (@${login})`);
104
+ if (scripted) syncDomainOwners(root, entry);
105
+ else await repoWalk(root, entry);
106
+ return { entry: entry.name, created };
107
+ }
108
+
109
+ // `grant <name> <repo> <role...>` — scriptable per-repo grant (member must already be in the roster).
110
+ function rosterGrant(root, [name, repo, ...roles]) {
111
+ if (!name || !repo || !roles.length) { fail('usage: yad roster grant <name> <repo> <role...>'); process.exitCode = 1; return {}; }
112
+ const invalid = roles.filter((r) => !ROLES.includes(r));
113
+ if (invalid.length) { fail(`unknown role(s): ${invalid.join(', ')} (allowed: ${ROLES.join(', ')})`); process.exitCode = 1; return {}; }
114
+ const hub = loadHub(root);
115
+ if (!hub || !Array.isArray(hub.roster) || !hub.roster.some((e) => e.name === name)) {
116
+ fail(`'${name}' is not in the roster — add them first with \`yad roster add <login>\``); process.exitCode = 1; return {};
117
+ }
118
+ if (!loadRepos(root).some((r) => r.name === repo)) warn(`repo '${repo}' is not registered — the role is recorded and applies once it is connected`);
119
+ addRepoRoles(root, repo, Object.fromEntries(roles.map((r) => [r, [name]])));
120
+ if (roles.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: true });
121
+ ok(`granted ${name} ${roles.join(', ')} on ${repo}`);
122
+ return { name, repo, roles };
123
+ }
124
+
125
+ // `revoke <name> <repo> <role...>` — scriptable per-repo revoke.
126
+ function rosterRevoke(root, [name, repo, ...roles]) {
127
+ if (!name || !repo || !roles.length) { fail('usage: yad roster revoke <name> <repo> <role...>'); process.exitCode = 1; return {}; }
128
+ removeRepoRole(root, name, repo, roles);
129
+ if (roles.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: false });
130
+ ok(`revoked ${name} ${roles.join(', ')} on ${repo}`);
131
+ return { name, repo, roles };
132
+ }
133
+
134
+ // `remove <login>` — delete a member; warn (do not cascade) if still a domain owner in repos.json.
135
+ function rosterRemove(root, login) {
136
+ if (!login) { fail('usage: yad roster remove <login>'); process.exitCode = 1; return {}; }
137
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
138
+ const hub = readJSON(hubPath, null);
139
+ if (!hub || !Array.isArray(hub.roster)) { warn('no roster to remove from (.sdlc/hub.json)'); return { removed: 0 }; }
140
+ const idx = hub.roster.findIndex((e) => e.login === login);
141
+ if (idx < 0) { warn(`no roster member with login '${login}'`); return { removed: 0 }; }
142
+ const [removed] = hub.roster.splice(idx, 1);
143
+ writeJSON(hubPath, hub);
144
+ ok(`removed ${removed.name} (@${login})`);
145
+ const refs = loadRepos(root).filter((r) => ownersOf(r).includes(removed.name)).map((r) => r.name);
146
+ if (refs.length) hand(`'${removed.name}' is still a domain owner in repos.json for: ${refs.join(', ')} — revoke with \`yad roster revoke ${removed.name} <repo> domain-owner\``);
147
+ return { removed: 1 };
148
+ }
149
+
150
+ export async function runRoster(root, { action = 'list', args = [], name, email, roles } = {}) {
151
+ switch (action) {
152
+ case 'list': return rosterList(root);
153
+ case 'add':
154
+ case 'edit': return rosterAdd(root, args[0], { name, email, roles });
155
+ case 'grant': return rosterGrant(root, args);
156
+ case 'revoke': return rosterRevoke(root, args);
157
+ case 'remove':
158
+ case 'rm': return rosterRemove(root, args[0]);
159
+ default:
160
+ fail(`unknown roster action: ${action} (list | add | edit | grant | revoke | remove)`);
161
+ process.exitCode = 1;
162
+ return {};
163
+ }
164
+ }
package/cli/setup.mjs CHANGED
@@ -11,13 +11,29 @@ import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, legacyRepoActions, legacyHubActions,
13
13
  } from './plan.mjs';
14
- import { validateLogin } from './platform.mjs';
14
+ import { validateLogin, rolesForScope } from './platform.mjs';
15
15
 
16
16
  // Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
17
- function parseList(s) {
17
+ export function parseList(s) {
18
18
  return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
19
19
  }
20
20
 
21
+ // Parse a per-scope roles spec — `"hub=owner,reviewer backend=domain-owner"` — into the roster's
22
+ // per-scope map `{ hub: ['owner','reviewer'], backend: ['domain-owner'] }`. Tokens are whitespace
23
+ // separated; each is `scope=role[,role...]`. Malformed tokens (no `=`, empty scope/roles) are skipped.
24
+ export function parseRolesSpec(s) {
25
+ const out = {};
26
+ for (const tok of (s || '').split(/\s+/).map((x) => x.trim()).filter(Boolean)) {
27
+ const eq = tok.indexOf('=');
28
+ if (eq < 0) continue;
29
+ const scope = tok.slice(0, eq).trim();
30
+ const roles = parseList(tok.slice(eq + 1));
31
+ if (!scope || !roles.length) continue;
32
+ out[scope] = [...new Set([...(out[scope] || []), ...roles])];
33
+ }
34
+ return out;
35
+ }
36
+
21
37
  const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
22
38
 
23
39
  export function detectPlatform(remoteUrl = '') {
@@ -68,6 +84,98 @@ export function addRepoRoles(root, repo, grants = {}) {
68
84
  if (touched) writeJSON(hubPath, hub);
69
85
  }
70
86
 
87
+ // Normalize a roster entry's roles in place to the per-scope map, migrating the two legacy shapes
88
+ // (a flat array, or a single `role` string) into `roles.hub` so nothing is lost. Mirrors the
89
+ // migration addRepoRoles does; pulled out so upsert/remove share it.
90
+ function normalizeRoles(entry) {
91
+ if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
92
+ const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
93
+ entry.roles = hub.length ? { hub } : {};
94
+ delete entry.role;
95
+ }
96
+ return entry.roles;
97
+ }
98
+
99
+ // Upsert one roster member into hub.json, keyed by `login`. Deep-merges the per-scope `roles` map so
100
+ // scopes the caller did not name are preserved; sets `name`/`email` when given; validates the login
101
+ // against the hub (warn-only — a miss flags `unverified`, `checked:false` skips silently). Creates the
102
+ // hub.json shell if absent. Returns { entry, created }.
103
+ export function upsertRosterEntry(root, { login, name, email, roles = {}, platform } = {}) {
104
+ if (!login) { warn('roster upsert needs a login — skipped'); return { entry: null, created: false }; }
105
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
106
+ const hub = readJSON(hubPath, null) || { platform: platform && platform !== 'none' ? platform : null, bridge_enabled: false, bridge: false, default_branch: 'main', roster: [] };
107
+ if (!Array.isArray(hub.roster)) hub.roster = [];
108
+ let entry = hub.roster.find((e) => e.login === login);
109
+ const created = !entry;
110
+ if (!entry) { entry = { login, name: name || login, roles: {} }; hub.roster.push(entry); }
111
+ if (name) entry.name = name;
112
+ if (email) entry.email = email;
113
+ normalizeRoles(entry);
114
+ for (const [scope, list] of Object.entries(roles || {})) {
115
+ const cur = Array.isArray(entry.roles[scope]) ? entry.roles[scope] : [];
116
+ entry.roles[scope] = [...new Set([...cur, ...list])];
117
+ }
118
+ if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
119
+ const plat = platform || hub.platform;
120
+ if (plat && plat !== 'none') {
121
+ const v = validateLogin(plat, login);
122
+ if (v.checked && !v.exists) { warn(`'${login}' not found on ${plat} — saved as unverified`); entry.unverified = true; }
123
+ else if (v.checked && v.exists) { ok(`verified ${login} on ${plat}`); delete entry.unverified; }
124
+ }
125
+ writeJSON(hubPath, hub);
126
+ return { entry, created };
127
+ }
128
+
129
+ // Inverse of addRepoRoles: drop the named role(s) from a member's `roles[<repo>]` scope, removing the
130
+ // scope key when it empties. Member is found by yad `name` (matching addRepoRoles). Idempotent.
131
+ export function removeRepoRole(root, name, repo, roles = []) {
132
+ const hubPath = path.join(root, PROJECT_FILES.hubConfig);
133
+ const hub = readJSON(hubPath, null);
134
+ if (!hub || !Array.isArray(hub.roster)) return;
135
+ const entry = hub.roster.find((e) => e.name === name);
136
+ if (!entry) { warn(`'${name}' is not in the roster — nothing to revoke for ${repo}`); return; }
137
+ normalizeRoles(entry);
138
+ const cur = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
139
+ const next = cur.filter((r) => !roles.includes(r));
140
+ if (next.length === cur.length) return; // nothing removed
141
+ if (next.length) entry.roles[repo] = next; else delete entry.roles[repo];
142
+ writeJSON(hubPath, hub);
143
+ }
144
+
145
+ // Keep repos.json `domain_owners` in sync when a domain-owner role is granted/revoked via the roster,
146
+ // so the gate's per-repo reviewer-routing and the derivation fallback never drift from hub.json. Adds
147
+ // or removes the yad `name` and mirrors `domain_owner = domain_owners[0]`. No-op + warn if the repo is
148
+ // not registered. Returns true when the registry was written.
149
+ export function setRepoDomainOwners(root, repo, name, { add = true } = {}) {
150
+ const regPath = path.join(root, PROJECT_FILES.reposRegistry);
151
+ const registry = readJSON(regPath, { repos: [] });
152
+ const entry = (registry.repos || []).find((r) => r.name === repo);
153
+ if (!entry) { warn(`repo '${repo}' is not registered (.sdlc/repos.json) — domain_owners not synced`); return false; }
154
+ const owners = Array.isArray(entry.domain_owners) ? [...entry.domain_owners] : (entry.domain_owner ? [entry.domain_owner] : []);
155
+ const has = owners.includes(name);
156
+ let next;
157
+ if (add) { if (has) return false; next = [...owners, name]; }
158
+ else { if (!has) return false; next = owners.filter((o) => o !== name); }
159
+ entry.domain_owners = next;
160
+ entry.domain_owner = next[0] || '';
161
+ writeJSON(regPath, registry);
162
+ return true;
163
+ }
164
+
165
+ // Reconcile one repo's roles for a member to exactly `want`: grant what is new, revoke what is gone,
166
+ // and mirror domain-owner changes into repos.json. `current` is the member's existing roles for the
167
+ // repo. Shared by the `yad roster` walk and the `yad setup` per-repo role step. Idempotent.
168
+ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
169
+ const toAdd = want.filter((r) => !current.includes(r));
170
+ const toRemove = current.filter((r) => !want.includes(r));
171
+ if (!toAdd.length && !toRemove.length) { info(` ${repo}: unchanged`); return; }
172
+ if (toAdd.length) addRepoRoles(root, repo, Object.fromEntries(toAdd.map((r) => [r, [name]])));
173
+ if (toRemove.length) removeRepoRole(root, name, repo, toRemove);
174
+ if (toAdd.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: true });
175
+ if (toRemove.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: false });
176
+ ok(` ${repo}: ${want.length ? want.join(', ') : 'cleared'}`);
177
+ }
178
+
71
179
  export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
72
180
  if (!insideRoot(root, rpath)) {
73
181
  warn(`${rpath} resolves outside the project root — skipped`);
@@ -370,6 +478,24 @@ export async function runSetup(root, opts = {}) {
370
478
  }
371
479
  }
372
480
 
481
+ // 7b. Assign/update roles for ALREADY-connected repos. The connect loop above only prompts for the
482
+ // repos you add now; this closes the gap so a member's domain-owner/reviewer/owner role on a repo
483
+ // connected in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
484
+ const hub7 = readJSON(hubPath, null);
485
+ if (registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
486
+ && await askYesNo('Assign/update roles for connected repos?', false)) {
487
+ for (const member of hub7.roster) {
488
+ if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
489
+ for (const repo of registry.repos) {
490
+ const cur = rolesForScope(member, repo.name);
491
+ if (!(await askYesNo(` set ${member.name}'s role on ${repo.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
492
+ const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
493
+ const want = parseList(input).filter((x) => ['owner', 'reviewer', 'domain-owner'].includes(x));
494
+ reconcileRepoRoles(root, member.name, repo.name, cur, want);
495
+ }
496
+ }
497
+ }
498
+
373
499
  // 8. Wire each connected repo + the hub itself
374
500
  step(8, total, 'Wire connected repos + the hub (CI gates, PR template, comment scaffold, gate-sync)');
375
501
  if (registry.repos.length === 0) info('no repos to wire');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.7.0",
3
+ "version": "2.8.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, ship, repo). A BMAD module + 29 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -22,7 +22,6 @@
22
22
  "!cli/test.mjs",
23
23
  "!cli/test-checks.mjs",
24
24
  "skills/",
25
- "docs/index.html",
26
25
  "README.md",
27
26
  "LICENSE",
28
27
  "CHANGELOG.md"
@@ -55,7 +54,7 @@
55
54
  "deeptutor"
56
55
  ],
57
56
  "devDependencies": {
58
- "@eslint/js": "^9.39.4",
57
+ "@eslint/js": "^10.0.1",
59
58
  "@semantic-release/changelog": "^6.0.3",
60
59
  "eslint": "^9.39.4",
61
60
  "semantic-release": "^25.0.3"
@@ -26,5 +26,5 @@ SDLC Workflow,yad-run,Run (Automation),RN,"Phase 4 orchestrator: drive a story's
26
26
  SDLC Workflow,yad-status,SDLC Status,SS,"Read-only: print the full front-state chain, per-step dials, contract lock, story repo tags, and pending approvals at the active gate. For stories in the build half, also print each back-half step's automation dial and status, the trust record (runs / % approved-unchanged / earned vs gathering evidence), and the system-wide kill-switch state.",,{epic: EP-<slug>},1-front,,,false,,
27
27
  SDLC Workflow,yad-connect-docs,Connect Docs Target,DX,"Setup/maintenance: connect a docs/Pages publishing target so the interactive-docs steps can deploy the generated site (not just commit its source). Auto-detects the platform from .sdlc/hub.json (github->github-pages, gitlab->gitlab-pages, null->build-only), resolves the Vite base path, and records the target in .sdlc/docs.json (local-user auth, no stored tokens). Degrades to build-only when gh/glab is absent. Idempotent and refreshable; one connection per project. Not a gated state — never touches epic state or approvals.",,{action: connect|refresh|list|disconnect} {target: github-pages|gitlab-pages|none} {scope: hub|<repo>|dedicated} {base_path: <override>},0-setup,,yad-docs,false,.sdlc/,docs.json
28
28
  SDLC Workflow,yad-docs,Author Docs Site,DS,"Generate the per-epic interactive documentation SPA (animated flow canvas + role-based stakeholder doc pages) from the epic's approved artifacts (epic, architecture, locked contract, ui-design, stories, code-context, test-cases), themed by the connected design system, into epics/EP-<slug>/docs-site/. Copies the vendored React/Vite/Tailwind shell verbatim and generates src/data/*.ts deterministically; drives the yad docs build/deploy CLI to publish to Pages (or build-only). An OUTPUT ENRICHMENT — never a gate; never mutates state.json, approvals, or the contract lock.",,{epic: EP-<slug>} {action: generate|refresh|deploy} {login_gate: true|false},,yad-review-gate,,false,epics/EP-<slug>/docs-site/,docs-site/ docs-build.json
29
- SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Supersedes the hand-maintained docs/index.html (left as a thin redirect for one release). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
29
+ SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Folds the hand-maintained docs/index.html report into the site as report.html (linked from the nav). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
30
30
  SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + the doc-shell version vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that rebuilds on push (carrying [skip ci] + a concurrency group to prevent deploy loops). Generalizes the rule that feature work must hand-update docs/index.html + diagrams + skill counts. Refresh is always a human/CI decision; never a gate.",,{action: check|refresh|wire} {epic: EP-<slug>},,,,false,epics/EP-<slug>/.sdlc/,docs-build.json yad-docs.yml
@@ -133,6 +133,10 @@ write only `{project-root}/.sdlc/hub.json` (`config.yaml` `hub.config`) — neve
133
133
  `roles[<repo>]` (and still **derived** as a fallback when a roster `name` equals a repo's
134
134
  `domain_owner`/`domain_owners` in `repos.json` — see `references/hub-config.md`). An unmapped login
135
135
  degrades to a plain `reviewer`, never auto-promoted to owner/domain-owner.
136
+ **The deterministic half is the `yad roster` CLI command** — runnable any time, not just at setup:
137
+ `yad roster list`; `yad roster add <login>` (upsert, then a repo-driven walk that asks for each
138
+ connected repo's role); `yad roster grant|revoke <name> <repo> <role>`; `yad roster remove <login>`.
139
+ A `domain-owner` grant/revoke keeps `repos.json` `domain_owners` in sync so the gate never drifts.
136
140
 
137
141
  If the hub has no remote (`platform: null`) or the bridge is disabled, the front-half gate runs
138
142
  file-only with no error — the bridge is purely additive.
@@ -33,7 +33,9 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
33
33
 
34
34
  The roster is how a platform identity (a GitHub/GitLab **login**) becomes an SDLC **name + role(s)** in
35
35
  the file ledger (`approvals.json` / `comments.json`). Roles are the same three the gate uses:
36
- `owner | reviewer | domain-owner`.
36
+ `owner | reviewer | domain-owner`. Populate and edit it any time with the **`yad roster`** CLI command
37
+ (`list` / `add` / `grant` / `revoke` / `remove`) — `add` walks the connected repos asking for each
38
+ one's role, and a `domain-owner` grant keeps `repos.json` `domain_owners` in sync.
37
39
 
38
40
  - **`login`** — the platform username whose PR review / approval is being mapped.
39
41
  - **`name`** — the SDLC name written into the ledger (the same names used across `approvals.json`,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yad-docs-overview
3
- description: 'Generates the project-level SDLC-overview interactive site — the same React/Vite/Tailwind shell as the per-epic docs — showing every yadflow stage from setup → ship: the pipeline as a flow canvas, each skill/gate as a flow step, the durable .sdlc state objects as system components, and the lenses as stakeholder roles. Themed with yadflow''s own brand palette for continuity, built from config.yaml + module-help.csv + the overview diagram. Supersedes docs/index.html with a thin redirect and deploys via `yad docs deploy --overview`. This is project documentation, not a gated state — it never touches any epic''s state or approvals. Use when the user says "generate the overview site", "build the SDLC overview docs", or after the pipeline (module-help.csv / config.yaml / skill count) changes.'
3
+ description: 'Generates the project-level SDLC-overview interactive site — the same React/Vite/Tailwind shell as the per-epic docs — showing every yadflow stage from setup → ship: the pipeline as a flow canvas, each skill/gate as a flow step, the durable .sdlc state objects as system components, and the lenses as stakeholder roles. Themed with yadflow''s own brand palette for continuity, built from config.yaml + module-help.csv + the overview diagram. Folds the legacy hand-maintained docs/index.html report into the site as report.html (linked from the nav) and deploys via `yad docs deploy --overview`. This is project documentation, not a gated state — it never touches any epic''s state or approvals. Use when the user says "generate the overview site", "build the SDLC overview docs", or after the pipeline (module-help.csv / config.yaml / skill count) changes.'
4
4
  ---
5
5
 
6
6
  # SDLC — Author the Overview Site (project-level, the pipeline as a living map)
@@ -9,7 +9,7 @@ description: 'Generates the project-level SDLC-overview interactive site — the
9
9
  reusing the same shell as the per-epic docs (`skills/yad-docs/templates/app/`). Where `yad-docs`
10
10
  animates one epic's flows, this animates the **workflow itself**: the front gates, the build half, the
11
11
  automation dial, the setup connectors. It is the regenerable successor to the hand-maintained
12
- `docs/index.html` overview.
12
+ overview report, which is folded into this site as `public/report.html`.
13
13
 
14
14
  This is **project documentation, not a gated state** — there is no epic, no `state.json`, no approvals.
15
15
  It only reads the pipeline definition and writes a project-level site. When a docs target is connected
@@ -22,7 +22,7 @@ It only reads the pipeline definition and writes a project-level site. When a do
22
22
  the generated **source is committed**. The overview build manifest is `docs/sdlc-site/.docs-build.json`.
23
23
  - The shell template is `skills/yad-docs/templates/app/` — copied **verbatim**, themed only in the
24
24
  `:root` of `index.css`. Generated data satisfies `src/data/types.ts`.
25
- - Theme: **yadflow's own brand palette** (the `:root` of `docs/index.html`) — for visual continuity with
25
+ - Theme: **yadflow's own brand palette** (the `:root` of the legacy report, now `docs/sdlc-site/public/report.html`) — for visual continuity with
26
26
  the existing overview, not an epic's design tokens.
27
27
  - Speak in the configured `communication_language`; write documents in `document_output_language`.
28
28
 
@@ -68,7 +68,7 @@ Map the pipeline onto the same data structures `yad-docs` uses (concrete mapping
68
68
  Copy the shell from `templates/app/` **verbatim**, generate `src/data/*.ts` deterministically (same
69
69
  determinism rules as `yad-docs`: stable-ID sort by skill pipeline order / phase, fixed key order, no
70
70
  timestamps in the data files), theme the `:root` of `index.css` from **yadflow's brand palette** — the
71
- `docs/index.html` `:root`: `--accent: #2471a3` and the node colors (`--artifact-*`, `--gate-*`,
71
+ the legacy report's `:root`: `--accent: #2471a3` and the node colors (`--artifact-*`, `--gate-*`,
72
72
  `--earns-*`, `--locked-*`, `--sentinel-*`) — and substitute the Vite base from `.sdlc/docs.json`
73
73
  `basePath` (the overview sits at the base root, e.g. `/<repo>/`).
74
74
 
@@ -91,11 +91,13 @@ doc-shell upgrade triggers a rebuild). `skillCount` rides along in the manifest
91
91
  — it is **not** a separate hash input, since `module-help.csv` already moves whenever the skill set does.
92
92
  Not per-epic artifacts/repo heads.
93
93
 
94
- ### Step 5 — Supersede `docs/index.html` (one release)
95
- Turn `docs/index.html` into a **thin redirect** to `docs/sdlc-site/` (e.g. a `<meta http-equiv="refresh">`
96
- + a one-line link), and **note in the report** that the hand-maintained overview is superseded by the
97
- generated site for this release. This generalizes the standing rule that feature work hand-updates
98
- `docs/index.html`: the overview site now **regenerates** instead.
94
+ ### Step 5 — Fold the legacy report into the site
95
+ Relocate the hand-maintained static report into the generated site as `docs/sdlc-site/public/report.html`
96
+ (Vite copies `public/` verbatim into `dist/`, so it publishes alongside the app at `<base>/report.html`),
97
+ and link it from the app nav (a "Full report" link in `TopNavBar`). The interactive overview becomes the
98
+ primary documentation and the legacy report rides along as its detailed companion — no orphaned
99
+ `docs/index.html` at the repo root. This generalizes the standing rule that feature work hand-updates the
100
+ report: the overview site now **regenerates** instead.
99
101
 
100
102
  ### Step 6 — Build / deploy (`action`)
101
103
  - `action: generate` (default) — generate source + manifest; stop.
@@ -104,8 +106,8 @@ generated site for this release. This generalizes the standing rule that feature
104
106
 
105
107
  ### Step 7 — Stop. Report (no gate, no epic)
106
108
  Report: the site path (`docs/sdlc-site/`), the data files produced, that the theme is the yadflow brand
107
- palette, the deploy URL or "build-only", the staleness baseline, and that `docs/index.html` now
108
- redirects to the generated site. Never touches any epic state.
109
+ palette, the deploy URL or "build-only", the staleness baseline, and that the legacy report is folded in
110
+ at `public/report.html` (linked from the nav). Never touches any epic state.
109
111
 
110
112
  ## Hard rules
111
113
 
@@ -97,6 +97,6 @@ The eight yadflow lenses, each to its relevant phase sections + paths:
97
97
  ## Determinism + theme
98
98
  Same discipline as `yad-docs`: stable-sort steps by phase then pipeline order, fixed key order, **no
99
99
  timestamps** in `src/data/*.ts` (build time lives only in `.docs-build.json`). Theme the `:root` from
100
- **yadflow's brand palette** (`docs/index.html` `:root`): `--accent: #2471a3`, and carry the node-class
100
+ **yadflow's brand palette** (the legacy report's `:root`): `--accent: #2471a3`, and carry the node-class
101
101
  colors through to step/path colors — `--artifact-* #b7950b`, `--gate-* #ca6f1e`, `--earns-* #2471a3`,
102
102
  `--locked-* #566573`, `--sentinel-* #1e8449` — so the canvas reads like the existing diagram.
@@ -3,6 +3,7 @@
3
3
  The roster lives in `.sdlc/hub.json` (`roster: [...]`) and is the only thing that turns a platform
4
4
  **login** into an SDLC **name + role** for the ledger. Schema and the no-tokens rule are documented once
5
5
  in `../../yad-connect-repos/references/hub-config.md`; this file covers how the bridge *uses* it.
6
+ It is populated/edited any time with the `yad roster` CLI command (see that reference).
6
7
 
7
8
  ## Entry
8
9