yadflow 3.16.0 → 3.16.1

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,12 @@
1
+ ## [3.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.0...v3.16.1) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checkpoint:** guard --retro-ship per repo so a multi-repo story can be fully recorded ([d6d2fae](https://github.com/abdelrahmannasr/yadflow/commit/d6d2faeea91c0d59a00a532c0605f76c5b77eace)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
7
+ * **checkpoint:** validate the retro-ship repo instead of relying on the duplicate guard ([f1e085e](https://github.com/abdelrahmannasr/yadflow/commit/f1e085e89e4eb10fbf853647ab10c1912237daf8)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166) [#166](https://github.com/abdelrahmannasr/yadflow/issues/166) [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
8
+ * **ledger:** hold an exclusive lock across a ledger read-modify-write ([45b849a](https://github.com/abdelrahmannasr/yadflow/commit/45b849a7755a5bc58bc646e627218170a54b31bc)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
9
+
1
10
  # [3.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.5...v3.16.0) (2026-08-12)
2
11
 
3
12
 
package/bin/yad.mjs CHANGED
@@ -115,7 +115,8 @@ ${c.bold('Build helpers')}
115
115
  yad checkpoint --retro-ship <epic>/<story> --repo <r>
116
116
  Record a retroactive build-log ship for a PRE-TRACKING story
117
117
  (merged before ledger tracking), then carry its status: shipped
118
- flip in the same commit (--merge-commit <sha>, --task <t> opt.)
118
+ flip in the same commit (--merge-commit <sha>, --task <t> opt.);
119
+ one repo per run — re-run per --repo for a multi-repo story
119
120
  yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
120
121
  trust-log/build-log entries) back into the single folded
121
122
  ledger, as one chore(hub) commit — the manual "pack it up"
@@ -22,7 +22,7 @@
22
22
  // marker would strand the PR's required checks.
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
- import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
25
+ import { c, log, ok, info, fail, hand, exists, readJSON, pushWithRebase } from './lib.mjs';
26
26
  import { PROJECT_FILES } from './manifest.mjs';
27
27
  import { loadHub } from './gate.mjs';
28
28
  import { resolveCommitterLogin } from './platform.mjs';
@@ -171,6 +171,26 @@ export function stagedStoryIsStatusOnly(git, file) {
171
171
  // (with a printed reason) aborts the commit; `file` is the shard just written, so a dry run can delete it
172
172
  // and leave no side effect. Does NOT author the story frontmatter — it only supplies the missing
173
173
  // evidence, and the human must have ALREADY flipped `status:` to a back-half value in the working tree.
174
+ //
175
+ // ONE repo per run (#166). A story that shipped in several repos is backfilled by re-running with each
176
+ // `--repo`; the second run finds the flip already committed, so it lands only the new ship shard.
177
+ //
178
+ // The repo names a retro ship MAY carry — the story's own `repos:` frontmatter (its statement of where
179
+ // it was implemented), else the hub's connected-repo registry as the project-wide fallback. Used to
180
+ // reject a typo'd/mis-cased/invented `--repo` (#166 review): once the duplicate guard is per repo, a
181
+ // wrong name no longer collides with anything, so nothing else would stop it from committing a
182
+ // `retroactive: true` ship for a repo that never existed. Returns `{ names: [], source: 'none' }` when
183
+ // neither declares anything — a legacy story with no metadata is still backfillable, never blocked on
184
+ // a missing list.
185
+ export function retroShipRepos(root, storyFile) {
186
+ const declared = readFrontmatter(storyFile).repos;
187
+ const story = (Array.isArray(declared) ? declared : declared ? [declared] : []).map(String).filter(Boolean);
188
+ if (story.length) return { names: story, source: 'story' };
189
+ const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
190
+ const names = (Array.isArray(reg?.repos) ? reg.repos : []).map((r) => r?.name).filter(Boolean);
191
+ return { names, source: names.length ? 'registry' : 'none' };
192
+ }
193
+
174
194
  export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, today }) {
175
195
  if (!epic || !story) { fail('--retro-ship needs <epic>/<story> (e.g. --retro-ship EP-foo/EP-foo-S01)'); return { ok: false }; }
176
196
  if (!repo) { fail('--retro-ship needs --repo <name> (the repo the story shipped in)'); return { ok: false }; }
@@ -189,14 +209,51 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
189
209
  return { ok: false };
190
210
  }
191
211
 
212
+ // A ship is permanent audit evidence, so the repo it names must be one the story could have shipped
213
+ // in — an unrecognized `--repo` is a typo, not a discovery. Skipped only when nothing declares any
214
+ // repo (`source: 'none'`), so a legacy story with no metadata is never blocked.
215
+ const { names, source } = retroShipRepos(root, storyFile);
216
+ if (names.length && !names.includes(repo)) {
217
+ fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this hub'} — a retroactive ship must name a real repo, never invent one`);
218
+ hand(`known: ${names.join(', ')} (names are case-sensitive)`);
219
+ return { ok: false };
220
+ }
221
+ // Which of the story's OWN declared repos still lack evidence — read BEFORE the write so both the
222
+ // refusal and the success path can report honestly how much of a multi-repo backfill is left. Only
223
+ // the story's own list is used: the hub registry lists every connected repo, which says nothing
224
+ // about where THIS story shipped.
225
+ const remaining = () => {
226
+ if (source !== 'story') return [];
227
+ let recorded;
228
+ try { recorded = new Set(readShips(epicDir).filter((s) => s.story === story).map((s) => s.repo)); }
229
+ catch { return []; } // corrupt build-log — the write below reports it; don't guess at progress
230
+ return names.filter((n) => !recorded.has(n) && n !== repo);
231
+ };
232
+ const left = remaining();
233
+
192
234
  let res;
193
235
  try { res = writeRetroShip(epicDir, { story, repo, task, mergeCommit, shippedAt: today }); }
194
236
  catch (e) { fail(`could not record retroactive ship — ${e.message}`); return { ok: false }; }
195
237
  if (!res.written) {
196
- fail(`${story} already has a build-log ship — it is not pre-tracking; use the normal ship/checkpoint flow`);
238
+ if (res.reason === 'collision') {
239
+ // Distinct names, ONE shard file (`buildShardName` sanitizes each component) — recording this one
240
+ // would overwrite the other repo's ship record, so it is refused rather than silently clobbered.
241
+ fail(`${repo} cannot be recorded: it shares a build-log shard name with ${res.repo ? `the already-recorded ${res.repo}` : `an existing shard (${path.basename(res.file)})`}`);
242
+ hand('recording it would overwrite that ship record — rename one of the repos in the registry, or record this ship through the normal ship/checkpoint flow');
243
+ return { ok: false };
244
+ }
245
+ // Per REPO, not per story (#166) — so the message names the repo. The follow-up hint names the
246
+ // story's OWN still-unrecorded repos; with none left there is nothing to re-run, and inventing a
247
+ // `--repo <other>` at that point would fabricate a ship for a repo the story never shipped in.
248
+ fail(`${story} already has a build-log ship in ${repo} — it is not pre-tracking there; use the normal ship/checkpoint flow`);
249
+ if (left.length) hand(`still unrecorded for ${story}: ${left.join(', ')} — re-run with \`--repo <name>\` for each`);
197
250
  return { ok: false };
198
251
  }
199
252
  ok(`recorded retroactive ship for ${story} (${repo})${mergeCommit ? ` @ ${mergeCommit}` : ''}`);
253
+ // A multi-repo story is only half-reconciled until every declared repo has evidence, and nothing
254
+ // downstream reports the gap (the story already reads `shipped`) — so say it here, while the
255
+ // operator is running the backfill.
256
+ if (left.length) hand(`${story} declares ${names.length} repos — still unrecorded: ${left.join(', ')}; re-run with \`--repo <name>\` for each`);
200
257
  return { ok: true, file: res.file };
201
258
  }
202
259
 
package/cli/errors.mjs CHANGED
@@ -21,6 +21,7 @@ export const CODES = {
21
21
  'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
22
22
  'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
23
23
  'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
24
+ 'YAD-STATE-006': 'a back-half ledger is locked by another yad process that is writing it',
24
25
  'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
25
26
  'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
26
27
  'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
package/cli/ledger.mjs CHANGED
@@ -9,10 +9,69 @@
9
9
  //
10
10
  // A legacy epic that only has the folded file still reads correctly (no shards to union) → zero
11
11
  // migration; new writes simply go to shards.
12
+ //
13
+ // Shards remove the conflict BETWEEN entries; they do not make a single read-modify-write atomic, so
14
+ // every writer that decides something from the ledger before writing takes an exclusive lock first
15
+ // (see "ledger locking" below).
12
16
  import fs from 'node:fs';
13
17
  import path from 'node:path';
14
18
  import { readJSON, readJSONStrict, writeJSON } from './lib.mjs';
15
19
  import { epicFiles } from './manifest.mjs';
20
+ import { err } from './errors.mjs';
21
+
22
+ // ---- ledger locking -------------------------------------------------------------------------------
23
+ // Shards make concurrent writers conflict-free ACROSS entries, but a writer that READS THEN WRITES is
24
+ // only safe if nothing slips in between. `writeRetroShip` decides "no ship for this (story, repo) yet"
25
+ // and then writes; `updateShip` finds a ship and then rewrites it; a fold reads shards, merges them,
26
+ // then deletes them. Two `--retro-ship` runs with different `--task` values both read "no ship", both
27
+ // write, and the duplicate guard is defeated — two shards for one (story, repo). So every
28
+ // read-modify-write on a ledger holds an exclusive lock for its whole span.
29
+ //
30
+ // `fs.mkdirSync` is the primitive: directory creation is atomic on POSIX and Windows, so exactly one
31
+ // caller wins and the rest get EEXIST. It needs no fd bookkeeping, releases with one `rmdir`, and an
32
+ // EMPTY DIRECTORY IS INVISIBLE TO GIT — a lock can never be committed, and `readShardDir` only reads
33
+ // `*.json` files, so it is never mistaken for an entry either.
34
+ //
35
+ // The lock is per LEDGER, not per (story, repo). A per-identity lock keyed on the raw identity would
36
+ // let the one pair that most needs serializing race: `api.v2` and `api_v2` are DIFFERENT identities
37
+ // that share ONE shard filename (`safe()` maps both to `api_v2`), so they would take different locks
38
+ // and then collide on the same file. A ledger-wide lock cannot be wrong that way, and what it
39
+ // serializes is a handful of local file operations.
40
+ const LOCK_STALE_MS = 30_000; // a lock older than this belonged to a process that died holding it
41
+ const LOCK_WAIT_MS = 50;
42
+ const LOCK_RETRIES = 100; // ≈5s — long enough for any real ledger write, short enough to report
43
+
44
+ // Block the thread without spinning. `Atomics.wait` on the main thread is allowed in Node (only
45
+ // browsers forbid it), and these ledger writers are synchronous by design.
46
+ const sleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
47
+
48
+ // Run `fn` while holding `lockPath` exclusively; always releases, even when `fn` throws. Throws
49
+ // YAD-STATE-006 when the lock is held by a live process for the whole retry window — better to report
50
+ // than to write over another writer's work.
51
+ export function withLedgerLock(lockPath, fn, { retries = LOCK_RETRIES, waitMs = LOCK_WAIT_MS } = {}) {
52
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
53
+ for (let i = 0; ; i++) {
54
+ try {
55
+ fs.mkdirSync(lockPath);
56
+ break;
57
+ } catch (e) {
58
+ if (e.code !== 'EEXIST') throw e;
59
+ // A holder that died leaves its lock behind forever; reclaim one that is provably too old.
60
+ let age;
61
+ try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch { continue; } // vanished → retry
62
+ if (age > LOCK_STALE_MS) { try { fs.rmdirSync(lockPath); } catch { /* someone else won */ } continue; }
63
+ if (i >= retries) {
64
+ throw err('YAD-STATE-006', `another process is writing ${path.basename(lockPath, '.lock')} in ${path.basename(path.dirname(path.dirname(lockPath)))}`,
65
+ 'wait for the other yad command to finish and re-run; if nothing else is running, delete the stale .lock directory the message names');
66
+ }
67
+ sleep(waitMs);
68
+ }
69
+ }
70
+ try { return fn(); } finally { try { fs.rmdirSync(lockPath); } catch { /* already reclaimed */ } }
71
+ }
72
+
73
+ const buildLogLock = (epicDir) => `${epicFiles(epicDir).buildLog}.lock`;
74
+ const trustLogLock = (epicDir) => `${epicFiles(epicDir).trustLog}.lock`;
16
75
 
17
76
  // ---- shard filenames — the ONE source of truth for the naming convention -------------------------
18
77
  // story ids already contain hyphens; the filename is just a unique handle (the entry inside carries
@@ -86,46 +145,68 @@ export function readShips(epicDir) {
86
145
  // ship: `task` defaults to the sentinel `retro`, `mergeCommit` is written only when the caller supplies
87
146
  // it (never invented), and `shippedAt` is the backfill date (the `retroactive` flag marks it as such).
88
147
  //
89
- // Guard: refuse when the story ALREADY has ANY build-log ship — then it isn't pre-tracking and the
90
- // normal ship/checkpoint flow applies; a retro record would only muddy the ledger. Returns
91
- // { written: false, reason } in that case, else { written: true, file, ship }.
92
- export function writeRetroShip(epicDir, { story, repo, task = 'retro', mergeCommit, shippedAt }) {
148
+ // Guard: refuse when the story already has a build-log ship IN THIS REPO — then it isn't pre-tracking
149
+ // there and the normal ship/checkpoint flow applies; a retro record would only muddy the ledger. The
150
+ // key is (story, repo), NOT story alone (#166): a story tagged with several repos ships once per repo,
151
+ // so a story-only guard let the FIRST backfill lock out every other repo and a multi-repo pre-tracking
152
+ // story could never be fully recorded. Each repo is recorded by its own run — the shards are distinct
153
+ // by construction (`buildShardName` keys on story+task+repo). Not keyed on `task` too: that would let
154
+ // repeated `--task T0x` pile several retro shards into one repo, the very muddying this guard prevents.
155
+ //
156
+ // Two names can differ yet share ONE shard file: `buildShardName` sanitizes each component through
157
+ // `safe()`, so `api.v2` and `api_v2` both become `api_v2` while the (story, repo) key sees them as
158
+ // distinct. Writing the second would silently overwrite the first repo's ship — destroying evidence in
159
+ // an append-only ledger, and (via the --dry-run cleanup) deleting an already-committed shard. So a
160
+ // name that COLLIDES with an existing ship's shard name is refused as `reason: 'collision'`, and the
161
+ // target file is never overwritten even when `readShips` cannot see it (a corrupt shard is skipped by
162
+ // `readShardDir`). Returns { written: false, reason } when refused, else { written: true, file, ship }.
163
+ export function writeRetroShip(epicDir, { story, repo, task = 'retro', mergeCommit, shippedAt }, lockOpts = {}) {
93
164
  if (!story) throw new Error('writeRetroShip: story is required');
94
165
  if (!repo) throw new Error('writeRetroShip: repo is required');
95
- if (readShips(epicDir).some((s) => s.story === story)) {
96
- return { written: false, reason: 'exists' };
97
- }
98
- const t = task || 'retro';
99
- const ship = { story, task: t, repo, retroactive: true, note: 'pre-tracking backfill' };
100
- if (mergeCommit) ship.mergeCommit = mergeCommit;
101
- if (shippedAt) ship.shippedAt = shippedAt;
102
- const f = epicFiles(epicDir);
103
- const file = path.join(f.buildLogDir, buildShardName({ story, task: t, repo }));
104
- writeJSON(file, ship);
105
- return { written: true, file, ship };
166
+ // Both guards and the write are ONE critical section: read outside the lock and a concurrent run
167
+ // (notably a different `--task` for the same (story, repo)) can write between the check and ours.
168
+ return withLedgerLock(buildLogLock(epicDir), () => {
169
+ const ships = readShips(epicDir).filter((s) => s.story === story);
170
+ if (ships.some((s) => s.repo === repo)) return { written: false, reason: 'exists' };
171
+ const clash = ships.find((s) => safe(s.repo) === safe(repo));
172
+ if (clash) return { written: false, reason: 'collision', repo: clash.repo };
173
+ const t = task || 'retro';
174
+ const ship = { story, task: t, repo, retroactive: true, note: 'pre-tracking backfill' };
175
+ if (mergeCommit) ship.mergeCommit = mergeCommit;
176
+ if (shippedAt) ship.shippedAt = shippedAt;
177
+ const f = epicFiles(epicDir);
178
+ const file = path.join(f.buildLogDir, buildShardName({ story, task: t, repo }));
179
+ if (fs.existsSync(file)) return { written: false, reason: 'collision', file };
180
+ writeJSON(file, ship);
181
+ return { written: true, file, ship };
182
+ }, lockOpts);
106
183
  }
107
184
 
108
185
  // Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
109
186
  // folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
110
- // { found, where, file, ship }; found:false writes nothing (the caller warns).
111
- export function updateShip(epicDir, match, update) {
112
- const f = epicFiles(epicDir);
113
- for (const { name, obj } of readShardDir(f.buildLogDir)) {
114
- if (match(obj)) {
115
- update(obj);
116
- const file = path.join(f.buildLogDir, name);
117
- writeJSON(file, obj);
118
- return { found: true, where: 'shard', file, ship: obj };
187
+ // { found, where, file, ship }; found:false writes nothing (the caller warns). The find and the
188
+ // write-back are one locked span: a fold running between them would move the ship into the folded
189
+ // file and delete the shard this call is about to rewrite, resurrecting the deleted shard.
190
+ export function updateShip(epicDir, match, update, lockOpts = {}) {
191
+ return withLedgerLock(buildLogLock(epicDir), () => {
192
+ const f = epicFiles(epicDir);
193
+ for (const { name, obj } of readShardDir(f.buildLogDir)) {
194
+ if (match(obj)) {
195
+ update(obj);
196
+ const file = path.join(f.buildLogDir, name);
197
+ writeJSON(file, obj);
198
+ return { found: true, where: 'shard', file, ship: obj };
199
+ }
119
200
  }
120
- }
121
- const foldedObj = readJSONStrict(f.buildLog, null);
122
- const ship = Array.isArray(foldedObj?.ships) ? foldedObj.ships.find(match) : null;
123
- if (ship) {
124
- update(ship);
125
- writeJSON(f.buildLog, foldedObj);
126
- return { found: true, where: 'folded', file: f.buildLog, ship };
127
- }
128
- return { found: false };
201
+ const foldedObj = readJSONStrict(f.buildLog, null);
202
+ const ship = Array.isArray(foldedObj?.ships) ? foldedObj.ships.find(match) : null;
203
+ if (ship) {
204
+ update(ship);
205
+ writeJSON(f.buildLog, foldedObj);
206
+ return { found: true, where: 'folded', file: f.buildLog, ship };
207
+ }
208
+ return { found: false };
209
+ }, lockOpts);
129
210
  }
130
211
 
131
212
  // ---- folding (used by `yad tidy up`) -------------------------------------------------------------
@@ -170,11 +251,16 @@ function fold(epicDir, { foldedPath, dir, arr, isTrust }, pick, { dryRun = false
170
251
  return { folded: toFold.length, remaining: shards.length - toFold.length, deleted };
171
252
  }
172
253
 
254
+ // Both folds are read-fold-delete, so they hold their ledger's lock for the whole span — otherwise a
255
+ // ship written (or stamped by `updateShip`) after the read but before the delete is folded away
256
+ // without its change, or deleted without ever being folded.
173
257
  export function foldTrust(epicDir, pick, opts = {}) {
174
258
  const f = epicFiles(epicDir);
175
- return fold(epicDir, { foldedPath: f.trustLog, dir: f.trustLogDir, arr: 'runs', isTrust: true }, pick, opts);
259
+ return withLedgerLock(trustLogLock(epicDir), () =>
260
+ fold(epicDir, { foldedPath: f.trustLog, dir: f.trustLogDir, arr: 'runs', isTrust: true }, pick, opts), opts);
176
261
  }
177
262
  export function foldBuild(epicDir, pick, opts = {}) {
178
263
  const f = epicFiles(epicDir);
179
- return fold(epicDir, { foldedPath: f.buildLog, dir: f.buildLogDir, arr: 'ships', isTrust: false }, pick, opts);
264
+ return withLedgerLock(buildLogLock(epicDir), () =>
265
+ fold(epicDir, { foldedPath: f.buildLog, dir: f.buildLogDir, arr: 'ships', isTrust: false }, pick, opts), opts);
180
266
  }
package/cli/usage.mjs CHANGED
@@ -243,11 +243,17 @@ function memberFlags(m) {
243
243
 
244
244
  // Team-level hygiene, keyed by epic/story — a ship with no recorded engineer review is a process gap,
245
245
  // not attributable to one person, so it lives here rather than in a member's flag list.
246
+ //
247
+ // A `retroactive: true` ship is EXCLUDED: it is a `yad checkpoint --retro-ship` reconciliation of a
248
+ // story that shipped before the ledger existed, so it never had a tracked PR to review — counting it
249
+ // as a missing review reports a gap the team could not have filled. It also scales with repo count
250
+ // (one backfill shard per repo since #166), which would swamp the real gaps with reconciliation noise.
246
251
  export function shipHygiene(root, { since, until } = {}) {
247
252
  const items = [];
248
253
  for (const epic of listEpics(root)) {
249
254
  for (const s of readShips(path.join(root, 'epics', epic))) {
250
255
  if (!inWindow(s.shippedAt, since, until)) continue;
256
+ if (s.retroactive) continue;
251
257
  if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
252
258
  items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
253
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.16.0",
3
+ "version": "3.16.1",
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",
@@ -81,11 +81,39 @@ yad checkpoint --retro-ship <epic>/<story> --repo <r> [--task <t>] [--merge-comm
81
81
  It writes ONE minimal ship shard marked `retroactive: true` (`task` defaults to the sentinel `retro`;
82
82
  `mergeCommit` is written only if you pass `--merge-commit`; `shippedAt` is the backfill date), then runs
83
83
  the normal checkpoint so the story's already-made `status:` flip rides along in the **same** commit. It
84
- refuses when the story already has a real ship (then it isn't pre-tracking — use the normal flow). It
85
- does **not** author the story frontmatter — and to keep evidence and the flip atomic (the no-drift
84
+ refuses when the story already has a ship **in that repo** (then it isn't pre-tracking there — use the
85
+ normal flow). It does **not** author the story frontmatter — and to keep evidence and the flip atomic (the no-drift
86
86
  invariant), it **refuses** unless you have already set `status: shipped` in `stories/<story>.md`, so a
87
87
  ship shard is never committed while the artifact still says `approved`.
88
88
 
89
+ **One repo per run (#166).** A ship is recorded per `(story, task, repo)`, so a story that shipped in
90
+ several repos needs one retroactive shard **per repo** — the guard is keyed on `(story, repo)`, not on
91
+ the story alone, so recording the first repo never locks out the rest. Re-run once per repo:
92
+
93
+ ```
94
+ yad checkpoint --retro-ship EP-foo/EP-foo-S01 --repo web --push
95
+ yad checkpoint --retro-ship EP-foo/EP-foo-S01 --repo api --push
96
+ ```
97
+
98
+ The `status:` flip rides the **first** commit (it only needs one ship to be carried); each later run
99
+ lands only its own ship shard, so the story ends up with complete per-repo evidence. After each run the
100
+ command names the declared repos that still have none, so a half-finished backfill is visible instead of
101
+ looking complete.
102
+
103
+ A ship is permanent audit evidence, so `--repo` is checked before anything is written: it must be a repo
104
+ the story's `repos:` frontmatter declares (or, for a legacy story that declares none, one connected in
105
+ `.sdlc/repos.json`). A typo'd, mis-cased or invented name is **refused** — the per-repo guard means it
106
+ would otherwise collide with nothing and quietly record a ship for a repo that never existed. A name
107
+ that would share a shard **filename** with an already-recorded repo (shard names sanitize everything
108
+ outside `[A-Za-z0-9_-]` to `_`, so `api.v2` and `api_v2` are one file) is refused for the same reason:
109
+ recording it would overwrite the other repo's ship record in an append-only ledger.
110
+
111
+ Those checks decide from the ledger and then write it, so each one runs under an exclusive **lock** on
112
+ the epic's `build-log` — as does `yad review reconcile`'s stamp and `yad tidy up`'s fold. Two commands
113
+ writing the same ledger at once would otherwise both read "no ship yet" and both write. The lock is an
114
+ empty directory (`build-log.json.lock`), so git never sees it; one held by a process that died is
115
+ reclaimed after 30s, and a live one reports `YAD-STATE-006` rather than writing over the other's work.
116
+
89
117
  **Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
90
118
  — `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
91
119
  (`yad review trailer/context/nudge`, a real trailer/cards/chat session over the diff), `none` for a bare