yadflow 2.12.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,14 +1,9 @@
1
- # [2.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.11.1...v2.12.0) (2026-06-16)
2
-
3
-
4
- ### Bug Fixes
5
-
6
- * **checks:** harden spec-link + gitlab gate templates ([#69](https://github.com/abdelrahmannasr/yadflow/issues/69)) ([42f3949](https://github.com/abdelrahmannasr/yadflow/commit/42f3949841095624db03cb17f85db3138be8b93b))
1
+ # [2.14.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.13.0...v2.14.0) (2026-06-21)
7
2
 
8
3
 
9
4
  ### Features
10
5
 
11
- * **docs:** pipeline-shaped overview canvas + collapsible panels + content refresh ([#70](https://github.com/abdelrahmannasr/yadflow/issues/70)) ([da47b80](https://github.com/abdelrahmannasr/yadflow/commit/da47b8050ccd7aa1919bf4b364298a90cdd56012))
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))
12
7
 
13
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
14
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/docs.mjs CHANGED
@@ -43,11 +43,12 @@ export function deployTargetFromHub(hub = {}) {
43
43
  }
44
44
 
45
45
  // The Vite `base` for one site: join the project base path (from docs.json) with the per-site
46
- // subpath. Per-epic sites nest under epics/<id>/; the overview is the Pages root. Always a
46
+ // subpath. The overview SPA mounts under `app/` so the hand-maintained report.html can own the
47
+ // Pages root (`<base>/`) as the main documentation; per-epic sites nest under epics/<id>/. Always a
47
48
  // leading+trailing slash so it works as a Vite base and a router basename.
48
49
  export function siteBasePath(docs = {}, { epic, overview } = {}) {
49
50
  const root = (docs.basePath || '/').replace(/\/+$/, '') || '';
50
- const sub = overview ? '' : epic ? `/epics/${epic}` : '';
51
+ const sub = overview ? '/app' : epic ? `/epics/${epic}` : '';
51
52
  const joined = `${root}${sub}`.replace(/\/+/g, '/');
52
53
  return joined ? `${joined.replace(/\/$/, '')}/` : '/';
53
54
  }
@@ -113,12 +114,13 @@ export function docsStale(manifest, { artifactHash, repoHeads = {}, templateVers
113
114
 
114
115
  // ---- pure: the Pages CI workflow (committed by `yad docs sync --wire`) ---------------------------
115
116
  // GitHub Actions deploy-pages, or a GitLab `pages` job. Both assemble a single `public/` tree: the
116
- // overview at the root and every per-epic site under `epics/<id>/` matching siteBasePath's nesting
117
- // (overview at `<base>/`, epics at `<base>/epics/EP-<slug>/`). A concurrency group prevents the
117
+ // hand-maintained report.html owns the root (`<base>/` the main documentation), the overview SPA
118
+ // mounts under `app/`, and every per-epic site nests under `epics/<id>/` matching siteBasePath
119
+ // (overview at `<base>/app/`, epics at `<base>/epics/EP-<slug>/`). A concurrency group prevents the
118
120
  // deploy from retriggering. The shared shell script keeps the two platforms byte-for-byte aligned.
119
121
  const BUILD_PUBLIC = [
120
122
  'mkdir -p public',
121
- 'if [ -d docs/sdlc-site ]; then (cd docs/sdlc-site && npm ci && npm run build) && cp -r docs/sdlc-site/dist/. public/; fi',
123
+ 'if [ -d docs/sdlc-site ]; then (cd docs/sdlc-site && npm ci && npm run build) && mkdir -p public/app && cp -r docs/sdlc-site/dist/. public/app/ && cp docs/sdlc-site/public/report.html public/index.html && cp docs/sdlc-site/public/report.html public/report.html; fi',
122
124
  'for d in epics/*/docs-site; do [ -d "$d" ] || continue; id=$(basename "$(dirname "$d")"); (cd "$d" && npm ci && npm run build) && mkdir -p "public/epics/$id" && cp -r "$d/dist/." "public/epics/$id/"; done',
123
125
  ];
124
126
  export function pagesWorkflow(platform) {
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)`);