yadflow 3.6.1 → 3.7.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,9 +1,13 @@
1
- ## [3.6.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.6.0...v3.6.1) (2026-07-04)
1
+ # [3.7.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.6.1...v3.7.0) (2026-07-04)
2
2
 
3
3
 
4
- ### Bug Fixes
4
+ ### Features
5
5
 
6
- * **gate:** include the Checklist section in the hub review-PR body ([3134f89](https://github.com/abdelrahmannasr/yadflow/commit/3134f89658a172a3a346cf0945f62e6fa63bac74)), closes [#103](https://github.com/abdelrahmannasr/yadflow/issues/103)
6
+ * **cli:** add shared hub-commit default-branch guard helpers ([44290f6](https://github.com/abdelrahmannasr/yadflow/commit/44290f6109c806f077609fec352904efe801f830))
7
+ * **cli:** add yad checkpoint to commit machine-written back-half state ([c09089c](https://github.com/abdelrahmannasr/yadflow/commit/c09089c698884ad39fc8cf66a7e28d8d44321668))
8
+ * **cli:** add yad tidy up to fold finished ledger shards ([59727f3](https://github.com/abdelrahmannasr/yadflow/commit/59727f32a7b5e3274c8fd96b8b39134b3acf6705))
9
+ * **cli:** shard-then-fold storage for the back-half ledgers ([6182598](https://github.com/abdelrahmannasr/yadflow/commit/618259847e3dbdee5f00bce4fa2a5d64df0364a3))
10
+ * **cli:** wire yad checkpoint and yad tidy up into the CLI ([fe4770d](https://github.com/abdelrahmannasr/yadflow/commit/fe4770de87ebec394c3a29c78077a0baf7a924cc))
7
11
 
8
12
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
13
 
package/bin/yad.mjs CHANGED
@@ -10,6 +10,8 @@ import { runCommit } from '../cli/commit.mjs';
10
10
  import { runOpenPr } from '../cli/openpr.mjs';
11
11
  import { reviewTrailer, reviewContext, reviewNudge, reviewReconcile, reviewWalkthrough } from '../cli/review.mjs';
12
12
  import { runShip } from '../cli/ship.mjs';
13
+ import { runCheckpoint } from '../cli/checkpoint.mjs';
14
+ import { runTidy } from '../cli/tidy.mjs';
13
15
  import { runRepo } from '../cli/repo.mjs';
14
16
  import { runRoster } from '../cli/roster.mjs';
15
17
  import { runDocs } from '../cli/docs.mjs';
@@ -81,6 +83,14 @@ ${c.bold('Build helpers')}
81
83
  branch opens the front-half artifact-review PR (delegates to
82
84
  gate open), any other hub branch uses the code-task template
83
85
  yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
86
+ yad checkpoint [--push] Commit the machine-written back-half hub state
87
+ (trust-log/build-log/build-state) as one audit-trail
88
+ chore(hub) commit — default branch only (--allow-branch
89
+ to override); a no-op when nothing changed
90
+ yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
91
+ trust-log/build-log entries) back into the single folded
92
+ ledger, as one chore(hub) commit — the manual "pack it up"
93
+ for the shard files; a no-op when nothing is foldable
84
94
  yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
85
95
  yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
86
96
  yad review walkthrough --repo <r> --pr <n> Bundle + ordered risk-tagged stops for the
@@ -133,6 +143,8 @@ function parseArgs(argv) {
133
143
  else if (a === '--force') o.force = true;
134
144
  else if (a === '--contract-change') o.contractChange = true;
135
145
  else if (a === '--no-push') o.noPush = true;
146
+ else if (a === '--push') o.push = true;
147
+ else if (a === '--allow-branch') o.allowBranch = true;
136
148
  else if (a === '--merged') o.merged = true;
137
149
  else if (a === '--overview') o.overview = true;
138
150
  // `--check` is a bare boolean for `docs sync --check`, but takes a value for
@@ -259,6 +271,15 @@ async function main() {
259
271
  case 'ship':
260
272
  await runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
261
273
  break;
274
+ case 'checkpoint':
275
+ await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
276
+ break;
277
+ case 'tidy': {
278
+ const [, action, epic] = o._;
279
+ if (action !== 'up') { log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`); process.exitCode = action ? 1 : 0; break; }
280
+ await runTidy(o.dir, { epic: epic || o.epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
281
+ break;
282
+ }
262
283
  case 'repo': {
263
284
  const [, action, name] = o._;
264
285
  await runRepo(o.dir, { action: action || 'list', name, today });
@@ -0,0 +1,159 @@
1
+ // `yad checkpoint` — commit the machine-written back-half hub state (trust-log / build-log /
2
+ // build-state) as one audit-trail commit. This is the back-half analogue of the front-half gate sync
3
+ // (cli/gate.mjs): the SDLC back half (yad-run, yad-engineer-review) WRITES these ledgers into the
4
+ // working tree but never commits them, so teammates/CI/`yad status` on other machines see stale trust
5
+ // evidence. checkpoint lands them with a `chore(hub): ...` message.
6
+ //
7
+ // Two invariants keep it out of the gates' way:
8
+ // 1. It stages ONLY the three back-half ledgers by an explicit allowlist — never `git add -A`,
9
+ // which would sweep the CI-owned front-half ledger (state/approvals/comments/hub-prs.json,
10
+ // reviews/*.md) and trip the ledger-guard gate.
11
+ // 2. It commits ONLY on the default branch (mirroring gate.mjs), so the commit never enters a PR's
12
+ // base..HEAD range — where verified-commits would fail an unsigned commit and its [skip ci]
13
+ // marker would strand the PR's required checks.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
17
+ import { PROJECT_FILES } from './manifest.mjs';
18
+ import { loadHub } from './gate.mjs';
19
+ import { resolveCommitterLogin } from './platform.mjs';
20
+ import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
21
+
22
+ // The machine-written back-half ledgers, relative to an epic's dir. The two append-only logs are
23
+ // shard-then-fold (cli/ledger.mjs): each is a folded file PLUS a shard dir of loose per-entry files —
24
+ // both are allowlisted so a checkpoint commits new shards and any `yad tidy up` fold. `build-state` is
25
+ // the whole dir (one JSON per story). Keep in sync with cli/manifest.mjs epicFiles.
26
+ const BACK_HALF = [
27
+ '.sdlc/trust-log.json', '.sdlc/trust-log',
28
+ '.sdlc/build-log.json', '.sdlc/build-log',
29
+ '.sdlc/build-state',
30
+ ];
31
+
32
+ // PURE — the repo-relative pathspecs to stage: every back-half ledger that exists under any epic.
33
+ // Explicit allowlist by design (see invariant 1 above).
34
+ export function backHalfPathspecs(root) {
35
+ const epicsDir = path.join(root, 'epics');
36
+ if (!fs.existsSync(epicsDir)) return [];
37
+ const out = [];
38
+ for (const e of fs.readdirSync(epicsDir, { withFileTypes: true })) {
39
+ if (!e.isDirectory()) continue;
40
+ for (const rel of BACK_HALF) {
41
+ if (fs.existsSync(path.join(epicsDir, e.name, rel))) out.push(path.posix.join('epics', e.name, rel));
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // PURE — turn the staged pathspecs into the subject label + the body's file list. A story id is pulled
48
+ // from any file that carries one (`build-state/<story>.json`, a `trust-log/`/`build-log/` shard whose
49
+ // name starts with the story id); the folded logs are epic-scoped. Prefer the most specific label.
50
+ export function summarizeStaged(files = []) {
51
+ const stories = new Set();
52
+ const epics = new Set();
53
+ const basenames = [];
54
+ for (const f of files) {
55
+ const m = f.match(/^epics\/([^/]+)\/\.sdlc\/(.+)$/);
56
+ if (!m) continue;
57
+ const [, epic, rest] = m;
58
+ epics.add(epic);
59
+ // the first `…-S0N` token in the filename is the story id (ids contain hyphens; `[\w-]` stops at `/`)
60
+ const s = rest.match(/([A-Za-z][\w-]*?-S\d+)/);
61
+ if (s) stories.add(`${epic}/${s[1]}`);
62
+ basenames.push(rest);
63
+ }
64
+ let label;
65
+ if (stories.size === 1) label = [...stories][0];
66
+ else if (stories.size > 1) label = `${stories.size} stories`;
67
+ else if (epics.size === 1) label = [...epics][0];
68
+ else label = `${epics.size} epics`;
69
+ return { label, basenames };
70
+ }
71
+
72
+ // Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
73
+ // path from breaking the one-line subject or injecting a fake trailer line.
74
+ const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
75
+
76
+ // `@login` from the roster (the auditable handle the user asked for), else the raw git user.name,
77
+ // else a stable placeholder so the subject is never empty.
78
+ export function checkpointAuthor(login, name) {
79
+ if (login) return `@${oneLine(login)}`;
80
+ const n = oneLine(name);
81
+ return n || 'unknown';
82
+ }
83
+
84
+ // PURE — the audit-trail commit message. The subject passes the hub commit-message gate (valid type
85
+ // `chore`, optional scope `hub`, non-empty description, no trailing period). No Task trailer and no
86
+ // Co-Authored-By footer: this is human-owned machine state, not an authored code change. `label` and
87
+ // `author` are collapsed to one line so nothing can split the subject or forge a trailer.
88
+ export function buildCheckpointMessage({ label, author, basenames = [] }) {
89
+ const subject = `chore(hub): sync back-half state — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
90
+ const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
91
+ return body ? `${subject}\n\n${body}` : subject;
92
+ }
93
+
94
+ export async function runCheckpoint(root, opts = {}) {
95
+ log(c.bold('\nyad checkpoint'));
96
+ if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
97
+ if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
98
+ fail('no .sdlc/hub.json — checkpoint commits the hub back-half ledger; run it from the product hub');
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+
103
+ const { hub } = loadHub(root);
104
+ const git = hubGit(root);
105
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
106
+ const defaultBranch = resolveDefaultBranch(git, hub);
107
+
108
+ // Default-branch guard (invariant 2) — shared with `yad tidy up`.
109
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint' })) return;
110
+
111
+ const pathspecs = backHalfPathspecs(root);
112
+ if (!pathspecs.length) { info('no back-half ledgers found — nothing to checkpoint'); return; }
113
+
114
+ // Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
115
+ // files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
116
+ // top-level ledger is intentionally NOT staged (its spec drops out on the existence check) — an
117
+ // append-only audit ledger vanishing is an anomaly a human should see, not something to auto-commit.
118
+ const add = git('add', '--', ...pathspecs);
119
+ if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
120
+ if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
121
+ info('back-half state unchanged — nothing to commit');
122
+ return;
123
+ }
124
+ // The exact files staged from the allowlist — all known to git by construction, so they are the
125
+ // pathspec for the commit (a directory spec like build-state/ would make `git commit -- <dir>` fail
126
+ // when the dir is empty, e.g. created before its first story JSON). This also scopes the commit to
127
+ // ONLY the allowlist, so any unrelated pre-staged file never rides along.
128
+ const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
129
+
130
+ const { label, basenames } = summarizeStaged(staged);
131
+ const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
132
+ const message = buildCheckpointMessage({ label, author, basenames });
133
+
134
+ if (opts.dryRun) {
135
+ log('\n' + c.dim(message) + '\n');
136
+ git('reset', '-q', '--', ...pathspecs); // restore the index — a dry run must not leave things staged
137
+ info('dry run — not committed');
138
+ return { message };
139
+ }
140
+
141
+ const cm = git('commit', '-m', message, '--', ...staged);
142
+ if (!cm.ok) {
143
+ git('reset', '-q', '--', ...pathspecs); // don't leave the ledgers staged for an unrelated commit to sweep up
144
+ fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
145
+ process.exitCode = 1;
146
+ return { message };
147
+ }
148
+ ok(`checkpointed ${staged.length} file(s): ${c.dim(label)}`);
149
+
150
+ if (!opts.push) return { message };
151
+ // Push HEAD to its OWN branch — never to `defaultBranch` blindly. On the default branch these are the
152
+ // same; with --allow-branch on a WIP branch, pushing HEAD:defaultBranch would publish the whole WIP
153
+ // branch to the default branch (bypassing review), so the target is always the branch we are on.
154
+ if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { message }; }
155
+ fail(`could not push to origin/${branch} — a protected branch, or the append-only ledgers hit an unresolvable rebase conflict`);
156
+ hand(`run \`git pull --rebase\` and re-run \`yad checkpoint --push\``);
157
+ process.exitCode = 1;
158
+ return { message };
159
+ }
package/cli/gate.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import {
8
- c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run,
8
+ c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
9
9
  } from './lib.mjs';
10
10
  import { PROJECT_FILES } from './manifest.mjs';
11
11
  import {
@@ -83,7 +83,7 @@ function warnIncompleteDiscovery(epicDir, artifact) {
83
83
 
84
84
  // Fail fast on a corrupt or wrong-shape hub config: a silently-defaulted hub.json would degrade
85
85
  // every gate to file-only without anyone noticing, and a typo'd platform would read as "no bridge".
86
- function loadHub(root) {
86
+ export function loadHub(root) {
87
87
  const hubFile = path.join(root, PROJECT_FILES.hubConfig);
88
88
  const regFile = path.join(root, PROJECT_FILES.reposRegistry);
89
89
  // Distinguish an ABSENT hub.json (null default → fine, file-only gate) from one that exists but
@@ -441,13 +441,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
441
441
  ok(`committed gate update: ${c.dim(subject)}`);
442
442
  if (!push) return { synced };
443
443
 
444
- for (let attempt = 1; attempt <= 3; attempt++) {
445
- if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed to origin/${target}`); return { synced }; }
446
- if (attempt < 3) {
447
- info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
448
- if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
449
- }
450
- }
444
+ if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced }; }
451
445
  fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
452
446
  process.exitCode = 1;
453
447
  return { synced };
@@ -0,0 +1,33 @@
1
+ // Shared machinery for committing the machine-written back-half ledgers to the hub — used by both
2
+ // `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
3
+ // the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
4
+ // strand required checks and fail verified-commits). This module is the single home of that guard.
5
+ import { warn, fail, hand, run } from './lib.mjs';
6
+
7
+ export const hubGit = (root) => (...args) => run('git', args, { cwd: root });
8
+
9
+ // The default branch: hub config, else the remote's published default (origin/HEAD), else 'main'.
10
+ // NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
11
+ // default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
12
+ // a future PR's range.
13
+ export function resolveDefaultBranch(git, hub) {
14
+ const originHead = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD'); // e.g. "origin/main"
15
+ return hub?.default_branch
16
+ || (originHead.ok && originHead.stdout ? originHead.stdout.replace(/^origin\//, '') : '')
17
+ || 'main';
18
+ }
19
+
20
+ // Guard: only commit on the default branch. Returns true when OK; on a non-default branch it prints the
21
+ // refusal + sets a non-zero exit code, unless `allowBranch` overrides (with a warning). `--allow-branch`
22
+ // is the SINGLE documented override — never `--force` (which elsewhere only waives the atomic guard).
23
+ export function guardDefaultBranch(branch, defaultBranch, { allowBranch = false, cmd = 'yad checkpoint' } = {}) {
24
+ if (branch === defaultBranch) return true;
25
+ if (allowBranch) {
26
+ warn(`--allow-branch: committing on '${branch}' — pushes go to origin/${branch}, and this commit needs a verified signature to pass the gate in a PR`);
27
+ return true;
28
+ }
29
+ fail(`on '${branch}', not the default branch '${defaultBranch}' — ${cmd} commits go to the default branch to stay out of PR-checked ranges`);
30
+ hand(`switch to '${defaultBranch}' and re-run, or pass --allow-branch to override (set default_branch in .sdlc/hub.json if '${defaultBranch}' is wrong)`);
31
+ process.exitCode = 1;
32
+ return false;
33
+ }
package/cli/ledger.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // Shard-then-fold storage for the two append-only back-half ledgers (trust-log, build-log).
2
+ //
3
+ // The problem: both ledgers were ONE file per epic, so two people driving different stories of the
4
+ // SAME epic both appended to the same file → git merge conflict on push. The fix ("loose objects +
5
+ // `git gc`"): each writer writes ONE small file per entry under a shard dir, so concurrent writers
6
+ // touch different files → zero conflict, by construction. Readers UNION the folded file (the legacy
7
+ // single file, and the output of `yad tidy up`) with every loose shard. `yad tidy up` folds finished
8
+ // shards back into the folded file on demand — a single, serialized, human-run act.
9
+ //
10
+ // A legacy epic that only has the folded file still reads correctly (no shards to union) → zero
11
+ // migration; new writes simply go to shards.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { readJSON, readJSONStrict, writeJSON } from './lib.mjs';
15
+ import { epicFiles } from './manifest.mjs';
16
+
17
+ // ---- shard filenames — the ONE source of truth for the naming convention -------------------------
18
+ // story ids already contain hyphens; the filename is just a unique handle (the entry inside carries
19
+ // the fields), so no parsing-back is needed. A trust entry needs `uid` to stay unique across re-runs
20
+ // of the same (story, repo, step); a ship is unique by (story, task, repo) already.
21
+ export const trustShardName = (e) => `${e.story}-${e.repo}-${e.step}-${e.uid}.json`;
22
+ export const buildShardName = (e) => `${e.story}-${e.task}-${e.repo}.json`;
23
+
24
+ // Read every shard object under `dir` (each file = ONE entry object). Sorted for determinism; a
25
+ // corrupt/non-object shard is skipped (these ledgers are advisory evidence, never fatal).
26
+ function readShardDir(dir) {
27
+ if (!fs.existsSync(dir)) return [];
28
+ const out = [];
29
+ for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
30
+ const obj = readJSON(path.join(dir, name), null);
31
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) out.push({ name, obj });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ // The half-applied-tidy guard key: a shard is a genuine duplicate of a folded entry ONLY when its FULL
37
+ // identity matches — NOT `uid` alone. `uid` is minted by an LLM skill, so two runs of DIFFERENT
38
+ // (story,repo,step) could share a short token; keying on uid alone would drop the second as a false
39
+ // "duplicate" and corrupt the trust count (in the unsafe direction). Legacy folded entries lack `uid`,
40
+ // so their key never collides with a real shard's key — they are never skipped.
41
+ const trustKey = (e) => `${e.story}|${e.repo}|${e.step}|${e.uid}`;
42
+
43
+ // trust-log = the evidence base. EVERY entry is a distinct step run (re-runs of a step share
44
+ // story/repo/step), so we CONCATENATE folded runs + shards and never dedup by (story,repo,step) —
45
+ // that would drop re-run history the trust threshold counts. The only guard is a shard whose full
46
+ // identity (`trustKey`) already appears folded (a half-applied tidy). A corrupt folded file must throw
47
+ // (readJSONStrict), never silently read as empty — under-reporting the evidence base is a safety bug.
48
+ export function readTrustRuns(epicDir) {
49
+ const f = epicFiles(epicDir);
50
+ const foldedObj = readJSONStrict(f.trustLog, null);
51
+ const folded = Array.isArray(foldedObj?.runs) ? foldedObj.runs : [];
52
+ const seen = new Set(folded.filter((e) => e && e.uid).map(trustKey));
53
+ const out = [...folded];
54
+ for (const { obj } of readShardDir(f.trustLogDir)) {
55
+ if (obj.uid && seen.has(trustKey(obj))) continue; // a true full-identity twin already folded
56
+ out.push(obj);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // build-log = one ship per (story, task, repo) — a natural unique key. `yad review reconcile` mutates
62
+ // a ship's engineer_review in its shard, so a shard is authoritative and WINS over a stale folded
63
+ // ship of the same key.
64
+ const shipKey = (s) => `${s.story}|${s.task}|${s.repo}`;
65
+ export function readShips(epicDir) {
66
+ const f = epicFiles(epicDir);
67
+ const foldedObj = readJSONStrict(f.buildLog, null);
68
+ const folded = Array.isArray(foldedObj?.ships) ? foldedObj.ships : [];
69
+ const byKey = new Map();
70
+ for (const s of folded) byKey.set(shipKey(s), s);
71
+ for (const { obj } of readShardDir(f.buildLogDir)) byKey.set(shipKey(obj), obj);
72
+ return [...byKey.values()];
73
+ }
74
+
75
+ // Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
76
+ // folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
77
+ // { found, where, file, ship }; found:false writes nothing (the caller warns).
78
+ export function updateShip(epicDir, match, update) {
79
+ const f = epicFiles(epicDir);
80
+ for (const { name, obj } of readShardDir(f.buildLogDir)) {
81
+ if (match(obj)) {
82
+ update(obj);
83
+ const file = path.join(f.buildLogDir, name);
84
+ writeJSON(file, obj);
85
+ return { found: true, where: 'shard', file, ship: obj };
86
+ }
87
+ }
88
+ const foldedObj = readJSONStrict(f.buildLog, null);
89
+ const ship = Array.isArray(foldedObj?.ships) ? foldedObj.ships.find(match) : null;
90
+ if (ship) {
91
+ update(ship);
92
+ writeJSON(f.buildLog, foldedObj);
93
+ return { found: true, where: 'folded', file: f.buildLog, ship };
94
+ }
95
+ return { found: false };
96
+ }
97
+
98
+ // ---- folding (used by `yad tidy up`) -------------------------------------------------------------
99
+ const trustSort = (a, b) =>
100
+ `${a.date || ''}|${a.story || ''}|${a.repo || ''}|${a.step || ''}|${a.uid || ''}`
101
+ .localeCompare(`${b.date || ''}|${b.story || ''}|${b.repo || ''}|${b.step || ''}|${b.uid || ''}`);
102
+ const buildSort = (a, b) =>
103
+ `${a.shippedAt || ''}|${a.story || ''}|${a.task || ''}|${a.repo || ''}`
104
+ .localeCompare(`${b.shippedAt || ''}|${b.story || ''}|${b.task || ''}|${b.repo || ''}`);
105
+
106
+ // Fold the shards that `pick(entry)` selects into the folded file, then delete them. Returns
107
+ // { folded, remaining, deleted } — `deleted` are the removed shard paths so the caller can stage them.
108
+ // Idempotent: nothing picked → no write, no delete. Deterministic order so the folded output is stable.
109
+ function fold(epicDir, { foldedPath, dir, arr, isTrust }, pick, { dryRun = false } = {}) {
110
+ const shards = readShardDir(dir);
111
+ const toFold = shards.filter((s) => pick(s.obj));
112
+ if (!toFold.length) return { folded: 0, remaining: shards.length, deleted: [] };
113
+ if (dryRun) return { folded: toFold.length, remaining: shards.length - toFold.length, deleted: [] };
114
+
115
+ // A corrupt folded file must ABORT the fold (readJSONStrict throws), never be silently rebuilt from
116
+ // scratch — rebuilding would erase all previously-folded history.
117
+ const foldedObj = readJSONStrict(foldedPath, null) || { epic: path.basename(epicDir), [arr]: [] };
118
+ if (!Array.isArray(foldedObj[arr])) foldedObj[arr] = [];
119
+ if (isTrust) {
120
+ // Guard on FULL identity (trustKey), not uid alone — a shard skipped here is a genuine twin of an
121
+ // already-folded run, so deleting it below is safe; two runs that merely share a uid keep both.
122
+ const seen = new Set(foldedObj.runs.filter((e) => e && e.uid).map(trustKey));
123
+ for (const s of toFold) if (!(s.obj.uid && seen.has(trustKey(s.obj)))) foldedObj.runs.push(s.obj);
124
+ foldedObj.runs.sort(trustSort);
125
+ } else {
126
+ const byKey = new Map(foldedObj.ships.map((s) => [shipKey(s), s]));
127
+ for (const s of toFold) byKey.set(shipKey(s.obj), s.obj);
128
+ foldedObj.ships = [...byKey.values()].sort(buildSort);
129
+ }
130
+ writeJSON(foldedPath, foldedObj);
131
+ const deleted = [];
132
+ for (const s of toFold) {
133
+ const p = path.join(dir, s.name);
134
+ fs.rmSync(p, { force: true });
135
+ deleted.push(p);
136
+ }
137
+ return { folded: toFold.length, remaining: shards.length - toFold.length, deleted };
138
+ }
139
+
140
+ export function foldTrust(epicDir, pick, opts = {}) {
141
+ const f = epicFiles(epicDir);
142
+ return fold(epicDir, { foldedPath: f.trustLog, dir: f.trustLogDir, arr: 'runs', isTrust: true }, pick, opts);
143
+ }
144
+ export function foldBuild(epicDir, pick, opts = {}) {
145
+ const f = epicFiles(epicDir);
146
+ return fold(epicDir, { foldedPath: f.buildLog, dir: f.buildLogDir, arr: 'ships', isTrust: false }, pick, opts);
147
+ }
package/cli/lib.mjs CHANGED
@@ -147,3 +147,19 @@ export function run(cmd, args = [], opts = {}) {
147
147
  };
148
148
  }
149
149
  export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;
150
+
151
+ // Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the front-half gate
152
+ // sync and the back-half checkpoint push append-only ledgers to the default branch, so a concurrent
153
+ // push is a normal race, not an error. Returns { ok } after up to `attempts` tries; logs each retry.
154
+ // A failed `pull --rebase` is aborted so we never leave a wedged rebase for the next command.
155
+ export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
156
+ const git = (...args) => run('git', args, { cwd });
157
+ for (let attempt = 1; attempt <= attempts; attempt++) {
158
+ if (git('push', 'origin', `HEAD:${target}`).ok) return { ok: true };
159
+ if (attempt < attempts) {
160
+ info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/${attempts})`);
161
+ if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort');
162
+ }
163
+ }
164
+ return { ok: false };
165
+ }
package/cli/manifest.mjs CHANGED
@@ -178,7 +178,13 @@ export const epicFiles = (epicRoot) => ({
178
178
  comments: `${epicRoot}/.sdlc/comments.json`,
179
179
  hubPrs: `${epicRoot}/.sdlc/hub-prs.json`,
180
180
  contractLock: `${epicRoot}/.sdlc/contract-lock.json`,
181
- buildLog: `${epicRoot}/.sdlc/build-log.json`,
181
+ // The two append-only back-half ledgers use shard-then-fold storage (cli/ledger.mjs): writers add
182
+ // one loose shard per entry under the *Dir path (conflict-free concurrent writes); `yad tidy up`
183
+ // folds finished shards back into the single *Log file. Readers union the folded file + loose shards.
184
+ buildLog: `${epicRoot}/.sdlc/build-log.json`, // folded ships (also the legacy single file)
185
+ buildLogDir: `${epicRoot}/.sdlc/build-log`, // one <story>-<task>-<repo>.json per ship
186
+ trustLog: `${epicRoot}/.sdlc/trust-log.json`, // folded runs (also the legacy single file)
187
+ trustLogDir: `${epicRoot}/.sdlc/trust-log`, // one <story>-<repo>-<step>-<uid>.json per run
182
188
  buildStateDir: `${epicRoot}/.sdlc/build-state`, // Phase 4 — per-story, per-repo back-half state
183
189
 
184
190
  change: `${epicRoot}/.sdlc/change.json`, // Phase 6 — change/defect intake + triage
package/cli/review.mjs CHANGED
@@ -6,8 +6,9 @@
6
6
  // The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
7
7
  // trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
8
8
  import path from 'node:path';
9
- import { log, ok, info, warn, fail, note, run, readJSON, writeJSON } from './lib.mjs';
10
- import { PROJECT_FILES, epicFiles } from './manifest.mjs';
9
+ import { log, ok, info, warn, fail, note, run, readJSON } from './lib.mjs';
10
+ import { PROJECT_FILES } from './manifest.mjs';
11
+ import { updateShip } from './ledger.mjs';
11
12
  import { epicRoot } from './epic-state.mjs';
12
13
  import {
13
14
  detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
@@ -161,18 +162,19 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
161
162
  engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
162
163
  }
163
164
 
164
- const file = epicFiles(epicRoot(root, epic)).buildLog;
165
- const ledger = readJSON(file, null);
166
165
  // Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
167
- const ship = ledger?.ships?.find((s) => s.pr != null
168
- && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)));
169
- if (!ship) {
166
+ // build-log is shard-then-fold now: updateShip mutates the ship's own loose shard (authoritative
167
+ // until `yad tidy up` folds it), or the folded file if already folded — never both.
168
+ const res = updateShip(
169
+ epicRoot(root, epic),
170
+ (s) => s.pr != null && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)),
171
+ (s) => { s.engineer_review = engineerReview; },
172
+ );
173
+ if (!res.found) {
170
174
  warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
171
175
  log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
172
176
  return { engineerReview, written: false };
173
177
  }
174
- ship.engineer_review = engineerReview;
175
- writeJSON(file, ledger);
176
- ok(`stamped engagement onto ${epic} ship ${ship.story || ''}${ship.task ? '/' + ship.task : ''} (PR #${pr})`);
178
+ ok(`stamped engagement onto ${epic} ship ${res.ship.story || ''}${res.ship.task ? '/' + res.ship.task : ''} (PR #${pr})`);
177
179
  return { engineerReview, written: true };
178
180
  }
package/cli/thread.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
8
+ import { readShips } from './ledger.mjs';
8
9
  import {
9
10
  epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic,
10
11
  resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
@@ -17,7 +18,9 @@ export const loadDebt = (root, epic) => {
17
18
  const v = readJSON(path.join(epicRoot(root, epic), '.sdlc', 'reconcile-debt.json'), []);
18
19
  return Array.isArray(v) ? v : [];
19
20
  };
20
- export const loadBuildLog = (root, epic) => readJSON(path.join(epicRoot(root, epic), '.sdlc', 'build-log.json'), null);
21
+ // The build ledger is shard-then-fold now (cli/ledger.mjs): union the folded file + loose ship shards
22
+ // so a caller sees every ship whether or not `yad tidy up` has folded them yet.
23
+ export const loadBuildLog = (root, epic) => ({ epic, ships: readShips(epicRoot(root, epic)) });
21
24
 
22
25
  // An epic is SEALED once every authored story is `shipped` (config.yaml change.seal_on). A sealed epic
23
26
  // refuses new behaviour (epic-open.sh) — a further change must open a new threaded change-epic, which is
package/cli/tidy.mjs ADDED
@@ -0,0 +1,116 @@
1
+ // `yad tidy up` — fold FINISHED back-half shards back into their single folded ledger, on demand.
2
+ //
3
+ // The two logs are shard-then-fold (cli/ledger.mjs): writers drop one loose file per entry so
4
+ // concurrent writers never conflict. Left alone they accumulate; `yad tidy up` is the "pack it up"
5
+ // step (git's loose-objects → `git gc`). It is a MANUAL, human-run act, so only one person folds at a
6
+ // time — the fold is a rewrite (worse to conflict on than an append), and doing it deliberately keeps
7
+ // it serialized. It folds ONLY shards of a SHIPPED story (terminal, no more writes coming), never an
8
+ // in-progress one, so it can't race an active writer. Idempotent: nothing finished → no-op.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
12
+ import { PROJECT_FILES } from './manifest.mjs';
13
+ import { loadHub } from './gate.mjs';
14
+ import { resolveCommitterLogin } from './platform.mjs';
15
+ import { checkpointAuthor } from './checkpoint.mjs';
16
+ import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
17
+ import { foldTrust, foldBuild } from './ledger.mjs';
18
+ import { epicRoot, isValidEpicId, readFrontmatter } from './epic-state.mjs';
19
+
20
+ // Story ids in an epic whose frontmatter `status` is `shipped` — the terminal, safe-to-fold signal
21
+ // (`yad-engineer-review` sets it once every task in the story has a ship record).
22
+ export function shippedStories(root, epic) {
23
+ const dir = path.join(epicRoot(root, epic), 'stories');
24
+ const set = new Set();
25
+ if (!exists(dir)) return set;
26
+ for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.md'))) {
27
+ if (readFrontmatter(path.join(dir, name)).status === 'shipped') set.add(name.replace(/\.md$/, ''));
28
+ }
29
+ return set;
30
+ }
31
+
32
+ function epicsWithLedgers(root) {
33
+ const dir = path.join(root, 'epics');
34
+ if (!exists(dir)) return [];
35
+ return fs.readdirSync(dir, { withFileTypes: true })
36
+ .filter((e) => e.isDirectory() && isValidEpicId(e.name)).map((e) => e.name).sort();
37
+ }
38
+
39
+ export async function runTidy(root, opts = {}) {
40
+ log(c.bold('\nyad tidy up'));
41
+ if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
42
+ if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
43
+ fail('no .sdlc/hub.json — run `yad tidy up` from the product hub');
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+
48
+ const { hub } = loadHub(root);
49
+ const git = hubGit(root);
50
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
51
+ const defaultBranch = resolveDefaultBranch(git, hub);
52
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up' })) return;
53
+
54
+ if (opts.epic && !isValidEpicId(opts.epic)) { fail(`invalid epic id '${opts.epic}'`); process.exitCode = 1; return; }
55
+ if (opts.epic && !exists(epicRoot(root, opts.epic))) { fail(`no such epic '${opts.epic}'`); process.exitCode = 1; return; }
56
+ const epics = opts.epic ? [opts.epic] : epicsWithLedgers(root);
57
+ const touched = [];
58
+ let folded = 0;
59
+ for (const epic of epics) {
60
+ const shipped = shippedStories(root, epic);
61
+ const pick = (e) => shipped.has(e.story); // only a shipped story's shards are safe to fold
62
+ const epicDir = epicRoot(root, epic);
63
+ const t = foldTrust(epicDir, pick, { dryRun: opts.dryRun });
64
+ const b = foldBuild(epicDir, pick, { dryRun: opts.dryRun });
65
+ if (t.folded + b.folded > 0) {
66
+ folded += t.folded + b.folded;
67
+ touched.push(epic);
68
+ info(`${epic}: ${opts.dryRun ? 'would fold' : 'folded'} ${t.folded} trust + ${b.folded} build shard(s)`);
69
+ }
70
+ }
71
+ if (!folded) { info('nothing to tidy — no finished shards to fold'); return { folded: 0 }; }
72
+
73
+ const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
74
+ const label = touched.length === 1 ? touched[0] : `${touched.length} epics`;
75
+ const message = `chore(hub): tidy back-half ledgers — ${label} by ${author} [skip ci]`;
76
+
77
+ if (opts.dryRun) {
78
+ log('\n' + c.dim(message) + '\n');
79
+ info('dry run — nothing folded or committed');
80
+ return { message, folded };
81
+ }
82
+
83
+ // Stage the fold (modified folded files + deleted shards) under each touched epic's ledger paths.
84
+ // A pathspec that matches NOTHING (neither on disk nor tracked) makes `git add` fatal (exit 128), so
85
+ // keep only the paths that exist OR are tracked — `git add -A` on those stages new/modified files AND
86
+ // the shard deletions (even when the shard dir is now empty or removed).
87
+ const candidates = touched.flatMap((e) => {
88
+ const rel = `epics/${e}/.sdlc`;
89
+ return [`${rel}/trust-log.json`, `${rel}/trust-log`, `${rel}/build-log.json`, `${rel}/build-log`];
90
+ });
91
+ const pathspecs = candidates.filter((spec) =>
92
+ exists(path.join(root, spec)) || git('ls-files', '--error-unmatch', '--', spec).ok);
93
+ if (!pathspecs.length) { info('fold produced no stageable change'); return { folded }; }
94
+ const add = git('add', '-A', '--', ...pathspecs);
95
+ if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return { folded }; }
96
+ const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
97
+ if (!staged.length) { info('fold produced no net change — nothing to commit'); return { folded }; }
98
+
99
+ const cm = git('commit', '-m', message, '--', ...staged);
100
+ if (!cm.ok) {
101
+ // Leave the fold STAGED (don't reset) — the on-disk fold already happened, so a re-run would see
102
+ // "nothing to tidy"; keeping it staged lets a retry or `yad checkpoint` land it, never lose it.
103
+ fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
104
+ hand('the fold is staged — re-run `yad tidy up`, or `yad checkpoint` to land it');
105
+ process.exitCode = 1;
106
+ return { message, folded };
107
+ }
108
+ ok(`tidied ${folded} shard(s) into ${touched.length} epic ledger(s): ${c.dim(label)}`);
109
+
110
+ if (!opts.push) return { message, folded };
111
+ if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { message, folded }; }
112
+ fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
113
+ hand(`run \`git pull --rebase\` and re-run \`yad tidy up --push\``);
114
+ process.exitCode = 1;
115
+ return { message, folded };
116
+ }
package/cli/usage.mjs CHANGED
@@ -15,6 +15,7 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { c, log, ok, note, readJSON, run } from './lib.mjs';
17
17
  import { PROJECT_FILES, epicFiles } from './manifest.mjs';
18
+ import { readShips } from './ledger.mjs';
18
19
  import { rolesForScope } from './platform.mjs';
19
20
 
20
21
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -105,8 +106,7 @@ function ledgerEvents(root, epic, resolver) {
105
106
  };
106
107
  for (const a of readLedger(f.approvals, []) || []) emit(a.approver, 'approved', a.date, { artifact: a.artifact, role: a.role });
107
108
  for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact, role: cm.role });
108
- const bl = readLedger(f.buildLog, null);
109
- for (const s of bl?.ships || []) {
109
+ for (const s of readShips(path.join(root, 'epics', epic))) {
110
110
  for (const er of s.engineer_review || []) emit(er.approver, 'shipped', s.shippedAt, { story: s.story, task: s.task, repo: s.repo, risk: s.risk });
111
111
  }
112
112
  return events;
@@ -246,8 +246,7 @@ function memberFlags(m) {
246
246
  export function shipHygiene(root, { since, until } = {}) {
247
247
  const items = [];
248
248
  for (const epic of listEpics(root)) {
249
- const bl = readLedger(epicFiles(path.join(root, 'epics', epic)).buildLog, null);
250
- for (const s of bl?.ships || []) {
249
+ for (const s of readShips(path.join(root, 'epics', epic))) {
251
250
  if (!inWindow(s.shippedAt, since, until)) continue;
252
251
  if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
253
252
  items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.6.1",
3
+ "version": "3.7.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",
@@ -16,7 +16,11 @@ then **ship** — merge, record the ship, and update the story state. This is th
16
16
  - `{project-root}` resolves from the project working directory — the **product** repo (the source of
17
17
  truth: it holds the story and the build ledger).
18
18
  - Code repos are separate git repos under `{project-root}/demo-repos/<repo>/`.
19
- - The build ledger is `{project-root}/epics/<epic>/.sdlc/build-log.json` (append-only).
19
+ - The build ledger uses **shard-then-fold** storage: each ship is its own shard file
20
+ `{project-root}/epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json`, so concurrent shippers never
21
+ conflict; readers UNION the folded `build-log.json` with every loose shard, and `yad tidy up` folds
22
+ finished shards back into `build-log.json`. It — like the trust log and build-state — is committed by
23
+ `yad checkpoint` (see Step 3), not by hand.
20
24
  - The engineer-review rule reuses `yad-review-gate`: base = at least one `owner` AND one distinct
21
25
  `reviewer`; **escalated** (the PR's Impact & Risk is `high`, or it touches contract/auth/payments) =
22
26
  base PLUS one `domain-owner` per touched domain — the same routing `yad-pr-template`'s
@@ -59,7 +63,7 @@ and records an approval. Determine the rule from the PR's Impact & Risk block (r
59
63
  domain-owner per touched domain. Record each approval; re-evaluate whether the rule is satisfied.
60
64
  Record `engagement: verified` when the engineer reviewed through the companion (else `none` for a bare
61
65
  approve); `yad review reconcile --epic <id> --repo <r> --pr <n>` stamps it onto the ship record from the
62
- platform. Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
66
+ platform (mutating the ship's shard where it lives, or its folded entry if already tidied). Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
63
67
  `hub.review.requireEngagement: true`. The signal is gameable by design and sits **beside** the CI gates,
64
68
  never above them.
65
69
  Recording an approval does **not** ship — shipping is a separate, explicit step. Front-half discipline:
@@ -69,7 +73,9 @@ the gate talks only through files; refuse to treat AI review as a human approval
69
73
  Ship **iff ALL hold**: the check gates pass (Step C), the AI review has run (advisory), and the
70
74
  engineer-review rule is satisfied (Step 2). Then:
71
75
  - **Merge** the task branch into the repo's default branch (the human performs/authorises the merge).
72
- - **Record the ship** — append to `epics/<epic>/.sdlc/build-log.json`:
76
+ - **Record the ship** — write the ship to its own shard
77
+ `epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json` (readers union the folded `build-log.json` +
78
+ the loose shards, deduping by (story, task, repo) so a shard wins over a stale folded ship):
73
79
  ```json
74
80
  { "story": "<story>", "task": "<task>", "repo": "<repo>", "branch": "feat/<story>-<task>-…",
75
81
  "pr": "<url|#>", "mergeCommit": "<sha>", "gates": ["spec-link","contract-check","build-test-lint"],
@@ -81,11 +87,19 @@ engineer-review rule is satisfied (Step 2). Then:
81
87
  **epic → story → task → PR → mergeCommit** is now traceable end to end.
82
88
  - **Finalize the trust verdict (Phase 4).** If this story has a `build-state/<story>.json` (it ran
83
89
  through `yad-run`), the engineer **confirms or overrides** the provisional trust verdict that the
84
- orchestrator derived for this run, and the final verdict is written to
85
- `epics/<epic>/.sdlc/trust-log.json`. The human has the last word on the trust signal: a diff merged
90
+ orchestrator derived for this run, and the final verdict is written back into that run's trust shard
91
+ `epics/<epic>/.sdlc/trust-log/<story>-<repo>-implement-<uid>.json` (or, if the run was already folded
92
+ by `yad tidy up`, into its entry in the folded `trust-log.json`). The human has the last word on the trust signal: a diff merged
86
93
  as authored is `approved-unchanged`; one the engineer edited before merge is `approved-with-edits`;
87
94
  a rejected one is `rejected`. This is the evidence that later earns a step its `machine_advance`
88
95
  (it never weakens the merge gate — the engineer still owns the merge).
96
+ - **Commit the machine-written ledgers.** Run `yad checkpoint --push` from `{project-root}` to commit
97
+ the back-half ledgers just written (the `build-log/` shard, and the `trust-log/` shard /
98
+ `build-state/<story>.json` if the story ran through `yad-run`) as one `chore(hub): …` audit-trail
99
+ commit — default branch only, staging the shard dirs (`yad tidy up` folds finished shards later),
100
+ never a front-half gate file. It is the back-half analogue of the front-half `yad gate` sync; the
101
+ reviewable state (the story frontmatter `status`, the code-repo `tasks.md`) is committed as usual
102
+ alongside — checkpoint only lands the machine audit ledgers.
89
103
 
90
104
  ### Step 4 — Stop
91
105
  Report what shipped and the story's state. Do not advance anything else; the front-half `state.json`
@@ -15,9 +15,24 @@ ship. Shipping records the merge and updates the story state so the whole chain
15
15
  - **escalated:** when the PR's Impact & Risk is `high`, or it touches contract/auth/payments — base
16
16
  PLUS one `domain-owner` per touched domain (exactly what `risk-route.sh` prints).
17
17
 
18
- ## The build ledger — `epics/<epic>/.sdlc/build-log.json`
18
+ ## The build ledger — shard-then-fold (`epics/<epic>/.sdlc/build-log/` → `build-log.json`)
19
19
 
20
- Append-only. One record per shipped task:
20
+ Append-only, one record per shipped task. **Storage mirrors the trust log's "loose objects + `git gc`"
21
+ model** so two people shipping different tasks of the same epic never collide on one file:
22
+ - **Per-ship shard:** each ship is its own file `epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json`
23
+ = ONE ship object (the record below). `(story, task, repo)` is already unique, so no `uid` is needed;
24
+ concurrent shippers write different files → zero merge conflict.
25
+ - **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ … ] }` — the
26
+ legacy single-file layout, and where `yad tidy up` folds finished shards.
27
+ - **Union-read rule:** to read the ledger, union the folded `ships` with every `build-log/` shard,
28
+ **deduping by `(story, task, repo)`** — a loose shard WINS over a stale folded ship of the same key.
29
+ - `yad review reconcile` stamps the engagement signal by **mutating the ship's shard** where it lives
30
+ (or the folded entry if the story was already tidied) — see below.
31
+ - `yad checkpoint` commits the shard dir; `yad tidy up` (manual, one person) folds a shipped story's
32
+ finished shards into `build-log.json`.
33
+
34
+ The folded file is shown below; a **shard** file is just one element of `ships` (the bare ship object,
35
+ without the `{ epic, ships }` wrapper):
21
36
 
22
37
  ```json
23
38
  {
@@ -45,7 +60,11 @@ Append-only. One record per shipped task:
45
60
  ```
46
61
 
47
62
  This is the back-half analogue of the front half's `approvals.json` — files only, no hidden state, so a
48
- future service can drive ship by writing the same records.
63
+ future service can drive ship by writing the same records. Like the trust log and build-state, it is a
64
+ machine-written ledger committed by **`yad checkpoint`** (the back-half analogue of `yad gate` sync),
65
+ not by hand: after recording the ship, `yad checkpoint --push` lands the new `build-log/` shard as a
66
+ `chore(hub): …` audit-trail commit on the default branch (allowlist-scoped to the back-half ledgers,
67
+ never a front-half gate file); `yad tidy up` folds finished shards into `build-log.json` later.
49
68
 
50
69
  **Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
51
70
  — `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
@@ -53,7 +72,8 @@ future service can drive ship by writing the same records.
53
72
  approve. The optional `companion` block records which faces ran. It is **soft by default** (both count;
54
73
  a bare approve draws a friendly `yad review nudge`); it only gates ship when
55
74
  `hub.review.requireEngagement: true`. `yad review reconcile --epic <id> --repo <r> --pr <n>` reads the
56
- code PR's approvals (with the engagement signal) and stamps them onto the matching ship record — the
75
+ code PR's approvals (with the engagement signal) and stamps them onto the matching ship record — writing
76
+ back into the ship's shard where it lives (or its folded entry if the story was already tidied) — the
57
77
  back-half **bridge**, the analogue of `yad gate sync`. The signal is gameable by design ("visible, not
58
78
  impossible"): it makes engineer-review quality visible, it does not prove a human read the diff. It sits
59
79
  **beside** the CI gates (build/test/lint/contract/verified-commits) — never above them; CI still
@@ -149,6 +149,15 @@ Phase 3 recorded build progress only *after the fact* in `build-log.json`. Phase
149
149
  steps to carry their own `automation` dial so the orchestrator (`yad-run`) can read it and decide
150
150
  whether to advance on its own. Two new files under `.sdlc/` do this.
151
151
 
152
+ > **Who commits these.** `build-state/<story-id>.json`, `trust-log.json`, and `build-log.json` are
153
+ > **machine-written** by the back half (`yad-run`, `yad-engineer-review`) and committed by
154
+ > **`yad checkpoint`** — the back-half analogue of the front-half `yad gate ci` sync. It lands them as
155
+ > one `chore(hub): sync back-half state — <epic>/<story> by @<login>` audit-trail commit, on the
156
+ > default branch, staging **only** these three ledgers by an explicit allowlist (never a front-half
157
+ > gate file — `state/approvals/comments/hub-prs.json`, `reviews/*.md` — so `ledger-guard` never trips).
158
+ > Teammates don't review these machine writes; the commit exists so CI, `yad status`, and other
159
+ > machines always see current trust evidence.
160
+
152
161
  ## `build-state/<story-id>.json`
153
162
  One file per story that has entered the build half. The build half is **per-story, per-repo**, so the
154
163
  steps live under each repo (mirrors the per-repo shape of `build-log.json`).
@@ -189,15 +198,37 @@ story/repo's `currentStep` into the next build sub-step (`spec`/`tasks` → `yad
189
198
  `yad-implement`, `checks` → `yad-checks`, `engineer-review` → `yad-engineer-review`) and prints it with
190
199
  the remaining chain and the step's automation dial — so the build half is guided, not just hinted at.
191
200
 
192
- ## `trust-log.json`
193
- Append-only ledger (an array), the back-half analogue of `approvals.json`. **This is the evidence
194
- base** that decides when a step is safe to automate (build plan Step A). One entry per step run:
201
+ ## `trust-log.json` (shard-then-fold)
202
+ Append-only ledger, the back-half analogue of `approvals.json`. **This is the evidence base** that
203
+ decides when a step is safe to automate (build plan Step A). One entry per step run.
204
+
205
+ **Storage — loose shards + a folded file (the "loose objects + `git gc`" model).** Two people driving
206
+ different stories of the same epic used to both append to one `trust-log.json` → a git merge conflict on
207
+ push. So each writer now writes ONE small shard file per entry under a shard dir, and readers union it
208
+ back:
209
+ - **Shard dir & name:** `epics/<epic>/.sdlc/trust-log/<story>-<repo>-<step>-<uid>.json` — each file is
210
+ ONE trust entry object. `uid` is a short unique token the writer generates fresh per run (never
211
+ reused), so re-runs of the same `(story, repo, step)` stay distinct files → concurrent writers touch
212
+ different files → zero conflict by construction.
213
+ - **Folded file:** `epics/<epic>/.sdlc/trust-log.json` = `{ "epic": "<id>", "runs": [ <entry>, … ] }`
214
+ (also the legacy single-file layout, and the output of `yad tidy up`).
215
+ - **Union-read rule:** to read the ledger, take the folded file's `runs` array PLUS every file in the
216
+ `trust-log/` shard dir, and **concatenate** — every entry is a distinct run and the trust threshold
217
+ counts re-runs, so **never dedup by `(story, repo, step)`**. (The only guard: a shard whose FULL
218
+ identity `(story, repo, step, uid)` already appears in the folded `runs` is a half-applied tidy and is
219
+ skipped — keying on `uid` alone would wrongly drop a different run that happened to reuse a token.) A legacy epic with only
220
+ the folded file and no shard dir still reads correctly — nothing to union.
221
+ - **`yad tidy up`** (manual, one person) folds a SHIPPED story's finished shards into the folded file's
222
+ `runs` and deletes them. Writers never fold — they only add shards; `yad checkpoint` commits the shard
223
+ dir, and `yad tidy up` is the back-half analogue of `git gc` folding loose objects.
224
+ - The **threshold slice** (below) reads this same union, filtered to the step (and repo).
195
225
 
196
226
  ```json
197
227
  {
198
228
  "story": "EP-<slug>-S0N",
199
229
  "repo": "backend",
200
230
  "step": "checks",
231
+ "uid": "<short-unique-token>",
201
232
  "automation": "human_approve",
202
233
  "verdict": "approved-unchanged",
203
234
  "signals": { "checks": "pass", "human_edited_diff": false, "scope_overrun": false, "contract_touch": false },
@@ -210,6 +241,7 @@ base** that decides when a step is safe to automate (build plan Step A). One ent
210
241
  | Field | Values | Meaning |
211
242
  |-------|--------|---------|
212
243
  | `step` | a `back_steps` id | Which step this run is recorded against. |
244
+ | `uid` | short unique token | Generated fresh per run (never reused) — makes each shard file and each re-run distinct; also the folded/loose de-dup guard. Legacy folded entries may lack it. |
213
245
  | `automation` | dial in force at run time | So the log shows whether the run was a manual or an automated advance. |
214
246
  | `verdict` | `approved-unchanged` \| `approved-with-edits` \| `rejected` | The trust signal. **Provisional verdict is derived** (below); the human gate for that step confirms or overrides it and finalizes the entry. |
215
247
  | `signals` | object | The raw inputs the provisional verdict was derived from. The fields present depend on the step (table below). |
@@ -231,10 +263,25 @@ same three-way shape, anchored to each step's human gate, never self-graded):
231
263
  - accepted as produced → `approved-unchanged`.
232
264
 
233
265
  **Trust threshold** (from `config.yaml` `automation.trust_threshold`): a step is a candidate for
234
- `machine_advance` only when its slice of `trust-log.json` (same `step`, this story's repo or the
235
- project) has `>= min_runs` entries AND the fraction with `verdict == "approved-unchanged"` is
266
+ `machine_advance` only when its slice of the trust ledger the **union** of the folded `trust-log.json`
267
+ `runs` plus every `trust-log/` shard, filtered to the same `step` (this story's repo or the project) —
268
+ has `>= min_runs` entries AND the fraction with `verdict == "approved-unchanged"` is
236
269
  `>= min_approved_unchanged`. The dial-setter in `yad-run` enforces this; `yad-status` surfaces it.
237
270
 
271
+ ## `build-log.json` (shard-then-fold)
272
+ The build ledger records one ship per merged task. Its schema and the ship record's fields are
273
+ documented authoritatively in `../../yad-engineer-review/references/ship-and-record.md`; only its
274
+ storage layout is noted here (it mirrors `trust-log.json`):
275
+ - **Shard dir & name:** `epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json` — each file is ONE
276
+ ship object. `(story, task, repo)` is already a natural unique key, so no `uid` is needed.
277
+ - **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ <ship>, … ] }`
278
+ (also the legacy single-file layout, and the output of `yad tidy up`).
279
+ - **Union-read rule:** union the folded `ships` with every `build-log/` shard, **deduping by
280
+ `(story, task, repo)`** — a shard WINS over a stale folded ship (so a `yad review reconcile` edit to a
281
+ ship's shard is authoritative until it is folded).
282
+ - `yad checkpoint` commits the shard dir; `yad tidy up` folds a shipped story's finished shards into the
283
+ folded file (loose objects + `git gc`).
284
+
238
285
  ---
239
286
 
240
287
  # Phase 6 — feature threads (post-lock change management)
@@ -121,8 +121,9 @@ finalize a `tasks` trust entry, anchored to what the human/dev actually did with
121
121
  - the task is **re-scoped** first (its `Files:`/boundary edited) → `approved-with-edits`
122
122
  (signal `task_rescoped: true`);
123
123
  - the task list is discarded / regenerated → `rejected`.
124
- Append the entry to `epics/<epic>/.sdlc/trust-log.json` (schema:
125
- `../yad-epic/references/state-schema.md`). `tasks` stays `human_approve` until its slice clears
124
+ Write the entry to its own shard `epics/<epic>/.sdlc/trust-log/<story>-<repo>-tasks-<uid>.json` (a fresh
125
+ `uid` per run, so concurrent writers never conflict; readers union the folded `trust-log.json` + the
126
+ loose shards). Schema: `../yad-epic/references/state-schema.md`. `tasks` stays `human_approve` until its slice clears
126
127
  the threshold — this only *gathers* evidence. (The `implement` step's own verdict is finalized later,
127
128
  at the engineer review in `yad-engineer-review`: merged as authored → `approved-unchanged`; edited first →
128
129
  `approved-with-edits`; scope/contract/checks halt → `rejected`.)
@@ -31,8 +31,15 @@ signal to seed them from, so they are earned only on real runs.
31
31
  - Automation config is `skills/sdlc/config.yaml` → `automation:` (`back_steps`, `default`,
32
32
  `trust_threshold`, `locked_steps`, `kill_switch`).
33
33
  - Per-story build-half state: `epics/<epic>/.sdlc/build-state/<story-id>.json` (per repo).
34
- - Trust ledger: `epics/<epic>/.sdlc/trust-log.json` (append-only). Schemas:
34
+ - Trust ledger: **shard-then-fold** — each run is its own shard file
35
+ `epics/<epic>/.sdlc/trust-log/<story>-<repo>-<step>-<uid>.json` (a fresh `uid` per run, so concurrent
36
+ writers never conflict); readers UNION the folded `trust-log.json` with every loose shard, and
37
+ `yad tidy up` folds finished shards back into `trust-log.json`. Schemas:
35
38
  `../yad-epic/references/state-schema.md`.
39
+ - These machine-written back-half files (`build-state/<story>.json`, the `trust-log/` shards) are
40
+ committed by **`yad checkpoint`** — the back-half analogue of the front-half `yad gate` sync; the loop
41
+ calls it each iteration so the state is durable and shared without a human commit. (`yad checkpoint`
42
+ stages the shard dirs; `yad tidy up` later folds finished shards — loose objects + `git gc`.)
36
43
  - The orchestrator **calls the existing step skills unchanged** — `yad-spec` (A), `yad-implement`
37
44
  (B), `yad-checks` (C). It owns only the *advance decision*, never what a step does.
38
45
  - To see (read-only, without driving the loop) the next build sub-step per story/repo, use
@@ -59,9 +66,10 @@ Walk the steps for `repo` starting at `from`/`currentStep`. For each step:
59
66
 
60
67
  1. **Run the step's skill** — `spec`→`yad-spec`, `tasks`→ the tasks leg of `yad-spec`,
61
68
  `implement`→`yad-implement`, `checks`→`yad-checks (action: run)`. Capture its result.
62
- 2. **Derive trust signals & append a trust-log entry** (`ranBy: machine` if this advance was
63
- automated, else `human`) see `references/run-loop.md` for the derivation. Do this for *every*
64
- step run, pass or fail; the log is the evidence base.
69
+ 2. **Derive trust signals & write a trust-log shard** write the entry to its own file
70
+ `trust-log/<story>-<repo>-<step>-<uid>.json` (a fresh `uid` per run; never append to a shared file),
71
+ with `ranBy: machine` if this advance was automated, else `human` — see `references/run-loop.md` for
72
+ the derivation. Do this for *every* step run, pass or fail; the log is the evidence base.
65
73
  3. **Compute the effective dial.** Start from the step's `automation` in build-state, then **force it
66
74
  to `human_approve`** if `automation.kill_switch` is true OR the step is `locked` OR the step id is
67
75
  in `automation.locked_steps`. (So a kill switch or a lock always wins.)
@@ -76,12 +84,21 @@ Walk the steps for `repo` starting at `from`/`currentStep`. For each step:
76
84
  5. **Always stop at `engineer-review`** (it is `locked`): hand off to `yad-engineer-review` for the human merge
77
85
  gate, which finalizes the trust verdict (confirm/override the provisional one).
78
86
 
87
+ **Commit the machine-written state.** After each iteration's writes (the trust-log shard in 2 and the
88
+ build-state change in 4), run `yad checkpoint --push` from `{project-root}`. It commits *only* the
89
+ `trust-log/` shards + `build-state/<story>.json` (never a front-half gate file) as one `chore(hub): …`
90
+ audit-trail commit, and only ever on the default branch. It is a safe no-op when nothing changed, so
91
+ call it every iteration — teammates don't review these machine writes, but CI and `yad status` on
92
+ other machines must see current trust evidence. Never run it off the default branch (it will refuse):
93
+ an unpushed or branch-stranded trust log quietly undermines the "earned automation" premise.
94
+
79
95
  ### `action: set-dial` — earn (or revert) a step's automation
80
96
  Flip `step`'s `automation` to `to` in build-state. Enforce, in order:
81
97
  - **Refuse** if `step` is in `automation.locked_steps` or is a front state or `engineer-review` —
82
98
  these can never be `machine_advance` (front-state lock, build plan §E). Report the refusal reason.
83
- - For `to: machine_advance`, **refuse unless the trust threshold is met**: the step's slice of
84
- `trust-log.json` has `>= trust_threshold.min_runs` entries AND the fraction with
99
+ - For `to: machine_advance`, **refuse unless the trust threshold is met**: the step's slice of the trust
100
+ ledger — the **union** of the folded `trust-log.json` `runs` PLUS every `trust-log/` shard, filtered to
101
+ this step (and repo) — has `>= trust_threshold.min_runs` entries AND the fraction with
85
102
  `verdict == "approved-unchanged"` is `>= trust_threshold.min_approved_unchanged`. If it is not met,
86
103
  report the current evidence (runs, % unchanged) and how far short it is — "it seems fine" is not
87
104
  evidence.
@@ -28,17 +28,19 @@ while step is a back step (not engineer-review):
28
28
 
29
29
  signals = derive_signals(step, result) # see "Deriving signals"
30
30
  verdict = derive_verdict(signals) # rejected | approved-with-edits | approved-unchanged
31
- append trust-log entry { story, repo, step, automation: bs.step.automation,
32
- verdict, signals, ranBy, date }
31
+ uid = fresh_short_token() # e.g. `openssl rand -hex 4` — unique per run,
32
+ # never reuse; a collision would fuse two runs
33
+ write trust-log/<story>-<repo>-<step>-<uid>.json # ONE shard file = ONE entry (never append to a shared file)
34
+ = { story, repo, step, uid, automation: bs.step.automation, verdict, signals, ranBy, date }
33
35
 
34
36
  eff = effective_dial(step, bs, cfg) # see "Effective dial"
35
37
 
36
38
  if result is a HALT (failed / scope overrun / contract touch / ambiguous):
37
- bs.step.status = "blocked"; persist; STOP and report the human action needed
39
+ bs.step.status = "blocked"; persist; checkpoint; STOP and report the human action needed
38
40
  elif eff == "machine_advance":
39
- bs.step.status = "done"; advance bs.currentStep to next; persist; continue # Step B advance
41
+ bs.step.status = "done"; advance bs.currentStep to next; persist; checkpoint; continue # Step B advance
40
42
  else: # human_approve
41
- bs.step.status = "done"; persist; STOP and report "waiting for human at <next>"
43
+ bs.step.status = "done"; persist; checkpoint; STOP and report "waiting for human at <next>"
42
44
 
43
45
  # reached engineer-review: always stop, hand to yad-engineer-review (human gate, finalizes the verdict)
44
46
  ```
@@ -47,6 +49,18 @@ while step is a back step (not engineer-review):
47
49
  nudge; otherwise `human`. Persist build-state after every transition so a halt leaves an accurate,
48
50
  resumable record.
49
51
 
52
+ `checkpoint` = run `yad checkpoint --push` from `{project-root}`. It commits the machine-written
53
+ back-half ledgers (in this loop, the loose `trust-log/` shards this run wrote + `build-state/<story>.json`;
54
+ also the `build-log/` shards at engineer-review) — the shard dirs are what checkpoint stages, the same
55
+ way `git gc` folds loose objects later (`yad tidy up` folds finished shards into the folded
56
+ `trust-log.json` / `build-log.json`) — as one `chore(hub): sync back-half state — <epic>/<story> by @<login> [skip ci]`
57
+ audit-trail commit, on the default branch only,
58
+ staging *only* those files by an explicit allowlist (never a front-half gate file — so `ledger-guard`
59
+ never trips). It is idempotent (a no-op when nothing changed), so calling it after every transition —
60
+ including a halt — is safe and keeps the shared trust evidence current for CI, teammates, and
61
+ `yad status` on other machines. It refuses to run off the default branch (an unsigned `[skip ci]`
62
+ commit inside a future PR range would fail `verified-commits` and strand the PR).
63
+
50
64
  ## Effective dial (kill switch & locks always win)
51
65
 
52
66
  ```
@@ -100,7 +114,10 @@ A step is a **candidate** for `machine_advance` only when its trust evidence cle
100
114
  to `machine_advance`:
101
115
 
102
116
  ```
103
- slice = trust-log entries for this step (this story's repo; widen to the project if you track it there)
117
+ # read the ledger by UNION: the folded trust-log.json `runs` array PLUS every loose trust-log/ shard
118
+ # (concatenate — every shard is a distinct run; never dedup by story/repo/step, the threshold counts re-runs)
119
+ all = trust-log.json.runs + [read each file in trust-log/]
120
+ slice = entries in `all` for this step (this story's repo; widen to the project if you track it there)
104
121
  runs = len(slice)
105
122
  unchanged = count(e.verdict == "approved-unchanged" in slice)
106
123
  earned = runs >= trust_threshold.min_runs
@@ -101,9 +101,10 @@ verdict is **anchored to the human who accepts the spec**, never self-graded:
101
101
  `human_edited_spec: true`);
102
102
  - the spec is rejected or the ceremony re-run → `rejected`.
103
103
  `yad-run` records a provisional entry when the spec is generated; this acceptance finalizes it (same
104
- pattern as the engineer review finalizing `implement` at `yad-engineer-review`). Append the finalized entry to
105
- `epics/<epic>/.sdlc/trust-log.json` (schema:
106
- `../yad-epic/references/state-schema.md`). **Run standalone, no trust entry is written** the
104
+ pattern as the engineer review finalizing `implement` at `yad-engineer-review`). Write/finalize the
105
+ entry in its own shard `epics/<epic>/.sdlc/trust-log/<story>-<repo>-spec-<uid>.json` (a fresh `uid` per
106
+ run; readers union the folded `trust-log.json` + the loose shards). Schema:
107
+ `../yad-epic/references/state-schema.md`. **Run standalone, no trust entry is written** — the
107
108
  log measures orchestrated runs. `spec` stays `human_approve` until its slice clears the threshold;
108
109
  this step only *gathers* the evidence, it does not flip the dial.
109
110
 
@@ -21,7 +21,12 @@ report all if the user asked for an overview).
21
21
  ### Step 2 — Read state
22
22
  Read `.sdlc/state.json`, `.sdlc/approvals.json`, `epic.md` frontmatter (for `repos`), and — if present
23
23
  — `.sdlc/contract-lock.json`. For the build half (Phase 4), also read — if present — every
24
- `.sdlc/build-state/<story-id>.json`, `.sdlc/trust-log.json`, and the `automation` block of
24
+ `.sdlc/build-state/<story-id>.json`, and the trust ledger read as the **union** of the folded
25
+ `.sdlc/trust-log.json` `runs` PLUS every loose `.sdlc/trust-log/` shard (concatenate — every shard is a
26
+ distinct run; never dedup by story/repo/step — but DO skip a shard whose full identity
27
+ `(story,repo,step,uid)` already appears in the folded `runs`, i.e. a half-applied `yad tidy up`). All are committed by `yad checkpoint` (so a fresh clone
28
+ or another machine sees current evidence; `yad tidy up` folds finished shards into `trust-log.json`).
29
+ Also read the `automation` block of
25
30
  `skills/sdlc/config.yaml` (`back_steps`, `trust_threshold`, `locked_steps`, `kill_switch`). For the
26
31
  cross-cutting learning layer, also read — if present — the **local-only** `.sdlc/learning-records.json`
27
32
  (the per-epic learning ledger, gitignored) and the project-wide `{project-root}/.sdlc/learning-records.json`.
@@ -72,8 +77,9 @@ Print, in this order:
72
77
  reads the same `build-state` files.)
73
78
  8. **Automation & trust** — print the system-wide **kill switch** state from `config.yaml`
74
79
  `automation.kill_switch` (when `on`, note that every step is forced to `human_approve`). Then, for
75
- each back-half step that has entries in `.sdlc/trust-log.json`, print its **trust record**: number
76
- of runs, the fraction with `verdict == "approved-unchanged"`, and whether that clears
80
+ each back-half step that has entries in the trust ledger the **union** of the folded
81
+ `.sdlc/trust-log.json` `runs` plus every loose `.sdlc/trust-log/` shard print its **trust record**:
82
+ number of runs, the fraction with `verdict == "approved-unchanged"`, and whether that clears
77
83
  `automation.trust_threshold` (`min_runs`, `min_approved_unchanged`) — i.e. whether the step is
78
84
  **earned** (eligible to be flipped to `machine_advance`) or still **gathering evidence**. Restate
79
85
  the predicate (self-contained): `earned = runs >= min_runs AND unchanged/runs >= min_approved_unchanged`.