yadflow 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * render an epic's kind as its noun in next/thread/status ([42e80e1](https://github.com/abdelrahmannasr/yadflow/commit/42e80e19e20e129a2a3941c85db6777a66ab00cc))
7
+
8
+ ## [3.11.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.0...v3.11.1) (2026-07-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **gate:** close the authoring step when its review gate advances ([8baaed9](https://github.com/abdelrahmannasr/yadflow/commit/8baaed9416a063013b5e6acf1ae36c5a1b3c920b))
14
+ * **setup:** contain repo paths to the workspace so sibling repos connect ([265a7ae](https://github.com/abdelrahmannasr/yadflow/commit/265a7ae543fccf8697ff89726ecb1a6aadde62ce))
15
+
1
16
  # [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
2
17
 
3
18
 
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)
@@ -688,6 +755,13 @@ export function readFrontmatter(file) {
688
755
 
689
756
  const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
690
757
 
758
+ // The human-facing noun for a lineage kind. Presentation only — the artifact is always an epic
759
+ // (`EP-<slug>`); this just renders WHAT KIND of work it is so `yad next`/`yad thread`/`yad status`
760
+ // read as "Defect EP-…" / "Change request EP-…" instead of a generic "Epic". `feature` (and any
761
+ // unknown/absent kind) falls back to "Epic". A bug is a defect (kind:defect) — no separate noun.
762
+ export const KIND_NOUN = { feature: 'Epic', change: 'Change request', defect: 'Defect', hotfix: 'Hotfix' };
763
+ export const kindNoun = (kind) => KIND_NOUN[kind] || 'Epic';
764
+
691
765
  // The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
692
766
  // so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
693
767
  export function epicLineage(root, 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/next.mjs CHANGED
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
15
15
  import { PROJECT_FILES } from './manifest.mjs';
16
- import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, DISCOVERY_EPIC } from './epic-state.mjs';
16
+ import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, epicLineage, kindNoun, DISCOVERY_EPIC } from './epic-state.mjs';
17
17
 
18
18
  // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
19
19
  // missing/old hub.json never breaks the driver.
@@ -102,7 +102,10 @@ function actionLine(a, { solo } = {}) {
102
102
 
103
103
  // Full, friendly printout for a single epic.
104
104
  function printAction(a, { solo } = {}) {
105
- log(`\n ${c.bold(a.epicId || '(epic)')} ${c.dim(`— ${a.why}`)}`);
105
+ // Prefix the id with the kind noun (Defect / Change request / Hotfix / Epic) so a glance says what
106
+ // kind of work this is. The discovery front-zero is not a feature epic — leave it un-prefixed.
107
+ const noun = a.lineageKind && a.epicId !== DISCOVERY_EPIC ? `${kindNoun(a.lineageKind)} ` : '';
108
+ log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)} ${c.dim(`— ${a.why}`)}`);
106
109
  // In the build half with live lanes, print each story/repo's next sub-step + remaining chain instead
107
110
  // of the single static hint; otherwise the one actionable line.
108
111
  if (a.kind === 'build' && a.builds?.length) printBuildLanes(a.builds);
@@ -139,7 +142,10 @@ function generalNext(root, { all } = {}) {
139
142
  return;
140
143
  }
141
144
 
142
- const actions = featureEpics.map((id) => nextAction(loadLedger(epicRoot(root, id)), { epic: id }));
145
+ const actions = featureEpics.map((id) => ({
146
+ ...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
147
+ lineageKind: epicLineage(root, id).kind,
148
+ }));
143
149
  if (discoveryOpen) printAction(discoveryAction, { solo }); // an unfinished discovery comes first
144
150
 
145
151
  if (featureEpics.length === 1 || all) {
@@ -148,7 +154,7 @@ function generalNext(root, { all } = {}) {
148
154
  }
149
155
  // Several epics — list each with a one-liner, then point at the per-epic / --all views.
150
156
  log(`\n ${c.bold(`${featureEpics.length} epics`)} ${c.dim('— next action each:')}`);
151
- for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
157
+ for (const a of actions) log(` ${c.cyan(`${kindNoun(a.lineageKind)} ${a.epicId}`)} ${actionLine(a, { solo })}`);
152
158
  info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
153
159
  }
154
160
 
@@ -183,5 +189,8 @@ export async function runNext(root, { epic, check, all } = {}) {
183
189
  process.exitCode = 1;
184
190
  return;
185
191
  }
186
- printAction(nextAction(loadLedger(epicDir), { epic }), { solo: isSolo(root) });
192
+ printAction(
193
+ { ...nextAction(loadLedger(epicDir), { epic }), lineageKind: epicLineage(root, epic).kind },
194
+ { solo: isSolo(root) },
195
+ );
187
196
  }
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/cli/thread.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
8
8
  import { readShips } from './ledger.mjs';
9
9
  import {
10
- epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic,
10
+ epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic, kindNoun,
11
11
  resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
12
12
  } from './epic-state.mjs';
13
13
 
@@ -73,7 +73,10 @@ export function threadSummary(root, threadOrEpic) {
73
73
  };
74
74
  }
75
75
 
76
- const KIND_TAG = { feature: c.green('feature'), change: c.cyan('change'), defect: c.yellow('defect'), hotfix: c.red('hotfix') };
76
+ // Colour a node's kind noun for the tree render. The noun words live in one place (`kindNoun`); this
77
+ // only layers the per-kind colour on top, so the two never drift. Unknown kind → uncoloured noun.
78
+ const KIND_COLOR = { feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red };
79
+ const kindTag = (kind) => (KIND_COLOR[kind] || ((s) => s))(kindNoun(kind));
77
80
 
78
81
  export async function runThread(root, { epic, json = false } = {}) {
79
82
  if (!epic) {
@@ -103,7 +106,7 @@ export async function runThread(root, { epic, json = false } = {}) {
103
106
  log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
104
107
  if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
105
108
  for (const n of s.nodes) {
106
- const tag = KIND_TAG[n.kind] || n.kind;
109
+ const tag = kindTag(n.kind);
107
110
  const seal = n.sealed ? c.dim(' [sealed]') : '';
108
111
  const stub = n.stub ? c.yellow(' [stub · backfill pending]') : '';
109
112
  const dep = n.depth ? c.dim(` ${n.depth}`) : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
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
@@ -35,8 +35,11 @@ Do not modify any of them.
35
35
  ### Step 3 — Report
36
36
  Print, in this order:
37
37
 
38
- 1. **Epic:** `epicId`, `status` from `epic.md` frontmatter, `currentStep`, and `repos` (the touched
39
- domains).
38
+ 1. **Header:** render the kind noun from `epic.md` frontmatter `kind` **Change request** (`change`),
39
+ **Defect** (`defect`), **Hotfix** (`hotfix`), or **Epic** (`feature`, and the default when `kind` is
40
+ absent) — followed by `epicId`, then `status` from `epic.md` frontmatter, `currentStep`, and `repos`
41
+ (the touched domains). Example: `Defect EP-istifta-queue-filter — draft @ stories`. A bug is a defect
42
+ (`kind: defect`) — there is no separate noun. This is presentation only; the artifact is still an epic.
40
43
  2. **Steps table** — for every front step in `steps[]` order (10, or 12 when the optional analysis step
41
44
  was run): `id`, `type`, `status`, `assistance`, `automation`, `locked`, and `risk_tags`. Mark the
42
45
  `currentStep` with `→`. The gating chain is `[analysis → analysis-review →] epic → epic-review →
@@ -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
@@ -48,7 +48,9 @@ deterministically; theme from the design system). The thread maps onto the shell
48
48
  = what it re-authored, its side-effects = the ships it produced + any contract re-lock.
49
49
  - **System components** = the artifacts (epic/architecture/contract/ui/stories/test-cases), each labelled
50
50
  with the epic that currently **owns** it (from the resolved map).
51
- - Colour nodes by `kind` (feature/change/defect/hotfix); mark sealed epics and open debt.
51
+ - Label and colour nodes by `kind` render each node's kind noun (**Change request** / **Defect** /
52
+ **Hotfix** / **Epic** for feature) alongside its id, not the generic word "epic"; mark sealed epics and
53
+ open debt. (A bug is a defect — `kind: defect`. Presentation only; every node is still an epic.)
52
54
 
53
55
  ### Step 4 — Emit `thread-resolved.md` (the current-truth map — derived, non-authoritative)
54
56
  Write `epics/<thread>/thread-resolved.md`: for each artifact base, the **owning epic** (the latest in the
@@ -58,7 +60,7 @@ is the file the next `yad-change` / `yad-epic` reads as "the feature's current t
58
60
 
59
61
  ### Step 5 — Emit `TIMELINE.md` + (optional) deploy
60
62
  Write a short `epics/<thread>/TIMELINE.md` (the chain, what each node changed, ships, open debt) for a
61
- plain-text read. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
63
+ plain-text read — head each node with its kind noun (Change request / Defect / Hotfix / Epic) + id. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
62
64
 
63
65
  ## Hard rules
64
66