yadflow 3.13.0 → 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/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ ## [3.13.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.0...v3.13.1) (2026-07-29)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **checks:** apply the Task-trailer rule to the thread gates too ([18aed8d](https://github.com/abdelrahmannasr/yadflow/commit/18aed8df958718483fe929cea84275ca86ae67be)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
7
+ * **checks:** do not build a /-rooted lock path when product-repo is absent ([0d2a214](https://github.com/abdelrahmannasr/yadflow/commit/0d2a214a30c7fc31ad087fa127277b50637a00de))
8
+ * **checks:** keep product-repo resolution backward-compatible and loud ([4c00c92](https://github.com/abdelrahmannasr/yadflow/commit/4c00c928642fbab4517805be14a1c6926f41b84c)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149) [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
9
+ * **checks:** resolve link.md product-repo the same way in every gate ([47c9b30](https://github.com/abdelrahmannasr/yadflow/commit/47c9b30548153ef5be607cf760c3e9ec9e73699f)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
10
+ * **checks:** spec-link resolves a Task trailer even on a maintenance commit ([d24dd83](https://github.com/abdelrahmannasr/yadflow/commit/d24dd83abadc63bb00e8c8d6d7c6008e32344db8)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
11
+ * **doctor:** refuse a decorative contract lock, and report a gate that went stale ([1ee3296](https://github.com/abdelrahmannasr/yadflow/commit/1ee3296dbddfdd308949b8013b3bf678f638fc80)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
12
+ * **gate:** bound the review-branch probe so it can never hang gate open ([afa5416](https://github.com/abdelrahmannasr/yadflow/commit/afa54167efd5879537c4bc19ab29531fe08ed31b))
13
+ * **gate:** hash the contract surface exactly as the documented recipe does ([f29e781](https://github.com/abdelrahmannasr/yadflow/commit/f29e78183b92c7baf33b46dbdfa7a4776aa06e4e)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
14
+ * **gate:** keep the recorded PR entry when --pr names that same PR ([b1febe1](https://github.com/abdelrahmannasr/yadflow/commit/b1febe184278109f0bafc2968648da362d68483a))
15
+ * **gate:** make a merged review PR reachable by hand ([4ccbd17](https://github.com/abdelrahmannasr/yadflow/commit/4ccbd1736640935175d63bac15a01a94e381b44e)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
16
+ * **gate:** never drop a done step's approval record on re-sync ([1a5e434](https://github.com/abdelrahmannasr/yadflow/commit/1a5e4348d00edd4e775a735a49fc0a86a3f0e391)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
17
+ * **gate:** re-bind approvals recorded before PR provenance existed ([6b7e816](https://github.com/abdelrahmannasr/yadflow/commit/6b7e816638a329d9365c6e727b2c12debdbd24aa)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156) [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
18
+ * **gate:** re-sync a re-opened review so its approvals re-bind ([28eabd2](https://github.com/abdelrahmannasr/yadflow/commit/28eabd222214ad2af7d18ef20c56795c9eb171d1)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
19
+ * **gate:** require the review branch on origin, and check before writing state ([27f0d92](https://github.com/abdelrahmannasr/yadflow/commit/27f0d92ce680c26df6ea7ff6510879a11f05d00f)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
20
+ * **gate:** stop a done-step re-sync from churning the ledger ([68462a6](https://github.com/abdelrahmannasr/yadflow/commit/68462a622753c0d30b458d22d9e1ad890ad8e61f)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
21
+ * **gate:** validate --pr and confirm it names this artifact's review ([f5d7773](https://github.com/abdelrahmannasr/yadflow/commit/f5d7773da474784a43cc3e24298da94c8f38bbdb)), closes [#7](https://github.com/abdelrahmannasr/yadflow/issues/7) [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
22
+ * **hub-bridge:** serialize the GitLab gate-sync job ([4294979](https://github.com/abdelrahmannasr/yadflow/commit/4294979f80cb4491d074fe27e74f9af454a2f62a)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
23
+
1
24
  # [3.13.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.2...v3.13.0) (2026-07-14)
2
25
 
3
26
 
package/bin/yad.mjs CHANGED
@@ -74,8 +74,13 @@ ${c.bold('Where am I / what next')}
74
74
  --undo reverses it until the stories review opens
75
75
 
76
76
  ${c.bold('Review gate (front half)')}
77
- yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
78
- yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
77
+ yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review. The review
78
+ branch must already be on origin (it is never created here)
79
+ yad gate sync <epic> [artifact] [--pr <n>]
80
+ Pull PR state -> ledger; advance on approved+resolved+merged.
81
+ With no recorded PR, resolves it from the review branch; --pr
82
+ names one (and overrides a stale recorded pointer). Advisory
83
+ in bridge mode — there, recover with 'yad gate ci' below
79
84
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
80
85
  yad gate status <epic> Show each review step + approvals
81
86
  yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
@@ -272,7 +277,7 @@ async function main() {
272
277
  // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
273
278
  // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
274
279
  if (action === 'open') await gateOpen(o.dir, { epic, artifact });
275
- else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
280
+ else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
276
281
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
277
282
  else if (action === 'status') await gateStatus(o.dir, { epic });
278
283
  else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
package/cli/doctor.mjs CHANGED
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
8
8
  import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
9
- import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
9
+ import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
10
10
  import { loadDebt } from './thread.mjs';
11
11
  import { gitHead, insideWorkspace } from './setup.mjs';
12
12
  import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
@@ -280,6 +280,116 @@ export function ciTagsChecks(checks, root, hub, registry) {
280
280
  }
281
281
  }
282
282
 
283
+ // Is `.sdlc/contract-lock.json` still the hash of the surface it claims to lock? The lock is what the
284
+ // spec pins and what contract-check compares a code repo's slice against, but nothing ever verified it
285
+ // against the live contract.md — so a surface edited without a re-lock (or locked with a different
286
+ // recipe) read as "locked" while binding nothing. FAIL on a mismatch: a decorative lock is worse than
287
+ // none, because everyone downstream treats it as proof.
288
+ //
289
+ // Two shapes (yad-change references/triage.md): a SURFACE lock, verified against this epic's own
290
+ // contract.md, and a POINTER lock — a change-epic that inherited architecture, which carries no
291
+ // contract.md at all and instead copies the parent's hash verbatim. Its integrity property is that the
292
+ // copy still equals what the referenced lock holds, so verify it there.
293
+ function contractLockCheck(checks, root, epic, ledger) {
294
+ const id = `epic:${epic}:contract-lock`;
295
+ const lock = ledger.contractLock;
296
+ const epicDir = epicRoot(root, epic);
297
+ // An epic that has not reached the lock yet simply has no lock file — that is the normal pre-lock
298
+ // state and stays silent. A lock file that EXISTS but carries no usable hash is the opposite: it is
299
+ // the decorative lock this check was added to catch, so it must never read as "not locked yet".
300
+ // `readJSONStrict` yields null both for an absent file and for one holding literal `null`, so ask the
301
+ // filesystem — the second is a malformed lock, not a missing one.
302
+ if (lock === null && !exists(ledger.files.contractLock)) return;
303
+ const stored = typeof lock?.hash === 'string' && /^sha256:[0-9a-f]{64}$/.test(lock.hash) ? lock.hash : null;
304
+ if (!stored) {
305
+ check(checks, id, 'epics', 'fail',
306
+ `${epic}: contract-lock.json exists but carries no usable sha256 hash`,
307
+ 're-lock the surface (yad-architecture Step 5) or delete the file — a lock nobody can verify is worse than none');
308
+ return;
309
+ }
310
+ const short = (h) => `${h.slice(0, 19)}…`;
311
+
312
+ if (lock.inheritedFrom || lock.ref) {
313
+ // The ref is repo-controlled text, so keep it inside this hub's epics/ — a lock file must not be
314
+ // able to point the check at arbitrary JSON elsewhere on disk.
315
+ const epicsDir = path.join(root, 'epics');
316
+ const refPath = path.resolve(path.join(epicDir, '.sdlc'), lock.ref || `../../${lock.inheritedFrom}/.sdlc/contract-lock.json`);
317
+ if (refPath !== epicsDir && !refPath.startsWith(epicsDir + path.sep)) {
318
+ check(checks, id, 'epics', 'fail',
319
+ `${epic}: pointer-lock ref '${lock.ref}' resolves outside epics/`,
320
+ 'a pointer-lock must reference another epic in this hub — fix `ref` (yad-change writes ../../EP-<parent>/.sdlc/contract-lock.json)');
321
+ return;
322
+ }
323
+ const parent = readJSON(refPath, null);
324
+ if (!parent || typeof parent.hash !== 'string') {
325
+ check(checks, id, 'epics', 'fail',
326
+ `${epic}: pointer-lock references ${lock.inheritedFrom || lock.ref}, whose contract-lock.json is missing or has no hash`,
327
+ 're-thread the change-epic (yad-change) so it points at a real parent lock');
328
+ return;
329
+ }
330
+ if (parent.hash !== stored) {
331
+ check(checks, id, 'epics', 'fail',
332
+ `${epic}: pointer-lock pins ${short(stored)} but ${lock.inheritedFrom || 'its parent'} now locks ${short(parent.hash)}`,
333
+ 'the inherited surface was re-locked upstream — re-copy the parent hash, or re-author architecture in this epic');
334
+ return;
335
+ }
336
+ // A pointer-lock epic has no contract.md by construction (the surface physically cannot drift).
337
+ // One that DOES have a contract.md is a change-epic that re-authored architecture but left the
338
+ // inherited fields behind, so verify the live surface as well rather than trusting the pointer.
339
+ if (!exists(path.join(epicDir, 'contract.md'))) {
340
+ check(checks, id, 'epics', 'ok', `${epic}: pointer-lock matches ${lock.inheritedFrom || 'its parent'} (${short(stored)})`);
341
+ return;
342
+ }
343
+ check(checks, `${id}:inherited`, 'epics', 'warn',
344
+ `${epic}: lock is marked inherited from ${lock.inheritedFrom || lock.ref} but this epic has its own contract.md`,
345
+ 're-authored architecture? drop `inheritedFrom`/`ref` and re-lock against this epic\'s surface');
346
+ // and fall through to verify the live surface too
347
+ }
348
+
349
+ if (!exists(path.join(epicDir, 'contract.md'))) {
350
+ check(checks, id, 'epics', 'fail',
351
+ `${epic}: contract-lock.json pins ${short(stored)} but there is no contract.md to lock`,
352
+ 'restore contract.md, or record the lock as inherited (`inheritedFrom` + `ref`) if this epic threads off a parent');
353
+ return;
354
+ }
355
+ const current = contractSurfaceHash(epicDir);
356
+ if (current === null) {
357
+ check(checks, id, 'epics', 'fail',
358
+ `${epic}: contract-lock.json pins ${short(stored)} but contract.md has no readable CONTRACT-SURFACE block`,
359
+ 'restore the BEGIN/END markers around the surface, then re-lock (see yad-architecture Step 5)');
360
+ return;
361
+ }
362
+ if (current !== stored) {
363
+ check(checks, id, 'epics', 'fail',
364
+ `${epic}: contract surface drifted from its lock — contract.md hashes ${short(current)}, contract-lock.json pins ${short(stored)}`,
365
+ 'the surface changed without a re-lock: re-run the yad-architecture Step 5 recipe and re-open the architecture gate');
366
+ return;
367
+ }
368
+ check(checks, id, 'epics', 'ok', `${epic}: contract surface matches its lock (${short(stored)})`);
369
+ }
370
+
371
+ // A review step that is `done` but whose approvals no longer bind to the artifact as it stands today.
372
+ // The gate is deliberately one-way — nothing pulls a chain backward once work is built on it — so the
373
+ // only way this surfaces is if something reports it. `gate sync` records the gap on the step it is
374
+ // syncing; this reports it for the whole epic, so a re-locked surface that was never re-approved is
375
+ // visible in the one command people run when something looks wrong. WARN, not FAIL: the state is a
376
+ // fact about history, and the fix (re-open the review) is a human decision.
377
+ function staleGateCheck(checks, root, epic, ledger) {
378
+ const epicDir = epicRoot(root, epic);
379
+ for (const s of ledger.state.steps) {
380
+ if (s.type !== 'review+approve' || s.status !== 'done' || s.inherited || s.skipped) continue;
381
+ const cur = artifactHash(epicDir, s.artifact);
382
+ if (!cur) continue; // nothing to bind to (no locked surface / incomplete set) — not a staleness claim
383
+ const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
384
+ if (!forStep.length) continue; // solo mode waives approvals entirely; absence is not staleness
385
+ const live = forStep.filter((a) => !a.artifactHash || a.artifactHash === cur);
386
+ if (live.length) continue;
387
+ check(checks, `epic:${epic}:${s.id}:stale`, 'epics', 'warn',
388
+ `${epic}: ${s.id} is done, but all ${forStep.length} approval(s) are bound to an older ${s.artifact}`,
389
+ 'the artifact changed after it was approved — re-open the review (a fresh PR/MR) so the record matches what shipped');
390
+ }
391
+ }
392
+
283
393
  export function epicChecks(checks, root) {
284
394
  const epicsDir = path.join(root, 'epics');
285
395
  if (!exists(epicsDir)) return;
@@ -308,6 +418,8 @@ export function epicChecks(checks, root) {
308
418
  if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
309
419
  `${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
310
420
  'opened under a pre-3.0 yadflow? merge/close it before continuing — CI now records the gate ledger on the default branch only at merge');
421
+ contractLockCheck(checks, root, e, ledger);
422
+ staleGateCheck(checks, root, e, ledger);
311
423
  }
312
424
  } catch (err) {
313
425
  check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
@@ -57,11 +57,19 @@ export function upsertHubPr(hubPrs = [], rec) {
57
57
  return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
58
58
  }
59
59
 
60
- // SHA-256 of the contract surface block (architecture only). Mirrors
61
- // yad-architecture/references/contract-format.md (awk markers + sha256).
62
- // Line endings are normalized to LF so the same surface hashes identically across
63
- // platforms (a CRLF re-save must not revoke approvals). A BEGIN without an END is
64
- // malformed and yields null — never a silent hash of everything to end-of-file.
60
+ // SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
61
+ // yad-architecture/references/contract-format.md publishes the one the architect runs to write
62
+ // contract-lock.json:
63
+ //
64
+ // awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' contract.md \
65
+ // | tr -d '\r' | shasum -a 256
66
+ //
67
+ // Canonicalization, in the order awk applies it: every line strictly between the markers, CRLF
68
+ // normalized to LF (so a CRLF re-save never revokes approvals), joined by LF, and TERMINATED by a
69
+ // trailing LF — awk emits a newline after every line it prints, so that last newline is part of the
70
+ // hashed bytes. Omitting it made the CLI digest and the documented recipe disagree by exactly one
71
+ // byte on every surface, so contract-lock.json could never equal what approvals bind to (issue #156).
72
+ // A BEGIN without an END is malformed and yields null — never a silent hash of everything to EOF.
65
73
  export function contractSurfaceHash(epicDir) {
66
74
  const file = path.join(epicDir, 'contract.md');
67
75
  if (!fs.existsSync(file)) return null;
@@ -75,7 +83,7 @@ export function contractSurfaceHash(epicDir) {
75
83
  if (inside) body.push(ln);
76
84
  }
77
85
  if (!terminated || !body.length) return null;
78
- return 'sha256:' + createHash('sha256').update(body.join('\n')).digest('hex');
86
+ return 'sha256:' + createHash('sha256').update(body.join('\n') + '\n').digest('hex');
79
87
  }
80
88
 
81
89
  // Deterministic fingerprint of the whole stories/ set: hash each story file, sort, combine. Lets an
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) {