yadflow 3.16.2 → 3.17.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/cli/plan.mjs CHANGED
@@ -5,10 +5,11 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { err } from './errors.mjs';
7
7
  import {
8
- asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha,
8
+ asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha, warn,
9
9
  } from './lib.mjs';
10
10
  import {
11
- VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
11
+ VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES, isBridgeHub,
12
+ HOOK_WIRING, HOOK_SETTINGS, HOOK_TOOL_MATCHER, HOOK_COMMAND, HOOK_COMMAND_LEGACY,
12
13
  LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
13
14
  } from './manifest.mjs';
14
15
 
@@ -441,7 +442,7 @@ export function legacyRepoActions(root, repo) {
441
442
 
442
443
  export function legacyHubActions(root) {
443
444
  const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
444
- if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
445
+ if (!isBridgeHub(hub)) return [];
445
446
  const wiring = [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])];
446
447
  return legacyFileActions('hub', root, LEGACY_HUB_FILES[hub.platform], wiring);
447
448
  }
@@ -460,14 +461,168 @@ export function repoActions(root, repo) {
460
461
  export function hubActions(root) {
461
462
  const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
462
463
  // `bridge_enabled` is the canonical flag (the documented hub-config schema); older setup versions
463
- // wrote `bridge` — accept an explicit true in either spelling, wire nothing otherwise.
464
- if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
464
+ // wrote `bridge` — `isBridgeHub` accepts an explicit true in either spelling, and is the one
465
+ // predicate the CLI, the wiring, and the ledger hook all read (#186). Wire nothing otherwise.
466
+ if (!isBridgeHub(hub)) return [];
465
467
  const ledger = readManagedLedger(root);
466
468
  return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
467
469
  wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
468
470
  );
469
471
  }
470
472
 
473
+ // ---- harness hooks (#171) --------------------------------------------------------------------
474
+ // The desired hook entry, in the shape a harness reads it.
475
+ export const hookEntry = () => ({
476
+ matcher: HOOK_TOOL_MATCHER,
477
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
478
+ });
479
+
480
+ // Ours is a hook command EXACTLY equal to one we have written — the current spelling or a
481
+ // documented past one. Never "the entry at index N", never "the entry with our matcher", and
482
+ // deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also claim a team's
483
+ // own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on the `outdated`
484
+ // path that takes no backup. Matching exactly means the worst case is a second entry (the guard runs
485
+ // twice — harmless) instead of someone else's hook disappearing.
486
+ const OWNED_COMMANDS = new Set([HOOK_COMMAND, ...HOOK_COMMAND_LEGACY]);
487
+ const OURS = (h) => typeof h?.command === 'string' && OWNED_COMMANDS.has(h.command);
488
+
489
+ // Additive merge of our PreToolUse entry into a parsed settings object. Returns
490
+ // `{ settings, changed }`; `settings` is a new object, so a caller can compare without mutating.
491
+ // A matcher the team NARROWED is left alone (only the command is normalised) — the same respect for
492
+ // a local edit that `modified` gives a managed file. Widening it back would silently undo their choice.
493
+ export function mergeHookSettings(input) {
494
+ const settings = { ...(input && typeof input === 'object' && !Array.isArray(input) ? input : {}) };
495
+ const hooks = { ...(settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {}) };
496
+ const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.map((e) => ({ ...e })) : [];
497
+ let changed = false;
498
+ let found = false;
499
+ for (const entry of pre) {
500
+ if (!Array.isArray(entry.hooks)) continue;
501
+ entry.hooks = entry.hooks.map((h) => {
502
+ if (!OURS(h)) return h;
503
+ found = true;
504
+ if (h.command === HOOK_COMMAND && h.type === 'command') return h;
505
+ changed = true;
506
+ return { ...h, type: 'command', command: HOOK_COMMAND };
507
+ });
508
+ }
509
+ if (!found) { pre.push(hookEntry()); changed = true; }
510
+ hooks.PreToolUse = pre;
511
+ settings.hooks = hooks;
512
+ return { settings, changed };
513
+ }
514
+
515
+ // Does the installed entry still select at least one file-editing tool? The merge deliberately
516
+ // leaves a narrowed `matcher` alone — it is the team's — but a matcher narrowed to nothing (blanked,
517
+ // or pointed at `Bash`) means the guard is installed and never fires, which must not read as healthy.
518
+ // The matcher is a regex the harness tests tool names against, so test it as one; an invalid regex
519
+ // cannot fire either.
520
+ export function hookMatcherFires(settings) {
521
+ const pre = settings?.hooks?.PreToolUse;
522
+ if (!Array.isArray(pre)) return false;
523
+ const tools = HOOK_TOOL_MATCHER.split('|');
524
+ for (const entry of pre) {
525
+ if (!Array.isArray(entry?.hooks) || !entry.hooks.some(OURS)) continue;
526
+ let re;
527
+ try { re = new RegExp(entry.matcher ?? ''); } catch { continue; }
528
+ // An empty matcher matches every tool name in Claude Code, so it is armed, not blank.
529
+ if (!entry.matcher || tools.some((t) => re.test(t))) return true;
530
+ }
531
+ return false;
532
+ }
533
+
534
+ // One harness's settings file as an action. Not a `wiredFileAction`: there is no template to compare
535
+ // bytes against — the file belongs to the team and we own exactly one entry inside it. So it is also
536
+ // deliberately NOT recorded in `.sdlc/managed.json` (recordManagedWrites only records a dest that
537
+ // byte-matches its src); the marker above is its provenance instead.
538
+ function hookSettingsAction(root, ide, relDest) {
539
+ const dest = path.join(root, relDest);
540
+ const raw = exists(dest) ? fs.readFileSync(dest, 'utf8') : null;
541
+ let parsed = null;
542
+ let unreadable = false;
543
+ if (raw !== null) {
544
+ try { parsed = JSON.parse(raw); } catch { unreadable = true; }
545
+ if (!unreadable && (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))) unreadable = true;
546
+ }
547
+ // Scope is the IDE target, item the file under it, so the report reads `.claude/settings.json`
548
+ // once — the same scope the skills for that target are grouped under.
549
+ //
550
+ // `paths` is EMPTY, unlike every other action's: it is the pathspec `yad update --push` stages, and
551
+ // every other entry in that allowlist is a file yad wrote in full. This one the team co-owns, so
552
+ // staging it wholesale would sweep their unrelated working-tree edits into a `chore(yad-update)`
553
+ // commit pushed straight to the default branch, bypassing review. The entry is theirs to commit.
554
+ const base = { scope: ide, item: path.basename(relDest), root, paths: [] };
555
+ // A settings file we cannot READ is never rewritten — not even by `--overwrite-local`.
556
+ //
557
+ // For a managed file, `--overwrite-local` restores the shipped template, which is coherent. Here
558
+ // there is no template: only the parse failed, and everything the file holds — permissions, env,
559
+ // other hooks — is the team's. Synthesizing a replacement from an empty object would discard all of
560
+ // it, and `--overwrite-local` is a generic recovery command someone runs for an unrelated drifted
561
+ // gate script. So this reports `modified` forever and writes nothing; the human fixes the JSON.
562
+ //
563
+ // Warned at PLAN time, not from apply(): reconcile only reaches a `modified` action's apply() with
564
+ // `--overwrite-local`, so a plain `yad check --fix` would print nothing but the generic drift
565
+ // hand — "replace them with `yad update --overwrite-local`" — advice that can never clear this,
566
+ // since this action deliberately writes nothing. The specific reason has to surface either way.
567
+ if (unreadable) {
568
+ warn(`${relDest} does not parse — the ledger guard cannot be wired; fix the JSON, then re-run \`yad check --fix\``);
569
+ return { ...base, status: 'modified', apply: () => {} };
570
+ }
571
+ const { changed } = mergeHookSettings(parsed);
572
+ return {
573
+ ...base,
574
+ status: raw === null ? 'missing' : changed ? 'outdated' : 'ok',
575
+ // Re-read at apply() time rather than closing over the merge computed above: setup and reconcile
576
+ // build every action before applying any, so the file may have been written since.
577
+ //
578
+ // A no-op when the entry is already there. `yad setup` re-applies with force:true, which reaches
579
+ // an `ok` action — and an unconditional write would reformat a team's hand-formatted (but valid)
580
+ // settings.json to writeJSON's style on every re-run. Nothing is lost, but the diff noise lands
581
+ // in a committed file we only own one entry of.
582
+ //
583
+ // The re-read is STRICT. `readJSON`'s swallow-and-default would turn a file that became
584
+ // unparseable between plan and apply into `{}` and write the team's whole config away — with no
585
+ // backup, since that is the branch above. Re-check instead, and refuse the same way.
586
+ apply: () => {
587
+ if (!changed && raw !== null) return;
588
+ if (exists(dest)) {
589
+ let current;
590
+ try { current = JSON.parse(fs.readFileSync(dest, 'utf8')); } catch { /* unreadable — refused below */ }
591
+ if (!current || typeof current !== 'object' || Array.isArray(current)) {
592
+ warn(`${relDest} does not parse — left untouched; fix the JSON, then re-run \`yad check --fix\``);
593
+ return;
594
+ }
595
+ writeJSON(dest, mergeHookSettings(current).settings);
596
+ return;
597
+ }
598
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
599
+ writeJSON(dest, mergeHookSettings({}).settings);
600
+ },
601
+ };
602
+ }
603
+
604
+ // Harness-hook wiring on the hub: the guard script plus, per IDE target that defines a hook protocol,
605
+ // the entry that invokes it. Bridge-gated exactly like `hubActions` — with no bridge the ledger is
606
+ // locally owned, the hand-edit the authoring skills describe is CORRECT, and a guard would be wrong.
607
+ export function hookActions(root, ideTargets = ideTargetsFor(root)) {
608
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
609
+ if (!isBridgeHub(hub)) return [];
610
+ const ledger = readManagedLedger(root);
611
+ const actions = HOOK_WIRING.map((w) =>
612
+ wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
613
+ );
614
+ for (const ide of safeIdeTargetsFor(root, ideTargets)) {
615
+ const relDest = HOOK_SETTINGS[ide];
616
+ if (relDest) actions.push(hookSettingsAction(root, ide, relDest));
617
+ }
618
+ // The two halves must land TOGETHER, so `missing` is relabelled `new` — the same relabel a new
619
+ // first-party skill gets, and for the same reason: `yad update` (--scope=changed) excludes only
620
+ // the literal 'missing'. Without it, an upgrade on a hub that already has a settings.json applies
621
+ // the entry (`outdated`) while skipping the script (`missing`), leaving every file edit firing a
622
+ // PreToolUse command that does not exist — a hook error per edit, and no guarding at all.
623
+ return actions.map(asNewSkill);
624
+ }
625
+
471
626
  // Every email the verified-commits gate should accept as a known author: the hub roster's `email`
472
627
  // (or `emails`) fields plus hub.json's free-form `verified_authors` list. Lower-cased, deduped,
473
628
  // sorted — deterministic so the generated file is drift-checkable like any wired file.
package/cli/platform.mjs CHANGED
@@ -378,6 +378,94 @@ export function branchExists(cwd, branch) {
378
378
  return remote.code === 2 ? false : null;
379
379
  }
380
380
 
381
+ // ---- default branch -----------------------------------------------------------------------------
382
+ // The REMOTE's own default branch, asked of the platform. This is the branch the platform (and the
383
+ // tooling that keys off it — CodeRabbit's auto-review eligibility, branch protection, "compare"
384
+ // defaults) considers the trunk, so it is the only authoritative answer to "what should a PR target?".
385
+ // `runner` is injectable so the read is unit-testable without shelling out (mirrors searchIssues).
386
+ // Returns { ok, branch, reason }; never throws — an absent/unauthenticated CLI is just `ok:false`.
387
+ // It is a READ of the remote's own config, so it costs one API round-trip; callers that only need a
388
+ // base branch get it folded into resolveBaseBranch below rather than calling this twice.
389
+ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
390
+ // No `platformReady` probe: an absent CLI already surfaces as a failed spawn, and skipping the probe
391
+ // keeps the read a pure function of `runner` (so a test never depends on gh/glab being installed).
392
+ if (!cliFor(platform)) return { ok: false, reason: 'no platform (github/gitlab) to ask' };
393
+ // This is a live network round-trip on a SYNCHRONOUS command path, so it carries the same ceiling
394
+ // branchExists documents for its own remote probe: "cannot ask" has to be fast, or a black-holed
395
+ // host / wedged credential helper turns `yad open-pr` into a hang. A timeout surfaces as ok:false,
396
+ // which the caller already treats as "the platform could not tell me".
397
+ const opts = { cwd, timeout: 10_000 };
398
+ if (platform === 'gitlab') {
399
+ // `:id` is glab's own placeholder for the project the cwd resolves to (same form as readPrGitLab).
400
+ const r = runner('glab', ['api', 'projects/:id'], opts);
401
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api projects/:id failed' };
402
+ try {
403
+ const branch = JSON.parse(r.stdout)?.default_branch;
404
+ return branch ? { ok: true, branch } : { ok: false, reason: 'project has no default_branch' };
405
+ } catch { return { ok: false, reason: 'unreadable glab api response' }; }
406
+ }
407
+ const r = runner('gh', ['repo', 'view', '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name'], opts);
408
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh repo view failed' };
409
+ return r.stdout ? { ok: true, branch: r.stdout } : { ok: false, reason: 'gh returned no defaultBranchRef' };
410
+ }
411
+
412
+ // The branch a PR/MR should target, resolved rather than assumed (issue #168: open-pr hardcoded
413
+ // 'main', so every task PR on a `staging`-trunk repo was mis-based — and CodeRabbit, which decides
414
+ // auto-review eligibility at PR-OPEN time from the base, silently skipped every one of them).
415
+ //
416
+ // Order — most explicit first, and configuration outranks the remote: the same
417
+ // configuration-outranks-the-remote order `yad repo sync` (repo.mjs) and the contract-check gate use,
418
+ // though only this chain has a platform rung — they stop at the local `origin/HEAD`:
419
+ // 1 flag — an explicit --base; the human said so
420
+ // 2 registry — the repo's `default_branch` in .sdlc/repos.json
421
+ // 3 hub — hub.json's `default_branch`, for a PR against the product hub itself
422
+ // 4 platform — what the remote says (see platformDefaultBranch)
423
+ // 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/hubcommit.mjs use.
424
+ // Deliberately NOT `ls-remote`: see branchExists above for why a network probe on
425
+ // this path is a hang hazard.
426
+ // 6 fallback — 'main'
427
+ //
428
+ // `probe` decides WHEN the platform is asked, and exists because the two callers want different things:
429
+ // true (default) — ask up front, so `platformDefault` rides along even when an earlier rung won.
430
+ // `yad open-pr` needs that to warn about a base that is not the remote's trunk,
431
+ // and it is about to shell out to gh/glab anyway.
432
+ // false — ask only if the config rungs all miss. A caller that just wants a base (the
433
+ // review companion) would otherwise pay a live round-trip — up to the full 10s
434
+ // timeout on a slow/unreachable host — for a `platformDefault` it discards.
435
+ // Either way the probe runs AT MOST once. Returns { base, source, platformDefault }; with `probe:false`
436
+ // and an early rung winning, `platformDefault` is null because it was never asked, not because the
437
+ // platform had no answer.
438
+ export function resolveBaseBranch(platform, {
439
+ cwd, explicit = null, meta = null, hub = null, runner = run, probe = true,
440
+ } = {}) {
441
+ let platformDefault = null;
442
+ let asked = false;
443
+ const askPlatform = () => {
444
+ if (asked) return platformDefault;
445
+ asked = true;
446
+ const remote = platformDefaultBranch(platform, { cwd, runner });
447
+ platformDefault = remote.ok ? remote.branch : null;
448
+ return platformDefault;
449
+ };
450
+ if (probe) askPlatform();
451
+ const originHead = () => {
452
+ const r = runner('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd });
453
+ return r.ok && r.stdout ? r.stdout.replace(/^origin\//, '') : null;
454
+ };
455
+ const chain = [
456
+ ['flag', explicit],
457
+ ['registry', meta?.default_branch],
458
+ ['hub', hub?.default_branch],
459
+ ['platform', askPlatform],
460
+ ['origin-head', originHead],
461
+ ];
462
+ for (const [source, value] of chain) {
463
+ const branch = typeof value === 'function' ? value() : value;
464
+ if (branch) return { base: branch, source, platformDefault };
465
+ }
466
+ return { base: 'main', source: 'fallback', platformDefault };
467
+ }
468
+
381
469
  // ---- create a PR/MR -----------------------------------------------------------------------------
382
470
  // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
383
471
  // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
package/cli/reconcile.mjs CHANGED
@@ -12,7 +12,7 @@ const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch {
12
12
  import { preflightGuardReadiness } from './hubcommit.mjs';
13
13
  import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX } from './manifest.mjs';
14
14
  import {
15
- moduleActions, repoActions, hubActions, authorsActions,
15
+ moduleActions, repoActions, hubActions, hookActions, authorsActions,
16
16
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
17
17
  ideTargetStateFor, recordManagedWrites,
18
18
  } from './plan.mjs';
@@ -48,7 +48,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
48
48
  // and purge of skills removed in a later release ('removed': delete the lingering install) ---
49
49
  const actions = [
50
50
  ...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
51
- ...hubActions(root), ...legacyHubActions(root),
51
+ ...hubActions(root), ...legacyHubActions(root), ...hookActions(root, ideTargets),
52
52
  ...authorsActions(root, registry.repos),
53
53
  ];
54
54
  if (ideState.needsRepair) {
package/cli/review.mjs CHANGED
@@ -12,6 +12,7 @@ import { updateShip } from './ledger.mjs';
12
12
  import { epicRoot } from './epic-state.mjs';
13
13
  import {
14
14
  detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
15
+ resolveBaseBranch,
15
16
  } from './platform.mjs';
16
17
  import { upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
17
18
  import { sequenceDiff } from './walkthrough.mjs';
@@ -40,12 +41,16 @@ function platformOf(root, repoRoot, meta) {
40
41
  // Build (but don't print) the back-half grounding bundle. Shared by `context` and `walkthrough` so the
41
42
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
42
43
  // Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
43
- function contextBundle(root, { repo, dir, pr } = {}) {
44
+ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
44
45
  const rr = resolveRepo(root, { repo, dir });
45
46
  if (rr.error) return { error: rr.error };
46
47
  const { repoRoot, meta } = rr;
47
48
  const platform = platformOf(root, repoRoot, meta);
48
- const base = meta?.default_branch || 'main';
49
+ // Same resolution as `yad open-pr` (#168): without it a repo whose trunk is not `main` grounded the
50
+ // companion on the wrong diff range — or on a branch that does not exist at all. `probe: false`
51
+ // because this caller only wants the base: a configured `default_branch` must answer locally and
52
+ // instantly, never behind a live gh/glab round-trip that could stall for the full timeout.
53
+ const { base } = resolveBaseBranch(platform, { cwd: repoRoot, meta, runner, probe: false });
49
54
  const bundle = {
50
55
  repo: meta?.name || null,
51
56
  repoRoot,
@@ -66,8 +71,8 @@ function contextBundle(root, { repo, dir, pr } = {}) {
66
71
 
67
72
  // `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
68
73
  // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
69
- export async function reviewContext(root, { repo, dir, pr } = {}) {
70
- const r = contextBundle(root, { repo, dir, pr });
74
+ export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
75
+ const r = contextBundle(root, { repo, dir, pr, runner });
71
76
  if (r.error) { fail(r.error); process.exitCode = 1; return; }
72
77
  log(JSON.stringify(r.bundle, null, 2));
73
78
  return r.bundle;
@@ -78,7 +83,7 @@ export async function reviewContext(root, { repo, dir, pr } = {}) {
78
83
  // first). The CLI sequences deterministically; the skill (yad-pair-review) walks the stops, generates
79
84
  // the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
80
85
  export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
81
- const r = contextBundle(root, { repo, dir, pr });
86
+ const r = contextBundle(root, { repo, dir, pr, runner });
82
87
  if (r.error) { fail(r.error); process.exitCode = 1; return; }
83
88
  const { bundle, repoRoot, base } = r;
84
89
  const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
package/cli/setup.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from './lib.mjs';
9
9
  import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
10
10
  import {
11
- moduleActions, repoActions, hubActions, authorsActions,
11
+ moduleActions, repoActions, hubActions, hookActions, authorsActions,
12
12
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
13
  safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
14
14
  } from './plan.mjs';
@@ -734,6 +734,15 @@ export async function runSetup(root, opts = {}) {
734
734
  wired.push(...hubWiring);
735
735
  }
736
736
  applyActions(legacyHubActions(root), { force: true });
737
+ // the hub, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
738
+ // the moment it tries it rather than by a failed pipeline later (#171). Bridge-gated like the CI
739
+ // above — with no bridge the ledger is locally owned and the guard would be wrong.
740
+ const hookWiring = hookActions(root, ideTargets);
741
+ if (hookWiring.length) {
742
+ log(` ${c.bold('hub')} ${c.dim('(agent ledger guard)')}`);
743
+ applyActions(hookWiring, { force: true });
744
+ wired.push(...hookWiring);
745
+ }
737
746
  // After every write to a managed path has landed (including the legacy renames), so the recorded
738
747
  // sha is the file's final state.
739
748
  recordManagedWrites(wired);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.16.2",
3
+ "version": "3.17.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, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -20,8 +20,8 @@ SDLC Workflow,yad-implement,Implement Task,IM,"Build-half Step B: with the dev l
20
20
  SDLC Workflow,yad-checks,Check Gates,CK,"Build-half Step C: wire and run the production-safety CI gates on a code repo (and product hub) — spec-link (every change links a real story/spec via its Task trailer), contract-check (a contract-surface change without Contract-Change + an updated re-locked contract FAILS and routes back to the architecture gate), build/test/lint, verified-commits (signed + roster-known authors), and the pattern gates commit-message / pr-title / pr-template (profile-aware code|hub). CI-agnostic bash invoked by GitHub Actions and GitLab CI. Blocking in CI; the human still owns the merge. Never auto-advances.",,{repo: <one of an epic's repos | hub>} {action: wire|run} {base: target branch},3-build,yad-implement,,false,demo-repos/<repo>/,checks/*.sh .github/workflows/yad-checks.yml .gitlab-ci.yml
21
21
  SDLC Workflow,yad-pr-template,PR/MR Template,PT,"Build-half Step D: detect a code repo's platform and commit the matching PR/MR template (.github/pull_request_template.md or .gitlab/merge_request_templates/Default.md) with an Impact & Risk block. A high risk level (or a touched contract/auth/payments surface) routes the review to domain owners — the same escalation yad-review-gate applies. Ships the routing helper risk-route.sh plus the pattern-gate scripts pr-title.sh and pr-template.sh (used by yad-checks). Never auto-advances.",,{repo: <one of an epic's repos | hub>} {action: wire|route} {body: PR description file},3-build,yad-checks,,false,demo-repos/<repo>/,.github/pull_request_template.md .gitlab/merge_request_templates/Default.md checks/risk-route.sh checks/pr-title.sh checks/pr-template.sh
22
22
  SDLC Workflow,yad-commit,Commit by Convention,CM,"Build-half helper: commit ONE staged atomic change by the conventions — a Conventional-Commits subject, the fixed trailer block (Task -> Contract-Change -> Co-Authored-By), and the <=3-file atomic guard. The human git author owns the commit; an assisting AI is recorded only as a Co-Authored-By footer chosen per-commit with --ai (claude|copilot|cursor|coderabbit|none, default none). Drives the yad commit CLI. Never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {task: <id>} {contract-change: true|false},3-build,yad-implement,,false,<repo>/,one commit
23
- SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build-half helper: open a code-repo task PR/MR from the committed platform template — detect GitHub/GitLab, push the task branch, create the PR/MR with the body prefilled (Summary / Story-task / Impact & Risk) and the title defaulting to the commit subject. Auto-assigns from the hub roster (assignee = committer, reviewers = repo reviewers + domain-owners); high risk / contract surface routes to domain owners (risk-route.sh). Drives the yad open-pr CLI. Never merges; never auto-advances.",,{repo: <name>} {risk: low|medium|high} {contract-change: true|false},3-build,yad-commit,,false,<repo>/,one PR/MR
24
- SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build-half 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 with the roster auto-assigned. 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},3-build,yad-pr-template,,false,<repo>/,one commit + one PR/MR
23
+ SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build-half helper: open a code-repo task PR/MR from the committed platform template — detect GitHub/GitLab, push the task branch, create the PR/MR with the body prefilled (Summary / Story-task / Impact & Risk) and the title defaulting to the commit subject. Auto-assigns from the hub roster (assignee = committer, reviewers = repo reviewers + domain-owners); high risk / contract surface routes to domain owners (risk-route.sh). Bases the PR on the repo's RESOLVED default branch (repos.json default_branch, else the platform's own default, else origin/HEAD, else main) instead of a hardcoded main, and warns when the base is not the platform default — that loses the AI first pass irreversibly. Drives the yad open-pr CLI. Never merges; never auto-advances.",,{repo: <name>} {risk: low|medium|high} {contract-change: true|false} {base: <branch>},3-build,yad-commit,,false,<repo>/,one PR/MR
24
+ SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build-half 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 with the roster auto-assigned. 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-hub-bridge,Hub Review Bridge,HB,"The templated PR/MR bridge for the front-half review gate: when the product hub has a platform (.sdlc/hub.json), open a review PR/MR on the hub for an authored artifact, set required reviewers/labels from the routing rule, 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 file-only 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/,hub-prs.json
26
26
  SDLC Workflow,yad-engineer-review,Engineer Review & Merge,ER,"Build-half Step E: wire an advisory AI first-pass (CodeRabbit) on the PR, record the human engineer review with the same human_approve discipline as the front gates (owner + 1 reviewer, escalating to domain owners on high risk / contract / auth / payments), 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
27
  SDLC Workflow,yad-backfill,Backfill Specs,BF,"Build-half 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
@@ -65,6 +65,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
65
65
  default branch the guard is absolute again. Runs in `yad-hub-checks`
66
66
  alongside `verified-commits` (which waives the allowlist for the bot but still requires its
67
67
  signature). See `yad-hub-bridge`.
68
+ - `templates/hooks/ledger-guard.sh` → **hub-only** agent guardrail, active **only in bridge mode**
69
+ (the same `isBridgeHub` predicate). Not a CI gate: it is a **harness hook** that refuses an agent's
70
+ edit to the CI-owned ledger at the moment it is attempted and names `yad gate open` instead — the
71
+ local half of `checks/ledger-guard.sh` (#171). Installed to `<hub>/hooks/ledger-guard.sh` with the
72
+ `PreToolUse` entry in `.claude/settings.json`. Fails OPEN; see "Step 2b" below.
68
73
  - `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
69
74
  → the standalone hub-side verified-commits CI (installed by `yad check --fix` with the hub wiring)
70
75
  - `templates/github/yad-checks.yml` → installs to `.github/workflows/yad-checks.yml` (marked `# yad-managed: yad-checks`)
@@ -142,6 +147,43 @@ Commit the wiring on the repo's default branch (it is shared infrastructure, not
142
147
  **The hub is wired the same way.** `repo: hub` wires the hub repo itself (platform from `.sdlc/hub.json`)
143
148
  with a hub-flavored gate set — see "Wiring the hub" in `references/check-gates.md`.
144
149
 
150
+ **The hub also gets the agent guardrail** (see below): `templates/hooks/ledger-guard.sh` →
151
+ `<hub>/hooks/ledger-guard.sh`, plus the `PreToolUse` entry in `.claude/settings.json`. `yad setup`
152
+ and `yad check --fix` install both; there is nothing to do by hand.
153
+
154
+ ### Step 2b — the agent guardrail (harness hooks, bridge mode only)
155
+ The CI gates speak at CI time. That is too late for one failure the field kept hitting (#171): in
156
+ bridge mode the gate ledger is **CI-owned**, so an agent that hand-edits
157
+ `epics/*/.sdlc/state.json` only finds out twenty minutes later, from a `ledger-guard` FAIL with
158
+ nothing connecting cause to effect — and by then the write has to be reverted before the review
159
+ PR/MR can go green.
160
+
161
+ `hooks/ledger-guard.sh` is the local half of that same rule. It runs as a **harness hook** before a
162
+ file-editing tool call and refuses the write up front, naming the command that owns the transition
163
+ (`yad gate open`), so the agent corrects itself instead of failing a pipeline.
164
+
165
+ - **Harness-agnostic by contract.** The script only locates `yad` and hands the tool payload to
166
+ `yad hook ledger-guard`: **stdin** is a JSON tool-call payload, **exit 0** allows, **exit 2**
167
+ denies with the reason on stderr. Claude Code's `PreToolUse` protocol is exactly that, so no
168
+ adapter logic is needed; another harness needs only those two exit codes.
169
+ - **Same scope as the CI gate**, deliberately: guarded are `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json`
170
+ and `epics/*/reviews/*.md` (at the gate's own glob depth, which spans `/`); exempt are
171
+ `contract-lock.json`, `change.json`, and every artifact. A **new** epic's ledger is exempt too —
172
+ creation, not mutation (#162), decided by listing the epics the **base ref** carries (an
173
+ `origin/` ref, case-folded slugs), never by looking at the working tree.
174
+ - **A no-op without the bridge.** There the ledger is locally owned and the hand-edit the authoring
175
+ skills describe is *correct*, so nothing is wired and nothing is blocked.
176
+ - **It fails OPEN** — no `yad`, no hub, an unreadable config, an unparseable payload all ALLOW, with
177
+ a note on stderr. `ledger-guard` in CI fails *closed* and remains the authority. `YAD_HOOK_DISABLE=1`
178
+ skips one command.
179
+ - **Known gaps** (both caught by the CI gate instead): a `Bash` tool call (`sed -i epics/…`) is not
180
+ intercepted — matching it would mean parsing shell; and the hook arms sessions **rooted at the
181
+ hub**, since a harness reads hooks from its own project root — a session opened at the workspace
182
+ above the hub never loads the hub's `.claude/settings.json`.
183
+
184
+ `yad doctor` reports the guardrail as `agent ledger guard wired` / `not wired` on a bridge hub.
185
+ See `references/check-gates.md` §"The agent guardrail".
186
+
145
187
  ### Step 3 — `run` (run the gates now)
146
188
  From inside the repo, run each gate against `base` and report PASS/FAIL per gate:
147
189
  ```
@@ -366,6 +366,112 @@ title + the code task template), so a PR that changes the hub's own workflows/ch
366
366
  `templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
367
367
  line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
368
368
 
369
+ ## The agent guardrail (`templates/hooks/ledger-guard.sh` + `yad hook ledger-guard`)
370
+
371
+ Not a CI gate — a **harness hook**, and the only piece of yadflow that runs *inside* an agent's tool
372
+ loop. It exists because of the gap #171 reported: `checks/ledger-guard.sh` is correct and blocking,
373
+ but it speaks at CI time. An agent that hand-edits `epics/*/.sdlc/state.json` in bridge mode learns
374
+ twenty minutes later, from a FAIL with nothing connecting cause to effect, and by then the write must
375
+ be reverted before the review PR/MR can go green.
376
+
377
+ **Contract** — deliberately not Claude-Code-shaped:
378
+
379
+ | | |
380
+ |---|---|
381
+ | stdin | a harness tool-call payload as JSON (optional; `--path <p>` works instead) |
382
+ | exit 0 | allow |
383
+ | exit 2 | deny — the reason is on stderr, for the agent to read |
384
+
385
+ Claude Code's `PreToolUse` protocol is exactly that (exit 2 blocks the call and feeds stderr back to
386
+ the model), so `.claude/settings.json` wires it with no adapter logic. Any harness that can run a
387
+ command and read those two exit codes can use the same script.
388
+
389
+ **Layering.** `hooks/ledger-guard.sh` is only the adapter: it locates `yad` (`$YAD_BIN` → the hub's
390
+ `node_modules/yadflow` → `PATH` → `npx --no-install`) and passes the payload to `yad hook
391
+ ledger-guard`, which holds the decision. So the wiring never hard-codes an install path, and the
392
+ logic is unit-tested (`cli/hook.mjs`, `cli/test.mjs`) instead of living in bash.
393
+
394
+ **Scope — identical to the CI gate, on purpose**, down to the details that decide the hard cases:
395
+
396
+ - Guarded: `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` and `epics/*/reviews/*.md`.
397
+ Exempt: `contract-lock.json` (artifact-side), `change.json`, every artifact.
398
+ - **Depth matches the gate's globs.** Its arms are bash `case` patterns, and a bash `*` spans `/`, so
399
+ `epics/EP-a/nested/.sdlc/state.json` is guarded there — and here. Being stricter locally would let
400
+ a path through that CI blocks.
401
+ - **The seed carve-out reads the base ref, not the working tree** (#162): the epics whose
402
+ `state.json` the base carries are listed once with `ls-tree`, and an epic absent from that list is
403
+ a creation. Never a `<rev>:<path>` probe — that spec resolves from the repository top level and
404
+ `-C` does not re-anchor it, so a hub in a subdirectory of its repo would miss every time and the
405
+ guard would allow everything, silently.
406
+ - **The base is an `origin/` ref** — `origin/<default_branch>`, then the remote's published default,
407
+ then `origin/main`, the gate's own order. Never a bare local branch: `git fetch` does not
408
+ fast-forward one, so a stale trunk would report a merged epic's ledger as absent and wave a real
409
+ mutation through. If none resolves, the answer is *unknown* and unknown allows.
410
+ - **Slugs are case-folded**, as the gate folds them. On a case-insensitive filesystem `epics/ep-x/…`
411
+ and `epics/EP-X/…` are the same file, so a byte-exact compare would let a mutation be laundered as
412
+ a creation.
413
+ - Bridge-gated by the same `isBridgeHub` predicate the CLI and the wiring read (#186): without the
414
+ bridge the ledger is locally owned, the hand-edit the authoring skills describe is correct, and
415
+ nothing is wired or blocked.
416
+
417
+ **It fails OPEN, and that asymmetry is the design.** No `yad` on PATH, no hub above the edited path,
418
+ an unreadable `hub.json`, an unparseable payload, a `yad` that errors — every one of them ALLOWS,
419
+ with a note on stderr. A local guardrail that failed closed would brick an agent's ability to edit
420
+ anything the moment an install went sideways. The CI gate fails **closed** and is what actually
421
+ protects the ledger; this only shortens the feedback loop. `YAD_HOOK_DISABLE=1` skips one command.
422
+
423
+ **Known gaps** — both fall through to the CI gate, which is why it stays the authority:
424
+
425
+ - A `Bash` tool call (`sed -i epics/…`) is not intercepted; matching it would mean parsing shell for
426
+ write intent.
427
+ - The hook arms sessions **rooted at the hub**. A harness loads hooks from its own project root, so a
428
+ session opened at the *workspace* (`project/`, with the hub at `project/product/`) never reads the
429
+ hub's `.claude/settings.json` and the guard does not fire there — even though the decision itself
430
+ resolves the hub correctly from any path. In that layout, open the session at the hub, or copy the
431
+ entry into the workspace's own settings (the command's `$CLAUDE_PROJECT_DIR` would then need the
432
+ hub-relative path).
433
+
434
+ **Wiring** (installed by `yad setup` / `yad check --fix`, bridge hubs only):
435
+
436
+ | Path | Owner |
437
+ |---|---|
438
+ | `<hub>/hooks/ledger-guard.sh` | fully managed — drift-checked and recorded in `.sdlc/managed.json` like any gate script |
439
+ | `<hub>/.claude/settings.json` | **one entry**, merged additively into `hooks.PreToolUse`. See below. |
440
+
441
+ The settings file is the team's, so the rules around that one entry are deliberately conservative:
442
+
443
+ - **Ownership is an exact command match** — the current spelling or a documented past one — never a
444
+ substring. A team keeping its own wrapper at `.claude/hooks/ledger-guard.sh` would otherwise have
445
+ their hook silently rewritten to ours, on the `outdated` path that takes no backup. Matching
446
+ exactly means the worst case is a second entry (the guard runs twice, harmlessly).
447
+ - **The command is quoted** (`"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"`) because the harness runs
448
+ it through a shell: unquoted, a project path containing a space word-splits and the guard is
449
+ silently off while `check` and `doctor` still call it wired.
450
+ - **A file that does not parse is never rewritten** — not even by `--overwrite-local`. For a managed
451
+ file that flag restores a shipped template; here there is none, and everything in the file is the
452
+ team's. It reports `modified` until a human fixes the JSON.
453
+ - **A `matcher` the team narrowed is left as they set it** — but `yad doctor` warns when it no longer
454
+ selects any file-editing tool, so an installed-but-dead guard cannot pass for healthy.
455
+ - **It is never staged by `yad update --push`.** Every other path in that allowlist is a file yad
456
+ wrote in full; this one would sweep the team's unrelated edits into a `chore` commit pushed
457
+ straight to the default branch.
458
+ - **Both halves land together.** The script and the entry ride `yad update` as one: applying the
459
+ entry without the script it points at would fire a missing command on every file edit.
460
+
461
+ `.claude` is the only IDE target wired: it is the only one with a defined hook protocol. Other
462
+ targets get the script, and the contract above is what they would wire by hand.
463
+
464
+ `yad doctor` reports the guard on a bridge hub, and distinguishes the three states that matter — it
465
+ reads the same persisted `ideTargets` the wiring reads, so every gap it names is one the command it
466
+ names can actually close:
467
+
468
+ | State | Report | Remedy |
469
+ |---|---|---|
470
+ | script + entry present, matcher live | `agent ledger guard wired` | — |
471
+ | either half absent | `not wired: <what>` | `yad check --fix` |
472
+ | present but the matcher no longer selects a file-editing tool | `installed but its matcher no longer selects file edits` | restore the matcher — it is wired and never fires |
473
+ | the settings file does not parse | `cannot be wired — … does not parse` | fix the JSON by hand; yad never rewrites one it cannot parse, so nothing else clears it |
474
+
369
475
  ## Running by hand (Phase 3 is manual)
370
476
 
371
477
  From inside the code repo, against the PR/MR base (e.g. `master`). For the gates that take one, the