yadflow 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
- # [2.14.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.13.0...v2.14.0) (2026-06-21)
1
+ # [2.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.14.0...v2.15.0) (2026-06-24)
2
2
 
3
3
 
4
4
  ### Features
5
5
 
6
- * yad next driver, precondition guards, solo mode, and guided setup interview ([#72](https://github.com/abdelrahmannasr/yadflow/issues/72)) ([7125c9d](https://github.com/abdelrahmannasr/yadflow/commit/7125c9d80043cb282a7be4f08dfaa95ddf94594a))
6
+ * merge-driven review gate (Path B) CI never pushes the review branch ([#78](https://github.com/abdelrahmannasr/yadflow/issues/78)) ([d4d983a](https://github.com/abdelrahmannasr/yadflow/commit/d4d983ab4efddb4e6ec259bb940b393e9237f9cf)), closes [#76](https://github.com/abdelrahmannasr/yadflow/issues/76)
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/bin/yad.mjs CHANGED
@@ -14,6 +14,7 @@ import { runRoster } from '../cli/roster.mjs';
14
14
  import { runDocs } from '../cli/docs.mjs';
15
15
  import { runDoctor } from '../cli/doctor.mjs';
16
16
  import { runNext } from '../cli/next.mjs';
17
+ import { syncStatuses } from '../cli/artifact-status.mjs';
17
18
 
18
19
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
19
20
 
@@ -27,6 +28,8 @@ ${c.bold('Setup & maintenance')}
27
28
  also migrates pre-2.0 sdlc-* installs to the yad-* names
28
29
  yad doctor [--json] Environment + state health: tools/auth, config files,
29
30
  repo paths, epic ledgers (exit 1 on any failure)
31
+ yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
32
+ from .sdlc/state.json — all epics if omitted (--dry-run to preview)
30
33
 
31
34
  ${c.bold('Reviewer roster')}
32
35
  yad roster list Show every member + their roles per scope (hub + each repo)
@@ -47,9 +50,9 @@ ${c.bold('Review gate (front half)')}
47
50
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
48
51
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
49
52
  yad gate status <epic> Show each review step + approvals
50
- yad gate ci [--branch <head>] [--pr <n>]
51
- CI entry (hub workflow): derive epic/artifact from the review branch,
52
- sync, commit the ledger to the default branch (sweep all PRs if no --branch)
53
+ yad gate ci [--branch <head>] [--pr <n>] [--merged]
54
+ CI entry (hub workflow): pre-merge is read-only (nothing pushed);
55
+ --merged advances the step + flips artifact status on the default branch
53
56
 
54
57
  ${c.bold('Build helpers')}
55
58
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
@@ -80,6 +83,7 @@ ${c.bold('Options')}
80
83
  --force commit: bypass the atomic-file guard / re-copy unchanged files
81
84
  --branch <head> gate ci: the review PR/MR head branch (review/EP-<slug>/<artifact>)
82
85
  --pr <n> gate ci: the PR/MR number from the CI event
86
+ --merged gate ci: merge phase — advance the step on the default branch
83
87
  --no-push gate ci: commit the ledger but do not push
84
88
  -h, --help Show this help
85
89
  -v, --version Print version`;
@@ -94,6 +98,7 @@ function parseArgs(argv) {
94
98
  else if (a === '--force') o.force = true;
95
99
  else if (a === '--contract-change') o.contractChange = true;
96
100
  else if (a === '--no-push') o.noPush = true;
101
+ else if (a === '--merged') o.merged = true;
97
102
  else if (a === '--overview') o.overview = true;
98
103
  // `--check` is a bare boolean for `docs sync --check`, but takes a value for
99
104
  // `next <epic> --check <step>`. Only the `next` command consumes the following token as a value —
@@ -153,6 +158,12 @@ async function main() {
153
158
  case 'doctor':
154
159
  await runDoctor(o.dir, { json: o.json });
155
160
  break;
161
+ case 'sync-status': {
162
+ const [, epic] = o._;
163
+ if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
164
+ await syncStatuses(o.dir, { epic, dryRun: o.dryRun });
165
+ break;
166
+ }
156
167
  case 'next': {
157
168
  const [, epic] = o._;
158
169
  // `--check` with no step is a malformed guard call — fail loudly rather than silently print.
@@ -163,12 +174,15 @@ async function main() {
163
174
  case 'gate': {
164
175
  const [, action, epic, artifact] = o._;
165
176
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
166
- if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, push: !o.noPush, today }); break; }
177
+ if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
167
178
  if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|ci> <epic> [artifact]')); process.exitCode = 1; break; }
168
179
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
169
180
  if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
181
+ // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
182
+ // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
183
+ // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
170
184
  if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
171
- else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today });
185
+ else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
172
186
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
173
187
  else if (action === 'status') await gateStatus(o.dir, { epic });
174
188
  else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|ci)`)); process.exitCode = 1; }
@@ -0,0 +1,102 @@
1
+ // `yad sync-status [epic]` — reconcile each artifact's frontmatter `status:` with the real
2
+ // source of truth, the per-epic state machine in .sdlc/state.json. The authoring skills hard-code
3
+ // `status: draft` at creation and never update it, so artifacts read as "draft" long after their
4
+ // gate has passed. This derives draft / in-review / approved from the step statuses and rewrites
5
+ // only the `status:` line — advance-only, and never touching build-owned values.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { c, log, ok, info, readJSONStrict } from './lib.mjs';
9
+ import { epicRoot, artifactBase, artifactFromBase, findReviewStep } from './epic-state.mjs';
10
+ import { epicFiles } from './manifest.mjs';
11
+
12
+ // The front-gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
13
+ // ladder, so a re-run never regresses anything.
14
+ const RANK = { draft: 0, 'in-review': 1, approved: 2 };
15
+
16
+ // Values owned by other parts of the workflow — left untouched. `locked` is the contract surface;
17
+ // `in-build` / `shipped` are set by the build half (engineer-review) per story; `ready-for-build`,
18
+ // `done`, `blocked` are roll-ups/states we must not overwrite from the front-gate view.
19
+ const PRESERVE = new Set(['locked', 'in-build', 'shipped', 'ready-for-build', 'done', 'blocked']);
20
+
21
+ // The per-epic artifact files this command considers (bases). Story files are handled separately
22
+ // because they live under stories/ and all map to the single stories / stories-review step pair.
23
+ const ARTIFACT_FILES = ['analysis.md', 'epic.md', 'architecture.md', 'contract.md', 'ui-design.md', 'test-cases.md'];
24
+
25
+ // The desired front-gate status for an artifact base, derived purely from state.json. Returns null
26
+ // when the chain has no steps for this base (nothing to manage) — e.g. contract has no own step.
27
+ export function desiredStatus(state, base) {
28
+ if (!state?.steps) return null;
29
+ const review = findReviewStep(state, artifactFromBase(base));
30
+ const author = state.steps.find((s) => s.type === 'author' && artifactBase(s.artifact) === base);
31
+ if (!review && !author) return null;
32
+ if (review?.status === 'done') return 'approved';
33
+ if (review?.status === 'in_review' || author?.status === 'done') return 'in-review';
34
+ return 'draft';
35
+ }
36
+
37
+ // Rewrite ONLY the `status:` line inside the first `---\n...\n---` frontmatter block, preserving
38
+ // everything else. Returns the prior status when a change was written, or null for a no-op (no
39
+ // frontmatter, no status line, preserved value, or not an advance). Mirrors the frontmatter regex
40
+ // in gate.mjs.
41
+ export function setFrontmatterStatus(file, status) {
42
+ if (!fs.existsSync(file)) return null;
43
+ const text = fs.readFileSync(file, 'utf8');
44
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
45
+ if (!fm) return null;
46
+ const cur = (fm[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
47
+ if (cur === undefined) return null;
48
+ // Advance-only within the managed ladder; anything else (build-owned, roll-ups) is left as-is.
49
+ if (PRESERVE.has(cur)) return null;
50
+ if (!(cur in RANK) || !(status in RANK) || RANK[status] <= RANK[cur]) return null;
51
+ const block = fm[1].replace(/^status:\s*.*$/m, `status: ${status}`);
52
+ fs.writeFileSync(file, text.replace(fm[1], block));
53
+ return cur;
54
+ }
55
+
56
+ // Sweep one epic (or every epic under epics/) and reconcile artifact frontmatter with state.json.
57
+ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
58
+ const epicsDir = path.join(root, 'epics');
59
+ const epics = epic
60
+ ? [epic]
61
+ : (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
62
+ if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0 }; }
63
+
64
+ let changed = 0;
65
+ for (const e of epics) {
66
+ const dir = epicRoot(root, e);
67
+ const state = readJSONStrict(epicFiles(dir).state, null);
68
+ if (!state?.steps) { info(`${e}: no state.json — skipping`); continue; }
69
+
70
+ // Single-file artifacts + each story file (all keyed to the stories step pair).
71
+ const files = ARTIFACT_FILES.map((f) => ({ base: artifactBase(f), file: path.join(dir, f) }));
72
+ const storiesDir = path.join(dir, 'stories');
73
+ if (fs.existsSync(storiesDir)) {
74
+ for (const f of fs.readdirSync(storiesDir).filter((x) => x.endsWith('.md'))) {
75
+ files.push({ base: 'stories', file: path.join(storiesDir, f) });
76
+ }
77
+ }
78
+
79
+ for (const { base, file } of files) {
80
+ if (!fs.existsSync(file)) continue;
81
+ const want = desiredStatus(state, base);
82
+ if (!want) continue;
83
+ if (dryRun) {
84
+ // Peek without writing so --dry-run reports exactly what would change. Scope the match to the
85
+ // frontmatter block so a `status:` line in the Markdown body can't be mistaken for the value.
86
+ const text = fs.readFileSync(file, 'utf8');
87
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
88
+ const cur = (fm?.[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
89
+ if (cur && !PRESERVE.has(cur) && cur in RANK && RANK[want] > RANK[cur]) {
90
+ log(` ${c.dim('• would update')} ${path.relative(root, file)}: ${cur} → ${want}`);
91
+ changed++;
92
+ }
93
+ continue;
94
+ }
95
+ const prev = setFrontmatterStatus(file, want);
96
+ if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
97
+ }
98
+ }
99
+ if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
100
+ else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
101
+ return { changed };
102
+ }
package/cli/doctor.mjs CHANGED
@@ -244,7 +244,20 @@ export function epicChecks(checks, root) {
244
244
  try {
245
245
  const ledger = loadLedger(epicRoot(root, e));
246
246
  if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`, 'author it via yad-epic, or remove the directory');
247
- else check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
247
+ else {
248
+ check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
249
+ // Migration guard (pre-3.0 model): under the current model CI records the ledger on the
250
+ // default branch only at merge (when the step is already done), and writes nothing during
251
+ // review — so an OPEN (non-done) review PR recorded here means it was opened under an older
252
+ // model. Merge/close it under the version that opened it before relying on the CI flow.
253
+ const openPr = (ledger.hubPrs || []).find((p) => {
254
+ const st = (ledger.state.steps.find((s) => s.id === p.step) || {}).status;
255
+ return st && st !== 'done';
256
+ });
257
+ if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
258
+ `${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
259
+ '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');
260
+ }
248
261
  } catch (err) {
249
262
  check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
250
263
  }
@@ -42,7 +42,7 @@ export function artifactFromBase(base) {
42
42
  }
43
43
 
44
44
  // The files (relative to the epic dir) a review of this artifact covers — what `gate open` commits
45
- // on the review branch, and what the CI overlay checks out from the head ref (and never commits).
45
+ // on the review branch (the owner's artifact), and what CI re-reads to bind the approval at merge.
46
46
  // Architecture mirrors artifactHash(): the approval is bound to the locked contract surface too.
47
47
  export function artifactPaths(base) {
48
48
  if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
package/cli/gate.mjs CHANGED
@@ -11,9 +11,10 @@ import { PROJECT_FILES } from './manifest.mjs';
11
11
  import {
12
12
  epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
13
13
  advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
14
- artifactPaths, upsertHubPr,
14
+ upsertHubPr,
15
15
  } from './epic-state.mjs';
16
16
  import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
17
+ import { syncStatuses } from './artifact-status.mjs';
17
18
  import { err } from './errors.mjs';
18
19
 
19
20
  // ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
@@ -90,6 +91,12 @@ function loadHub(root) {
90
91
  // merge advances the step). Recorded per-project in hub.json by `yad setup`.
91
92
  const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
92
93
 
94
+ // Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
95
+ // or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
96
+ // hands-off. A platform without the bridge (no gate-sync CI installed) keeps the local write path,
97
+ // or reviews could never advance. Mirrors plan.mjs hubActions.
98
+ const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
99
+
93
100
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
94
101
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
95
102
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
@@ -158,13 +165,19 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
158
165
 
159
166
  // ---- actions ------------------------------------------------------------------------------------
160
167
 
161
- export async function gateSync(root, { epic, artifact, today, reader = readPr } = {}) {
168
+ export async function gateSync(root, { epic, artifact, today, reader = readPr, local = false, dryRun = false } = {}) {
162
169
  const { hub, repos } = loadHub(root);
163
170
  if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
164
171
  const platform = hub.platform;
165
172
  const roster = hub.roster || [];
166
173
  const defaultReviewers = 1;
167
174
  const solo = isSolo(hub);
175
+ // Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
176
+ // the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
177
+ // Without the bridge (platform but no gate-sync CI) the local command stays the writer.
178
+ // dryRun forces the same read-only behavior regardless of bridge — used for the Path B pre-merge
179
+ // evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
180
+ const readOnly = (local && isBridge(hub)) || dryRun;
168
181
  const epicDir = epicRoot(root, epic);
169
182
  const ledger = loadLedger(epicDir);
170
183
  if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
@@ -174,6 +187,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
174
187
  if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`yad gate open\` first)`); return { synced: 0 }; }
175
188
 
176
189
  let synced = 0;
190
+ let advanced = 0;
177
191
  for (const pr of targets) {
178
192
  const step = findReviewStep(state, pr.artifact);
179
193
  if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
@@ -182,11 +196,13 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
182
196
  if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done — skipping`); continue; }
183
197
  const domains = touchedDomains(epicDir, step);
184
198
  const pull = reader(platform, pr.number, { cwd: root });
185
- if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} skipping (file-only)`); continue; }
199
+ // A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
200
+ // (the wired workflow's reconcile/sweep aggregates this exit) instead of silently not advancing.
201
+ if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} — skipping (file-only)`); process.exitCode = 1; continue; }
186
202
 
187
203
  const curHash = artifactHash(epicDir, pr.artifact);
188
204
  warnUnlockedContract(epicDir, pr.artifact);
189
- const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains });
205
+ const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
190
206
  approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
191
207
 
192
208
  const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
@@ -196,7 +212,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
196
212
  ...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
197
213
  ...unresolved,
198
214
  ];
199
- writeComments(epicDir, base(pr.artifact), today, blocking);
215
+ // Advisory (read-only) sync must not touch the working tree — defer the reviews/*.md write.
216
+ if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
200
217
  comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
201
218
 
202
219
  const pred = gatePredicate({
@@ -207,6 +224,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
207
224
  log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
208
225
  if (pred.passed) {
209
226
  state = advanceState(state, step);
227
+ advanced++;
210
228
  ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
211
229
  } else {
212
230
  state = markInReview(state, step);
@@ -216,29 +234,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
216
234
  synced++;
217
235
  }
218
236
 
237
+ if (readOnly) {
238
+ info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
239
+ return { synced, advanced };
240
+ }
219
241
  writeJSON(ledger.files.approvals, approvals);
220
242
  writeJSON(ledger.files.comments, comments);
221
243
  writeJSON(ledger.files.hubPrs, hubPrs);
222
244
  writeJSON(ledger.files.state, state);
223
245
  refreshRoster(epicDir, targets, approvals, today);
224
- return { synced };
246
+ return { synced, advanced };
225
247
  }
226
248
 
227
- // `yad gate ci` — the self-sufficient entry point hub CI calls on platform events (review
228
- // submitted/dismissed, PR synchronize, PR merged) and on the GitLab schedule. Event mode derives
229
- // epic/artifact from the `review/EP-<slug>/<base>` head branch (so it works even when the author
230
- // never committed hub-prs.json); sweep mode (no --branch) re-syncs every open review PR. Either way
231
- // it runs the unchanged gateSync, then commits ONLY the ledger files to the hub default branch —
232
- // the artifact itself lands on main via the human merge, never via CI.
233
- export async function gateCi(root, { branch, pr, today, push = true, reader = readPr } = {}) {
249
+ // `yad gate ci` — the self-sufficient entry point hub CI calls on platform events. Path B: CI
250
+ // never writes the ledger to the review branch — during review the platform PR/MR is the source of
251
+ // truth, and the ledger is reconciled onto the default branch at merge.
252
+ //
253
+ // PRE-MERGE (a held step, no --merged and nothing advanced): READ-ONLY. The predicate is
254
+ // evaluated for visibility, but nothing is committed or pushed so an in-flight approval is never
255
+ // dismissed and the PR's required checks never strand on a CI commit.
256
+ //
257
+ // MERGE (--merged, PR/MR closed+merged): the artifact reached the default branch via the human
258
+ // merge; the workflow checks out the default branch. CI runs the sync — the PR reads merged=true,
259
+ // so the predicate ADVANCES the step, flips the artifact `status:` to approved (syncStatuses), and
260
+ // commits the advance to the default branch. CI re-reads approvals fresh from the platform, so it
261
+ // needs no ledger pre-seeded on the branch.
262
+ //
263
+ // CI is the SOLE writer of the ledger and only ever commits to the default branch; humans never
264
+ // commit gate-state files (enforced by the ledger-guard check). Sweep mode (no --branch) advances
265
+ // merged-but-stuck reviews found in the locally checked-out default-branch ledgers.
266
+ export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
234
267
  const { hub } = loadHub(root);
235
268
  if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
236
269
  const git = (...args) => run('git', args, { cwd: root });
237
- // Push target: an explicit hub.default_branch wins; else the branch CI actually checked out (the
238
- // workflow checks out the PR base / $CI_DEFAULT_BRANCH hub.json from `yad setup` has no
239
- // default_branch field, so the checkout is the truth); 'main' only as the last resort.
240
- const head = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
241
- const target = hub.default_branch || (head && head !== 'HEAD' ? head : 'main');
270
+ const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
271
+ // Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
272
+ // not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
242
273
 
243
274
  // Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
244
275
  const jobs = [];
@@ -271,6 +302,7 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
271
302
 
272
303
  let synced = 0;
273
304
  const touched = new Set();
305
+ const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
274
306
  for (const job of jobs) {
275
307
  const epicDir = epicRoot(root, job.epic);
276
308
  // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
@@ -284,14 +316,14 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
284
316
  continue;
285
317
  }
286
318
  if (!ledger.state) {
287
- warn(`${job.epic}: no epic state on ${target}commit epics/${job.epic}/.sdlc to ${target} first`);
319
+ warn(`${job.epic}: no epic state on the checked-out branch the review branch is cut from the default branch, so it should carry it`);
288
320
  continue;
289
321
  }
290
322
  const step = findReviewStep(ledger.state, job.artifact);
291
323
  if (!step) { warn(`${job.epic}: no review step for ${job.artifact} — skipping`); continue; }
292
324
 
293
- // The event may fire before the author ever committed hub-prs.json build the entry from the
294
- // event itself, so the first CI commit lands it on the default branch and the views converge.
325
+ // The merge event may fire before any hub-prs record exists (Path B never wrote one pre-merge) —
326
+ // build the entry from the event itself so the advance commit carries it onto the default branch.
295
327
  const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
296
328
  const number = Number(job.pr) || existing?.number || null;
297
329
  if (!existing || existing.number !== number || existing.branch !== job.branch) {
@@ -302,60 +334,78 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
302
334
  writeJSON(ledger.files.hubPrs, ledger.hubPrs);
303
335
  }
304
336
 
305
- // Overlay the artifact from the review branch so artifactHash binds approvals to what the
306
- // reviewers actually approved (pre-merge, the default branch does not have it yet). A failed
307
- // fetch (branch deleted after merge) is fine — the artifact already landed via the merge.
308
- const overlay = artifactPaths(job.base).map((p) => path.join('epics', job.epic, p));
309
- const fetched = job.branch ? git('fetch', 'origin', job.branch).ok : false;
310
- if (fetched) for (const p of overlay) git('checkout', 'FETCH_HEAD', '--', p);
311
-
337
+ // No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
338
+ // binds to the reviewed content directly when CI re-reads the platform.
312
339
  let failed = false;
313
340
  try {
314
- const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader });
341
+ // A branch event that is not a merge can never advance (the predicate requires merged), so it
342
+ // is read-only under Path B — run it as a dry sync that persists nothing to the working tree.
343
+ const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged });
315
344
  synced += r.synced;
345
+ // When the step actually ADVANCED (the merge phase, or a swept merge the schedule observed),
346
+ // reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
347
+ // --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
348
+ // on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
349
+ if (r.advanced > 0) { advancedEpics.add(job.epic); await syncStatuses(root, { epic: job.epic }); }
316
350
  } catch (err) {
317
351
  if (branch) throw err; // event mode: one epic — surface the failure
318
352
  warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
319
353
  process.exitCode = 1;
320
354
  failed = true;
321
- } finally {
322
- // Drop the overlay — even when sync throws: only the ledger may reach the default branch via CI.
323
- if (fetched) {
324
- for (const p of overlay) {
325
- git('checkout', 'HEAD', '--', p); // tracked files back to HEAD (no-op fail if not in HEAD)
326
- git('clean', '-fd', '--', p); // files new on the branch: remove
327
- }
328
- }
329
355
  }
330
356
  if (failed) continue; // a failed epic's partial state must not be committed by this run
331
357
  touched.add(job.epic);
332
358
  }
333
359
  if (!touched.size) return { synced };
334
360
 
335
- // Commit the ledger only, then push with a rebase-retry (ledger commits across epics touch
336
- // disjoint files; same-repo runs are serialized by the CI concurrency group).
361
+ // Path B: CI never writes the ledger to the review branch. A held step that did not advance is
362
+ // read-only here during review the platform PR/MR is the source of truth (native approvals/
363
+ // threads); the ledger is reconciled onto the default branch at merge. Keeping CI off the PR head
364
+ // is what stops an approval from being dismissed and required checks from stranding. Correctness is
365
+ // unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
366
+ const advancedAny = advancedEpics.size > 0;
367
+ if (!merged && !advancedAny) {
368
+ // Pre-merge is read-only (Path B): the gate was evaluated with a dry sync that persists nothing.
369
+ // The one working-tree write is the hub-prs.json seed above (so the dry sync could find the PR);
370
+ // restore exactly that file per epic so the checkout stays clean — never touching anything else,
371
+ // so a local `yad gate ci --branch` cannot disturb unrelated files.
372
+ for (const e of touched) {
373
+ const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
374
+ git('checkout', '-q', '--', hp); // restore it if it was tracked
375
+ git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
376
+ }
377
+ info('pre-merge: gate evaluated; the ledger reconciles on the default branch at merge — nothing pushed');
378
+ return { synced };
379
+ }
380
+ const target = defaultBranch; // CI only ever commits the ledger to the default branch
381
+
382
+ // Stage what this merge-phase run owns, per epic (everything lands on the default branch):
383
+ // - advanced → the whole epic (ledger advance + the status flip syncStatuses wrote into the .md).
384
+ // - merged but not advanced (merged before the rule passed) → the ledger (.sdlc) + the generated
385
+ // reviews/ summaries only; the artifact is the owner's, left untouched.
337
386
  for (const e of touched) {
338
- // Separate adds: a pathspec with no match (reviews/ not created yet) must not abort the other.
339
- git('add', '-A', '--', path.join('epics', e, '.sdlc'));
340
- git('add', '-A', '--', path.join('epics', e, 'reviews'));
387
+ if (advancedEpics.has(e)) git('add', '-A', '--', path.join('epics', e));
388
+ else { git('add', '-A', '--', path.join('epics', e, '.sdlc')); git('add', '-A', '--', path.join('epics', e, 'reviews')); }
341
389
  }
342
390
  if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
343
- const subject = branch
344
- ? `chore(gate): sync ${jobs[0].epic}/${jobs[0].base} via CI [skip ci]`
345
- : 'chore(gate): scheduled gate sync [skip ci]';
391
+ // [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
392
+ // sibling workflows. CI never pushes the review branch (Path B), so there is no synchronize loop.
393
+ const subject = !branch
394
+ ? 'chore(gate): scheduled gate sync [skip ci]' // sweep is a batch; one subject for the run
395
+ : `chore(gate): advance ${jobs[0].epic}/${jobs[0].base} on merge [skip ci]`;
346
396
  const cm = git('commit', '-m', subject);
347
397
  if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
348
- ok(`committed ledger update: ${c.dim(subject)}`);
398
+ ok(`committed gate update: ${c.dim(subject)}`);
349
399
  if (!push) return { synced };
350
400
 
351
401
  for (let attempt = 1; attempt <= 3; attempt++) {
352
- if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed ledger to origin/${target}`); return { synced }; }
402
+ if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed to origin/${target}`); return { synced }; }
353
403
  if (attempt < 3) {
354
404
  info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
355
405
  if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
356
406
  }
357
407
  }
358
- fail(`could not push the ledger to origin/${target} — protected branch? allow the CI actor to push (see yad-hub-bridge references/bridge.md) or run \`yad gate sync\` locally`);
408
+ fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
359
409
  process.exitCode = 1;
360
410
  return { synced };
361
411
  }
@@ -412,12 +462,22 @@ export async function gateOpen(root, { epic, artifact } = {}) {
412
462
  const domains = touchedDomains(epicDir, step);
413
463
  warnUnlockedContract(epicDir, artifact);
414
464
 
415
- // Mark in-review in the ledger regardless of platform (file-only still works).
416
- ledger.state = markInReview(ledger.state, step);
417
- writeJSON(ledger.files.state, ledger.state);
418
-
419
- if (!hub?.platform) { warn('no hub platform — marked in_review file-only (no PR opened)'); ok(`${step.id} → in_review`); return; }
465
+ const bridge = isBridge(hub);
466
+ // Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
467
+ // ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
468
+ if (!bridge) {
469
+ ledger.state = markInReview(ledger.state, step);
470
+ writeJSON(ledger.files.state, ledger.state);
471
+ }
472
+ if (!hub?.platform) {
473
+ warn('no hub platform — marked in_review file-only (no PR opened)');
474
+ ok(`${step.id} → in_review`);
475
+ return;
476
+ }
420
477
 
478
+ // Open the PR. In bridge mode CI records the hub-prs entry (and advances) on the default branch at
479
+ // merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
480
+ // CI writes nothing pre-merge. Without the bridge, the local command records the PR itself (no CI will).
421
481
  const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
422
482
  // Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
423
483
  // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
@@ -428,13 +488,16 @@ export async function gateOpen(root, { epic, artifact } = {}) {
428
488
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
429
489
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
430
490
  const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
431
- if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
491
+ if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
432
492
 
433
- const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
434
- ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number, url: r.url, branch, lastSyncedAt: null });
435
- writeJSON(ledger.files.hubPrs, ledger.hubPrs);
493
+ if (!bridge) {
494
+ ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null, url: r.url, branch, lastSyncedAt: null });
495
+ writeJSON(ledger.files.hubPrs, ledger.hubPrs);
496
+ }
436
497
  ok(`opened ${r.url}`);
437
- hand(`reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
498
+ hand(bridge
499
+ ? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
500
+ : `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
438
501
  }
439
502
 
440
503
  // ---- helpers ------------------------------------------------------------------------------------
package/cli/manifest.mjs CHANGED
@@ -195,6 +195,8 @@ export const wiringFor = (platform) => [
195
195
  export const HUB_WIRING = {
196
196
  common: [
197
197
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
198
+ // The ledger is CI-owned: block non-bot commits to gate-state files on hub review PRs.
199
+ { src: 'skills/yad-checks/templates/checks/ledger-guard.sh', dest: 'checks/ledger-guard.sh', exec: true },
198
200
  // Pattern gates run on the hub too (profile: hub) — commit subject + PR title + PR body.
199
201
  { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
200
202
  { src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },