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.
- package/CHANGELOG.md +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- 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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
// ----
|
|
64
|
+
// ---- attribution -------------------------------------------------------------------------------
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
98
|
-
//
|
|
99
|
-
function ledgerEvents(root, epic,
|
|
100
|
-
const f = epicFiles(
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact
|
|
109
|
-
for (const s of readShips(
|
|
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
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
154
|
-
action: 'authored', epic: rel.
|
|
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
|
|
162
|
-
|
|
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
|
|
172
|
-
events.push({ ts: cm.ad, actor:
|
|
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,
|
|
180
|
-
const events = [...gitAuthoredEvents(root
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
204
|
+
const seed = (key, name, login) => {
|
|
198
205
|
if (!members.has(key)) {
|
|
199
|
-
members.set(key, { key, name, login: login || null,
|
|
206
|
+
members.set(key, { key, name, login: login || null, counts: zeroCounts(), total: 0, firstActive: null, lastActive: null, epics: new Set(), timeline: [] });
|
|
200
207
|
}
|
|
201
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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(
|
|
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
|
|
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/
|
|
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 |
|
|
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})` : ''}`)} | ${
|
|
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
|
|
354
|
-
const
|
|
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
|
|
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))
|
|
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
|
-
|
|
381
|
-
|
|
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": "
|
|
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).
|
|
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",
|