yadflow 3.12.2 → 3.13.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/cli/gate.mjs CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
17
17
  import {
18
18
  readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
19
- getPrBody, editPrBody, postComment,
19
+ getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
20
20
  } from './platform.mjs';
21
21
  import { isNoBlock, upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
22
22
  import { sequenceDiff } from './walkthrough.mjs';
@@ -127,32 +127,54 @@ const requireEngagement = (hub) => !!(hub && (hub.review?.requireEngagement ===
127
127
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
128
128
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
129
129
  // makes "revoke only when the artifact changed" work.
130
- function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
130
+ // `closed`: the step already advanced. Drop-and-re-add is what makes a dismissal or revocation vanish
131
+ // idempotently on an OPEN step — the platform is the live source of truth there. On a CLOSED step it
132
+ // is destructive instead: the gate passed, and the approvals that passed it are the audit record of
133
+ // why. A roster edit, a GitLab approval reset, or any degraded-but-`ok` read yields an empty `recs`
134
+ // and would erase them, leaving `done` with zero approvals — the very state issue #156 is about,
135
+ // reached from the other side. So a closed step's record is only ever added to or refreshed in place.
136
+ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNumber = null, closed = false }) {
131
137
  const keyOf = (name, role, domain) => `${stepId}|${name}|${role}|${domain || ''}`;
132
138
  const prior = new Map(
133
139
  approvals.filter((a) => a.step === stepId && a.source === 'bridge')
134
140
  .map((a) => [keyOf(a.approver, a.role, a.domain), a]),
135
141
  );
136
- const kept = approvals.filter((a) => !(a.step === stepId && a.source === 'bridge'));
142
+ const seen = new Set(recs.map((r) => keyOf(r.name, r.role, r.domain)));
143
+ const kept = approvals.filter((a) => {
144
+ if (!(a.step === stepId && a.source === 'bridge')) return true;
145
+ // Closed step: keep a prior approval the platform no longer reports. It is history, not state.
146
+ return closed && !seen.has(keyOf(a.approver, a.role, a.domain));
147
+ });
137
148
  for (const r of recs) {
138
149
  const was = prior.get(keyOf(r.name, r.role, r.domain));
139
150
  let artHash = curHash; // first time we see this approval => bind to current content
140
151
  let approvedAt = r.submittedAt || today;
152
+ let recordedOn = today;
141
153
  if (was) {
142
- // We only adopt the new hash when the platform PROVES a genuinely newer review (a later
143
- // submittedAt). Otherwisesame review, or a platform that gives no timestamp (GitLab) — we
144
- // KEEP the hash they originally approved, so a later artifact change still revokes the approval.
145
- const genuinelyNewer = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
146
- if (!genuinelyNewer) {
154
+ // We only adopt the new hash when the platform PROVES a genuinely newer review. Otherwise
155
+ // the same review read again we KEEP the hash they originally approved, so a later artifact
156
+ // change still revokes the approval. Two independent proofs, because one platform lacks each:
157
+ // - a later submittedAt (GitHub; GitLab approvals carry no timestamp at all), or
158
+ // - a DIFFERENT PR/MR than the one this approval was recorded against. A re-opened review is
159
+ // always a new PR, so an approval arriving on it cannot be the old one read again. Without
160
+ // this, a GitLab re-review after a re-lock re-recorded the pre-edit hash and stayed
161
+ // permanently stale — the step read `done` with zero live approvals (issue #156).
162
+ const newerReview = r.submittedAt && was.approvedAt && r.submittedAt > was.approvedAt;
163
+ const newerPr = prNumber != null && was.pr != null && was.pr !== prNumber;
164
+ if (!newerReview && !newerPr) {
147
165
  artHash = was.artifactHash ?? curHash;
148
166
  approvedAt = was.approvedAt ?? approvedAt;
167
+ // Same review, re-read: keep the date it was RECORDED too, so re-syncing an unchanged
168
+ // approval is a byte-identical no-op instead of a daily one-line ledger commit.
169
+ recordedOn = was.date ?? recordedOn;
149
170
  }
150
171
  }
151
172
  kept.push({
152
173
  artifact, step: stepId, approver: r.name, role: r.role,
153
174
  ...(r.domain ? { domain: r.domain } : {}),
154
- status: 'approved', date: today, source: 'bridge',
175
+ status: 'approved', date: recordedOn, source: 'bridge',
155
176
  artifactHash: artHash, approvedAt,
177
+ ...(prNumber != null ? { pr: prNumber } : {}),
156
178
  engagement: r.engagement === 'verified' ? 'verified' : 'none',
157
179
  ...(r.unverified ? { unverified: true } : {}),
158
180
  });
@@ -160,6 +182,20 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today }) {
160
182
  return kept;
161
183
  }
162
184
 
185
+ // Mutates in place, returns how many it stamped. Backfill `pr` on this step's bridge approvals that
186
+ // predate approvals recording which PR they arrived on. `prNumber` must be the pointer they were
187
+ // recorded against — callers stamp only at the moment that pointer is about to be replaced, so nothing
188
+ // is invented: it is exactly the PR those approvals came from.
189
+ export function stampLegacyPr(approvals, stepId, prNumber) {
190
+ let n = 0;
191
+ for (const a of approvals) {
192
+ if (a.step !== stepId || a.source !== 'bridge' || a.pr != null) continue;
193
+ a.pr = prNumber;
194
+ n++;
195
+ }
196
+ return n;
197
+ }
198
+
163
199
  function writeComments(epicDir, base, today, blocking) {
164
200
  if (!blocking.length) return;
165
201
  const file = path.join(epicDir, 'reviews', `${base}--${today}--comments.md`);
@@ -192,7 +228,57 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
192
228
 
193
229
  // ---- actions ------------------------------------------------------------------------------------
194
230
 
195
- export async function gateSync(root, { epic, artifact, today, reader = readPr, local = false, dryRun = false } = {}) {
231
+ // The review PR/MR(s) to sync. Normally the ledger's own pointer but under the bridge the ledger
232
+ // records that pointer only at merge (CI is the sole writer), so a review a human needs to push
233
+ // through by hand has NO recorded pointer at all. Fall back to the PR number the caller named
234
+ // (`--pr`), else resolve it from the review branch on the platform. Without this, `gate sync` reported
235
+ // "no open review PR recorded" for a PR sitting merged on the platform and the advance was
236
+ // unreachable by hand (issue #158).
237
+ // `--pr` is a recovery flag, so an explicit one WINS over the recorded pointer (a re-opened review is a
238
+ // new PR the ledger has not seen). It is also the one number a human types, so it is checked before it
239
+ // can bind approvals: it must be a positive integer, and — when the platform can be asked — it must be
240
+ // the PR for this artifact's review branch. Without that confirmation a typo'd number naming some
241
+ // unrelated merged-and-approved PR would have its reviewers bound to this artifact's hash and satisfy
242
+ // the gate.
243
+ function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd }) {
244
+ const recorded = hubPrs.filter((p) => !artifact || p.artifact === artifact);
245
+ const named = number == null || number === '' ? null : Number(number);
246
+ if (named !== null && (!Number.isInteger(named) || named <= 0)) {
247
+ return { targets: [], discovered: false, reason: `--pr must be a positive integer, got '${number}'` };
248
+ }
249
+ if (named === null && recorded.length) return { targets: recorded, discovered: false };
250
+ if (!artifact) return { targets: [], discovered: false, reason: 'name the artifact to resolve its review PR' };
251
+ const step = findReviewStep(state, artifact);
252
+ if (!step) return { targets: [], discovered: false, reason: `no review step for ${artifact}` };
253
+ const branch = `review/${epic}/${base(artifact)}`;
254
+ // `upsertHubPr` replaces the whole entry for an artifact, so a record built from scratch DROPS
255
+ // whatever the recorded one carried. That matters when `--pr` names the PR already on file: `nudged`
256
+ // is the idempotency set for the engagement nudge, so losing it makes the next writer run
257
+ // re-@-mention every bare approver on the PR — a platform write, not just a ledger one — and `url`
258
+ // would churn to null. Carry the recorded entry forward whenever the number is the same one.
259
+ const entry = (n, url) => {
260
+ const prev = recorded.find((p) => p.number === n) || {};
261
+ return [{ ...prev, step: step.id, artifact, platform, number: n, url: url ?? prev.url ?? null, branch, lastSyncedAt: prev.lastSyncedAt ?? null }];
262
+ };
263
+ if (named !== null) {
264
+ // Confirm the number names THIS artifact's review before its reviewers are bound to this
265
+ // artifact's hash. A platform that cannot answer (no CLI, no auth, offline) is not evidence
266
+ // against it — warn and take the human at their word — but a definite mismatch is refused.
267
+ const head = branchOf(platform, named, { cwd });
268
+ if (head.ok && head.branch !== branch) {
269
+ return { targets: [], discovered: false, reason: `#${named} is on '${head.branch}', not this artifact's review branch '${branch}'` };
270
+ }
271
+ if (!head.ok) warn(`could not confirm #${named} belongs to ${branch} (${head.reason}) — using it as given`);
272
+ if (recorded.length && recorded[0].number !== named) info(`--pr #${named} overrides the recorded review PR #${recorded[0].number}`);
273
+ return { targets: entry(named, null), discovered: true };
274
+ }
275
+ const found = finder(platform, branch, { cwd });
276
+ if (!found.ok) return { targets: [], discovered: false, reason: found.reason };
277
+ info(`no recorded review PR — resolved #${found.number}${found.state ? ` (${found.state})` : ''} from ${branch}`);
278
+ return { targets: entry(found.number, found.url), discovered: true };
279
+ }
280
+
281
+ export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false } = {}) {
196
282
  const { hub, repos } = loadHub(root);
197
283
  if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
198
284
  const platform = hub.platform;
@@ -211,17 +297,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
211
297
  if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
212
298
 
213
299
  let { approvals, comments, hubPrs, state } = ledger;
214
- const targets = hubPrs.filter((p) => !artifact || p.artifact === artifact);
215
- if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`yad gate open\` first)`); return { synced: 0 }; }
300
+ // Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
301
+ // so it can never be told apart from one arriving on a replacement PR and on GitLab, with no
302
+ // submittedAt either, the other proof is unavailable too. The pointer recorded here IS the PR those
303
+ // approvals came from, so stamp them before anything replaces it.
304
+ for (const p of hubPrs) {
305
+ const s = p.number != null ? findReviewStep(state, p.artifact) : null;
306
+ if (s) stampLegacyPr(approvals, s.id, p.number);
307
+ }
308
+ const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
309
+ const targets = resolved.targets;
310
+ if (!targets.length) {
311
+ warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
312
+ hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
313
+ return { synced: 0 };
314
+ }
315
+ // A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
316
+ // bridge mode this run is advisory and writes nothing, so the human never ends up with a gate-state
317
+ // file in their working tree for the ledger-guard check to reject.
318
+ if (resolved.discovered && !readOnly) hubPrs = upsertHubPr(hubPrs, targets[0]);
216
319
 
217
320
  let synced = 0;
218
321
  let advanced = 0;
322
+ // Targets whose step is still open. The dated approval-roster file is regenerated only for these —
323
+ // an already-done step is re-synced for its approvals alone, and would otherwise drop a new
324
+ // reviews/<artifact>--<today>--approved.md every time the scheduled sweep re-visits it.
325
+ const open = [];
219
326
  for (const pr of targets) {
220
327
  const step = findReviewStep(state, pr.artifact);
221
328
  if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
222
- // Already advanced: a re-sync must not re-run advance (it would reset the next step's status /
223
- // currentStep backward). The gate is one-way per step.
224
- if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done skipping`); continue; }
329
+ // A step that already advanced is never advanced AGAIN (that would reset the next step's status /
330
+ // currentStep backward) the gate is one-way per step. But it is still SYNCED: in bridge mode
331
+ // nothing ever moves a step back to in_review (CI is the sole ledger writer), so a re-opened
332
+ // review — surface re-locked, fresh PR, fresh approvals, merged — used to hit a blanket skip here
333
+ // and write nothing but the PR pointer. The step then read `done` while its approvals were all
334
+ // stale: work proceeded on an audit trail saying the re-review never happened (issue #156).
335
+ const alreadyDone = step.status === 'done';
225
336
  const domains = touchedDomains(epicDir, step);
226
337
  const pull = reader(platform, pr.number, { cwd: root });
227
338
  // A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
@@ -231,8 +342,9 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
231
342
  const curHash = artifactHash(epicDir, pr.artifact);
232
343
  warnUnlockedContract(epicDir, pr.artifact);
233
344
  warnIncompleteDiscovery(epicDir, pr.artifact);
345
+ const approvalsBefore = JSON.stringify(approvals);
234
346
  const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
235
- approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
347
+ approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today, prNumber: pr.number ?? null, closed: alreadyDone });
236
348
 
237
349
  const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
238
350
  // 2f: companion scaffolding + nudge threads carry the noblock marker and are EXCLUDED from the
@@ -245,17 +357,27 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
245
357
  ...unresolved,
246
358
  ];
247
359
  // Advisory (read-only) sync must not touch the working tree — defer the reviews/*.md write.
248
- if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
249
- comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
360
+ //
361
+ // An already-done step is re-synced for its APPROVALS ONLY. Everything else here is per-round
362
+ // bookkeeping for a review still in flight, and re-running it on a closed one is pure churn: both
363
+ // wired sweeps drive `gate ci --branch … --merged` (event mode) over a 7-day window, so a merged
364
+ // review that still carries one unresolved thread would append a fresh comment round — and a fresh
365
+ // `chore(gate): advance … [skip ci]` commit on the default branch — every 15 minutes for a week.
366
+ // That is the same churn the resource_group fix exists to stop, so it must not be reintroduced here.
367
+ if (!alreadyDone) {
368
+ if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
369
+ comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
370
+ }
250
371
 
251
372
  // Social nudge: a bare APPROVE (no verified engagement) still counts (soft default), but the bot
252
373
  // posts a friendly public @-mention inviting the reviewer to run the companion. Idempotent via
253
- // pr.nudged; only on the writer path (a platform comment, not a ledger write).
254
- if (!readOnly) {
374
+ // pr.nudged; only on the writer path (a platform comment, not a ledger write) — and never on a
375
+ // closed step, where it would @-mention reviewers on an already-merged PR.
376
+ if (!readOnly && !alreadyDone) {
255
377
  const nudged = new Set(pr.nudged || []);
256
378
  for (const rv of pull.reviews) {
257
379
  if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login || nudged.has(rv.login)) continue;
258
- if (postComment(platform, pr.number, nudgeMessage(rv.login), { cwd: root }).ok) nudged.add(rv.login);
380
+ if (poster(platform, pr.number, nudgeMessage(rv.login), { cwd: root }).ok) nudged.add(rv.login);
259
381
  }
260
382
  pr.nudged = [...nudged];
261
383
  }
@@ -266,7 +388,15 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
266
388
  });
267
389
 
268
390
  log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
269
- if (pred.passed) {
391
+ if (alreadyDone) {
392
+ // The step keeps its `done` status and the chain is untouched — re-advancing would reset the
393
+ // next step, and moving it back to in_review would un-ship work already built on it. What this
394
+ // pass DOES do is record the approvals that arrived, so `gate status` tells the truth about how
395
+ // many of them are live against the current artifact.
396
+ const verdict = pred.passed ? 'the rule still holds' : `the rule no longer holds${pred.staleDropped ? ` (${pred.staleDropped} stale)` : ''}`;
397
+ info(`${step.id} already done — approvals re-synced, chain not re-advanced; ${verdict}`);
398
+ for (const m of pred.missing) hand(`recorded gap: ${m}`);
399
+ } else if (pred.passed) {
270
400
  state = advanceState(state, step);
271
401
  advanced++;
272
402
  ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
@@ -274,7 +404,12 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
274
404
  state = markInReview(state, step);
275
405
  for (const m of pred.missing) hand(`still needed: ${m}`);
276
406
  }
277
- pr.lastSyncedAt = today;
407
+ // Stamp when this run actually learned something: an open step every time, and a closed one only
408
+ // when the approval record genuinely changed (a re-opened review that was re-approved). Otherwise
409
+ // an identical re-sync would rewrite the date daily and churn the ledger, while a real re-review
410
+ // would leave no trace of when it was reconciled.
411
+ if (!alreadyDone) { pr.lastSyncedAt = today; open.push(pr); }
412
+ else if (JSON.stringify(approvals) !== approvalsBefore) pr.lastSyncedAt = today;
278
413
  synced++;
279
414
  }
280
415
 
@@ -286,7 +421,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, l
286
421
  writeJSON(ledger.files.comments, comments);
287
422
  writeJSON(ledger.files.hubPrs, hubPrs);
288
423
  writeJSON(ledger.files.state, state);
289
- refreshRoster(epicDir, targets, approvals, today);
424
+ refreshRoster(epicDir, open, approvals, today);
290
425
  return { synced, advanced };
291
426
  }
292
427
 
@@ -370,6 +505,16 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
370
505
  // build the entry from the event itself so the advance commit carries it onto the default branch.
371
506
  const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
372
507
  const number = Number(job.pr) || existing?.number || null;
508
+ // Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
509
+ // recorded before replacing it, or a re-review on the replacement PR can never be told from a
510
+ // re-read of the old one and stays permanently stale.
511
+ if (existing?.number != null && number !== existing.number) {
512
+ const stamped = stampLegacyPr(ledger.approvals, step.id, existing.number);
513
+ if (stamped) {
514
+ writeJSON(ledger.files.approvals, ledger.approvals);
515
+ info(`${job.epic}: recorded PR #${existing.number} on ${stamped} approval(s) that predate PR provenance`);
516
+ }
517
+ }
373
518
  if (!existing || existing.number !== number || existing.branch !== job.branch) {
374
519
  ledger.hubPrs = upsertHubPr(ledger.hubPrs, {
375
520
  step: step.id, artifact: job.artifact, platform: hub.platform, number,
@@ -555,7 +700,7 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
555
700
  // the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
556
701
  // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
557
702
  // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
558
- export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
703
+ export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists } = {}) {
559
704
  const { hub, repos } = loadHub(root);
560
705
  const epicDir = epicRoot(root, epic);
561
706
  const ledger = loadLedger(epicDir);
@@ -570,6 +715,27 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr
570
715
  warnIncompleteDiscovery(epicDir, artifact);
571
716
 
572
717
  const bridge = isBridge(hub);
718
+ // The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
719
+ // pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
720
+ // local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
721
+ // `open-pr` pushes the checked-out branch first and passes it as `head`, so that path is unaffected;
722
+ // only the branch this command COMPUTED is checked. A null answer means git could not be asked (no
723
+ // checkout, unreachable origin) — not evidence of absence, so it warns rather than blocks.
724
+ //
725
+ // Checked BEFORE any state is written: marking the step in_review and then refusing would leave the
726
+ // ledger claiming a review is open that was never opened.
727
+ if (!head && hub?.platform) {
728
+ const present = hasBranch(root, branch);
729
+ if (present === false) {
730
+ fail(`review branch '${branch}' is not on origin`);
731
+ hand(`git push -u origin ${branch}`);
732
+ hand('or run `yad open-pr` from the branch — it pushes, then opens the review PR');
733
+ process.exitCode = 1;
734
+ return;
735
+ }
736
+ if (present === null) warn(`could not verify that '${branch}' is on origin — opening the PR against it anyway`);
737
+ }
738
+
573
739
  // Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
574
740
  // ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
575
741
  if (!bridge) {
package/cli/ledger.mjs CHANGED
@@ -79,6 +79,32 @@ export function readShips(epicDir) {
79
79
  return [...byKey.values()];
80
80
  }
81
81
 
82
+ // Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the back-half
83
+ // ledger existed, so it has no build-log ship and `yad checkpoint` can't carry its `status:` flip
84
+ // (issue #142). Writes ONE minimal ship shard, marked `retroactive: true`, so `readShips` now proves
85
+ // the story shipped and checkpoint carries the human's already-made flip. It is NOT a fabricated real
86
+ // ship: `task` defaults to the sentinel `retro`, `mergeCommit` is written only when the caller supplies
87
+ // it (never invented), and `shippedAt` is the backfill date (the `retroactive` flag marks it as such).
88
+ //
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 }) {
93
+ if (!story) throw new Error('writeRetroShip: story is required');
94
+ 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 };
106
+ }
107
+
82
108
  // Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
83
109
  // folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
84
110
  // { found, where, file, ship }; found:false writes nothing (the caller warns).
package/cli/platform.mjs CHANGED
@@ -298,6 +298,86 @@ export function readPr(platform, n, opts = {}) {
298
298
  return platform === 'gitlab' ? readPrGitLab(n, opts) : readPrGitHub(n, opts);
299
299
  }
300
300
 
301
+ // ---- find the PR/MR for a branch ----------------------------------------------------------------
302
+ // The review PR/MR opened for `review/EP-<slug>/<artifact>`, by HEAD/source branch. Under the bridge
303
+ // the ledger records that pointer only at merge (CI is the sole writer), so without this a human has
304
+ // no way to name the review a merged PR belongs to — `gate sync` would just report "no open review PR
305
+ // recorded" for a PR that is sitting merged on the platform (issue #158).
306
+ //
307
+ // State is deliberately UNfiltered: the interesting case is a MERGED PR that never advanced. Newest
308
+ // first, so a re-opened review resolves to its current PR and not a superseded one. Returns
309
+ // { ok, number, url } — never throws; `ok:false` carries the reason.
310
+ export function findPrForBranch(platform, branch, { cwd } = {}) {
311
+ if (!branch) return { ok: false, reason: 'no branch given' };
312
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
313
+ if (platform === 'gitlab') {
314
+ const r = run('glab', ['api', `projects/:id/merge_requests?source_branch=${encodeURIComponent(branch)}&order_by=updated_at&sort=desc&per_page=1`], { cwd });
315
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api merge_requests failed' };
316
+ let rows;
317
+ try { rows = JSON.parse(r.stdout); } catch { return { ok: false, reason: 'unreadable glab api response' }; }
318
+ const mr = Array.isArray(rows) ? rows[0] : null;
319
+ if (!mr?.iid) return { ok: false, reason: `no merge request found for source branch ${branch}` };
320
+ return { ok: true, number: Number(mr.iid), url: mr.web_url || null };
321
+ }
322
+ const r = run('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,url'], { cwd });
323
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh pr list failed' };
324
+ let rows;
325
+ try { rows = JSON.parse(r.stdout); } catch { return { ok: false, reason: 'unreadable gh pr list response' }; }
326
+ const pr = Array.isArray(rows) ? rows[0] : null;
327
+ if (!pr?.number) return { ok: false, reason: `no pull request found for head branch ${branch}` };
328
+ return { ok: true, number: Number(pr.number), url: pr.url || null };
329
+ }
330
+
331
+ // The head/source branch of a PR/MR, so a caller can confirm a number a human typed actually belongs
332
+ // to the review it is about to bind approvals to. Returns { ok, branch }; never throws.
333
+ export function prBranch(platform, n, { cwd } = {}) {
334
+ if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
335
+ if (platform === 'gitlab') {
336
+ const r = run('glab', ['api', `projects/:id/merge_requests/${Number(n)}`], { cwd });
337
+ if (!r.ok) return { ok: false, reason: r.stderr || 'glab api merge_request failed' };
338
+ try {
339
+ const mr = JSON.parse(r.stdout);
340
+ return mr?.source_branch ? { ok: true, branch: mr.source_branch } : { ok: false, reason: `MR !${n} has no source_branch` };
341
+ } catch { return { ok: false, reason: 'unreadable glab api response' }; }
342
+ }
343
+ const r = run('gh', ['pr', 'view', String(n), '--json', 'headRefName'], { cwd });
344
+ if (!r.ok) return { ok: false, reason: r.stderr || 'gh pr view failed' };
345
+ try {
346
+ const pr = JSON.parse(r.stdout);
347
+ return pr?.headRefName ? { ok: true, branch: pr.headRefName } : { ok: false, reason: `PR #${n} has no headRefName` };
348
+ } catch { return { ok: false, reason: 'unreadable gh pr view response' }; }
349
+ }
350
+
351
+ // Is `branch` on ORIGIN? `gate open` opens a PR against the review branch but never creates or pushes
352
+ // it — and neither does the platform CLI, since `gh pr create --head <b>` explicitly disables its
353
+ // automatic push. So a branch that exists only locally is just as unusable as one that does not exist
354
+ // at all, and checking locally would wave it through into the opaque platform error this replaces.
355
+ // Returns null when git cannot answer (not a checkout, no origin, network/auth failure) — "unknown"
356
+ // must never read as "missing" and block.
357
+ export function branchExists(cwd, branch) {
358
+ if (!run('git', ['rev-parse', '--git-dir'], { cwd }).ok) return null;
359
+ // This runs synchronously on the `gate open` path, so "cannot ask" has to be FAST — a blocked probe
360
+ // is a hung command, not the intended null. Three separate ways it could block:
361
+ // GIT_TERMINAL_PROMPT=0 — git's own credential prompt (https origins)
362
+ // GIT_SSH_COMMAND — ssh's passphrase / host-key prompts, which git's flag does NOT cover
363
+ // (an unset host key otherwise waits on "Are you sure…?" forever)
364
+ // timeout — anything else that stalls: a black-holed host, a wedged helper
365
+ // A caller's own GIT_SSH_COMMAND wins; we only supply the default.
366
+ const remote = run('git', ['ls-remote', '--exit-code', '--heads', 'origin', branch], {
367
+ cwd,
368
+ timeout: 10_000,
369
+ env: {
370
+ ...process.env,
371
+ GIT_TERMINAL_PROMPT: '0',
372
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
373
+ },
374
+ });
375
+ if (remote.ok) return true;
376
+ // exit 2 is ls-remote's own "no matching ref" — the only definite negative. Anything else (no
377
+ // remote configured, auth, offline) is a question we could not ask.
378
+ return remote.code === 2 ? false : null;
379
+ }
380
+
301
381
  // ---- create a PR/MR -----------------------------------------------------------------------------
302
382
  // `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
303
383
  // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.12.2",
3
+ "version": "3.13.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",
@@ -62,8 +62,8 @@
62
62
  ],
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.1",
65
- "@semantic-release/changelog": "^6.0.3",
66
- "@semantic-release/git": "^10.0.1",
65
+ "@semantic-release/changelog": "^7.0.0",
66
+ "@semantic-release/git": "^11.0.1",
67
67
  "eslint": "^10.5.0",
68
68
  "semantic-release": "^25.0.3"
69
69
  }
@@ -155,16 +155,28 @@ themselves) and write `{project-root}/epics/EP-<slug>/.sdlc/contract-lock.json`:
155
155
  ```
156
156
 
157
157
  Canonicalization (so the hash round-trips): hash the surface region as written between the markers,
158
- LF line endings, no leading/trailing blank-line normalization beyond what is in the file. Recompute
159
- the same way later; if it differs, the contract surface changed. The reference command:
158
+ LF line endings, including the newline that terminates the last surface line, and no leading/trailing
159
+ blank-line normalization beyond what is in the file. Recompute the same way later; if it differs, the
160
+ contract surface changed. The command below **is** the definition — `yad` computes the identical
161
+ digest, so `yad doctor` can verify `contract-lock.json` against the live `contract.md` and FAIL when
162
+ the surface drifted from its lock:
160
163
 
161
164
  ```bash
162
165
  awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
163
- epics/EP-<slug>/contract.md | shasum -a 256
166
+ epics/EP-<slug>/contract.md | tr -d '\r' | shasum -a 256
164
167
  ```
165
168
 
166
169
  (See `references/contract-format.md` for the altitude rule and the exact hashing recipe.)
167
170
 
171
+ > **Upgrading from a yadflow before this recipe and the CLI agreed.** The CLI used to omit the final
172
+ > newline, so the digest it bound approvals to differed from the one the recipe above wrote into
173
+ > `contract-lock.json` — always, on every surface. Lock files are unaffected (they were written by the
174
+ > recipe and are now verifiable), but **architecture approvals recorded under the old CLI are bound to
175
+ > the old digest and go stale once**. An in-flight `architecture-review` therefore needs re-approval
176
+ > after the upgrade; a step already `done` stays done and is reported by `yad doctor` (see the
177
+ > `…:stale` check) rather than silently re-opened. Nothing else re-binds on its own — that is the point
178
+ > of hash-binding.
179
+
168
180
  ### Step 6 — Advance the authoring step (NOT the gate)
169
181
  In `state.json`: set `architecture.status: "done"`, set `architecture-review.status: "in_review"`, and
170
182
  set `currentStep: "architecture-review"`. Write `state.json`. Do **not** touch `approvals.json` — only
@@ -49,7 +49,10 @@ awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' \
49
49
  ```
50
50
 
51
51
  - `awk` emits every line strictly between the two markers (the `next` after BEGIN skips the BEGIN
52
- line; setting `f=0` on END stops before printing END).
52
+ line; setting `f=0` on END stops before printing END), each **terminated by a newline** — so the
53
+ hashed bytes are the surface lines joined by LF **plus a trailing LF**. That trailing byte is part
54
+ of the digest; `yad` computes the identical value (`contractSurfaceHash`, `cli/epic-state.mjs`), so
55
+ the lock file and what the gate binds approvals to are the same number.
53
56
  - `tr -d '\r'` normalizes CRLF line endings to LF before hashing — the same surface must hash
54
57
  identically no matter which platform last saved the file (the CLI normalizes the same way).
55
58
  - `shasum -a 256` (BSD/macOS) or `sha256sum` (GNU/Linux) produce the same hex digest for identical
@@ -26,6 +26,10 @@ repo uses. Each reads conventions established by earlier steps — it invents no
26
26
  - Maintenance commits are **exempt**: a Conventional-Commits subject of type `ci`, `chore`, `build`,
27
27
  or `test` (optional `(scope)` / breaking `!`) **PASSes** without a link — CI wiring, dependency
28
28
  bumps, and test-infra changes legitimately link no story.
29
+ - The exemption waives the **requirement** for a link, never the **validity** of one that is claimed.
30
+ A maintenance commit that *does* carry a `Task:` trailer is resolved like any other: a malformed id
31
+ or a missing `specs/<story>/link.md` **FAILS**. Otherwise the trailer is decorative on exempt
32
+ commits and an unlinked `chore:` is indistinguishable from one naming a story that never existed.
29
33
  - For every other commit, requires a `Task: <story>-<task>` trailer. **FAIL** if absent.
30
34
  - The trailer must be a well-formed `<story>-T<NN>` id. **FAIL** on a malformed trailer (e.g.
31
35
  `EP-demo-S01` with no `-T<NN>`) rather than letting it slip through the suffix-strip.
@@ -168,10 +172,23 @@ After the contract locks and code ships, a change must not mutate a locked artif
168
172
  epic threaded to its parent (`config.yaml` `change:`). These three gates keep that discipline. All three
169
173
  resolve the owning epic the same way: `Task:` trailer → `specs/<story>/link.md` (`epic` + `product-repo`)
170
174
  → the hub epic. All **fail closed** on an unresolvable base; all are **per commit**; `ci|chore|build|test`
171
- commits are exempt. When the **product hub is not reachable** from CI (the usual case for a code-repo
175
+ commits **with no `Task:` trailer** are exempt like spec-link, the exemption waives the requirement
176
+ for an owning epic, never the validity of one that is claimed, so a maintenance subject cannot buy a
177
+ pass past the sealed-epic / orphan-thread / frozen-thread checks. When the **product hub is not reachable** from CI (the usual case for a code-repo
172
178
  PR), each degrades to a **PASS-with-note** — the hub-side check (`yad doctor` / `yad reconcile`) covers
173
179
  that path, and spec-link still proves the story link.
174
180
 
181
+ **Resolving `product-repo` (shared by all four hub-reading gates, contract-check included).** An
182
+ **absolute** value is used as-is; a **relative** value is joined to the `link.md`'s own directory,
183
+ `specs/<story>/`, falling back to a repo-root reading when only that resolves (what contract-check
184
+ historically did, so `link.md` files written for it keep working). The `link.md` itself is read from
185
+ its frontmatter block, falling back to a whole-file scan for a pre-frontmatter one. Every gate applies
186
+ the identical rule — when they disagree, a value one gate can resolve becomes an unreachable path for
187
+ another, and "unreachable" is a PASS-with-note, so the gate silently stops gating (issue #149). Each
188
+ gate now **prints that note**, so a deferred check is never mistaken for a passed one. The block is
189
+ duplicated verbatim across the four scripts (they are standalone by design) and a test asserts the
190
+ four copies stay byte-identical.
191
+
175
192
  - **lineage-check** — reads the hub epic's `kind`/`parent` frontmatter. A `feature` (genesis) epic
176
193
  passes. A `change`/`defect`/`hotfix` epic **FAILS** unless it declares a `parent:` that resolves to a
177
194
  real `epics/<parent>/` in the hub (no orphan threads). This is the "every code change has an owning