yadflow 3.16.3 → 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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # [3.17.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.3...v3.17.0) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **check:** leave an already-wired settings.json byte-identical ([1d50ed4](https://github.com/abdelrahmannasr/yadflow/commit/1d50ed4b173f7de2350344fc5facb1de19bd9c61))
7
+ * **check:** stop the hook wiring from damaging a file the team owns ([a348c99](https://github.com/abdelrahmannasr/yadflow/commit/a348c997ba3b87406addef4e5868bdd14406fa49))
8
+ * **doctor:** give an unparseable settings file its own advice ([0862875](https://github.com/abdelrahmannasr/yadflow/commit/08628750a3c8e8642075db0fc0712d217dacdeb4))
9
+ * **doctor:** report the ledger guard against what actually arms it ([a1ec4ba](https://github.com/abdelrahmannasr/yadflow/commit/a1ec4bafec8441b6bf8c0515bf4f1bd68cb00ef7))
10
+ * **hook:** read the seeded set the way the CI gate reads it ([38cd206](https://github.com/abdelrahmannasr/yadflow/commit/38cd206fc94256782a074a309fe6dbc2ae3ce135)), closes [#171](https://github.com/abdelrahmannasr/yadflow/issues/171)
11
+ * **hook:** resolve the command before suppressing the update notice ([44d8768](https://github.com/abdelrahmannasr/yadflow/commit/44d87683abbc8acff02b011b6ce47d140e8075d3))
12
+ * **hook:** survive an empty command array on bash 3.2 ([7fd6984](https://github.com/abdelrahmannasr/yadflow/commit/7fd698412d8229f312f62c2a21f299dad445700b))
13
+
14
+
15
+ ### Features
16
+
17
+ * **check:** install and report the agent ledger guardrail ([8251d9f](https://github.com/abdelrahmannasr/yadflow/commit/8251d9f92740690ca7b3e27a0280f999593793f3))
18
+ * **hook:** refuse an agent the CI-owned ledger write, at the edit ([15291ce](https://github.com/abdelrahmannasr/yadflow/commit/15291ce2d73167cebeb5aba52920bdb078d6d0aa))
19
+
1
20
  ## [3.16.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.2...v3.16.3) (2026-08-12)
2
21
 
3
22
 
package/README.md CHANGED
@@ -89,6 +89,10 @@ In one pass it produces:
89
89
  push-on-main **`yad-update-guard`** (which re-checks any direct-to-default commit — e.g. from
90
90
  `yad update --push` — with just `verified-commits` + `commit-message`), shipped as CI-agnostic bash
91
91
  under `checks/`.
92
+ - **An agent guardrail** on a bridge hub — `hooks/ledger-guard.sh`, a harness hook that refuses an
93
+ agent the CI-owned gate-ledger write at the moment it tries it and names the command that owns the
94
+ transition, instead of letting it surface as a CI failure twenty minutes later. Harness-agnostic
95
+ (stdin payload, exit 0 allows / 2 denies) and fails open — the CI gate stays the authority.
92
96
  - **PR/MR templates** and an opt-in CodeRabbit config.
93
97
 
94
98
  Your first `yad-epic` seeds the `epics/EP-<slug>/` ledger — state, approvals, and the contract lock —
package/bin/yad.mjs CHANGED
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
22
22
  import { runThread, runReconcile } from '../cli/thread.mjs';
23
23
  import { runReport } from '../cli/report.mjs';
24
24
  import { runUsage } from '../cli/usage.mjs';
25
+ import { runLedgerGuardHook } from '../cli/hook.mjs';
25
26
  import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
26
27
 
27
28
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
@@ -51,6 +52,11 @@ ${c.bold('Setup & maintenance')}
51
52
  yad report [-m <text>] File a bug in the yadflow repo with auto-scrubbed diagnostics
52
53
  (no paths/hosts/repo names/logins/flag values). Also offered
53
54
  automatically after an unexpected failure. YAD_NO_REPORT=1 disables.
55
+ yad hook ledger-guard ${c.dim('harness-invoked, not typed')} — refuse an agent's edit to the
56
+ CI-owned gate ledger in bridge mode and name the command that owns
57
+ the transition. Reads a tool-call payload on stdin (or --path <p>);
58
+ exit 0 allows, exit 2 denies with the reason on stderr. Wired into
59
+ .claude/settings.json by setup / check --fix. YAD_HOOK_DISABLE=1 skips.
54
60
 
55
61
  ${c.bold('Reviewer roster')}
56
62
  yad roster list Show every member + their roles per scope (hub + each repo)
@@ -179,7 +185,7 @@ ${c.bold('Environment')}
179
185
  YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
180
186
  YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
181
187
 
182
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit']);
188
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit', '--path']);
183
189
 
184
190
  function parseArgs(argv) {
185
191
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -254,6 +260,18 @@ async function main() {
254
260
  case 'doctor':
255
261
  await runDoctor(o.dir, { json: o.json });
256
262
  break;
263
+ // Harness-invoked, not typed by a human: a tool-call payload arrives on stdin and the exit code
264
+ // is the verdict (0 allow, 2 deny). See cli/hook.mjs for the contract.
265
+ case 'hook': {
266
+ const [, action] = o._;
267
+ if (action !== 'ledger-guard') {
268
+ log(c.red(`unknown hook: ${action ?? '(none)'} (ledger-guard)`));
269
+ process.exitCode = 1;
270
+ break;
271
+ }
272
+ runLedgerGuardHook({ paths: o.path ? [o.path] : [] });
273
+ break;
274
+ }
257
275
  case 'report':
258
276
  await runReport(o.dir, { message: o.message });
259
277
  break;
@@ -415,7 +433,11 @@ main()
415
433
  // the readline handle open so the process never exits.
416
434
  .finally(async () => {
417
435
  try {
418
- await maybeNotifyUpdate();
436
+ // Never on `yad hook`: it runs on every agent tool call, and its stderr is the channel the
437
+ // block reason travels on — an update banner there would land in front of a model.
438
+ // Resolved the way main() resolves it, NOT from argv[2]: that is the first raw argument, so
439
+ // `yad --dir <path> hook ledger-guard` puts `--dir` there and the banner slips through.
440
+ if (parseArgs(process.argv.slice(2))._[0] !== 'hook') await maybeNotifyUpdate();
419
441
  } catch { /* the notice is never worth failing or hanging a command over */ } finally {
420
442
  closePrompts();
421
443
  }
package/cli/doctor.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
8
- import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
8
+ import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS, HOOK_SETTINGS, HOOK_TOOL_MATCHER, isBridgeHub } from './manifest.mjs';
9
+ import { mergeHookSettings, hookMatcherFires, ideTargetsFor } from './plan.mjs';
9
10
  import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
10
11
  import { loadDebt } from './thread.mjs';
11
12
  import { gitHead, insideWorkspace } from './setup.mjs';
@@ -150,6 +151,59 @@ export function projectChecks(checks, root) {
150
151
  }
151
152
  }
152
153
 
154
+ // The harness ledger guard (#171). Only meaningful in bridge mode: there the ledger is CI-owned and
155
+ // an agent's hand-edit is always rejected later by `ledger-guard`, so the local hook that refuses it
156
+ // up front should be installed. Without the bridge the ledger is locally owned and the hand-edit the
157
+ // authoring skills describe is correct — nothing to report, so the check is silent rather than `ok`.
158
+ const hubForHooks = readJSON(hubPath, null);
159
+ if (isBridgeHub(hubForHooks)) {
160
+ const unwired = [];
161
+ const broken = [];
162
+ if (!exists(path.join(root, 'hooks', 'ledger-guard.sh'))) unwired.push('hooks/ledger-guard.sh');
163
+ // The SAME target list `hookActions` wires — the persisted `ideTargets`, not "does the directory
164
+ // exist". Keyed on the directory, a project whose targets are `['.agents']` but which also has a
165
+ // stray `.claude/` would be told to run `yad check --fix` forever, while that command builds no
166
+ // action for `.claude` and correctly reports "already up to date". Never name a remedy that
167
+ // cannot reach the thing being reported.
168
+ const unreadable = [];
169
+ for (const ide of ideTargetsFor(root)) {
170
+ const relDest = HOOK_SETTINGS[ide];
171
+ if (!relDest) continue;
172
+ const settingsPath = path.join(root, relDest);
173
+ // A file that exists but does not parse is its OWN report. `readJSON` returns null for both
174
+ // "absent" and "broken", and null merges as "not wired" — which would send the human to
175
+ // `yad check --fix`, a command that (correctly) refuses to rewrite a settings file it cannot
176
+ // parse. The warning would then repeat forever with advice that can never apply.
177
+ // Read ONCE and reuse: parsing the same file twice lets `unreadable` and the merge check
178
+ // describe different content if it changes in between.
179
+ let settings = null;
180
+ if (exists(settingsPath)) {
181
+ let parsed;
182
+ try { parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { /* reported below */ }
183
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { unreadable.push(relDest); continue; }
184
+ settings = parsed;
185
+ }
186
+ if (mergeHookSettings(settings).changed) { unwired.push(relDest); continue; }
187
+ // Present is not the same as armed. The entry's matcher is the team's to narrow (the merge
188
+ // deliberately leaves it alone), but one that no longer selects any file-editing tool means
189
+ // nothing is intercepted — and reporting that as `ok` is how a disarmed guard passes for
190
+ // healthy until a ledger edit fails in CI.
191
+ if (!hookMatcherFires(settings)) broken.push(relDest);
192
+ }
193
+ if (unreadable.length) {
194
+ check(checks, 'hooks', 'project', 'warn', `agent ledger guard cannot be wired — ${unreadable.join(', ')} does not parse [YAD-STATE-001]`,
195
+ 'fix the JSON by hand, then run `yad check --fix` — yad never rewrites a settings file it cannot parse, so nothing else can clear this');
196
+ } else if (unwired.length) {
197
+ check(checks, 'hooks', 'project', 'warn', `agent ledger guard not wired: ${unwired.join(', ')}`,
198
+ 'run `yad check --fix` — until then an agent can hand-edit the CI-owned ledger and only find out when the review PR/MR fails');
199
+ } else if (broken.length) {
200
+ check(checks, 'hooks', 'project', 'warn', `agent ledger guard installed but its matcher no longer selects file edits: ${broken.join(', ')}`,
201
+ `restore the matcher to \`${HOOK_TOOL_MATCHER}\` — as it stands the hook is wired but never fires`);
202
+ } else {
203
+ check(checks, 'hooks', 'project', 'ok', 'agent ledger guard wired (hooks/ledger-guard.sh)');
204
+ }
205
+ }
206
+
153
207
  // design.json: parse + shape + tool + MCP confirmation (absent is the normal markdown-only default —
154
208
  // pre-feature projects have none, so silence rather than warn when the file does not exist).
155
209
  const designPath = path.join(root, PROJECT_FILES.designConfig);
package/cli/gate.mjs CHANGED
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import {
8
8
  c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
9
9
  } from './lib.mjs';
10
- import { PROJECT_FILES } from './manifest.mjs';
10
+ import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
11
11
  import {
12
12
  epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
@@ -113,11 +113,10 @@ export function loadHub(root) {
113
113
  // merge advances the step). Recorded per-project in hub.json by `yad setup`.
114
114
  const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
115
115
 
116
- // Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
117
- // or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
118
- // hands-off. A platform without the bridge (no gate-sync CI installed) keeps the local write path,
119
- // or reviews could never advance. Mirrors plan.mjs hubActions.
120
- const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
116
+ // Bridge mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
117
+ // defined once in manifest.mjs (`isBridgeHub`) and shared with plan.mjs's wiring and the ledger
118
+ // hook, so no two readers can disagree about who owns the ledger (#186).
119
+ const isBridge = isBridgeHub;
121
120
 
122
121
  // requireEngagement (config `hub.review.requireEngagement`): when on, the predicate counts only
123
122
  // approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
package/cli/hook.mjs ADDED
@@ -0,0 +1,224 @@
1
+ // `yad hook ledger-guard` — the harness-side half of the ledger rule (#171).
2
+ //
3
+ // In BRIDGE mode the gate ledger is CI-owned: `templates/checks/ledger-guard.sh` rejects any non-bot
4
+ // commit that changes `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`.
5
+ // That gate is the authority, but it only speaks at CI time — an agent that hand-edits `state.json`
6
+ // learns twenty minutes later, from a failed pipeline with nothing connecting cause to effect. This
7
+ // hook says the same thing at the moment of the edit, and names the command that owns the transition.
8
+ //
9
+ // HARNESS-AGNOSTIC CONTRACT — the reason this is a `yad` subcommand and not Claude-Code-shaped code:
10
+ // stdin a harness tool-call payload as JSON (optional; `--path <p>` works instead)
11
+ // exit 0 allow
12
+ // exit 2 deny — the reason is on stderr, for the agent to read
13
+ // Claude Code's PreToolUse protocol is exactly that (exit 2 blocks the call and feeds stderr back to
14
+ // the model), so `hooks/ledger-guard.sh` wires it with no adapter logic; another harness needs only
15
+ // the same two exit codes.
16
+ //
17
+ // FAIL-OPEN, deliberately. No hub, unreadable config, an unparseable payload, no git — every one of
18
+ // those ALLOWS, with a note on stderr. This is a local guardrail, and one that failed closed would
19
+ // brick an agent's ability to edit anything the moment a config went sideways. The asymmetry is the
20
+ // design: `ledger-guard` in CI fails closed and is what actually protects the ledger.
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { note, readJSON, run } from './lib.mjs';
24
+ import { PROJECT_FILES, isBridgeHub } from './manifest.mjs';
25
+
26
+ // The CI-owned files, exactly as `templates/checks/ledger-guard.sh` lists them. NOT `contract-lock.json`
27
+ // (artifact-side: the architect commits it with the architecture) and NOT `change.json` — both are a
28
+ // human's to write, and both are exempt in the gate too.
29
+ const LEDGER_FILES = new Set(['state.json', 'approvals.json', 'comments.json', 'hub-prs.json']);
30
+
31
+ // `epics/<epic>/.sdlc/<ledger>.json` or `epics/<epic>/reviews/<name>.md` → { epic, rel }; else null.
32
+ // Takes a hub-relative POSIX path.
33
+ //
34
+ // Depth is matched the way the CI gate matches it, not more strictly. Its arms are bash `case`
35
+ // globs — `epics/*/.sdlc/state.json` — and a bash glob's `*` spans `/`, so the gate ALSO rejects
36
+ // `epics/EP-a/nested/.sdlc/state.json`. Requiring exactly four segments here would have let a path
37
+ // through locally that CI blocks, in a guard whose whole claim is that its scope is the gate's.
38
+ // The slug is the second segment either way (the gate's `${f#epics/}` / `${_slug%%/*}`).
39
+ export function protectedLedgerPath(rel) {
40
+ if (!rel.startsWith('epics/')) return null;
41
+ const epic = rel.slice('epics/'.length).split('/')[0];
42
+ if (!epic || epic === '.' || epic === '..') return null;
43
+ const ledgers = [...LEDGER_FILES].map((f) => f.replace('.', '\\.')).join('|');
44
+ if (new RegExp(`/\\.sdlc/(?:${ledgers})$`).test(rel)) return { epic, rel, kind: 'state' };
45
+ if (/\/reviews\/.*\.md$/.test(rel)) return { epic, rel, kind: 'review' };
46
+ return null;
47
+ }
48
+
49
+ // Every path a tool call would write. Covers the shapes harnesses actually send: a single
50
+ // `file_path` (Edit/Write), `notebook_path` (NotebookEdit), and a `MultiEdit`-style `edits[]` array.
51
+ // An unrecognised payload yields nothing, which allows — see the fail-open note above.
52
+ export function payloadPaths(payload) {
53
+ const out = [];
54
+ const input = payload?.tool_input;
55
+ if (!input || typeof input !== 'object') return out;
56
+ for (const key of ['file_path', 'notebook_path', 'path']) {
57
+ if (typeof input[key] === 'string' && input[key]) out.push(input[key]);
58
+ }
59
+ if (Array.isArray(input.edits)) {
60
+ for (const edit of input.edits) if (typeof edit?.file_path === 'string' && edit.file_path) out.push(edit.file_path);
61
+ }
62
+ return [...new Set(out)];
63
+ }
64
+
65
+ // The hub a path belongs to: the nearest ancestor holding `.sdlc/hub.json`.
66
+ //
67
+ // Resolved from the PATH, never from the session. The documented layout puts code repos BESIDE the
68
+ // hub (`project/{product,backend,mobile}` — see `insideWorkspace` in setup.mjs), so a session opened
69
+ // at the workspace has no `hub.json` under its root, and a session-rooted lookup would find nothing
70
+ // and silently allow a mutation inside `project/product/epics/…` — the multi-repo, parallel-agent
71
+ // setup this hook was reported from.
72
+ export function hubRootFor(abs) {
73
+ let dir = path.dirname(path.resolve(abs));
74
+ for (;;) {
75
+ if (fs.existsSync(path.join(dir, PROJECT_FILES.hubConfig))) return dir;
76
+ const parent = path.dirname(dir);
77
+ if (parent === dir) return null;
78
+ dir = parent;
79
+ }
80
+ }
81
+
82
+ // Where a RELATIVE path in the payload is anchored. Only used to make such a path absolute, so the
83
+ // hub walk-up above has somewhere to start.
84
+ export function baseDirFor(env = process.env, runner = run) {
85
+ if (env.CLAUDE_PROJECT_DIR) return env.CLAUDE_PROJECT_DIR;
86
+ const top = runner('git', ['rev-parse', '--show-toplevel']);
87
+ return top.ok && top.stdout ? top.stdout : process.cwd();
88
+ }
89
+
90
+ // Case-folded, exactly as the CI gate folds (`tr '[:upper:]' '[:lower:]'`).
91
+ const fold = (s) => s.toLowerCase();
92
+
93
+ // The base ref, resolved in the CI gate's own order: `origin/<default_branch>`, then the remote's
94
+ // published default, then `origin/main`. Returns null when none resolves.
95
+ //
96
+ // ORIGIN refs only — never a bare local branch. A local trunk is whatever the developer last pulled,
97
+ // and `git fetch` never fast-forwards it, so probing `main` would report an epic whose review PR has
98
+ // already merged as absent from the base and wave a real mutation straight through. That is the
99
+ // stale-clone case, and it is the common one, not an edge.
100
+ export function resolveHookBase(hubRoot, hub, runner = run) {
101
+ const cfg = hub?.default_branch || '';
102
+ const head = runner('git', ['-C', hubRoot, 'symbolic-ref', '--short', '--quiet', 'refs/remotes/origin/HEAD']);
103
+ for (const base of [cfg ? `origin/${cfg}` : '', head.ok ? head.stdout : '', 'origin/main']) {
104
+ if (!base || base === 'origin/') continue;
105
+ if (runner('git', ['-C', hubRoot, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`]).ok) return base;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // Every epic whose ledger is already on the base ref, case-folded. The #162 carve-out, mirrored from
111
+ // the gate's `is_seeding`: no CI path can CREATE a ledger (`gate ci` only advances an existing chain,
112
+ // at merge, on the default branch), so a brand-new epic's seed is legitimately a human's write and
113
+ // rides the first review PR/MR. Mutating a ledger that is already on the base is what only the bot
114
+ // may do.
115
+ //
116
+ // Read with `ls-tree` from the hub, never with a `<rev>:<path>` probe: a rev:path spec is always
117
+ // resolved from the repository TOP LEVEL and `-C` does not re-anchor it, so a hub sitting in a
118
+ // subdirectory of its repo (a monorepo, or a workspace that is itself a repo) would miss on every
119
+ // probe and the guard would allow everything, silently. `ls-tree` run with `-C hubRoot` takes a
120
+ // cwd-relative pathspec and prints cwd-relative paths, so both halves stay hub-relative.
121
+ //
122
+ // Slugs are FOLDED because the gate folds them: on a case-insensitive filesystem `epics/ep-x/…` and
123
+ // `epics/EP-X/…` are the same file, so a byte-exact compare lets a mutation be laundered as a
124
+ // creation — the vector the gate's own header names.
125
+ //
126
+ // null means the base could not be read at all — "unknown", which ALLOWS. The working tree cannot
127
+ // stand in for the base ref: a seed writes `state.json` first, so using that as proof would deny
128
+ // every remaining file of the same seed.
129
+ export function seededSlugs(hubRoot, hub, runner = run) {
130
+ const base = resolveHookBase(hubRoot, hub, runner);
131
+ if (!base) return null;
132
+ const tree = runner('git', [
133
+ '-C', hubRoot, '-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', '-z', base, '--', 'epics',
134
+ ]);
135
+ if (!tree.ok) return null;
136
+ const slugs = new Set();
137
+ for (const p of tree.stdout.split('\0')) {
138
+ const m = /^epics\/([^/]+)\/\.sdlc\/state\.json$/.exec(p);
139
+ if (m) slugs.add(fold(m[1]));
140
+ }
141
+ return slugs;
142
+ }
143
+
144
+
145
+ // What the agent is told when the edit is refused. Names the command that owns each transition —
146
+ // the whole point of #171 was that the ledger write had no command behind it.
147
+ export function denyMessage({ epic, rel, hubRoot }) {
148
+ return [
149
+ `[yad] Blocked: ${rel} is CI-owned gate state.`,
150
+ '',
151
+ 'This hub runs in bridge mode, where CI is the sole writer of the gate ledger. The `ledger-guard`',
152
+ 'check rejects any non-bot commit that changes it, so this edit cannot reach the default branch —',
153
+ 'it would fail the review PR/MR and have to be reverted.',
154
+ '',
155
+ 'Use the command that owns the transition instead:',
156
+ ` author step done → review opened yad gate open ${epic} <artifact>`,
157
+ ' the full advance at merge CI runs `yad gate ci --merged` — nothing to do locally',
158
+ ` a genuinely broken ledger yad gate repair ${epic}`,
159
+ '',
160
+ 'Commit the ARTIFACT only (the .md you authored) and hand off to `yad-review-gate`; the ledger',
161
+ 'follows on merge.',
162
+ '',
163
+ `hub: ${hubRoot} · override for one command: YAD_HOOK_DISABLE=1`,
164
+ ].join('\n');
165
+ }
166
+
167
+ // The decision, with git injectable so the tests can drive every branch. Returns
168
+ // `{ allow: true }` or `{ allow: false, message, epic, rel }`.
169
+ export function ledgerGuardDecision(paths, { env = process.env, runner = run } = {}) {
170
+ if (env.YAD_HOOK_DISABLE) return { allow: true, skipped: 'YAD_HOOK_DISABLE' };
171
+ if (!paths.length) return { allow: true };
172
+ const base = baseDirFor(env, runner);
173
+ // One `ls-tree` per hub, not one per candidate path: a MultiEdit carries many paths and this runs
174
+ // inside the agent's tool loop.
175
+ const seededByHub = new Map();
176
+ for (const candidate of paths) {
177
+ const abs = path.resolve(base, candidate);
178
+ const hubRoot = hubRootFor(abs);
179
+ if (!hubRoot) continue;
180
+ // Non-strict on purpose: a hub.json that does not parse is a real problem, but refusing every
181
+ // edit in the repo is not this hook's way of reporting it (`yad doctor` says so properly).
182
+ const hub = readJSON(path.join(hubRoot, PROJECT_FILES.hubConfig), null);
183
+ if (!isBridgeHub(hub)) continue;
184
+ const rel = path.relative(hubRoot, abs).split(path.sep).join('/');
185
+ const hit = protectedLedgerPath(rel);
186
+ if (!hit) continue;
187
+ if (!seededByHub.has(hubRoot)) seededByHub.set(hubRoot, seededSlugs(hubRoot, hub, runner));
188
+ const seeded = seededByHub.get(hubRoot);
189
+ if (seeded === null) continue; // base unreadable — unknown allows
190
+ if (!seeded.has(fold(hit.epic))) continue; // creation, not mutation (#162)
191
+ return { allow: false, epic: hit.epic, rel, message: denyMessage({ epic: hit.epic, rel, hubRoot }) };
192
+ }
193
+ return { allow: true };
194
+ }
195
+
196
+ // Read the harness payload off stdin. Absent, empty, or unparseable all mean "nothing to inspect" —
197
+ // never an error, and never a block.
198
+ function readPayload() {
199
+ try {
200
+ if (process.stdin.isTTY) return null;
201
+ const raw = fs.readFileSync(0, 'utf8').trim();
202
+ if (!raw) return null;
203
+ return JSON.parse(raw);
204
+ } catch {
205
+ note('yad hook: could not read a JSON tool payload on stdin — allowing');
206
+ return null;
207
+ }
208
+ }
209
+
210
+ // The `yad hook ledger-guard` entry point. `paths` (from `--path`) is additive to the payload, so
211
+ // a harness with no JSON contract can call the guard directly.
212
+ export function runLedgerGuardHook({ paths = [] } = {}) {
213
+ // Only read stdin when there is nothing else to go on. `readFileSync(0)` blocks until EOF, and a
214
+ // caller that passes `--path` (the documented stdin-free alternative) may well have inherited an
215
+ // open pipe from a long-lived parent — `isTTY` is false there, so the TTY guard does not trip and
216
+ // the hook would hang forever. A hang is strictly worse for an agent than a block.
217
+ const payload = paths.length ? null : readPayload();
218
+ const all = [...new Set([...payloadPaths(payload), ...paths])];
219
+ const verdict = ledgerGuardDecision(all);
220
+ if (verdict.allow) return 0;
221
+ console.error(verdict.message);
222
+ process.exitCode = 2;
223
+ return 2;
224
+ }
package/cli/manifest.mjs CHANGED
@@ -158,6 +158,18 @@ export const PROJECT_FILES = {
158
158
  version: '.sdlc/cli-version.json',
159
159
  };
160
160
 
161
+ // Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
162
+ // or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
163
+ // hands-off, `hubActions` wires the hub CI, and the ledger guards (the `ledger-guard` check gate and
164
+ // the `yad hook ledger-guard` harness hook) are live. A platform without the bridge keeps the local
165
+ // write path, or reviews could never advance.
166
+ //
167
+ // ONE definition, imported by every JS caller. Copies that drift are how #186 happened — a hub that
168
+ // one reader called bridge and another called file-only had no permitted ledger writer at all.
169
+ // `templates/checks/ledger-guard.sh` re-implements it in bash because the check gates are standalone
170
+ // by design; that copy is the only one, and its header says so.
171
+ export const isBridgeHub = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
172
+
161
173
  // ---- `yad commit` conventions (mirror skills/sdlc/config.yaml `build`) ----
162
174
  // Conventional-commit types (config.yaml commit_subject_style).
163
175
  export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'refactor', 'test', 'perf', 'build', 'ci', 'chore', 'revert'];
@@ -275,3 +287,31 @@ export const HUB_WIRING = {
275
287
  { src: 'skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml', dest: '.gitlab/ci/yad-update-guard.yml' },
276
288
  ],
277
289
  };
290
+
291
+ // Harness hooks: the LOCAL half of the ledger rule, installed on the hub beside the CI gates and
292
+ // active under the same bridge predicate (#171). Kept out of `HUB_WIRING` because a hook is not a
293
+ // CI gate — it is advisory, fails open, and its adapter (below) is per-harness, not per-platform.
294
+ export const HOOK_WIRING = [
295
+ { src: 'skills/yad-checks/templates/hooks/ledger-guard.sh', dest: 'hooks/ledger-guard.sh', exec: true },
296
+ ];
297
+
298
+ // Per-harness adapter config: which IDE target gets a hook entry written, and where.
299
+ // `.claude` alone — it is the only supported target with a defined hook protocol (`.agents`,
300
+ // `.zencoder` and `.opencode` carry skills only). The others simply get the script and no wiring;
301
+ // the contract in the script header is what they would wire by hand.
302
+ export const HOOK_SETTINGS = { '.claude': '.claude/settings.json' };
303
+ // The tools that can write a file. A `Bash` call (`sed -i epics/…`) is deliberately NOT matched:
304
+ // matching it would mean parsing shell, and CI's ledger-guard already fails closed on the result.
305
+ export const HOOK_TOOL_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
306
+ // `$CLAUDE_PROJECT_DIR` so the entry works whatever the harness's working directory is — QUOTED,
307
+ // because the harness runs this through a shell: unquoted, a project path containing a space
308
+ // word-splits, the command is not found, and the guard is silently off while `check` and `doctor`
309
+ // both still report it wired.
310
+ export const HOOK_COMMAND = '"$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh"';
311
+ // Spellings a previous yadflow wrote for the SAME hook. An installed entry matching one of these is
312
+ // ours to normalise; anything else is the team's, even if it names a similar path. Never widen this
313
+ // to a substring test — a team keeping its own wrapper at `.claude/hooks/ledger-guard.sh` would have
314
+ // their hook silently rewritten to ours.
315
+ export const HOOK_COMMAND_LEGACY = Object.freeze([
316
+ '$CLAUDE_PROJECT_DIR/hooks/ledger-guard.sh', // 3.16.x, pre-quoting
317
+ ]);
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/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/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.3",
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",
@@ -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
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # ledger-guard HARNESS HOOK — the local half of the CI gate of the same name (#171).
3
+ #
4
+ # The gate ledger is CI-owned in bridge mode: `checks/ledger-guard.sh` rejects any non-bot commit
5
+ # that changes `epics/*/.sdlc/{state,approvals,comments,hub-prs}.json` or `epics/*/reviews/*.md`.
6
+ # This hook says so at the moment an agent tries the edit, instead of twenty minutes later in a
7
+ # failed pipeline, and names the command that owns the transition (`yad gate open`).
8
+ #
9
+ # This file is only the ADAPTER. It locates `yad` and hands the tool-call payload to
10
+ # `yad hook ledger-guard`, which holds the decision — so the wiring never hard-codes an install path
11
+ # and the logic stays testable. The contract it passes through:
12
+ #
13
+ # stdin the harness's tool-call payload as JSON (optional)
14
+ # exit 0 allow
15
+ # exit 2 deny, reason on stderr
16
+ #
17
+ # Wired for Claude Code as a `PreToolUse` hook in `.claude/settings.json` (`yad check --fix` writes
18
+ # that entry). Any harness that can run a command and read those two exit codes can use it.
19
+ #
20
+ # FAIL-OPEN: if no `yad` can be found, this ALLOWS and says why on stderr. A guardrail that blocked
21
+ # every edit the moment an install went sideways would be worse than the problem. The CI gate fails
22
+ # CLOSED and is what actually protects the ledger.
23
+ set -uo pipefail
24
+
25
+ # The hub root is this script's grandparent — hooks/ledger-guard.sh — so the resolution below does
26
+ # not depend on the harness's working directory.
27
+ HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
28
+ HUB_ROOT="$(dirname -- "$HOOK_DIR")"
29
+
30
+ # Resolution order, cheapest and most specific first: an explicit override, then the copy installed
31
+ # in this hub, then whatever is on PATH, then a network-free npx. `--no-install` matters — a hook
32
+ # runs on every tool call and must never pause an agent to download a package.
33
+ CMD=()
34
+ _yad_bin="${YAD_BIN:-}"
35
+ if [ -n "${_yad_bin//[[:space:]]/}" ]; then
36
+ # Split deliberately: YAD_BIN is commonly an interpreter + script ("node /path/to/yad.mjs").
37
+ # The whitespace-stripped test above matters: a YAD_BIN of only spaces would leave CMD empty, and
38
+ # macOS's bash 3.2 treats "${CMD[@]}" on an empty array as an unbound variable under `set -u` —
39
+ # aborting the script with a confusing 127 instead of taking one of the branches below.
40
+ read -r -a CMD <<< "$_yad_bin"
41
+ elif [ -f "$HUB_ROOT/node_modules/yadflow/bin/yad.mjs" ] && command -v node >/dev/null 2>&1; then
42
+ CMD=(node "$HUB_ROOT/node_modules/yadflow/bin/yad.mjs")
43
+ elif command -v yad >/dev/null 2>&1; then
44
+ CMD=(yad)
45
+ elif command -v npx >/dev/null 2>&1; then
46
+ CMD=(npx --no-install yadflow)
47
+ else
48
+ echo " • yad hook: no \`yad\` on PATH and none installed in $HUB_ROOT — allowing (install yadflow to re-arm the ledger guard)" >&2
49
+ exit 0
50
+ fi
51
+
52
+ # Belt and braces for bash 3.2's empty-array-is-unbound rule: every branch above sets CMD, but an
53
+ # unexpanded array under `set -u` would abort the script rather than allow, so check before using it.
54
+ if [ "${#CMD[@]}" -eq 0 ]; then
55
+ echo " • yad hook: could not resolve a \`yad\` to run — allowing" >&2
56
+ exit 0
57
+ fi
58
+
59
+ # Run it rather than `exec`, so the exit code can be mapped. ONLY an explicit deny (2) blocks: a
60
+ # `yad` that is present but cannot run — an `npx --no-install` with no yadflow to find, a crash, a
61
+ # broken install — must not read as a refusal. Fail-open is the whole stance of this hook; the CI
62
+ # gate is what fails closed.
63
+ "${CMD[@]}" hook ledger-guard "$@"
64
+ rc=$?
65
+ [ "$rc" -eq 2 ] && exit 2
66
+ if [ "$rc" -ne 0 ]; then
67
+ echo " • yad hook: \`${CMD[*]} hook ledger-guard\` exited $rc — allowing (run \`yad doctor\` to check the install)" >&2
68
+ fi
69
+ exit 0
@@ -87,7 +87,9 @@ Install the hub CI that turns the human **merge** into a `yad gate ci` run, with
87
87
  writer** of the ledger. There is no pre-merge CI write — during review the platform PR/MR is the
88
88
  source of truth (native approvals + threads). On merge, CI re-reads approvals from the platform,
89
89
  advances the step, and flips the artifact `status:` on the **default branch** (the only place CI ever
90
- commits). Also install the `ledger-guard` check (yad-checks) so humans cannot commit gate-state files.
90
+ commits). Also install the `ledger-guard` check (yad-checks) so humans cannot commit gate-state files,
91
+ and its local counterpart `hooks/ledger-guard.sh` — the harness hook that refuses an **agent** the
92
+ same write at the moment it tries it, instead of letting it surface as a CI failure later (#171).
91
93
  Revoke-on-change is enforced at merge: on **GitHub** in code (an approval whose commit ≠ the merged
92
94
  head is dropped — no setting needed); on **GitLab** it has no per-approval commit SHA, so enabling the
93
95
  platform's **"remove all approvals when commits are added to the source branch"** is **required** for
@@ -188,7 +188,9 @@ PR only — against the `review/<epic>/<artifact>` branch, which must already ex
188
188
  records this skill describes. The skill's
189
189
  job is the human half: presenting the artifact, helping the owner address comments, and narrating the
190
190
  gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
191
- nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it). The single
191
+ nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it, and the
192
+ `hooks/ledger-guard.sh` harness hook refuses an agent the edit up front, naming `yad gate open`
193
+ instead — see `yad-checks`). The single
192
194
  exception is an epic's **seed** — no CI path can create a ledger, so a brand-new epic's `.sdlc/` rides
193
195
  its **first** review PR/MR, cut from the authoring branch (creation, not mutation, #162).
194
196