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/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
- const events = [...gitAuthoredEvents(root)];
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
274
+ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
- // NOTHING HERE WRITES OUTSIDE THIS MACHINE. `new` never creates a remote — it prints the exact
23
- // `gh repo create` / `glab repo create` line. `join` never commits, never pushes, and never changes a
24
- // file the team shares: it installs only what git ignores in the Product (the per-machine skill copies)
25
- // and the git pre-commit hook, which git never shares (E48).
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.4.0",
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 rebuilds on push (carrying [skip ci] + a concurrency group to prevent deploy loops). 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
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, like yad-docs-sync). 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
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/CI reconciler that enforces overview regeneration: `../yad-docs-sync/SKILL.md`.
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 runs the check on push and rebuilds on staleness. 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".'
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 automates the check + rebuild.
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, run **`yad docs sync --check`**; on detected staleness, **rebuild + deploy** the affected
77
- site(s);
78
- - carry **`[skip ci]`** on any commit the workflow itself makes (the regenerated source / manifest) and
79
- a **concurrency group** (one docs deploy at a time) — both to **prevent deploy loops** (a rebuild must
80
- not retrigger the workflow). See `references/staleness.md`.
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
- - **Loop-prevention is mandatory in CI.** The wired workflow must carry `[skip ci]` on its own commits
95
- and a concurrency group so a deploy never retriggers a deploy.
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 loop-prevention note: `references/staleness.md`.
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 loop-prevention note
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`) runs
72
- `yad docs sync --check` on push and rebuilds + deploys on staleness. Because the rebuild **commits** the
73
- regenerated `src/data/*.ts` + refreshed `docs-build.json`, that commit would re-trigger the same workflow
74
- — a deploy loop. Two guards, both mandatory:
75
-
76
- - **`[skip ci]`** in the message of any commit the workflow itself makes (the regenerated source +
77
- manifest), so the workflow does not re-fire on its own output;
78
- - a **concurrency group** (e.g. `concurrency: { group: yad-docs-deploy, cancel-in-progress: true }`) so
79
- at most one docs build/deploy runs at a time, and a queued one is superseded rather than stacking.
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, like yad-docs-sync) 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".'
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 — the same loop-prevention `yad-docs-sync` uses. The job **reports**;
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).