yadflow 2.13.0 → 2.15.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 +2 -2
- package/README.md +28 -16
- package/bin/yad.mjs +55 -9
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +35 -2
- package/cli/epic-state.mjs +86 -11
- package/cli/gate.mjs +130 -60
- package/cli/lib.mjs +3 -0
- package/cli/manifest.mjs +2 -0
- package/cli/next.mjs +123 -0
- package/cli/platform.mjs +44 -6
- package/cli/repo.mjs +8 -4
- package/cli/setup.mjs +215 -80
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +8 -0
- package/skills/yad-analysis/SKILL.md +6 -3
- package/skills/yad-architecture/SKILL.md +8 -3
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +8 -0
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +7 -0
- package/skills/yad-epic/SKILL.md +4 -1
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-review-gate/SKILL.md +12 -10
- package/skills/yad-stories/SKILL.md +8 -3
- package/skills/yad-test-cases/SKILL.md +10 -5
- package/skills/yad-ui/SKILL.md +8 -3
package/cli/setup.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import {
|
|
6
|
-
c, log, step, ok, info, warn, hand, fail, ask, askYesNo, run, has,
|
|
6
|
+
c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
|
|
7
7
|
exists, readJSON, writeJSON,
|
|
8
8
|
} from './lib.mjs';
|
|
9
9
|
import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
|
|
@@ -176,7 +176,7 @@ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
|
|
|
176
176
|
ok(` ${repo}: ${want.length ? want.join(', ') : 'cleared'}`);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
|
|
179
|
+
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
|
|
180
180
|
if (!insideRoot(root, rpath)) {
|
|
181
181
|
warn(`${rpath} resolves outside the project root — skipped`);
|
|
182
182
|
return null;
|
|
@@ -198,7 +198,10 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
|
|
|
198
198
|
name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
|
|
199
199
|
domain_owner: owners[0] || '', domain_owners: owners, default_branch,
|
|
200
200
|
connectedAt: today, lastSyncedAt: today,
|
|
201
|
-
|
|
201
|
+
// Only claim a synced HEAD when a pack is actually produced. The greenfield path skips packing
|
|
202
|
+
// (pack:false), so leave syncedHead null — the repo then reads as "needs an initial pack" in
|
|
203
|
+
// `yad repo list` / `yad doctor` instead of falsely "fresh".
|
|
204
|
+
syncedHead: pack ? head : null,
|
|
202
205
|
contextPack: `.sdlc/code-context/${name}/pack.md`,
|
|
203
206
|
codeMap: `.sdlc/code-context/${name}/code-map.md`,
|
|
204
207
|
source: 'repomix',
|
|
@@ -305,13 +308,70 @@ function applyActions(actions, { force = false } = {}) {
|
|
|
305
308
|
return changed;
|
|
306
309
|
}
|
|
307
310
|
|
|
311
|
+
// Step 0 — resolve the setup profile that branches the rest of the wizard. Flags pre-answer each
|
|
312
|
+
// question (CI/scripts); an existing hub.json carries prior answers forward (idempotent re-run);
|
|
313
|
+
// otherwise we prompt with a default. Pure of side effects — it only reads. Returns
|
|
314
|
+
// { solo, team_size, codebase, repo_layout, configureTools }.
|
|
315
|
+
export async function resolveProfile(root, opts = {}) {
|
|
316
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
|
|
317
|
+
const prev = (hub && hub.profile) || {};
|
|
318
|
+
|
|
319
|
+
// 1. Solo or team (+ size). --solo / --team <n> win; else carry hub.solo forward; else ask.
|
|
320
|
+
let solo, team_size;
|
|
321
|
+
if (opts.solo) { solo = true; team_size = 1; }
|
|
322
|
+
else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
|
|
323
|
+
else if (typeof hub?.solo === 'boolean') { solo = hub.solo; team_size = prev.team_size ?? (solo ? 1 : 2); }
|
|
324
|
+
else {
|
|
325
|
+
// Default from any existing roster: a hub already carrying reviewers is a team; otherwise solo.
|
|
326
|
+
const rosterN = Array.isArray(hub?.roster) ? hub.roster.length : 0;
|
|
327
|
+
solo = !(await ask('Solo or team?', rosterN > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
|
|
328
|
+
team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(rosterN || 2)), 10) || 2);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 2. Greenfield (new code) or brownfield (existing code).
|
|
332
|
+
let codebase;
|
|
333
|
+
if (opts.greenfield) codebase = 'greenfield';
|
|
334
|
+
else if (opts.brownfield) codebase = 'brownfield';
|
|
335
|
+
else if (prev.codebase) codebase = prev.codebase;
|
|
336
|
+
else codebase = (await ask('Greenfield (new code) or brownfield (existing code)?', 'greenfield')).toLowerCase().startsWith('b') ? 'brownfield' : 'greenfield';
|
|
337
|
+
|
|
338
|
+
// 3. Monorepo (one repo) or separate repos.
|
|
339
|
+
let repo_layout;
|
|
340
|
+
if (opts.monorepo) repo_layout = 'monorepo';
|
|
341
|
+
else if (opts.separate) repo_layout = 'separate';
|
|
342
|
+
else if (prev.repo_layout) repo_layout = prev.repo_layout;
|
|
343
|
+
else repo_layout = (await ask('Monorepo (one repo) or separate repos?', 'monorepo')).toLowerCase().startsWith('s') ? 'separate' : 'monorepo';
|
|
344
|
+
|
|
345
|
+
// 4. Configure the optional tools now, or defer (records them as none, connect later).
|
|
346
|
+
const configureTools = opts.tools === true ? true
|
|
347
|
+
: process.env.SDLC_NONINTERACTIVE ? false
|
|
348
|
+
: await askYesNo('Configure design/testing/learning tools now? (else connect them later)', false);
|
|
349
|
+
|
|
350
|
+
return { solo, team_size, codebase, repo_layout, configureTools };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The guided, idempotent first-run wizard: a Step 0 profile interview (resolveProfile) that branches
|
|
354
|
+
// the remaining steps — install, hub + roster, optional tools, repos, wiring — and persists the profile.
|
|
308
355
|
export async function runSetup(root, opts = {}) {
|
|
309
|
-
const total = 10;
|
|
310
356
|
log(c.bold(`\nSDLC Workflow setup ${c.dim('v' + VERSION)}`));
|
|
311
357
|
log(c.dim(`target: ${root}`));
|
|
312
358
|
|
|
313
|
-
//
|
|
314
|
-
|
|
359
|
+
// 0. Profile interview — branch the wizard to the user's situation (solo/team, code, repo layout).
|
|
360
|
+
const { solo, team_size, codebase, repo_layout, configureTools } = await resolveProfile(root, opts);
|
|
361
|
+
// Steps: interview, preflight, install, hub, tools (1 if deferred else 3), repos, wire, coderabbit, done.
|
|
362
|
+
const total = 8 + (configureTools ? 3 : 1);
|
|
363
|
+
let _n = 0;
|
|
364
|
+
const S = (title) => step(++_n, total, title);
|
|
365
|
+
|
|
366
|
+
S('Profile');
|
|
367
|
+
guide([
|
|
368
|
+
'How you answer here shapes the rest of setup — fewer prompts, the right path.',
|
|
369
|
+
`solo: ${solo ? 'yes — you review by merging your own PR (approval waived)' : `no — team of ${team_size}`}`,
|
|
370
|
+
`code: ${codebase} • repos: ${repo_layout} • optional tools: ${configureTools ? 'configure now' : 'deferred (connect later)'}`,
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
// Preflight
|
|
374
|
+
S('Preflight');
|
|
315
375
|
if (!exists(path.join(root, '.git'))) {
|
|
316
376
|
if (await askYesNo('Not a git repo. Run `git init` here?', true)) {
|
|
317
377
|
run('git', ['init'], { cwd: root });
|
|
@@ -321,8 +381,12 @@ export async function runSetup(root, opts = {}) {
|
|
|
321
381
|
for (const tool of ['git', 'node']) has(tool) ? ok(`${tool} present`) : warn(`${tool} not found on PATH`);
|
|
322
382
|
if (!has('npx')) warn('npx not found — repomix packing will be skipped');
|
|
323
383
|
|
|
324
|
-
//
|
|
325
|
-
|
|
384
|
+
// Install the module
|
|
385
|
+
S('Install the module (skills + _bmad registration)');
|
|
386
|
+
guide([
|
|
387
|
+
'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
|
|
388
|
+
'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
|
|
389
|
+
]);
|
|
326
390
|
let ideTargets = opts.ideTargets;
|
|
327
391
|
if (!ideTargets) {
|
|
328
392
|
const present = ALL_IDES.filter((d) => exists(path.join(root, d)));
|
|
@@ -352,8 +416,18 @@ export async function runSetup(root, opts = {}) {
|
|
|
352
416
|
}
|
|
353
417
|
}
|
|
354
418
|
|
|
355
|
-
//
|
|
356
|
-
|
|
419
|
+
// Detect hub platform + roster
|
|
420
|
+
S(solo ? 'Hub platform (solo — no roster)' : 'Hub platform & reviewer roster');
|
|
421
|
+
guide(solo
|
|
422
|
+
? [
|
|
423
|
+
'Your hub is this repo on GitHub/GitLab (or none for a file-only gate).',
|
|
424
|
+
'Solo: no roster needed — you review by merging your own PR (approval waived).',
|
|
425
|
+
]
|
|
426
|
+
: [
|
|
427
|
+
'Your hub is this repo on GitHub/GitLab; reviewers approve artifacts there.',
|
|
428
|
+
`Add your ${team_size}-person roster: platform login → yad name → hub role (owner/reviewer).`,
|
|
429
|
+
'An owner + 1 reviewer is required to pass a gate; skip now and add later with `yad roster add`.',
|
|
430
|
+
]);
|
|
357
431
|
const hubPath = path.join(root, PROJECT_FILES.hubConfig);
|
|
358
432
|
if (exists(hubPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
|
|
359
433
|
info('keeping existing .sdlc/hub.json');
|
|
@@ -367,7 +441,8 @@ export async function runSetup(root, opts = {}) {
|
|
|
367
441
|
platform = 'none';
|
|
368
442
|
}
|
|
369
443
|
const roster = [];
|
|
370
|
-
|
|
444
|
+
// Solo mode needs no roster — the lone developer is owner and reviewer-by-merge.
|
|
445
|
+
if (!solo && await askYesNo('Add reviewers to the roster now?', true)) {
|
|
371
446
|
for (;;) {
|
|
372
447
|
const login = await ask(' reviewer platform login (blank to finish)', '');
|
|
373
448
|
if (!login) break;
|
|
@@ -392,68 +467,113 @@ export async function runSetup(root, opts = {}) {
|
|
|
392
467
|
// `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
|
|
393
468
|
// for anything that still reads it.
|
|
394
469
|
const enabled = platform !== 'none';
|
|
395
|
-
writeJSON(hubPath, { platform: enabled ? platform : null, bridge_enabled: enabled, bridge: enabled, default_branch, roster });
|
|
396
|
-
ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s))`);
|
|
470
|
+
writeJSON(hubPath, { platform: enabled ? platform : null, bridge_enabled: enabled, bridge: enabled, default_branch, roster, solo, profile: { codebase, repo_layout, team_size } });
|
|
471
|
+
ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
|
|
397
472
|
}
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let tool = (await ask(`Design tool (${DESIGN_TOOLS.join('/')}/none)`, DESIGN_PRIMARY)).toLowerCase();
|
|
406
|
-
if (![...DESIGN_TOOLS, 'none'].includes(tool)) {
|
|
407
|
-
warn(`unknown design tool '${tool}' — using ${DESIGN_PRIMARY}`);
|
|
408
|
-
tool = DESIGN_PRIMARY;
|
|
473
|
+
// Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
|
|
474
|
+
// flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
|
|
475
|
+
if (exists(hubPath)) {
|
|
476
|
+
const cur = readJSON(hubPath, {}) || {};
|
|
477
|
+
if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size })) {
|
|
478
|
+
writeJSON(hubPath, { ...cur, solo, profile: { codebase, repo_layout, team_size } });
|
|
479
|
+
info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
|
|
409
480
|
}
|
|
410
|
-
const project_url = tool === 'none' ? null : (await ask(' project/file URL (blank to set later)', '')) || null;
|
|
411
|
-
registerDesign(root, { tool, project_url, today: opts.today ?? null });
|
|
412
|
-
ok(tool === 'none'
|
|
413
|
-
? `wrote ${PROJECT_FILES.designConfig} (markdown-only)`
|
|
414
|
-
: `wrote ${PROJECT_FILES.designConfig} (${tool})`);
|
|
415
481
|
}
|
|
416
482
|
|
|
417
|
-
//
|
|
418
|
-
|
|
483
|
+
// Optional tools (design / testing / learning). Paths are declared here so the final summary can
|
|
484
|
+
// read them whether or not we configured the tools this run.
|
|
485
|
+
const designPath = path.join(root, PROJECT_FILES.designConfig);
|
|
419
486
|
const testingPath = path.join(root, PROJECT_FILES.testingConfig);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
487
|
+
const learningPath = path.join(root, PROJECT_FILES.learningConfig);
|
|
488
|
+
if (configureTools) {
|
|
489
|
+
// Connect a design tool (Figma-first, pluggable; the UI step materializes the design here)
|
|
490
|
+
S('Connect a design tool (Figma / pencil / none)');
|
|
491
|
+
guide([
|
|
492
|
+
'Where yad-ui materializes real screens. figma (confirm the MCP later) or none for markdown-only.',
|
|
493
|
+
'Skipping is safe — the UI step degrades to ui-design.md.',
|
|
494
|
+
]);
|
|
495
|
+
if (exists(designPath) && !(await askYesNo('design.json exists — reconfigure?', false))) {
|
|
496
|
+
info('keeping existing .sdlc/design.json');
|
|
497
|
+
} else {
|
|
498
|
+
let tool = (await ask(`Design tool (${DESIGN_TOOLS.join('/')}/none)`, DESIGN_PRIMARY)).toLowerCase();
|
|
499
|
+
if (![...DESIGN_TOOLS, 'none'].includes(tool)) {
|
|
500
|
+
warn(`unknown design tool '${tool}' — using ${DESIGN_PRIMARY}`);
|
|
501
|
+
tool = DESIGN_PRIMARY;
|
|
502
|
+
}
|
|
503
|
+
const project_url = tool === 'none' ? null : (await ask(' project/file URL (blank to set later)', '')) || null;
|
|
504
|
+
registerDesign(root, { tool, project_url, today: opts.today ?? null });
|
|
505
|
+
ok(tool === 'none'
|
|
506
|
+
? `wrote ${PROJECT_FILES.designConfig} (markdown-only)`
|
|
507
|
+
: `wrote ${PROJECT_FILES.designConfig} (${tool})`);
|
|
427
508
|
}
|
|
428
|
-
const project_url = tool === 'none' ? null : (await ask(' project/config reference (blank to set later)', '')) || null;
|
|
429
|
-
registerTesting(root, { tool, project_url, today: opts.today ?? null });
|
|
430
|
-
ok(tool === 'none'
|
|
431
|
-
? `wrote ${PROJECT_FILES.testingConfig} (artifacts-only)`
|
|
432
|
-
: `wrote ${PROJECT_FILES.testingConfig} (${tool})`);
|
|
433
|
-
}
|
|
434
509
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
tool =
|
|
510
|
+
// Connect a testing tool (Playwright-first, pluggable; the test-cases step implements automation here)
|
|
511
|
+
S('Connect a testing tool (playwright / cypress / pytest / none)');
|
|
512
|
+
guide([
|
|
513
|
+
'Where yad-test-cases generates automation. playwright/cypress/pytest, or none for artifacts-only.',
|
|
514
|
+
'Skipping is safe — test-cases authors test-cases.md only.',
|
|
515
|
+
]);
|
|
516
|
+
if (exists(testingPath) && !(await askYesNo('testing.json exists — reconfigure?', false))) {
|
|
517
|
+
info('keeping existing .sdlc/testing.json');
|
|
518
|
+
} else {
|
|
519
|
+
let tool = (await ask(`Testing tool (${TESTING_TOOLS.join('/')}/none)`, TESTING_PRIMARY)).toLowerCase();
|
|
520
|
+
if (![...TESTING_TOOLS, 'none'].includes(tool)) {
|
|
521
|
+
warn(`unknown testing tool '${tool}' — using ${TESTING_PRIMARY}`);
|
|
522
|
+
tool = TESTING_PRIMARY;
|
|
523
|
+
}
|
|
524
|
+
const project_url = tool === 'none' ? null : (await ask(' project/config reference (blank to set later)', '')) || null;
|
|
525
|
+
registerTesting(root, { tool, project_url, today: opts.today ?? null });
|
|
526
|
+
ok(tool === 'none'
|
|
527
|
+
? `wrote ${PROJECT_FILES.testingConfig} (artifacts-only)`
|
|
528
|
+
: `wrote ${PROJECT_FILES.testingConfig} (${tool})`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Connect a learning tool (DeepTutor-first, pluggable; the learning layer tutors team members here)
|
|
532
|
+
S('Connect a learning tool (deeptutor / none)');
|
|
533
|
+
guide([
|
|
534
|
+
'Lets any team member invoke yad-learn to be tutored in-context. deeptutor (a CLI), or none.',
|
|
535
|
+
'Skipping is safe — yad-learn tutors via the harness model (harness-native).',
|
|
536
|
+
]);
|
|
537
|
+
if (exists(learningPath) && !(await askYesNo('learning.json exists — reconfigure?', false))) {
|
|
538
|
+
info('keeping existing .sdlc/learning.json');
|
|
539
|
+
} else {
|
|
540
|
+
let tool = (await ask(`Learning tool (${LEARNING_TOOLS.join('/')}/none)`, LEARNING_PRIMARY)).toLowerCase();
|
|
541
|
+
if (![...LEARNING_TOOLS, 'none'].includes(tool)) {
|
|
542
|
+
warn(`unknown learning tool '${tool}' — using ${LEARNING_PRIMARY}`);
|
|
543
|
+
tool = LEARNING_PRIMARY;
|
|
544
|
+
}
|
|
545
|
+
registerLearning(root, { tool, today: opts.today ?? null });
|
|
546
|
+
ok(tool === 'none'
|
|
547
|
+
? `wrote ${PROJECT_FILES.learningConfig} (harness-native)`
|
|
548
|
+
: `wrote ${PROJECT_FILES.learningConfig} (${tool})`);
|
|
445
549
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
550
|
+
} else {
|
|
551
|
+
// Deferred: record any not-yet-present tool as none (degrades gracefully). Existing connections kept.
|
|
552
|
+
S('Optional tools (design / testing / learning) — deferred');
|
|
553
|
+
guide(['Recorded as none; connect any later with the yad-connect-* skills. Existing connections are kept.']);
|
|
554
|
+
if (!exists(designPath)) registerDesign(root, { tool: 'none', project_url: null, today: opts.today ?? null });
|
|
555
|
+
if (!exists(testingPath)) registerTesting(root, { tool: 'none', project_url: null, today: opts.today ?? null });
|
|
556
|
+
if (!exists(learningPath)) registerLearning(root, { tool: 'none', today: opts.today ?? null });
|
|
557
|
+
info('design / testing / learning recorded as none (connect later)');
|
|
450
558
|
}
|
|
451
559
|
|
|
452
|
-
//
|
|
453
|
-
|
|
560
|
+
// Connect code repos
|
|
561
|
+
S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
|
|
562
|
+
guide(repo_layout === 'monorepo'
|
|
563
|
+
? [
|
|
564
|
+
'One repo holds all the code; the contract lives in the hub and stories tag this single repo.',
|
|
565
|
+
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the front phases see what already exists.',
|
|
566
|
+
]
|
|
567
|
+
: [
|
|
568
|
+
'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
|
|
569
|
+
'Per repo: name → path (inside this project) → platform → domain owner(s).',
|
|
570
|
+
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the front phases see what already exists.',
|
|
571
|
+
]);
|
|
454
572
|
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
455
573
|
const registry = readJSON(regPath, { repos: [] });
|
|
456
574
|
const known = new Set(registry.repos.map((r) => r.name));
|
|
575
|
+
const greenfield = codebase === 'greenfield';
|
|
576
|
+
const mono = repo_layout === 'monorepo';
|
|
457
577
|
if (await askYesNo(`Connect a code repo? ${c.dim(`(${registry.repos.length} already registered)`)}`, registry.repos.length === 0)) {
|
|
458
578
|
for (;;) {
|
|
459
579
|
const name = await ask(' repo name (blank to finish)', '');
|
|
@@ -463,26 +583,28 @@ export async function runSetup(root, opts = {}) {
|
|
|
463
583
|
if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root — skipped`); continue; }
|
|
464
584
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
465
585
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
const domain_owners = parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
|
|
469
|
-
const repoReviewers = parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
|
|
470
|
-
const repoOwners = parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
|
|
586
|
+
// Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
|
|
587
|
+
// skip these prompts — there is no second person to route to.
|
|
588
|
+
const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
|
|
589
|
+
const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
|
|
590
|
+
const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
|
|
471
591
|
const default_branch = await ask(' default branch', 'main');
|
|
472
|
-
const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null });
|
|
592
|
+
const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null, pack: !greenfield });
|
|
473
593
|
if (!repo) continue;
|
|
474
594
|
addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
|
|
475
595
|
known.add(name);
|
|
476
596
|
ok(`registered ${name}`);
|
|
477
|
-
|
|
597
|
+
if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
|
|
598
|
+
else packRepo(root, repo);
|
|
599
|
+
if (mono) { info('monorepo — one repo connected; stop here'); break; }
|
|
478
600
|
}
|
|
479
601
|
}
|
|
480
602
|
|
|
481
|
-
//
|
|
482
|
-
// repos you add now; this closes the gap so a member's
|
|
483
|
-
//
|
|
603
|
+
// Assign/update roles for ALREADY-connected repos. Skipped in solo mode (no roster). The connect loop
|
|
604
|
+
// above only prompts for repos you add now; this closes the gap so a member's role on a repo connected
|
|
605
|
+
// in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
|
|
484
606
|
const hub7 = readJSON(hubPath, null);
|
|
485
|
-
if (registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
|
|
607
|
+
if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
|
|
486
608
|
&& await askYesNo('Assign/update roles for connected repos?', false)) {
|
|
487
609
|
for (const member of hub7.roster) {
|
|
488
610
|
if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
|
|
@@ -496,8 +618,9 @@ export async function runSetup(root, opts = {}) {
|
|
|
496
618
|
}
|
|
497
619
|
}
|
|
498
620
|
|
|
499
|
-
//
|
|
500
|
-
|
|
621
|
+
// Wire each connected repo + the hub itself
|
|
622
|
+
S('Wire connected repos + the hub (CI gates, PR template, comment scaffold, gate-sync)');
|
|
623
|
+
guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
|
|
501
624
|
if (registry.repos.length === 0) info('no repos to wire');
|
|
502
625
|
for (const repo of registry.repos) {
|
|
503
626
|
log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
|
|
@@ -516,8 +639,9 @@ export async function runSetup(root, opts = {}) {
|
|
|
516
639
|
// author allowlists for the verified-commits gate (hub + every repo), from the roster emails
|
|
517
640
|
applyActions(authorsActions(root, registry.repos), { force: true });
|
|
518
641
|
|
|
519
|
-
//
|
|
520
|
-
|
|
642
|
+
// Optional CodeRabbit
|
|
643
|
+
S('AI review (CodeRabbit)');
|
|
644
|
+
guide(['Advisory AI first-pass on PRs — never the authority. Opt in per repo; safe to skip.']);
|
|
521
645
|
for (const repo of registry.repos) {
|
|
522
646
|
const cr = path.join(path.resolve(root, repo.path), '.coderabbit.yaml');
|
|
523
647
|
if (exists(cr)) { info(`${repo.name}: .coderabbit.yaml present`); continue; }
|
|
@@ -527,13 +651,25 @@ export async function runSetup(root, opts = {}) {
|
|
|
527
651
|
}
|
|
528
652
|
}
|
|
529
653
|
|
|
530
|
-
//
|
|
531
|
-
|
|
654
|
+
// Summary + version stamp
|
|
655
|
+
S('Done');
|
|
532
656
|
writeJSON(path.join(root, PROJECT_FILES.version), { version: VERSION, ideTargets, updatedAt: opts.today ?? null });
|
|
533
657
|
ok(`stamped ${PROJECT_FILES.version} (v${VERSION})`);
|
|
534
658
|
log('');
|
|
535
|
-
|
|
536
|
-
|
|
659
|
+
// Tailored fastest path to the first epic, by profile.
|
|
660
|
+
log(c.bold('Next:'));
|
|
661
|
+
if (codebase === 'brownfield' && registry.repos.length) {
|
|
662
|
+
hand('capture what already exists first: run `yad-backfill`, then your first epic with `yad-epic`');
|
|
663
|
+
} else {
|
|
664
|
+
hand('author your first epic: run `yad-epic`');
|
|
665
|
+
}
|
|
666
|
+
hand('your single next action, anytime: `yad next`');
|
|
667
|
+
if (!solo && !(readJSON(hubPath, null)?.roster || []).length) {
|
|
668
|
+
hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
|
|
669
|
+
}
|
|
670
|
+
log('');
|
|
671
|
+
log(c.bold('Then — AI-only steps (run in Claude Code):'));
|
|
672
|
+
if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
|
|
537
673
|
const design = readJSON(designPath, null);
|
|
538
674
|
if (design && design.tool && design.tool !== 'none') {
|
|
539
675
|
hand(`confirm the design tool: run \`yad-connect-design\` to detect the ${design.tool} MCP (or it degrades to markdown-only)`);
|
|
@@ -546,7 +682,6 @@ export async function runSetup(root, opts = {}) {
|
|
|
546
682
|
if (learning && learning.tool && learning.tool !== 'none') {
|
|
547
683
|
hand(`confirm the learning tool: run \`yad-connect-learning\` to detect the ${learning.tool} CLI (or it degrades to harness-native)`);
|
|
548
684
|
}
|
|
549
|
-
hand('author your first epic: run `yad-epic`');
|
|
550
685
|
log('');
|
|
551
686
|
log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
|
|
552
687
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo). A BMAD module + 30 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -31,6 +31,14 @@ defaults:
|
|
|
31
31
|
# Team review gate defaults (build plan §3 piece 2, §4).
|
|
32
32
|
review_gate:
|
|
33
33
|
default_reviewers: 1 # non-owner reviewers required (in addition to 1 owner) => owner + 1 reviewer
|
|
34
|
+
# Solo mode (a lone developer): a relaxed, opt-in safety guarantee, recorded per-project in
|
|
35
|
+
# .sdlc/hub.json (`solo: true`). On GitHub you CANNOT approve your own PR, so requiring an approval
|
|
36
|
+
# would deadlock a solo user. Solo waives the APPROVAL requirement only — the review PR/MR and its
|
|
37
|
+
# MERGE stay (CI still runs on the PR, and the merge is what advances the step). Net: the gate passes
|
|
38
|
+
# on `merged + all comment threads resolved`, no approval needed. NOT a default; team gates are
|
|
39
|
+
# unchanged. (Branch protection must not "require approvals", or the solo dev's own merge is blocked —
|
|
40
|
+
# `yad doctor` warns when it does.)
|
|
41
|
+
solo: false
|
|
34
42
|
escalate_when: [contract, auth, payments] # escalate to domain owners
|
|
35
43
|
# PR-driven automation (the `yad gate` CLI). With a hub platform, the review rides the per-step
|
|
36
44
|
# PR/MR: `yad gate sync` maps platform reviews/threads into the file ledger and the step
|
|
@@ -26,9 +26,12 @@ engine (never typed by hand); front steps are locked to `human_approve`.
|
|
|
26
26
|
## On Activation
|
|
27
27
|
|
|
28
28
|
### Step 1 — Get the idea
|
|
29
|
-
Ask the user for a one-line feature idea if not provided.
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Ask the user for a one-line feature idea if not provided.
|
|
30
|
+
|
|
31
|
+
**Precondition gate (rail):** analysis is the optional first entry point and seeds state exactly once.
|
|
32
|
+
If `.sdlc/state.json` already exists for the target epic, run `yad next EP-<slug> --check analysis`; if it
|
|
33
|
+
exits non-zero, **STOP** and point the user at `yad next EP-<slug>` (the epic is past analysis). If it
|
|
34
|
+
exits zero, resume analysis for that epic. When no `state.json` exists yet, proceed and seed state.
|
|
32
35
|
|
|
33
36
|
### Step 2 — Shape the idea (assist: analyst)
|
|
34
37
|
Adopt the **analyst** lens (`bmad-agent-analyst`, Mary) to pressure-test the idea in depth: who is the
|
|
@@ -23,9 +23,14 @@ shared cross-repo surface at charter altitude; front steps stay locked to `human
|
|
|
23
23
|
## On Activation
|
|
24
24
|
|
|
25
25
|
### Step 1 — Resolve the epic and check the gate
|
|
26
|
-
Resolve the `EP-<slug>` (ask if not provided).
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
Resolve the `EP-<slug>` (ask if not provided).
|
|
27
|
+
|
|
28
|
+
**Precondition gate (rail):** run `yad next EP-<slug> --check architecture` first. If it exits non-zero,
|
|
29
|
+
**STOP** — surface the blocker it prints and point the user at `yad next EP-<slug>`. Do not author until
|
|
30
|
+
it passes. (The check is the authoritative rail; the description below explains what it enforces.)
|
|
31
|
+
|
|
32
|
+
This passes when `architecture` is the next runnable step per the state sequence — every prior step
|
|
33
|
+
(through the epic review) is `done` and `architecture` is not already `done`.
|
|
29
34
|
|
|
30
35
|
### Step 1b — Open the authoring branch
|
|
31
36
|
Open the architecture authoring branch `architecture/EP-<slug>` per the shared procedure
|
|
@@ -38,6 +38,13 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
|
|
|
38
38
|
- Canonical gate sources live in this skill's `templates/` (the source of truth that gets installed
|
|
39
39
|
into each code repo):
|
|
40
40
|
- `templates/checks/{spec-link,contract-check,build-test-lint,verified-commits}.sh`
|
|
41
|
+
- `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** (a no-op
|
|
42
|
+
when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
|
|
43
|
+
CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
|
|
44
|
+
is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
|
|
45
|
+
spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. Runs in `yad-hub-checks`
|
|
46
|
+
alongside `verified-commits` (which waives the allowlist for the bot but still requires its
|
|
47
|
+
signature). See `yad-hub-bridge`.
|
|
41
48
|
- `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
|
|
42
49
|
→ the standalone hub-side verified-commits CI (installed by `yad check --fix` with the hub wiring)
|
|
43
50
|
- `templates/github/yad-checks.yml` → installs to `.github/workflows/yad-checks.yml` (marked `# yad-managed: yad-checks`)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ledger-guard gate.
|
|
3
|
+
# In BRIDGE mode the gate ledger is CI-owned: only the yad gate-sync bot may change the
|
|
4
|
+
# machine-written gate-state files. A commit on a review PR by anyone else that modifies them is
|
|
5
|
+
# rejected — the human keeps the artifact, CI keeps the ledger. This makes "CI is the sole writer of
|
|
6
|
+
# the ledger" a mechanical guarantee instead of a convention.
|
|
7
|
+
#
|
|
8
|
+
# Protected (gate-state, machine-written):
|
|
9
|
+
# epics/*/.sdlc/state.json, approvals.json, comments.json, hub-prs.json
|
|
10
|
+
# epics/*/reviews/*.md
|
|
11
|
+
# NOT protected:
|
|
12
|
+
# epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
|
|
13
|
+
# `gate open`, so a human legitimately commits it alongside the architecture artifact.
|
|
14
|
+
#
|
|
15
|
+
# A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
|
|
16
|
+
# platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
|
|
17
|
+
# Verified signature (a key the contributor cannot forge under the bot identity) is what actually
|
|
18
|
+
# distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
|
|
19
|
+
# human edit and rejected.
|
|
20
|
+
#
|
|
21
|
+
# Scope: enforced ONLY when the bridge is enabled (a platform + gate-sync CI). Without the bridge
|
|
22
|
+
# (file-only / non-bridge) humans legitimately write the ledger locally, so the gate is a no-op.
|
|
23
|
+
#
|
|
24
|
+
# Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
|
|
25
|
+
# badge) WARNs and waives the signature half — the same stance verified-commits takes.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
# ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
|
|
29
|
+
HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
|
|
30
|
+
if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space:]]*true' "$HUB"; then
|
|
31
|
+
echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
BASE="${1:-${SDLC_BASE:-origin/main}}"
|
|
36
|
+
if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
|
|
37
|
+
echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
RANGE="${BASE}..HEAD"
|
|
41
|
+
|
|
42
|
+
commits="$(git rev-list "$RANGE")"
|
|
43
|
+
if [ -z "$commits" ]; then
|
|
44
|
+
echo "PASS [ledger-guard]: no commits in ${RANGE}"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# ---- platform for the signature check (mirrors verified-commits) ------------------------------
|
|
49
|
+
remote="$(git remote get-url origin 2>/dev/null || true)"
|
|
50
|
+
platform=""
|
|
51
|
+
case "$remote" in
|
|
52
|
+
*github*) platform=github ;;
|
|
53
|
+
*gitlab*) platform=gitlab ;;
|
|
54
|
+
esac
|
|
55
|
+
platform="${SDLC_PLATFORM:-$platform}"
|
|
56
|
+
case "$platform" in
|
|
57
|
+
github|gitlab) ;;
|
|
58
|
+
""|none) platform=""; echo "WARN [ledger-guard]: no GitHub/GitLab remote — bot signature NOT verified (the Verified badge is a platform concept)." ;;
|
|
59
|
+
*) echo "FAIL [ledger-guard]: unknown platform '${platform}' (SDLC_PLATFORM must be github|gitlab|none)."; exit 1 ;;
|
|
60
|
+
esac
|
|
61
|
+
|
|
62
|
+
# 0 when the platform marks the commit's signature verified.
|
|
63
|
+
signature_verified() {
|
|
64
|
+
local sha v body
|
|
65
|
+
sha="$1"
|
|
66
|
+
case "$platform" in
|
|
67
|
+
github)
|
|
68
|
+
v="$(gh api "repos/{owner}/{repo}/commits/${sha}" --jq '.commit.verification.verified' 2>/dev/null || echo api-error)"
|
|
69
|
+
[ "$v" = "true" ]
|
|
70
|
+
;;
|
|
71
|
+
gitlab)
|
|
72
|
+
if [ -n "${CI_API_V4_URL:-}" ] && [ -n "${CI_PROJECT_ID:-}" ]; then
|
|
73
|
+
body="$(curl -fsS --header "PRIVATE-TOKEN: ${GITLAB_TOKEN:-${SDLC_API_TOKEN:-}}" \
|
|
74
|
+
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/repository/commits/${sha}/signature" 2>/dev/null || true)"
|
|
75
|
+
else
|
|
76
|
+
body="$(glab api "projects/:id/repository/commits/${sha}/signature" 2>/dev/null || true)"
|
|
77
|
+
fi
|
|
78
|
+
printf '%s' "$body" | grep -qE '"verification_status"[[:space:]]*:[[:space:]]*"verified"'
|
|
79
|
+
;;
|
|
80
|
+
*) return 1 ;;
|
|
81
|
+
esac
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# A trusted bot commit = bot-attributed AND (platform-Verified, or no platform to check against).
|
|
85
|
+
trusted_bot() {
|
|
86
|
+
case "$(git show -s --format='%an|%ae' "$1")" in
|
|
87
|
+
*yad-gate-sync*) ;;
|
|
88
|
+
*) return 1 ;;
|
|
89
|
+
esac
|
|
90
|
+
[ -z "$platform" ] && return 0 # degraded: cannot read the Verified badge — waive (warned above)
|
|
91
|
+
signature_verified "$1"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
violations=0
|
|
95
|
+
for sha in $commits; do
|
|
96
|
+
touches_ledger=0
|
|
97
|
+
while IFS= read -r f; do
|
|
98
|
+
[ -n "$f" ] || continue
|
|
99
|
+
case "$f" in
|
|
100
|
+
epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
|
|
101
|
+
epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
|
|
102
|
+
touches_ledger=1
|
|
103
|
+
echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
|
|
104
|
+
;;
|
|
105
|
+
esac
|
|
106
|
+
done < <(git diff-tree --no-commit-id --name-only -r "$sha")
|
|
107
|
+
if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
|
|
108
|
+
violations=$((violations + 1))
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
if [ "$violations" -gt 0 ]; then
|
|
113
|
+
echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
|
|
117
|
+
exit 0
|
|
@@ -93,8 +93,14 @@ while IFS= read -r sha; do
|
|
|
93
93
|
[ -z "$sha" ] && continue
|
|
94
94
|
short="$(git log -1 --format=%h "$sha")"
|
|
95
95
|
author="$(git log -1 --format=%ae "$sha" | tr '[:upper:]' '[:lower:]')"
|
|
96
|
+
# The gate-sync bot is a machine identity, not a roster human — waive the allowlist for it. Its
|
|
97
|
+
# commits are still held to the SIGNATURE check below, so a contributor cannot spoof the bot author
|
|
98
|
+
# to dodge the allowlist (a forged-author commit is not platform-Verified). Mirrors how the
|
|
99
|
+
# platform-committer merge commits are allowlist-exempt but signature-covered.
|
|
100
|
+
is_bot=0
|
|
101
|
+
case "${author}|$(git log -1 --format=%an "$sha" | tr '[:upper:]' '[:lower:]')" in *yad-gate-sync*) is_bot=1 ;; esac
|
|
96
102
|
|
|
97
|
-
if [ "$authors_on" = 1 ]; then
|
|
103
|
+
if [ "$authors_on" = 1 ] && [ "$is_bot" = 0 ]; then
|
|
98
104
|
# tolerate CRLF / stray surrounding whitespace in a hand-edited allowlist
|
|
99
105
|
if grep -vE '^[[:space:]]*(#|$)' "$ALLOWLIST" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
|
100
106
|
| tr '[:upper:]' '[:lower:]' | grep -qxF "$author"; then
|
|
@@ -103,6 +109,8 @@ while IFS= read -r sha; do
|
|
|
103
109
|
echo "FAIL [verified-commits]: ${short} author <${author}> is not in ${ALLOWLIST} — unverified user."
|
|
104
110
|
rc=1
|
|
105
111
|
fi
|
|
112
|
+
elif [ "$is_bot" = 1 ]; then
|
|
113
|
+
echo "PASS [verified-commits]: ${short} gate-sync bot — allowlist waived (signature still required)"
|
|
106
114
|
fi
|
|
107
115
|
|
|
108
116
|
if [ -n "$platform" ]; then
|