yadflow 3.4.2 → 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,10 +1,14 @@
1
- ## [3.4.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.4.1...v3.4.2) (2026-07-01)
1
+ # [3.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.4.2...v3.5.0) (2026-07-02)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * **doctor:** warn YAD-CFG-005 on hub.json missing git_url; stop misleading YAD-ENV-002 ([3f588af](https://github.com/abdelrahmannasr/yadflow/commit/3f588af2e01e117e661b08b5f5b8e069e41e887f))
7
- * **setup:** write and backfill hub.json git_url from the origin remote ([c809358](https://github.com/abdelrahmannasr/yadflow/commit/c8093581319dcb9700b3dd0ebe930dbf784348ff))
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))
8
12
 
9
13
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
10
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/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.2",
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",