yadflow 2.13.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/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]`) ----------------------------------
@@ -85,6 +86,17 @@ function loadHub(root) {
85
86
  return { hub, repos: registry.repos };
86
87
  }
87
88
 
89
+ // Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
90
+ // PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
91
+ // merge advances the step). Recorded per-project in hub.json by `yad setup`.
92
+ const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
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
+
88
100
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
89
101
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
90
102
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
@@ -153,12 +165,19 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
153
165
 
154
166
  // ---- actions ------------------------------------------------------------------------------------
155
167
 
156
- 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 } = {}) {
157
169
  const { hub, repos } = loadHub(root);
158
170
  if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
159
171
  const platform = hub.platform;
160
172
  const roster = hub.roster || [];
161
173
  const defaultReviewers = 1;
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;
162
181
  const epicDir = epicRoot(root, epic);
163
182
  const ledger = loadLedger(epicDir);
164
183
  if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
@@ -168,6 +187,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
168
187
  if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`yad gate open\` first)`); return { synced: 0 }; }
169
188
 
170
189
  let synced = 0;
190
+ let advanced = 0;
171
191
  for (const pr of targets) {
172
192
  const step = findReviewStep(state, pr.artifact);
173
193
  if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
@@ -176,11 +196,13 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
176
196
  if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done — skipping`); continue; }
177
197
  const domains = touchedDomains(epicDir, step);
178
198
  const pull = reader(platform, pr.number, { cwd: root });
179
- 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; }
180
202
 
181
203
  const curHash = artifactHash(epicDir, pr.artifact);
182
204
  warnUnlockedContract(epicDir, pr.artifact);
183
- const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains });
205
+ const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
184
206
  approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
185
207
 
186
208
  const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
@@ -190,17 +212,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
190
212
  ...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
191
213
  ...unresolved,
192
214
  ];
193
- 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);
194
217
  comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
195
218
 
196
219
  const pred = gatePredicate({
197
220
  step, approvals, currentHash: curHash, touchedDomains: domains,
198
- defaultReviewers, threadsResolved, merged: pull.merged,
221
+ defaultReviewers, threadsResolved, merged: pull.merged, solo,
199
222
  });
200
223
 
201
224
  log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
202
225
  if (pred.passed) {
203
226
  state = advanceState(state, step);
227
+ advanced++;
204
228
  ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
205
229
  } else {
206
230
  state = markInReview(state, step);
@@ -210,29 +234,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
210
234
  synced++;
211
235
  }
212
236
 
237
+ if (readOnly) {
238
+ info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
239
+ return { synced, advanced };
240
+ }
213
241
  writeJSON(ledger.files.approvals, approvals);
214
242
  writeJSON(ledger.files.comments, comments);
215
243
  writeJSON(ledger.files.hubPrs, hubPrs);
216
244
  writeJSON(ledger.files.state, state);
217
245
  refreshRoster(epicDir, targets, approvals, today);
218
- return { synced };
246
+ return { synced, advanced };
219
247
  }
220
248
 
221
- // `yad gate ci` — the self-sufficient entry point hub CI calls on platform events (review
222
- // submitted/dismissed, PR synchronize, PR merged) and on the GitLab schedule. Event mode derives
223
- // epic/artifact from the `review/EP-<slug>/<base>` head branch (so it works even when the author
224
- // never committed hub-prs.json); sweep mode (no --branch) re-syncs every open review PR. Either way
225
- // it runs the unchanged gateSync, then commits ONLY the ledger files to the hub default branch —
226
- // the artifact itself lands on main via the human merge, never via CI.
227
- 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 } = {}) {
228
267
  const { hub } = loadHub(root);
229
268
  if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
230
269
  const git = (...args) => run('git', args, { cwd: root });
231
- // Push target: an explicit hub.default_branch wins; else the branch CI actually checked out (the
232
- // workflow checks out the PR base / $CI_DEFAULT_BRANCH hub.json from `yad setup` has no
233
- // default_branch field, so the checkout is the truth); 'main' only as the last resort.
234
- const head = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
235
- 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).
236
273
 
237
274
  // Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
238
275
  const jobs = [];
@@ -265,6 +302,7 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
265
302
 
266
303
  let synced = 0;
267
304
  const touched = new Set();
305
+ const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
268
306
  for (const job of jobs) {
269
307
  const epicDir = epicRoot(root, job.epic);
270
308
  // Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
@@ -278,14 +316,14 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
278
316
  continue;
279
317
  }
280
318
  if (!ledger.state) {
281
- 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`);
282
320
  continue;
283
321
  }
284
322
  const step = findReviewStep(ledger.state, job.artifact);
285
323
  if (!step) { warn(`${job.epic}: no review step for ${job.artifact} — skipping`); continue; }
286
324
 
287
- // The event may fire before the author ever committed hub-prs.json build the entry from the
288
- // 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.
289
327
  const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
290
328
  const number = Number(job.pr) || existing?.number || null;
291
329
  if (!existing || existing.number !== number || existing.branch !== job.branch) {
@@ -296,60 +334,78 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
296
334
  writeJSON(ledger.files.hubPrs, ledger.hubPrs);
297
335
  }
298
336
 
299
- // Overlay the artifact from the review branch so artifactHash binds approvals to what the
300
- // reviewers actually approved (pre-merge, the default branch does not have it yet). A failed
301
- // fetch (branch deleted after merge) is fine — the artifact already landed via the merge.
302
- const overlay = artifactPaths(job.base).map((p) => path.join('epics', job.epic, p));
303
- const fetched = job.branch ? git('fetch', 'origin', job.branch).ok : false;
304
- if (fetched) for (const p of overlay) git('checkout', 'FETCH_HEAD', '--', p);
305
-
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.
306
339
  let failed = false;
307
340
  try {
308
- 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 });
309
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 }); }
310
350
  } catch (err) {
311
351
  if (branch) throw err; // event mode: one epic — surface the failure
312
352
  warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
313
353
  process.exitCode = 1;
314
354
  failed = true;
315
- } finally {
316
- // Drop the overlay — even when sync throws: only the ledger may reach the default branch via CI.
317
- if (fetched) {
318
- for (const p of overlay) {
319
- git('checkout', 'HEAD', '--', p); // tracked files back to HEAD (no-op fail if not in HEAD)
320
- git('clean', '-fd', '--', p); // files new on the branch: remove
321
- }
322
- }
323
355
  }
324
356
  if (failed) continue; // a failed epic's partial state must not be committed by this run
325
357
  touched.add(job.epic);
326
358
  }
327
359
  if (!touched.size) return { synced };
328
360
 
329
- // Commit the ledger only, then push with a rebase-retry (ledger commits across epics touch
330
- // 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.
331
386
  for (const e of touched) {
332
- // Separate adds: a pathspec with no match (reviews/ not created yet) must not abort the other.
333
- git('add', '-A', '--', path.join('epics', e, '.sdlc'));
334
- 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')); }
335
389
  }
336
390
  if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
337
- const subject = branch
338
- ? `chore(gate): sync ${jobs[0].epic}/${jobs[0].base} via CI [skip ci]`
339
- : '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]`;
340
396
  const cm = git('commit', '-m', subject);
341
397
  if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
342
- ok(`committed ledger update: ${c.dim(subject)}`);
398
+ ok(`committed gate update: ${c.dim(subject)}`);
343
399
  if (!push) return { synced };
344
400
 
345
401
  for (let attempt = 1; attempt <= 3; attempt++) {
346
- 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 }; }
347
403
  if (attempt < 3) {
348
404
  info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
349
405
  if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
350
406
  }
351
407
  }
352
- 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`);
353
409
  process.exitCode = 1;
354
410
  return { synced };
355
411
  }
@@ -382,7 +438,8 @@ export async function gateStatus(root, { epic } = {}) {
382
438
  const epicDir = epicRoot(root, epic);
383
439
  const ledger = loadLedger(epicDir);
384
440
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
385
- log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}`)}`);
441
+ const solo = isSolo(loadHub(root).hub);
442
+ log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
386
443
  for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
387
444
  const cur = artifactHash(epicDir, s.artifact);
388
445
  const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash && cur && a.artifactHash !== cur));
@@ -405,12 +462,22 @@ export async function gateOpen(root, { epic, artifact } = {}) {
405
462
  const domains = touchedDomains(epicDir, step);
406
463
  warnUnlockedContract(epicDir, artifact);
407
464
 
408
- // Mark in-review in the ledger regardless of platform (file-only still works).
409
- ledger.state = markInReview(ledger.state, step);
410
- writeJSON(ledger.files.state, ledger.state);
411
-
412
- 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
+ }
413
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).
414
481
  const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
415
482
  // Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
416
483
  // domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
@@ -421,13 +488,16 @@ export async function gateOpen(root, { epic, artifact } = {}) {
421
488
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
422
489
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
423
490
  const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
424
- 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; }
425
492
 
426
- const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
427
- ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number, url: r.url, branch, lastSyncedAt: null });
428
- 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
+ }
429
497
  ok(`opened ${r.url}`);
430
- 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}\``);
431
501
  }
432
502
 
433
503
  // ---- helpers ------------------------------------------------------------------------------------
package/cli/lib.mjs CHANGED
@@ -30,6 +30,9 @@ export const info = (s) => log(` ${c.dim('•')} ${s}`);
30
30
  export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
31
31
  export const fail = (s) => log(` ${c.red('✗')} ${s}`);
32
32
  export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
33
+ // Dimmed, indented guidance under a step — what it does / why / what to enter / what skipping means.
34
+ // Accepts a string or an array of lines so a knowledgeable user can skim past it.
35
+ export const guide = (lines) => { for (const l of (Array.isArray(lines) ? lines : [lines])) log(` ${c.dim(l)}`); };
33
36
 
34
37
  // ---- prompts ------------------------------------------------------------
35
38
  let rl;
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 },
package/cli/next.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // `yad next` — the unified next-step driver. Read-only: it never writes state or acts. It reads the
2
+ // file ledger and prints the ONE concrete, copy-pasteable next action (and a one-line why), so a user
3
+ // never has to remember which of the 30 skills / gate commands comes next. "Guide, don't act" — the
4
+ // front half still never auto-advances.
5
+ //
6
+ // yad next general orientation across the whole project
7
+ // yad next <epic> the single next action for one epic
8
+ // yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
9
+ // yad next --all every active epic's next action at once
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
13
+ import { PROJECT_FILES } from './manifest.mjs';
14
+ import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId } from './epic-state.mjs';
15
+
16
+ // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
17
+ // missing/old hub.json never breaks the driver.
18
+ function isSolo(root) {
19
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
20
+ return !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
21
+ }
22
+ // The setup profile recorded by `yad setup` (codebase / repo_layout / team_size), or null.
23
+ const profileOf = (root) => readJSON(path.join(root, PROJECT_FILES.hubConfig), null)?.profile || null;
24
+ // Has `yad setup` run here? True once the version stamp or hub config exists.
25
+ const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(path.join(root, PROJECT_FILES.hubConfig));
26
+
27
+ // Every epic that has a state ledger, in directory order.
28
+ function listEpics(root) {
29
+ const dir = path.join(root, 'epics');
30
+ if (!exists(dir)) return [];
31
+ return fs.readdirSync(dir, { withFileTypes: true })
32
+ .filter((e) => e.isDirectory() && isValidEpicId(e.name))
33
+ .map((e) => e.name)
34
+ .filter((id) => exists(path.join(dir, id, '.sdlc', 'state.json')))
35
+ .sort();
36
+ }
37
+
38
+ // A short, copy-pasteable line for one action — the `▸` line a user can act on directly.
39
+ function actionLine(a, { solo } = {}) {
40
+ switch (a.kind) {
41
+ case 'new':
42
+ return `invoke the ${c.bold(a.skill)} skill ${c.dim('(author the epic)')}`;
43
+ case 'author':
44
+ return `invoke the ${c.bold(a.skill || ('yad-' + a.step))} skill ${c.dim(`(author ${a.artifact})`)}`;
45
+ case 'review-open':
46
+ case 'review-sync':
47
+ return `${c.bold(a.command)}${solo ? c.dim(' (solo: no approval needed — just merge your own PR)') : ''}`;
48
+ case 'build':
49
+ return `${c.bold('yad-run')} ${c.dim('(or per story: yad-spec → yad-implement → yad ship → yad-engineer-review)')}`;
50
+ default:
51
+ return c.dim('nothing to do');
52
+ }
53
+ }
54
+
55
+ // Full, friendly printout for a single epic.
56
+ function printAction(a, { solo } = {}) {
57
+ log(`\n ${c.bold(a.epicId || '(epic)')} ${c.dim(`— ${a.why}`)}`);
58
+ hand(actionLine(a, { solo }));
59
+ if (a.kind === 'review-sync') info(c.dim(`unresolved comments? ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`));
60
+ if (a.parallel) hand(`parallel track: invoke the ${c.bold(a.parallel.skill)} skill ${c.dim(`(author ${a.parallel.artifact})`)}`);
61
+ }
62
+
63
+ // `yad next` with no epic: orient across the whole project, always ending on ONE thing to do.
64
+ function generalNext(root, { all } = {}) {
65
+ if (!isSetUp(root)) {
66
+ log(`\n ${c.bold('Project not set up yet.')}`);
67
+ hand(`run ${c.bold('yad setup')} ${c.dim('(then come back to `yad next`)')}`);
68
+ return;
69
+ }
70
+ const epics = listEpics(root);
71
+ if (!epics.length) {
72
+ const brownfield = profileOf(root)?.codebase === 'brownfield';
73
+ log(`\n ${c.bold('Set up — no epics yet.')}`);
74
+ if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
75
+ hand(`start your first epic: invoke the ${c.bold('yad-epic')} skill`);
76
+ return;
77
+ }
78
+ const solo = isSolo(root);
79
+ const actions = epics.map((id) => nextAction(loadLedger(epicRoot(root, id)), { epic: id }));
80
+
81
+ if (epics.length === 1 || all) {
82
+ for (const a of actions) printAction(a, { solo });
83
+ return;
84
+ }
85
+ // Several epics — list each with a one-liner, then point at the per-epic / --all views.
86
+ log(`\n ${c.bold(`${epics.length} epics`)} ${c.dim('— next action each:')}`);
87
+ for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
88
+ info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
89
+ }
90
+
91
+ // `yad next <epic> --check <step>`: the precondition guard. Exit 0 if runnable now, 1 otherwise.
92
+ function checkPrecondition(root, epic, stepId) {
93
+ const ledger = loadLedger(epicRoot(root, epic));
94
+ const res = preconditionsMet(ledger.state, stepId);
95
+ if (res.ok) {
96
+ ok(`${epic}: ${stepId} is ready to run`);
97
+ return;
98
+ }
99
+ fail(`${epic}: ${stepId} is blocked — ${res.reason}`);
100
+ hand(`see what to do now: ${c.bold(`yad next ${epic}`)}`);
101
+ process.exitCode = 1;
102
+ }
103
+
104
+ // Entry point for the `next` command: route to the precondition check, a single epic's action, or the
105
+ // project-wide general view. Validates the epic id first.
106
+ export async function runNext(root, { epic, check, all } = {}) {
107
+ if (epic && !isValidEpicId(epic)) {
108
+ fail(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ if (epic && check) return checkPrecondition(root, epic, check);
113
+ if (!epic) return generalNext(root, { all });
114
+
115
+ const epicDir = epicRoot(root, epic);
116
+ if (!exists(path.join(epicDir, '.sdlc', 'state.json'))) {
117
+ warn(`no epic state at ${epicDir}/.sdlc/state.json`);
118
+ hand(`is the id right? list project status with ${c.bold('yad next')}`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ printAction(nextAction(loadLedger(epicDir), { epic }), { solo: isSolo(root) });
123
+ }
package/cli/platform.mjs CHANGED
@@ -141,10 +141,21 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
141
141
 
142
142
  // Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
143
143
  // so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
144
- export function mapApprovers(reviews = [], { roster, repos, touchedDomains }) {
144
+ export function mapApprovers(reviews = [], { roster, repos, touchedDomains, headOid } = {}) {
145
145
  const out = [];
146
146
  for (const r of reviews) {
147
147
  if (r.state !== 'APPROVED') continue;
148
+ // Revoke-on-change, enforced in code where the platform binds an approval to a commit. The reader
149
+ // sets `commit` to the review's SHA (GitHub), to `null` when that read DEGRADED, or leaves it
150
+ // ABSENT when the platform exposes no per-approval SHA (GitLab):
151
+ // - `null` (degraded read) → FAIL CLOSED → drop, independently of headOid: we cannot prove the
152
+ // approval is for the merged content, so a transient failure holds
153
+ // the gate rather than advancing on unverifiable approvals;
154
+ // - a known SHA ≠ head → the approval is stale (artifact moved) → drop;
155
+ // - absent (GitLab) → keep: revoke-on-change is the platform's "remove approvals on new
156
+ // commits" setting.
157
+ if (r.commit === null) continue;
158
+ if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
148
159
  for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
149
160
  out.push({ ...rec, submittedAt: r.submittedAt || null });
150
161
  }
@@ -157,17 +168,35 @@ function readPrGitHub(n, { cwd } = {}) {
157
168
  const view = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
158
169
  if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
159
170
  const meta = JSON.parse(view.stdout);
160
- // latestReviews collapses a reviewer's superseded reviews to their current one.
161
- const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
162
- const reviews = rev.ok
163
- ? (JSON.parse(rev.stdout).latestReviews || []).map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt }))
164
- : [];
171
+ let reviews = [];
172
+ let reviewsOk = false;
165
173
  // Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
166
174
  // >100 threads is not mistakenly read as "all resolved".
167
175
  let threads = [];
168
176
  const nwo = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
169
177
  if (nwo.ok) {
170
178
  const { owner, name } = JSON.parse(nwo.stdout);
179
+ // latestReviews collapses a reviewer's superseded reviews to their current one; commit.oid binds
180
+ // each approval to the revision it was made on, so an approval on an older commit than the merged
181
+ // head is dropped as stale (revoke-on-change in code — see mapApprovers). `gh pr view --json
182
+ // latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
183
+ // reviewers never silently omits one; any page failure aborts to the commitless fallback below,
184
+ // which fails closed rather than advancing on a partial read.
185
+ const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt commit{oid}}}}}}`;
186
+ let rcursor = null;
187
+ reviewsOk = true;
188
+ for (let guard = 0; guard < 50; guard++) {
189
+ const args = ['api', 'graphql', '-f', `query=${rq}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
190
+ if (rcursor) args.push('-F', `c=${rcursor}`);
191
+ const rg = run('gh', args, { cwd });
192
+ if (!rg.ok) { reviewsOk = false; reviews = []; break; }
193
+ const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
194
+ for (const x of conn?.nodes || []) {
195
+ reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
196
+ }
197
+ if (!conn?.pageInfo?.hasNextPage) break;
198
+ rcursor = conn.pageInfo.endCursor;
199
+ }
171
200
  const q = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:1){nodes{author{login} body}}}}}}}`;
172
201
  let cursor = null;
173
202
  for (let guard = 0; guard < 50; guard++) {
@@ -188,6 +217,15 @@ function readPrGitHub(n, { cwd } = {}) {
188
217
  cursor = page.pageInfo.endCursor;
189
218
  }
190
219
  }
220
+ // Fallback if the GraphQL reviews read failed (no nwo / API hiccup): take the plain JSON view with
221
+ // commit=null. Approvals then FAIL CLOSED in mapApprovers (a degraded read cannot prove an approval
222
+ // is for the merged content), while CHANGES_REQUESTED is still honored — so a transient failure
223
+ // holds the gate, never advances it.
224
+ if (!reviewsOk) {
225
+ const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
226
+ if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
227
+ .map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
228
+ }
191
229
  return {
192
230
  ok: true,
193
231
  state: meta.state,
package/cli/repo.mjs CHANGED
@@ -13,11 +13,14 @@ function load(root) {
13
13
  return { regPath, registry: readJSON(regPath, { repos: [] }) };
14
14
  }
15
15
 
16
- // HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha).
16
+ // HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha). A repo that has a HEAD but
17
+ // no syncedHead was registered without a pack (the greenfield path) — it needs an initial pack, which is
18
+ // also a "run `yad repo refresh`" state, kept distinct from HEAD-moved staleness.
17
19
  function staleness(root, repo) {
18
20
  const head = gitHead(path.resolve(root, repo.path));
21
+ const neverPacked = !!head && !repo.syncedHead;
19
22
  const stale = head && repo.syncedHead && head !== repo.syncedHead;
20
- return { head, stale: !!stale, unknown: !head };
23
+ return { head, stale: !!stale, unknown: !head, neverPacked };
21
24
  }
22
25
 
23
26
  // ---- git helpers for `sync` (local-user auth only — never embed credentials) ----
@@ -40,9 +43,10 @@ export async function runRepo(root, { action = 'list', name, today } = {}) {
40
43
  log(c.bold('\nconnected repos'));
41
44
  let staleCount = 0;
42
45
  for (const repo of registry.repos) {
43
- const { stale, unknown } = staleness(root, repo);
46
+ const { stale, unknown, neverPacked } = staleness(root, repo);
44
47
  if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
45
- if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
48
+ if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
49
+ else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
46
50
  else ok(`${repo.name} ${c.dim('— fresh')}`);
47
51
  }
48
52
  if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);