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/cli/usage.mjs
CHANGED
|
@@ -67,6 +67,7 @@ const ARTIFACT_FILES = new Set([
|
|
|
67
67
|
// has to derive the same login and the rule is compared between the two; this export stays (rule 3 —
|
|
68
68
|
// add the new name beside the old) and is the one every older reader here still calls.
|
|
69
69
|
import { loginFromEmail } from './riskmap.mjs';
|
|
70
|
+
import { gateMemberMap, hashEmail } from './members.mjs';
|
|
70
71
|
export { loginFromEmail };
|
|
71
72
|
|
|
72
73
|
// ---- epic enumeration --------------------------------------------------------------------------
|
|
@@ -92,7 +93,7 @@ const inWindow = (date, since, until) => !!date && (!since || date >= since) &&
|
|
|
92
93
|
|
|
93
94
|
// Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews, attributed to the
|
|
94
95
|
// name the ledger records — as recorded, with nothing to translate it through.
|
|
95
|
-
function ledgerEvents(root, epic, aliases = new Map()) {
|
|
96
|
+
export function ledgerEvents(root, epic, aliases = new Map()) {
|
|
96
97
|
const f = epicFiles(epicRoot(root, epic));
|
|
97
98
|
const events = [];
|
|
98
99
|
const emit = (rec, rawName, action, date, extra = {}) => {
|
|
@@ -143,12 +144,12 @@ const GIT_PRETTY = '--pretty=format:\x01%an%x00%ae%x00%ad';
|
|
|
143
144
|
|
|
144
145
|
// git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the Product
|
|
145
146
|
// is not a git repo (e.g. a test fixture dir), so the command never depends on git being present.
|
|
146
|
-
function gitAuthoredEvents(root) {
|
|
147
|
+
function gitAuthoredEvents(root, pairs = new Map()) {
|
|
147
148
|
const r = run('git', ['-C', root, 'log', '--no-merges', '--date=short', GIT_PRETTY, '--name-only', '--', 'epics', FOUNDATION_DIR]);
|
|
148
149
|
if (!r.ok || !r.stdout) return [];
|
|
149
150
|
const events = [];
|
|
150
151
|
for (const cm of parseGitLog(r.stdout)) {
|
|
151
|
-
const login = loginFromEmail(cm.ae);
|
|
152
|
+
const login = loginFromEmail(cm.ae) || pairs.get(hashEmail(cm.ae)) || null;
|
|
152
153
|
for (const rel of cm.files) {
|
|
153
154
|
if (!isArtifactPath(rel)) continue;
|
|
154
155
|
events.push({
|
|
@@ -163,7 +164,7 @@ function gitAuthoredEvents(root) {
|
|
|
163
164
|
|
|
164
165
|
// Optional (`--repos`): code commits in each connected code repo, attributed to the git author (or the
|
|
165
166
|
// login a noreply address carries).
|
|
166
|
-
function repoCommitEvents(root) {
|
|
167
|
+
function repoCommitEvents(root, pairs = new Map()) {
|
|
167
168
|
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
168
169
|
const events = [];
|
|
169
170
|
for (const repo of reg?.repos || []) {
|
|
@@ -172,7 +173,7 @@ function repoCommitEvents(root) {
|
|
|
172
173
|
const r = run('git', ['-C', abs, 'log', '--no-merges', '--date=short', GIT_PRETTY]);
|
|
173
174
|
if (!r.ok || !r.stdout) continue;
|
|
174
175
|
for (const cm of parseGitLog(r.stdout)) {
|
|
175
|
-
const login = loginFromEmail(cm.ae);
|
|
176
|
+
const login = loginFromEmail(cm.ae) || pairs.get(hashEmail(cm.ae)) || null;
|
|
176
177
|
events.push({ ts: cm.ad, actor: login || cm.an, login, action: 'committed', repo: repo.name });
|
|
177
178
|
}
|
|
178
179
|
}
|
|
@@ -181,11 +182,15 @@ function repoCommitEvents(root) {
|
|
|
181
182
|
|
|
182
183
|
// The full, window-filtered event stream. Deterministic: sorted by (date, action, epic/repo, actor).
|
|
183
184
|
export function deriveEvents(root, { since, until, repos = false } = {}) {
|
|
184
|
-
|
|
185
|
+
// E131: a member file joins a commit's email to the person's login, so one person is one row. A report,
|
|
186
|
+
// so GitLab's pairings count here too (the gate count reads GitHub's only).
|
|
187
|
+
let pairs = new Map();
|
|
188
|
+
try { pairs = gateMemberMap(root, { anyPlatform: true }); } catch { /* no members — rows stay as git names them */ }
|
|
189
|
+
const events = [...gitAuthoredEvents(root, pairs)];
|
|
185
190
|
let aliases = new Map();
|
|
186
191
|
try { aliases = legacyLogins(readJSON(productConfigPath(root), null)); } catch { /* no Product — nothing to translate */ }
|
|
187
192
|
for (const epic of listEpics(root)) events.push(...ledgerEvents(root, epic, aliases));
|
|
188
|
-
if (repos) events.push(...repoCommitEvents(root));
|
|
193
|
+
if (repos) events.push(...repoCommitEvents(root, pairs));
|
|
189
194
|
return events
|
|
190
195
|
.filter((e) => inWindow(e.ts, since, until))
|
|
191
196
|
.sort((a, b) =>
|
|
@@ -266,7 +271,7 @@ export function shipHygiene(root, { since, until } = {}) {
|
|
|
266
271
|
|
|
267
272
|
// ---- rendering ---------------------------------------------------------------------------------
|
|
268
273
|
|
|
269
|
-
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
274
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
270
275
|
const rangeLabel = (w) => (!w.since && !w.until ? 'all time' : `${w.since || '…'} → ${w.until || '…'}`);
|
|
271
276
|
|
|
272
277
|
// A single self-contained HTML file — inline CSS + inline SVG bars, no build step, no external assets —
|
|
@@ -327,11 +332,13 @@ ${hygiene}
|
|
|
327
332
|
</div></body></html>\n`;
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
// Markdown cell and line text: a `|` or a newline in a name or repo can't corrupt a table or a list.
|
|
336
|
+
export const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
337
|
+
export const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
|
|
338
|
+
|
|
330
339
|
// A compact Markdown variant for quick reads / pasting into a PR. Dynamic values are sanitized so a
|
|
331
340
|
// name/repo containing `|` or a newline can't corrupt the table or list structure.
|
|
332
341
|
export function renderMarkdown(model, today = '') {
|
|
333
|
-
const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
334
|
-
const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
|
|
335
342
|
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
343
|
for (const m of model.members) {
|
|
337
344
|
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(', ') || '—')} |`);
|
|
@@ -364,7 +371,7 @@ export function buildModel(root, { since, until, repos = false, member } = {}) {
|
|
|
364
371
|
|
|
365
372
|
// Ensure a report's parent directory exists, then write it (mirrors copyFile/writeJSON in lib.mjs,
|
|
366
373
|
// which always mkdir the dirname first) so `--out sub/dir/report.html` never throws a raw ENOENT.
|
|
367
|
-
function writeReport(dest, content) {
|
|
374
|
+
export function writeReport(dest, content) {
|
|
368
375
|
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
369
376
|
fs.writeFileSync(dest, content);
|
|
370
377
|
}
|
package/cli/workspace.mjs
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
//
|
|
20
20
|
// `yad setup` is unchanged: new and init are front doors over it, not a replacement.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// `new` never creates a remote — it prints the exact `gh repo create` / `glab repo create` line. `join`
|
|
23
|
+
// installs only what git ignores in the Product (the per-machine skill copies) and the git pre-commit
|
|
24
|
+
// hook, which git never shares (E48). ONE EXCEPTION, since E131 (the user's decision, 2026-10-05): join
|
|
25
|
+
// ends with `yad member add`, which writes the person's member file on a branch of its own
|
|
26
|
+
// (`yad/member/<login>`), pushes it and opens a PR/MR — never in the checkout, never on the default
|
|
27
|
+
// branch. Any problem there is a warning and the command to run later; it never fails join.
|
|
26
28
|
import path from 'node:path';
|
|
27
29
|
import fs from 'node:fs';
|
|
28
30
|
import { spawnSync } from 'node:child_process';
|
|
@@ -33,6 +35,7 @@ import { runSetup, insideWorkspace, throughGitDir, selectIdeTargets } from './se
|
|
|
33
35
|
import { moduleActions, gitHookActions } from './plan.mjs';
|
|
34
36
|
import { writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
|
|
35
37
|
import { offerToolbox } from './toolbox.mjs';
|
|
38
|
+
import { runMemberAdd } from './members.mjs';
|
|
36
39
|
|
|
37
40
|
// E80: the file that lets yad find the Product from inside the repos it registers. Written by all three.
|
|
38
41
|
function noteWorkspaceFile(product) {
|
|
@@ -490,6 +493,12 @@ export async function runJoin(cwd, url, folder, opts = {}) {
|
|
|
490
493
|
log(c.bold('Toolbox'));
|
|
491
494
|
const toolbox = offerToolbox(product, product, { canRemove: false });
|
|
492
495
|
|
|
496
|
+
// E131: the person's member file — proven by the platform, on its own branch and PR/MR. Never fatal.
|
|
497
|
+
log('');
|
|
498
|
+
let member = null;
|
|
499
|
+
try { member = await runMemberAdd(product, { soft: true, noPush: !!opts.noPush }) ?? null; }
|
|
500
|
+
catch (e) { warn(`member file not written: ${shown(e.message)} — run \`yad member add\` from the Product later`); }
|
|
501
|
+
|
|
493
502
|
// Under a heading of its own, so these lines do not read as part of the toolbox list above.
|
|
494
503
|
log('');
|
|
495
504
|
log(c.bold('Next'));
|
|
@@ -498,6 +507,6 @@ export async function runJoin(cwd, url, folder, opts = {}) {
|
|
|
498
507
|
return {
|
|
499
508
|
workspace, product,
|
|
500
509
|
repos, skills: { installed: skills.installed, stale: skills.stale.length, shared: skills.shared.length },
|
|
501
|
-
hook: hook.length ? 'installed' : 'unchanged', toolbox,
|
|
510
|
+
hook: hook.length ? 'installed' : 'unchanged', toolbox, member,
|
|
502
511
|
};
|
|
503
512
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"description": "Shared project memory and workflows for teams building with AI: a versioned record of plans, decisions, contracts and reviews across repos, human-approved review gates, and a zero-dependency `yad` CLI plus 38 skills for your AI assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"!cli/test-migrate.mjs",
|
|
27
27
|
"!cli/test-hooks.mjs",
|
|
28
28
|
"!cli/test-detect.mjs",
|
|
29
|
+
"!cli/test-members.mjs",
|
|
30
|
+
"!cli/test-standup.mjs",
|
|
29
31
|
"!cli/test-fuzz.js",
|
|
30
32
|
"!cli/fixtures",
|
|
31
33
|
"skills/",
|
|
@@ -43,10 +45,10 @@
|
|
|
43
45
|
"scripts": {
|
|
44
46
|
"yad": "node bin/yad.mjs",
|
|
45
47
|
"lint": "eslint cli bin",
|
|
46
|
-
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-fuzz.js",
|
|
48
|
+
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-members.mjs cli/test-standup.mjs cli/test-fuzz.js",
|
|
47
49
|
"test:e2e": "bash test/e2e/run.sh",
|
|
48
50
|
"release-check": "bash scripts/release-check.sh",
|
|
49
|
-
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-fuzz.js",
|
|
51
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-detect.mjs cli/test-members.mjs cli/test-standup.mjs cli/test-fuzz.js",
|
|
50
52
|
"diagrams": "npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/work-and-memory.mmd -o docs/diagrams/work-and-memory.svg -b transparent && npx -y @mermaid-js/mermaid-cli@11.15.0 -i docs/diagrams/feature-evolution.mmd -o docs/diagrams/feature-evolution.svg -b transparent",
|
|
51
53
|
"prepublishOnly": "npm test"
|
|
52
54
|
},
|
|
@@ -31,9 +31,9 @@ SDLC Workflow,yad-report,Report Issue,RP,"Self issue reporter: when a yad flow b
|
|
|
31
31
|
SDLC Workflow,yad-connect-docs,Connect Docs Target,DX,"Setup/maintenance: connect a docs/Pages publishing target so the interactive-docs steps can deploy the generated site (not just commit its source). Auto-detects the platform from .sdlc/product.json (github->github-pages, gitlab->gitlab-pages, null->build-only), resolves the Vite base path, and records the target in .sdlc/docs.json (local-user auth, no stored tokens). Degrades to build-only when gh/glab is absent. Idempotent and refreshable; one connection per project. Not a gated state — never touches epic state or approvals.",,{action: connect|refresh|list|disconnect} {target: github-pages|gitlab-pages|none} {scope: product|<repo>|dedicated} {base_path: <override>},0-setup,,yad-docs,false,.sdlc/,docs.json
|
|
32
32
|
SDLC Workflow,yad-docs,Author Docs Site,DS,"Generate the per-epic interactive documentation SPA (animated flow canvas + role-based stakeholder doc pages) from the epic's approved artifacts (epic, architecture, locked contract, ui-design, stories, code-context, test-cases), themed by the connected design system, into epics/EP-<slug>/docs-site/. Copies the vendored React/Vite/Tailwind shell verbatim and generates src/data/*.ts deterministically; drives the yad docs build/deploy CLI to publish to Pages (or build-only). An OUTPUT ENRICHMENT — never a gate; never mutates state.json, approvals, or the contract lock.",,{epic: EP-<slug>} {action: generate|refresh|deploy} {login_gate: true|false},4-automate,yad-review-gate,,false,epics/EP-<slug>/docs-site/,docs-site/ docs-build.json
|
|
33
33
|
SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Folds the hand-maintained docs/index.html report into the site as report.html (linked from the nav). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},4-automate,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
|
|
34
|
-
SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + for a per-epic site the doc-shell version, vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that
|
|
34
|
+
SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + for a per-epic site the doc-shell version, vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that builds and deploys every site on push (one deploy at a time; a site that fails to build fails the run). Generalizes the rule that feature work must hand-update docs/index.html + diagrams + skill counts. Refresh is always a human/CI decision; never a gate.",,{action: check|refresh|wire} {epic: EP-<slug>},4-automate,,,false,epics/EP-<slug>/.sdlc/,docs-build.json yad-docs.yml
|
|
35
35
|
SDLC Workflow,yad-change,Change/Defect Intake,CH,"Phase 6 post-lock change management: the INTAKE + TRIAGE step of a feature thread. Classifies the change DEPTH (defect-fix / behavioral-no-surface / contract-surface / new-capability), seeds a NEW EP-<slug> change-epic threaded to its parent (lineage frontmatter kind+type/parent/thread/inherits/supersedes ; then `yad epic new --parent` writes the state.json whose inherited steps are carried by reference and only the changed steps run, plus a pointer-lock contract-lock.json when architecture is inherited), and records the intake in change.json (escape_stage + root_cause for defects). For hotfixes it records the ship-first exception and opens reconcile-debt.json. Never auto-advances — hands off to the normal authoring skills + yad-review-gate.",,{parent: EP-<slug>} {title: one-line} {type: change|defect|hotfix} {origin: production|staging|qa|review} {severity: sev1..sev4} {description: text} {affected: artifacts},6-change,,yad-review-gate,false,epics/EP-<slug>/,epic.md state.json change.json reconcile-debt.json contract-lock.json
|
|
36
36
|
SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + the build ledger (folded build-log.json UNIONed with every build-log/ shard). An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},6-change,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
|
|
37
37
|
SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every defect-type change-epic + each change.json defect block + shipped regressions in the build ledger (folded build-log.json UNIONed with every build-log/ shard), aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},6-change,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
|
|
38
|
-
SDLC Workflow,yad-reconcile,Change Reconciler,RE,"Maintenance/CI (mirrors yad-docs-sync — never a gate): detect post-lock DRIFT/ORPHANS — shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread — plus open hotfix reconcile debt, and report which thread drifted and why. refresh points at yad-change to open a reconcile change-epic (or yad-stub first, to anchor orphan brownfield code that has no epic at all) — never silent; wire commits advisory CI ([skip ci] + concurrency
|
|
38
|
+
SDLC Workflow,yad-reconcile,Change Reconciler,RE,"Maintenance/CI (mirrors yad-docs-sync — never a gate): detect post-lock DRIFT/ORPHANS — shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread — plus open hotfix reconcile debt, and report which thread drifted and why. refresh points at yad-change to open a reconcile change-epic (or yad-stub first, to anchor orphan brownfield code that has no epic at all) — never silent; wire commits advisory CI ([skip ci] + concurrency). The actual merge BLOCK is the lineage-check / reconcile-debt gates; this only discovers.",,{action: check|refresh|wire} {thread: EP-<genesis>},6-change,,,false,epics/EP-<genesis>/.sdlc/,(report) reconcile-debt.json yad-reconcile.yml
|
|
39
39
|
SDLC Workflow,yad-stub,Stub Genesis Epic,SG,"Phase 6 brownfield helper: mint a STUB genesis epic for an already-built feature that has no epic in the Product, so a defect/change can thread off it TODAY (yad-change requires a real parent and dead-ends without one). Creates the smallest real thread anchor — a tiny epic.md (type feature, thread:self, verified:false, stub:backfill-pending) + a seeded state.json (kind:stub / currentStep:backfill-pending) + empty ledgers — never inventing behaviour. Defects thread off it immediately (gates pass; the bug list is derived by the thread rollup); yad-backfill + its promote step later flip it into a real feature epic. Never auto-advances.",,{feature: <name>} {repos: [<repo>]} {description: one-line},6-change,,yad-change,false,epics/EP-<slug>/,epic.md state.json approvals.json comments.json
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// member-check gate (E131) — a member file (`.sdlc/members/<platform>-<login>.json`) says which accounts and
|
|
3
|
+
// which git emails are one person. The active-people count joins a commit to an approval through it, so a
|
|
4
|
+
// false file would fold one person into another and make the count SMALLER. This gate refuses that:
|
|
5
|
+
//
|
|
6
|
+
// 1. A PR may ADD or CHANGE only its author's own file: named `<platform>-<author>.json`, holding exactly
|
|
7
|
+
// one account on the Product's platform and host — the author's, with the author's numeric account id
|
|
8
|
+
// (so a login someone renamed away from, and another person registered, cannot take over the old file).
|
|
9
|
+
// Never a bot's.
|
|
10
|
+
// 2. On GitHub, every email in a file the PR adds or changes must be the author email of a commit in the PR that GitHub itself
|
|
11
|
+
// attributes to the PR's author (`commits/<sha>` → `author.login`). GitHub links a commit to an account
|
|
12
|
+
// only through an email that account has verified, so this is the platform's proof, not the file's
|
|
13
|
+
// claim. `yad member add` writes one commit per email for exactly this. On GitLab the API says no such
|
|
14
|
+
// thing, so emails there feed the team list only and are never used by the gate count (the user's
|
|
15
|
+
// decision, 2026-10-05) — this gate checks the owner rule alone.
|
|
16
|
+
// 3. A PR may DELETE anyone's file (removing a person can only make the count larger). `yad member remove
|
|
17
|
+
// <login> --reason` puts the reason in the commit.
|
|
18
|
+
// 4. No account on the Product's platform, and no email, may be in two member files. (An OTHER account
|
|
19
|
+
// two files claim is not refused: the CLI matches it to nobody until one file drops it, and refusing
|
|
20
|
+
// it here would let whoever claimed it first block its real owner from joining.)
|
|
21
|
+
// 5. `.sdlc` and `.sdlc/members` are real folders and nothing under them is a link, and no path is a
|
|
22
|
+
// case or Unicode twin of `.sdlc/members/` — a macOS or Windows checkout would read either as a member
|
|
23
|
+
// file this gate never judged (E131 review 1–2; the E115 and E121 lessons).
|
|
24
|
+
//
|
|
25
|
+
// LIMIT: like every Product gate, this file and the workflow that runs it come from the PR's own checkout,
|
|
26
|
+
// so a PR can change the gate it is judged by; branch protection on the default branch is the backstop.
|
|
27
|
+
//
|
|
28
|
+
// A Node script, not bash: it reads JSON and hashes, and both CI images already have Node (E113's hooks are
|
|
29
|
+
// Node for the same reason). Usage: node checks/member-check.mjs <base-ref>
|
|
30
|
+
// GitHub: PR_AUTHOR and PR_AUTHOR_ID (github.event.pull_request.user.login / .id) and GH_TOKEN (a read
|
|
31
|
+
// token) in the environment.
|
|
32
|
+
// GitLab: CI_API_V4_URL, CI_PROJECT_ID, CI_MERGE_REQUEST_IID, and GITLAB_TOKEN or SDLC_API_TOKEN. With no
|
|
33
|
+
// token it FAILS when a member file changed (fail closed — the user's decision, 2026-10-05).
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
|
|
38
|
+
const DIR = '.sdlc/members/';
|
|
39
|
+
const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
40
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
41
|
+
let rc = 0;
|
|
42
|
+
const say = (s) => process.stdout.write(`${s}\n`);
|
|
43
|
+
const fail = (s) => { say(`FAIL [member-check]: ${s}`); rc = 1; };
|
|
44
|
+
const git = (...args) => {
|
|
45
|
+
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
46
|
+
return { ok: r.status === 0, out: r.stdout || '' };
|
|
47
|
+
};
|
|
48
|
+
const hash = (email) => {
|
|
49
|
+
const e = String(email || '').trim().toLowerCase();
|
|
50
|
+
return e.indexOf('@', 1) < 0 ? null : `sha256:${createHash('sha256').update(e).digest('hex')}`;
|
|
51
|
+
};
|
|
52
|
+
const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
|
|
53
|
+
|
|
54
|
+
// The Product's settings under either name (product.json from 4.0, hub.json before). Drift between the two
|
|
55
|
+
// is refused by the other gates and the CLI; here the first one found is read.
|
|
56
|
+
const settings = parse(fs.existsSync('.sdlc/product.json') ? fs.readFileSync('.sdlc/product.json', 'utf8')
|
|
57
|
+
: fs.existsSync('.sdlc/hub.json') ? fs.readFileSync('.sdlc/hub.json', 'utf8') : 'null');
|
|
58
|
+
const platform = settings?.platform === 'github' || settings?.platform === 'gitlab' ? settings.platform : null;
|
|
59
|
+
// The Product's host, from its clone URL (`git@host:…` or `https://host/…`), else the platform's own.
|
|
60
|
+
function hostOf(url) {
|
|
61
|
+
const u = String(url || '').trim();
|
|
62
|
+
const scp = u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/);
|
|
63
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) return scp[1].toLowerCase();
|
|
64
|
+
try { return new URL(u).hostname.toLowerCase() || null; } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
// The same order the CLI reads it in: the settings' `git_url`, then the clone's origin (review 3).
|
|
67
|
+
const productHost = hostOf(settings?.git_url) || hostOf(git('remote', 'get-url', 'origin').out) || (platform === 'gitlab' ? 'gitlab.com' : 'github.com');
|
|
68
|
+
const onProduct = (a) => a?.platform === platform && String(a?.host || '').toLowerCase() === productHost;
|
|
69
|
+
|
|
70
|
+
const base = process.argv[2];
|
|
71
|
+
if (!base) { say('FAIL [member-check]: usage: node checks/member-check.mjs <base-ref>'); process.exit(1); }
|
|
72
|
+
if (!git('rev-parse', '--verify', '-q', `${base}^{commit}`).ok) { say(`FAIL [member-check]: cannot resolve the base ref ${base} (fetch-depth: 0?)`); process.exit(1); }
|
|
73
|
+
|
|
74
|
+
// Every path the PR touches, with its status. NUL-separated, no renames (a rename is a delete plus an add,
|
|
75
|
+
// and each is judged).
|
|
76
|
+
const diff = git('diff', '--no-renames', '--name-status', '-z', `${base}...HEAD`);
|
|
77
|
+
if (!diff.ok) { say('FAIL [member-check]: git could not list the changed files'); process.exit(1); }
|
|
78
|
+
const parts = diff.out.split('\0').filter(Boolean);
|
|
79
|
+
const all = [];
|
|
80
|
+
for (let i = 0; i + 1 < parts.length; i += 2) all.push({ status: parts[i][0], path: parts[i + 1] });
|
|
81
|
+
|
|
82
|
+
// A path as a case-insensitive, Unicode-folding file system sees it: `.sdlc/Members/x` and `.ſdlc/members/x`
|
|
83
|
+
// land in `.sdlc/members/` on macOS and Windows.
|
|
84
|
+
// A deletion is never judged here: removing a twin is how one is cleaned up (review 2).
|
|
85
|
+
const fold = (p) => p.normalize('NFKC').toLowerCase();
|
|
86
|
+
for (const { status, path } of all) {
|
|
87
|
+
if (status === 'D') continue;
|
|
88
|
+
const f = fold(path);
|
|
89
|
+
if ((f === '.sdlc' || f.startsWith('.sdlc/')) && !(path === '.sdlc' || path.startsWith('.sdlc/'))) fail(`${JSON.stringify(path)} is another spelling of .sdlc/ — a macOS or Windows checkout reads it as the Product's own folder, unjudged`);
|
|
90
|
+
else if ((f === '.sdlc/members' || f.startsWith(DIR)) && !path.startsWith(DIR)) fail(`${JSON.stringify(path)} is another spelling of ${DIR} — a macOS or Windows checkout reads it as a member file this gate does not judge`);
|
|
91
|
+
}
|
|
92
|
+
// The folders themselves must be folders at HEAD, and nothing under them a link.
|
|
93
|
+
for (const p of ['.sdlc', '.sdlc/members']) {
|
|
94
|
+
const t = git('ls-tree', 'HEAD', '--', p).out.trim();
|
|
95
|
+
if (t && !t.startsWith('040000 ')) fail(`${p} is not a folder at HEAD (a link?) — member files are read only from a real ${DIR}`);
|
|
96
|
+
}
|
|
97
|
+
const tree = git('ls-tree', '-r', '-z', 'HEAD', '--', DIR).out.split('\0').filter(Boolean);
|
|
98
|
+
const folded = new Map();
|
|
99
|
+
for (const line of tree) {
|
|
100
|
+
const [meta, p] = line.split('\t');
|
|
101
|
+
if (!meta.startsWith('100644 ') && !meta.startsWith('100755 ')) fail(`${p} is not a plain file (a link?) — a member file is never read through one`);
|
|
102
|
+
const k = fold(p);
|
|
103
|
+
if (folded.has(k)) fail(`${p} and ${folded.get(k)} are one file on macOS and Windows — keep one`);
|
|
104
|
+
folded.set(k, p);
|
|
105
|
+
}
|
|
106
|
+
if (rc) process.exit(rc);
|
|
107
|
+
|
|
108
|
+
const changed = all.filter((c) => c.path.startsWith(DIR));
|
|
109
|
+
if (!changed.length) { say('PASS [member-check]: no member file changed'); process.exit(0); }
|
|
110
|
+
if (!platform) { say('FAIL [member-check]: member files changed, but the Product names no platform — nothing can be proven'); process.exit(1); }
|
|
111
|
+
|
|
112
|
+
// Who opened the PR/MR, asked of the platform's own record.
|
|
113
|
+
// Returns { login, id }: the numeric id is what stays with a person when a login is renamed and reused.
|
|
114
|
+
async function prAuthor() {
|
|
115
|
+
if (platform === 'github') return { login: process.env.PR_AUTHOR || null, id: Number(process.env.PR_AUTHOR_ID) || null };
|
|
116
|
+
const url = process.env.CI_API_V4_URL;
|
|
117
|
+
const project = process.env.CI_PROJECT_ID;
|
|
118
|
+
const iid = process.env.CI_MERGE_REQUEST_IID;
|
|
119
|
+
const token = process.env.GITLAB_TOKEN || process.env.SDLC_API_TOKEN;
|
|
120
|
+
if (!url || !project || !iid) return null;
|
|
121
|
+
if (!token) { fail('no API token (GITLAB_TOKEN or SDLC_API_TOKEN) to read who opened this MR — a member file cannot be checked without it; add the token as a masked CI/CD variable'); return null; }
|
|
122
|
+
try {
|
|
123
|
+
const r = await fetch(`${url}/projects/${encodeURIComponent(project)}/merge_requests/${encodeURIComponent(iid)}`, { headers: { 'PRIVATE-TOKEN': token } });
|
|
124
|
+
if (!r.ok) { fail(`could not read the MR (HTTP ${r.status})`); return null; }
|
|
125
|
+
const mr = await r.json();
|
|
126
|
+
return { login: mr?.author?.username || null, id: Number(mr?.author?.id) || null };
|
|
127
|
+
} catch (e) { fail(`could not read the MR: ${e.message}`); return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const who = await prAuthor();
|
|
131
|
+
if (rc) process.exit(rc);
|
|
132
|
+
const author = who?.login || null;
|
|
133
|
+
const authorId = who?.id || null;
|
|
134
|
+
if (!author || !LOGIN_RE.test(author)) { say(`FAIL [member-check]: cannot tell who opened this ${platform === 'github' ? 'PR' : 'MR'}${author ? ` (${author} is a bot)` : ''} — a member file is changed only by its own person`); process.exit(1); }
|
|
135
|
+
|
|
136
|
+
const fileAt = (ref, path) => { const r = git('show', `${ref}:${path}`); return r.ok ? parse(r.out) : null; };
|
|
137
|
+
const emailsOf = (rec) => new Set(Array.isArray(rec?.emails) ? rec.emails.filter((h) => typeof h === 'string' && HASH_RE.test(h)) : []);
|
|
138
|
+
|
|
139
|
+
// GitHub's own answer: which account a commit in this PR belongs to. Cached per commit.
|
|
140
|
+
const loginOf = new Map();
|
|
141
|
+
function githubLogin(sha) {
|
|
142
|
+
if (loginOf.has(sha)) return loginOf.get(sha);
|
|
143
|
+
const r = spawnSync('gh', ['api', `repos/{owner}/{repo}/commits/${sha}`, '--jq', '.author.login // ""'], { encoding: 'utf8' });
|
|
144
|
+
const login = r.status === 0 ? (r.stdout || '').trim() : null;
|
|
145
|
+
loginOf.set(sha, login);
|
|
146
|
+
return login;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const prCommits = git('log', '--format=%H%x1f%ae', `${base}..HEAD`).out.split('\n').filter(Boolean).map((l) => { const [sha, email] = l.split('\x1f'); return { sha, hash: hash(email) }; });
|
|
150
|
+
|
|
151
|
+
for (const { status, path } of changed) {
|
|
152
|
+
const name = path.slice(DIR.length);
|
|
153
|
+
if (status === 'D') { say(`PASS [member-check]: ${path} removed (removing a member can only make the count larger)`); continue; }
|
|
154
|
+
if (name.includes('/') || !name.endsWith('.json')) { fail(`${path}: only <platform>-<login>.json files belong in ${DIR}`); continue; }
|
|
155
|
+
if (name !== `${platform}-${author.toLowerCase()}.json`) { fail(`${path}: a PR may add or change only its author's own member file (${platform}-${author.toLowerCase()}.json) — use \`yad member remove <login> --reason\` to remove someone else's`); continue; }
|
|
156
|
+
const rec = fileAt('HEAD', path);
|
|
157
|
+
if (!rec || !Array.isArray(rec.accounts)) { fail(`${path}: not a member file (no "accounts" list)`); continue; }
|
|
158
|
+
const here = rec.accounts.filter(onProduct);
|
|
159
|
+
if (here.length !== 1) { fail(`${path}: must hold exactly one ${platform} account on ${productHost}, the author's own (it holds ${here.length})`); continue; }
|
|
160
|
+
if (String(here[0].login || '').toLowerCase() !== author.toLowerCase()) { fail(`${path}: does not hold ${author}'s own ${platform} account on ${productHost}`); continue; }
|
|
161
|
+
// The numeric id: a login can be renamed away and registered again by someone else.
|
|
162
|
+
if (!authorId) { fail(`${path}: cannot tell the numeric id of who opened this ${platform === 'github' ? 'PR (PR_AUTHOR_ID)' : 'MR'}`); continue; }
|
|
163
|
+
if (here[0].id !== authorId) { fail(`${path}: the account id in the file (${here[0].id ?? 'none'}) is not the author's (${authorId})`); continue; }
|
|
164
|
+
const was = fileAt(base, path);
|
|
165
|
+
const wasHere = Array.isArray(was?.accounts) ? was.accounts.filter(onProduct) : [];
|
|
166
|
+
if (wasHere.length && wasHere[0].id !== authorId) { fail(`${path}: belonged to account id ${wasHere[0].id}, not ${author}'s (${authorId}) — a reused login does not take over a member file; remove it first`); continue; }
|
|
167
|
+
if (platform !== 'github') { say(`PASS [member-check]: ${path} belongs to ${author} (on GitLab its emails feed the team list only)`); continue; }
|
|
168
|
+
// EVERY email in a changed file is proven, not only the new ones (review 5): an email that reached the
|
|
169
|
+
// branch while this gate was off or edited would otherwise ride along on any later change.
|
|
170
|
+
const added = [...emailsOf(rec)];
|
|
171
|
+
for (const h of added) {
|
|
172
|
+
const proof = prCommits.filter((c) => c.hash === h).find((c) => String(githubLogin(c.sha) || '').toLowerCase() === author.toLowerCase());
|
|
173
|
+
if (!proof) fail(`${path}: holds an email no commit in this PR proves — GitHub must attribute a commit authored by that email to ${author} (\`yad member add\` writes one such commit per email; verify the email on the account)`);
|
|
174
|
+
}
|
|
175
|
+
if (!rc) say(`PASS [member-check]: ${path} belongs to ${author}${added.length ? `; ${added.length} email(s), each proven by a commit GitHub attributes to them` : ''}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// No account on the Product's platform and no email in two files, across every member file at HEAD.
|
|
179
|
+
const files = tree.map((l) => l.split('\t')[1]).filter((p) => p.endsWith('.json'));
|
|
180
|
+
const accounts = new Map();
|
|
181
|
+
const emails = new Map();
|
|
182
|
+
for (const p of files) {
|
|
183
|
+
const rec = fileAt('HEAD', p);
|
|
184
|
+
// Keyed on each file's OWN account only (review 2): an OTHER account two files claim is not refused here —
|
|
185
|
+
// the CLI matches it to nobody — or whoever claimed someone's account first would block its owner.
|
|
186
|
+
const login = p.slice(DIR.length).replace(new RegExp(`^${platform}-`), '').replace(/\.json$/, '');
|
|
187
|
+
const k = `${platform}:${productHost}:${login.toLowerCase()}`;
|
|
188
|
+
accounts.set(k, [...new Set([...(accounts.get(k) || []), p])]);
|
|
189
|
+
for (const h of emailsOf(rec)) emails.set(h, [...(emails.get(h) || []), p]);
|
|
190
|
+
}
|
|
191
|
+
for (const [k, list] of accounts) if (list.length > 1) fail(`the account ${k} is in ${list.join(' and ')} — one account belongs to one member`);
|
|
192
|
+
// On GitLab an email is never proven and never used by the count, so a shared one is not refused: refusing it
|
|
193
|
+
// would let whoever listed someone's known address first block that person (review 3). The CLI pairs it
|
|
194
|
+
// with nobody.
|
|
195
|
+
if (platform === 'github') for (const [, list] of emails) if (list.length > 1) fail(`one email is in ${list.join(' and ')} — one email belongs to one member`);
|
|
196
|
+
|
|
197
|
+
process.exit(rc);
|
|
@@ -69,3 +69,18 @@ jobs:
|
|
|
69
69
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
70
70
|
with: { fetch-depth: 0 }
|
|
71
71
|
- run: bash checks/ledger-guard.sh "origin/${{ github.base_ref }}"
|
|
72
|
+
|
|
73
|
+
# E131: a member file (.sdlc/members/) is changed only by its own person, and every email it adds must be
|
|
74
|
+
# the author of a commit in this PR that GitHub attributes to that person. Needs a read token to ask
|
|
75
|
+
# GitHub who a commit belongs to; the PR author comes from the event, never from the PR's own files.
|
|
76
|
+
member-check:
|
|
77
|
+
runs-on: ubuntu-latest
|
|
78
|
+
if: github.event.action != 'edited'
|
|
79
|
+
env:
|
|
80
|
+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
|
81
|
+
PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
|
|
82
|
+
GH_TOKEN: ${{ github.token }}
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
85
|
+
with: { fetch-depth: 0 }
|
|
86
|
+
- run: node checks/member-check.mjs "origin/${{ github.base_ref }}"
|
|
@@ -52,3 +52,12 @@ yad-product-ledger-guard:
|
|
|
52
52
|
needs: []
|
|
53
53
|
script:
|
|
54
54
|
- bash checks/ledger-guard.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
55
|
+
|
|
56
|
+
# E131: a member file (.sdlc/members/) is changed only by the MR's own author. GitLab's predefined
|
|
57
|
+
# variables do not name the MR author, so the check reads it from the API with GITLAB_TOKEN or SDLC_API_TOKEN
|
|
58
|
+
# (a masked CI/CD variable) — and FAILS when a member file changed and no token is set.
|
|
59
|
+
yad-product-member-check:
|
|
60
|
+
extends: .yad_product_mr_only
|
|
61
|
+
needs: []
|
|
62
|
+
script:
|
|
63
|
+
- node checks/member-check.mjs "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
@@ -150,4 +150,4 @@ under `<base>/app/` and cross-linked. Never touches any epic state.
|
|
|
150
150
|
`yad-*` skill in pipeline order + its phase + outputs: `references/pipeline-model.md`.
|
|
151
151
|
- The per-epic counterpart (shell, determinism, theming): `../yad-docs/SKILL.md`.
|
|
152
152
|
- The connected docs target + base-path resolution: `../yad-connect-docs/SKILL.md`.
|
|
153
|
-
- The staleness
|
|
153
|
+
- The staleness reconciler (reports stale sites, refreshes on request, wires the Pages build): `../yad-docs-sync/SKILL.md`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yad-docs-sync
|
|
3
|
-
description: 'The maintenance/CI reconciler for the generated docs sites — mirroring the `yad check` / `yad gate ci` drift pattern. Recomputes each site''s freshness hashes (per-epic: the artifact hash + repo HEADs; overview: config.yaml + module-help.csv + the overview diagram + skill count) and compares them to each docs-build.json baseline: a site is stale when any hash differs or its shell template is out of date. `--check` (default, read-only) reports which sites are stale and WHY; `--refresh` regenerates + redeploys each stale site; `--wire` commits the CI workflow that
|
|
3
|
+
description: 'The maintenance/CI reconciler for the generated docs sites — mirroring the `yad check` / `yad gate ci` drift pattern. Recomputes each site''s freshness hashes (per-epic: the artifact hash + repo HEADs; overview: config.yaml + module-help.csv + the overview diagram + skill count) and compares them to each docs-build.json baseline: a site is stale when any hash differs or its shell template is out of date. `--check` (default, read-only) reports which sites are stale and WHY; `--refresh` regenerates + redeploys each stale site; `--wire` commits the CI workflow that builds and deploys every site on push. Refresh is always a human/CI decision, never silent; docs are never a gate. Use when the user says "sync the docs", "check docs staleness", "refresh stale docs sites", or "wire the docs CI".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDLC — Sync the Docs Sites (the staleness reconciler)
|
|
@@ -67,17 +67,17 @@ A missing `docs-build.json` (a site never generated) counts as **stale → needs
|
|
|
67
67
|
rebuild failed: `yad docs sync --refresh` exits 1 when a stale site's npm install or build fails, and
|
|
68
68
|
its `--json` `sites` rows say which (`built: false` with an `error`). **Never silent** — refresh
|
|
69
69
|
is a deliberate human/CI act, surfaced exactly like `yad repo refresh`.
|
|
70
|
-
- **`wire`** — commit the CI workflow (Step 4) that
|
|
70
|
+
- **`wire`** — commit the CI workflow (Step 4) that builds and deploys every site on push.
|
|
71
71
|
|
|
72
72
|
### Step 4 — `wire`: the CI auto-rebuild workflow
|
|
73
73
|
Commit the platform-matched workflow (GitHub `.github/workflows/yad-docs.yml`, or a GitLab `pages` job at
|
|
74
74
|
`.gitlab/ci/yad-docs.yml` that must be `include:`d from the root `.gitlab-ci.yml` —
|
|
75
75
|
`include: { local: .gitlab/ci/yad-docs.yml }`, the same fragment+include shape as the `yad-checks` gates):
|
|
76
|
-
- on push
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
- on a push to the default branch, **build every committed site and deploy them**; it commits nothing,
|
|
77
|
+
so it cannot retrigger itself, and a **concurrency group** keeps one docs deploy at a time;
|
|
78
|
+
- a site that fails to build **fails the run** (every failed site is named) and nothing is deployed, so
|
|
79
|
+
the last good site stays live. `yad doctor` warns (`docs-workflow`) when the wired file still deploys after a failed site build (as yadflow 4.5.0 and older wrote it) — re-run
|
|
80
|
+
`yad docs sync --wire`. See `references/staleness.md`.
|
|
81
81
|
|
|
82
82
|
### Step 5 — Report
|
|
83
83
|
Report per target: **fresh** or **stale (why)**; for `refresh`, what was regenerated + the deploy URL or
|
|
@@ -91,14 +91,15 @@ Report per target: **fresh** or **stale (why)**; for `refresh`, what was regener
|
|
|
91
91
|
`contract-lock.json`. Staleness blocks nothing in the SDLC; it only flags out-of-date docs.
|
|
92
92
|
- **HEAD-sha staleness, reused.** Repo drift uses the exact `repos.json` `syncedHead`-vs-current-HEAD
|
|
93
93
|
rule. The overview uses config + manifest + diagram + skill-count.
|
|
94
|
-
- **
|
|
95
|
-
and
|
|
94
|
+
- **CI builds and deploys; it never commits.** The wired workflow builds every committed site on push
|
|
95
|
+
and deploys them, under one concurrency group; it makes no commit, so it cannot retrigger itself. A
|
|
96
|
+
site that fails to build fails the run, and nothing is deployed.
|
|
96
97
|
- **Reconcile, don't re-author.** This skill detects drift and delegates regeneration to `yad-docs` /
|
|
97
98
|
`yad-docs-overview`; it does not generate `src/data/*.ts` itself.
|
|
98
99
|
|
|
99
100
|
## Reference
|
|
100
101
|
- The manifest schema (per-epic + overview), the exact hash inputs, the head-sha staleness rule, and the
|
|
101
|
-
CI
|
|
102
|
+
CI note: `references/staleness.md`.
|
|
102
103
|
- The generators this delegates to: `../yad-docs/SKILL.md`, `../yad-docs-overview/SKILL.md`.
|
|
103
104
|
- The docs target it deploys to: `../yad-connect-docs/SKILL.md`.
|
|
104
105
|
- The drift / refresh discipline this mirrors: `../yad-connect-repos/SKILL.md` (HEAD-sha staleness).
|
|
@@ -65,18 +65,16 @@ A site is **stale** when ANY holds:
|
|
|
65
65
|
`check` reports which of these tripped and why; `refresh` regenerates + redeploys; neither blocks any
|
|
66
66
|
SDLC step (docs are never a gate).
|
|
67
67
|
|
|
68
|
-
## CI
|
|
68
|
+
## CI note
|
|
69
69
|
|
|
70
70
|
The `wire` workflow (`.github/workflows/yad-docs.yml`, or the GitLab `pages` job at
|
|
71
|
-
`.gitlab/ci/yad-docs.yml` included from the root `.gitlab-ci.yml`)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Together these make the CI rebuild idempotent: a push that moves an artifact deploys exactly one fresh
|
|
82
|
-
site, and the resulting bot commit does not start another round.
|
|
71
|
+
`.gitlab/ci/yad-docs.yml` included from the root `.gitlab-ci.yml`) builds every committed site and
|
|
72
|
+
deploys them on each push to the default branch. It does not check staleness and commits nothing:
|
|
73
|
+
regenerating a site's `src/data/*.ts` stays a local `yad docs sync --refresh` that a person commits.
|
|
74
|
+
So it cannot start itself again; a **concurrency group** (`yad-docs-pages`, cancel-in-progress) keeps
|
|
75
|
+
it to one deploy at a time.
|
|
76
|
+
|
|
77
|
+
A site that fails to build fails the run, and the run names every site that failed. A red run uploads
|
|
78
|
+
nothing, so the last good deployment stays live. (Before this, a failed site was silently left out and
|
|
79
|
+
the run went green — that is how a site could lose a page.) `yad doctor` warns (`docs-workflow`) when the wired file still deploys after a failed site build (as yadflow 4.5.0 and older wrote it); run
|
|
80
|
+
`yad docs sync --wire` again and commit it.
|
|
@@ -146,6 +146,10 @@ default branch. (local mode keeps `yad gate sync` as the local writer.)
|
|
|
146
146
|
case set the variable to a real tag. The variable lives in project settings, so it survives
|
|
147
147
|
every `yad` sync. Single value only — `tags: [$VAR]` is one tag equal to the whole variable,
|
|
148
148
|
not a comma-split.
|
|
149
|
+
- **Git LFS needs nothing extra.** For a Product that uses Git LFS, the runner installs LFS git
|
|
150
|
+
hooks that fail without `git-lfs`, and the job's `node:20` image has none — so the job downloads a
|
|
151
|
+
pinned `git-lfs` itself before its checkout (#327; a Product without LFS skips it). A
|
|
152
|
+
`before_script` added by hand to install `git-lfs` can be removed after `yad update`.
|
|
149
153
|
3. Commit the workflow to the Product. GitHub needs nothing else — the ephemeral `github.token` reads the
|
|
150
154
|
PR and pushes the merge advance to the default branch, and the workflow's reconcile **schedule**
|
|
151
155
|
runs automatically (no setup) as the safety net that recovers a merge whose run failed transiently.
|
|
@@ -81,6 +81,24 @@ yad-gate-sync:
|
|
|
81
81
|
# jq streams glab's concatenated per-page --paginate arrays natively.
|
|
82
82
|
- JQ_VERSION=1.7.1
|
|
83
83
|
- curl -fsSL "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64" -o /usr/local/bin/jq && chmod +x /usr/local/bin/jq
|
|
84
|
+
# Pinned git-lfs, ONLY for a Product that uses Git LFS (issue #327). The runner clones an LFS repo
|
|
85
|
+
# with its own git-lfs and installs LFS git hooks (post-checkout, pre-push, …) that exit with an
|
|
86
|
+
# error when git-lfs is missing — and node:20 has none. So the `git checkout` below failed, and so
|
|
87
|
+
# would the push of the advance. A Product without LFS skips the download. Only the clone's own
|
|
88
|
+
# signs count (its hooks, its .git/config, its .gitattributes) — never a global git setting.
|
|
89
|
+
# The download is checked against the release's published SHA-256 (sha256sums.asc) before it is
|
|
90
|
+
# unpacked: this job holds a token that can push the default branch, and git-lfs then runs on every
|
|
91
|
+
# checkout and push. One command per line, not an `&&` chain — `set -e` does not stop on a failure
|
|
92
|
+
# inside a chain, so a bad checksum would fall through to the checkout.
|
|
93
|
+
- |
|
|
94
|
+
GIT_LFS_VERSION=3.8.0
|
|
95
|
+
GIT_LFS_SHA256=e455e00f15d9b95661b8d53498ffb0c3367962cf1ec73c31ab7369516cd6ab8d
|
|
96
|
+
if ! command -v git-lfs >/dev/null 2>&1 && { grep -qs git-lfs "$(git rev-parse --git-path hooks)"/* || git config --local --get-regexp '^filter\.lfs\.' >/dev/null 2>&1 || grep -qs 'filter=lfs' .gitattributes; }; then
|
|
97
|
+
curl -fsSL "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-amd64-v${GIT_LFS_VERSION}.tar.gz" -o /tmp/git-lfs.tar.gz
|
|
98
|
+
echo "${GIT_LFS_SHA256} /tmp/git-lfs.tar.gz" | sha256sum -c -
|
|
99
|
+
tar -xzf /tmp/git-lfs.tar.gz -C /tmp "git-lfs-${GIT_LFS_VERSION}/git-lfs"
|
|
100
|
+
install -m 0755 "/tmp/git-lfs-${GIT_LFS_VERSION}/git-lfs" /usr/local/bin/git-lfs
|
|
101
|
+
fi
|
|
84
102
|
- git config user.name "yad-gate-sync" && git config user.email "yad-gate-sync@noreply.${CI_SERVER_HOST}"
|
|
85
103
|
- git remote set-url origin "https://oauth2:${SDLC_GATE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
|
|
86
104
|
- export GITLAB_TOKEN="$SDLC_GATE_TOKEN" GITLAB_HOST="$CI_SERVER_URL"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yad-reconcile
|
|
3
|
-
description: 'Phase 6 maintenance/CI — the change reconciler (mirrors yad-docs-sync; NEVER a gate). Detects post-lock DRIFT and ORPHANS across feature threads: shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread, a broken lineage (cycle, missing parent, or a thread cache that disagrees with the computed root), and open hotfix reconcile debt — reporting which thread drifted and WHY. `check` (default, read-only) reports; `refresh` points the human at opening a reconcile change-epic with yad-change (never silent); `wire` commits an advisory CI job ([skip ci] + concurrency
|
|
3
|
+
description: 'Phase 6 maintenance/CI — the change reconciler (mirrors yad-docs-sync; NEVER a gate). Detects post-lock DRIFT and ORPHANS across feature threads: shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread, a broken lineage (cycle, missing parent, or a thread cache that disagrees with the computed root), and open hotfix reconcile debt — reporting which thread drifted and WHY. `check` (default, read-only) reports; `refresh` points the human at opening a reconcile change-epic with yad-change (never silent); `wire` commits an advisory CI job ([skip ci] + concurrency) that runs the check on push. The hard merge BLOCK is the lineage-check / epic-open / reconcile-debt CI gates — this only DISCOVERS. Use when the user says "reconcile the changes", "check for thread drift", "is anything shipped without an epic", or "find open hotfix debt".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDLC — Change Reconciler (Phase 6, the drift/orphan sweep)
|
|
@@ -65,7 +65,7 @@ that feature — then thread the reconcile change off that stub (and run `yad-ba
|
|
|
65
65
|
|
|
66
66
|
### Step 3 — `wire` (advisory CI, no block)
|
|
67
67
|
Commit an advisory CI job that runs `yad reconcile --check` on push, carrying `[skip ci]` on any commit
|
|
68
|
-
it makes and a concurrency group
|
|
68
|
+
it makes and a concurrency group, so it never retriggers itself. The job **reports**;
|
|
69
69
|
it never blocks. The blocking enforcement is the `yad-checks` gates, not this.
|
|
70
70
|
|
|
71
71
|
### Step 4 — Report
|
|
@@ -15,6 +15,9 @@ reads `.sdlc/` and `reviews/` and reports.
|
|
|
15
15
|
|
|
16
16
|
## On Activation
|
|
17
17
|
|
|
18
|
+
This skill reports on epics. For people — what each team member did since the previous working day,
|
|
19
|
+
what they are working on and what is waiting — point to `yad standup` (E132) instead of rebuilding it.
|
|
20
|
+
|
|
18
21
|
### Step 1 — Resolve the epic
|
|
19
22
|
If no `EP-<slug>` was given, list the epics under `{project-root}/epics/` and ask which one (or
|
|
20
23
|
report all if the user asked for an overview).
|