yadflow 4.3.0 → 4.5.0
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 +63 -0
- package/README.md +4 -2
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +80 -14
- package/cli/claims.mjs +16 -13
- package/cli/detect.mjs +2 -2
- package/cli/docs.mjs +1 -1
- package/cli/doctor.mjs +108 -3
- package/cli/epic-state.mjs +121 -24
- package/cli/epic.mjs +7 -5
- package/cli/manifest.mjs +3 -0
- package/cli/members.mjs +822 -0
- package/cli/next.mjs +21 -9
- 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/recommend.mjs +301 -0
- package/cli/setup.mjs +3 -2
- package/cli/skill.mjs +263 -63
- package/cli/standup.mjs +709 -0
- package/cli/toolbox.mjs +433 -11
- package/cli/usage.mjs +18 -11
- package/cli/workspace.mjs +14 -5
- package/package.json +5 -3
- package/skills/sdlc/config.yaml +1 -1
- package/skills/sdlc/module-help.csv +1 -1
- package/skills/yad-backfill/SKILL.md +40 -7
- package/skills/yad-backfill/references/backfill.md +5 -4
- package/skills/yad-change/SKILL.md +1 -1
- 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-connect-design/SKILL.md +16 -0
- package/skills/yad-connect-learning/SKILL.md +14 -0
- package/skills/yad-connect-repos/SKILL.md +34 -5
- package/skills/yad-connect-repos/references/code-context.md +5 -5
- package/skills/yad-connect-testing/SKILL.md +20 -0
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-epic/references/state-schema.md +10 -2
- package/skills/yad-learn/SKILL.md +14 -0
- package/skills/yad-run/SKILL.md +7 -4
- package/skills/yad-run/references/run-loop.md +2 -1
- package/skills/yad-spec/SKILL.md +37 -6
- package/skills/yad-spec/references/spec-handoff.md +2 -2
- package/skills/yad-status/SKILL.md +3 -0
- package/skills/yad-test-cases/SKILL.md +20 -0
- package/skills/yad-ui/SKILL.md +36 -3
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.0",
|
|
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
|
},
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -217,7 +217,7 @@ build:
|
|
|
217
217
|
story_build_states: [in-build, shipped] # in-build = some tasks shipped; shipped = all tasks in tasks.md shipped
|
|
218
218
|
# Backfill (yad-backfill) — specs for already-built features in an existing repo.
|
|
219
219
|
backfill:
|
|
220
|
-
tool: repomix # the one true CLI subprocess: `npx repomix@
|
|
220
|
+
tool: repomix # the one true CLI subprocess: `npx repomix@1.18.1` (Phase 0 / RESEARCH-NOTES §3)
|
|
221
221
|
pack_flags: "--compress --include <globs> --include-logs --style markdown" # one feature at a time; Secretlint by default
|
|
222
222
|
spec_location: "specs/backfill/<feature>/spec.md" # draft (verified: false) until a human approves
|
|
223
223
|
gate_scope: touched-features # block a change only until the features it touches have approved specs
|
|
@@ -24,7 +24,7 @@ SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build helper: open a code-repo task PR/
|
|
|
24
24
|
SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build helper: commit AND open the task PR/MR in one step — a thin orchestration over yad-commit then yad-open-pr. Commits the staged atomic change by the conventions, then pushes the branch and opens the PR/MR from the committed template, assigned to the logged-in gh/glab login with no reviewers requested. The PR step runs ONLY if the commit lands (a failed commit, tripped guard, or --dry-run stops before pushing). Drives the yad ship CLI. Never merges; never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {repo: <name>} {risk: low|medium|high} {contract-change: true|false} {base: <branch>},3-build,yad-pr-template,,false,<repo>/,one commit + one PR/MR
|
|
25
25
|
SDLC Workflow,yad-product-bridge,Product Review Bridge,PB,"The templated PR/MR bridge for the Shape review gate: when the Product has a platform (.sdlc/product.json), open a review PR/MR on the Product for an authored artifact, apply the touched-domain labels (no reviewers are requested), and provide the read-only gh/glab recipes yad-review-gate's sync uses to pull platform comments + approvals into the file ledger. Local-user auth, no stored tokens; file ledger stays the source of truth; degrades to local when no platform/CLI. Never auto-advances.",,{epic: EP-<slug>} {artifact: epic.md|architecture.md|ui-design.md|stories/} {action: open|route},1-front,yad-review-gate,yad-review-gate,false,epics/EP-<slug>/.sdlc/,product-prs.json
|
|
26
26
|
SDLC Workflow,yad-engineer-review,Engineer Review & Merge,ER,"Build Step E: wire an advisory AI first-pass (CodeRabbit) on the PR, record the human engineer review with the same advance-human discipline as the Shape gates (1 approval from someone other than the author; high risk / contract / auth / payments add an advisory risk step), and on merge record the ship in epics/<epic>/.sdlc/build-log.json and update the story state. AI review is advisory, never the authority; the human owns the merge. Never auto-advances.",,{epic: EP-<slug>} {story: EP-<slug>-S0N} {task: T0N} {repo: <repo>} {action: ai-review|approve|ship},3-build,yad-ship,,false,epics/EP-<slug>/.sdlc/,build-log.json story-status
|
|
27
|
-
SDLC Workflow,yad-backfill,Backfill Specs,BF,"Build Step G: generate specs for already-built features in an existing repo. Confirm Repomix (npx repomix CLI), pack ONE feature (compress + git logs, secret-scan), feed to AI with a 'describe what exists, do not invent' prompt, write a DRAFT spec marked verified: false. Human approval (reuse yad-review-gate) makes it real. Boundary auto-proposed and human-confirmed. A change is blocked only until the features it touches have approved specs. The promote action flips a brownfield stub epic (yad-stub) to a real, verified feature epic once its backfill spec is approved. Never auto-advances.",,{repo: <repo>} {feature: <name + globs>} {action: pack|draft|approve|gate|promote} {epic: EP-<slug> (promote)},3-build,,,false,demo-repos/<repo>/specs/backfill/<feature>/,spec.md backfill-check.sh
|
|
27
|
+
SDLC Workflow,yad-backfill,Backfill Specs,BF,"Build Step G: generate specs for already-built features in an existing repo. Confirm Repomix (npx repomix@1.18.1 CLI), pack ONE feature (compress + git logs, secret-scan), feed to AI with a 'describe what exists, do not invent' prompt, write a DRAFT spec marked verified: false. Human approval (reuse yad-review-gate) makes it real. Boundary auto-proposed and human-confirmed. A change is blocked only until the features it touches have approved specs. The promote action flips a brownfield stub epic (yad-stub) to a real, verified feature epic once its backfill spec is approved. Never auto-advances.",,{repo: <repo>} {feature: <name + globs>} {action: pack|draft|approve|gate|promote} {epic: EP-<slug> (promote)},3-build,,,false,demo-repos/<repo>/specs/backfill/<feature>/,spec.md backfill-check.sh
|
|
28
28
|
SDLC Workflow,yad-run,Run (Automation),RN,"Phase 4 orchestrator: drive a story's Build loop (spec→tasks→implement→checks) in one code repo, reading each step's advance dial through the engine (yad dial --json) — on auto it advances on its own, on human it stops for a human. Records every run in the trust log, the run record yad dial shows as advice. A clean checks pass on auto advances to the engineer review; any FAIL / scope overrun / contract touch HALTS. set-dial runs yad dial (no trust threshold since E34; a gate is refused); kill/unkill run yad kill / yad unkill, recorded in .sdlc/automation.json. Never drives a Shape step; the human merge gate never auto-advances.",,{epic: EP-<slug>} {story: EP-<slug>-S0N} {repo: <repo>} {action: run|set-dial|kill|unkill} {step: <back-step>} {to: human|auto},4-automate,yad-spec,yad-engineer-review,false,epics/EP-<slug>/.sdlc/,build-state/<story-id>.json trust-log.json
|
|
29
29
|
SDLC Workflow,yad-status,SDLC Status,SS,"Read-only: print the full Shape step chain, per-step dials, contract lock, story repo tags, and pending approvals at the active gate. For stories in Build, also print each Build step's automation dial and status, the run record (runs / % approved-unchanged, as advice), the Shape author dials and the kill switch from .sdlc/automation.json.",,{epic: EP-<slug>},anytime,,,false,,
|
|
30
30
|
SDLC Workflow,yad-report,Report Issue,RP,"Self issue reporter: when a yad flow breaks, file a well-formed bug in the upstream yadflow repo with AUTO-SCRUBBED diagnostics (yadflow/node/os version, tool present+authenticated booleans, Product platform enum, the YadError code/hint, a path-scrubbed message, and the failing command + flag NAMES only) — never absolute paths, hostnames, git URLs, repo names, logins/emails, epic/story IDs, branch names, or flag values. Searches open issues first to avoid duplicates, previews the exact payload, and asks before posting to the public repo; files via an authenticated gh (the upstream repo is on GitHub) or a prefilled issues/new URL fallback. Also offered automatically after an unexpected failure (interactive only; YAD_NO_REPORT=1 disables). Never a gate, never touches epic state.",,{message: one-line summary},,,,false,,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yad-backfill
|
|
3
|
-
description: 'Build Step G of the gated SDLC — backfill: generate specs for already-built features in an existing repo so new work does not break them. Confirm Repomix (the one true CLI subprocess: npx repomix), pack ONE feature at a time (compress + git logs, secret-scan), feed it to AI with a "describe what exists, do not invent" prompt, and write a DRAFT spec marked unverified. Require human approval (reuse yad-review-gate) before the spec counts as real. Boundary is auto-proposed from the project convention and human-confirmed. A change is blocked only until the features IT touches have approved specs. The `promote` action flips a brownfield stub epic (minted by yad-stub, so defects could thread off it) to a real, verified feature epic once its backfill spec is approved. Use when the user says "backfill specs", "document an existing feature", "spec the legacy code", or "promote the stub epic".'
|
|
3
|
+
description: 'Build Step G of the gated SDLC — backfill: generate specs for already-built features in an existing repo so new work does not break them. Confirm Repomix (the one true CLI subprocess: npx repomix@1.18.1), pack ONE feature at a time (compress + git logs, secret-scan), feed it to AI with a "describe what exists, do not invent" prompt, and write a DRAFT spec marked unverified. Require human approval (reuse yad-review-gate) before the spec counts as real. Boundary is auto-proposed from the project convention and human-confirmed. A change is blocked only until the features IT touches have approved specs. The `promote` action flips a brownfield stub epic (minted by yad-stub, so defects could thread off it) to a real, verified feature epic once its backfill spec is approved. Use when the user says "backfill specs", "document an existing feature", "spec the legacy code", or "promote the stub epic".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDLC — Backfill (existing-code specs)
|
|
@@ -11,12 +11,41 @@ generating a spec for what is **already built** — so future changes have a con
|
|
|
11
11
|
it count. Gating is **per touched feature**: a new change is blocked only until the features it touches
|
|
12
12
|
have approved specs — never the whole repo at once.
|
|
13
13
|
|
|
14
|
+
## When a tool is missing
|
|
15
|
+
|
|
16
|
+
<!-- Written from yadflow's cli/toolbox.mjs (E87). Change the words there: a test holds this section to it. -->
|
|
17
|
+
|
|
18
|
+
No tool below is required. When one is missing, this skill still finishes, using the fallback written
|
|
19
|
+
beside it.
|
|
20
|
+
|
|
21
|
+
For Repomix, decide from yad's answer, not from a guess:
|
|
22
|
+
run `yad toolbox list --json` and read, in its `tools` list, the entry with the tool's `id`. Run it
|
|
23
|
+
where the tool will run: for work inside a code repo, add `--dir <that repo>`, so the tools installed
|
|
24
|
+
there are found.
|
|
25
|
+
|
|
26
|
+
- **The team's choices.** They come from the Product. If the answer's `product` is null, yad found no
|
|
27
|
+
Product, so a tool the team chose not to use still shows `used: true`. Tell the person that.
|
|
28
|
+
- **When yad cannot see the tool.** yad does not read every way a tool can be set up (an older install
|
|
29
|
+
may be missed). If the entry says `missing` but the check in the steps below finds the tool, use
|
|
30
|
+
it — but never when the entry says `used: false`.
|
|
31
|
+
- **When yad cannot answer** (yad is not installed here, or the command fails), use the check in the
|
|
32
|
+
steps below.
|
|
33
|
+
|
|
34
|
+
Tell the person which tools you used and which fallbacks, and why.
|
|
35
|
+
|
|
36
|
+
- **Repomix** (`repomix`). Use it when its entry has `used: true` and `status.state` is `installed` or `available`. Otherwise — not found, its plugin turned off in Claude Code, or the team chose not to use it (`yad toolbox remove repomix`) — use the fallback.
|
|
37
|
+
- Fallback — no Repomix pack. yad-connect-repos and yad-backfill put the same context together by hand, from the source tree and the recent git log, and the Shape steps read the code map.
|
|
38
|
+
- Record `source: repomix-unavailable`. It means the tool was not used, whatever the reason.
|
|
39
|
+
|
|
40
|
+
<!-- end: When a tool is missing -->
|
|
41
|
+
|
|
14
42
|
## Conventions
|
|
15
43
|
|
|
16
44
|
- `{project-root}` resolves from the project working directory; code repos are separate git repos under
|
|
17
45
|
`{project-root}/demo-repos/<repo>/`.
|
|
18
|
-
- **Repomix is a true CLI subprocess** (Phase 0 / RESEARCH-NOTES §3): `npx repomix@
|
|
19
|
-
NOT a slash-command.
|
|
46
|
+
- **Repomix is a true CLI subprocess** (Phase 0 / RESEARCH-NOTES §3): `npx repomix@1.18.1 [flags]` —
|
|
47
|
+
NOT a slash-command. Run exactly that version: it is the one yadflow vetted, and `@latest` would
|
|
48
|
+
take a release nobody checked. It secret-scans by default (Secretlint).
|
|
20
49
|
- Backfilled specs live in the code repo at `specs/backfill/<feature>/spec.md`.
|
|
21
50
|
- A **feature** is the project's natural unit from the constitution (e.g. a module / a `src/<feature>/`
|
|
22
51
|
directory). Auto-propose the boundary from the convention; **a human confirms** it where the code
|
|
@@ -40,12 +69,13 @@ guess silently where the code breaks the convention.
|
|
|
40
69
|
### Step 2 — `pack` (Repomix, one feature)
|
|
41
70
|
Run, from inside the repo, over **only this feature's files**:
|
|
42
71
|
```
|
|
43
|
-
npx repomix@
|
|
72
|
+
npx repomix@1.18.1 --compress --include "<feature globs>" --include-logs --style markdown -o <out>.md
|
|
44
73
|
```
|
|
45
74
|
`--compress` (Tree-sitter structural compression) keeps it small; `--include-logs` adds the relevant
|
|
46
75
|
git history (default 50; `--include-logs-count N` to change); Secretlint secret-scans by default. If a
|
|
47
|
-
secret is reported, STOP and have it removed/redacted before continuing. (If
|
|
48
|
-
|
|
76
|
+
secret is reported, STOP and have it removed/redacted before continuing. (If Repomix is not
|
|
77
|
+
used — see **When a tool is missing** — degrade: hand-assemble the same feature context and record
|
|
78
|
+
`source: repomix-unavailable`.)
|
|
49
79
|
|
|
50
80
|
### Step 3 — `draft` (describe what exists — do NOT invent)
|
|
51
81
|
Feed the packed context to the AI with the **"describe what exists, do not invent"** instruction
|
|
@@ -58,10 +88,13 @@ repo: <repo>
|
|
|
58
88
|
artifact: backfill-spec
|
|
59
89
|
status: draft
|
|
60
90
|
verified: false
|
|
61
|
-
source: repomix
|
|
91
|
+
source: repomix
|
|
62
92
|
generated: <YYYY-MM-DD>
|
|
63
93
|
---
|
|
64
94
|
```
|
|
95
|
+
|
|
96
|
+
When Repomix was not used, write `source: repomix-unavailable` instead.
|
|
97
|
+
|
|
65
98
|
`verified: false` means the spec is not real until a human approves it. Keep that line free of a `#`
|
|
66
99
|
comment: the gate reads the whole line, so `verified: true # approved` would not count. Mark every
|
|
67
100
|
uncertain item explicitly (`<!-- unverified: ... -->`); do not fill gaps with invented behaviour.
|
|
@@ -6,10 +6,10 @@ the whole repo.
|
|
|
6
6
|
|
|
7
7
|
## Repomix (the one true CLI subprocess)
|
|
8
8
|
|
|
9
|
-
`npx repomix@
|
|
9
|
+
`npx repomix@1.18.1 [flags]` (Phase 0 / RESEARCH-NOTES §3). For a single feature:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
npx repomix@
|
|
12
|
+
npx repomix@1.18.1 --compress --include "src/<feature>/**" --include-logs --style markdown -o <out>.md
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
- `--compress` — Tree-sitter structural compression (keeps the pack small and signal-dense).
|
|
@@ -18,8 +18,9 @@ npx repomix@latest --compress --include "src/<feature>/**" --include-logs --styl
|
|
|
18
18
|
- `--style markdown` — human/AI-readable; default output is `repomix-output.xml`.
|
|
19
19
|
- **Secretlint runs by default** — if a secret is reported, STOP and redact before any AI sees the code.
|
|
20
20
|
|
|
21
|
-
If
|
|
22
|
-
recent git log for those paths) and record
|
|
21
|
+
If Repomix is not used (the skill's "When a tool is missing" section says how to tell), degrade:
|
|
22
|
+
hand-assemble the same feature context (the feature's files + recent git log for those paths) and record
|
|
23
|
+
`source: repomix-unavailable` in the spec frontmatter.
|
|
23
24
|
|
|
24
25
|
## The "describe what exists, do not invent" prompt
|
|
25
26
|
|
|
@@ -249,7 +249,7 @@ is paid (Shape artifacts updated **and** a regression test added, then `status:
|
|
|
249
249
|
Report: the new `EP-<slug>`, its thread + parent, the re-author-vs-inherit split, the seeded
|
|
250
250
|
`currentStep`, and the next skill — run `yad next EP-<slug>` and name what it names, followed by
|
|
251
251
|
`yad-review-gate`. Do NOT name a skill from memory: which skill runs a step is the project's setting
|
|
252
|
-
(`.sdlc/skills.json
|
|
252
|
+
(`.sdlc/skills.json`, for every epic or for this epic's route), so a team that bound its own would be sent to one this epic will never use
|
|
253
253
|
again. Unbound, that command names `yad-architecture` for a contract-surface change and `yad-stories`
|
|
254
254
|
/ `yad-test-cases` otherwise, which is what this step used to say. Shape steps do not auto-advance. Suggest
|
|
255
255
|
`yad thread <thread>` to see the evolution and `yad-timeline` / `yad-defects` to render it.
|
|
@@ -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"
|
|
@@ -16,6 +16,22 @@ connected and its MCP is available, the `ux-designer` lens **generates** screens
|
|
|
16
16
|
**links** an existing human-made design and reads it back); when nothing is connected, `yad-ui` runs
|
|
17
17
|
markdown-only exactly as before.
|
|
18
18
|
|
|
19
|
+
## When a tool is missing
|
|
20
|
+
|
|
21
|
+
<!-- Written from yadflow's cli/toolbox.mjs (E87). Change the words there: a test holds this section to it. -->
|
|
22
|
+
|
|
23
|
+
No tool below is required. When one is missing, this skill still finishes, using the fallback written
|
|
24
|
+
beside it.
|
|
25
|
+
|
|
26
|
+
Tell the person which tools you used and which fallbacks, and why.
|
|
27
|
+
|
|
28
|
+
- **Figma** (`figma`, a connector). This skill connects it, by writing `.sdlc/design.json` as the steps below say. The other skills follow that file, not yad's toolbox answer.
|
|
29
|
+
- Fallback — markdown-only: yad-ui writes ui-design.md and DESIGN.md only.
|
|
30
|
+
- **Pencil (pen.dev)** (`pencil`, a connector). This skill connects it, by writing `.sdlc/design.json` as the steps below say. The other skills follow that file, not yad's toolbox answer.
|
|
31
|
+
- Fallback — markdown-only: yad-ui writes ui-design.md and DESIGN.md only.
|
|
32
|
+
|
|
33
|
+
<!-- end: When a tool is missing -->
|
|
34
|
+
|
|
19
35
|
## Conventions
|
|
20
36
|
|
|
21
37
|
- `{project-root}` resolves from the project working directory (the **Product**).
|
|
@@ -17,6 +17,20 @@ available it drives the CLI (grounded in the knowledge base); when nothing is co
|
|
|
17
17
|
degrades to **harness-native** tutoring — the harness model reads the project artifacts directly and
|
|
18
18
|
explains the concept. The learning layer is **purely opt-in and never blocks a gate**.
|
|
19
19
|
|
|
20
|
+
## When a tool is missing
|
|
21
|
+
|
|
22
|
+
<!-- Written from yadflow's cli/toolbox.mjs (E87). Change the words there: a test holds this section to it. -->
|
|
23
|
+
|
|
24
|
+
No tool below is required. When one is missing, this skill still finishes, using the fallback written
|
|
25
|
+
beside it.
|
|
26
|
+
|
|
27
|
+
Tell the person which tools you used and which fallbacks, and why.
|
|
28
|
+
|
|
29
|
+
- **DeepTutor** (`deeptutor`, a connector). This skill connects it, by writing `.sdlc/learning.json` as the steps below say. The other skills follow that file, not yad's toolbox answer.
|
|
30
|
+
- Fallback — harness-native tutoring: yad-learn has your agent read the project files directly.
|
|
31
|
+
|
|
32
|
+
<!-- end: When a tool is missing -->
|
|
33
|
+
|
|
20
34
|
## Conventions
|
|
21
35
|
|
|
22
36
|
- `{project-root}` resolves from the project working directory (the **Product**).
|