yadflow 3.11.0 → 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,11 @@
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
+
1
9
  # [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
2
10
 
3
11
 
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';
@@ -78,6 +78,8 @@ ${c.bold('Review gate (front half)')}
78
78
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
79
79
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
80
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
81
83
  yad gate review <epic> [artifact] Print the grounding bundle for the review companion
82
84
  (artifact + risk + contract + PR + code-maps) — fun, easy review
83
85
  yad gate walkthrough <epic> [artifact] Grounding bundle + ordered risk-tagged stops for the
@@ -259,7 +261,7 @@ async function main() {
259
261
  const [, action, epic, artifact] = o._;
260
262
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
261
263
  if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
262
- 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; }
263
265
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
264
266
  if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
265
267
  // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
@@ -269,10 +271,11 @@ async function main() {
269
271
  else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
270
272
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
271
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 });
272
275
  else if (action === 'review') await gateReview(o.dir, { epic, artifact });
273
276
  else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
274
277
  else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
275
- 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; }
276
279
  break;
277
280
  }
278
281
  case 'review': {
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/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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.11.0",
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