yadflow 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,9 @@
1
- # [2.13.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.12.0...v2.13.0) (2026-06-16)
1
+ # [2.14.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.13.0...v2.14.0) (2026-06-21)
2
2
 
3
3
 
4
4
  ### Features
5
5
 
6
- * **docs:** make the report the main documentation, mount the SPA under /app/ ([#71](https://github.com/abdelrahmannasr/yadflow/issues/71)) ([1993e4d](https://github.com/abdelrahmannasr/yadflow/commit/1993e4dc282df281474ca1923acd52dbd1262dcb))
6
+ * yad next driver, precondition guards, solo mode, and guided setup interview ([#72](https://github.com/abdelrahmannasr/yadflow/issues/72)) ([7125c9d](https://github.com/abdelrahmannasr/yadflow/commit/7125c9d80043cb282a7be4f08dfaa95ddf94594a))
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/README.md CHANGED
@@ -85,7 +85,8 @@ with `npx` from your **product hub** repo — no clone needed.
85
85
 
86
86
  | Command | What it does |
87
87
  |---------|--------------|
88
- | `npx yadflow setup` | Guided first-run wizard (the steps below). |
88
+ | `npx yadflow setup` | Guided first-run wizard — a short **profile interview** (solo/team, greenfield/brownfield, monorepo/separate) then the branched steps below. Pre-answer for CI/scripts with `--solo`/`--team <n>`, `--greenfield`/`--brownfield`, `--monorepo`/`--separate`, `--tools`. |
89
+ | `yad next [<epic>]` | **Where am I / what next.** With no epic: project-wide orientation — the one next action (run setup, start an epic, or the single active epic's step). With an epic: that epic's exact next action (a skill to invoke or a `yad` command to run). `yad next <epic> --check <step>` exits non-zero when a step is run out of order (the precondition guard); `yad next --all` lists every epic's next action. |
89
90
  | `npx yadflow check` | Read-only report: what is **missing** / **outdated** (drifted) / **stale** (code-context) / **legacy** (pre-2.0 `sdlc-*` names) vs the bundled manifest. |
90
91
  | `npx yadflow check --fix` | Reconcile: fill what is missing **and** update what changed — touches nothing already correct. |
91
92
  | `npx yadflow update` | Apply drift only (alias for `check --fix --scope=changed`). Also migrates a pre-2.0 install in place: `sdlc-*` skill copies and marker-owned `sdlc-*.yml` CI files are replaced by their `yad-*` names (a same-named file *you* authored is never touched). |
@@ -120,6 +121,13 @@ The merge click is the human approval act, so front steps still never `machine_a
120
121
  **revoked when the reviewed artifact actually changes** (re-hash), giving reviewers a fresh pass. With no
121
122
  hub platform / no `gh`/`glab`, the gate degrades to file-only with no error.
122
123
 
124
+ **Solo mode.** A lone developer can't approve their own PR on GitHub, so an approval requirement would
125
+ deadlock them. Opt in (`yad setup --solo`, recorded as `solo: true` in `.sdlc/hub.json`) and the gate
126
+ **waives the approval requirement only** — the review PR/MR and its merge stay, so CI still runs on the
127
+ PR and the **merge** advances the step. Net: the gate passes on *merged + all threads resolved*. It's a
128
+ documented, reversible relaxation; `yad doctor` warns if branch protection still "requires approvals"
129
+ (which would block the solo dev's own merge).
130
+
123
131
  **Event-driven sync.** Wire the hub once (`yad check --fix` installs `.github/workflows/yad-gate-sync.yml`,
124
132
  or the GitLab fragment + schedule) and every **approval, change request, and merge** on a review PR/MR
125
133
  triggers `yad gate ci` in the hub's own CI: the ledger updates land directly on the hub's default branch
@@ -132,29 +140,33 @@ a manual `yad gate sync` racing CI, or GitLab pipelines — two simultaneous syn
132
140
  *commits* via the rebase retry but each works from the state it read at start, so the rarer of two
133
141
  simultaneous advancements can be lost; the next event or scheduled sweep re-syncs and converges.
134
142
 
135
- ### What `setup` walks you through (10 steps)
143
+ ### What `setup` walks you through (a guided, branching interview)
144
+
145
+ Setup opens with a short **profile interview** — *solo or team (how many)? greenfield or brownfield?
146
+ monorepo or separate repos?* — and the answers (recorded in `.sdlc/hub.json` as `solo` + `profile`)
147
+ branch the rest so you only answer what your situation needs. Each step prints inline guidance (what it
148
+ does / why / what to enter / what skipping means), and the step count adapts.
136
149
 
150
+ 0. **Profile** — the three questions above, plus "configure optional tools now?". Pre-answer for
151
+ CI/scripts with `--solo`/`--team <n>`, `--greenfield`/`--brownfield`, `--monorepo`/`--separate`, `--tools`.
137
152
  1. **Preflight** — confirm the hub is a git repo (offers `git init`); check `git`/`node`/`npx`.
138
153
  2. **Install the module** — copy all 30 `yad-*` skills into the IDE skill dirs you pick
139
154
  (`.claude/`, `.agents/`, `.zencoder/`, `.opencode/`) and register `_bmad/sdlc/`.
140
155
  3. **Hub platform & roster** — detect GitHub/GitLab from the remote; record reviewers → `.sdlc/hub.json`.
141
- Edit the roster any time afterwards with `yad roster` (no need to re-run the whole wizard).
142
- 4. **Connect a design tool** — record the design tool (Figma / pencil / none) `.sdlc/design.json` so
143
- the UI step can materialize the design; the MCP itself is confirmed later by `yad-connect-design`.
144
- 5. **Connect a testing tool** — record the testing tool (Playwright / cypress / pytest / none)
145
- `.sdlc/testing.json` so the test-cases step can implement the automation; the MCP itself is confirmed
146
- later by `yad-connect-testing`.
147
- 6. **Connect a learning tool** — record the learning tool (DeepTutor / none) → `.sdlc/learning.json` so
148
- the learning layer can tutor the team; the CLI + knowledge base are confirmed later by
149
- `yad-connect-learning`.
150
- 7. **Connect code repos** register each repo into `.sdlc/repos.json` and cache a Repomix pack.
151
- 8. **Wire each repo** — CI gates, PR/MR template, and review-comment scaffold.
152
- 9. **AI review** — optionally write `.coderabbit.yaml`.
153
- 10. **Done** — stamp `.sdlc/cli-version.json` and hand off the AI-only steps (code-maps; first epic).
156
+ **Solo skips the roster** (you review by merging your own PR). Edit the roster any time with `yad roster`.
157
+ 4. **Optional tools** — design (Figma/pencil), testing (Playwright/cypress/pytest), learning (DeepTutor).
158
+ Configure now, or **defer with one prompt** all recorded as `none` (connect later with the
159
+ `yad-connect-*` skills; the MCPs/CLIs are confirmed there).
160
+ 5. **Connect code repos** register repos into `.sdlc/repos.json`. **Monorepo** connects one repo and
161
+ skips domain-owner prompts; **greenfield** skips the Repomix pack (run `yad repo refresh` once it has code).
162
+ 6. **Wire each repo** — CI gates, PR/MR template, and review-comment scaffold.
163
+ 7. **AI review** optionally write `.coderabbit.yaml`.
164
+ 8. **Done** — stamp `.sdlc/cli-version.json` and print a **profile-tailored next step** (brownfield →
165
+ `yad-backfill` first; everyone `yad next` and your first epic via `yad-epic`).
154
166
 
155
167
  The deterministic file work runs automatically; the AI-only steps are handed to the Claude Code skills
156
168
  with a printed next-action. Re-run `… check --fix` any time the workflow updates — it never re-asks for
157
- input you already gave.
169
+ input you already gave; re-running `setup` carries your profile forward.
158
170
 
159
171
  **Releases:** automated via semantic-release on merge to `main` (Conventional Commits → npm, with
160
172
  provenance). See [`RELEASING.md`](RELEASING.md).
package/bin/yad.mjs CHANGED
@@ -13,11 +13,14 @@ import { runRepo } from '../cli/repo.mjs';
13
13
  import { runRoster } from '../cli/roster.mjs';
14
14
  import { runDocs } from '../cli/docs.mjs';
15
15
  import { runDoctor } from '../cli/doctor.mjs';
16
+ import { runNext } from '../cli/next.mjs';
16
17
 
17
18
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
18
19
 
19
20
  ${c.bold('Setup & maintenance')}
20
- yad setup Guided first-run setup (install module, connect & wire repos)
21
+ yad setup Guided first-run setup (profile interview, install, connect & wire repos)
22
+ profile flags: --solo | --team <n>, --greenfield | --brownfield,
23
+ --monorepo | --separate, --tools (configure design/testing/learning now)
21
24
  yad check Report what is missing / drifted / stale / legacy (read-only)
22
25
  yad check --fix Reconcile: fill what is missing, update what changed
23
26
  yad update Apply drift only (alias for: check --fix --scope=changed);
@@ -33,6 +36,12 @@ ${c.bold('Reviewer roster')}
33
36
  yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
34
37
  yad roster remove <login> Delete a member from the roster
35
38
 
39
+ ${c.bold('Where am I / what next')}
40
+ yad next Project-wide: the one next action to take (or run setup)
41
+ yad next <epic> The single next action for one epic (skill or yad command)
42
+ yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
43
+ yad next --all Every active epic's next action at once
44
+
36
45
  ${c.bold('Review gate (front half)')}
37
46
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
38
47
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
@@ -75,7 +84,7 @@ ${c.bold('Options')}
75
84
  -h, --help Show this help
76
85
  -v, --version Print version`;
77
86
 
78
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles']);
87
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team']);
79
88
 
80
89
  function parseArgs(argv) {
81
90
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -86,7 +95,19 @@ function parseArgs(argv) {
86
95
  else if (a === '--contract-change') o.contractChange = true;
87
96
  else if (a === '--no-push') o.noPush = true;
88
97
  else if (a === '--overview') o.overview = true;
89
- else if (a === '--check') o.check = true;
98
+ // `--check` is a bare boolean for `docs sync --check`, but takes a value for
99
+ // `next <epic> --check <step>`. Only the `next` command consumes the following token as a value —
100
+ // scoping it to `next` keeps `docs sync --check overview` (and any other command) from swallowing a
101
+ // positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
102
+ else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
103
+ else if (a === '--all') o.all = true;
104
+ // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
105
+ else if (a === '--solo') o.solo = true;
106
+ else if (a === '--greenfield') o.greenfield = true;
107
+ else if (a === '--brownfield') o.brownfield = true;
108
+ else if (a === '--monorepo') o.monorepo = true;
109
+ else if (a === '--separate') o.separate = true;
110
+ else if (a === '--tools') o.tools = true;
90
111
  else if (a === '--refresh') o.refresh = true;
91
112
  else if (a === '--wire') o.wire = true;
92
113
  else if (a === '--dry-run') o.dryRun = true;
@@ -117,7 +138,11 @@ async function main() {
117
138
  const today = new Date().toISOString().slice(0, 10);
118
139
  switch (cmd) {
119
140
  case 'setup':
120
- await runSetup(o.dir, { today, force: o.force });
141
+ await runSetup(o.dir, {
142
+ today, force: o.force,
143
+ solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
144
+ monorepo: o.monorepo, separate: o.separate, tools: o.tools,
145
+ });
121
146
  break;
122
147
  case 'check':
123
148
  await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, today });
@@ -128,6 +153,13 @@ async function main() {
128
153
  case 'doctor':
129
154
  await runDoctor(o.dir, { json: o.json });
130
155
  break;
156
+ case 'next': {
157
+ const [, epic] = o._;
158
+ // `--check` with no step is a malformed guard call — fail loudly rather than silently print.
159
+ if (o.check === true) { log(c.red('usage: yad next <epic> --check <step>')); process.exitCode = 1; break; }
160
+ await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all });
161
+ break;
162
+ }
131
163
  case 'gate': {
132
164
  const [, action, epic, artifact] = o._;
133
165
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
package/cli/doctor.mjs CHANGED
@@ -12,6 +12,12 @@ import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
12
12
 
13
13
  const MIN_NODE = 18;
14
14
 
15
+ // Solo mode (a lone developer): approval waived, merge + resolved threads still gate. Persisted in
16
+ // hub.json. Mirrors gate.mjs / next.mjs.
17
+ const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
18
+ // owner/repo slug from a git url (https or ssh), for the branch-protection probe.
19
+ const repoSlug = (url) => ((url || '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/) || [])[1] || null;
20
+
15
21
  // Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
16
22
  function check(checks, id, section, status, message, hint = '') {
17
23
  checks.push({ id, section, status, message, ...(hint ? { hint } : {}) });
@@ -65,6 +71,7 @@ export function projectChecks(checks, root) {
65
71
  else if (hub.roster !== undefined && !Array.isArray(hub.roster)) check(checks, 'hub', 'project', 'fail', `${PROJECT_FILES.hubConfig}: \`roster\` must be an array [YAD-STATE-002]`, 'fix the file or re-run `yad setup`');
66
72
  else {
67
73
  check(checks, 'hub', 'project', 'ok', `hub: ${hub.platform || 'file-only'}, ${(hub.roster || []).length} reviewer(s)`);
74
+ if (isSolo(hub)) check(checks, 'solo', 'project', 'ok', 'mode: solo — approval waived; the PR merge + resolved threads gate the step');
68
75
  // platform CLI + auth (best-effort; auth probing is the user's own session)
69
76
  const cli = cliFor(hub.platform);
70
77
  if (cli) {
@@ -86,6 +93,18 @@ export function projectChecks(checks, root) {
86
93
  }
87
94
  if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
88
95
  else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
96
+ // Solo + GitHub: a branch that "requires approvals" would block the solo dev's own merge
97
+ // (they can't approve their own PR). Best-effort probe; a 404 (no protection) is fine.
98
+ if (isSolo(hub) && hub.platform === 'github') {
99
+ const slug = repoSlug(hub.git_url) || repoSlug(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
100
+ const br = hub.default_branch || 'main';
101
+ if (slug) {
102
+ const probe = run('gh', ['api', `repos/${slug}/branches/${br}/protection/required_pull_request_reviews`, '--jq', '.required_approving_review_count']);
103
+ if (probe.ok && Number(probe.stdout) > 0) {
104
+ check(checks, 'solo-branch-protection', 'project', 'warn', `solo mode but ${br} requires ${probe.stdout} approval(s) — you cannot approve your own PR, so the merge will be blocked`, `relax "Require approvals" in ${slug} branch protection for ${br}`);
105
+ }
106
+ }
107
+ }
89
108
  }
90
109
  }
91
110
  }
@@ -173,7 +192,8 @@ export function projectChecks(checks, root) {
173
192
  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; }
174
193
  const head = gitHead(repoRoot);
175
194
  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; }
176
- if (repo.syncedHead && head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + repo.name + '`');
195
+ 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');
196
+ else if (head !== repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: code-context is stale (HEAD moved since last pack)`, 'run `yad repo refresh ' + repo.name + '`');
177
197
  else check(checks, `repo:${repo.name}`, 'project', 'ok', `${repo.name}: git repo, context fresh`);
178
198
  }
179
199
  if (!registry.repos.length) check(checks, 'repos', 'project', 'warn', 'no code repos registered', 'run `yad setup` to connect one');
@@ -158,6 +158,7 @@ export function gatePredicate({
158
158
  defaultReviewers = 1,
159
159
  threadsResolved = true,
160
160
  merged = true,
161
+ solo = false,
161
162
  }) {
162
163
  const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
163
164
  // Revoke-on-change: an approval bound to a stale content hash no longer counts.
@@ -168,19 +169,24 @@ export function gatePredicate({
168
169
  const reviewers = uniqueBy(live.filter((a) => a.role === 'reviewer'), 'approver');
169
170
  const domainOwners = live.filter((a) => a.role === 'domain-owner');
170
171
 
171
- const missing = [];
172
- if (owners.length < 1) missing.push('1 owner approval');
173
- if (reviewers.length < defaultReviewers) {
174
- missing.push(`${defaultReviewers - reviewers.length} reviewer approval(s)`);
175
- }
176
172
  const escalate = isEscalated(step);
177
- if (escalate) {
178
- for (const d of touchedDomains) {
179
- if (!domainOwners.some((a) => a.domain === d)) missing.push(`domain-owner for ${d}`);
173
+ const missing = [];
174
+ // Solo mode waives the APPROVAL requirements entirely (you can't approve your own PR on GitHub)
175
+ // merge + resolved threads are what advance the step. Team mode is unchanged.
176
+ if (!solo) {
177
+ if (owners.length < 1) missing.push('1 owner approval');
178
+ if (reviewers.length < defaultReviewers) {
179
+ missing.push(`${defaultReviewers - reviewers.length} reviewer approval(s)`);
180
+ }
181
+ if (escalate) {
182
+ for (const d of touchedDomains) {
183
+ if (!domainOwners.some((a) => a.domain === d)) missing.push(`domain-owner for ${d}`);
184
+ }
180
185
  }
181
186
  }
182
187
  const approvalsSatisfied = missing.length === 0;
183
- if (stale.length) missing.unshift(`${stale.length} approval(s) revoked artifact changed; re-approve`);
188
+ // A stale approval only matters when approvals are required (team mode); in solo they are moot.
189
+ if (!solo && stale.length) missing.unshift(`${stale.length} approval(s) revoked — artifact changed; re-approve`);
184
190
  if (!threadsResolved) missing.push('unresolved review comments');
185
191
  if (!merged) missing.push('review PR/MR not merged');
186
192
 
@@ -191,7 +197,7 @@ export function gatePredicate({
191
197
  staleDropped: stale.length,
192
198
  passed: approvalsSatisfied && threadsResolved && merged,
193
199
  missing,
194
- rule: escalate ? (step.id === 'stories-review' ? 'per-repo' : 'escalated') : 'base',
200
+ rule: solo ? 'solo' : escalate ? (step.id === 'stories-review' ? 'per-repo' : 'escalated') : 'base',
195
201
  };
196
202
  }
197
203
 
@@ -235,4 +241,73 @@ export function markInReview(state, step) {
235
241
  return state;
236
242
  }
237
243
 
244
+ // The front authoring step a `yad next` action maps to — the skill the user invokes for that step.
245
+ // Review (review+approve) steps are driven by the `yad gate` CLI, not a skill, so they are not here.
246
+ export const STEP_SKILL = {
247
+ analysis: 'yad-analysis',
248
+ epic: 'yad-epic',
249
+ architecture: 'yad-architecture',
250
+ 'ui-design': 'yad-ui',
251
+ stories: 'yad-stories',
252
+ 'test-cases': 'yad-test-cases',
253
+ };
254
+
255
+ // PURE precondition guard. Is `stepId` runnable right now? A step is runnable iff every step BEFORE it
256
+ // in the chain is `done` and the step itself is not already `done`. With no state yet (greenfield), the
257
+ // only runnable steps are the entry authoring steps (analysis | epic). Used by `yad next --check`
258
+ // (the Phase B rail) and by the driver. No FS / network.
259
+ export function preconditionsMet(state, stepId) {
260
+ if (!state || !Array.isArray(state.steps)) {
261
+ const ok = stepId === 'epic' || stepId === 'analysis';
262
+ return { ok, blockedBy: null, reason: ok ? 'entry step (no epic seeded yet)' : `start with yad-epic — no epic state for '${stepId}'` };
263
+ }
264
+ const i = state.steps.findIndex((s) => s.id === stepId);
265
+ if (i === -1) return { ok: false, blockedBy: null, reason: `unknown step '${stepId}'` };
266
+ if (state.steps[i].status === 'done') return { ok: false, blockedBy: null, reason: `${stepId} is already done` };
267
+ const blocker = state.steps.slice(0, i).find((s) => s.status !== 'done');
268
+ if (blocker) return { ok: false, blockedBy: blocker.id, reason: `${blocker.id} has not passed yet` };
269
+ return { ok: true, blockedBy: null, reason: 'ready' };
270
+ }
271
+
272
+ // PURE next-action resolver for ONE epic's ledger — what `yad next <epic>` prints. Reads state + the
273
+ // recorded review PRs only. kind:
274
+ // 'new' — no epic state yet (seed one with yad-epic)
275
+ // 'author' — invoke a front authoring skill (STEP_SKILL)
276
+ // 'review-open' — open the review PR/MR (`yad gate open`)
277
+ // 'review-sync' — a review PR/MR is open; sync its state (`yad gate sync`)
278
+ // 'build' — front half approved (ready-for-build); the build half can run
279
+ export function nextAction(ledger, { epic } = {}) {
280
+ const state = ledger?.state;
281
+ const epicId = epic || state?.epicId || null;
282
+ if (!state) return { epicId, kind: 'new', skill: 'yad-epic', why: 'no epic state yet — seed it with yad-epic' };
283
+
284
+ // The parallel test-cases track stays workable even once the epic is ready-for-build.
285
+ const tc = state.steps.find((s) => s.id === 'test-cases');
286
+ const tcOpen = !!tc && tc.status !== 'done' && tc.status !== 'blocked';
287
+ const parallel = tcOpen ? { step: 'test-cases', skill: STEP_SKILL['test-cases'], artifact: tc.artifact } : null;
288
+
289
+ if (state.currentStep === 'ready-for-build') {
290
+ return { epicId, kind: 'build', step: 'ready-for-build', status: 'ready-for-build', parallel,
291
+ why: 'front half approved — the build half can run' };
292
+ }
293
+
294
+ const step = state.steps.find((s) => s.id === state.currentStep)
295
+ || state.steps.find((s) => s.status !== 'done');
296
+ if (!step) return { epicId, kind: 'build', step: 'ready-for-build', parallel, why: 'all front steps are done' };
297
+
298
+ if (step.type === 'author') {
299
+ return { epicId, kind: 'author', step: step.id, status: step.status, parallel,
300
+ skill: STEP_SKILL[step.id] || null, artifact: step.artifact,
301
+ why: `${step.id} is ${step.status} — author ${step.artifact}` };
302
+ }
303
+
304
+ // review+approve: open the review PR if none is recorded yet, else sync the open one.
305
+ const pr = (ledger.hubPrs || []).find((p) => artifactBase(p.artifact) === artifactBase(step.artifact));
306
+ const verb = pr ? 'sync' : 'open';
307
+ return { epicId, kind: pr ? 'review-sync' : 'review-open', step: step.id, status: step.status,
308
+ artifact: step.artifact, pr: pr ? pr.number : null, parallel,
309
+ command: `yad gate ${verb} ${epicId} ${step.artifact}`,
310
+ why: pr ? `review PR #${pr.number} is open — sync its state to advance` : `${step.id} is open — create the review PR/MR` };
311
+ }
312
+
238
313
  export { writeJSON };
package/cli/gate.mjs CHANGED
@@ -85,6 +85,11 @@ function loadHub(root) {
85
85
  return { hub, repos: registry.repos };
86
86
  }
87
87
 
88
+ // Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
89
+ // PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
90
+ // merge advances the step). Recorded per-project in hub.json by `yad setup`.
91
+ const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
92
+
88
93
  // Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
89
94
  // revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
90
95
  // reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
@@ -159,6 +164,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
159
164
  const platform = hub.platform;
160
165
  const roster = hub.roster || [];
161
166
  const defaultReviewers = 1;
167
+ const solo = isSolo(hub);
162
168
  const epicDir = epicRoot(root, epic);
163
169
  const ledger = loadLedger(epicDir);
164
170
  if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
@@ -195,7 +201,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
195
201
 
196
202
  const pred = gatePredicate({
197
203
  step, approvals, currentHash: curHash, touchedDomains: domains,
198
- defaultReviewers, threadsResolved, merged: pull.merged,
204
+ defaultReviewers, threadsResolved, merged: pull.merged, solo,
199
205
  });
200
206
 
201
207
  log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
@@ -382,7 +388,8 @@ export async function gateStatus(root, { epic } = {}) {
382
388
  const epicDir = epicRoot(root, epic);
383
389
  const ledger = loadLedger(epicDir);
384
390
  if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
385
- log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}`)}`);
391
+ const solo = isSolo(loadHub(root).hub);
392
+ log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${ledger.state.currentStep}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
386
393
  for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
387
394
  const cur = artifactHash(epicDir, s.artifact);
388
395
  const live = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved' && !(a.artifactHash && cur && a.artifactHash !== cur));
package/cli/lib.mjs CHANGED
@@ -30,6 +30,9 @@ export const info = (s) => log(` ${c.dim('•')} ${s}`);
30
30
  export const warn = (s) => log(` ${c.yellow('!')} ${s}`);
31
31
  export const fail = (s) => log(` ${c.red('✗')} ${s}`);
32
32
  export const hand = (s) => log(` ${c.yellow('→')} ${s}`);
33
+ // Dimmed, indented guidance under a step — what it does / why / what to enter / what skipping means.
34
+ // Accepts a string or an array of lines so a knowledgeable user can skim past it.
35
+ export const guide = (lines) => { for (const l of (Array.isArray(lines) ? lines : [lines])) log(` ${c.dim(l)}`); };
33
36
 
34
37
  // ---- prompts ------------------------------------------------------------
35
38
  let rl;
package/cli/next.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // `yad next` — the unified next-step driver. Read-only: it never writes state or acts. It reads the
2
+ // file ledger and prints the ONE concrete, copy-pasteable next action (and a one-line why), so a user
3
+ // never has to remember which of the 30 skills / gate commands comes next. "Guide, don't act" — the
4
+ // front half still never auto-advances.
5
+ //
6
+ // yad next general orientation across the whole project
7
+ // yad next <epic> the single next action for one epic
8
+ // yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
9
+ // yad next --all every active epic's next action at once
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
13
+ import { PROJECT_FILES } from './manifest.mjs';
14
+ import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId } from './epic-state.mjs';
15
+
16
+ // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
17
+ // missing/old hub.json never breaks the driver.
18
+ function isSolo(root) {
19
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
20
+ return !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
21
+ }
22
+ // The setup profile recorded by `yad setup` (codebase / repo_layout / team_size), or null.
23
+ const profileOf = (root) => readJSON(path.join(root, PROJECT_FILES.hubConfig), null)?.profile || null;
24
+ // Has `yad setup` run here? True once the version stamp or hub config exists.
25
+ const isSetUp = (root) => exists(path.join(root, PROJECT_FILES.version)) || exists(path.join(root, PROJECT_FILES.hubConfig));
26
+
27
+ // Every epic that has a state ledger, in directory order.
28
+ function listEpics(root) {
29
+ const dir = path.join(root, 'epics');
30
+ if (!exists(dir)) return [];
31
+ return fs.readdirSync(dir, { withFileTypes: true })
32
+ .filter((e) => e.isDirectory() && isValidEpicId(e.name))
33
+ .map((e) => e.name)
34
+ .filter((id) => exists(path.join(dir, id, '.sdlc', 'state.json')))
35
+ .sort();
36
+ }
37
+
38
+ // A short, copy-pasteable line for one action — the `▸` line a user can act on directly.
39
+ function actionLine(a, { solo } = {}) {
40
+ switch (a.kind) {
41
+ case 'new':
42
+ return `invoke the ${c.bold(a.skill)} skill ${c.dim('(author the epic)')}`;
43
+ case 'author':
44
+ return `invoke the ${c.bold(a.skill || ('yad-' + a.step))} skill ${c.dim(`(author ${a.artifact})`)}`;
45
+ case 'review-open':
46
+ case 'review-sync':
47
+ return `${c.bold(a.command)}${solo ? c.dim(' (solo: no approval needed — just merge your own PR)') : ''}`;
48
+ case 'build':
49
+ return `${c.bold('yad-run')} ${c.dim('(or per story: yad-spec → yad-implement → yad ship → yad-engineer-review)')}`;
50
+ default:
51
+ return c.dim('nothing to do');
52
+ }
53
+ }
54
+
55
+ // Full, friendly printout for a single epic.
56
+ function printAction(a, { solo } = {}) {
57
+ log(`\n ${c.bold(a.epicId || '(epic)')} ${c.dim(`— ${a.why}`)}`);
58
+ hand(actionLine(a, { solo }));
59
+ if (a.kind === 'review-sync') info(c.dim(`unresolved comments? ${c.bold(`yad gate comments ${a.epicId} ${a.artifact}`)}`));
60
+ if (a.parallel) hand(`parallel track: invoke the ${c.bold(a.parallel.skill)} skill ${c.dim(`(author ${a.parallel.artifact})`)}`);
61
+ }
62
+
63
+ // `yad next` with no epic: orient across the whole project, always ending on ONE thing to do.
64
+ function generalNext(root, { all } = {}) {
65
+ if (!isSetUp(root)) {
66
+ log(`\n ${c.bold('Project not set up yet.')}`);
67
+ hand(`run ${c.bold('yad setup')} ${c.dim('(then come back to `yad next`)')}`);
68
+ return;
69
+ }
70
+ const epics = listEpics(root);
71
+ if (!epics.length) {
72
+ const brownfield = profileOf(root)?.codebase === 'brownfield';
73
+ log(`\n ${c.bold('Set up — no epics yet.')}`);
74
+ if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
75
+ hand(`start your first epic: invoke the ${c.bold('yad-epic')} skill`);
76
+ return;
77
+ }
78
+ const solo = isSolo(root);
79
+ const actions = epics.map((id) => nextAction(loadLedger(epicRoot(root, id)), { epic: id }));
80
+
81
+ if (epics.length === 1 || all) {
82
+ for (const a of actions) printAction(a, { solo });
83
+ return;
84
+ }
85
+ // Several epics — list each with a one-liner, then point at the per-epic / --all views.
86
+ log(`\n ${c.bold(`${epics.length} epics`)} ${c.dim('— next action each:')}`);
87
+ for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
88
+ info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
89
+ }
90
+
91
+ // `yad next <epic> --check <step>`: the precondition guard. Exit 0 if runnable now, 1 otherwise.
92
+ function checkPrecondition(root, epic, stepId) {
93
+ const ledger = loadLedger(epicRoot(root, epic));
94
+ const res = preconditionsMet(ledger.state, stepId);
95
+ if (res.ok) {
96
+ ok(`${epic}: ${stepId} is ready to run`);
97
+ return;
98
+ }
99
+ fail(`${epic}: ${stepId} is blocked — ${res.reason}`);
100
+ hand(`see what to do now: ${c.bold(`yad next ${epic}`)}`);
101
+ process.exitCode = 1;
102
+ }
103
+
104
+ // Entry point for the `next` command: route to the precondition check, a single epic's action, or the
105
+ // project-wide general view. Validates the epic id first.
106
+ export async function runNext(root, { epic, check, all } = {}) {
107
+ if (epic && !isValidEpicId(epic)) {
108
+ fail(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ if (epic && check) return checkPrecondition(root, epic, check);
113
+ if (!epic) return generalNext(root, { all });
114
+
115
+ const epicDir = epicRoot(root, epic);
116
+ if (!exists(path.join(epicDir, '.sdlc', 'state.json'))) {
117
+ warn(`no epic state at ${epicDir}/.sdlc/state.json`);
118
+ hand(`is the id right? list project status with ${c.bold('yad next')}`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ printAction(nextAction(loadLedger(epicDir), { epic }), { solo: isSolo(root) });
123
+ }
package/cli/repo.mjs CHANGED
@@ -13,11 +13,14 @@ function load(root) {
13
13
  return { regPath, registry: readJSON(regPath, { repos: [] }) };
14
14
  }
15
15
 
16
- // HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha).
16
+ // HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha). A repo that has a HEAD but
17
+ // no syncedHead was registered without a pack (the greenfield path) — it needs an initial pack, which is
18
+ // also a "run `yad repo refresh`" state, kept distinct from HEAD-moved staleness.
17
19
  function staleness(root, repo) {
18
20
  const head = gitHead(path.resolve(root, repo.path));
21
+ const neverPacked = !!head && !repo.syncedHead;
19
22
  const stale = head && repo.syncedHead && head !== repo.syncedHead;
20
- return { head, stale: !!stale, unknown: !head };
23
+ return { head, stale: !!stale, unknown: !head, neverPacked };
21
24
  }
22
25
 
23
26
  // ---- git helpers for `sync` (local-user auth only — never embed credentials) ----
@@ -40,9 +43,10 @@ export async function runRepo(root, { action = 'list', name, today } = {}) {
40
43
  log(c.bold('\nconnected repos'));
41
44
  let staleCount = 0;
42
45
  for (const repo of registry.repos) {
43
- const { stale, unknown } = staleness(root, repo);
46
+ const { stale, unknown, neverPacked } = staleness(root, repo);
44
47
  if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
45
- if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
48
+ if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
49
+ else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
46
50
  else ok(`${repo.name} ${c.dim('— fresh')}`);
47
51
  }
48
52
  if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
package/cli/setup.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import {
6
- c, log, step, ok, info, warn, hand, fail, ask, askYesNo, run, has,
6
+ c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
7
7
  exists, readJSON, writeJSON,
8
8
  } from './lib.mjs';
9
9
  import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
@@ -176,7 +176,7 @@ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
176
176
  ok(` ${repo}: ${want.length ? want.join(', ') : 'cleared'}`);
177
177
  }
178
178
 
179
- export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
179
+ export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
180
180
  if (!insideRoot(root, rpath)) {
181
181
  warn(`${rpath} resolves outside the project root — skipped`);
182
182
  return null;
@@ -198,7 +198,10 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
198
198
  name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
199
199
  domain_owner: owners[0] || '', domain_owners: owners, default_branch,
200
200
  connectedAt: today, lastSyncedAt: today,
201
- syncedHead: head,
201
+ // Only claim a synced HEAD when a pack is actually produced. The greenfield path skips packing
202
+ // (pack:false), so leave syncedHead null — the repo then reads as "needs an initial pack" in
203
+ // `yad repo list` / `yad doctor` instead of falsely "fresh".
204
+ syncedHead: pack ? head : null,
202
205
  contextPack: `.sdlc/code-context/${name}/pack.md`,
203
206
  codeMap: `.sdlc/code-context/${name}/code-map.md`,
204
207
  source: 'repomix',
@@ -305,13 +308,70 @@ function applyActions(actions, { force = false } = {}) {
305
308
  return changed;
306
309
  }
307
310
 
311
+ // Step 0 — resolve the setup profile that branches the rest of the wizard. Flags pre-answer each
312
+ // question (CI/scripts); an existing hub.json carries prior answers forward (idempotent re-run);
313
+ // otherwise we prompt with a default. Pure of side effects — it only reads. Returns
314
+ // { solo, team_size, codebase, repo_layout, configureTools }.
315
+ export async function resolveProfile(root, opts = {}) {
316
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
317
+ const prev = (hub && hub.profile) || {};
318
+
319
+ // 1. Solo or team (+ size). --solo / --team <n> win; else carry hub.solo forward; else ask.
320
+ let solo, team_size;
321
+ if (opts.solo) { solo = true; team_size = 1; }
322
+ else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
323
+ else if (typeof hub?.solo === 'boolean') { solo = hub.solo; team_size = prev.team_size ?? (solo ? 1 : 2); }
324
+ else {
325
+ // Default from any existing roster: a hub already carrying reviewers is a team; otherwise solo.
326
+ const rosterN = Array.isArray(hub?.roster) ? hub.roster.length : 0;
327
+ solo = !(await ask('Solo or team?', rosterN > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
328
+ team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(rosterN || 2)), 10) || 2);
329
+ }
330
+
331
+ // 2. Greenfield (new code) or brownfield (existing code).
332
+ let codebase;
333
+ if (opts.greenfield) codebase = 'greenfield';
334
+ else if (opts.brownfield) codebase = 'brownfield';
335
+ else if (prev.codebase) codebase = prev.codebase;
336
+ else codebase = (await ask('Greenfield (new code) or brownfield (existing code)?', 'greenfield')).toLowerCase().startsWith('b') ? 'brownfield' : 'greenfield';
337
+
338
+ // 3. Monorepo (one repo) or separate repos.
339
+ let repo_layout;
340
+ if (opts.monorepo) repo_layout = 'monorepo';
341
+ else if (opts.separate) repo_layout = 'separate';
342
+ else if (prev.repo_layout) repo_layout = prev.repo_layout;
343
+ else repo_layout = (await ask('Monorepo (one repo) or separate repos?', 'monorepo')).toLowerCase().startsWith('s') ? 'separate' : 'monorepo';
344
+
345
+ // 4. Configure the optional tools now, or defer (records them as none, connect later).
346
+ const configureTools = opts.tools === true ? true
347
+ : process.env.SDLC_NONINTERACTIVE ? false
348
+ : await askYesNo('Configure design/testing/learning tools now? (else connect them later)', false);
349
+
350
+ return { solo, team_size, codebase, repo_layout, configureTools };
351
+ }
352
+
353
+ // The guided, idempotent first-run wizard: a Step 0 profile interview (resolveProfile) that branches
354
+ // the remaining steps — install, hub + roster, optional tools, repos, wiring — and persists the profile.
308
355
  export async function runSetup(root, opts = {}) {
309
- const total = 10;
310
356
  log(c.bold(`\nSDLC Workflow setup ${c.dim('v' + VERSION)}`));
311
357
  log(c.dim(`target: ${root}`));
312
358
 
313
- // 1. Preflight
314
- step(1, total, 'Preflight');
359
+ // 0. Profile interview — branch the wizard to the user's situation (solo/team, code, repo layout).
360
+ const { solo, team_size, codebase, repo_layout, configureTools } = await resolveProfile(root, opts);
361
+ // Steps: interview, preflight, install, hub, tools (1 if deferred else 3), repos, wire, coderabbit, done.
362
+ const total = 8 + (configureTools ? 3 : 1);
363
+ let _n = 0;
364
+ const S = (title) => step(++_n, total, title);
365
+
366
+ S('Profile');
367
+ guide([
368
+ 'How you answer here shapes the rest of setup — fewer prompts, the right path.',
369
+ `solo: ${solo ? 'yes — you review by merging your own PR (approval waived)' : `no — team of ${team_size}`}`,
370
+ `code: ${codebase} • repos: ${repo_layout} • optional tools: ${configureTools ? 'configure now' : 'deferred (connect later)'}`,
371
+ ]);
372
+
373
+ // Preflight
374
+ S('Preflight');
315
375
  if (!exists(path.join(root, '.git'))) {
316
376
  if (await askYesNo('Not a git repo. Run `git init` here?', true)) {
317
377
  run('git', ['init'], { cwd: root });
@@ -321,8 +381,12 @@ export async function runSetup(root, opts = {}) {
321
381
  for (const tool of ['git', 'node']) has(tool) ? ok(`${tool} present`) : warn(`${tool} not found on PATH`);
322
382
  if (!has('npx')) warn('npx not found — repomix packing will be skipped');
323
383
 
324
- // 2. Install the module
325
- step(2, total, 'Install the module (skills + _bmad registration)');
384
+ // Install the module
385
+ S('Install the module (skills + _bmad registration)');
386
+ guide([
387
+ 'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
388
+ 'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
389
+ ]);
326
390
  let ideTargets = opts.ideTargets;
327
391
  if (!ideTargets) {
328
392
  const present = ALL_IDES.filter((d) => exists(path.join(root, d)));
@@ -352,8 +416,18 @@ export async function runSetup(root, opts = {}) {
352
416
  }
353
417
  }
354
418
 
355
- // 3. Detect hub platform + roster
356
- step(3, total, 'Hub platform & reviewer roster');
419
+ // Detect hub platform + roster
420
+ S(solo ? 'Hub platform (solo — no roster)' : 'Hub platform & reviewer roster');
421
+ guide(solo
422
+ ? [
423
+ 'Your hub is this repo on GitHub/GitLab (or none for a file-only gate).',
424
+ 'Solo: no roster needed — you review by merging your own PR (approval waived).',
425
+ ]
426
+ : [
427
+ 'Your hub is this repo on GitHub/GitLab; reviewers approve artifacts there.',
428
+ `Add your ${team_size}-person roster: platform login → yad name → hub role (owner/reviewer).`,
429
+ 'An owner + 1 reviewer is required to pass a gate; skip now and add later with `yad roster add`.',
430
+ ]);
357
431
  const hubPath = path.join(root, PROJECT_FILES.hubConfig);
358
432
  if (exists(hubPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
359
433
  info('keeping existing .sdlc/hub.json');
@@ -367,7 +441,8 @@ export async function runSetup(root, opts = {}) {
367
441
  platform = 'none';
368
442
  }
369
443
  const roster = [];
370
- if (await askYesNo('Add reviewers to the roster now?', true)) {
444
+ // Solo mode needs no roster — the lone developer is owner and reviewer-by-merge.
445
+ if (!solo && await askYesNo('Add reviewers to the roster now?', true)) {
371
446
  for (;;) {
372
447
  const login = await ask(' reviewer platform login (blank to finish)', '');
373
448
  if (!login) break;
@@ -392,68 +467,113 @@ export async function runSetup(root, opts = {}) {
392
467
  // `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
393
468
  // for anything that still reads it.
394
469
  const enabled = platform !== 'none';
395
- writeJSON(hubPath, { platform: enabled ? platform : null, bridge_enabled: enabled, bridge: enabled, default_branch, roster });
396
- ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s))`);
470
+ writeJSON(hubPath, { platform: enabled ? platform : null, bridge_enabled: enabled, bridge: enabled, default_branch, roster, solo, profile: { codebase, repo_layout, team_size } });
471
+ ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
397
472
  }
398
-
399
- // 4. Connect a design tool (Figma-first, pluggable; the UI step materializes the design here)
400
- step(4, total, 'Connect a design tool (Figma / pencil / none)');
401
- const designPath = path.join(root, PROJECT_FILES.designConfig);
402
- if (exists(designPath) && !(await askYesNo('design.json exists reconfigure?', false))) {
403
- info('keeping existing .sdlc/design.json');
404
- } else {
405
- let tool = (await ask(`Design tool (${DESIGN_TOOLS.join('/')}/none)`, DESIGN_PRIMARY)).toLowerCase();
406
- if (![...DESIGN_TOOLS, 'none'].includes(tool)) {
407
- warn(`unknown design tool '${tool}' — using ${DESIGN_PRIMARY}`);
408
- tool = DESIGN_PRIMARY;
473
+ // Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
474
+ // flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
475
+ if (exists(hubPath)) {
476
+ const cur = readJSON(hubPath, {}) || {};
477
+ if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size })) {
478
+ writeJSON(hubPath, { ...cur, solo, profile: { codebase, repo_layout, team_size } });
479
+ info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
409
480
  }
410
- const project_url = tool === 'none' ? null : (await ask(' project/file URL (blank to set later)', '')) || null;
411
- registerDesign(root, { tool, project_url, today: opts.today ?? null });
412
- ok(tool === 'none'
413
- ? `wrote ${PROJECT_FILES.designConfig} (markdown-only)`
414
- : `wrote ${PROJECT_FILES.designConfig} (${tool})`);
415
481
  }
416
482
 
417
- // 5. Connect a testing tool (Playwright-first, pluggable; the test-cases step implements automation here)
418
- step(5, total, 'Connect a testing tool (playwright / cypress / pytest / none)');
483
+ // Optional tools (design / testing / learning). Paths are declared here so the final summary can
484
+ // read them whether or not we configured the tools this run.
485
+ const designPath = path.join(root, PROJECT_FILES.designConfig);
419
486
  const testingPath = path.join(root, PROJECT_FILES.testingConfig);
420
- if (exists(testingPath) && !(await askYesNo('testing.json exists — reconfigure?', false))) {
421
- info('keeping existing .sdlc/testing.json');
422
- } else {
423
- let tool = (await ask(`Testing tool (${TESTING_TOOLS.join('/')}/none)`, TESTING_PRIMARY)).toLowerCase();
424
- if (![...TESTING_TOOLS, 'none'].includes(tool)) {
425
- warn(`unknown testing tool '${tool}' using ${TESTING_PRIMARY}`);
426
- tool = TESTING_PRIMARY;
487
+ const learningPath = path.join(root, PROJECT_FILES.learningConfig);
488
+ if (configureTools) {
489
+ // Connect a design tool (Figma-first, pluggable; the UI step materializes the design here)
490
+ S('Connect a design tool (Figma / pencil / none)');
491
+ guide([
492
+ 'Where yad-ui materializes real screens. figma (confirm the MCP later) or none for markdown-only.',
493
+ 'Skipping is safe — the UI step degrades to ui-design.md.',
494
+ ]);
495
+ if (exists(designPath) && !(await askYesNo('design.json exists — reconfigure?', false))) {
496
+ info('keeping existing .sdlc/design.json');
497
+ } else {
498
+ let tool = (await ask(`Design tool (${DESIGN_TOOLS.join('/')}/none)`, DESIGN_PRIMARY)).toLowerCase();
499
+ if (![...DESIGN_TOOLS, 'none'].includes(tool)) {
500
+ warn(`unknown design tool '${tool}' — using ${DESIGN_PRIMARY}`);
501
+ tool = DESIGN_PRIMARY;
502
+ }
503
+ const project_url = tool === 'none' ? null : (await ask(' project/file URL (blank to set later)', '')) || null;
504
+ registerDesign(root, { tool, project_url, today: opts.today ?? null });
505
+ ok(tool === 'none'
506
+ ? `wrote ${PROJECT_FILES.designConfig} (markdown-only)`
507
+ : `wrote ${PROJECT_FILES.designConfig} (${tool})`);
427
508
  }
428
- const project_url = tool === 'none' ? null : (await ask(' project/config reference (blank to set later)', '')) || null;
429
- registerTesting(root, { tool, project_url, today: opts.today ?? null });
430
- ok(tool === 'none'
431
- ? `wrote ${PROJECT_FILES.testingConfig} (artifacts-only)`
432
- : `wrote ${PROJECT_FILES.testingConfig} (${tool})`);
433
- }
434
509
 
435
- // 6. Connect a learning tool (DeepTutor-first, pluggable; the learning layer tutors team members here)
436
- step(6, total, 'Connect a learning tool (deeptutor / none)');
437
- const learningPath = path.join(root, PROJECT_FILES.learningConfig);
438
- if (exists(learningPath) && !(await askYesNo('learning.json exists reconfigure?', false))) {
439
- info('keeping existing .sdlc/learning.json');
440
- } else {
441
- let tool = (await ask(`Learning tool (${LEARNING_TOOLS.join('/')}/none)`, LEARNING_PRIMARY)).toLowerCase();
442
- if (![...LEARNING_TOOLS, 'none'].includes(tool)) {
443
- warn(`unknown learning tool '${tool}' using ${LEARNING_PRIMARY}`);
444
- tool = LEARNING_PRIMARY;
510
+ // Connect a testing tool (Playwright-first, pluggable; the test-cases step implements automation here)
511
+ S('Connect a testing tool (playwright / cypress / pytest / none)');
512
+ guide([
513
+ 'Where yad-test-cases generates automation. playwright/cypress/pytest, or none for artifacts-only.',
514
+ 'Skipping is safe — test-cases authors test-cases.md only.',
515
+ ]);
516
+ if (exists(testingPath) && !(await askYesNo('testing.json exists — reconfigure?', false))) {
517
+ info('keeping existing .sdlc/testing.json');
518
+ } else {
519
+ let tool = (await ask(`Testing tool (${TESTING_TOOLS.join('/')}/none)`, TESTING_PRIMARY)).toLowerCase();
520
+ if (![...TESTING_TOOLS, 'none'].includes(tool)) {
521
+ warn(`unknown testing tool '${tool}' — using ${TESTING_PRIMARY}`);
522
+ tool = TESTING_PRIMARY;
523
+ }
524
+ const project_url = tool === 'none' ? null : (await ask(' project/config reference (blank to set later)', '')) || null;
525
+ registerTesting(root, { tool, project_url, today: opts.today ?? null });
526
+ ok(tool === 'none'
527
+ ? `wrote ${PROJECT_FILES.testingConfig} (artifacts-only)`
528
+ : `wrote ${PROJECT_FILES.testingConfig} (${tool})`);
529
+ }
530
+
531
+ // Connect a learning tool (DeepTutor-first, pluggable; the learning layer tutors team members here)
532
+ S('Connect a learning tool (deeptutor / none)');
533
+ guide([
534
+ 'Lets any team member invoke yad-learn to be tutored in-context. deeptutor (a CLI), or none.',
535
+ 'Skipping is safe — yad-learn tutors via the harness model (harness-native).',
536
+ ]);
537
+ if (exists(learningPath) && !(await askYesNo('learning.json exists — reconfigure?', false))) {
538
+ info('keeping existing .sdlc/learning.json');
539
+ } else {
540
+ let tool = (await ask(`Learning tool (${LEARNING_TOOLS.join('/')}/none)`, LEARNING_PRIMARY)).toLowerCase();
541
+ if (![...LEARNING_TOOLS, 'none'].includes(tool)) {
542
+ warn(`unknown learning tool '${tool}' — using ${LEARNING_PRIMARY}`);
543
+ tool = LEARNING_PRIMARY;
544
+ }
545
+ registerLearning(root, { tool, today: opts.today ?? null });
546
+ ok(tool === 'none'
547
+ ? `wrote ${PROJECT_FILES.learningConfig} (harness-native)`
548
+ : `wrote ${PROJECT_FILES.learningConfig} (${tool})`);
445
549
  }
446
- registerLearning(root, { tool, today: opts.today ?? null });
447
- ok(tool === 'none'
448
- ? `wrote ${PROJECT_FILES.learningConfig} (harness-native)`
449
- : `wrote ${PROJECT_FILES.learningConfig} (${tool})`);
550
+ } else {
551
+ // Deferred: record any not-yet-present tool as none (degrades gracefully). Existing connections kept.
552
+ S('Optional tools (design / testing / learning) — deferred');
553
+ guide(['Recorded as none; connect any later with the yad-connect-* skills. Existing connections are kept.']);
554
+ if (!exists(designPath)) registerDesign(root, { tool: 'none', project_url: null, today: opts.today ?? null });
555
+ if (!exists(testingPath)) registerTesting(root, { tool: 'none', project_url: null, today: opts.today ?? null });
556
+ if (!exists(learningPath)) registerLearning(root, { tool: 'none', today: opts.today ?? null });
557
+ info('design / testing / learning recorded as none (connect later)');
450
558
  }
451
559
 
452
- // 7. Connect code repos
453
- step(7, total, 'Connect code repos');
560
+ // Connect code repos
561
+ S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
562
+ guide(repo_layout === 'monorepo'
563
+ ? [
564
+ 'One repo holds all the code; the contract lives in the hub and stories tag this single repo.',
565
+ codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the front phases see what already exists.',
566
+ ]
567
+ : [
568
+ 'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
569
+ 'Per repo: name → path (inside this project) → platform → domain owner(s).',
570
+ codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the front phases see what already exists.',
571
+ ]);
454
572
  const regPath = path.join(root, PROJECT_FILES.reposRegistry);
455
573
  const registry = readJSON(regPath, { repos: [] });
456
574
  const known = new Set(registry.repos.map((r) => r.name));
575
+ const greenfield = codebase === 'greenfield';
576
+ const mono = repo_layout === 'monorepo';
457
577
  if (await askYesNo(`Connect a code repo? ${c.dim(`(${registry.repos.length} already registered)`)}`, registry.repos.length === 0)) {
458
578
  for (;;) {
459
579
  const name = await ask(' repo name (blank to finish)', '');
@@ -463,26 +583,28 @@ export async function runSetup(root, opts = {}) {
463
583
  if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root — skipped`); continue; }
464
584
  const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
465
585
  const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
466
- // A repo can have one or more domain owners; reviewers/owners can also be scoped to it. Names
467
- // refer to roster `name`s; each grant is written into that person's per-scope roles map.
468
- const domain_owners = parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
469
- const repoReviewers = parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
470
- const repoOwners = parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
586
+ // Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
587
+ // skip these prompts there is no second person to route to.
588
+ const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
589
+ const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
590
+ const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
471
591
  const default_branch = await ask(' default branch', 'main');
472
- const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null });
592
+ const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null, pack: !greenfield });
473
593
  if (!repo) continue;
474
594
  addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
475
595
  known.add(name);
476
596
  ok(`registered ${name}`);
477
- packRepo(root, repo);
597
+ if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
598
+ else packRepo(root, repo);
599
+ if (mono) { info('monorepo — one repo connected; stop here'); break; }
478
600
  }
479
601
  }
480
602
 
481
- // 7b. Assign/update roles for ALREADY-connected repos. The connect loop above only prompts for the
482
- // repos you add now; this closes the gap so a member's domain-owner/reviewer/owner role on a repo
483
- // connected in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
603
+ // Assign/update roles for ALREADY-connected repos. Skipped in solo mode (no roster). The connect loop
604
+ // above only prompts for repos you add now; this closes the gap so a member's role on a repo connected
605
+ // in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
484
606
  const hub7 = readJSON(hubPath, null);
485
- if (registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
607
+ if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
486
608
  && await askYesNo('Assign/update roles for connected repos?', false)) {
487
609
  for (const member of hub7.roster) {
488
610
  if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
@@ -496,8 +618,9 @@ export async function runSetup(root, opts = {}) {
496
618
  }
497
619
  }
498
620
 
499
- // 8. Wire each connected repo + the hub itself
500
- step(8, total, 'Wire connected repos + the hub (CI gates, PR template, comment scaffold, gate-sync)');
621
+ // Wire each connected repo + the hub itself
622
+ S('Wire connected repos + the hub (CI gates, PR template, comment scaffold, gate-sync)');
623
+ guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
501
624
  if (registry.repos.length === 0) info('no repos to wire');
502
625
  for (const repo of registry.repos) {
503
626
  log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
@@ -516,8 +639,9 @@ export async function runSetup(root, opts = {}) {
516
639
  // author allowlists for the verified-commits gate (hub + every repo), from the roster emails
517
640
  applyActions(authorsActions(root, registry.repos), { force: true });
518
641
 
519
- // 9. Optional CodeRabbit
520
- step(9, total, 'AI review (CodeRabbit)');
642
+ // Optional CodeRabbit
643
+ S('AI review (CodeRabbit)');
644
+ guide(['Advisory AI first-pass on PRs — never the authority. Opt in per repo; safe to skip.']);
521
645
  for (const repo of registry.repos) {
522
646
  const cr = path.join(path.resolve(root, repo.path), '.coderabbit.yaml');
523
647
  if (exists(cr)) { info(`${repo.name}: .coderabbit.yaml present`); continue; }
@@ -527,13 +651,25 @@ export async function runSetup(root, opts = {}) {
527
651
  }
528
652
  }
529
653
 
530
- // 10. Summary + version stamp
531
- step(10, total, 'Done');
654
+ // Summary + version stamp
655
+ S('Done');
532
656
  writeJSON(path.join(root, PROJECT_FILES.version), { version: VERSION, ideTargets, updatedAt: opts.today ?? null });
533
657
  ok(`stamped ${PROJECT_FILES.version} (v${VERSION})`);
534
658
  log('');
535
- log(c.bold('Next AI-only steps (run in Claude Code):'));
536
- hand('generate code-maps: run `yad-connect-repos` for each connected repo');
659
+ // Tailored fastest path to the first epic, by profile.
660
+ log(c.bold('Next:'));
661
+ if (codebase === 'brownfield' && registry.repos.length) {
662
+ hand('capture what already exists first: run `yad-backfill`, then your first epic with `yad-epic`');
663
+ } else {
664
+ hand('author your first epic: run `yad-epic`');
665
+ }
666
+ hand('your single next action, anytime: `yad next`');
667
+ if (!solo && !(readJSON(hubPath, null)?.roster || []).length) {
668
+ hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
669
+ }
670
+ log('');
671
+ log(c.bold('Then — AI-only steps (run in Claude Code):'));
672
+ if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
537
673
  const design = readJSON(designPath, null);
538
674
  if (design && design.tool && design.tool !== 'none') {
539
675
  hand(`confirm the design tool: run \`yad-connect-design\` to detect the ${design.tool} MCP (or it degrades to markdown-only)`);
@@ -546,7 +682,6 @@ export async function runSetup(root, opts = {}) {
546
682
  if (learning && learning.tool && learning.tool !== 'none') {
547
683
  hand(`confirm the learning tool: run \`yad-connect-learning\` to detect the ${learning.tool} CLI (or it degrades to harness-native)`);
548
684
  }
549
- hand('author your first epic: run `yad-epic`');
550
685
  log('');
551
686
  log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
552
687
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.13.0",
3
+ "version": "2.14.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). A BMAD module + 30 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -31,6 +31,14 @@ defaults:
31
31
  # Team review gate defaults (build plan §3 piece 2, §4).
32
32
  review_gate:
33
33
  default_reviewers: 1 # non-owner reviewers required (in addition to 1 owner) => owner + 1 reviewer
34
+ # Solo mode (a lone developer): a relaxed, opt-in safety guarantee, recorded per-project in
35
+ # .sdlc/hub.json (`solo: true`). On GitHub you CANNOT approve your own PR, so requiring an approval
36
+ # would deadlock a solo user. Solo waives the APPROVAL requirement only — the review PR/MR and its
37
+ # MERGE stay (CI still runs on the PR, and the merge is what advances the step). Net: the gate passes
38
+ # on `merged + all comment threads resolved`, no approval needed. NOT a default; team gates are
39
+ # unchanged. (Branch protection must not "require approvals", or the solo dev's own merge is blocked —
40
+ # `yad doctor` warns when it does.)
41
+ solo: false
34
42
  escalate_when: [contract, auth, payments] # escalate to domain owners
35
43
  # PR-driven automation (the `yad gate` CLI). With a hub platform, the review rides the per-step
36
44
  # PR/MR: `yad gate sync` maps platform reviews/threads into the file ledger and the step
@@ -26,9 +26,12 @@ engine (never typed by hand); front steps are locked to `human_approve`.
26
26
  ## On Activation
27
27
 
28
28
  ### Step 1 — Get the idea
29
- Ask the user for a one-line feature idea if not provided. If `.sdlc/state.json` already exists for the
30
- target epic, analysis was already seeded (or the epic is past it) — stop and point the user at
31
- `yad-status`. The entry point seeds state exactly once.
29
+ Ask the user for a one-line feature idea if not provided.
30
+
31
+ **Precondition gate (rail):** analysis is the optional first entry point and seeds state exactly once.
32
+ If `.sdlc/state.json` already exists for the target epic, run `yad next EP-<slug> --check analysis`; if it
33
+ exits non-zero, **STOP** and point the user at `yad next EP-<slug>` (the epic is past analysis). If it
34
+ exits zero, resume analysis for that epic. When no `state.json` exists yet, proceed and seed state.
32
35
 
33
36
  ### Step 2 — Shape the idea (assist: analyst)
34
37
  Adopt the **analyst** lens (`bmad-agent-analyst`, Mary) to pressure-test the idea in depth: who is the
@@ -23,9 +23,14 @@ shared cross-repo surface at charter altitude; front steps stay locked to `human
23
23
  ## On Activation
24
24
 
25
25
  ### Step 1 — Resolve the epic and check the gate
26
- Resolve the `EP-<slug>` (ask if not provided). Read `{project-root}/epics/EP-<slug>/.sdlc/state.json`.
27
- Only proceed when `currentStep == "architecture"` and that step's `status == "in_progress"` (the epic
28
- review must already have passed). If not, stop and point the user at `yad-status` / the gate.
26
+ Resolve the `EP-<slug>` (ask if not provided).
27
+
28
+ **Precondition gate (rail):** run `yad next EP-<slug> --check architecture` first. If it exits non-zero,
29
+ **STOP** — surface the blocker it prints and point the user at `yad next EP-<slug>`. Do not author until
30
+ it passes. (The check is the authoritative rail; the description below explains what it enforces.)
31
+
32
+ This passes when `architecture` is the next runnable step per the state sequence — every prior step
33
+ (through the epic review) is `done` and `architecture` is not already `done`.
29
34
 
30
35
  ### Step 1b — Open the authoring branch
31
36
  Open the architecture authoring branch `architecture/EP-<slug>` per the shared procedure
@@ -40,7 +40,10 @@ exists for the idea.
40
40
 
41
41
  Either mode runs Step 3b (branch), Step 4 (write the epic), and Step 6 (stop at the gate).
42
42
 
43
- If `state.json` exists but `currentStep != "epic"`, stop and point the user at `yad-status` / the gate.
43
+ **Precondition gate (rail):** if `state.json` already exists (analysis-ran or re-entry), run
44
+ `yad next EP-<slug> --check epic` — if it exits non-zero, **STOP** and surface the blocker, pointing the
45
+ user at `yad next EP-<slug>`. When no `state.json` exists yet, this is the greenfield entry point — the
46
+ check is not applicable, so proceed and seed state.
44
47
 
45
48
  ### Step 2 — Shape the idea (assist: analyst) — or read the analysis
46
49
  - **Analysis skipped:** ask the user for a one-line feature idea if not provided, then adopt the
@@ -24,9 +24,14 @@ There is **no `sm` agent** (Phase 0 Deviation 1): the `pm` lens breaks down the
24
24
  ## On Activation
25
25
 
26
26
  ### Step 1 — Resolve the epic and check the gate
27
- Resolve the `EP-<slug>` (ask if not provided). Read `.sdlc/state.json`. Only proceed when
28
- `currentStep == "stories"` and that step's `status == "in_progress"` (the UI review must already have
29
- passed). If not, stop and point the user at `yad-status` / the gate.
27
+ Resolve the `EP-<slug>` (ask if not provided).
28
+
29
+ **Precondition gate (rail):** run `yad next EP-<slug> --check stories` first. If it exits non-zero,
30
+ **STOP** — surface the blocker it prints and point the user at `yad next EP-<slug>`. Do not author until
31
+ it passes.
32
+
33
+ This passes when `stories` is the next runnable step per the state sequence — every prior step
34
+ (through the UI review) is `done` and `stories` is not already `done`.
30
35
 
31
36
  ### Step 1b — Open the authoring branch
32
37
  Open the stories authoring branch `stories/EP-<slug>` per the shared procedure
@@ -36,11 +36,16 @@ the Markdown artifact only — the testing tool is additive, exactly like the de
36
36
  ## On Activation
37
37
 
38
38
  ### Step 1 — Resolve the epic and check the track
39
- Resolve the `EP-<slug>` (ask if not provided). Read `.sdlc/state.json`. Only proceed when the
40
- **`test-cases` step's `status == "in_progress"`** — it opens when `stories-review` passes (the epic is
41
- already `ready-for-build` by then; `currentStep` stays there because this is a parallel track, so do
42
- **not** gate on `currentStep`). If `test-cases` is still `blocked`, the stories review has not passed
43
- stop and point the user at `yad-status` / the gate.
39
+ Resolve the `EP-<slug>` (ask if not provided).
40
+
41
+ **Precondition gate (rail):** run `yad next EP-<slug> --check test-cases` first. If it exits non-zero,
42
+ **STOP** surface the blocker it prints and point the user at `yad next EP-<slug>`. Do not author until
43
+ it passes. The check is track-aware: it keys off the `test-cases` step's predecessors, **not**
44
+ `currentStep` (this is a parallel track — `currentStep` stays at `ready-for-build`).
45
+
46
+ This passes once the `test-cases` step is runnable — its predecessor `stories-review` is `done` (so the
47
+ step has opened to `in_progress`) and `test-cases` is not already `done`. While it is still `blocked`,
48
+ the stories review has not passed.
44
49
 
45
50
  ### Step 1b — Open the authoring branch
46
51
  Open the test-cases authoring branch `test-cases/EP-<slug>` per the shared procedure
@@ -33,9 +33,14 @@ like Impeccable.
33
33
  ## On Activation
34
34
 
35
35
  ### Step 1 — Resolve the epic and check the gate
36
- Resolve the `EP-<slug>` (ask if not provided). Read `.sdlc/state.json`. Only proceed when
37
- `currentStep == "ui-design"` and that step's `status == "in_progress"` (the architecture review must
38
- already have passed). If not, stop and point the user at `yad-status` / the gate.
36
+ Resolve the `EP-<slug>` (ask if not provided).
37
+
38
+ **Precondition gate (rail):** run `yad next EP-<slug> --check ui-design` first. If it exits non-zero,
39
+ **STOP** — surface the blocker it prints and point the user at `yad next EP-<slug>`. Do not author until
40
+ it passes.
41
+
42
+ This passes when `ui-design` is the next runnable step per the state sequence — every prior step
43
+ (through the architecture review) is `done` and `ui-design` is not already `done`.
39
44
 
40
45
  ### Step 1b — Open the authoring branch
41
46
  Open the UI authoring branch `ui-design/EP-<slug>` per the shared procedure