yadflow 3.4.1 → 3.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,4 +1,14 @@
1
- ## [3.4.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.4.0...v3.4.1) (2026-07-01)
1
+ # [3.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.4.2...v3.5.0) (2026-07-02)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **usage:** address CodeRabbit review on PR [#98](https://github.com/abdelrahmannasr/yadflow/issues/98) ([e5e64fa](https://github.com/abdelrahmannasr/yadflow/commit/e5e64fa1c19de53b553a8679e76e8304c2001073))
7
+
8
+
9
+ ### Features
10
+
11
+ * **usage:** add derived team-member usage & behavior report ([1813026](https://github.com/abdelrahmannasr/yadflow/commit/181302613b6585b696fbf3ee132a236543c7fd63))
2
12
 
3
13
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
4
14
 
package/README.md CHANGED
@@ -83,7 +83,10 @@ surfaces blocks like a normal review comment.
83
83
 
84
84
  Tech leads and engineering managers who want their team to move fast with AI **without** giving up
85
85
  review, architectural control, or an audit trail — the governance layer around AI-assisted
86
- development, not another code generator.
86
+ development, not another code generator. And because the audit trail *is* the repo, `yad usage` turns
87
+ it into a per-member **adoption & behavior report** (HTML/JSON/MD) — who authored, reviewed, approved,
88
+ and shipped, with factual workflow-hygiene flags — derived read-only, so an EM can see how the team
89
+ actually uses the flow.
87
90
 
88
91
  ## Documentation
89
92
 
package/bin/yad.mjs CHANGED
@@ -18,6 +18,7 @@ import { runNext } from '../cli/next.mjs';
18
18
  import { syncStatuses } from '../cli/artifact-status.mjs';
19
19
  import { runThread, runReconcile } from '../cli/thread.mjs';
20
20
  import { runReport } from '../cli/report.mjs';
21
+ import { runUsage } from '../cli/usage.mjs';
21
22
 
22
23
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
23
24
 
@@ -46,6 +47,13 @@ ${c.bold('Reviewer roster')}
46
47
  yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
47
48
  yad roster remove <login> Delete a member from the roster
48
49
 
50
+ ${c.bold('Team usage (EM adoption & behavior report)')}
51
+ yad usage Build a per-member report (HTML) from git + the SDLC ledgers
52
+ (derived, read-only — writes no tracked state)
53
+ flags: --out <path> (default ./usage-report.html),
54
+ --since <YYYY-MM-DD> --until <YYYY-MM-DD> | --all,
55
+ --member <name>, --format html|json|md, --repos (code commits)
56
+
49
57
  ${c.bold('Where am I / what next')}
50
58
  yad next Project-wide: the one next action to take (or run setup)
51
59
  yad next <epic> The single next action for one epic (skill or yad command)
@@ -115,7 +123,7 @@ ${c.bold('Options')}
115
123
  -h, --help Show this help
116
124
  -v, --version Print version`;
117
125
 
118
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body']);
126
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format']);
119
127
 
120
128
  function parseArgs(argv) {
121
129
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -141,6 +149,7 @@ function parseArgs(argv) {
141
149
  else if (a === '--separate') o.separate = true;
142
150
  else if (a === '--tools') o.tools = true;
143
151
  else if (a === '--refresh') o.refresh = true;
152
+ else if (a === '--repos') o.repos = true;
144
153
  else if (a === '--wire') o.wire = true;
145
154
  else if (a === '--dry-run') o.dryRun = true;
146
155
  else if (a === '--json') o.json = true;
@@ -188,6 +197,12 @@ async function main() {
188
197
  case 'report':
189
198
  await runReport(o.dir, { message: o.message });
190
199
  break;
200
+ case 'usage':
201
+ runUsage(o.dir, {
202
+ out: o.out, since: o.since, until: o.until, all: o.all, member: o.member,
203
+ format: o.format, repos: o.repos, json: o.json, today,
204
+ });
205
+ break;
191
206
  case 'sync-status': {
192
207
  const [, epic] = o._;
193
208
  if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
package/cli/doctor.mjs CHANGED
@@ -76,13 +76,25 @@ export function projectChecks(checks, root) {
76
76
  // platform CLI + auth (best-effort; auth probing is the user's own session)
77
77
  const cli = cliFor(hub.platform);
78
78
  if (cli) {
79
- // Scope the auth probe to the hub's own host (derived from git_url). `${cli} auth status`
80
- // without --hostname exits non-zero when ANY configured instance fails, so an unrelated
81
- // stale login (e.g. a dead gitlab.com token) would falsely flag a working self-hosted hub.
82
- const host = hostFromGitUrl(hub.git_url);
83
- const authArgs = host ? ['auth', 'status', '--hostname', host] : ['auth', 'status'];
79
+ // git_url is required whenever a platform is set doctor needs it to scope the auth probe
80
+ // and the bridge/PR flow needs it to open PRs. Warn on its absence directly (not on the
81
+ // resolved host), so it fires even when an origin remote can substitute: the field itself
82
+ // is required regardless.
83
+ if (!hostFromGitUrl(hub.git_url)) {
84
+ check(checks, 'hub-git-url', 'project', 'warn',
85
+ `${PROJECT_FILES.hubConfig} sets platform '${hub.platform}' but has no git_url [YAD-CFG-005]`,
86
+ 'add git_url to hub.json (or re-run `yad setup`) — auth/PR checks need the hub host');
87
+ }
88
+ // Scope the auth probe to the hub's own host (derived from git_url, falling back to the
89
+ // origin remote). `${cli} auth status` without --hostname exits non-zero when ANY configured
90
+ // instance fails, so an unrelated stale login (e.g. a dead gitlab.com token) would falsely
91
+ // flag a working self-hosted hub — so we SKIP the probe entirely when no host resolves
92
+ // rather than run the flaky unscoped form.
93
+ const host = hostFromGitUrl(hub.git_url)
94
+ || hostFromGitUrl(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
84
95
  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`);
85
- else if (!run(cli, authArgs).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated${host ? ` for ${host}` : ''} [YAD-ENV-002]`, `run \`${cli} auth login${host ? ` --hostname ${host}` : ''}\``);
96
+ else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped hub host unknown (no git_url / origin)', 'add git_url to hub.json so the auth probe can target the right host');
97
+ else if (!run(cli, ['auth', 'status', '--hostname', host]).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated for ${host} [YAD-ENV-002]`, `run \`${cli} auth login --hostname ${host}\``);
86
98
  else {
87
99
  check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
88
100
  // Re-validate each roster login against the hub (warn-only). Skips when a login is already
@@ -99,10 +111,10 @@ export function projectChecks(checks, root) {
99
111
  // probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
100
112
  if (hub.platform === 'gitlab') {
101
113
  // Scope the probe to the hub's own host (like the auth check above) so a multi-instance
102
- // setup doesn't hit the wrong GitLab and emit a misleading warning.
103
- const apiArgs = host ? ['api', 'version', '--hostname', host] : ['api', 'version'];
104
- if (!run('glab', apiArgs).ok) {
105
- check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed${host ? ` for ${host}` : ''} [YAD-ENV-002]`, 'ensure the token has `api` scope — the gate reads MR approvals/discussions via the API');
114
+ // setup doesn't hit the wrong GitLab. `host` is guaranteed truthy here (we skip the whole
115
+ // auth branch when it cannot be resolved), so the probe is always host-scoped.
116
+ if (!run('glab', ['api', 'version', '--hostname', host]).ok) {
117
+ check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed for ${host} [YAD-ENV-002]`, 'ensure the token has `api` scope — the gate reads MR approvals/discussions via the API');
106
118
  }
107
119
  }
108
120
  // Solo + GitHub: a branch that "requires approvals" would block the solo dev's own merge
package/cli/errors.mjs CHANGED
@@ -23,6 +23,7 @@ export const CODES = {
23
23
  'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
24
24
  'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
25
25
  'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
26
+ 'YAD-CFG-005': 'hub.json sets a platform but is missing git_url (required to scope auth + open PRs)',
26
27
  };
27
28
 
28
29
  export const err = (code, message, hint) => new YadError(code, message, hint);
package/cli/setup.mjs CHANGED
@@ -482,16 +482,24 @@ export async function runSetup(root, opts = {}) {
482
482
  // `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
483
483
  // for anything that still reads it.
484
484
  const enabled = platform !== 'none';
485
- writeJSON(hubPath, { platform: enabled ? platform : null, bridge_enabled: enabled, bridge: enabled, default_branch, roster, solo, profile: { codebase, repo_layout, team_size } });
485
+ // Record git_url doctor needs it to scope the auth probe (YAD-CFG-005) and the bridge/PR flow
486
+ // needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
487
+ const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
488
+ writeJSON(hubPath, { platform: enabled ? platform : null, git_url, bridge_enabled: enabled, bridge: enabled, default_branch, roster, solo, profile: { codebase, repo_layout, team_size } });
486
489
  ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
487
490
  }
488
491
  // Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
489
492
  // flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
493
+ // Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
490
494
  if (exists(hubPath)) {
491
495
  const cur = readJSON(hubPath, {}) || {};
492
- if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size })) {
493
- writeJSON(hubPath, { ...cur, solo, profile: { codebase, repo_layout, team_size } });
494
- info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
496
+ const backfillUrl = (cur.platform && !cur.git_url)
497
+ ? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
498
+ : null;
499
+ if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
500
+ writeJSON(hubPath, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), solo, profile: { codebase, repo_layout, team_size } });
501
+ if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
502
+ else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
495
503
  }
496
504
  }
497
505
 
package/cli/usage.mjs ADDED
@@ -0,0 +1,389 @@
1
+ // `yad usage` — the team-member adoption & behavior report.
2
+ //
3
+ // DERIVED, READ-ONLY, NO NEW SOURCE OF TRUTH. This command reconstructs each roster member's audit
4
+ // trail entirely from data ALREADY in git — the approval/comment/ship ledgers + git authorship — and
5
+ // renders it as a portable report (HTML by default) to a location the caller chooses. It writes no
6
+ // tracked state, hooks no commands, and stores nothing that cannot be rebuilt from the repos. This
7
+ // mirrors how `yad-status` and `thread-resolved.md` derive their views (see docs/phase-5-build-plan.md:
8
+ // instrumentation is derived and read-only, never a new ledger).
9
+ //
10
+ // PRIVACY: the report is FACTUAL — names/logins, action kinds, epic IDs, integer counts, dates, and
11
+ // explainable hygiene flags. It NEVER emits emails, commit messages, or free-text comment bodies. It is
12
+ // a workflow-hygiene / adoption view for an EM, not a judgmental scorecard (see memory:
13
+ // no-private-data-in-reports).
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { c, log, ok, note, readJSON, run } from './lib.mjs';
17
+ import { PROJECT_FILES, epicFiles } from './manifest.mjs';
18
+ import { rolesForScope } from './platform.mjs';
19
+
20
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
21
+
22
+ // Ledger read for the report. A MISSING file is normal (the epic hasn't reached that gate) and yields
23
+ // `def` silently; a file that EXISTS but fails to parse is surfaced to stderr and skipped — NOT thrown,
24
+ // since one corrupt ledger must not abort the whole derived view, but a silent under-count must never
25
+ // masquerade as "no activity" (an active reviewer wrongly shown dormant). Mirrors the `readJSONStrict`
26
+ // hazard note in lib.mjs, softened to warn-and-continue for this read-only aggregation.
27
+ function readLedger(p, def) {
28
+ if (!fs.existsSync(p)) return def;
29
+ try {
30
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
31
+ } catch (e) {
32
+ note(c.yellow(`skipped unreadable ledger ${path.basename(path.dirname(path.dirname(p)))}/${path.basename(p)}: ${e.message}`));
33
+ return def;
34
+ }
35
+ }
36
+
37
+ // The five kinds of workflow action we can attribute to an individual from git-tracked data.
38
+ export const ACTIONS = ['authored', 'commented', 'approved', 'shipped', 'committed'];
39
+
40
+ // Artifacts whose authoring commit counts as an "authored" event (a top-level epic artifact, or any
41
+ // story file). Ledger files under .sdlc/ are gate machinery, not authoring, and are excluded.
42
+ const ARTIFACT_FILES = new Set([
43
+ 'epic.md', 'analysis.md', 'architecture.md', 'contract.md', 'ui-design.md', 'DESIGN.md', 'test-cases.md',
44
+ ]);
45
+
46
+ // ---- roster / attribution ----------------------------------------------------------------------
47
+
48
+ function loadRoster(root) {
49
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
50
+ return hub && Array.isArray(hub.roster) ? hub.roster : [];
51
+ }
52
+
53
+ // A member's hub-scope role label (supports both the per-scope `roles` map and the legacy flat `role`).
54
+ function rosterRole(m) {
55
+ const scoped = rolesForScope(m, 'hub');
56
+ if (scoped.length) return scoped.join(', ');
57
+ return m.role || '';
58
+ }
59
+
60
+ // Resolve a display name / login / git-author identity to a canonical roster member (or null when the
61
+ // person is not in the roster — we still attribute the event to the raw name so nothing is dropped).
62
+ function makeResolver(roster) {
63
+ const byEmail = new Map();
64
+ const byName = new Map();
65
+ const byLogin = new Map();
66
+ for (const m of roster) {
67
+ if (m.email) byEmail.set(m.email.toLowerCase(), m);
68
+ if (m.name) byName.set(m.name, m);
69
+ if (m.login) byLogin.set(m.login, m);
70
+ }
71
+ return {
72
+ // Ledgers store the roster `name` (approver/commenter) — map back to the full entry when possible.
73
+ byNameOrLogin: (s) => (s == null ? null : byName.get(s) || byLogin.get(s) || null),
74
+ // git authorship maps by commit email first (the reliable key), then by author name == roster name.
75
+ byGitAuthor: (name, email) =>
76
+ byEmail.get((email || '').toLowerCase()) || byName.get(name) || byLogin.get(name) || null,
77
+ };
78
+ }
79
+
80
+ // ---- epic enumeration --------------------------------------------------------------------------
81
+
82
+ function listEpics(root) {
83
+ const dir = path.join(root, 'epics');
84
+ if (!fs.existsSync(dir)) return [];
85
+ return fs
86
+ .readdirSync(dir, { withFileTypes: true })
87
+ .filter((e) => e.isDirectory() && /^EP-[a-z0-9-]+$/i.test(e.name))
88
+ .map((e) => e.name)
89
+ .sort();
90
+ }
91
+
92
+ const inWindow = (date, since, until) => !!date && (!since || date >= since) && (!until || date <= until);
93
+
94
+ // ---- event derivation --------------------------------------------------------------------------
95
+
96
+ // Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews. Each carries the
97
+ // roster `name` already, so attribution is direct.
98
+ function ledgerEvents(root, epic, resolver) {
99
+ const f = epicFiles(path.join(root, 'epics', epic));
100
+ const events = [];
101
+ const emit = (rawName, action, date, extra = {}) => {
102
+ if (!rawName || !date) return;
103
+ const m = resolver.byNameOrLogin(rawName);
104
+ events.push({ ts: date, actor: m ? m.name || m.login : rawName, login: m ? m.login || null : null, rostered: !!m, action, epic, ...extra });
105
+ };
106
+ for (const a of readLedger(f.approvals, []) || []) emit(a.approver, 'approved', a.date, { artifact: a.artifact, role: a.role });
107
+ for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact, role: cm.role });
108
+ const bl = readLedger(f.buildLog, null);
109
+ for (const s of bl?.ships || []) {
110
+ for (const er of s.engineer_review || []) emit(er.approver, 'shipped', s.shippedAt, { story: s.story, task: s.task, repo: s.repo, risk: s.risk });
111
+ }
112
+ return events;
113
+ }
114
+
115
+ // True for `epics/<EP>/<artifact>.md` or `epics/<EP>/stories/<file>.md`.
116
+ function isArtifactPath(rel) {
117
+ const parts = rel.split('/');
118
+ if (parts[0] !== 'epics' || parts.length < 3) return false;
119
+ if (parts[2] === 'stories') return parts.length >= 4 && parts[3].endsWith('.md');
120
+ return parts.length === 3 && ARTIFACT_FILES.has(parts[2]);
121
+ }
122
+
123
+ // Parse `git log --name-only` output into a flat list of {an, ae, ad, files[]} commits. Uses \x01 as
124
+ // the record marker and \x00 as the field separator so author names containing spaces never confuse it.
125
+ function parseGitLog(stdout) {
126
+ const commits = [];
127
+ let cur = null;
128
+ for (const line of stdout.split('\n')) {
129
+ if (line.startsWith('\x01')) {
130
+ const [an, ae, ad] = line.slice(1).split('\x00');
131
+ cur = { an, ae, ad, files: [] };
132
+ commits.push(cur);
133
+ } else if (line.trim() && cur) {
134
+ cur.files.push(line.trim());
135
+ }
136
+ }
137
+ return commits;
138
+ }
139
+
140
+ const GIT_PRETTY = '--pretty=format:\x01%an%x00%ae%x00%ad';
141
+
142
+ // git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the hub
143
+ // is not a git repo (e.g. a test fixture dir), so the command never depends on git being present.
144
+ function gitAuthoredEvents(root, resolver) {
145
+ const r = run('git', ['-C', root, 'log', '--no-merges', '--date=short', GIT_PRETTY, '--name-only', '--', 'epics']);
146
+ if (!r.ok || !r.stdout) return [];
147
+ const events = [];
148
+ for (const cm of parseGitLog(r.stdout)) {
149
+ const m = resolver.byGitAuthor(cm.an, cm.ae);
150
+ for (const rel of cm.files) {
151
+ if (!isArtifactPath(rel)) continue;
152
+ events.push({
153
+ ts: cm.ad, actor: m ? m.name || m.login : cm.an, login: m ? m.login || null : null, rostered: !!m,
154
+ action: 'authored', epic: rel.split('/')[1], artifact: rel.split('/').pop().replace(/\.md$/, ''),
155
+ });
156
+ }
157
+ }
158
+ return events;
159
+ }
160
+
161
+ // Optional (`--repos`): code commits in each connected code repo, attributed by author → roster.
162
+ function repoCommitEvents(root, resolver) {
163
+ const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
164
+ const events = [];
165
+ for (const repo of reg?.repos || []) {
166
+ if (!repo.path) continue;
167
+ const abs = path.isAbsolute(repo.path) ? repo.path : path.join(root, repo.path);
168
+ const r = run('git', ['-C', abs, 'log', '--no-merges', '--date=short', GIT_PRETTY]);
169
+ if (!r.ok || !r.stdout) continue;
170
+ for (const cm of parseGitLog(r.stdout)) {
171
+ const m = resolver.byGitAuthor(cm.an, cm.ae);
172
+ events.push({ ts: cm.ad, actor: m ? m.name || m.login : cm.an, login: m ? m.login || null : null, rostered: !!m, action: 'committed', repo: repo.name });
173
+ }
174
+ }
175
+ return events;
176
+ }
177
+
178
+ // The full, window-filtered event stream. Deterministic: sorted by (date, action, epic/repo, actor).
179
+ export function deriveEvents(root, resolver, { since, until, repos = false } = {}) {
180
+ const events = [...gitAuthoredEvents(root, resolver)];
181
+ for (const epic of listEpics(root)) events.push(...ledgerEvents(root, epic, resolver));
182
+ if (repos) events.push(...repoCommitEvents(root, resolver));
183
+ return events
184
+ .filter((e) => inWindow(e.ts, since, until))
185
+ .sort((a, b) =>
186
+ a.ts.localeCompare(b.ts) || a.action.localeCompare(b.action) ||
187
+ String(a.epic || a.repo || '').localeCompare(String(b.epic || b.repo || '')) || a.actor.localeCompare(b.actor));
188
+ }
189
+
190
+ // ---- analysis ----------------------------------------------------------------------------------
191
+
192
+ const zeroCounts = () => Object.fromEntries(ACTIONS.map((a) => [a, 0]));
193
+
194
+ // Per-member rollup + explainable hygiene flags + team totals. `window` echoes the requested range.
195
+ export function analyze(events, roster, window = { since: null, until: null }) {
196
+ const members = new Map();
197
+ const seed = (key, name, login, role, rostered, isReviewer = false) => {
198
+ if (!members.has(key)) {
199
+ members.set(key, { key, name, login: login || null, role: role || '', rostered, isReviewer, counts: zeroCounts(), total: 0, firstActive: null, lastActive: null, epics: new Set(), timeline: [] });
200
+ }
201
+ return members.get(key);
202
+ };
203
+ // Seed every roster member first so dormant members appear at zero, not missing.
204
+ for (const m of roster) seed(m.login || m.name, m.name || m.login, m.login || null, rosterRole(m), true, isReviewerAnywhere(m));
205
+ for (const e of events) {
206
+ const m = seed(e.login || e.actor, e.actor, e.login, '', e.rostered);
207
+ m.counts[e.action] = (m.counts[e.action] || 0) + 1;
208
+ m.total += 1;
209
+ if (e.epic) m.epics.add(e.epic);
210
+ if (!m.firstActive || e.ts < m.firstActive) m.firstActive = e.ts;
211
+ if (!m.lastActive || e.ts > m.lastActive) m.lastActive = e.ts;
212
+ m.timeline.push({ ts: e.ts, action: e.action, epic: e.epic || null, repo: e.repo || null, artifact: e.artifact || null });
213
+ }
214
+ const list = [...members.values()].map((m) => ({
215
+ name: m.name, login: m.login, role: m.role, rostered: m.rostered,
216
+ counts: m.counts, total: m.total, firstActive: m.firstActive, lastActive: m.lastActive,
217
+ epics: [...m.epics].sort(), flags: memberFlags(m), timeline: m.timeline,
218
+ }));
219
+ list.sort((a, b) => a.name.localeCompare(b.name));
220
+ const totals = zeroCounts();
221
+ for (const m of list) for (const a of ACTIONS) totals[a] += m.counts[a];
222
+ return { window, generatedFrom: 'derived', members: list, totals };
223
+ }
224
+
225
+ // Is this roster entry a reviewer in ANY scope? Reviewer roles are usually repo-scoped
226
+ // (`roles: { backend: ['reviewer'] }`), not hub-scoped, so a hub-only check would miss most of them.
227
+ export function isReviewerAnywhere(entry) {
228
+ if (!entry) return false;
229
+ if ((entry.role || '') === 'reviewer') return true; // legacy flat role
230
+ if (Array.isArray(entry.roles)) return entry.roles.includes('reviewer'); // legacy hub-scope array (rolesForScope shape)
231
+ return Object.values(entry.roles || {}).some((list) => Array.isArray(list) && list.includes('reviewer'));
232
+ }
233
+
234
+ // Factual, per-member flags — each is a plain derivation, never a score.
235
+ function memberFlags(m) {
236
+ const flags = [];
237
+ if (m.rostered && m.total === 0) flags.push('dormant'); // in the roster, no activity in range
238
+ const reviews = m.counts.commented + m.counts.approved;
239
+ if (m.total > 0 && m.counts.authored > 0 && reviews === 0) flags.push('no-review-participation'); // authors but never reviews
240
+ if (m.total > 0 && m.isReviewer && reviews === 0) flags.push('reviewer-not-reviewing'); // a reviewer (any scope) who never reviews
241
+ return flags;
242
+ }
243
+
244
+ // Team-level hygiene, keyed by epic/story — a ship with no recorded engineer review is a process gap,
245
+ // not attributable to one person, so it lives here rather than in a member's flag list.
246
+ export function shipHygiene(root, { since, until } = {}) {
247
+ const items = [];
248
+ for (const epic of listEpics(root)) {
249
+ const bl = readLedger(epicFiles(path.join(root, 'epics', epic)).buildLog, null);
250
+ for (const s of bl?.ships || []) {
251
+ if (!inWindow(s.shippedAt, since, until)) continue;
252
+ if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
253
+ items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
254
+ }
255
+ }
256
+ }
257
+ return items.sort((a, b) => String(a.epic).localeCompare(String(b.epic)) || String(a.story).localeCompare(String(b.story)));
258
+ }
259
+
260
+ // ---- rendering ---------------------------------------------------------------------------------
261
+
262
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
263
+ const rangeLabel = (w) => (!w.since && !w.until ? 'all time' : `${w.since || '…'} → ${w.until || '…'}`);
264
+
265
+ // A single self-contained HTML file — inline CSS + inline SVG bars, no build step, no external assets —
266
+ // so the caller can drop it anywhere and open it in a browser.
267
+ export function renderHtml(model, today = '') {
268
+ const flagChip = (f) => `<span class="flag flag-${esc(f)}">${esc(f)}</span>`;
269
+ const bar = (m) => {
270
+ const max = Math.max(1, ...model.members.map((x) => x.total));
271
+ const w = Math.round((m.total / max) * 100);
272
+ return `<div class="bar"><span style="width:${w}%"></span></div>`;
273
+ };
274
+ const memberCard = (m) => `
275
+ <section class="member${m.total === 0 ? ' idle' : ''}">
276
+ <header>
277
+ <h3>${esc(m.name)} ${m.login ? `<span class="login">@${esc(m.login)}</span>` : ''} ${m.role ? `<span class="role">${esc(m.role)}</span>` : ''}${m.rostered ? '' : ' <span class="role">off-roster</span>'}</h3>
278
+ <div class="flags">${m.flags.map(flagChip).join(' ')}</div>
279
+ </header>
280
+ ${bar(m)}
281
+ <table class="counts"><tr>${ACTIONS.map((a) => `<th>${a}</th>`).join('')}<th>total</th></tr>
282
+ <tr>${ACTIONS.map((a) => `<td>${m.counts[a]}</td>`).join('')}<td><b>${m.total}</b></td></tr></table>
283
+ <p class="meta">${m.total ? `active ${esc(m.firstActive)} → ${esc(m.lastActive)} · epics: ${m.epics.length ? esc(m.epics.join(', ')) : '—'}` : 'no activity in range'}</p>
284
+ ${m.timeline.length ? `<details><summary>timeline (${m.timeline.length})</summary><ul class="timeline">${m.timeline
285
+ .map((t) => `<li><time>${esc(t.ts)}</time> <b>${esc(t.action)}</b> ${esc(t.epic || t.repo || '')}${t.artifact ? ` · ${esc(t.artifact)}` : ''}</li>`)
286
+ .join('')}</ul></details>` : ''}
287
+ </section>`;
288
+ const hygiene = model.hygiene?.length
289
+ ? `<section class="hygiene"><h2>Workflow hygiene</h2><p>Ships with no recorded engineer review (process gaps, not attributed to one person):</p><ul>${model.hygiene
290
+ .map((h) => `<li><b>${esc(h.epic)}</b> ${esc(h.story || '')}${h.task ? `/${esc(h.task)}` : ''} ${h.repo ? `(${esc(h.repo)})` : ''} — shipped ${esc(h.shippedAt || '?')}</li>`)
291
+ .join('')}</ul></section>`
292
+ : '<section class="hygiene"><h2>Workflow hygiene</h2><p>No ship-without-review gaps in range. ✓</p></section>';
293
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
294
+ <title>yadflow — team usage report</title><style>
295
+ :root{--bg:#0f1115;--fg:#e6e8ee;--dim:#9aa0ad;--card:#181b22;--line:#262a33;--accent:#5b9dff}
296
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
297
+ .wrap{max-width:920px;margin:0 auto;padding:32px 20px}
298
+ h1{font-size:22px;margin:0 0 4px}.sub{color:var(--dim);margin:0 0 24px}
299
+ .totals{display:flex;gap:16px;flex-wrap:wrap;margin:0 0 24px;padding:14px;background:var(--card);border:1px solid var(--line);border-radius:10px}
300
+ .totals div{font-size:13px;color:var(--dim)}.totals b{display:block;font-size:20px;color:var(--fg)}
301
+ .member{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 14px}
302
+ .member.idle{opacity:.6}.member header{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
303
+ h3{margin:0;font-size:15px}.login{color:var(--dim);font-weight:400;font-size:13px}
304
+ .role{color:var(--accent);font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-left:4px}
305
+ .bar{height:6px;background:var(--line);border-radius:4px;margin:10px 0;overflow:hidden}.bar span{display:block;height:100%;background:var(--accent)}
306
+ table.counts{border-collapse:collapse;font-size:12px;margin:6px 0}table.counts th{color:var(--dim);text-align:left;font-weight:500;padding:2px 14px 2px 0}table.counts td{padding:2px 14px 2px 0}
307
+ .meta{color:var(--dim);font-size:12px;margin:6px 0 0}
308
+ .flags{display:flex;gap:6px;flex-wrap:wrap}.flag{font-size:11px;border-radius:6px;padding:1px 8px;background:#3a2a12;color:#ffcf7a;border:1px solid #5a3f14}
309
+ .flag-dormant{background:#2a2f3a;color:#9aa7bd;border-color:#39414f}
310
+ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:12px}
311
+ ul.timeline{list-style:none;padding:8px 0 0;margin:0;font-size:12px}ul.timeline li{padding:2px 0;border-top:1px solid var(--line)}
312
+ time{color:var(--dim);font-variant-numeric:tabular-nums;margin-right:6px}
313
+ .hygiene{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:24px 0 0}.hygiene h2{font-size:15px;margin:0 0 8px}.hygiene ul{margin:6px 0 0;padding-left:18px}
314
+ footer{color:var(--dim);font-size:12px;margin-top:28px;border-top:1px solid var(--line);padding-top:12px}
315
+ </style></head><body><div class="wrap">
316
+ <h1>Team usage &amp; behavior report</h1>
317
+ <p class="sub">Range: ${esc(rangeLabel(model.window))} · ${model.members.length} member(s)${today ? ` · generated ${esc(today)}` : ''}</p>
318
+ <div class="totals">${ACTIONS.map((a) => `<div>${a}<b>${model.totals[a]}</b></div>`).join('')}</div>
319
+ ${model.members.map(memberCard).join('')}
320
+ ${hygiene}
321
+ <footer>Derived, read-only view — reconstructed from git history and the SDLC ledgers. Regenerate any time with <code>yad usage</code>. No emails, commit messages, or comment bodies are included.</footer>
322
+ </div></body></html>\n`;
323
+ }
324
+
325
+ // A compact Markdown variant for quick reads / pasting into a PR. Dynamic values are sanitized so a
326
+ // name/role/repo containing `|` or a newline can't corrupt the table or list structure.
327
+ export function renderMarkdown(model, today = '') {
328
+ const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
329
+ const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
330
+ const L = [`# Team usage & behavior report`, ``, `- Range: **${rangeLabel(model.window)}**${today ? ` · generated ${today}` : ''}`, `- Members: ${model.members.length}`, `- Totals: ${ACTIONS.map((a) => `${a} ${model.totals[a]}`).join(' · ')}`, ``, `| member | role | ${ACTIONS.join(' | ')} | total | flags |`, `|---|---|${ACTIONS.map(() => '--:').join('|')}|--:|---|`];
331
+ for (const m of model.members) {
332
+ L.push(`| ${mdCell(`${m.name}${m.login ? ` (@${m.login})` : ''}`)} | ${mdCell(m.role || '—')} | ${ACTIONS.map((a) => m.counts[a]).join(' | ')} | ${m.total} | ${mdCell(m.flags.join(', ') || '—')} |`);
333
+ }
334
+ L.push('', '## Workflow hygiene');
335
+ if (model.hygiene?.length) {
336
+ L.push('Ships with no recorded engineer review:');
337
+ for (const h of model.hygiene) L.push(`- **${mdText(h.epic)}** ${mdText(h.story || '')}${h.task ? `/${mdText(h.task)}` : ''} ${h.repo ? `(${mdText(h.repo)})` : ''} — shipped ${mdText(h.shippedAt || '?')}`);
338
+ } else {
339
+ L.push('No ship-without-review gaps in range. ✓');
340
+ }
341
+ L.push('', '_Derived, read-only — reconstructed from git + the SDLC ledgers; no emails/comment bodies._', '');
342
+ return L.join('\n');
343
+ }
344
+
345
+ // ---- CLI entry ---------------------------------------------------------------------------------
346
+
347
+ export function buildModel(root, { since, until, repos = false, member } = {}) {
348
+ const roster = loadRoster(root);
349
+ const resolver = makeResolver(roster);
350
+ const events = deriveEvents(root, resolver, { since, until, repos });
351
+ const model = analyze(events, roster, { since: since || null, until: until || null });
352
+ model.hygiene = shipHygiene(root, { since, until });
353
+ if (member) {
354
+ model.members = model.members.filter((m) => m.name === member || m.login === member);
355
+ const totals = zeroCounts(); // totals track the shown members, not the whole team
356
+ for (const m of model.members) for (const a of ACTIONS) totals[a] += m.counts[a];
357
+ model.totals = totals;
358
+ }
359
+ return model;
360
+ }
361
+
362
+ // Ensure a report's parent directory exists, then write it (mirrors copyFile/writeJSON in lib.mjs,
363
+ // which always mkdir the dirname first) so `--out sub/dir/report.html` never throws a raw ENOENT.
364
+ function writeReport(dest, content) {
365
+ fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
366
+ fs.writeFileSync(dest, content);
367
+ }
368
+
369
+ export function runUsage(root, { out, since, until, all, member, format = 'html', repos = false, json = false, today = '' } = {}) {
370
+ if (all) { since = undefined; until = undefined; }
371
+ // Dates compare lexically as strings, so an unpadded value (2026-6-1) silently mis-windows — warn.
372
+ for (const [flag, val] of [['--since', since], ['--until', until]]) {
373
+ if (val && !DATE_RE.test(val)) note(c.yellow(`${flag} ${val} is not YYYY-MM-DD — dates compare lexically, so a non-padded value may filter incorrectly`));
374
+ }
375
+ let fmt = json ? 'json' : format;
376
+ if (!['html', 'json', 'md'].includes(fmt)) { note(c.yellow(`unknown --format ${fmt} (html|json|md) — using html`)); fmt = 'html'; }
377
+ const model = buildModel(root, { since, until, repos, member });
378
+
379
+ if (fmt === 'json') {
380
+ const s = JSON.stringify(model, null, 2);
381
+ if (out) { writeReport(out, s + '\n'); note(`wrote JSON → ${out}`); } else { log(s); } // stdout stays pure JSON
382
+ return model;
383
+ }
384
+ const content = fmt === 'md' ? renderMarkdown(model, today) : renderHtml(model, today);
385
+ const dest = out || `usage-report.${fmt === 'md' ? 'md' : 'html'}`;
386
+ writeReport(dest, content);
387
+ ok(`wrote ${fmt.toUpperCase()} report → ${c.bold(dest)} (${model.members.length} member(s), range: ${rangeLabel(model.window)})`);
388
+ return model;
389
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.4.1",
3
+ "version": "3.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, ship, repo, thread, reconcile). A BMAD module + 37 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -16,7 +16,7 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
16
16
  ```json
17
17
  {
18
18
  "platform": "github", // github | gitlab (from the hub's own remote host); null when local-only
19
- "git_url": "https://github.com/abdelrahmannasr/yadflow.git",
19
+ "git_url": "https://github.com/abdelrahmannasr/yadflow.git", // REQUIRED when platform is non-null (scopes auth + opens PRs); yad doctor warns YAD-CFG-005 if absent
20
20
  "default_branch": "main",
21
21
  "bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
22
22
  "review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
@@ -69,6 +69,11 @@ run `git remote get-url origin` **on the hub itself** and read the host —
69
69
  Auth is the **local user's own** `gh`/`glab`/git credentials; **no tokens are ever stored** (same rule
70
70
  as the registry). `detect-hub` upserts `hub.json` in place — it is idempotent and safe to re-run.
71
71
 
72
+ **`git_url` is required whenever `platform` is non-null.** `yad doctor` uses it to scope the auth
73
+ probe to the hub's own host (an unscoped `glab auth status` fails on any unrelated broken instance),
74
+ and the bridge/PR flow uses it to open PRs. Doctor flags its absence with a warn (`YAD-CFG-005`);
75
+ re-running `yad setup` backfills it from the origin remote (idempotent, non-interactive).
76
+
72
77
  ## Bridge enable / degradation
73
78
 
74
79
  - `bridge_enabled: true` **and** a non-null `platform` **and** `gh`/`glab` authenticated → the front-half