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/people.mjs
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// How many people are ACTIVE right now (E71) — the live count Part 3's three windows ask for, and the
|
|
2
|
+
// number E72's cap (`needed` capped at `active − 1`, `gateCapFor` in cli/epic-state.mjs) reads.
|
|
3
|
+
//
|
|
4
|
+
// THE SAFETY DIRECTION IS THE OPPOSITE OF E66 AND E67, and it is the whole reason this file exists
|
|
5
|
+
// instead of a call into `yad usage`. There, an input nobody could read must never say "nobody worked
|
|
6
|
+
// here", because that DROPS an ask and UNDER-asks for review. Here, a low `active` LOWERS the cap and
|
|
7
|
+
// weakens every gate, so a partial read that looks like "few people" is the dangerous answer. Part 3
|
|
8
|
+
// says it in one line: when counting capacity, err towards MORE people — "undercounting weakens gates
|
|
9
|
+
// quietly, overcounting jams them loudly, and we have an exit for jams".
|
|
10
|
+
//
|
|
11
|
+
// So there is ONE rule here, and every decision below is it applied:
|
|
12
|
+
//
|
|
13
|
+
// IF A SOURCE CANNOT BE READ, THE ANSWER IS `null`, NEVER A NUMBER.
|
|
14
|
+
//
|
|
15
|
+
// Not a partial count, and NOT a floor either: a floor on `active` is a CEILING on the cap, which is
|
|
16
|
+
// the same mistake wearing the other hat.
|
|
17
|
+
//
|
|
18
|
+
// WHY NOT `yad usage`, which already derives a people view. Because every one of its degradations runs
|
|
19
|
+
// the wrong way for a gate, and all four are in the code (cli/usage.mjs):
|
|
20
|
+
// * `repoCommitEvents`: `if (!r.ok || !r.stdout) continue` — a repo missing from disk, not a git repo,
|
|
21
|
+
// or a failing git reads as "nobody committed there".
|
|
22
|
+
// * `deriveEvents`: `readJSON(repos.json, { repos: [] })` — a registry that does not parse reads as
|
|
23
|
+
// "no repos". This is the bug E65 fixed in `targets()` by moving to `readJSONStrict`.
|
|
24
|
+
// * `readLedger`: warns and skips a corrupt approvals file — that person vanishes.
|
|
25
|
+
// * no shallow-clone check (E67 added one for exactly this class), and no `--since` at all.
|
|
26
|
+
// That is correct for a REPORT, where a gap is a cosmetic flaw. It is wrong for a gate input. What is
|
|
27
|
+
// reused is the RULES, which are sound and now shared outright: `loginFromEmail` and `isBot` from
|
|
28
|
+
// cli/riskmap.mjs, `ledgerPersonLogin` and `legacyLogins` from cli/epic-state.mjs, and the
|
|
29
|
+
// case-insensitive login key. What is new is the failure contract.
|
|
30
|
+
//
|
|
31
|
+
// NOTHING IS WRITTEN TO DISK (E7's stance, kept): the count is derived live on every call, so it can
|
|
32
|
+
// never go stale and never becomes an audit record of its own. No file shape moves.
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
|
|
37
|
+
import { c, info, readJSONStrict, warn } from './lib.mjs';
|
|
38
|
+
import { PROJECT_FILES, epicFiles } from './manifest.mjs';
|
|
39
|
+
import { epicIds, epicRoot, unlistedLedgerDirs, FOUNDATION_EPIC, FOUNDATION_DIR, ledgerPersonLogin, legacyLogins, capLimit, capSeat, smallestTeam } from './epic-state.mjs';
|
|
40
|
+
import { corruptShards, readShips } from './ledger.mjs';
|
|
41
|
+
import { isBot, loginFromEmail } from './riskmap.mjs';
|
|
42
|
+
|
|
43
|
+
// yadflow's OWN gate bot on GitLab. The GitHub workflow commits as `yad-gate-sync[bot]`, which `isBot`
|
|
44
|
+
// already skips; the GitLab one commits as `yad-gate-sync` <yad-gate-sync@noreply.<host>> (the wired
|
|
45
|
+
// `yad-gate-sync.gitlab-ci.yml`), with no `[bot]`, so it was counted as a person (E72 review, case b) —
|
|
46
|
+
// and a solo GitLab developer read as two. Matched EXACTLY on both the name and the address our own
|
|
47
|
+
// template writes: that is evidence, not a guess from how a name looks. Kept here, not in `isBot`,
|
|
48
|
+
// because `isBot` has an awk twin in `risk-map-check.sh` that a parity test holds equal.
|
|
49
|
+
export const isGateBot = (name, email) => String(name || '').trim() === 'yad-gate-sync'
|
|
50
|
+
&& /^yad-gate-sync@noreply\./i.test(String(email || '').trim());
|
|
51
|
+
|
|
52
|
+
// ---- the three windows (Part 3, "Counting people") ----------------------------------------------
|
|
53
|
+
//
|
|
54
|
+
// | Purpose | Window | Shape |
|
|
55
|
+
// | Expertise (tier 2) | 30 days | Fixed. Tight |
|
|
56
|
+
// | Capacity (the cap) | Last 20 merged PRs, bounded 30–180 days | Adaptive to pace |
|
|
57
|
+
// | Gone / stale | 120 days | Fixed. Long |
|
|
58
|
+
export const EXPERTISE_DAYS = 30;
|
|
59
|
+
export const STALE_DAYS = 120;
|
|
60
|
+
export const CAPACITY_MERGES = 20;
|
|
61
|
+
export const CAPACITY_MIN_DAYS = 30;
|
|
62
|
+
export const CAPACITY_MAX_DAYS = 180;
|
|
63
|
+
|
|
64
|
+
// How much history to ASK GIT FOR, as a multiple of the widest window we can end up using (180 days).
|
|
65
|
+
//
|
|
66
|
+
// `--since` does not merely filter: git stops walking a chain at the first commit older than the date,
|
|
67
|
+
// so a commit whose date is out of order can hide every commit behind it. E67 recorded that limit and
|
|
68
|
+
// could accept it — there it UNDER-LISTS, so every name it prints is a real one, the safe side. Here
|
|
69
|
+
// the same behaviour UNDER-COUNTS, which lowers the cap. So we ask for twice the window and throw the
|
|
70
|
+
// out-of-range commits away in JS, where no ordering can hide anything (the user's decision,
|
|
71
|
+
// 2026-09-21). The limit shrinks; it does not vanish, and it is stated in the roadmap row.
|
|
72
|
+
export const WALK_PAD = 2;
|
|
73
|
+
|
|
74
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
75
|
+
|
|
76
|
+
// A day number back to `YYYY-MM-DD`, or null when it is not a date a human would recognise. It is a
|
|
77
|
+
// FORMATTER, so everything it is handed must be checked: `toISOString()` renders a year outside 0000-9999
|
|
78
|
+
// as `+058691-05-…`, which is not a date and — because `+` sorts below every digit — compares as EARLIER
|
|
79
|
+
// than any real one; and past about 8.64e15 it throws `RangeError: Invalid time value` instead.
|
|
80
|
+
const dayString = (n) => {
|
|
81
|
+
let text;
|
|
82
|
+
try {
|
|
83
|
+
text = new Date(n * 86400000).toISOString().slice(0, 10);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return DATE_RE.test(text) ? text : null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// A `YYYY-MM-DD` date as a whole number of days, or null when it is not one.
|
|
91
|
+
//
|
|
92
|
+
// The round-trip is the real check. `Date.parse` does NOT reject an impossible day: it rolls it
|
|
93
|
+
// forward, so `2026-02-30` silently becomes 2 March and a typo in a ledger a human edited would be
|
|
94
|
+
// read as a real date a couple of days out. Parsing it back and comparing the text is what rejects it.
|
|
95
|
+
export function dayNumber(date) {
|
|
96
|
+
const text = String(date ?? '');
|
|
97
|
+
if (!DATE_RE.test(text)) return null;
|
|
98
|
+
const t = Date.parse(`${text}T00:00:00Z`);
|
|
99
|
+
if (!Number.isFinite(t)) return null;
|
|
100
|
+
const n = Math.floor(t / 86400000);
|
|
101
|
+
// The round-trip is the real check: `Date.parse` does NOT reject an impossible day, it rolls it
|
|
102
|
+
// forward, so `2026-02-30` would silently become 2 March.
|
|
103
|
+
return dayString(n) === text ? n : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `days` before `date`. Null in, null out — a caller with an unreadable `today` has nothing to ask.
|
|
107
|
+
export function daysBefore(date, days) {
|
|
108
|
+
const n = dayNumber(date);
|
|
109
|
+
return n === null ? null : dayString(n - days); // dayString itself refuses anything unprintable
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const todayString = () => new Date().toISOString().slice(0, 10);
|
|
113
|
+
|
|
114
|
+
// ---- who is one person --------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
// The key two records share when they are the same human. Case-insensitive because GitHub and GitLab
|
|
117
|
+
// logins are: `OctoCat` on an approval and `octocat` in a noreply commit address are one approver.
|
|
118
|
+
//
|
|
119
|
+
// A LOGIN AND A BARE NAME LIVE IN DIFFERENT NAMESPACES, and that is the whole point. They used to share
|
|
120
|
+
// one, so a git author literally named `ada` and a different human whose platform login is `ada`
|
|
121
|
+
// collapsed into ONE row — an under-count, the one direction this file may never go, and a collision
|
|
122
|
+
// nobody would ever notice. Namespacing turns that into a harmless split.
|
|
123
|
+
//
|
|
124
|
+
// Every intended join still works, because a join is now only ever login-to-login or name-to-name: a
|
|
125
|
+
// noreply commit address carries a login (`loginFromEmail`), a bridge-written approval carries one
|
|
126
|
+
// (`ledgerPersonLogin`), and those meet. What no longer joins is a bare name that merely LOOKS like a
|
|
127
|
+
// login — which is exactly E64's rule that only exact evidence proves identity. The cost is that such a
|
|
128
|
+
// person lands on two rows and is counted twice, which over-counts: the direction Part 3 asks for.
|
|
129
|
+
export const personKey = (person) => {
|
|
130
|
+
const login = String(person.login || '').trim().toLowerCase();
|
|
131
|
+
if (login) return `login:${login}`;
|
|
132
|
+
const name = String(person.name || '').trim().toLowerCase();
|
|
133
|
+
return name ? `name:${name}` : '';
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ---- reading the sources ------------------------------------------------------------------------
|
|
137
|
+
//
|
|
138
|
+
// Two kinds of evidence, and only two, because Part 3 defines it: "Active" = committed OR approved
|
|
139
|
+
// within the window.
|
|
140
|
+
// * APPROVED — the Product ledger: every Shape gate approval, and every Build ship's
|
|
141
|
+
// engineer-review. Both are MEANT to record a platform LOGIN since E62, but only a record
|
|
142
|
+
// `ledgerPersonLogin` accepts proves it (bridge-written, roster-stamped, or an older roster alias);
|
|
143
|
+
// an engineer-review entry carries no marker, so it is keyed by name — E73's lines call such a
|
|
144
|
+
// person "not matched to a platform login".
|
|
145
|
+
// * COMMITTED — git authorship, in the Product and in every connected code repo. Git records a NAME,
|
|
146
|
+
// plus a login only when the address is a platform `noreply` one.
|
|
147
|
+
// A COMMENT is not either of them and does not count. Part 3 names two actions; a commenter has proven
|
|
148
|
+
// neither, and widening a definition the row already closed is not this row's call. The same goes for
|
|
149
|
+
// whoever MERGED a review PR: `closingRecord` records them, and they are used below to MEASURE THE
|
|
150
|
+
// WINDOW, never to fill it.
|
|
151
|
+
|
|
152
|
+
// Every approval-shaped event in the Product ledger, plus the dates of the merges that set the
|
|
153
|
+
// capacity window. Throws nothing: a source it cannot read becomes a line in `unknown`.
|
|
154
|
+
function ledgerEvidence(root, aliases) {
|
|
155
|
+
const events = [];
|
|
156
|
+
const merges = [];
|
|
157
|
+
const unknown = [];
|
|
158
|
+
let ids;
|
|
159
|
+
try {
|
|
160
|
+
ids = epicIds(root);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { events, merges, unknown: [`the epic list could not be read: ${e.message}`] };
|
|
163
|
+
}
|
|
164
|
+
// `epicIds` drops a directory whose name is not a valid id — and, because `Dirent.isDirectory()` is
|
|
165
|
+
// false for a symlink, a symlinked epic too. That guard is right for an enumerator that turns a name
|
|
166
|
+
// into a path segment; it is wrong for a counter, because the approvals inside go with it. Anything
|
|
167
|
+
// holding a `.sdlc/` that the enumerator did not name is reported.
|
|
168
|
+
try {
|
|
169
|
+
for (const e of unlistedLedgerDirs(root, ids)) {
|
|
170
|
+
// EP-foundation IS a valid id — its folder is foundation/, so the one under epics/ is never read.
|
|
171
|
+
unknown.push(e === FOUNDATION_EPIC
|
|
172
|
+
? `epics/${e} holds a ledger, but ${FOUNDATION_EPIC} lives in ${FOUNDATION_DIR}/ — this folder is never read, so its people are not counted`
|
|
173
|
+
: `epics/${e} holds a ledger but is not a readable epic id — its people are not counted`);
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
unknown.push(`the epics folder could not be listed: ${e.code || e.message}`);
|
|
177
|
+
}
|
|
178
|
+
for (const epic of ids) {
|
|
179
|
+
const dir = epicRoot(root, epic);
|
|
180
|
+
const f = epicFiles(dir);
|
|
181
|
+
// readJSONStrict, not readJSON: a corrupt approvals file must never read as an empty one. This is
|
|
182
|
+
// the read-strictly-before-a-count twin of the read-strictly-before-a-write rule.
|
|
183
|
+
let approvals;
|
|
184
|
+
let state;
|
|
185
|
+
try {
|
|
186
|
+
approvals = readJSONStrict(f.approvals, []);
|
|
187
|
+
state = readJSONStrict(f.state, null);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
unknown.push(`${epic}: ${e.message}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(approvals)) {
|
|
193
|
+
unknown.push(`${epic}: approvals.json is not a list`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
for (const a of approvals) {
|
|
197
|
+
if (!a || typeof a !== 'object') continue;
|
|
198
|
+
const name = typeof a.approver === 'string' ? a.approver.trim() : '';
|
|
199
|
+
// A record naming nobody IS nobody — the same rule `gatePredicate` and `mapApprovers` apply.
|
|
200
|
+
if (!name) continue;
|
|
201
|
+
// But a record that names SOMEBODY and carries a date we cannot read is a source we could not
|
|
202
|
+
// read, not one person fewer. These dates are written by LLM-driven skills from a `<YYYY-MM-DD>`
|
|
203
|
+
// template, so a `2026-9-4` is a realistic input. The value is never printed — only the field.
|
|
204
|
+
if (dayNumber(a.date) === null) {
|
|
205
|
+
unknown.push(`${epic}: an approval has no readable \`date\``);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// `status` is not filtered: someone who asked for changes reviewed the artifact and is plainly
|
|
209
|
+
// active. It counts them in, which is the safe direction. Nothing here feeds `have`; E73's
|
|
210
|
+
// `approverCount` does read `how: 'approved'` as "approvals can be given here", and today every
|
|
211
|
+
// writer records only approvals in this file.
|
|
212
|
+
events.push({ ts: a.date, name, login: ledgerPersonLogin(a, name, aliases), how: 'approved' });
|
|
213
|
+
}
|
|
214
|
+
// A merged review PR, as the Product recorded it: a step whose closing record carries a PR number
|
|
215
|
+
// and a date. This is what "the last 20 merged PRs" reads (the user's decision, 2026-09-21) —
|
|
216
|
+
// NOT merge commits on a default branch, because a squash or rebase merge leaves none, and a
|
|
217
|
+
// window that shrinks on teams who squash means fewer people, the unsafe direction.
|
|
218
|
+
for (const s of Array.isArray(state?.steps) ? state.steps : []) {
|
|
219
|
+
const closed = s && typeof s === 'object' ? s.closed : null;
|
|
220
|
+
if (closed && typeof closed === 'object' && closed.pr != null && dayNumber(closed.date) !== null) merges.push(closed.date);
|
|
221
|
+
}
|
|
222
|
+
// The FOLDED build-log has the same hazard in a different shape: `readShips` does
|
|
223
|
+
// `Array.isArray(foldedObj?.ships) ? … : []`, so a file that parses but whose `ships` is missing or
|
|
224
|
+
// is not a list reads as "no ships at all" — every engineer-review approver and every merge date in
|
|
225
|
+
// it vanishes, with no throw. That shrinks the count AND widens the window. `corruptShards` cannot
|
|
226
|
+
// see it: it only inspects the shard directory.
|
|
227
|
+
if (fs.existsSync(f.buildLog)) {
|
|
228
|
+
let folded;
|
|
229
|
+
try {
|
|
230
|
+
folded = readJSONStrict(f.buildLog, null);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
unknown.push(`${epic}: ${e.message}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!folded || typeof folded !== 'object' || Array.isArray(folded) || !Array.isArray(folded.ships)) {
|
|
236
|
+
unknown.push(`${epic}: .sdlc/build-log.json holds no \`ships\` list`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// A corrupt ship shard is SKIPPED by `readShardDir` — advisory behaviour that is right for a
|
|
241
|
+
// report and wrong for a count, so it is asked about first (cli/ledger.mjs `corruptShards`).
|
|
242
|
+
const shards = corruptShards(f.buildLogDir);
|
|
243
|
+
if (shards.unreadable) {
|
|
244
|
+
unknown.push(`${epic}: ${shards.unreadable}`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (shards.bad.length) {
|
|
248
|
+
unknown.push(`${epic}: ${shards.bad.length} unreadable ship record(s) in .sdlc/build-log/ (${shards.bad.join(', ')})`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let ships;
|
|
252
|
+
try {
|
|
253
|
+
ships = readShips(dir);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
unknown.push(`${epic}: ${e.message}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
for (const s of ships) {
|
|
259
|
+
if (!s || typeof s !== 'object') continue;
|
|
260
|
+
// `readShips` de-duplicates on `story|task|repo`, so two ships missing those fields collide on
|
|
261
|
+
// `undefined|undefined|undefined` and one silently replaces the other — taking its engineer
|
|
262
|
+
// reviewers with it. We cannot tell which was lost, so the whole count is unknown.
|
|
263
|
+
if (s.story == null || s.task == null || s.repo == null) {
|
|
264
|
+
unknown.push(`${epic}: a ship record has no story/task/repo, so ships cannot be told apart`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// A merge date we cannot read only costs a point on the WINDOW, which then stays at the wide end
|
|
268
|
+
// — more days, more people. Safe, so it is skipped rather than reported.
|
|
269
|
+
if (s.pr != null && dayNumber(s.shippedAt) !== null) merges.push(s.shippedAt);
|
|
270
|
+
const reviews = Array.isArray(s.engineer_review) ? s.engineer_review : [];
|
|
271
|
+
// An engineer-review entry carries no date of its own; the ship's is the day it counted. So a ship
|
|
272
|
+
// with reviewers and no readable `shippedAt` loses PEOPLE, which is never skipped quietly.
|
|
273
|
+
if (reviews.length && dayNumber(s.shippedAt) === null) {
|
|
274
|
+
unknown.push(`${epic}: a ship with engineer reviews has no readable \`shippedAt\``);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
for (const er of reviews) {
|
|
278
|
+
if (!er || typeof er !== 'object') continue;
|
|
279
|
+
const name = typeof er.approver === 'string' ? er.approver.trim() : '';
|
|
280
|
+
if (!name) continue;
|
|
281
|
+
events.push({ ts: s.shippedAt, name, login: ledgerPersonLogin(er, name, aliases), how: 'approved' });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { events, merges, unknown };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// One git repository's authors, newest first, as `{ ts, name, login }`. `{ unknown }` instead whenever
|
|
289
|
+
// git could not answer fully — that is the whole point of this function.
|
|
290
|
+
//
|
|
291
|
+
// AUTHOR RECORDS ONLY. No `--name-only`, no file list, nothing read back and matched: E67 paid for that
|
|
292
|
+
// lesson eight bugs over four review rounds, and the fix was to stop asking git for file names at all.
|
|
293
|
+
//
|
|
294
|
+
// `--all`, not HEAD: a clone sitting on a feature branch would otherwise miss everyone who merged to
|
|
295
|
+
// the default branch since it diverged — fewer people. `--all` can include an abandoned branch, which
|
|
296
|
+
// counts someone who is still a real person. Over, not under.
|
|
297
|
+
//
|
|
298
|
+
// The date is the COMMITTER date (`%cd` with `--since`, which filters on the same field), so a rebase
|
|
299
|
+
// or squash counts a commit from when it LANDED. E67 recorded the same behaviour. It refreshes old
|
|
300
|
+
// work into the window, which over-counts — the safe side here.
|
|
301
|
+
//
|
|
302
|
+
// `since` is an ABSOLUTE `YYYY-MM-DD` date, never `'N days ago'`. A relative one is measured from the
|
|
303
|
+
// real clock, and the JS range below is measured from the INJECTED `today` — so the two would disagree
|
|
304
|
+
// on every day but one, and a fixture test pinned to a fixed `today` would quietly rot into a failure.
|
|
305
|
+
// Injecting `today` is what lets a CALLER fix the day — `gate sync` and `gate ci` pass theirs, and the
|
|
306
|
+
// golden test would otherwise move daily. It does not mean nothing here reads a clock: `todayString()`
|
|
307
|
+
// is the default, and `gate status`, `gate open` and `gate review` take it.
|
|
308
|
+
// Does this clone hold that commit? Used to tell a clone that is provably behind the registry from one
|
|
309
|
+
// that is merely old.
|
|
310
|
+
export function gitHas(repoRoot, sha) {
|
|
311
|
+
const r = spawnSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: repoRoot, encoding: 'utf8' });
|
|
312
|
+
return r.status === 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function gitAuthors(repoRoot, since) {
|
|
316
|
+
const git = (args) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
317
|
+
if (since === null) return { unknown: 'the window has no readable start date' };
|
|
318
|
+
if (!fs.existsSync(repoRoot)) return { unknown: `${repoRoot} is not on disk` };
|
|
319
|
+
if (git(['rev-parse', '--is-inside-work-tree']).status !== 0) return { unknown: `${repoRoot} is not a git repo` };
|
|
320
|
+
// A shallow clone holds only the newest commits. E67's rule, and the reason is even stronger here:
|
|
321
|
+
// reading a truncated history as "these are all the people" is exactly the quiet under-count.
|
|
322
|
+
// The exit status matters as much as the word: `--is-shallow-repository` did not exist before git
|
|
323
|
+
// 2.15, and there the command FAILS and prints nothing — which would have read as "not shallow".
|
|
324
|
+
const shallow = git(['rev-parse', '--is-shallow-repository']);
|
|
325
|
+
if (shallow.status !== 0) return { unknown: `${repoRoot}: this git cannot say whether the clone is shallow` };
|
|
326
|
+
if (/true/.test(shallow.stdout || '')) {
|
|
327
|
+
return { unknown: `${repoRoot} is a shallow clone — it does not hold enough history to count people` };
|
|
328
|
+
}
|
|
329
|
+
// `%ct`, the committer date as EPOCH SECONDS — never `%cd` with `--date=short`. `%cd` renders the date
|
|
330
|
+
// in the COMMIT'S OWN timezone, while every other date here comes from `toISOString()` and is UTC, so
|
|
331
|
+
// the two clocks disagreed by up to a day. A commit made at 08:00 in Tokyo is 23:00 the previous day
|
|
332
|
+
// in UTC: git printed tomorrow's date, the range below dropped it, and a real person vanished —
|
|
333
|
+
// `active: 0` with an empty `unknown`, the exact outcome this file exists to prevent.
|
|
334
|
+
const r = git(['log', '--all', '--no-merges', `--since=${since}`, '--format=%ct%x1f%an%x1f%ae']);
|
|
335
|
+
if (r.status !== 0) return { unknown: `git could not read the history of ${repoRoot}` };
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const line of (r.stdout || '').split('\n')) {
|
|
338
|
+
if (!line) continue;
|
|
339
|
+
// \x1f, never NUL: E67's separator, for the same reason — a reader that takes C strings would cut
|
|
340
|
+
// the line at the first NUL byte.
|
|
341
|
+
const [ct, name, email] = line.split('\x1f');
|
|
342
|
+
if (isBot(name, email) || isGateBot(name, email)) continue; // a robot cannot approve, so it is not capacity
|
|
343
|
+
// A commit we can READ but cannot date is a source we could not read — the same rule this file
|
|
344
|
+
// applies to an approval and to a ship, and the one place the first pass left it as a silent skip.
|
|
345
|
+
// `Number('')` is 0, which is finite and would have become 1970-01-01; a `%ct` in MILLISECONDS
|
|
346
|
+
// (a slip git accepts) formats as `+058691-05`, which sorts BELOW every real date and made its
|
|
347
|
+
// author disappear from all three windows with nothing reported.
|
|
348
|
+
const secs = Number(ct);
|
|
349
|
+
const ts = Number.isFinite(secs) && ct !== '' ? dayString(Math.floor(secs / 86400)) : null;
|
|
350
|
+
if (ts === null) return { unknown: `${repoRoot}: a commit carries a date git cannot express as a calendar day` };
|
|
351
|
+
out.push({ ts, name: String(name || '').trim(), login: loginFromEmail(email), how: 'committed' });
|
|
352
|
+
}
|
|
353
|
+
return { events: out };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// The connected code repos, or `{ unknown }`. A registry that is ABSENT means a Product with no code
|
|
357
|
+
// repos — a real, readable answer. A registry that does not PARSE means we cannot know how many repos
|
|
358
|
+
// were meant to be counted, which is the unsafe unknown (E65 fixed the same confusion in `targets()`).
|
|
359
|
+
function connectedRepos(root) {
|
|
360
|
+
const file = path.join(root, PROJECT_FILES.reposRegistry);
|
|
361
|
+
if (!fs.existsSync(file)) return { repos: [] };
|
|
362
|
+
let reg;
|
|
363
|
+
try {
|
|
364
|
+
reg = readJSONStrict(file, null);
|
|
365
|
+
} catch (e) {
|
|
366
|
+
return { unknown: `${PROJECT_FILES.reposRegistry} does not parse: ${e.message}` };
|
|
367
|
+
}
|
|
368
|
+
if (!reg || !Array.isArray(reg.repos)) return { unknown: `${PROJECT_FILES.reposRegistry} holds no \`repos\` list` };
|
|
369
|
+
const repos = [];
|
|
370
|
+
for (const r of reg.repos) {
|
|
371
|
+
if (!r || typeof r.name !== 'string' || !r.name) return { unknown: `${PROJECT_FILES.reposRegistry} holds a repo with no name` };
|
|
372
|
+
// A repo with no path is registered but not on this machine. It is NOT "zero people": we cannot
|
|
373
|
+
// read it, so the count is unknown rather than smaller.
|
|
374
|
+
if (typeof r.path !== 'string' || !r.path) return { unknown: `repo '${r.name}' has no local path — its history cannot be counted here` };
|
|
375
|
+
repos.push({ name: r.name, root: path.resolve(root, r.path), syncedHead: typeof r.syncedHead === 'string' ? r.syncedHead : null });
|
|
376
|
+
}
|
|
377
|
+
return { repos };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Every piece of evidence, from every source, with one list of the reasons any of it is missing.
|
|
381
|
+
export function peopleEvidence(root, { today = todayString(), aliases = new Map(), sinceDays = CAPACITY_MAX_DAYS * WALK_PAD } = {}) {
|
|
382
|
+
const led = ledgerEvidence(root, aliases);
|
|
383
|
+
const events = [...led.events];
|
|
384
|
+
const unknown = [...led.unknown];
|
|
385
|
+
// One absolute date for every repo, computed from the injected `today` (see `gitAuthors`).
|
|
386
|
+
const since = daysBefore(today, sinceDays);
|
|
387
|
+
|
|
388
|
+
// The Product's own git history: authoring an artifact is committing.
|
|
389
|
+
const product = gitAuthors(root, since);
|
|
390
|
+
if (product.unknown) unknown.push(product.unknown);
|
|
391
|
+
else events.push(...product.events);
|
|
392
|
+
|
|
393
|
+
const conn = connectedRepos(root);
|
|
394
|
+
if (conn.unknown) unknown.push(conn.unknown);
|
|
395
|
+
else {
|
|
396
|
+
for (const repo of conn.repos) {
|
|
397
|
+
// A clone last fetched months ago answers with a small number and no complaint — the one
|
|
398
|
+
// failure this reader cannot see from git alone. The registry records the commit it last packed
|
|
399
|
+
// (`syncedHead`); if this clone does not even hold that, it is provably behind, and behind means
|
|
400
|
+
// fewer people. It is a floor, not a freshness guarantee: a clone that HAS it may still be stale,
|
|
401
|
+
// which stays a stated limit.
|
|
402
|
+
// AFTER the "is it even here" checks, not before: `spawnSync` with a missing `cwd` returns an
|
|
403
|
+
// error rather than throwing, so `gitHas` would answer false for a repo that is simply not on
|
|
404
|
+
// this machine and send the reader off to fix the wrong thing.
|
|
405
|
+
if (fs.existsSync(repo.root) && repo.syncedHead && !gitHas(repo.root, repo.syncedHead)) {
|
|
406
|
+
unknown.push(`repo '${repo.name}': this clone does not hold the commit the registry last packed — it is behind, and a behind clone shows fewer people; fetch it, or re-pack it with \`yad repo refresh ${repo.name}\``);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const got = gitAuthors(repo.root, since);
|
|
410
|
+
if (got.unknown) unknown.push(`repo '${repo.name}': ${got.unknown}`);
|
|
411
|
+
else events.push(...got.events);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { events, merges: led.merges, unknown };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---- the windows, as pure functions over that evidence -------------------------------------------
|
|
418
|
+
|
|
419
|
+
// The capacity window in days: how long the last 20 merged PRs took, bounded to 30–180.
|
|
420
|
+
//
|
|
421
|
+
// FEWER THAN 20 MERGES MEANS THE WIDE END, not a short window. A young or quiet project has little
|
|
422
|
+
// history, and reading "little history" as "few people" is the quiet under-count this file exists to
|
|
423
|
+
// prevent. `basis` says which branch was taken so a surface can explain the number.
|
|
424
|
+
// HERE THE SAFETY DIRECTION IS INVERTED, and it is the one place in this file where it is. Everywhere
|
|
425
|
+
// else more evidence means more people. For merges, MORE records push the 20th-newest merge closer to
|
|
426
|
+
// today, which makes the span smaller, the window narrower, and the count SMALLER. So a merge date that
|
|
427
|
+
// is wrong in the "recent" direction is the dangerous one, and a future date is the sharp edge: it
|
|
428
|
+
// makes `span` negative, and clamping a negative number lands on the NARROW end. Twenty bad records
|
|
429
|
+
// would have taken the window from 180 days to 30 and printed "-111 day(s)" as the reason.
|
|
430
|
+
export function capacityWindow(mergeDates, today) {
|
|
431
|
+
const now = dayNumber(today);
|
|
432
|
+
if (now === null) return { days: CAPACITY_MAX_DAYS, basis: 'today is not a readable date' };
|
|
433
|
+
// A merge cannot have happened after today. One that says so is a wrong clock or a typo, and
|
|
434
|
+
// believing it would narrow the window.
|
|
435
|
+
const parsed = [...mergeDates].map(dayNumber).filter((n) => n !== null);
|
|
436
|
+
const days = parsed.filter((n) => n <= now).sort((a, b) => b - a);
|
|
437
|
+
const ignored = parsed.length - days.length;
|
|
438
|
+
if (days.length < CAPACITY_MERGES) {
|
|
439
|
+
// Say how many were thrown away. Without it, a window that suddenly went wide sends the reader
|
|
440
|
+
// hunting for missing records when the records are there and their dates are wrong.
|
|
441
|
+
const why = ignored ? ` (${ignored} dated after today ${ignored === 1 ? 'was' : 'were'} ignored)` : '';
|
|
442
|
+
return { days: CAPACITY_MAX_DAYS, basis: `only ${days.length} usable merged PR(s) recorded${why} — fewer than ${CAPACITY_MERGES}, so the window is the wide end` };
|
|
443
|
+
}
|
|
444
|
+
// `days` is filtered to `<= now` and sorted descending, so `span` can never be negative here.
|
|
445
|
+
const span = now - days[CAPACITY_MERGES - 1];
|
|
446
|
+
const bounded = Math.min(CAPACITY_MAX_DAYS, Math.max(CAPACITY_MIN_DAYS, span));
|
|
447
|
+
const bound = bounded !== span ? `, bounded from ${span}` : '';
|
|
448
|
+
return { days: bounded, basis: `the last ${CAPACITY_MERGES} merged PRs span ${span} day(s)${bound}` };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The distinct people with an event ON OR AFTER `from`. Pure, and it never reads a clock.
|
|
452
|
+
//
|
|
453
|
+
// THERE IS NO UPPER BOUND, deliberately. A date in the future is not a reason to forget a person: clock
|
|
454
|
+
// skew, a rebase, a hand-edited ledger and a machine in another timezone all produce one, and dropping
|
|
455
|
+
// it makes the count SMALLER — the one direction this file may never go. Keeping it can only add a
|
|
456
|
+
// person who is real, which Part 3 asks for in as many words.
|
|
457
|
+
//
|
|
458
|
+
// With `today`, a person whose EVERY event is dated after it is marked `future: true`, and a kind of
|
|
459
|
+
// evidence (`committed`, `approved`) a person has ONLY after it is listed in `later`. They are still
|
|
460
|
+
// counted. Only E74's `teamHint` reads the marks: a suggestion must be able to stop, and a date that never
|
|
461
|
+
// leaves the window would keep it on screen for good — per kind, because one mistyped approval beside a
|
|
462
|
+
// current commit is still a mistyped approval.
|
|
463
|
+
export function activeIn(events, from, today = null) {
|
|
464
|
+
const people = new Map();
|
|
465
|
+
for (const e of events) {
|
|
466
|
+
if (!e || !e.ts || e.ts < from) continue;
|
|
467
|
+
const key = personKey(e);
|
|
468
|
+
if (!key) continue;
|
|
469
|
+
const now = today === null || String(e.ts).slice(0, 10) <= today;
|
|
470
|
+
const seen = people.get(key);
|
|
471
|
+
if (!seen) people.set(key, { key, name: e.name || null, login: e.login || null, how: new Set([e.how]), nowHow: new Set(now ? [e.how] : []) });
|
|
472
|
+
else {
|
|
473
|
+
if (!seen.login && e.login) seen.login = e.login;
|
|
474
|
+
if (!seen.name && e.name) seen.name = e.name;
|
|
475
|
+
seen.how.add(e.how);
|
|
476
|
+
if (now) seen.nowHow.add(e.how);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const list = [...people.values()]
|
|
480
|
+
.map((p) => {
|
|
481
|
+
const later = [...p.how].filter((h) => !p.nowHow.has(h)).sort();
|
|
482
|
+
return { key: p.key, name: p.name, login: p.login, how: [...p.how].sort(),
|
|
483
|
+
...(!p.nowHow.size ? { future: true } : later.length ? { later } : {}) };
|
|
484
|
+
})
|
|
485
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
486
|
+
return { count: list.length, people: list, nameOnly: list.filter((p) => !p.login).length };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---- the answer ----------------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
// The live active count, in all three windows.
|
|
492
|
+
//
|
|
493
|
+
// `today` is INJECTED, never read from a clock here: the gate predicate's return is compared byte for
|
|
494
|
+
// byte by the golden test, and a number derived from the current date would move the snapshot every
|
|
495
|
+
// day. `runUsage` already takes `today` the same way.
|
|
496
|
+
//
|
|
497
|
+
// Shape: { today, unknown: [], capacity: { days, basis, from, active, people, nameOnly }, expertise: {…},
|
|
498
|
+
// stale: {…} }. When `unknown` is not empty EVERY `active` is null — never a partial number.
|
|
499
|
+
// The windows themselves are still reported, because "we could not count, and here is the window we
|
|
500
|
+
// would have counted over" is a more useful thing to print than silence.
|
|
501
|
+
export function activePeople(root, { today = todayString(), aliases = new Map() } = {}) {
|
|
502
|
+
// NOTHING BELOW MAY THROW OUT OF HERE. `gate sync`, `gate status`, `gate open` and `gate review`
|
|
503
|
+
// call this with no guard of their own, so an unexpected filesystem error would stop a gate that
|
|
504
|
+
// worked yesterday. Every known case is already turned into an `unknown` line; this catches the ones
|
|
505
|
+
// nobody has thought of yet, and turns them into the same answer rather than an exception.
|
|
506
|
+
let events = [];
|
|
507
|
+
let merges = [];
|
|
508
|
+
let unknown = [];
|
|
509
|
+
try {
|
|
510
|
+
({ events, merges, unknown } = peopleEvidence(root, { today, aliases }));
|
|
511
|
+
} catch (e) {
|
|
512
|
+
unknown = [`the people could not be read: ${e.code || e.message}`];
|
|
513
|
+
}
|
|
514
|
+
if (dayNumber(today) === null) unknown.push(`'${today}' is not a readable date`);
|
|
515
|
+
const cap = capacityWindow(merges, today);
|
|
516
|
+
const window = (days, extra = {}) => {
|
|
517
|
+
const from = daysBefore(today, days);
|
|
518
|
+
// The ONE rule of this file: unknown never becomes a small number.
|
|
519
|
+
if (unknown.length || from === null) return { days, from, active: null, people: [], nameOnly: 0, ...extra };
|
|
520
|
+
const got = activeIn(events, from, today);
|
|
521
|
+
return { days, from, active: got.count, people: got.people, nameOnly: got.nameOnly, ...extra };
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
today,
|
|
525
|
+
unknown,
|
|
526
|
+
capacity: window(cap.days, { basis: cap.basis }),
|
|
527
|
+
expertise: window(EXPERTISE_DAYS),
|
|
528
|
+
stale: window(STALE_DAYS),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// How many of the people counted APPROVED something in the window (E73) — evidence that approvals can be
|
|
533
|
+
// given in this Product. 0 when the count is unknown or carries no list: the caller reads that as "no
|
|
534
|
+
// evidence", which can only make a warning speak, never hide one.
|
|
535
|
+
export const approverCount = (counted) => (Array.isArray(counted?.capacity?.people)
|
|
536
|
+
? counted.capacity.people.filter((p) => Array.isArray(p?.how) && p.how.includes('approved')).length : 0);
|
|
537
|
+
|
|
538
|
+
// SUGGEST TEAM MODE (E74) — read only in solo mode, and only ever a suggestion: nothing is switched and
|
|
539
|
+
// nothing is written. Switching needs a person to run `yad mode team`, as E10 set.
|
|
540
|
+
//
|
|
541
|
+
// ONE DIRECTION ONLY (the user's decision, 2026-09-22). The count is wrong both ways for ordinary teams
|
|
542
|
+
// (E72 case a, E73), and a wrong "go solo" that a team follows removes every approval quietly. So the
|
|
543
|
+
// solo direction stays with E73's `may not be met:` line, which already names `yad mode solo --reason`
|
|
544
|
+
// under a team gate. This one speaks only in solo mode, where it cannot repeat that line (E73 prints
|
|
545
|
+
// nothing in solo mode). A wrong "go team" asks for approvals nobody can give — loud, and easy to undo.
|
|
546
|
+
//
|
|
547
|
+
// "WHEN THE COUNT CHANGES" means the count disagrees with the mode that is set. Nothing is stored, so
|
|
548
|
+
// there is no file shape to move and nothing for CI to write on a verified Product. The line repeats
|
|
549
|
+
// until someone switches or the evidence leaves the window.
|
|
550
|
+
//
|
|
551
|
+
// THE EVIDENCE. A solo developer who commits with a work address AND through GitHub's web editor reads
|
|
552
|
+
// as two people: one git name, one login (E73's "one person" case), and must not be told "team". So the
|
|
553
|
+
// people are read as E73's SMALLEST POSSIBLE TEAM — the larger of the logins and the names not matched to
|
|
554
|
+
// a login, since each name may be a second row for one of the logins. One login and one name is 1.
|
|
555
|
+
// Either of these speaks:
|
|
556
|
+
// smallest that smallest team is 2 or more in the capacity window. Two logins are two accounts, and
|
|
557
|
+
// two names are two git identities; either may still be one person (two accounts, two
|
|
558
|
+
// spellings of one name), so the line says "may". A team whose commits all use work
|
|
559
|
+
// addresses reads as names only, and was silent until the user added names (2026-09-22).
|
|
560
|
+
// approval a person WITH A PLATFORM LOGIN approved in the window, and more than one person is
|
|
561
|
+
// counted. On GitHub an author cannot approve their own work, so such an approval shows a
|
|
562
|
+
// second person. An approval with no login (a hand-written one on a local ledger, or an
|
|
563
|
+
// engineer-review record) proves nothing here: an author can approve their own work on a
|
|
564
|
+
// local ledger (E62 decision h), and with a noreply commit address their commits are a login
|
|
565
|
+
// and their approval a name, which never join. That approval still counts as a name under
|
|
566
|
+
// `smallest`. `active >= 2` is asked because a roster alias can join an older approval to
|
|
567
|
+
// the author's own login, which is one person.
|
|
568
|
+
//
|
|
569
|
+
// A person whose EVERY record is dated after today (`future`, from `activeIn`) is left out here, and an
|
|
570
|
+
// approval a person has only after today (`later`) is not proof — both still count for the cap — because
|
|
571
|
+
// a date that never leaves the window would make the line permanent.
|
|
572
|
+
//
|
|
573
|
+
// Known limits, each a false "may be a team", the loud direction: two accounts; two spellings of one
|
|
574
|
+
// name; a robot committing under a plain name, or approving through an auto-approve workflow (GitHub
|
|
575
|
+
// reports a bot's login without `[bot]`); a GitLab author approving their own MR where the project
|
|
576
|
+
// allows it; a connected-repo committer who cannot approve; someone who left inside the window.
|
|
577
|
+
//
|
|
578
|
+
// An unknown count gives no suggestion (E71: an unknown is never a number); `known: false` carries a
|
|
579
|
+
// line saying so, for the caller to print or not. Returns { known, line }: `line` is null when there is
|
|
580
|
+
// nothing to suggest.
|
|
581
|
+
export const TEAM_CMD = 'yad mode team';
|
|
582
|
+
export function teamHint(counted) {
|
|
583
|
+
const cap = counted?.capacity;
|
|
584
|
+
if (!cap || !Number.isInteger(cap.active) || cap.active < 0) {
|
|
585
|
+
const first = Array.isArray(counted?.unknown) && counted.unknown[0] ? ` (${counted.unknown[0]})` : '';
|
|
586
|
+
return { known: false, line: `the people could not be counted${first}, so no switch to team mode is suggested` };
|
|
587
|
+
}
|
|
588
|
+
const people = (Array.isArray(cap.people) ? cap.people : []).filter((p) => p && !p.future);
|
|
589
|
+
const logins = people.filter((p) => p.login).length;
|
|
590
|
+
const names = people.length - logins;
|
|
591
|
+
const approvers = people.filter((p) => p.login && Array.isArray(p.how) && p.how.includes('approved')
|
|
592
|
+
&& !(Array.isArray(p.later) && p.later.includes('approved'))).length;
|
|
593
|
+
const why = [];
|
|
594
|
+
// The larger of the two is the smallest team, so it is the one named.
|
|
595
|
+
if (smallestTeam(logins, names) >= 2) {
|
|
596
|
+
why.push(logins >= names
|
|
597
|
+
? `${logins} different platform logins committed or approved`
|
|
598
|
+
: `${names} different names not matched to a platform login committed or approved`);
|
|
599
|
+
}
|
|
600
|
+
if (approvers >= 1 && people.length >= 2) why.push(`${approvers === 1 ? 'someone' : `${approvers} people`} approved a review`);
|
|
601
|
+
if (!why.length) return { known: true, line: null };
|
|
602
|
+
return {
|
|
603
|
+
known: true,
|
|
604
|
+
line: `solo mode is on, but ${why.join(', and ')} in the last ${cap.days} days, so more than one person may work on this Product. If so, \`${TEAM_CMD}\` makes each review gate ask for approvals, which solo mode waives`,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The ONE wiring every surface uses (`yad mode`, `gate status`, `yad next`, `yad doctor`): null outside
|
|
609
|
+
// solo mode, where no count is read; otherwise `teamHint` over the count the caller already read, or a
|
|
610
|
+
// fresh one with the roster aliases (without them an older roster-shaped approval reads as a second
|
|
611
|
+
// person). `solo` is the caller's `isSolo(hub)` — it lives in cli/gate.mjs, which imports this file.
|
|
612
|
+
export function soloTeamHint(root, hub, { solo, headCount = null, today = null } = {}) {
|
|
613
|
+
if (!solo) return null;
|
|
614
|
+
return teamHint(headCount || activePeople(root, { today: today || undefined, aliases: legacyLogins(hub) }));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// The ONE way a suggestion is printed on a text surface: a known one as a warning line (`! …`), an
|
|
618
|
+
// unknown one dimmed — and only where the caller asks for it (`unknown`), since `gate status` already
|
|
619
|
+
// says NOT COUNTED and `yad next` stays quiet about it (the user's choice, 2026-09-22).
|
|
620
|
+
export function printTeamHint(hint, { unknown = false } = {}) {
|
|
621
|
+
if (!hint?.line) return;
|
|
622
|
+
if (hint.known) warn(hint.line);
|
|
623
|
+
else if (unknown) info(c.dim(hint.line));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// The capacity count as ONE human-readable line, defined here beside the rule for the same reason
|
|
627
|
+
// `gateRuleSum` is defined beside `gateRuleFor`: several surfaces print it, and several copies of the
|
|
628
|
+
// wording would eventually disagree about what the number means.
|
|
629
|
+
//
|
|
630
|
+
// It always says what the number DOES to a gate (E72): a known count caps the count each gate ASKS for
|
|
631
|
+
// at `active − 1`, which is reported and not enforced until E108; an unknown one gives no cap.
|
|
632
|
+
export function activeSum(counted) {
|
|
633
|
+
const cap = counted?.capacity;
|
|
634
|
+
// `== null` on purpose, so a missing count and an explicitly null one take the SAME branch. With
|
|
635
|
+
// `=== null`, an `undefined` slipped through the counted branch and printed a disclaimer for a number
|
|
636
|
+
// that was not there.
|
|
637
|
+
if (!cap || cap.active == null) {
|
|
638
|
+
const [first, ...rest] = counted?.unknown || [];
|
|
639
|
+
const more = rest.length ? ` (and ${rest.length} more)` : '';
|
|
640
|
+
return `active people: NOT COUNTED — ${first || 'a source could not be read'}${more} — no cap can be shown, and only the base holds each gate`;
|
|
641
|
+
}
|
|
642
|
+
// THE CONSEQUENCE TRAVELS WITH THE NUMBER, and that is not a style choice. `activeBasis` is printed
|
|
643
|
+
// through `note()`, which writes to stderr, while this goes to stdout through `log()` — so anything
|
|
644
|
+
// that captures or pipes stdout alone (CI logs, a redirect) would keep the number and lose the
|
|
645
|
+
// sentence saying what it does. `capLimit` is the cap's one copy of the arithmetic (cli/epic-state.mjs).
|
|
646
|
+
const limit = capLimit(cap.active);
|
|
647
|
+
return `active people: ${cap.active} in the last ${cap.days} days — caps each gate's count at ${limit} approver${limit === 1 ? '' : 's'} (${capSeat(cap.active)}); reported, only the base is enforced`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Why that window is the length it is. The second line under `activeSum`, and the only part that may be
|
|
651
|
+
// lost without misleading anyone — the number above carries its own disclaimer.
|
|
652
|
+
export const activeBasis = (counted) => (counted?.capacity?.active == null
|
|
653
|
+
? 'an unreadable source is never counted as few people — no cap is computed from an unknown'
|
|
654
|
+
: String(counted?.capacity?.basis || ''));
|