yadflow 4.4.0 → 4.5.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 +37 -0
- package/README.md +2 -1
- package/bin/commands.mjs +2 -0
- package/bin/yad.mjs +58 -3
- package/cli/claims.mjs +16 -13
- package/cli/docs.mjs +21 -5
- package/cli/doctor.mjs +50 -0
- package/cli/lib.mjs +5 -2
- package/cli/manifest.mjs +3 -0
- package/cli/members.mjs +822 -0
- package/cli/people.mjs +13 -3
- package/cli/platform.mjs +1 -1
- package/cli/pr-list.mjs +125 -0
- package/cli/protection.mjs +4 -4
- package/cli/standup.mjs +709 -0
- package/cli/usage.mjs +18 -11
- package/cli/workspace.mjs +14 -5
- package/package.json +5 -3
- package/skills/sdlc/module-help.csv +2 -2
- package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
- package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
- package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
- package/skills/yad-docs-overview/SKILL.md +1 -1
- package/skills/yad-docs-sync/SKILL.md +11 -10
- package/skills/yad-docs-sync/references/staleness.md +11 -13
- package/skills/yad-product-bridge/SKILL.md +4 -0
- package/skills/yad-product-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +18 -0
- package/skills/yad-reconcile/SKILL.md +2 -2
- package/skills/yad-status/SKILL.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
## [4.5.1](https://github.com/abdelrahmannasr/yadflow/compare/v4.5.0...v4.5.1) (2026-10-06)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **docs:** a failed site build fails the Pages deploy ([32dd88f](https://github.com/abdelrahmannasr/yadflow/commit/32dd88f69dc1140977e1a2c126f94078aaa5f2ea))
|
|
7
|
+
* **docs:** doctor judges only the failure handling, on the wired platform (review 2) ([987a753](https://github.com/abdelrahmannasr/yadflow/commit/987a7532951f61aec530550e2a9cab9aa4e0795f))
|
|
8
|
+
* **docs:** escape the apostrophe that broke the overview site build ([fe7dcc8](https://github.com/abdelrahmannasr/yadflow/commit/fe7dcc81b2772dc577659e4afd2b85bec2bff81a))
|
|
9
|
+
* **docs:** name every failed site, quote GitLab lines as YAML, doctor warns on an old wired workflow (review 1) ([d525e87](https://github.com/abdelrahmannasr/yadflow/commit/d525e87961413a10ba16122dfc3811ac3d5361a7))
|
|
10
|
+
* **gitlab:** check the git-lfs download against its SHA-256 ([#327](https://github.com/abdelrahmannasr/yadflow/issues/327) review 1) ([01763a2](https://github.com/abdelrahmannasr/yadflow/commit/01763a25fdc47eea298592f3ab453341c2c14196))
|
|
11
|
+
* **gitlab:** install git-lfs in yad-gate-sync when the Product uses LFS ([#327](https://github.com/abdelrahmannasr/yadflow/issues/327)) ([fcb55fb](https://github.com/abdelrahmannasr/yadflow/commit/fcb55fb6a3c2d96be067cb7c027644424784181d))
|
|
12
|
+
* **windows:** leave the npm launcher name unquoted so npx finds itself ([#326](https://github.com/abdelrahmannasr/yadflow/issues/326)) ([b042848](https://github.com/abdelrahmannasr/yadflow/commit/b0428484f371a03cc5e722158481faf51004b519))
|
|
13
|
+
|
|
14
|
+
# [4.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.4.0...v4.5.0) (2026-10-06)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **member:** E131 review 1 — links, twins, proof and pushes ([1179a97](https://github.com/abdelrahmannasr/yadflow/commit/1179a9731e4ff9f8d6b80f5aed82d5809bde5ed2))
|
|
20
|
+
* **member:** E131 review 2 — gate-live trust, account ids, clean-ups ([74d41f6](https://github.com/abdelrahmannasr/yadflow/commit/74d41f65a585fdf06639be87688f69353eb61916))
|
|
21
|
+
* **member:** E131 review 3 — trust only files the gate judged ([2f9af14](https://github.com/abdelrahmannasr/yadflow/commit/2f9af141e524ca7daacbce8c2799f56c72c6d75e))
|
|
22
|
+
* **member:** E131 review 4 — trust is read from origin's default branch ([43af9c4](https://github.com/abdelrahmannasr/yadflow/commit/43af9c4565c3bbfae7042d7765df62150fa64447))
|
|
23
|
+
* **member:** E131 review 5 — every change re-proves every email ([041937c](https://github.com/abdelrahmannasr/yadflow/commit/041937c936f5d9f29683ef6f9d4cc6fb93ad83bd))
|
|
24
|
+
* **member:** E131 review 6 — re-runs, origin/HEAD, partial lists ([e9bad00](https://github.com/abdelrahmannasr/yadflow/commit/e9bad002eff766705d193b1515baa3d39ae0c360))
|
|
25
|
+
* **standup:** E132 review 1 — gate rule, malformed ledgers, terminal safety ([a0e7d47](https://github.com/abdelrahmannasr/yadflow/commit/a0e7d4750e7b485582cded4b55dfcc212013a101))
|
|
26
|
+
* **standup:** E132 review 2 — missed twins of round 1 ([ba1ce61](https://github.com/abdelrahmannasr/yadflow/commit/ba1ce61b961decbea46298b714fd33f811fb264a))
|
|
27
|
+
* **standup:** E132 review 3 — paths, disputed accounts, left members ([add86cc](https://github.com/abdelrahmannasr/yadflow/commit/add86cca821ab29afa79838734a3b83621dd2330))
|
|
28
|
+
* **standup:** E132 review 4 — Windows paths, a name is plain text ([435225f](https://github.com/abdelrahmannasr/yadflow/commit/435225f48a21fe8a850ba65c0c7f1315df6bbb1e))
|
|
29
|
+
* **standup:** E132 review 5 — path names in square brackets ([7ac3cb2](https://github.com/abdelrahmannasr/yadflow/commit/7ac3cb265c309f0e707e6dae32076796a5e2c240))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
* **join:** E131 join ends with yad member add ([3295429](https://github.com/abdelrahmannasr/yadflow/commit/3295429092b549707a03eecacddc50d15d1df77a))
|
|
35
|
+
* **member:** E131 team member files, proven by the platform ([3e60bb6](https://github.com/abdelrahmannasr/yadflow/commit/3e60bb62bb2edb467be4d5f8562b9fe1bac6ca0a))
|
|
36
|
+
* **standup:** yad standup — a daily status per team member (E132) ([00f32ce](https://github.com/abdelrahmannasr/yadflow/commit/00f32ce742166e8b82e796b3eef85b379edd4cee))
|
|
37
|
+
|
|
1
38
|
# [4.4.0](https://github.com/abdelrahmannasr/yadflow/compare/v4.3.0...v4.4.0) (2026-10-03)
|
|
2
39
|
|
|
3
40
|
|
package/README.md
CHANGED
|
@@ -144,7 +144,7 @@ unless CI runs it), or `yad gate advance` with no platform, is the human decisio
|
|
|
144
144
|
| --- | --- | --- |
|
|
145
145
|
| Existing repositories | `yad init`, in the folder that holds them | Run the `yad-backfill` skill first. It records what already exists. |
|
|
146
146
|
| One existing repository with all the code | `yad setup --brownfield --monorepo`, inside it | The same: run `yad-backfill` first. |
|
|
147
|
-
| A team that already uses Yadflow | `yad join <Product clone URL>` | It clones the Product and every repository it lists
|
|
147
|
+
| A team that already uses Yadflow | `yad join <Product clone URL>` | It clones the Product and every repository it lists and sets up this machine. Last, it records you as a team member (`yad member add`): your accounts and verified emails, on a branch of its own with its own pull request. |
|
|
148
148
|
|
|
149
149
|
**Small changes.** Not every change needs the full route:
|
|
150
150
|
|
|
@@ -256,6 +256,7 @@ The record is worth trusting because people approve what goes into it.
|
|
|
256
256
|
- the Shape and Build steps with review gates
|
|
257
257
|
- linked changes and `yad thread`
|
|
258
258
|
- `yad history`, `yad next` and `yad usage`
|
|
259
|
+
- `yad standup`, a daily status for each team member — what they did since the last working day, what they are on now, and what is waiting — rebuilt live from git, the ledgers and the platform, facts only
|
|
259
260
|
- `yad detect`, which lists the skills, agents, MCP servers and plugins installed for each AI agent
|
|
260
261
|
- `yad toolbox list`, which shows the external tools yadflow can use, which ones you have, and what happens without each; `yad toolbox add` / `remove` record the tools your team uses, and `yad toolbox check` lists the ones not ready on your machine (`yad setup`, `check`, `update` and `join` print the same list, and never install anything); each skill that uses a tool reads the same answer and says what it does without it
|
|
261
262
|
- `yad skill recommend`, which lists hand-picked skills from the recommended skill collections for each step, why each fits, and the command to bind one; it binds nothing, and every pick was checked against its source
|
package/bin/commands.mjs
CHANGED
|
@@ -29,6 +29,8 @@ export { runCapture } from '../cli/capture.mjs';
|
|
|
29
29
|
export { runFold } from '../cli/fold.mjs';
|
|
30
30
|
export { runClaims } from '../cli/claims.mjs';
|
|
31
31
|
export { runAssign, runUnassign, runOwners } from '../cli/owners.mjs';
|
|
32
|
+
export { runMemberAdd, runMemberList, runMemberRemove } from '../cli/members.mjs';
|
|
33
|
+
export { runStandup, foreignFlags as standupForeignFlags } from '../cli/standup.mjs';
|
|
32
34
|
export { runIndex } from '../cli/index-command.mjs';
|
|
33
35
|
export { runHistory, HISTORY_FLAGS } from '../cli/history.mjs';
|
|
34
36
|
export { runRepo } from '../cli/repo.mjs';
|
package/bin/yad.mjs
CHANGED
|
@@ -21,7 +21,8 @@ ${c.bold('Start a workspace')} ${c.dim('(the folder holding product/ and the cod
|
|
|
21
21
|
Clone the Product into [folder]/product/ (default: the URL's last name) and
|
|
22
22
|
every repo it registers; a failed repo clone is reported, never fatal. Then
|
|
23
23
|
only per-machine steps: git-ignored skill copies + the git pre-commit hook.
|
|
24
|
-
|
|
24
|
+
Last, \`yad member add\` (below): your member file, on a branch with its own
|
|
25
|
+
PR/MR — the only shared change join makes. A problem there never fails join
|
|
25
26
|
|
|
26
27
|
${c.bold('Setup & maintenance')}
|
|
27
28
|
yad setup Guided first-run setup (profile interview, install, connect & wire repos,
|
|
@@ -68,6 +69,31 @@ ${c.bold('Setup & maintenance')}
|
|
|
68
69
|
installs in this clone refuses a person's hand commit to the ledger.
|
|
69
70
|
YAD_HOOK_DISABLE=1 skips.
|
|
70
71
|
|
|
72
|
+
${c.bold('Team members')} ${c.dim('(identity only — no roles, no reviewers, no allowlist)')}
|
|
73
|
+
yad member add [--no-push] Prove who you are and write .sdlc/members/<platform>-<login>.json:
|
|
74
|
+
your account on the Product's platform (logs you in if needed),
|
|
75
|
+
your other GitHub/GitLab accounts and git emails that the platform
|
|
76
|
+
says are VERIFIED (stored hashed). One commit per email on
|
|
77
|
+
yad/member/<login>, pushed, with a PR/MR. Never your checkout
|
|
78
|
+
yad member list [--json] Every member: active (committed or approved within the TTL,
|
|
79
|
+
default 90 days), idle (quiet, still has access), left (the
|
|
80
|
+
platform says no access — off the team list) or unknown
|
|
81
|
+
yad member remove [<login>] [--reason <why>]
|
|
82
|
+
Delete a member file — your own, or anyone's with --reason
|
|
83
|
+
|
|
84
|
+
${c.bold('Daily standup')} ${c.dim('(derived live, read-only, facts only — no score, no ranking)')}
|
|
85
|
+
yad standup Each member on the team list: done since the last standup
|
|
86
|
+
(commits, approvals, comments, closed steps, ships, merged
|
|
87
|
+
PRs/MRs), working on now (owned steps, open claims, drafts, open
|
|
88
|
+
PRs/MRs) and waiting (gates on their steps, their next move, their
|
|
89
|
+
PRs/MRs with failing checks or no approving review). Then the
|
|
90
|
+
people seen who are not on the list, and who has left.
|
|
91
|
+
Window: from midnight of the previous working day (Monday reads
|
|
92
|
+
back to Friday). Asks the platform about the Product and every
|
|
93
|
+
registered repo; one it cannot read says "platform not read"
|
|
94
|
+
flags: --since <YYYY-MM-DD>|<N>h|<N>d, --tz <zone> (default UTC),
|
|
95
|
+
--member <login> | --me, --format md|html, --out <file>, --json
|
|
96
|
+
|
|
71
97
|
${c.bold('Team usage (EM adoption & behavior report)')}
|
|
72
98
|
yad usage Build a per-member report (HTML) from git + the SDLC ledgers
|
|
73
99
|
(derived, read-only — writes no tracked state)
|
|
@@ -349,7 +375,9 @@ ${c.bold('Environment')}
|
|
|
349
375
|
YAD_NO_REPORT=1 Never offer to file a bug report after a failure
|
|
350
376
|
YAD_PLATFORM_LOGIN=0 Name a record's author by git user.name; never ask gh/glab who is logged in`;
|
|
351
377
|
|
|
352
|
-
const
|
|
378
|
+
const STANDUP_USAGE = 'usage: yad standup [--since <YYYY-MM-DD>|<N>h|<N>d] [--tz <zone>] [--member <login>|--me] [--format md|html] [--out <file>] [--json]';
|
|
379
|
+
|
|
380
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--profile', '--parent', '--inherits', '--to', '--retro-ship', '--merge-commit', '--path', '--ide-targets', '--theme', '--thread', '--by', '--count', '--engagement', '--keep', '--role', '--fallback', '--detect', '--install', '--source', '--tz']);
|
|
353
381
|
|
|
354
382
|
function parseArgs(argv) {
|
|
355
383
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -360,6 +388,7 @@ function parseArgs(argv) {
|
|
|
360
388
|
else if (a === '--force') o.force = true;
|
|
361
389
|
else if (a === '--contract-change') o.contractChange = true;
|
|
362
390
|
else if (a === '--manual') o.manual = true;
|
|
391
|
+
else if (a === '--me') o.me = true;
|
|
363
392
|
else if (a === '--no-push') o.noPush = true;
|
|
364
393
|
else if (a === '--no-fetch') o.noFetch = true;
|
|
365
394
|
else if (a === '--push') o.push = true;
|
|
@@ -447,6 +476,7 @@ const ACTIONS = {
|
|
|
447
476
|
reconcile: { known: ['check', 'refresh', 'wire'] },
|
|
448
477
|
hook: { known: ['ledger-guard'] },
|
|
449
478
|
toolbox: { known: ['list', 'check', 'add', 'remove'], default: 'list' },
|
|
479
|
+
member: { known: ['add', 'list', 'remove'], default: 'list' },
|
|
450
480
|
};
|
|
451
481
|
// E80 — how each command finds its folder. A PRODUCT command works on the Product; a REPO command works
|
|
452
482
|
// on the code repo it runs in and reads the Product beside it. The rest take their folder as given:
|
|
@@ -455,7 +485,7 @@ const PRODUCT_CMDS = new Set([
|
|
|
455
485
|
'check', 'update', 'doctor', 'migrate', 'usage', 'sync-status', 'epic', 'foundation', 'skill', 'next',
|
|
456
486
|
'skip', 'unskip', 'defer', 'undefer', 'dial', 'mode', 'kill', 'unkill', 'unblock', 'gate', 'checkpoint',
|
|
457
487
|
'capture', 'claims', 'assign', 'unassign', 'owners', 'fold', 'tidy', 'index', 'history', 'repo',
|
|
458
|
-
'risk-map', 'codeowners', 'docs', 'thread', 'reconcile',
|
|
488
|
+
'risk-map', 'codeowners', 'docs', 'thread', 'reconcile', 'member', 'standup',
|
|
459
489
|
]);
|
|
460
490
|
const REPO_CMDS = new Set(['commit', 'open-pr', 'ship', 'review']);
|
|
461
491
|
// The Product commands that WRITE its files without first checking it is one. Run from a code repo they
|
|
@@ -468,6 +498,7 @@ function writesProduct(cmd, o) {
|
|
|
468
498
|
if (cmd === 'epic' || cmd === 'foundation') return action === 'new';
|
|
469
499
|
if (cmd === 'skill') return action === 'bind' || action === 'unbind';
|
|
470
500
|
if (cmd === 'dial') return o.to !== undefined;
|
|
501
|
+
if (cmd === 'member') return o._[1] === 'add' || o._[1] === 'remove';
|
|
471
502
|
// `--wire` writes a CI workflow; `--refresh` only rebuilds sites the folder already holds.
|
|
472
503
|
if (cmd === 'docs') return action === 'sync' && !!o.wire;
|
|
473
504
|
return false;
|
|
@@ -887,6 +918,30 @@ async function main() {
|
|
|
887
918
|
if (o.hook) process.exitCode = 0;
|
|
888
919
|
break;
|
|
889
920
|
}
|
|
921
|
+
case 'member': {
|
|
922
|
+
// E131. One file per person, identity only.
|
|
923
|
+
const [, action = 'list', who, extra] = o._;
|
|
924
|
+
const usage = 'usage: yad member add [--no-push] | yad member list [--json] | yad member remove [<login>] [--reason "<why>"]';
|
|
925
|
+
if (!['add', 'list', 'remove'].includes(action)) { refuse(`unknown member action: ${action} (add, list, remove)`, usage); break; }
|
|
926
|
+
if ((action !== 'remove' && who) || extra) { refuse(`unexpected word: ${extra || who}`, usage); break; }
|
|
927
|
+
if (noProduct()) break;
|
|
928
|
+
if (action === 'add') result = await commands.runMemberAdd(o.dir, { noPush: !!o.noPush, today });
|
|
929
|
+
else if (action === 'list') result = await commands.runMemberList(o.dir, { today });
|
|
930
|
+
else result = await commands.runMemberRemove(o.dir, { login: who ?? null, reason: o.reason ?? null });
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
case 'standup': {
|
|
934
|
+
// E132. Read-only; every flag that is not standup's own is refused, as history refuses them.
|
|
935
|
+
const foreign = commands.standupForeignFlags(process.argv.slice(2));
|
|
936
|
+
if (foreign.length) { refuse(`yad standup does not take ${foreign.join(', ')}`, STANDUP_USAGE); break; }
|
|
937
|
+
if (o._[1]) { refuse(`yad standup takes no words (got: ${o._[1]})`, STANDUP_USAGE); break; }
|
|
938
|
+
if (noProduct()) break;
|
|
939
|
+
result = await commands.runStandup(o.dir, {
|
|
940
|
+
since: o.since ?? null, tz: o.tz ?? 'UTC', member: o.member ?? null, me: !!o.me,
|
|
941
|
+
format: o.format ?? null, out: o.out ?? null, json: !!o.json,
|
|
942
|
+
});
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
890
945
|
case 'claims': {
|
|
891
946
|
// E46. Read from the capture branches; nothing is recorded here.
|
|
892
947
|
const [, epic, extra] = o._;
|
package/cli/claims.mjs
CHANGED
|
@@ -135,6 +135,21 @@ export function claimWarnings(root, changed, { env = process.env, now = Date.now
|
|
|
135
135
|
}
|
|
136
136
|
export const warningText = (hits, now) => `yad claims: ${hits.length === 1 ? 'this file is' : 'these files are'} also being edited by someone else — advice, not a lock; talk to them before you go further:\n${hits.map((c) => ` • ${claimLine(c, now)}`).join('\n')}`;
|
|
137
137
|
|
|
138
|
+
// Fetch everyone's capture branches and the default branch: 'done' | 'failed' | 'local' (no origin). One
|
|
139
|
+
// fetch, 30 seconds at most. `yad claims` and `yad standup` (E132) both read what it brings.
|
|
140
|
+
export function fetchCaptures(root, env = process.env) {
|
|
141
|
+
const git = gitIn(root, env);
|
|
142
|
+
if (!git(['remote', 'get-url', 'origin']).ok) return 'local';
|
|
143
|
+
const opts = { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...env, ...pushEnv(env) } };
|
|
144
|
+
const r = spawnSync('git', fetchAllArgs({ prune: true }), opts);
|
|
145
|
+
const fetched = r.status === 0 ? 'done' : 'failed';
|
|
146
|
+
// The default branch, for the "landed" rule — on its own: a name guessed wrong (no Product default, no
|
|
147
|
+
// origin/HEAD) must not stop the capture branches from arriving. Its failure changes nothing.
|
|
148
|
+
const def = defaultRef(root, git).name;
|
|
149
|
+
if (fetched === 'done') spawnSync('git', ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', 'fetch', '--quiet', '--no-tags', 'origin', `+refs/heads/${def}:refs/remotes/origin/${def}`], opts);
|
|
150
|
+
return fetched;
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
// `yad claims [<epic>] [--no-fetch]`. Returns a plain object (E1 makes it the `--json` answer).
|
|
139
154
|
export async function runClaims(root, { epic = null, noFetch = false, env = process.env, now = Date.now() } = {}) {
|
|
140
155
|
const refuse = (msg, hint) => { fail(msg); if (hint) hand(hint); process.exitCode = 1; };
|
|
@@ -142,19 +157,7 @@ export async function runClaims(root, { epic = null, noFetch = false, env = proc
|
|
|
142
157
|
if (!git(['rev-parse', '--show-toplevel']).ok) return refuse('not a git repository — there are no capture branches to read');
|
|
143
158
|
if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/hub.json or product.json here)', 'run it from the Product root, or pass --dir');
|
|
144
159
|
if (epic && !/^EP-[a-z0-9-]+$/.test(epic)) return refuse(`not an epic id: ${epic}`);
|
|
145
|
-
|
|
146
|
-
if (!noFetch) {
|
|
147
|
-
if (!git(['remote', 'get-url', 'origin']).ok) fetched = 'local';
|
|
148
|
-
else {
|
|
149
|
-
const opts = { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...env, ...pushEnv(env) } };
|
|
150
|
-
const r = spawnSync('git', fetchAllArgs({ prune: true }), opts);
|
|
151
|
-
fetched = r.status === 0 ? 'done' : 'failed';
|
|
152
|
-
// The default branch, for the "landed" rule — on its own: a name guessed wrong (no Product default, no
|
|
153
|
-
// origin/HEAD) must not stop the capture branches from arriving. Its failure changes nothing.
|
|
154
|
-
const def = defaultRef(root, git).name;
|
|
155
|
-
if (fetched === 'done') spawnSync('git', ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', 'fetch', '--quiet', '--no-tags', 'origin', `+refs/heads/${def}:refs/remotes/origin/${def}`], opts);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
160
|
+
const fetched = noFetch ? 'skipped' : fetchCaptures(root, env);
|
|
158
161
|
if (fetched === 'local') info('no remote named origin — only your own captures can be read here');
|
|
159
162
|
if (fetched === 'failed') warn('could not fetch the capture branches — showing what was last fetched, which may be old');
|
|
160
163
|
const { claims, defaultBranch } = readClaims(root, { env, now, epics: epic ? new Set([epic]) : null });
|
package/cli/docs.mjs
CHANGED
|
@@ -144,12 +144,28 @@ export function docsStale(manifest, { artifactHash, repoHeads = {}, shellVersion
|
|
|
144
144
|
// mounts under `app/`, and every per-epic site nests under `epics/<id>/` — matching siteBasePath
|
|
145
145
|
// (overview at `<base>/app/`, epics at `<base>/epics/EP-<slug>/`). A concurrency group prevents the
|
|
146
146
|
// deploy from retriggering. The shared shell script keeps the two platforms byte-for-byte aligned.
|
|
147
|
+
// A site that fails to build fails the run: CI runs the script under `set -e`, which does not stop on a
|
|
148
|
+
// failure inside an `&&` chain, so a failed site used to be silently left out while the deploy still went
|
|
149
|
+
// green — yadflow's own site lost its root page that way for days. Each chain ends in `|| failed=…`, so
|
|
150
|
+
// every site is still tried (as `yad docs build` does) and the last line fails the run, naming them all.
|
|
151
|
+
// A red run uploads nothing, so the last good deployment stays live.
|
|
152
|
+
// A YAML single-quoted scalar: a quote inside is written twice, and nothing else is escaped (a backslash
|
|
153
|
+
// is literal). The shell's '\'' is not YAML — it would make GitLab reject the whole pipeline.
|
|
154
|
+
export const yamlQuoted = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
155
|
+
const PAGES_FAIL_LINE = 'yad-docs: these sites did not build, so nothing is deployed:';
|
|
147
156
|
const BUILD_PUBLIC = [
|
|
148
157
|
'mkdir -p public',
|
|
149
|
-
'
|
|
150
|
-
'if [ -d docs/
|
|
151
|
-
'
|
|
158
|
+
'failed=',
|
|
159
|
+
'if [ -d docs/sdlc-site ]; then (cd docs/sdlc-site && npm ci && npm run build) && mkdir -p public/app && cp -r docs/sdlc-site/dist/. public/app/ && cp docs/sdlc-site/public/report.html public/index.html && cp docs/sdlc-site/public/report.html public/report.html || failed="$failed docs/sdlc-site"; fi',
|
|
160
|
+
'if [ -d docs/tutorial-site ]; then (cd docs/tutorial-site && npm ci && npm run build) && mkdir -p public/tutorial && cp -r docs/tutorial-site/dist/. public/tutorial/ || failed="$failed docs/tutorial-site"; fi',
|
|
161
|
+
'for d in epics/*/docs-site; do [ -d "$d" ] || continue; id=$(basename "$(dirname "$d")"); (cd "$d" && npm ci && npm run build) && mkdir -p "public/epics/$id" && cp -r "$d/dist/." "public/epics/$id/" || failed="$failed $d"; done',
|
|
162
|
+
`if [ -n "$failed" ]; then echo "${PAGES_FAIL_LINE}$failed" >&2; exit 1; fi`,
|
|
152
163
|
];
|
|
164
|
+
// The platform `--wire` writes for: the Product's docs target, GitHub unless it says GitLab.
|
|
165
|
+
export const wiredPagesPlatform = (docs) => (docs?.target === 'gitlab-pages' ? 'gitlab' : 'github');
|
|
166
|
+
// Does a wired Pages workflow fail its deploy when a site fails to build? Read by `yad doctor`. Only this
|
|
167
|
+
// line is looked for — not the whole file — so a Dependabot pin bump or an edit elsewhere is not news.
|
|
168
|
+
export const pagesWorkflowFailsOnBuild = (text) => text.includes(PAGES_FAIL_LINE);
|
|
153
169
|
export function pagesWorkflow(platform) {
|
|
154
170
|
if (platform === 'gitlab') {
|
|
155
171
|
return `# yad-managed — built by \`yad docs sync --wire\`. include it from .gitlab-ci.yml:
|
|
@@ -161,7 +177,7 @@ pages:
|
|
|
161
177
|
rules:
|
|
162
178
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
163
179
|
script:
|
|
164
|
-
${BUILD_PUBLIC.map((l) => ` -
|
|
180
|
+
${BUILD_PUBLIC.map((l) => ` - ${yamlQuoted(l)}`).join('\n')}
|
|
165
181
|
artifacts:
|
|
166
182
|
paths: [public]
|
|
167
183
|
resource_group: pages
|
|
@@ -347,7 +363,7 @@ function reportFreshness(root, t) {
|
|
|
347
363
|
}
|
|
348
364
|
|
|
349
365
|
function wirePages(root, docs) {
|
|
350
|
-
const platform = docs
|
|
366
|
+
const platform = wiredPagesPlatform(docs);
|
|
351
367
|
const rel = pagesWorkflowPath(platform);
|
|
352
368
|
const dest = path.join(root, rel);
|
|
353
369
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
package/cli/doctor.mjs
CHANGED
|
@@ -22,9 +22,11 @@ import { checkCodeowners, codeownersFindings } from './codeowners-command.mjs';
|
|
|
22
22
|
import { RISK_MAP_FILE } from './riskmap.mjs';
|
|
23
23
|
import { readProtection, protectionLine, protectionJSON, hideAddresses } from './protection.mjs';
|
|
24
24
|
import { soloTeamHint, TEAM_CMD } from './people.mjs';
|
|
25
|
+
import { pagesWorkflowPath, pagesWorkflowFailsOnBuild, wiredPagesPlatform } from './docs.mjs';
|
|
25
26
|
import { indexFreshness, INDEX_FILE } from './product-index.mjs';
|
|
26
27
|
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
27
28
|
import { readOwners } from './owners.mjs';
|
|
29
|
+
import { readMembers, judgedMemberFiles } from './members.mjs';
|
|
28
30
|
import { loadChoices, readToolboxFile } from './toolbox.mjs';
|
|
29
31
|
|
|
30
32
|
// A registered path doctor may run git in (E81): a checkout the judgement accepts — never a refused
|
|
@@ -378,6 +380,7 @@ export function projectChecks(checks, root, { headCount = null } = {}) {
|
|
|
378
380
|
// Background capture (E43), on any Product in either ledger mode: is the post-edit hook wired, and will
|
|
379
381
|
// a push to the `yad/wip/*` branches start the team's own CI?
|
|
380
382
|
if (exists(productPath)) captureChecks(root, checks, readJSON(productPath, null));
|
|
383
|
+
docsWorkflowChecks(root, checks);
|
|
381
384
|
if (exists(productPath)) lineEndingChecks(root, checks);
|
|
382
385
|
|
|
383
386
|
// design.json: parse + shape + tool + MCP confirmation (absent is the normal markdown-only default —
|
|
@@ -1889,6 +1892,37 @@ export function ownerChecks(checks, root) {
|
|
|
1889
1892
|
'such a file is ignored — no owner is shown and the edit-time warning is off. For a step on the chain, `yad assign <epic> <step> --force` replaces it and `yad unassign <epic> <step> --force` removes it; for any other, delete it (`git rm <path>`). `yad owners` lists them all');
|
|
1890
1893
|
}
|
|
1891
1894
|
|
|
1895
|
+
// `members:*` (E131). A member file that cannot be used, or an account or email two files share, pairs
|
|
1896
|
+
// nothing — the person it was meant to join counts twice, and nobody is told anywhere else. And where no
|
|
1897
|
+
// CI runs (a local ledger, no platform), nothing checks a member file: say that it is its owner's statement.
|
|
1898
|
+
export function memberChecks(checks, root) {
|
|
1899
|
+
let got;
|
|
1900
|
+
try { got = readMembers(root); } catch { return; }
|
|
1901
|
+
const some = (list) => `${list.slice(0, 3).join('; ')}${list.length > 3 ? ` (+${list.length - 3} more)` : ''}`;
|
|
1902
|
+
if (got.errors.length) {
|
|
1903
|
+
check(checks, 'members:unreadable', 'project', 'warn', `${got.errors.length} member file(s) pair nothing: ${some(got.errors.map((e) => e.error))}`,
|
|
1904
|
+
'such a file joins no email to any login, so its person counts twice and is not on the team list. Its owner runs `yad member add` to write it again; otherwise delete it (`yad member remove <login> --reason …`)');
|
|
1905
|
+
}
|
|
1906
|
+
if (got.duplicates.length) {
|
|
1907
|
+
check(checks, 'members:duplicate', 'project', 'warn', `an account or email is in two member files: ${some(got.duplicates)}`,
|
|
1908
|
+
'one account and one email belong to one member. Until one file is fixed, neither file joins it. On a verified Product the member-check gate refuses the PR that adds the second');
|
|
1909
|
+
}
|
|
1910
|
+
// Which files the gate COUNT leaves out, and why (review 3): no live gate, or a file the gate never judged.
|
|
1911
|
+
if (got.members.length && got.identity.verified && got.identity.platform === 'github') {
|
|
1912
|
+
const judged = judgedMemberFiles(root, got.identity);
|
|
1913
|
+
const left = got.members.filter((m) => !judged.files.has(m.rel)).map((m) => m.rel);
|
|
1914
|
+
if (left.length) {
|
|
1915
|
+
check(checks, 'members:untrusted', 'project', 'warn', `${left.length} member file(s) are not used by the active-people count: ${left.slice(0, 3).join('; ')}${left.length > 3 ? ` (+${left.length - 3} more)` : ''}`,
|
|
1916
|
+
judged.live ? 'no gate as shipped judged them: each was last changed before the member-check gate or its workflow last changed on origin\'s default branch (every `yad update` that changes either resets this), or differs here from origin — its owner runs `yad member add` again, and that change is judged'
|
|
1917
|
+
: 'the member-check gate or its workflow on origin\'s default branch is missing, edited, or from another yadflow version than this one — `yad update` installs the shipped copies, then each member runs `yad member add` again');
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
if (got.members.length && !got.identity.verified) {
|
|
1921
|
+
check(checks, 'members:unchecked', 'project', 'ok', `${got.members.length} member file(s), and no CI checks them on this Product`,
|
|
1922
|
+
'with a local ledger or no platform, no member-check gate runs, so each member file is its owner\'s own statement');
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1892
1926
|
// The installed gates whose changed list must name a rename by both paths (E114), and the test for one
|
|
1893
1927
|
// that does not. Read as bash reads it, one command at a time, so one fixed command never hides its blind
|
|
1894
1928
|
// twin: a `#` line is prose and is dropped FIRST (a `\` at its end does not go on); then a line ending in
|
|
@@ -2563,6 +2597,7 @@ export function collectDoctor(root, { headCount = null } = {}) {
|
|
|
2563
2597
|
skipChecks(checks, root);
|
|
2564
2598
|
stepStateChecks(checks, root);
|
|
2565
2599
|
ownerChecks(checks, root);
|
|
2600
|
+
memberChecks(checks, root);
|
|
2566
2601
|
ownerGuardChecks(checks, root);
|
|
2567
2602
|
renamedChecks(checks, root);
|
|
2568
2603
|
productProfileChecks(checks, root);
|
|
@@ -2664,6 +2699,21 @@ export function captureChecks(root, checks, cfg) {
|
|
|
2664
2699
|
}
|
|
2665
2700
|
}
|
|
2666
2701
|
|
|
2702
|
+
// The Pages workflow `yad docs sync --wire` wrote. Nothing else rewrites it, so a Product wired by yadflow
|
|
2703
|
+
// 4.5.0 or older keeps a script that deploys a site with a page missing when a site fails to build. Only
|
|
2704
|
+
// that one behaviour is judged — not the whole file, which Dependabot bumps and a team may edit — and only
|
|
2705
|
+
// in the file `--wire` writes for this Product's platform (it never touches the other one). A file
|
|
2706
|
+
// without our header is the team's own.
|
|
2707
|
+
export function docsWorkflowChecks(root, checks) {
|
|
2708
|
+
const rel = pagesWorkflowPath(wiredPagesPlatform(readJSON(path.join(root, PROJECT_FILES.docsConfig), null)));
|
|
2709
|
+
let text;
|
|
2710
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return; }
|
|
2711
|
+
if (!text.startsWith('# yad-managed — built by `yad docs sync --wire`')) return;
|
|
2712
|
+
if (pagesWorkflowFailsOnBuild(text)) check(checks, 'docs-workflow', 'project', 'ok', `${rel} fails the deploy when a site fails to build`);
|
|
2713
|
+
else check(checks, 'docs-workflow', 'project', 'warn', `${rel} still deploys when a site fails to build — the site then goes out with a page missing`,
|
|
2714
|
+
'run `yad docs sync --wire` and commit the file; it rewrites the whole file, so re-apply any change your team made to it');
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2667
2717
|
// The team's own GitHub workflow files that a push to a `yad/wip/*` branch would start. A line reader, not
|
|
2668
2718
|
// a YAML parser, confined to the TOP-LEVEL `on:` block (a job named `push` is not a trigger). It reads the
|
|
2669
2719
|
// usual spellings — `on: push`, `on: [push, …]`, and a `push:` key under `on:` — and decides the way
|
package/cli/lib.mjs
CHANGED
|
@@ -357,11 +357,14 @@ export function run(cmd, args = [], opts = {}) {
|
|
|
357
357
|
export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;
|
|
358
358
|
|
|
359
359
|
// An npm launcher (`npm`, `npx`) is a `.cmd` file on Windows, which Node will not start without a shell
|
|
360
|
-
// (since 18.20.2) — a plain spawn fails with EINVAL there. So on Windows it runs through one, each
|
|
360
|
+
// (since 18.20.2) — a plain spawn fails with EINVAL there. So on Windows it runs through one, each argument in
|
|
361
361
|
// double quotes (a Windows path cannot hold a `"`); everywhere else it is spawned directly (E113).
|
|
362
|
+
// The launcher name itself stays bare: a `.cmd` found on PATH through a QUOTED name reads `%~dp0` as the
|
|
363
|
+
// current folder, so `"npx"` looked for npx-cli.js inside the repo being packed (#326). Callers pass a
|
|
364
|
+
// fixed name (`npm`, `npx`), never user input.
|
|
362
365
|
export function launcherInvocation(cmd, args, platform = process.platform) {
|
|
363
366
|
if (platform !== 'win32') return { cmd, args, shell: false };
|
|
364
|
-
return { cmd: [cmd, ...args
|
|
367
|
+
return { cmd: [cmd, ...args.map((a) => `"${String(a).replace(/"/g, '')}"`)].join(' '), args: [], shell: true };
|
|
365
368
|
}
|
|
366
369
|
export function runLauncher(cmd, args = [], opts = {}) {
|
|
367
370
|
const inv = launcherInvocation(cmd, args);
|
package/cli/manifest.mjs
CHANGED
|
@@ -539,6 +539,9 @@ export const PRODUCT_WIRING = {
|
|
|
539
539
|
{ src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
|
|
540
540
|
{ src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
|
|
541
541
|
{ src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
|
|
542
|
+
// E131: a member file is changed only by its own person, and each email it adds is proven on the PR.
|
|
543
|
+
// A Node script (`node checks/member-check.mjs`), so it needs no execute bit.
|
|
544
|
+
{ src: 'skills/yad-checks/templates/checks/member-check.mjs', dest: 'checks/member-check.mjs' },
|
|
542
545
|
],
|
|
543
546
|
github: [
|
|
544
547
|
{ src: 'skills/yad-product-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
|