yadflow 3.16.1 → 3.16.3

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,24 @@
1
+ ## [3.16.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.2...v3.16.3) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **open-pr:** base the task PR on the repo default branch, not main ([63da011](https://github.com/abdelrahmannasr/yadflow/commit/63da011fd2c4b6e81940c646879cc692b634a877)), closes [#168](https://github.com/abdelrahmannasr/yadflow/issues/168)
7
+
8
+
9
+ ### Performance Improvements
10
+
11
+ * **review:** stop probing the platform for an already-configured base ([0fc3de3](https://github.com/abdelrahmannasr/yadflow/commit/0fc3de34c7a522ec3463953778787f247ab28071)), closes [#191](https://github.com/abdelrahmannasr/yadflow/issues/191)
12
+
13
+ ## [3.16.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.1...v3.16.2) (2026-08-12)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **checkpoint:** make a --retro-ship dry run honest and side-effect free ([b03704f](https://github.com/abdelrahmannasr/yadflow/commit/b03704fb0564b1510dca121a24853758431f9374)), closes [112/#142](https://github.com/abdelrahmannasr/yadflow/issues/142) [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
19
+ * **checkpoint:** name the shard path and the fold step after --retro-ship ([64c33b3](https://github.com/abdelrahmannasr/yadflow/commit/64c33b37171ee1cebf540b7a66c35ee56cdc61a7)), closes [#167](https://github.com/abdelrahmannasr/yadflow/issues/167) [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
20
+ * **skills:** read build-log as the folded + shard union ([4302de2](https://github.com/abdelrahmannasr/yadflow/commit/4302de2e5f569989e8fc51c0a165aaae5abe1625)), closes [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
21
+
1
22
  ## [3.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.0...v3.16.1) (2026-08-12)
2
23
 
3
24
 
package/bin/yad.mjs CHANGED
@@ -103,9 +103,11 @@ ${c.bold('Review gate (front half)')}
103
103
 
104
104
  ${c.bold('Build helpers')}
105
105
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
106
- yad open-pr [--repo <name>] Open a task PR/MR stage-aware on the hub: a review/EP-*
107
- branch opens the front-half artifact-review PR (delegates to
108
- gate open), any other hub branch uses the code-task template
106
+ yad open-pr [--repo <name>] Open a task PR/MR against the repo's DEFAULT branch (never a
107
+ hardcoded main; --base overrides) — stage-aware on the hub: a
108
+ review/EP-* branch opens the front-half artifact-review PR
109
+ (delegates to gate open), any other hub branch uses the
110
+ code-task template
109
111
  yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
110
112
  yad checkpoint [--push] Commit the machine-written back-half hub state
111
113
  (trust-log/build-log/build-state) — plus any story
@@ -153,6 +155,10 @@ ${c.bold('Options')}
153
155
  --contract-change commit/open-pr: mark the contract surface touched
154
156
  --risk <level> open-pr: low|medium|high (default low)
155
157
  --repo <name> open-pr: target a registered repo by name
158
+ --base <branch> open-pr: override the PR/MR base — default is the repo's own default
159
+ branch (repos.json default_branch, else hub.json default_branch for a PR
160
+ on the hub itself, else the platform, else origin/HEAD, else main); a
161
+ non-default base loses the AI first pass (warns, never blocks)
156
162
  --epic <id> docs: target one epic's site (EP-<slug>)
157
163
  --overview docs: target the project SDLC-overview site
158
164
  --check/--refresh/--wire docs sync: report stale / rebuild / install Pages CI
@@ -191,7 +191,7 @@ export function retroShipRepos(root, storyFile) {
191
191
  return { names, source: names.length ? 'registry' : 'none' };
192
192
  }
193
193
 
194
- export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, today }) {
194
+ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, today, dryRun = false }) {
195
195
  if (!epic || !story) { fail('--retro-ship needs <epic>/<story> (e.g. --retro-ship EP-foo/EP-foo-S01)'); return { ok: false }; }
196
196
  if (!repo) { fail('--retro-ship needs --repo <name> (the repo the story shipped in)'); return { ok: false }; }
197
197
  const epicDir = path.join(root, 'epics', epic);
@@ -203,9 +203,10 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
203
203
  // Evidence and the flip must land TOGETHER — the #112 no-drift invariant. Refuse unless the human has
204
204
  // already flipped the story frontmatter to a back-half status in the working tree; otherwise the ship
205
205
  // shard would commit while the artifact still says e.g. `approved` — the very drift #112 prevents.
206
- if (!BACK_HALF_STATUSES.has(readFrontmatter(storyFile).status)) {
206
+ const storyStatus = readFrontmatter(storyFile).status;
207
+ if (!BACK_HALF_STATUSES.has(storyStatus)) {
207
208
  fail(`${story} frontmatter is not at in-build|shipped`);
208
- hand(`set \`status: shipped\` in ${storyRel} first, then re-run — the ship and the flip land in one commit`);
209
+ hand(`set \`status: in-build\` or \`status: shipped\` in ${storyRel} first, then re-run — the ship and the flip land in one commit`);
209
210
  return { ok: false };
210
211
  }
211
212
 
@@ -238,7 +239,12 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
238
239
  if (res.reason === 'collision') {
239
240
  // Distinct names, ONE shard file (`buildShardName` sanitizes each component) — recording this one
240
241
  // 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
+ // `writeRetroShip` reports a clash EITHER as `repo` (a ship readShips can see) OR as `file` (a shard
243
+ // on disk it cannot parse) — and a malformed shard can carry a blank `repo`, satisfying neither. Name
244
+ // whichever it gave us; never index into the one it did not, or the refusal becomes a stack trace.
245
+ const clashedWith = res.repo ? `the already-recorded ${res.repo}`
246
+ : res.file ? `an existing shard (${path.basename(res.file)})` : 'an existing ship record';
247
+ fail(`${repo} cannot be recorded: it shares a build-log shard name with ${clashedWith}`);
242
248
  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
249
  return { ok: false };
244
250
  }
@@ -249,11 +255,26 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
249
255
  if (left.length) hand(`still unrecorded for ${story}: ${left.join(', ')} — re-run with \`--repo <name>\` for each`);
250
256
  return { ok: false };
251
257
  }
252
- ok(`recorded retroactive ship for ${story} (${repo})${mergeCommit ? ` @ ${mergeCommit}` : ''}`);
258
+ // A dry run WROTE this shard only so the flip could be previewed; it is rolled back before the command
259
+ // returns. Every line below must therefore speak in the conditional — a past-tense "recorded" would tell
260
+ // the operator the backfill landed, they would never re-run for real, and the story would keep
261
+ // `status: shipped` with no ship behind it: exactly the #112/#142 drift this command exists to remove.
262
+ ok(`${dryRun ? 'would record' : 'recorded'} retroactive ship for ${story} (${repo})${mergeCommit ? ` @ ${mergeCommit}` : ''}`);
263
+ // Name WHERE the record landed. The ledger is shard-then-fold, so this ship is one loose shard and the
264
+ // folded `build-log.json` is untouched until `yad tidy up` runs — an operator who opens build-log.json,
265
+ // finds nothing, and concludes the write was lost is reading half the ledger (#167). Every reader unions
266
+ // the two; say so here, while they are looking. `tidy up` only folds a story whose frontmatter is
267
+ // `shipped`, so an `in-build` story's shard stays loose (and still readable) by design — mention the
268
+ // `shipped` precondition only when it is actually still outstanding.
269
+ const rel = path.relative(root, res.file).split(path.sep).join('/');
270
+ info(`${dryRun ? 'would land at' : 'landed at'} ${rel}`);
271
+ info(`readers union build-log/ shards with the folded build-log.json; \`yad tidy up\` folds it in${storyStatus === 'shipped' ? '' : ` once ${story} is \`shipped\``}`);
253
272
  // A multi-repo story is only half-reconciled until every declared repo has evidence, and nothing
254
273
  // 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`);
274
+ // operator is running the backfill. `left` excludes the repo just recorded; in a dry run that record is
275
+ // rolled back, so it is still unrecorded and belongs back in the list.
276
+ const stillLeft = dryRun && left.length ? [repo, ...left] : left;
277
+ if (stillLeft.length) hand(`${story} declares ${names.length} repos — still unrecorded: ${stillLeft.join(', ')}; re-run with \`--repo <name>\` for each`);
257
278
  return { ok: true, file: res.file };
258
279
  }
259
280
 
@@ -288,22 +309,30 @@ export async function runCheckpoint(root, opts = {}) {
288
309
  // already wrote is then carried by the normal storyStatusPathspecs path below — no raw git needed.
289
310
  let retroFile;
290
311
  if (opts.retroShip) {
291
- const r = recordRetroShip(root, opts.retroShip);
312
+ const r = recordRetroShip(root, { ...opts.retroShip, dryRun: opts.dryRun });
292
313
  if (!r.ok) { process.exitCode = 1; return; }
293
314
  retroFile = r.file;
294
315
  }
295
316
 
317
+ // A --retro-ship DRY RUN wrote a real shard so the flip could be previewed, and it must be undone on
318
+ // EVERY exit path below, not just the happy one. An early return that skipped the rollback (git add
319
+ // failed, nothing staged) would leave an untracked retro shard behind — which then refuses the
320
+ // operator's next REAL backfill ("already has a build-log ship in <repo>") and rides into the next
321
+ // plain `yad checkpoint` as permanent `retroactive: true` audit evidence nobody chose to record.
322
+ // A real run deliberately keeps its shard on a failure (see the commit-failed path below).
323
+ const rollbackRetro = () => { if (opts.dryRun && retroFile) cleanupRetroShard(retroFile); };
324
+
296
325
  // The machine ledgers PLUS any build-log-backed story `status:` flip (#112) — one commit records
297
326
  // both, so the story artifact never drifts from build-log and no raw git-to-main push is needed.
298
327
  const pathspecs = [...backHalfPathspecs(root), ...storyStatusPathspecs(root)];
299
- if (!pathspecs.length) { info('no back-half ledgers found — nothing to checkpoint'); return; }
328
+ if (!pathspecs.length) { rollbackRetro(); info('no back-half ledgers found — nothing to checkpoint'); return; }
300
329
 
301
330
  // Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
302
331
  // files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
303
332
  // top-level ledger is intentionally NOT staged (its spec drops out on the existence check) — an
304
333
  // append-only audit ledger vanishing is an anomaly a human should see, not something to auto-commit.
305
334
  const add = git('add', '--', ...pathspecs);
306
- if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
335
+ if (!add.ok) { rollbackRetro(); fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
307
336
 
308
337
  // #112 review-bypass guard: a story is only carried when its staged change is the `status:` line
309
338
  // ALONE. Unstage any candidate whose working tree also touched prose/other frontmatter — those
@@ -318,6 +347,7 @@ export async function runCheckpoint(root, opts = {}) {
318
347
  }
319
348
 
320
349
  if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
350
+ rollbackRetro();
321
351
  info('back-half state unchanged — nothing to commit');
322
352
  return;
323
353
  }
@@ -336,7 +366,7 @@ export async function runCheckpoint(root, opts = {}) {
336
366
  git('reset', '-q', '--', ...pathspecs); // restore the index — a dry run must not leave things staged
337
367
  // A --retro-ship dry run wrote a shard so the flip could be PREVIEWED above; undo it now so the dry
338
368
  // run leaves no side effect on disk (git reset only unstaged it, back to untracked).
339
- if (retroFile) cleanupRetroShard(retroFile);
369
+ rollbackRetro();
340
370
  info('dry run — not committed');
341
371
  return { message };
342
372
  }
package/cli/openpr.mjs CHANGED
@@ -4,9 +4,11 @@
4
4
  // on the product hub.
5
5
  import path from 'node:path';
6
6
  import fs from 'node:fs';
7
- import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
7
+ import { c, log, ok, info, warn, hand, fail, run, exists, readJSON } from './lib.mjs';
8
8
  import { PROJECT_FILES } from './manifest.mjs';
9
- import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
9
+ import {
10
+ detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin, resolveBaseBranch,
11
+ } from './platform.mjs';
10
12
  import { taskFromBranch } from './commit.mjs';
11
13
  import { parseReviewBranch, artifactFromBase } from './epic-state.mjs';
12
14
  import { gateOpen } from './gate.mjs';
@@ -104,9 +106,6 @@ export async function runOpenPr(root, opts = {}) {
104
106
  if (!platform) { fail('could not detect platform (github/gitlab) — pass --platform'); process.exitCode = 1; return; }
105
107
 
106
108
  const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot }).stdout;
107
- const baseBranch = opts.base || meta?.default_branch || 'main';
108
- if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
109
-
110
109
  const stage = detectStage(root, repoRoot, branch, meta);
111
110
 
112
111
  // hub-front: this is a front-half artifact-review PR (review/EP-*/<artifact> head on the hub). The
@@ -129,6 +128,34 @@ export async function runOpenPr(root, opts = {}) {
129
128
  return res;
130
129
  }
131
130
 
131
+ // The hub roster + its default_branch. The latter only applies when the PR targets the hub ITSELF
132
+ // (a hub-tooling branch) — for a connected code repo the hub's trunk belongs to a different repo and
133
+ // must never leak in. Resolved AFTER the hub-front hand-off above, which delegates its own base to
134
+ // `yad gate open`: resolving before it would spend a platform round-trip and print a base that the
135
+ // delegated path then ignores.
136
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
137
+
138
+ // Resolve the base rather than assume it (#168). Hardcoding 'main' mis-based every PR on a repo
139
+ // whose trunk is something else — and CodeRabbit decides auto-review eligibility from the base at
140
+ // PR-OPEN time, so those PRs silently got no AI first pass at all.
141
+ const { base: baseBranch, source: baseSource, platformDefault } = resolveBaseBranch(platform, {
142
+ cwd: repoRoot, explicit: opts.base, meta, hub: stage === 'code-repo' ? null : hub, runner: opts.runner,
143
+ });
144
+ if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
145
+ info(`base ${baseBranch} ${c.dim(`(from ${baseSource})`)}`);
146
+ // A base that is not the remote's default is legitimate (stacked PRs, release branches) but it costs
147
+ // the AI first pass, invisibly and irreversibly — so it is never silent. The REMEDY depends on where
148
+ // the base came from: telling someone to override a `default_branch` they deliberately configured
149
+ // would mean contradicting their own committed config on every PR, forever.
150
+ if (platformDefault && platformDefault !== baseBranch) {
151
+ warn(`base '${baseBranch}' is not the repo default '${platformDefault}' — CodeRabbit skips auto-review on a non-default base unless .coderabbit.yaml lists it under reviews.base_branches, and retargeting later does NOT undo the skip`);
152
+ if (baseSource === 'registry' || baseSource === 'hub') {
153
+ hand(`the configured default_branch (${baseSource === 'hub' ? '.sdlc/hub.json' : '.sdlc/repos.json'}) disagrees with the platform — reconcile them, or allow '${baseBranch}' in .coderabbit.yaml`);
154
+ } else {
155
+ hand(`open against '${platformDefault}' (or pass --base ${platformDefault}) unless you meant to stack this PR`);
156
+ }
157
+ }
158
+
132
159
  // Push the branch (sets upstream) using the user's own auth. Abort on failure — creating a PR for a
133
160
  // branch that is not on the remote just fails with a more confusing error.
134
161
  info(`pushing ${branch} …`);
@@ -155,7 +182,6 @@ export async function runOpenPr(root, opts = {}) {
155
182
  // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
156
183
  // local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
157
184
  // Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
158
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
159
185
  const roster = hub.roster || [];
160
186
  const committer = resolveCommitterLogin(repoRoot, roster);
161
187
  const scope = meta?.name ? [meta.name] : [];
@@ -164,7 +190,10 @@ export async function runOpenPr(root, opts = {}) {
164
190
  const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer, repos: meta ? [meta] : [] });
165
191
  const assignees = committer ? [committer] : [];
166
192
 
167
- const r = createPr(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
193
+ // `creator` is injectable (mirrors gateOpen's) so a test can assert the base that reaches the
194
+ // platform CLI without shelling out to gh/glab.
195
+ const creator = opts.creator || createPr;
196
+ const r = creator(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
168
197
  if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
169
198
  ok(`opened ${r.url}`);
170
199
  if (r.mentioned?.length) info(`@-mentioned (GitLab single-reviewer field): ${r.mentioned.join(', ')}`);
package/cli/platform.mjs CHANGED
@@ -378,6 +378,94 @@ export function branchExists(cwd, branch) {
378
378
  return remote.code === 2 ? false : null;
379
379
  }
380
380
 
381
+ // ---- default branch -----------------------------------------------------------------------------
382
+ // The REMOTE's own default branch, asked of the platform. This is the branch the platform (and the
383
+ // tooling that keys off it — CodeRabbit's auto-review eligibility, branch protection, "compare"
384
+ // defaults) considers the trunk, so it is the only authoritative answer to "what should a PR target?".
385
+ // `runner` is injectable so the read is unit-testable without shelling out (mirrors searchIssues).
386
+ // Returns { ok, branch, reason }; never throws — an absent/unauthenticated CLI is just `ok:false`.
387
+ // It is a READ of the remote's own config, so it costs one API round-trip; callers that only need a
388
+ // base branch get it folded into resolveBaseBranch below rather than calling this twice.
389
+ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
390
+ // No `platformReady` probe: an absent CLI already surfaces as a failed spawn, and skipping the probe
391
+ // keeps the read a pure function of `runner` (so a test never depends on gh/glab being installed).
392
+ if (!cliFor(platform)) return { ok: false, reason: 'no platform (github/gitlab) to ask' };
393
+ // This is a live network round-trip on a SYNCHRONOUS command path, so it carries the same ceiling
394
+ // branchExists documents for its own remote probe: "cannot ask" has to be fast, or a black-holed
395
+ // host / wedged credential helper turns `yad open-pr` into a hang. A timeout surfaces as ok:false,
396
+ // which the caller already treats as "the platform could not tell me".
397
+ const opts = { cwd, timeout: 10_000 };
398
+ if (platform === 'gitlab') {
399
+ // `:id` is glab's own placeholder for the project the cwd resolves to (same form as readPrGitLab).
400
+ const r = runner('glab', ['api', 'projects/:id'], opts);
401
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api projects/:id failed' };
402
+ try {
403
+ const branch = JSON.parse(r.stdout)?.default_branch;
404
+ return branch ? { ok: true, branch } : { ok: false, reason: 'project has no default_branch' };
405
+ } catch { return { ok: false, reason: 'unreadable glab api response' }; }
406
+ }
407
+ const r = runner('gh', ['repo', 'view', '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name'], opts);
408
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh repo view failed' };
409
+ return r.stdout ? { ok: true, branch: r.stdout } : { ok: false, reason: 'gh returned no defaultBranchRef' };
410
+ }
411
+
412
+ // The branch a PR/MR should target, resolved rather than assumed (issue #168: open-pr hardcoded
413
+ // 'main', so every task PR on a `staging`-trunk repo was mis-based — and CodeRabbit, which decides
414
+ // auto-review eligibility at PR-OPEN time from the base, silently skipped every one of them).
415
+ //
416
+ // Order — most explicit first, and configuration outranks the remote: the same
417
+ // configuration-outranks-the-remote order `yad repo sync` (repo.mjs) and the contract-check gate use,
418
+ // though only this chain has a platform rung — they stop at the local `origin/HEAD`:
419
+ // 1 flag — an explicit --base; the human said so
420
+ // 2 registry — the repo's `default_branch` in .sdlc/repos.json
421
+ // 3 hub — hub.json's `default_branch`, for a PR against the product hub itself
422
+ // 4 platform — what the remote says (see platformDefaultBranch)
423
+ // 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/hubcommit.mjs use.
424
+ // Deliberately NOT `ls-remote`: see branchExists above for why a network probe on
425
+ // this path is a hang hazard.
426
+ // 6 fallback — 'main'
427
+ //
428
+ // `probe` decides WHEN the platform is asked, and exists because the two callers want different things:
429
+ // true (default) — ask up front, so `platformDefault` rides along even when an earlier rung won.
430
+ // `yad open-pr` needs that to warn about a base that is not the remote's trunk,
431
+ // and it is about to shell out to gh/glab anyway.
432
+ // false — ask only if the config rungs all miss. A caller that just wants a base (the
433
+ // review companion) would otherwise pay a live round-trip — up to the full 10s
434
+ // timeout on a slow/unreachable host — for a `platformDefault` it discards.
435
+ // Either way the probe runs AT MOST once. Returns { base, source, platformDefault }; with `probe:false`
436
+ // and an early rung winning, `platformDefault` is null because it was never asked, not because the
437
+ // platform had no answer.
438
+ export function resolveBaseBranch(platform, {
439
+ cwd, explicit = null, meta = null, hub = null, runner = run, probe = true,
440
+ } = {}) {
441
+ let platformDefault = null;
442
+ let asked = false;
443
+ const askPlatform = () => {
444
+ if (asked) return platformDefault;
445
+ asked = true;
446
+ const remote = platformDefaultBranch(platform, { cwd, runner });
447
+ platformDefault = remote.ok ? remote.branch : null;
448
+ return platformDefault;
449
+ };
450
+ if (probe) askPlatform();
451
+ const originHead = () => {
452
+ const r = runner('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd });
453
+ return r.ok && r.stdout ? r.stdout.replace(/^origin\//, '') : null;
454
+ };
455
+ const chain = [
456
+ ['flag', explicit],
457
+ ['registry', meta?.default_branch],
458
+ ['hub', hub?.default_branch],
459
+ ['platform', askPlatform],
460
+ ['origin-head', originHead],
461
+ ];
462
+ for (const [source, value] of chain) {
463
+ const branch = typeof value === 'function' ? value() : value;
464
+ if (branch) return { base: branch, source, platformDefault };
465
+ }
466
+ return { base: 'main', source: 'fallback', platformDefault };
467
+ }
468
+
381
469
  // ---- create a PR/MR -----------------------------------------------------------------------------
382
470
  // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
383
471
  // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
package/cli/review.mjs CHANGED
@@ -12,6 +12,7 @@ import { updateShip } from './ledger.mjs';
12
12
  import { epicRoot } from './epic-state.mjs';
13
13
  import {
14
14
  detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
15
+ resolveBaseBranch,
15
16
  } from './platform.mjs';
16
17
  import { upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
17
18
  import { sequenceDiff } from './walkthrough.mjs';
@@ -40,12 +41,16 @@ function platformOf(root, repoRoot, meta) {
40
41
  // Build (but don't print) the back-half grounding bundle. Shared by `context` and `walkthrough` so the
41
42
  // pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
42
43
  // Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
43
- function contextBundle(root, { repo, dir, pr } = {}) {
44
+ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
44
45
  const rr = resolveRepo(root, { repo, dir });
45
46
  if (rr.error) return { error: rr.error };
46
47
  const { repoRoot, meta } = rr;
47
48
  const platform = platformOf(root, repoRoot, meta);
48
- const base = meta?.default_branch || 'main';
49
+ // Same resolution as `yad open-pr` (#168): without it a repo whose trunk is not `main` grounded the
50
+ // companion on the wrong diff range — or on a branch that does not exist at all. `probe: false`
51
+ // because this caller only wants the base: a configured `default_branch` must answer locally and
52
+ // instantly, never behind a live gh/glab round-trip that could stall for the full timeout.
53
+ const { base } = resolveBaseBranch(platform, { cwd: repoRoot, meta, runner, probe: false });
49
54
  const bundle = {
50
55
  repo: meta?.name || null,
51
56
  repoRoot,
@@ -66,8 +71,8 @@ function contextBundle(root, { repo, dir, pr } = {}) {
66
71
 
67
72
  // `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
68
73
  // the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
69
- export async function reviewContext(root, { repo, dir, pr } = {}) {
70
- const r = contextBundle(root, { repo, dir, pr });
74
+ export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
75
+ const r = contextBundle(root, { repo, dir, pr, runner });
71
76
  if (r.error) { fail(r.error); process.exitCode = 1; return; }
72
77
  log(JSON.stringify(r.bundle, null, 2));
73
78
  return r.bundle;
@@ -78,7 +83,7 @@ export async function reviewContext(root, { repo, dir, pr } = {}) {
78
83
  // first). The CLI sequences deterministically; the skill (yad-pair-review) walks the stops, generates
79
84
  // the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
80
85
  export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
81
- const r = contextBundle(root, { repo, dir, pr });
86
+ const r = contextBundle(root, { repo, dir, pr, runner });
82
87
  if (r.error) { fail(r.error); process.exitCode = 1; return; }
83
88
  const { bundle, repoRoot, base } = r;
84
89
  const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.16.1",
3
+ "version": "3.16.3",
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",
@@ -139,7 +139,12 @@ build:
139
139
  risk_levels: [low, medium, high] # high (or a contract/auth/payments surface) routes to domain owners (yad-review-gate escalation)
140
140
  # Step E (yad-engineer-review) — AI review (advisory) + engineer review (the human gate) + merge.
141
141
  ai_review: coderabbit # advisory first pass; never the authority (.coderabbit.yaml)
142
- build_log: "epics/EP-<slug>/.sdlc/build-log.json" # append-only ship ledger (back-half analogue of approvals.json)
142
+ # Append-only ship ledger (back-half analogue of approvals.json), stored shard-then-fold: writers add one
143
+ # shard per ship under build_log_dir; `yad tidy up` folds a SHIPPED story's shards into build_log.
144
+ # READERS MUST UNION the two (dedupe by (story, task, repo); a shard wins) — build_log alone omits every
145
+ # unfolded ship, e.g. a `yad checkpoint --retro-ship` backfill or any ship on an `in-build` story.
146
+ build_log: "epics/EP-<slug>/.sdlc/build-log.json" # folded ships
147
+ build_log_dir: "epics/EP-<slug>/.sdlc/build-log/" # one <story>-<task>-<repo>.json shard per ship
143
148
  story_build_states: [in-build, shipped] # in-build = some tasks shipped; shipped = all tasks in tasks.md shipped
144
149
  # Backfill (yad-backfill) — specs for already-built features in an existing repo.
145
150
  backfill:
@@ -20,8 +20,8 @@ SDLC Workflow,yad-implement,Implement Task,IM,"Build-half Step B: with the dev l
20
20
  SDLC Workflow,yad-checks,Check Gates,CK,"Build-half Step C: wire and run the production-safety CI gates on a code repo (and product hub) — spec-link (every change links a real story/spec via its Task trailer), contract-check (a contract-surface change without Contract-Change + an updated re-locked contract FAILS and routes back to the architecture gate), build/test/lint, verified-commits (signed + roster-known authors), and the pattern gates commit-message / pr-title / pr-template (profile-aware code|hub). CI-agnostic bash invoked by GitHub Actions and GitLab CI. Blocking in CI; the human still owns the merge. Never auto-advances.",,{repo: <one of an epic's repos | hub>} {action: wire|run} {base: target branch},3-build,yad-implement,,false,demo-repos/<repo>/,checks/*.sh .github/workflows/yad-checks.yml .gitlab-ci.yml
21
21
  SDLC Workflow,yad-pr-template,PR/MR Template,PT,"Build-half Step D: detect a code repo's platform and commit the matching PR/MR template (.github/pull_request_template.md or .gitlab/merge_request_templates/Default.md) with an Impact & Risk block. A high risk level (or a touched contract/auth/payments surface) routes the review to domain owners — the same escalation yad-review-gate applies. Ships the routing helper risk-route.sh plus the pattern-gate scripts pr-title.sh and pr-template.sh (used by yad-checks). Never auto-advances.",,{repo: <one of an epic's repos | hub>} {action: wire|route} {body: PR description file},3-build,yad-checks,,false,demo-repos/<repo>/,.github/pull_request_template.md .gitlab/merge_request_templates/Default.md checks/risk-route.sh checks/pr-title.sh checks/pr-template.sh
22
22
  SDLC Workflow,yad-commit,Commit by Convention,CM,"Build-half helper: commit ONE staged atomic change by the conventions — a Conventional-Commits subject, the fixed trailer block (Task -> Contract-Change -> Co-Authored-By), and the <=3-file atomic guard. The human git author owns the commit; an assisting AI is recorded only as a Co-Authored-By footer chosen per-commit with --ai (claude|copilot|cursor|coderabbit|none, default none). Drives the yad commit CLI. Never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {task: <id>} {contract-change: true|false},3-build,yad-implement,,false,<repo>/,one commit
23
- SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build-half helper: open a code-repo task PR/MR from the committed platform template — detect GitHub/GitLab, push the task branch, create the PR/MR with the body prefilled (Summary / Story-task / Impact & Risk) and the title defaulting to the commit subject. Auto-assigns from the hub roster (assignee = committer, reviewers = repo reviewers + domain-owners); high risk / contract surface routes to domain owners (risk-route.sh). Drives the yad open-pr CLI. Never merges; never auto-advances.",,{repo: <name>} {risk: low|medium|high} {contract-change: true|false},3-build,yad-commit,,false,<repo>/,one PR/MR
24
- SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build-half helper: commit AND open the task PR/MR in one step — a thin orchestration over yad-commit then yad-open-pr. Commits the staged atomic change by the conventions, then pushes the branch and opens the PR/MR from the committed template with the roster auto-assigned. The PR step runs ONLY if the commit lands (a failed commit, tripped guard, or --dry-run stops before pushing). Drives the yad ship CLI. Never merges; never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {repo: <name>} {risk: low|medium|high} {contract-change: true|false},3-build,yad-pr-template,,false,<repo>/,one commit + one PR/MR
23
+ SDLC Workflow,yad-open-pr,Open PR/MR,OP,"Build-half helper: open a code-repo task PR/MR from the committed platform template — detect GitHub/GitLab, push the task branch, create the PR/MR with the body prefilled (Summary / Story-task / Impact & Risk) and the title defaulting to the commit subject. Auto-assigns from the hub roster (assignee = committer, reviewers = repo reviewers + domain-owners); high risk / contract surface routes to domain owners (risk-route.sh). Bases the PR on the repo's RESOLVED default branch (repos.json default_branch, else the platform's own default, else origin/HEAD, else main) instead of a hardcoded main, and warns when the base is not the platform default — that loses the AI first pass irreversibly. Drives the yad open-pr CLI. Never merges; never auto-advances.",,{repo: <name>} {risk: low|medium|high} {contract-change: true|false} {base: <branch>},3-build,yad-commit,,false,<repo>/,one PR/MR
24
+ SDLC Workflow,yad-ship,Commit + Open PR/MR,SP2,"Build-half helper: commit AND open the task PR/MR in one step — a thin orchestration over yad-commit then yad-open-pr. Commits the staged atomic change by the conventions, then pushes the branch and opens the PR/MR from the committed template with the roster auto-assigned. The PR step runs ONLY if the commit lands (a failed commit, tripped guard, or --dry-run stops before pushing). Drives the yad ship CLI. Never merges; never auto-advances.",,{type: feat|fix|...} {message: subject} {ai: <tool|none>} {repo: <name>} {risk: low|medium|high} {contract-change: true|false} {base: <branch>},3-build,yad-pr-template,,false,<repo>/,one commit + one PR/MR
25
25
  SDLC Workflow,yad-hub-bridge,Hub Review Bridge,HB,"The templated PR/MR bridge for the front-half review gate: when the product hub has a platform (.sdlc/hub.json), open a review PR/MR on the hub for an authored artifact, set required reviewers/labels from the routing rule, and provide the read-only gh/glab recipes yad-review-gate's sync uses to pull platform comments + approvals into the file ledger. Local-user auth, no stored tokens; file ledger stays the source of truth; degrades to file-only when no platform/CLI. Never auto-advances.",,{epic: EP-<slug>} {artifact: epic.md|architecture.md|ui-design.md|stories/} {action: open|route},1-front,yad-review-gate,yad-review-gate,false,epics/EP-<slug>/.sdlc/,hub-prs.json
26
26
  SDLC Workflow,yad-engineer-review,Engineer Review & Merge,ER,"Build-half Step E: wire an advisory AI first-pass (CodeRabbit) on the PR, record the human engineer review with the same human_approve discipline as the front gates (owner + 1 reviewer, escalating to domain owners on high risk / contract / auth / payments), and on merge record the ship in epics/<epic>/.sdlc/build-log.json and update the story state. AI review is advisory, never the authority; the human owns the merge. Never auto-advances.",,{epic: EP-<slug>} {story: EP-<slug>-S0N} {task: T0N} {repo: <repo>} {action: ai-review|approve|ship},3-build,yad-ship,,false,epics/EP-<slug>/.sdlc/,build-log.json story-status
27
27
  SDLC Workflow,yad-backfill,Backfill Specs,BF,"Build-half Step G: generate specs for already-built features in an existing repo. Confirm Repomix (npx repomix CLI), pack ONE feature (compress + git logs, secret-scan), feed to AI with a 'describe what exists, do not invent' prompt, write a DRAFT spec marked verified: false. Human approval (reuse yad-review-gate) makes it real. Boundary auto-proposed and human-confirmed. A change is blocked only until the features it touches have approved specs. The promote action flips a brownfield stub epic (yad-stub) to a real, verified feature epic once its backfill spec is approved. Never auto-advances.",,{repo: <repo>} {feature: <name + globs>} {action: pack|draft|approve|gate|promote} {epic: EP-<slug> (promote)},3-build,,,false,demo-repos/<repo>/specs/backfill/<feature>/,spec.md backfill-check.sh
@@ -33,7 +33,7 @@ SDLC Workflow,yad-docs,Author Docs Site,DS,"Generate the per-epic interactive do
33
33
  SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Folds the hand-maintained docs/index.html report into the site as report.html (linked from the nav). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
34
34
  SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + the doc-shell version vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that rebuilds on push (carrying [skip ci] + a concurrency group to prevent deploy loops). Generalizes the rule that feature work must hand-update docs/index.html + diagrams + skill counts. Refresh is always a human/CI decision; never a gate.",,{action: check|refresh|wire} {epic: EP-<slug>},,,,false,epics/EP-<slug>/.sdlc/,docs-build.json yad-docs.yml
35
35
  SDLC Workflow,yad-change,Change/Defect Intake,CH,"Phase 6 post-lock change management: the INTAKE + TRIAGE step of a feature thread. Classifies the change DEPTH (defect-fix / behavioral-no-surface / contract-surface / new-capability), seeds a NEW EP-<slug> change-epic threaded to its parent (lineage frontmatter kind/parent/thread/inherits/supersedes + a state.json whose inherited steps are pre-marked done and only the changed steps run; a pointer-lock contract-lock.json when architecture is inherited), and records the intake in change.json (escape_stage + root_cause for defects). For hotfixes it records the ship-first exception and opens reconcile-debt.json. Never auto-advances — hands off to the normal authoring skills + yad-review-gate.",,{parent: EP-<slug>} {title: one-line} {kind: change|defect|hotfix} {origin: production|staging|qa|review} {severity: sev1..sev4} {description: text} {affected: artifacts},1-front,,yad-review-gate,false,epics/EP-<slug>/,epic.md state.json change.json reconcile-debt.json contract-lock.json
36
- SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + build-log.json. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
37
- SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every kind:defect change-epic + each change.json defect block + shipped regressions in build-log.json, aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
36
+ SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + the build ledger (folded build-log.json UNIONed with every build-log/ shard). An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
37
+ SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every kind:defect change-epic + each change.json defect block + shipped regressions in the build ledger (folded build-log.json UNIONed with every build-log/ shard), aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
38
38
  SDLC Workflow,yad-reconcile,Change Reconciler,RE,"Maintenance/CI (mirrors yad-docs-sync — never a gate): detect post-lock DRIFT/ORPHANS — shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread — plus open hotfix reconcile debt, and report which thread drifted and why. refresh points at yad-change to open a reconcile change-epic (or yad-stub first, to anchor orphan brownfield code that has no epic at all) — never silent; wire commits advisory CI ([skip ci] + concurrency, like yad-docs-sync). The actual merge BLOCK is the lineage-check / reconcile-debt gates; this only discovers.",,{action: check|refresh|wire} {thread: EP-<genesis>},,,,false,epics/EP-<genesis>/.sdlc/,(report) reconcile-debt.json yad-reconcile.yml
39
39
  SDLC Workflow,yad-stub,Stub Genesis Epic,SG,"Phase 6 brownfield helper: mint a STUB genesis epic for an already-built feature that has no epic in the hub, so a defect/change can thread off it TODAY (yad-change requires a real parent and dead-ends without one). Creates the smallest real thread anchor — a tiny epic.md (kind:feature, thread:self, verified:false, stub:backfill-pending) + a seeded state.json (kind:stub / currentStep:backfill-pending) + empty ledgers — never inventing behaviour. Defects thread off it immediately (gates pass; the bug list is derived by the thread rollup); yad-backfill + its promote step later flip it into a real feature epic. Never auto-advances.",,{feature: <name>} {repos: [<repo>]} {description: one-line},1-front,,yad-change,false,epics/EP-<slug>/,epic.md state.json approvals.json comments.json
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yad-defects
3
- description: 'Phase 6 output enrichment (never a gate) — the quality-gap report. Generates a per-epic AND per-thread defect/bug report (the vendored React/Vite/Tailwind shell HTML + a DEFECTS.md) that aggregates every kind:defect change-epic + each change.json defect block + shipped regressions in build-log.json BY escape_stage (the SDLC gate that should have caught the defect) and root_cause, and visualizes WHERE quality gaps systematically come from — e.g. "% of this feature''s defects that escaped at the test-cases gate" — so the team hardens the originating stage instead of just fixing symptoms. Degrades to markdown-only when no docs target is connected. Use when the user says "show the defect report", "where are our quality gaps", "generate the bug report for this epic", or "which gate is leaking defects".'
3
+ description: 'Phase 6 output enrichment (never a gate) — the quality-gap report. Generates a per-epic AND per-thread defect/bug report (the vendored React/Vite/Tailwind shell HTML + a DEFECTS.md) that aggregates every kind:defect change-epic + each change.json defect block + shipped regressions in the build ledger (the folded build-log.json unioned with every build-log/ shard) BY escape_stage (the SDLC gate that should have caught the defect) and root_cause, and visualizes WHERE quality gaps systematically come from — e.g. "% of this feature''s defects that escaped at the test-cases gate" — so the team hardens the originating stage instead of just fixing symptoms. Degrades to markdown-only when no docs target is connected. Use when the user says "show the defect report", "where are our quality gaps", "generate the bug report for this epic", or "which gate is leaking defects".'
4
4
  ---
5
5
 
6
6
  # SDLC — Quality-Gap Report (Phase 6, output enrichment)
@@ -32,10 +32,16 @@ risk), not just the symptom. It is an **output enrichment**, exactly like `yad-d
32
32
  Resolve the scope (`yad thread <id> --json` for a thread). Collect, across the scoped epic(s):
33
33
  - every `kind: defect` (and `kind: hotfix`) change-epic + its `.sdlc/change.json` `defect` block
34
34
  (`origin`, `severity`, `escape_stage`, `root_cause`);
35
- - the shipped regression fixes from each `.sdlc/build-log.json` (the fix that closed the defect, linking
35
+ - the shipped regression fixes from each epic's build ledger (the fix that closed the defect, linking
36
36
  the change-epic → its regression story/test);
37
37
  - open reconcile debt (a hotfix whose front truth is not yet restored).
38
38
 
39
+ The build ledger is **shard-then-fold**: read it as the **union** of the folded `.sdlc/build-log.json`
40
+ `ships` PLUS every loose `.sdlc/build-log/` shard, deduped by `(story, task, repo)` — a shard WINS over a
41
+ folded ship of the same key. Reading `build-log.json` alone drops every ship not yet folded by
42
+ `yad tidy up` (including every `yad checkpoint --retro-ship` backfill, and every ship on a story still at
43
+ `in-build`, which `tidy up` never folds), which would under-count the very fixes this report attributes.
44
+
39
45
  ### Step 2 — Attribute each defect to the gate that should have caught it
40
46
  A defect is attributed to its **earliest** responsible SDLC stage. Use `change.json.escape_stage`
41
47
  (human-set at intake), cross-checked against the fix's shape: a missing negative test → `test-cases`; a
@@ -73,7 +79,9 @@ Also write a plain `epics/<scope>/DEFECTS.md` mirror. On `action: deploy`, `yad
73
79
  - **Degrade gracefully.** No docs target → `DEFECTS.md` only; never fail because a tool is absent.
74
80
 
75
81
  ## Reference
76
- - The defect data: `.sdlc/change.json` `defect` blocks + `build-log.json` (`../yad-epic/references/state-schema.md`, Phase 6).
82
+ - The defect data: `.sdlc/change.json` `defect` blocks + the build ledger, read as the folded
83
+ `build-log.json` unioned with every `.sdlc/build-log/` shard
84
+ (`../yad-epic/references/state-schema.md`, Phase 6; `../yad-engineer-review/references/ship-and-record.md`).
77
85
  - The shell + deterministic generation it reuses: `../yad-docs/SKILL.md`, `../yad-docs/references/data-mapping.md`.
78
86
  - The companion evolution view: `../yad-timeline/SKILL.md`.
79
87
  - The intake that records `escape_stage` + `root_cause`: `../yad-change/SKILL.md`.
@@ -69,7 +69,7 @@ Per-story, per-repo: `spec → tasks → implement → checks → engineer-revie
69
69
  | `yad-checks` | `checks/*.sh`, CI workflows — the gate set: `spec-link · contract-check · build-test-lint · verified-commits · commit-message · pr-title · pr-template · lineage-check · epic-open · reconcile-debt`, plus `yad-update-guard` (push-on-default: re-checks any direct-to-default commit with `verified-commits · commit-message`) |
70
70
  | `yad-pr-template` | PR/MR template + routing helpers |
71
71
  | `yad-commit` / `yad-open-pr` / `yad-ship` | one commit / one PR/MR |
72
- | `yad-engineer-review` | engineer review + ship recorded in `build-log.json` |
72
+ | `yad-engineer-review` | engineer review + ship recorded as a `build-log/` shard |
73
73
  | `yad-backfill` | DRAFT specs for legacy features |
74
74
 
75
75
  ### Path: Automation (the second dial + observation)
@@ -110,6 +110,11 @@ and the change-thread ledgers `change.json`, `reconcile-debt.json`, `build-log.j
110
110
  the **connected code repos**; the **design / testing / learning tools**; and the **platform**
111
111
  (GitHub/GitLab + Pages). A skill's `sideEffects` link its step to the component it writes.
112
112
 
113
+ `trust-log.json` and `build-log.json` are the *folded* halves of two **shard-then-fold** ledgers — each
114
+ also has a shard dir (`.sdlc/trust-log/`, `.sdlc/build-log/`) holding the entries `yad tidy up` has not
115
+ folded yet. They render as one component each, but anything READING them must union the folded file with
116
+ its shards (`../../yad-engineer-review/references/ship-and-record.md`).
117
+
113
118
  ## Roles = the lenses
114
119
 
115
120
  The eight yadflow lenses, each to its relevant phase sections + paths:
@@ -83,8 +83,15 @@ It writes ONE minimal ship shard marked `retroactive: true` (`task` defaults to
83
83
  the normal checkpoint so the story's already-made `status:` flip rides along in the **same** commit. It
84
84
  refuses when the story already has a ship **in that repo** (then it isn't pre-tracking there — use the
85
85
  normal flow). It does **not** author the story frontmatter — and to keep evidence and the flip atomic (the no-drift
86
- invariant), it **refuses** unless you have already set `status: shipped` in `stories/<story>.md`, so a
87
- ship shard is never committed while the artifact still says `approved`.
86
+ invariant), it **refuses** unless you have already set a back-half `status:` (`in-build` or `shipped`) in
87
+ `stories/<story>.md`, so a ship shard is never committed while the artifact still says `approved`.
88
+
89
+ **Where the record lands — it is a shard, not an append to `build-log.json`.** Like every other ship, a
90
+ retroactive one is written to `.sdlc/build-log/` and the folded `build-log.json` is left untouched until
91
+ `yad tidy up` folds it. Opening `build-log.json` and finding nothing does **not** mean the write was lost:
92
+ read the ledger by the union rule above and the ship is there (#167). Note `tidy up` only folds a story
93
+ whose frontmatter is `shipped`, so a backfill recorded against an `in-build` story stays a loose shard
94
+ indefinitely — which is precisely why reading the folded file alone is never sufficient.
88
95
 
89
96
  **One repo per run (#166).** A ship is recorded per `(story, task, repo)`, so a story that shipped in
90
97
  several repos needs one retroactive shard **per repo** — the guard is keyed on `(story, repo)`, not on
@@ -138,11 +145,12 @@ The story frontmatter `status` reflects build progress:
138
145
  You write this flip into `stories/<story>.md`, but **do not hand-commit it** — the next
139
146
  `yad checkpoint --push` carries it in the same `chore(hub)` commit as the ledgers (the story now has a
140
147
  build-log ship, so checkpoint stages it; #112). This is what keeps the story artifact from drifting
141
- from `build-log.json`, so there is never a reason to fall back to a raw `git push origin main`.
148
+ from the build ledger, so there is never a reason to fall back to a raw `git push origin main`.
142
149
 
143
150
  So the chain is traceable both ways: from the epic down (`epic.md` → `stories/<story>.md` →
144
- `tasks.md` → `build-log.json` ship → `mergeCommit`) and from a merge commit back up (its `Task:`
145
- trailer → story → epic).
151
+ `tasks.md` → the build-ledger ship → `mergeCommit`) and from a merge commit back up (its `Task:`
152
+ trailer → story → epic). Resolve that ship by the union rule, not from `build-log.json` alone — until
153
+ `yad tidy up` folds it, the ship lives only in its `build-log/` shard and the chain looks broken.
146
154
 
147
155
  ## Preconditions for ship (all required)
148
156
 
@@ -325,11 +325,17 @@ storage layout is noted here (it mirrors `trust-log.json`):
325
325
  ship object. `(story, task, repo)` is already a natural unique key, so no `uid` is needed.
326
326
  - **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ <ship>, … ] }`
327
327
  (also the legacy single-file layout, and the output of `yad tidy up`).
328
- - **Union-read rule:** union the folded `ships` with every `build-log/` shard, **deduping by
329
- `(story, task, repo)`** — a shard WINS over a stale folded ship (so a `yad review reconcile` edit to a
330
- ship's shard is authoritative until it is folded).
328
+ - **Union-read rule (binding on every reader — never read the folded file alone):** union the folded
329
+ `ships` with every `build-log/` shard, **deduping by `(story, task, repo)`** — a shard WINS over a stale
330
+ folded ship (so a `yad review reconcile` edit to a ship's shard is authoritative until it is folded).
331
+ `build-log.json` on its own is only the *folded* half of the ledger: it omits every ship `yad tidy up`
332
+ has not folded, including every `yad checkpoint --retro-ship` backfill, and — because `tidy up` folds
333
+ only a story whose frontmatter is `shipped` — every ship on a story still at `in-build`, indefinitely.
334
+ A reader that skips the union silently under-reports what shipped (#167).
331
335
  - `yad checkpoint` commits the shard dir; `yad tidy up` folds a shipped story's finished shards into the
332
- folded file (loose objects + `git gc`).
336
+ folded file (loose objects + `git gc`). No **ship** path ever appends to the folded file — that is what
337
+ keeps concurrent shippers conflict-free. (`yad review reconcile` does write it, but only to stamp a ship
338
+ already folded there; see `updateShip`.)
333
339
 
334
340
  ---
335
341
 
@@ -30,6 +30,17 @@ the product hub.
30
30
  (`## Summary` / `Risk level:` / `## Checklist`) instead of the hub's artifact-review
31
31
  `pull_request_template.md`, so the hub `pr-template` gate passes.
32
32
  In a code repo nothing changes — it reads the repo's own committed code-task template.
33
+ - **Base branch** — **resolved, never assumed.** In order: `--base` → the repo's `default_branch` in
34
+ `.sdlc/repos.json` → (for a PR against the hub itself) `hub.json`'s `default_branch` → what the
35
+ platform reports (`gh repo view --json defaultBranchRef` / `glab api projects/:id`) → local
36
+ `origin/HEAD` → `main`. The same **configuration-outranks-the-remote** order `yad repo sync` and the
37
+ contract-check gate already use (they stop at `origin/HEAD`; only this chain also asks the platform).
38
+ The CLI prints which rung answered.
39
+ **If the resolved base is not the platform's default branch it warns and still opens** — that is a
40
+ legitimate stacked-PR / release-branch move, but it costs the AI first pass: CodeRabbit decides
41
+ auto-review eligibility from the base at PR-**open** time, and retargeting afterwards does not undo
42
+ the skip. Hardcoding `main` here is the same bug the check gates already refuse to make (see
43
+ `../yad-checks/references/check-gates.md`).
33
44
  - **Auto-assign** — from the hub roster scoped to this repo: assignee = the committer (resolved from
34
45
  the local git identity), reviewers = the repo's `reviewer`/`domain-owner` logins minus the committer.
35
46
  Degrades cleanly when there is no roster.
@@ -42,7 +53,9 @@ the product hub.
42
53
  - `repo` — target a registered repo by name (optional; else the current dir).
43
54
  - `risk` — `low|medium|high` (default `low`); prefilled into the body.
44
55
  - `contractChange` — flag; marks the contract surface touched and triggers escalation.
45
- - `base` / `platform` / `title` optional overrides.
56
+ - `base` override the PR/MR base (optional; defaults to the repo's own default branch —
57
+ see **Base branch** above). Only pass it deliberately: a non-default base loses the AI first pass.
58
+ - `platform` / `title` — optional overrides.
46
59
 
47
60
  ## On Activation
48
61
 
@@ -56,7 +69,14 @@ Run from the repo root:
56
69
  yad open-pr [--repo <name>] [--risk <level>] [--contract-change] [--title "<subject>"]
57
70
  ```
58
71
  The CLI pushes the branch (sets upstream, the user's own auth), fills the template, and creates the
59
- PR/MR with the auto-assigned assignee + reviewers.
72
+ PR/MR with the auto-assigned assignee + reviewers. It prints the base it resolved and where that came
73
+ from.
74
+
75
+ The non-default-base warning is **advisory — it does not block, and the PR/MR is already open by the
76
+ time you read it.** If the base was intended (a stacked PR, a release branch), carry on. If it was
77
+ not, do **not** just retarget the open PR — that leaves the AI first pass skipped. Close it, fix the
78
+ cause (the repo's `default_branch`, or drop the wrong `--base`), and re-run `yad open-pr` so the PR
79
+ is *created* against the right base.
60
80
 
61
81
  ### Step 3 — Route the review (if escalated)
62
82
  On `high` risk or a contract touch, run `bash checks/risk-route.sh <pr-body>` to print the required
@@ -79,6 +99,8 @@ engineer review and merge happen in `yad-engineer-review` (Step E).
79
99
  ## Hard rules
80
100
 
81
101
  - **One task = one branch = one PR/MR.** Never open a PR from the default branch.
102
+ - **The base is the repo's default branch** unless you deliberately chose otherwise with `--base`.
103
+ Never hardcode `main`, and never ignore the non-default-base warning silently.
82
104
  - **Title follows the commit subject** — Conventional-Commits style, so the `pr-title` gate passes.
83
105
  - **High risk routes to domain owners** — the same escalation as the gate; never a separate rule.
84
106
  - **Opening a PR never merges.** The human owns the merge in Step E.
@@ -34,7 +34,7 @@ Run `yad reconcile check` (optionally `--thread EP-<genesis>`). For each thread
34
34
  `yad check` drift style, any of:
35
35
  - **Broken lineage** — a change-epic whose `parent` is missing, a cycle, or a `thread` cache that
36
36
  disagrees with the computed root (the same signal `yad doctor` reports).
37
- - **Orphan / drift** — code shipped (`build-log.json`) or a touched repo's HEAD advanced past its
37
+ - **Orphan / drift** — code shipped (the build ledger) or a touched repo's HEAD advanced past its
38
38
  `repos.json` `syncedHead` with **no owning change-epic** in any thread — i.e. behaviour reached
39
39
  production that no epic in the thread describes. Name the repo (`<repo>: <old>→<new>`).
40
40
  - **Open hotfix debt** — a `reconcile-debt.json` entry still `open`; the next normal change on that
@@ -42,6 +42,14 @@ Run `yad reconcile check` (optionally `--thread EP-<genesis>`). For each thread
42
42
 
43
43
  Writes nothing. This is the read-only sweep a human (or CI) runs to see the picture.
44
44
 
45
+ > **Read the build ledger as a union — a missed ship reads as "no drift".** It is **shard-then-fold**:
46
+ > union the folded `.sdlc/build-log.json` `ships` with every loose `.sdlc/build-log/` shard, deduped by
47
+ > `(story, task, repo)` (a shard WINS over a folded ship of the same key). Reading `build-log.json` alone
48
+ > drops every ship not yet folded by `yad tidy up` — including every `yad checkpoint --retro-ship` backfill,
49
+ > and every ship on a story still at `in-build`, which `tidy up` never folds. Those are exactly the ships
50
+ > most likely to be orphaned, so missing them turns this check into a false all-clear. See
51
+ > `../yad-engineer-review/references/ship-and-record.md`.
52
+
45
53
  ### Step 2 — `refresh` (advisory, never silent)
46
54
  For each flagged thread, **point the human at the fix** — open a reconcile change-epic with `yad-change`
47
55
  (`kind: change`, threaded to the affected feature) to bring the front artifacts back in step with what
@@ -34,7 +34,11 @@ its own and **never merges**. The engineer review + merge are Step E (`yad-engin
34
34
  footer**; the `Co-Authored-By` trailer appears only when this flag names a tool).
35
35
  - `task` — Task trailer (optional; derived from the branch when omitted).
36
36
  - `contractChange` — flag; marks the contract surface touched (commit trailer + PR escalation).
37
- - `repo` / `risk` / `base` / `platform` / `title` — PR/MR options (see `yad-open-pr`).
37
+ - `repo` / `risk` / `platform` / `title` — PR/MR options (see `yad-open-pr`).
38
+ - `base` — override the PR/MR base. The default is the repo's own default branch, resolved
39
+ by the full chain in **Base branch** (`yad-open-pr`) — the canonical description, including the
40
+ hub rung — never a hardcoded `main`. A non-default base loses the AI first pass; `ship` warns and
41
+ still opens.
38
42
 
39
43
  ## On Activation
40
44
 
@@ -59,6 +63,7 @@ the engineer review and merge are Step E (`yad-engineer-review`).
59
63
  ## Hard rules
60
64
 
61
65
  - **One staged atomic task = one commit = one PR/MR.** Never bundle; never open from the default branch.
66
+ - **The PR targets the repo's default branch** unless `--base` says otherwise; never assume `main`.
62
67
  - **No AI footer by default.** The wrapped commit writes a `Co-Authored-By` trailer ONLY when `--ai <id>`
63
68
  is explicitly passed; never add it on the AI's own initiative.
64
69
  - **No PR without a landed commit.** A failed/`--dry-run` commit stops the step before pushing.
@@ -38,9 +38,15 @@ current-truth map, and any open reconcile debt. **STOP** and report if the linea
38
38
 
39
39
  ### Step 2 — Read each node's evolution facts
40
40
  For each epic in the chain read `epic.md` (lineage + the change brief), `.sdlc/change.json` (depth,
41
- defect block), `.sdlc/build-log.json` (ship events), and the contract-lock (a real lock = a re-lock
41
+ defect block), the build ledger (ship events), and the contract-lock (a real lock = a re-lock
42
42
  event; a pointer-lock = inherited). Greenfield-safe: an absent input degrades its part of the view.
43
43
 
44
+ The build ledger is **shard-then-fold**, so read it as the **union** of the folded `.sdlc/build-log.json`
45
+ `ships` PLUS every loose `.sdlc/build-log/` shard, deduped by `(story, task, repo)` — a shard WINS over a
46
+ folded ship of the same key. Reading `build-log.json` alone silently drops every ship not yet folded by
47
+ `yad tidy up` (including every `yad checkpoint --retro-ship` backfill, and every ship on a story still at
48
+ `in-build`, which `tidy up` never folds). See `../yad-engineer-review/references/ship-and-record.md`.
49
+
44
50
  ### Step 3 — Render the evolution view (yad-docs shell)
45
51
  Generate the site into `epics/<thread>/timeline-site/` (copy the shell verbatim; generate `src/data/*.ts`
46
52
  deterministically; theme from the design system). The thread maps onto the shell primitives: