yadflow 3.18.1 → 4.0.0-next.1

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. package/skills/sdlc/install.sh +0 -68
package/cli/usage.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // `yad usage` — the team-member adoption & behavior report.
2
2
  //
3
- // DERIVED, READ-ONLY, NO NEW SOURCE OF TRUTH. This command reconstructs each roster member's audit
3
+ // DERIVED, READ-ONLY, NO NEW SOURCE OF TRUTH. This command reconstructs each contributor's audit
4
4
  // trail entirely from data ALREADY in git — the approval/comment/ship ledgers + git authorship — and
5
5
  // renders it as a portable report (HTML by default) to a location the caller chooses. It writes no
6
6
  // tracked state, hooks no commands, and stores nothing that cannot be rebuilt from the repos. This
@@ -11,26 +11,43 @@
11
11
  // explainable hygiene flags. It NEVER emits emails, commit messages, or free-text comment bodies. It is
12
12
  // a workflow-hygiene / adoption view for an EM, not a judgmental scorecard (see memory:
13
13
  // no-private-data-in-reports).
14
+ //
15
+ // PEOPLE COME FROM ACTIVITY, NOT FROM A LIST (E62). Before the roster was removed this report seeded every
16
+ // member from it — so someone with no activity showed as `dormant` — and used it to join a person's
17
+ // ledger name, git name and login into one row. With no stored list, a person is whoever did something:
18
+ // the name a ledger records (the platform login since E62; the roster's name in older records) or the
19
+ // git author. One join survives because it is evidence rather than a claim: a GitHub or GitLab noreply
20
+ // commit address carries the login, so those commits land on the same row as that login's approvals.
21
+ // A person whose git name and login differ may appear on more than one row, and the report says so
22
+ // rather than guessing.
23
+ //
24
+ // An older record (one still carrying the `role` the roster gave it) names the person by their ROSTER
25
+ // name, which can be ANOTHER person's login — `al` was bob's roster name and alice's login. Read raw,
26
+ // the two landed on one row once alice's records were re-synced under her login. So an older record is
27
+ // read through the roster's name → login table (`legacyLogins`), the same one `yad gate sync` uses, and
28
+ // only when the roster still holds that name for exactly one login; an `unverified` record already names
29
+ // a login.
14
30
  import fs from 'node:fs';
15
31
  import path from 'node:path';
16
- import { c, log, ok, note, readJSON, run } from './lib.mjs';
17
- import { PROJECT_FILES, epicFiles } from './manifest.mjs';
32
+ import { c, log, ok, note, readJSON, run, emitJSON, collectWarning } from './lib.mjs';
33
+ import { PROJECT_FILES, epicFiles, productConfigPath } from './manifest.mjs';
18
34
  import { readShips } from './ledger.mjs';
19
- import { rolesForScope } from './platform.mjs';
35
+ import { epicRoot, ledgerPersonLogin, FOUNDATION_DIR, FOUNDATION_EPIC, FOUNDATION_FILES } from './epic-state.mjs';
36
+ import { legacyLogins } from './gate.mjs';
20
37
 
21
38
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
22
39
 
23
40
  // Ledger read for the report. A MISSING file is normal (the epic hasn't reached that gate) and yields
24
41
  // `def` silently; a file that EXISTS but fails to parse is surfaced to stderr and skipped — NOT thrown,
25
42
  // since one corrupt ledger must not abort the whole derived view, but a silent under-count must never
26
- // masquerade as "no activity" (an active reviewer wrongly shown dormant). Mirrors the `readJSONStrict`
43
+ // masquerade as "no activity" (an active reviewer wrongly left off the report). Mirrors the `readJSONStrict`
27
44
  // hazard note in lib.mjs, softened to warn-and-continue for this read-only aggregation.
28
45
  function readLedger(p, def) {
29
46
  if (!fs.existsSync(p)) return def;
30
47
  try {
31
48
  return JSON.parse(fs.readFileSync(p, 'utf8'));
32
49
  } catch (e) {
33
- note(c.yellow(`skipped unreadable ledger ${path.basename(path.dirname(path.dirname(p)))}/${path.basename(p)}: ${e.message}`));
50
+ { const w = `skipped unreadable ledger ${path.basename(path.dirname(path.dirname(p)))}/${path.basename(p)}: ${e.message}`; collectWarning(w); note(c.yellow(w)); }
34
51
  return def;
35
52
  }
36
53
  }
@@ -44,77 +61,62 @@ const ARTIFACT_FILES = new Set([
44
61
  'epic.md', 'analysis.md', 'architecture.md', 'contract.md', 'ui-design.md', 'DESIGN.md', 'test-cases.md',
45
62
  ]);
46
63
 
47
- // ---- roster / attribution ----------------------------------------------------------------------
64
+ // ---- attribution -------------------------------------------------------------------------------
48
65
 
49
- function loadRoster(root) {
50
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
51
- return hub && Array.isArray(hub.roster) ? hub.roster : [];
52
- }
53
-
54
- // A member's hub-scope role label (supports both the per-scope `roles` map and the legacy flat `role`).
55
- function rosterRole(m) {
56
- const scoped = rolesForScope(m, 'hub');
57
- if (scoped.length) return scoped.join(', ');
58
- return m.role || '';
59
- }
60
-
61
- // Resolve a display name / login / git-author identity to a canonical roster member (or null when the
62
- // person is not in the roster — we still attribute the event to the raw name so nothing is dropped).
63
- function makeResolver(roster) {
64
- const byEmail = new Map();
65
- const byName = new Map();
66
- const byLogin = new Map();
67
- for (const m of roster) {
68
- if (m.email) byEmail.set(m.email.toLowerCase(), m);
69
- if (m.name) byName.set(m.name, m);
70
- if (m.login) byLogin.set(m.login, m);
71
- }
72
- return {
73
- // Ledgers store the roster `name` (approver/commenter) — map back to the full entry when possible.
74
- byNameOrLogin: (s) => (s == null ? null : byName.get(s) || byLogin.get(s) || null),
75
- // git authorship maps by commit email first (the reliable key), then by author name == roster name.
76
- byGitAuthor: (name, email) =>
77
- byEmail.get((email || '').toLowerCase()) || byName.get(name) || byLogin.get(name) || null,
78
- };
79
- }
66
+ // The login a noreply commit address carries. It LIVES in cli/riskmap.mjs now, because E67's bash twin
67
+ // has to derive the same login and the rule is compared between the two; this export stays (rule 3 —
68
+ // add the new name beside the old) and is the one every older reader here still calls.
69
+ import { loginFromEmail } from './riskmap.mjs';
70
+ export { loginFromEmail };
80
71
 
81
72
  // ---- epic enumeration --------------------------------------------------------------------------
82
73
 
74
+ // The Foundation (E75) is appended when its ledger folder exists: approving or commenting on it is
75
+ // contribution like any other gate, and a report that left it out would under-count exactly the
76
+ // people who framed the product. The case-insensitive match on `epics/` is this report's own, older
77
+ // rule and is kept as it was.
83
78
  function listEpics(root) {
84
79
  const dir = path.join(root, 'epics');
85
- if (!fs.existsSync(dir)) return [];
86
- return fs
87
- .readdirSync(dir, { withFileTypes: true })
88
- .filter((e) => e.isDirectory() && /^EP-[a-z0-9-]+$/i.test(e.name))
89
- .map((e) => e.name)
90
- .sort();
80
+ const ids = fs.existsSync(dir)
81
+ ? fs.readdirSync(dir, { withFileTypes: true })
82
+ .filter((e) => e.isDirectory() && /^EP-[a-z0-9-]+$/i.test(e.name) && e.name !== FOUNDATION_EPIC)
83
+ .map((e) => e.name)
84
+ : [];
85
+ if (fs.existsSync(path.join(root, FOUNDATION_DIR, '.sdlc'))) ids.push(FOUNDATION_EPIC);
86
+ return ids.sort();
91
87
  }
92
88
 
93
89
  const inWindow = (date, since, until) => !!date && (!since || date >= since) && (!until || date <= until);
94
90
 
95
91
  // ---- event derivation --------------------------------------------------------------------------
96
92
 
97
- // Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews. Each carries the
98
- // roster `name` already, so attribution is direct.
99
- function ledgerEvents(root, epic, resolver) {
100
- const f = epicFiles(path.join(root, 'epics', epic));
93
+ // Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews, attributed to the
94
+ // name the ledger records — as recorded, with nothing to translate it through.
95
+ function ledgerEvents(root, epic, aliases = new Map()) {
96
+ const f = epicFiles(epicRoot(root, epic));
101
97
  const events = [];
102
- const emit = (rawName, action, date, extra = {}) => {
98
+ const emit = (rec, rawName, action, date, extra = {}) => {
103
99
  if (!rawName || !date) return;
104
- const m = resolver.byNameOrLogin(rawName);
105
- events.push({ ts: date, actor: m ? m.name || m.login : rawName, login: m ? m.login || null : null, rostered: !!m, action, epic, ...extra });
100
+ // A record whose login a gate write recorded from the roster (E64, `rosterName`) names that login, just
101
+ // as the name table said before the stamp — so the row stays one known by login. The rule LIVES in
102
+ // cli/epic-state.mjs since E71, because the active-people count has to place a record on exactly the
103
+ // person this report does — two readers that disagreed would split or fold a person between them.
104
+ const login = ledgerPersonLogin(rec, rawName, aliases);
105
+ events.push({ ts: date, actor: login || rawName, login, action, epic, ...extra });
106
106
  };
107
- for (const a of readLedger(f.approvals, []) || []) emit(a.approver, 'approved', a.date, { artifact: a.artifact, role: a.role });
108
- for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact, role: cm.role });
109
- for (const s of readShips(path.join(root, 'epics', epic))) {
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 });
107
+ for (const a of readLedger(f.approvals, []) || []) emit(a, a.approver, 'approved', a.date, { artifact: a.artifact });
108
+ for (const cm of readLedger(f.comments, []) || []) emit(cm, cm.commenter, 'commented', cm.date, { artifact: cm.artifact });
109
+ for (const s of readShips(epicRoot(root, epic))) {
110
+ for (const er of s.engineer_review || []) emit(er, er.approver, 'shipped', s.shippedAt, { story: s.story, task: s.task, repo: s.repo, risk: s.risk });
111
111
  }
112
112
  return events;
113
113
  }
114
114
 
115
- // True for `epics/<EP>/<artifact>.md` or `epics/<EP>/stories/<file>.md`.
116
- function isArtifactPath(rel) {
115
+ // True for `epics/<EP>/<artifact>.md`, `epics/<EP>/stories/<file>.md`, or a Foundation section
116
+ // `foundation/<section>.md` (E75).
117
+ export function isArtifactPath(rel) {
117
118
  const parts = rel.split('/');
119
+ if (parts[0] === FOUNDATION_DIR) return parts.length === 2 && FOUNDATION_FILES.includes(parts[1]);
118
120
  if (parts[0] !== 'epics' || parts.length < 3) return false;
119
121
  if (parts[2] === 'stories') return parts.length >= 4 && parts[3].endsWith('.md');
120
122
  return parts.length === 3 && ARTIFACT_FILES.has(parts[2]);
@@ -139,27 +141,29 @@ function parseGitLog(stdout) {
139
141
 
140
142
  const GIT_PRETTY = '--pretty=format:\x01%an%x00%ae%x00%ad';
141
143
 
142
- // git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the hub
144
+ // git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the Product
143
145
  // 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
+ function gitAuthoredEvents(root) {
147
+ const r = run('git', ['-C', root, 'log', '--no-merges', '--date=short', GIT_PRETTY, '--name-only', '--', 'epics', FOUNDATION_DIR]);
146
148
  if (!r.ok || !r.stdout) return [];
147
149
  const events = [];
148
150
  for (const cm of parseGitLog(r.stdout)) {
149
- const m = resolver.byGitAuthor(cm.an, cm.ae);
151
+ const login = loginFromEmail(cm.ae);
150
152
  for (const rel of cm.files) {
151
153
  if (!isArtifactPath(rel)) continue;
152
154
  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
+ ts: cm.ad, actor: login || cm.an, login,
156
+ action: 'authored', epic: rel.startsWith(`${FOUNDATION_DIR}/`) ? FOUNDATION_EPIC : rel.split('/')[1],
157
+ artifact: rel.split('/').pop().replace(/\.md$/, ''),
155
158
  });
156
159
  }
157
160
  }
158
161
  return events;
159
162
  }
160
163
 
161
- // Optional (`--repos`): code commits in each connected code repo, attributed by author → roster.
162
- function repoCommitEvents(root, resolver) {
164
+ // Optional (`--repos`): code commits in each connected code repo, attributed to the git author (or the
165
+ // login a noreply address carries).
166
+ function repoCommitEvents(root) {
163
167
  const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
164
168
  const events = [];
165
169
  for (const repo of reg?.repos || []) {
@@ -168,18 +172,20 @@ function repoCommitEvents(root, resolver) {
168
172
  const r = run('git', ['-C', abs, 'log', '--no-merges', '--date=short', GIT_PRETTY]);
169
173
  if (!r.ok || !r.stdout) continue;
170
174
  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 });
175
+ const login = loginFromEmail(cm.ae);
176
+ events.push({ ts: cm.ad, actor: login || cm.an, login, action: 'committed', repo: repo.name });
173
177
  }
174
178
  }
175
179
  return events;
176
180
  }
177
181
 
178
182
  // 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
+ export function deriveEvents(root, { since, until, repos = false } = {}) {
184
+ const events = [...gitAuthoredEvents(root)];
185
+ let aliases = new Map();
186
+ try { aliases = legacyLogins(readJSON(productConfigPath(root), null)); } catch { /* no Product — nothing to translate */ }
187
+ for (const epic of listEpics(root)) events.push(...ledgerEvents(root, epic, aliases));
188
+ if (repos) events.push(...repoCommitEvents(root));
183
189
  return events
184
190
  .filter((e) => inWindow(e.ts, since, until))
185
191
  .sort((a, b) =>
@@ -192,18 +198,21 @@ export function deriveEvents(root, resolver, { since, until, repos = false } = {
192
198
  const zeroCounts = () => Object.fromEntries(ACTIONS.map((a) => [a, 0]));
193
199
 
194
200
  // Per-member rollup + explainable hygiene flags + team totals. `window` echoes the requested range.
195
- export function analyze(events, roster, window = { since: null, until: null }) {
201
+ // Everyone here did something in range: there is no stored list to seed an inactive person from (E62).
202
+ export function analyze(events, window = { since: null, until: null }) {
196
203
  const members = new Map();
197
- const seed = (key, name, login, role, rostered, isReviewer = false) => {
204
+ const seed = (key, name, login) => {
198
205
  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: [] });
206
+ members.set(key, { key, name, login: login || null, counts: zeroCounts(), total: 0, firstActive: null, lastActive: null, epics: new Set(), timeline: [] });
200
207
  }
201
- return members.get(key);
208
+ const m = members.get(key);
209
+ if (login && !m.login) m.login = login;
210
+ return m;
202
211
  };
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
212
  for (const e of events) {
206
- const m = seed(e.login || e.actor, e.actor, e.login, '', e.rostered);
213
+ // Keyed case-insensitively: GitHub and GitLab logins are, so `OctoCat` on an approval and `octocat`
214
+ // in a noreply address are one person.
215
+ const m = seed(String(e.login || e.actor).toLowerCase(), e.actor, e.login);
207
216
  m.counts[e.action] = (m.counts[e.action] || 0) + 1;
208
217
  m.total += 1;
209
218
  if (e.epic) m.epics.add(e.epic);
@@ -212,7 +221,7 @@ export function analyze(events, roster, window = { since: null, until: null }) {
212
221
  m.timeline.push({ ts: e.ts, action: e.action, epic: e.epic || null, repo: e.repo || null, artifact: e.artifact || null });
213
222
  }
214
223
  const list = [...members.values()].map((m) => ({
215
- name: m.name, login: m.login, role: m.role, rostered: m.rostered,
224
+ name: m.name, login: m.login,
216
225
  counts: m.counts, total: m.total, firstActive: m.firstActive, lastActive: m.lastActive,
217
226
  epics: [...m.epics].sort(), flags: memberFlags(m), timeline: m.timeline,
218
227
  }));
@@ -222,22 +231,15 @@ export function analyze(events, roster, window = { since: null, until: null }) {
222
231
  return { window, generatedFrom: 'derived', members: list, totals };
223
232
  }
224
233
 
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.
234
+ // Factual, per-member flags — each is a plain derivation, never a score. The two that needed a stored
235
+ // list went with the roster (E62): `dormant` (listed, no activity) and `reviewer-not-reviewing` (holds a
236
+ // reviewer role, never reviews).
235
237
  function memberFlags(m) {
236
238
  const flags = [];
237
- if (m.rostered && m.total === 0) flags.push('dormant'); // in the roster, no activity in range
238
239
  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
240
+ // Only on a row whose person is known by LOGIN. A row built from a bare git name cannot see that
241
+ // person's approvals, which are recorded under their login, so "never reviews" would be a false claim.
242
+ if (m.login && m.total > 0 && m.counts.authored > 0 && reviews === 0) flags.push('no-review-participation'); // authors but never reviews
241
243
  return flags;
242
244
  }
243
245
 
@@ -251,7 +253,7 @@ function memberFlags(m) {
251
253
  export function shipHygiene(root, { since, until } = {}) {
252
254
  const items = [];
253
255
  for (const epic of listEpics(root)) {
254
- for (const s of readShips(path.join(root, 'epics', epic))) {
256
+ for (const s of readShips(epicRoot(root, epic))) {
255
257
  if (!inWindow(s.shippedAt, since, until)) continue;
256
258
  if (s.retroactive) continue;
257
259
  if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
@@ -279,7 +281,7 @@ export function renderHtml(model, today = '') {
279
281
  const memberCard = (m) => `
280
282
  <section class="member${m.total === 0 ? ' idle' : ''}">
281
283
  <header>
282
- <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>
284
+ <h3>${esc(m.name)} ${m.login && m.login !== m.name ? `<span class="login">@${esc(m.login)}</span>` : ''}</h3>
283
285
  <div class="flags">${m.flags.map(flagChip).join(' ')}</div>
284
286
  </header>
285
287
  ${bar(m)}
@@ -306,12 +308,10 @@ h1{font-size:22px;margin:0 0 4px}.sub{color:var(--dim);margin:0 0 24px}
306
308
  .member{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 14px}
307
309
  .member.idle{opacity:.6}.member header{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
308
310
  h3{margin:0;font-size:15px}.login{color:var(--dim);font-weight:400;font-size:13px}
309
- .role{color:var(--accent);font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-left:4px}
310
311
  .bar{height:6px;background:var(--line);border-radius:4px;margin:10px 0;overflow:hidden}.bar span{display:block;height:100%;background:var(--accent)}
311
312
  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}
312
313
  .meta{color:var(--dim);font-size:12px;margin:6px 0 0}
313
314
  .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}
314
- .flag-dormant{background:#2a2f3a;color:#9aa7bd;border-color:#39414f}
315
315
  details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:12px}
316
316
  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)}
317
317
  time{color:var(--dim);font-variant-numeric:tabular-nums;margin-right:6px}
@@ -323,18 +323,18 @@ footer{color:var(--dim);font-size:12px;margin-top:28px;border-top:1px solid var(
323
323
  <div class="totals">${ACTIONS.map((a) => `<div>${a}<b>${model.totals[a]}</b></div>`).join('')}</div>
324
324
  ${model.members.map(memberCard).join('')}
325
325
  ${hygiene}
326
- <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>
326
+ <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. People are listed as the ledgers and git name them: someone whose git name differs from their platform login may appear twice.</footer>
327
327
  </div></body></html>\n`;
328
328
  }
329
329
 
330
330
  // A compact Markdown variant for quick reads / pasting into a PR. Dynamic values are sanitized so a
331
- // name/role/repo containing `|` or a newline can't corrupt the table or list structure.
331
+ // name/repo containing `|` or a newline can't corrupt the table or list structure.
332
332
  export function renderMarkdown(model, today = '') {
333
333
  const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
334
334
  const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
335
- 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('|')}|--:|---|`];
335
+ 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 | ${ACTIONS.join(' | ')} | total | flags |`, `|---|${ACTIONS.map(() => '--:').join('|')}|--:|---|`];
336
336
  for (const m of model.members) {
337
- 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(', ') || '—')} |`);
337
+ L.push(`| ${mdCell(`${m.name}${m.login && m.login !== m.name ? ` (@${m.login})` : ''}`)} | ${ACTIONS.map((a) => m.counts[a]).join(' | ')} | ${m.total} | ${mdCell(m.flags.join(', ') || '—')} |`);
338
338
  }
339
339
  L.push('', '## Workflow hygiene');
340
340
  if (model.hygiene?.length) {
@@ -343,17 +343,15 @@ export function renderMarkdown(model, today = '') {
343
343
  } else {
344
344
  L.push('No ship-without-review gaps in range. ✓');
345
345
  }
346
- L.push('', '_Derived, read-only — reconstructed from git + the SDLC ledgers; no emails/comment bodies._', '');
346
+ L.push('', '_Derived, read-only — reconstructed from git + the SDLC ledgers; no emails/comment bodies. People are listed as the ledgers and git name them, so one person may appear twice._', '');
347
347
  return L.join('\n');
348
348
  }
349
349
 
350
350
  // ---- CLI entry ---------------------------------------------------------------------------------
351
351
 
352
352
  export function buildModel(root, { since, until, repos = false, member } = {}) {
353
- const roster = loadRoster(root);
354
- const resolver = makeResolver(roster);
355
- const events = deriveEvents(root, resolver, { since, until, repos });
356
- const model = analyze(events, roster, { since: since || null, until: until || null });
353
+ const events = deriveEvents(root, { since, until, repos });
354
+ const model = analyze(events, { since: since || null, until: until || null });
357
355
  model.hygiene = shipHygiene(root, { since, until });
358
356
  if (member) {
359
357
  model.members = model.members.filter((m) => m.name === member || m.login === member);
@@ -371,16 +369,24 @@ function writeReport(dest, content) {
371
369
  fs.writeFileSync(dest, content);
372
370
  }
373
371
 
374
- export function runUsage(root, { out, since, until, all, member, format = 'html', repos = false, json = false, today = '' } = {}) {
372
+ export function runUsage(root, { out, since, until, all, member, format, repos = false, json = false, today = '' } = {}) {
375
373
  if (all) { since = undefined; until = undefined; }
376
374
  // Dates compare lexically as strings, so an unpadded value (2026-6-1) silently mis-windows — warn.
377
375
  for (const [flag, val] of [['--since', since], ['--until', until]]) {
378
- 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`));
376
+ if (val && !DATE_RE.test(val)) { const w = `${flag} ${val} is not YYYY-MM-DD — dates compare lexically, so a non-padded value may filter incorrectly`; collectWarning(w); note(c.yellow(w)); }
379
377
  }
380
- let fmt = json ? 'json' : format;
381
- if (!['html', 'json', 'md'].includes(fmt)) { note(c.yellow(`unknown --format ${fmt} (html|json|md) — using html`)); fmt = 'html'; }
378
+ // `--json` is the command's answer in E1's envelope; `--format json` is a REPORT format — the bare model,
379
+ // written to --out or printed — and stays that. With both, the report is written and the answer says where.
380
+ // With --json and no --format, a report written to --out is the JSON model, as it was before E1.
381
+ let fmt = json && format === undefined ? 'json' : (format ?? 'html');
382
+ if (!['html', 'json', 'md'].includes(fmt)) { const w = `unknown --format ${fmt} (html|json|md) — using html`; collectWarning(w); note(c.yellow(w)); fmt = 'html'; }
382
383
  const model = buildModel(root, { since, until, repos, member });
383
384
 
385
+ if (json) {
386
+ if (out) { writeReport(out, fmt === 'json' ? `${JSON.stringify(model, null, 2)}\n` : fmt === 'md' ? renderMarkdown(model, today) : renderHtml(model, today)); note(`wrote ${fmt.toUpperCase()} report → ${out}`); }
387
+ emitJSON({ ...model, out: out || null });
388
+ return model;
389
+ }
384
390
  if (fmt === 'json') {
385
391
  const s = JSON.stringify(model, null, 2);
386
392
  if (out) { writeReport(out, s + '\n'); note(`wrote JSON → ${out}`); } else { log(s); } // stdout stays pure JSON
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.18.1",
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 + 38 yad-* skills.",
3
+ "version": "4.0.0-next.1",
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). Plus 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
7
7
  "license": "MIT",
@@ -49,9 +49,9 @@
49
49
  },
50
50
  "keywords": [
51
51
  "sdlc",
52
+ "adlc",
52
53
  "yad",
53
54
  "yadflow",
54
- "bmad",
55
55
  "workflow",
56
56
  "cli",
57
57
  "spec-driven-development",