yadflow 2.7.0 → 2.9.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.9.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.8.0...v2.9.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
+ * **docs:** enhance interactive docs clearer diagram, brand icon, dimmed stubs ([#65](https://github.com/abdelrahmannasr/yadflow/issues/65)) ([969a20d](https://github.com/abdelrahmannasr/yadflow/commit/969a20dc1e9778e1ffd0962e557f3e2c28dfd6ef))
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/platform.mjs CHANGED
@@ -89,7 +89,7 @@ export function validateLogin(platform, login) {
89
89
  const r = run('glab', ['api', `users?username=${encodeURIComponent(login)}`]);
90
90
  if (!r.ok) return { ok: false, exists: false, checked: true };
91
91
  let exists = false;
92
- try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { exists = false; }
92
+ try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { /* malformed JSON -> exists stays false */ }
93
93
  return { ok: exists, exists, checked: true };
94
94
  }
95
95
 
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.9.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,9 +54,9 @@
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
- "eslint": "^9.39.4",
59
+ "eslint": "^10.5.0",
61
60
  "semantic-release": "^25.0.3"
62
61
  }
63
62
  }
@@ -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`,
@@ -15,6 +15,28 @@ shell renders whatever these export, as long as it satisfies `src/data/types.ts`
15
15
  | `docSections.ts` | `DOC_SECTIONS: DocSectionConfig[]` | `epic.md`, `architecture.md`, `contract.md`, `ui-design.md`, `test-cases.md` | the ordered doc-section registry (`{ id, title, icon, iconColor, component }`); each section id is referenced from `roles.ts`. |
16
16
  | `referenceData.ts` | the reference tables/payloads the doc-section components render | `contract.md` CONTRACT-SURFACE (authoritative) + `architecture.md` + `test-cases.md` | API reference rows, the status machine, the DB schema, feature flags, error codes, the test plan — the structured data behind the doc sections. |
17
17
 
18
+ ## Canvas layout (`components.ts` `position`)
19
+
20
+ `position` is `{ x, y }` in 0–100 (percent of the canvas). Lay the components out as a **hub-and-spoke
21
+ organized around a central hub into four surrounding zones** so the spokes fan out without crossing,
22
+ rather than scattering nodes:
23
+
24
+ - **Center** — the product hub (the brain).
25
+ - **Top band** — the file ledger the hub owns (state / approvals / contract-lock), spread across one row.
26
+ - **Left** — the code side: each connector with its external target just beyond it (`repos-json → code-repos`).
27
+ - **Right** — the connected tools as a single aligned column: each connector on the inner edge with its
28
+ external tool on the same row just outside it (`design-json → Design Tool`, etc.).
29
+ - **Bottom band** — publish / platform / evidence (docs / platform / trust-log).
30
+
31
+ Layout constraints (the nodes are fixed-size cards, ~116×146px, so spacing is what prevents overlap):
32
+ - Only **~4 rows** fit vertically — keep row centers **≥23% apart**; same-row neighbours **≥18% apart** in x.
33
+ - Keep all nodes inside ~6–94% on each axis so no card clips the canvas edge (tool column ≤ ~88% x,
34
+ bottom band ≤ ~80% y).
35
+ - Keep `label`s short (e.g. `Git Platform`, not `Platform (GitHub/GitLab)`) — a long label widens the
36
+ card and breaks the spacing.
37
+ - The layout is **deterministic**: assign zones by role (ledger / code / tools / platform+evidence) and
38
+ order within a zone by stable id, so an unchanged architecture regenerates byte-identically.
39
+
18
40
  ## Section sources (the doc sections + their artifact)
19
41
 
20
42
  | Doc section(s) | Artifact source |
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
5
+ <link rel="icon" type="image/png" href="/yadflow-icon.png" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Booking Flow Visualizer</title>
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -12,10 +12,8 @@ import { SystemLogsTerminal } from './components/Logs/SystemLogsTerminal';
12
12
  import { SubPathDetailPage } from './pages/SubPathDetailPage';
13
13
  import { RoleSelectPage } from './pages/RoleSelectPage';
14
14
  import { StakeholderDocPage } from './pages/StakeholderDocPage';
15
- import { LoginPage } from './components/Auth/LoginPage';
16
15
  import { usePlayback } from './hooks/usePlayback';
17
16
  import { useFlowStore } from './store/useFlowStore';
18
- import { useAuthStore } from './store/useAuthStore';
19
17
 
20
18
  function Dashboard() {
21
19
  usePlayback();
@@ -74,12 +72,6 @@ function Dashboard() {
74
72
  }
75
73
 
76
74
  function App() {
77
- const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
78
-
79
- if (!isAuthenticated) {
80
- return <LoginPage />;
81
- }
82
-
83
75
  return (
84
76
  <div className="flex flex-col h-screen w-screen overflow-hidden" style={{ background: 'var(--color-bg-primary)' }}>
85
77
  <TopNavBar />
@@ -52,8 +52,8 @@ export const SystemComponent: React.FC<SystemComponentProps> = React.memo(
52
52
  : 'rgba(47, 41, 56, 0.4)',
53
53
  backdropFilter: 'blur(12px)',
54
54
  ...glowStyle,
55
- minWidth: '130px',
56
- minHeight: '140px',
55
+ minWidth: '116px',
56
+ minHeight: '120px',
57
57
  }}
58
58
  animate={
59
59
  isReceiving
@@ -6,6 +6,7 @@ import { TriggerEventCard } from './TriggerEventCard';
6
6
  import { RequestPayloadPreview } from './RequestPayloadPreview';
7
7
  import { HandlerLogicSnippet } from './HandlerLogicSnippet';
8
8
  import { Icon } from '../shared/Icon';
9
+ import { Tooltip } from '../shared/Tooltip';
9
10
 
10
11
  export function RightPanel() {
11
12
  const getCurrentStep = useFlowStore((s) => s.getCurrentStep);
@@ -68,19 +69,19 @@ export function RightPanel() {
68
69
  <Icon name="open_in_new" size={18} />
69
70
  View Full Path Details
70
71
  </button>
71
- <button
72
- onClick={() => console.log('Debug step:', step)}
73
- className="w-full py-2.5 rounded-lg text-slate-300 text-sm font-medium border transition-colors flex items-center justify-center gap-2"
74
- style={{
75
- background: 'rgba(255,255,255,0.05)',
76
- borderColor: 'rgba(255,255,255,0.05)',
77
- }}
78
- onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.1)'}
79
- onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
80
- >
81
- <Icon name="bug_report" size={18} />
82
- Debug Step
83
- </button>
72
+ <Tooltip content="Coming soon" className="w-full">
73
+ <button
74
+ disabled
75
+ className="w-full py-2.5 rounded-lg text-slate-300 text-sm font-medium border flex items-center justify-center gap-2 opacity-50 cursor-not-allowed"
76
+ style={{
77
+ background: 'rgba(255,255,255,0.05)',
78
+ borderColor: 'rgba(255,255,255,0.05)',
79
+ }}
80
+ >
81
+ <Icon name="bug_report" size={18} />
82
+ Debug Step
83
+ </button>
84
+ </Tooltip>
84
85
  </div>
85
86
  )}
86
87
  </aside>