yadflow 3.10.1 → 3.11.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,18 @@
1
+ ## [3.11.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.0...v3.11.1) (2026-07-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **gate:** close the authoring step when its review gate advances ([8baaed9](https://github.com/abdelrahmannasr/yadflow/commit/8baaed9416a063013b5e6acf1ae36c5a1b3c920b))
7
+ * **setup:** contain repo paths to the workspace so sibling repos connect ([265a7ae](https://github.com/abdelrahmannasr/yadflow/commit/265a7ae543fccf8697ff89726ecb1a6aadde62ce))
8
+
9
+ # [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
10
+
11
+
12
+ ### Features
13
+
14
+ * notify when a newer yadflow is published ([9b7a5bf](https://github.com/abdelrahmannasr/yadflow/commit/9b7a5bfca4f27ab08ce4e3e48f2ba331c3e8bfd5))
15
+
1
16
  ## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
2
17
 
3
18
 
package/README.md CHANGED
@@ -63,6 +63,9 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
63
63
  [guided tutorial](https://abdelrahmannasr.github.io/yadflow/tutorial/)**, or read the
64
64
  [team guide](TEAM-GUIDE.md).
65
65
 
66
+ Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
67
+ `yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
68
+
66
69
  ## What `npx yadflow setup` installs
67
70
 
68
71
  ![npx yadflow setup — the guided wizard installs the yad-* skills, wires the CI gates, and stamps the .sdlc config](https://raw.githubusercontent.com/abdelrahmannasr/yadflow/main/docs/media/setup-wizard.gif)
package/bin/yad.mjs CHANGED
@@ -4,7 +4,7 @@ import { VERSION } from '../cli/manifest.mjs';
4
4
  import { c, log, closePrompts, askYesNo } from '../cli/lib.mjs';
5
5
  import { runSetup } from '../cli/setup.mjs';
6
6
  import { reconcile } from '../cli/reconcile.mjs';
7
- import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough } from '../cli/gate.mjs';
7
+ import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough, gateRepair } from '../cli/gate.mjs';
8
8
  import { isValidEpicId } from '../cli/epic-state.mjs';
9
9
  import { runCommit } from '../cli/commit.mjs';
10
10
  import { runOpenPr } from '../cli/openpr.mjs';
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
22
22
  import { runThread, runReconcile } from '../cli/thread.mjs';
23
23
  import { runReport } from '../cli/report.mjs';
24
24
  import { runUsage } from '../cli/usage.mjs';
25
+ import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
25
26
 
26
27
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
27
28
 
@@ -77,6 +78,8 @@ ${c.bold('Review gate (front half)')}
77
78
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
78
79
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
79
80
  yad gate status <epic> Show each review step + approvals
81
+ yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
82
+ (YAD-STATE-005); --push commits state.json to the default branch
80
83
  yad gate review <epic> [artifact] Print the grounding bundle for the review companion
81
84
  (artifact + risk + contract + PR + code-maps) — fun, easy review
82
85
  yad gate walkthrough <epic> [artifact] Grounding bundle + ordered risk-tagged stops for the
@@ -146,7 +149,11 @@ ${c.bold('Options')}
146
149
  --push check --fix / update: commit + push applied changes to the default branch
147
150
  --allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
148
151
  -h, --help Show this help
149
- -v, --version Print version`;
152
+ -v, --version Print version
153
+
154
+ ${c.bold('Environment')}
155
+ YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
156
+ YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
150
157
 
151
158
  const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
152
159
 
@@ -254,7 +261,7 @@ async function main() {
254
261
  const [, action, epic, artifact] = o._;
255
262
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
256
263
  if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
257
- if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
264
+ if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
258
265
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
259
266
  if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
260
267
  // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
@@ -264,10 +271,11 @@ async function main() {
264
271
  else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
265
272
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
266
273
  else if (action === 'status') await gateStatus(o.dir, { epic });
274
+ else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
267
275
  else if (action === 'review') await gateReview(o.dir, { epic, artifact });
268
276
  else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
269
277
  else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
270
- else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
278
+ else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
271
279
  break;
272
280
  }
273
281
  case 'review': {
@@ -364,4 +372,15 @@ main()
364
372
  } catch { /* reporting is best-effort — never mask the original failure */ }
365
373
  }
366
374
  })
367
- .finally(closePrompts);
375
+ // Runs for every command, success or failure, after any report prompt. Prints to stderr and never
376
+ // touches process.exitCode, so a command's stdout contract and exit status are unaffected.
377
+ // The try/finally is load-bearing, not defensive noise: a rejection here would escape as an
378
+ // unhandled rejection (exit 1 on an otherwise successful command) AND skip closePrompts(), leaving
379
+ // the readline handle open so the process never exits.
380
+ .finally(async () => {
381
+ try {
382
+ await maybeNotifyUpdate();
383
+ } catch { /* the notice is never worth failing or hanging a command over */ } finally {
384
+ closePrompts();
385
+ }
386
+ });
package/cli/doctor.mjs CHANGED
@@ -6,9 +6,9 @@ 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 } from './epic-state.mjs';
9
+ import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
10
10
  import { loadDebt } from './thread.mjs';
11
- import { gitHead } from './setup.mjs';
11
+ import { gitHead, insideWorkspace } from './setup.mjs';
12
12
  import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
13
13
 
14
14
  const MIN_NODE = 18;
@@ -18,6 +18,18 @@ const MIN_NODE = 18;
18
18
  const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
19
19
  // owner/repo slug from a git url (https or ssh), for the branch-protection probe.
20
20
  const repoSlug = (url) => ((url || '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/) || [])[1] || null;
21
+ // Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
22
+ // (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
23
+ // which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
24
+ // The path.sep suffix keeps /proj-evil from reading as inside /proj.
25
+ const underProjectRoot = (root, p) => {
26
+ const projectRoot = path.resolve(root);
27
+ return p === projectRoot || p.startsWith(projectRoot + path.sep);
28
+ };
29
+ // An absent path is only excused as "a sibling that lives elsewhere" when it is one the connect step
30
+ // would actually accept. A hand-edited registry pointing outside the workspace entirely (../../x) is
31
+ // corruption, and must not be reassured away as an expected sibling.
32
+ const isRegistrableSibling = (root, rpath) => insideWorkspace(root, rpath);
21
33
 
22
34
  // Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
23
35
  function check(checks, id, section, status, message, hint = '') {
@@ -213,7 +225,15 @@ export function projectChecks(checks, root) {
213
225
  // would read as "healthy") — an entry with no path is malformed.
214
226
  if (!repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${repo.name || '(unnamed)'}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
215
227
  const repoRoot = path.resolve(root, repo.path);
216
- if (!exists(repoRoot)) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo'); continue; }
228
+ // A registered repo may be a SIBLING of the hub (`../backend`, the standard multi-repo layout).
229
+ // Such a checkout is legitimately absent wherever only the hub is checked out — hub CI, a fresh
230
+ // clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
231
+ // still a hard fail: nothing but damage explains it.
232
+ if (!exists(repoRoot)) {
233
+ if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo');
234
+ else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the hub)`, 'expected when only the hub is checked out; clone it alongside the hub to work on it here');
235
+ continue;
236
+ }
217
237
  const head = gitHead(repoRoot);
218
238
  if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: ${repo.path} is not a git repository (or has no commits) [YAD-STATE-003]`, 'init/clone the repo, then re-connect it'); continue; }
219
239
  if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + repo.name + '` once it has code');
@@ -270,6 +290,13 @@ export function epicChecks(checks, root) {
270
290
  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');
271
291
  else {
272
292
  check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
293
+ // Chain consistency: a passed review gate whose author step was never closed. currentStep alone
294
+ // cannot see this, yet it blocks every later step (including the parallel test-cases track).
295
+ for (const v of stateInvariants(ledger.state)) {
296
+ check(checks, `epic:${e}:${v.authorStep}`, 'epics', 'fail',
297
+ `${e}: ${v.message} [${v.code}]`,
298
+ `run \`yad gate repair ${e}\` to close it`);
299
+ }
273
300
  // Migration guard (pre-3.0 model): under the current model CI records the ledger on the
274
301
  // default branch only at merge (when the step is already done), and writes nothing during
275
302
  // review — so an OPEN (non-done) review PR recorded here means it was opened under an older
@@ -184,6 +184,29 @@ export function findReviewStep(state, artifact) {
184
184
  export const isEscalated = (step) =>
185
185
  (step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t)) || step?.id === 'stories-review';
186
186
 
187
+ // The authoring step paired with a review gate: `stories-review` -> `stories`. Resolved BY ID (the
188
+ // same `-review` suffix convention `isSkippableStep` uses), never positionally — a chain may legally
189
+ // omit the author step (a change-epic that carries only the gate), and `steps[i-1]` would then point
190
+ // at an unrelated step. Returns null when the chain has no such step.
191
+ export function authorStepFor(state, reviewStep) {
192
+ const id = String(reviewStep?.id || '');
193
+ if (!id.endsWith('-review')) return null;
194
+ return state?.steps?.find((s) => s.id === id.replace(/-review$/, '')) || null;
195
+ }
196
+
197
+ // Closing a review gate implies its artifact was authored — so the CLI, not the authoring skill, is
198
+ // what makes `<step>.status = done` true. Without this an author step left at `in_progress` strands
199
+ // forever: `preconditionsMet` requires every PRIOR step done, so the parallel `test-cases` track (and
200
+ // every later step) stays blocked behind a review that already passed. Idempotent; a no-op on an
201
+ // absent step and on a `skipped` one (already `done`, carrying its skip provenance).
202
+ // Returns the id it closed, or null.
203
+ function closeAuthorStep(state, reviewStep) {
204
+ const author = authorStepFor(state, reviewStep);
205
+ if (!author || author.status === 'done') return null;
206
+ author.status = 'done';
207
+ return author.id;
208
+ }
209
+
187
210
  const uniqueBy = (arr, key) => {
188
211
  const seen = new Set();
189
212
  return arr.filter((x) => (seen.has(x[key]) ? false : seen.add(x[key])));
@@ -293,6 +316,10 @@ export function gatePredicate({
293
316
  export function advanceState(state, step) {
294
317
  const i = state.steps.findIndex((s) => s.id === step.id);
295
318
  state.steps[i] = { ...state.steps[i], status: 'done' };
319
+ // Defensive: `markInReview` normally closed the author step when the gate opened, but the CI bridge
320
+ // advances on a merge event without ever running it locally. Close it here too, so a passed gate can
321
+ // never leave its author step behind (issue #131).
322
+ closeAuthorStep(state, step);
296
323
  if (step.id === 'stories-review') {
297
324
  const tc = state.steps.find((s) => s.id === 'test-cases');
298
325
  if (tc && tc.status === 'blocked') tc.status = 'in_progress';
@@ -442,6 +469,9 @@ export function unskipStep(state, stepId) {
442
469
  export function markInReview(state, step) {
443
470
  const i = state.steps.findIndex((s) => s.id === step.id);
444
471
  if (state.steps[i].status !== 'done') state.steps[i].status = 'in_review';
472
+ // Opening a review gate means the artifact was authored — close the paired author step rather than
473
+ // trusting the authoring skill to have hand-edited state.json (issue #131).
474
+ closeAuthorStep(state, step);
445
475
  if (state.currentStep !== 'ready-for-build') state.currentStep = step.id;
446
476
  return state;
447
477
  }
@@ -567,6 +597,43 @@ export function preconditionsMet(state, stepId) {
567
597
  return { ok: true, blockedBy: null, reason: 'ready' };
568
598
  }
569
599
 
600
+ // PURE. Consistency invariants over a chain, for `doctor` (report) and `gate repair` (heal). Today one
601
+ // rule: a `review+approve` step that is `done` must have its paired author step `done` too — a gate
602
+ // cannot have passed on an unauthored artifact. Violations are epics damaged by a pre-fix `gate sync`
603
+ // (issue #131); they read as healthy to a `currentStep`-only check while silently blocking every later
604
+ // step through `preconditionsMet`.
605
+ //
606
+ // Deliberately NOT the broader "no non-done step precedes a done one": the parallel `test-cases` track
607
+ // legitimately sits `in_progress` after `stories-review` advanced the epic to ready-for-build.
608
+ // No FS / network. Returns [] on a missing or malformed chain (loadLedger already reports that).
609
+ export function stateInvariants(state) {
610
+ if (!state || !Array.isArray(state.steps)) return [];
611
+ const violations = [];
612
+ for (const step of state.steps) {
613
+ if (step.type !== 'review+approve' || step.status !== 'done') continue;
614
+ const author = authorStepFor(state, step);
615
+ if (!author || author.status === 'done') continue;
616
+ violations.push({
617
+ code: 'YAD-STATE-005',
618
+ reviewStep: step.id,
619
+ authorStep: author.id,
620
+ message: `${author.id} is '${author.status}' behind a completed ${step.id}`,
621
+ });
622
+ }
623
+ return violations;
624
+ }
625
+
626
+ // Apply the repair `stateInvariants` describes: close every author step stranded behind a done review
627
+ // gate. Mutates `state` and returns the ids it closed (empty when already consistent — idempotent).
628
+ export function repairState(state) {
629
+ const closed = [];
630
+ for (const v of stateInvariants(state)) {
631
+ const author = state.steps.find((s) => s.id === v.authorStep);
632
+ if (author && author.status !== 'done') { author.status = 'done'; closed.push(author.id); }
633
+ }
634
+ return closed;
635
+ }
636
+
570
637
  // PURE next-action resolver for ONE epic's ledger — what `yad next <epic>` prints. Reads state + the
571
638
  // recorded review PRs only. kind:
572
639
  // 'new' — no epic state yet (seed one with yad-epic)
package/cli/errors.mjs CHANGED
@@ -20,6 +20,7 @@ export const CODES = {
20
20
  'YAD-STATE-002': 'a ledger/config JSON file parses but has the wrong shape',
21
21
  'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
22
22
  'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
23
+ 'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
23
24
  'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
24
25
  'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
25
26
  'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
package/cli/gate.mjs CHANGED
@@ -11,8 +11,9 @@ 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
- upsertHubPr, DISCOVERY_FILES,
14
+ upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
15
15
  } from './epic-state.mjs';
16
+ import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
16
17
  import {
17
18
  readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
18
19
  getPrBody, editPrBody, postComment,
@@ -486,6 +487,70 @@ export async function gateStatus(root, { epic } = {}) {
486
487
  }
487
488
  }
488
489
 
490
+ // PURE — the audit-trail commit message for a state repair (mirrors buildCheckpointMessage). The
491
+ // subject passes the hub commit-message gate (valid type `chore`, scope `gate`, no trailing period)
492
+ // and carries [skip ci]: the repair lands on the default branch, where a re-triggered gate workflow
493
+ // would have nothing to do. No Task trailer and no Co-Authored-By footer — this is machine state a
494
+ // human corrected, not an authored code change.
495
+ export function buildRepairMessage({ epic, steps }) {
496
+ const subject = 'chore(gate): repair epic state — close stranded author step(s) [skip ci]';
497
+ return `${subject}\n\nEpic: ${epic}\nClosed: ${steps.join(', ')}\nReason: YAD-STATE-005 — author step(s) left behind a completed review gate`;
498
+ }
499
+
500
+ // `yad gate repair <epic>` — heal the chain inconsistency `doctor` reports as YAD-STATE-005: an author
501
+ // step stranded at in_progress behind a review gate that already advanced (issue #131). A pre-fix
502
+ // `gate sync` could leave this, and it never self-heals — sync skips a step that is already `done` — so
503
+ // the damage needs an explicit, auditable correction.
504
+ //
505
+ // Only state.json is touched, and `--push` commits ONLY that file: a `[skip ci]` chore commit must never
506
+ // sweep up an unrelated edit. It lands on the DEFAULT branch, where `ledger-guard` (which polices the
507
+ // machine-written ledger on review PRs) does not apply — so this stays compatible with "CI is the sole
508
+ // writer of the ledger" during review.
509
+ export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false } = {}) {
510
+ const epicDir = epicRoot(root, epic);
511
+ const ledger = loadLedger(epicDir);
512
+ if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { closed: [] }; }
513
+
514
+ log(c.bold(`\nyad gate repair ${c.dim(epic)}`));
515
+ const violations = stateInvariants(ledger.state);
516
+ if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { closed: [] }; }
517
+ for (const v of violations) warn(`${v.message} [${v.code}]`);
518
+
519
+ const closed = repairState(ledger.state);
520
+ if (dryRun) { info('dry run — nothing written'); return { closed }; }
521
+ writeJSON(ledger.files.state, ledger.state);
522
+ ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
523
+ if (!push) { hand('re-run `yad doctor` to confirm, then commit epics/*/.sdlc/state.json (or re-run with --push)'); return { closed }; }
524
+
525
+ // --- publish: narrow, default-branch-only commit of the one repaired file ---
526
+ preflightGuardReadiness(root);
527
+ const git = hubGit(root);
528
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
529
+ const defaultBranch = resolveDefaultBranch(git, loadHub(root).hub);
530
+ if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return { closed };
531
+
532
+ const spec = path.relative(root, ledger.files.state);
533
+ if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return { closed }; }
534
+ if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return { closed }; }
535
+
536
+ const message = buildRepairMessage({ epic, steps: closed });
537
+ const cm = git('commit', '-m', message, '--', spec);
538
+ if (!cm.ok) {
539
+ git('reset', '-q', '--', spec); // never leave it staged for an unrelated commit to sweep up
540
+ fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
541
+ process.exitCode = 1;
542
+ return { closed };
543
+ }
544
+ ok(`committed the repair: ${c.dim(message.split('\n')[0])}`);
545
+ // Push HEAD to its OWN branch — with --allow-branch we are not on the default branch, and pushing
546
+ // HEAD:defaultBranch would publish a WIP branch straight to it.
547
+ if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { closed }; }
548
+ fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
549
+ hand('run `git pull --rebase` and re-run `yad gate repair <epic> --push`');
550
+ process.exitCode = 1;
551
+ return { closed };
552
+ }
553
+
489
554
  // `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
490
555
  // the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
491
556
  // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
package/cli/manifest.mjs CHANGED
@@ -10,6 +10,10 @@ import { readFileSync } from 'node:fs';
10
10
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
11
  export const VERSION = pkg.version;
12
12
 
13
+ // The published npm package name — the registry path the update check queries, and the name in the
14
+ // `npm install <name> -g` line it prints. Read from package.json so a rename can never desync them.
15
+ export const PKG_NAME = pkg.name;
16
+
13
17
  // The upstream yadflow repo, as `owner/name` — where `yad report` files issues. Derived from
14
18
  // package.json `bugs.url` (the single source of truth) so it tracks a fork/rename automatically;
15
19
  // falls back to the canonical slug if the field is ever malformed.
package/cli/setup.mjs CHANGED
@@ -43,14 +43,28 @@ export function detectPlatform(remoteUrl = '') {
43
43
  }
44
44
  export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
45
45
 
46
- // Containment: every repo path must live inside the project root — the registry path is later
47
- // joined and executed against (repomix cwd, CI wiring), and even the read-only remote probe must
48
- // not run against an arbitrary outside path. The path.sep-suffixed compare avoids the
49
- // /proj vs /proj-evil prefix trap.
50
- export function insideRoot(root, rpath) {
46
+ // Containment: a repo path must live inside the WORKSPACE — the hub root's parent. The standard
47
+ // multi-repo layout puts the code repos BESIDE the hub, not under it (project/{product,backend,frontend}),
48
+ // so `../backend` has to register; containing to the hub root instead forced separate git repos to nest
49
+ // inside the hub's own repo (issue #129). A nested path (demo-repos/api) still works.
50
+ //
51
+ // The bound stays real: the registry path is later joined and executed against (repomix cwd,
52
+ // .coderabbit.yaml + CI wiring), and even the read-only remote probe must not run against an arbitrary
53
+ // outside path — so `../../elsewhere` and absolute-outside paths are still rejected. The path.sep-suffixed
54
+ // compare avoids the /project vs /project-evil prefix trap, now one level up at the workspace.
55
+ // A sibling of the hub (../product-evil) is, correctly, indistinguishable from ../backend: both are
56
+ // ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the hub, so
57
+ // registering it as a code repo would point repomix and the CI writes at the whole tree. Only the hub
58
+ // root itself (a monorepo, `.`) and strict descendants of the workspace pass.
59
+ //
60
+ // Place the hub one level below the workspace root (project/product), not directly in $HOME — the
61
+ // workspace is the trust boundary, and a shallow hub makes every sibling of it registerable.
62
+ export function insideWorkspace(root, rpath) {
51
63
  const projectRoot = path.resolve(root);
64
+ const parent = path.dirname(projectRoot);
65
+ const workspace = parent === projectRoot ? projectRoot : parent; // degenerate: root is the fs root
52
66
  const resolved = path.resolve(projectRoot, rpath);
53
- return resolved === projectRoot || resolved.startsWith(projectRoot + path.sep);
67
+ return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
54
68
  }
55
69
 
56
70
  // Validate + record one code repo into the registry (the testable half of the connect loop).
@@ -192,8 +206,8 @@ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
192
206
  }
193
207
 
194
208
  export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
195
- if (!insideRoot(root, rpath)) {
196
- warn(`${rpath} resolves outside the project root — skipped`);
209
+ if (!insideWorkspace(root, rpath)) {
210
+ warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
197
211
  return null;
198
212
  }
199
213
  const repoRoot = path.resolve(root, rpath);
@@ -633,8 +647,9 @@ export async function runSetup(root, opts = {}) {
633
647
  const name = await ask(' repo name (blank to finish)', '');
634
648
  if (!name) break;
635
649
  if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
636
- const rpath = await ask(' path (relative to project root)', `demo-repos/${name}`);
637
- if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root skipped`); continue; }
650
+ // Siblings of the hub are the common layout (project/{product,backend}) — `../backend` is valid.
651
+ const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
652
+ if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
638
653
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
639
654
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
640
655
  // Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
@@ -0,0 +1,179 @@
1
+ // "A new yadflow is out" — the update disclaimer printed after every `yad` command.
2
+ //
3
+ // Three rules make this safe to run on every invocation:
4
+ // 1. It never throws and never touches process.exitCode. A dead registry, an unwritable home, or a
5
+ // malformed cache degrades to silence, never to a failed command.
6
+ // 2. It prints to STDERR (the `note()` convention in lib.mjs), so `--json` commands, the grounding
7
+ // bundles, and `yad -v` keep a machine-readable STDOUT.
8
+ // 3. It is cache-first: the network is touched at most once per TTL. Every other run is pure disk.
9
+ //
10
+ // Deliberately NOT suppressed on a non-TTY. Skills invoke `yad` through an agent's Bash tool, where
11
+ // stdout/stderr are piped — the usual "only notify on a TTY" guard would hide the notice from exactly
12
+ // the case we most want it in. `CI` is the suppression signal instead.
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { VERSION, PKG_NAME, UPSTREAM_REPO } from './manifest.mjs';
16
+ import { c, exists, readJSON, writeJSON, PKG_ROOT } from './lib.mjs';
17
+
18
+ export const DAY_MS = 24 * 60 * 60 * 1000;
19
+ const FETCH_TIMEOUT_MS = 1500;
20
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
21
+
22
+ // An env var counts as "set" only when it carries a meaningful value — `CI=false` and `CI=0` are
23
+ // common in shells that always export the name.
24
+ const truthy = (v) => !!v && v !== '0' && v !== 'false';
25
+
26
+ // ---- semver -------------------------------------------------------------
27
+ // A deliberately small parser: we only ever compare a released `x.y.z` against another. Anything the
28
+ // registry hands us that is not a clean triple (garbage, a range, undefined) yields null → no notice.
29
+ export function parseVersion(v) {
30
+ if (typeof v !== 'string') return null;
31
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
32
+ if (!m) return null;
33
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ?? null };
34
+ }
35
+
36
+ // The canonical `x.y.z` form. `parseVersion` tolerates a leading `v`, so anything interpolated into
37
+ // the banner or the release-tag URL must be normalized first — otherwise a `v`-prefixed `latest`
38
+ // (from a mirror registry or a hand-edited cache) yields a dead `.../releases/tag/vv3.11.0` link.
39
+ export function normalizeVersion(v) {
40
+ const p = parseVersion(v);
41
+ return p ? `${p.major}.${p.minor}.${p.patch}${p.pre ? `-${p.pre}` : ''}` : null;
42
+ }
43
+
44
+ // True when `latest` is a release strictly newer than `current`. A prerelease `latest` never nags a
45
+ // user on a stable version — dist-tags.latest should never be one, but a mis-tagged publish would
46
+ // otherwise pester every user until it was fixed. Prereleases are not ordered against each other
47
+ // (rc.2 does not "beat" rc.1); the only prerelease transition we announce is rc → its stable.
48
+ export function isNewer(latest, current) {
49
+ const l = parseVersion(latest);
50
+ const cur = parseVersion(current);
51
+ if (!l || !cur) return false;
52
+ if (l.pre && !cur.pre) return false;
53
+ if (l.major !== cur.major) return l.major > cur.major;
54
+ if (l.minor !== cur.minor) return l.minor > cur.minor;
55
+ if (l.patch !== cur.patch) return l.patch > cur.patch;
56
+ // Same x.y.z: the stable release supersedes the prerelease of that same version, so a user sitting
57
+ // on 4.0.0-rc.1 is told when 4.0.0 final ships.
58
+ return !l.pre && !!cur.pre;
59
+ }
60
+
61
+ // ---- registry -----------------------------------------------------------
62
+ export function registryBase({ env = process.env } = {}) {
63
+ const base = env.YAD_REGISTRY_URL || env.npm_config_registry || DEFAULT_REGISTRY;
64
+ return base.replace(/\/+$/, '');
65
+ }
66
+
67
+ // The `dist-tags` endpoint returns a few dozen bytes (`{"latest":"3.10.1"}`); the packument at
68
+ // /<pkg> or /<pkg>/latest is orders of magnitude larger for the same one field.
69
+ // `fetchImpl` must NOT default to a bare `fetch` in the parameter list: default parameters are
70
+ // evaluated before the function body's try/catch is entered, so on a runtime without a global fetch
71
+ // (Node 18 started with --no-experimental-fetch) that would throw a ReferenceError straight past
72
+ // every guard here and out through bin/yad.mjs's .finally. Resolve it inside the try instead.
73
+ export async function fetchLatest({ env = process.env, timeoutMs = FETCH_TIMEOUT_MS, fetchImpl } = {}) {
74
+ try {
75
+ const doFetch = fetchImpl ?? globalThis.fetch;
76
+ if (typeof doFetch !== 'function') return null; // no fetch on this runtime — stay quiet
77
+ const url = `${registryBase({ env })}/-/package/${encodeURIComponent(PKG_NAME)}/dist-tags`;
78
+ const res = await doFetch(url, {
79
+ signal: AbortSignal.timeout(timeoutMs),
80
+ headers: { accept: 'application/json' },
81
+ });
82
+ if (!res.ok) return null;
83
+ const tags = await res.json();
84
+ return typeof tags?.latest === 'string' ? tags.latest : null;
85
+ } catch {
86
+ return null; // offline, DNS failure, timeout, non-JSON body — all mean "we don't know", not "fail"
87
+ }
88
+ }
89
+
90
+ // ---- cache --------------------------------------------------------------
91
+ // The CLI's only per-user state. Everything else it writes is project-scoped under .sdlc/.
92
+ export function cacheFile({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
93
+ if (env.YAD_CACHE_DIR) return path.join(env.YAD_CACHE_DIR, 'update-check.json');
94
+ if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'yadflow', 'update-check.json');
95
+ if (platform === 'win32' && env.LOCALAPPDATA) return path.join(env.LOCALAPPDATA, 'yadflow', 'update-check.json');
96
+ return path.join(home, '.cache', 'yadflow', 'update-check.json');
97
+ }
98
+
99
+ export const readCache = (file) => readJSON(file, null);
100
+
101
+ // A read-only home (CI images, locked-down laptops, a root-owned ~/.cache) must not break `yad`.
102
+ // Losing the cache only costs one registry round-trip per command.
103
+ export function writeCache(file, data) {
104
+ try {
105
+ writeJSON(file, data);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ // ---- suppression --------------------------------------------------------
113
+ // `pkgRoot` carrying a .git means yad is running from a source checkout (`npm run yad`, the test
114
+ // suite's execFileSync calls), not from a global npm install. Nagging a maintainer about the version
115
+ // they are editing is noise.
116
+ export function shouldSuppress({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
117
+ if (truthy(env.YAD_NO_UPDATE_NOTIFIER)) return true;
118
+ if (truthy(env.CI)) return true;
119
+ if (truthy(env.SDLC_NONINTERACTIVE)) return true;
120
+ if (exists(path.join(pkgRoot, '.git'))) return true;
121
+ return false;
122
+ }
123
+
124
+ // ---- banner -------------------------------------------------------------
125
+ // `yad update` is the necessary second half: upgrading the global CLI leaves this project's installed
126
+ // yad-* skills stamped at the old version in .sdlc/cli-version.json, which `yad doctor` then flags.
127
+ export function formatBanner(current, latest) {
128
+ // Normalize so a `v`-prefixed input can never produce `.../releases/tag/vv3.11.0`. Callers only
129
+ // reach here after isNewer(), so parseVersion has already accepted both — the ?? is belt and braces.
130
+ const v = normalizeVersion(latest) ?? latest;
131
+ const url = `https://github.com/${UPSTREAM_REPO}/releases/tag/v${v}`;
132
+ return [
133
+ '',
134
+ ` ${c.yellow('!')} ${c.bold(`${PKG_NAME} update available`)} — ${c.dim(current)} → ${c.green(v)}`,
135
+ ` ${c.dim('Changelog:')} ${url}`,
136
+ ` ${c.dim('Update:')} ${c.cyan(`npm install ${PKG_NAME} -g`)}`,
137
+ ` ${c.dim('Then:')} ${c.cyan('yad update')} ${c.dim("(re-sync this project's yad-* skills)")}`,
138
+ ].join('\n');
139
+ }
140
+
141
+ // ---- orchestrator -------------------------------------------------------
142
+ // Returns true when a banner was printed (tests assert on this; callers ignore it).
143
+ export async function maybeNotifyUpdate({
144
+ env = process.env,
145
+ now = Date.now(),
146
+ pkgRoot = PKG_ROOT,
147
+ ttlMs = DAY_MS,
148
+ current = VERSION,
149
+ out = (s) => console.error(s),
150
+ fetchImpl, // resolved to globalThis.fetch inside fetchLatest — see the note there
151
+ } = {}) {
152
+ try {
153
+ if (shouldSuppress({ env, pkgRoot })) return false;
154
+
155
+ const file = cacheFile({ env });
156
+ const cache = readCache(file);
157
+ // `age >= 0` matters: a lastCheck stamped in the future (a clock that jumped forward, an NTP
158
+ // correction, a cache synced from another machine) yields a negative age, which would read as
159
+ // "fresh" and pin a stale `latest` until real time caught up. Treat it as expired instead.
160
+ const age = now - cache?.lastCheck;
161
+ const fresh = Number.isFinite(cache?.lastCheck) && age >= 0 && age < ttlMs;
162
+
163
+ let latest = typeof cache?.latest === 'string' ? cache.latest : null;
164
+ if (!fresh) {
165
+ const fetched = await fetchLatest({ env, fetchImpl });
166
+ if (fetched) latest = fetched;
167
+ // Stamp lastCheck even when the fetch failed: an offline user would otherwise pay the full
168
+ // timeout on every single command. We keep any previously-known `latest` so the banner survives
169
+ // a temporary outage.
170
+ writeCache(file, { lastCheck: now, latest });
171
+ }
172
+
173
+ if (!isNewer(latest, current)) return false;
174
+ out(formatBanner(current, latest));
175
+ return true;
176
+ } catch {
177
+ return false; // never let the notifier turn a successful command into a failed one
178
+ }
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.10.1",
3
+ "version": "3.11.1",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -34,6 +34,24 @@ epic's approvals. It only writes the project-wide registry and the per-repo cont
34
34
  `roles` map, e.g. `roles: hub=owner,reviewer backend=domain-owner`). Validate the login against the
35
35
  hub (`gh api users/<login>` / `glab api users?username=`); a miss is flagged `unverified` (warn-only).
36
36
  - `path` — local path to the code repo (relative to `{project-root}` or absolute). For local repos.
37
+ It must resolve inside the **workspace** — the project root's parent — so the standard layout, where
38
+ the code repos sit **beside** the hub rather than under it, registers as `../backend`:
39
+
40
+ ```text
41
+ project/ <- the workspace (containment boundary)
42
+ product/ <- the hub repo; `yad setup` runs here
43
+ backend/ <- ../backend
44
+ frontend/ <- ../frontend
45
+ ```
46
+
47
+ A nested path (`demo-repos/api`, the demo layout) also works. A path that escapes the workspace
48
+ (`../../elsewhere`, or an absolute path outside it) is rejected and skipped — the registry path is
49
+ later used as a working directory (repomix) and written into (`.coderabbit.yaml`, CI wiring).
50
+ It is stored **exactly as typed**; every consumer re-resolves it against the project root.
51
+
52
+ The workspace is the trust boundary, so **put the hub one level below it** (`project/product`), not
53
+ directly in `$HOME` — with the hub at `~/product` the workspace becomes `~` and every home-dir
54
+ sibling turns into a registrable repo. The workspace directory itself (`..`) is never registrable.
37
55
  - `git_url` — optional remote (SSH or HTTPS; GitHub or GitLab). Used when the repo is not yet on disk.
38
56
  - `domain_owners` — the engineer(s) who own this repo's domain (a repo may have several; drives per-repo
39
57
  review routing). Each name is also written into that person's `roles[<repo>]` map in `hub.json`.
@@ -169,6 +169,11 @@ In `state.json`: set `epic.status: "done"`, set `epic-review.status: "in_review"
169
169
  `currentStep: "epic-review"`. Write `state.json`. Do **not** re-seed and do **not** touch
170
170
  `approvals.json` — only real reviewers approve, through the gate.
171
171
 
172
+ > Since 3.11 the CLI closes the authoring step itself whenever its review gate opens or advances
173
+ > (`yad gate open` / `sync`), so this edit is a no-op when the gate has already run. Keep making it —
174
+ > it keeps `state.json` truthful before the gate opens — but it is no longer load-bearing: an epic
175
+ > whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`).
176
+
172
177
  ### Step 6 — Stop at the gate (do NOT advance)
173
178
  Report: epic ID, the path to `epic.md`, and that the next action is **review** via
174
179
  `yad-review-gate`. **Never mark the epic-review step approved here** — only real reviewers do that
@@ -169,6 +169,13 @@ opens the PR only; local `yad gate sync` is advisory in bridge mode (writes noth
169
169
  everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
170
170
  these guards are no-ops.)
171
171
 
172
+ **The one sanctioned human ledger write: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
173
+ authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
174
+ is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
175
+ only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
176
+ roster-allowlisted author) vets it instead, exactly as it does for `yad checkpoint` and `yad update`.
177
+ The command refuses to commit off the default branch unless `--allow-branch` is passed.
178
+
172
179
  **Loop prevention & races.** The only ledger commit lands on the **default branch** at merge, which
173
180
  fires no PR trigger; it carries `[skip ci]` to guard sibling workflows. Because CI never pushes the
174
181
  review branch, there is no `synchronize` / MR-pipeline loop to prevent — and it is now **safe to enable**
@@ -55,7 +55,9 @@ re-approve. (Hash recipe: `yad-architecture/references/contract-format.md`.)
55
55
  4. `action: approve` approver *bob* role *reviewer* → ledger entry added. Predicate:
56
56
  `|owners|=1, |reviewers|=1` → **base pass**.
57
57
  5. `action: advance` → `epic-review.status=done`, `architecture.status=in_progress`,
58
- `currentStep=architecture`. Gate reports the advance.
58
+ `currentStep=architecture`. Gate reports the advance. The paired authoring step (`epic`) is closed
59
+ too, if it was not already — a gate cannot have passed on an unauthored artifact. `doctor` reports
60
+ any surviving violation as `YAD-STATE-005`; `yad gate repair <epic>` heals it.
59
61
 
60
62
  ## Participation record (comments.json)
61
63
  `approvals.json` answers "who approved"; `.sdlc/comments.json` answers "who reviewed/commented". The
@@ -104,6 +104,10 @@ As a <role>, I want <capability>, so that <outcome>.
104
104
  In `state.json`: set `stories.status: "done"`, set `stories-review.status: "in_review"`, and set
105
105
  `currentStep: "stories-review"`. Write `state.json`. Do **not** touch `approvals.json`.
106
106
 
107
+ > Since 3.11 the CLI also closes the authoring step when its review gate opens or advances, so this
108
+ > edit is a no-op once `yad gate open` has run. A `stories` step left `in_progress` behind a passed
109
+ > `stories-review` used to block the parallel `test-cases` track (`YAD-STATE-005`).
110
+
107
111
  ### Step 7 — Stop at the gate (do NOT advance)
108
112
  Report: the story IDs created, the repos each touches, and that the next action is **review** via
109
113
  `yad-review-gate`. Note that this review routes **per-repo reviewers**: owner + 1 reviewer **plus**, for