yadflow 2.13.0 → 2.15.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.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.14.0...v2.15.0) (2026-06-24)
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
+ * merge-driven review gate (Path B) CI never pushes the review branch ([#78](https://github.com/abdelrahmannasr/yadflow/issues/78)) ([d4d983a](https://github.com/abdelrahmannasr/yadflow/commit/d4d983ab4efddb4e6ec259bb940b393e9237f9cf)), closes [#76](https://github.com/abdelrahmannasr/yadflow/issues/76)
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,17 +13,23 @@ 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';
17
+ import { syncStatuses } from '../cli/artifact-status.mjs';
16
18
 
17
19
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
18
20
 
19
21
  ${c.bold('Setup & maintenance')}
20
- yad setup Guided first-run setup (install module, connect & wire repos)
22
+ yad setup Guided first-run setup (profile interview, install, connect & wire repos)
23
+ profile flags: --solo | --team <n>, --greenfield | --brownfield,
24
+ --monorepo | --separate, --tools (configure design/testing/learning now)
21
25
  yad check Report what is missing / drifted / stale / legacy (read-only)
22
26
  yad check --fix Reconcile: fill what is missing, update what changed
23
27
  yad update Apply drift only (alias for: check --fix --scope=changed);
24
28
  also migrates pre-2.0 sdlc-* installs to the yad-* names
25
29
  yad doctor [--json] Environment + state health: tools/auth, config files,
26
30
  repo paths, epic ledgers (exit 1 on any failure)
31
+ yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
32
+ from .sdlc/state.json — all epics if omitted (--dry-run to preview)
27
33
 
28
34
  ${c.bold('Reviewer roster')}
29
35
  yad roster list Show every member + their roles per scope (hub + each repo)
@@ -33,14 +39,20 @@ ${c.bold('Reviewer roster')}
33
39
  yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
34
40
  yad roster remove <login> Delete a member from the roster
35
41
 
42
+ ${c.bold('Where am I / what next')}
43
+ yad next Project-wide: the one next action to take (or run setup)
44
+ yad next <epic> The single next action for one epic (skill or yad command)
45
+ yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
46
+ yad next --all Every active epic's next action at once
47
+
36
48
  ${c.bold('Review gate (front half)')}
37
49
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
38
50
  yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
39
51
  yad gate comments <epic> [artifact] Fetch unresolved review comments to address
40
52
  yad gate status <epic> Show each review step + approvals
41
- yad gate ci [--branch <head>] [--pr <n>]
42
- CI entry (hub workflow): derive epic/artifact from the review branch,
43
- sync, commit the ledger to the default branch (sweep all PRs if no --branch)
53
+ yad gate ci [--branch <head>] [--pr <n>] [--merged]
54
+ CI entry (hub workflow): pre-merge is read-only (nothing pushed);
55
+ --merged advances the step + flips artifact status on the default branch
44
56
 
45
57
  ${c.bold('Build helpers')}
46
58
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
@@ -71,11 +83,12 @@ ${c.bold('Options')}
71
83
  --force commit: bypass the atomic-file guard / re-copy unchanged files
72
84
  --branch <head> gate ci: the review PR/MR head branch (review/EP-<slug>/<artifact>)
73
85
  --pr <n> gate ci: the PR/MR number from the CI event
86
+ --merged gate ci: merge phase — advance the step on the default branch
74
87
  --no-push gate ci: commit the ledger but do not push
75
88
  -h, --help Show this help
76
89
  -v, --version Print version`;
77
90
 
78
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles']);
91
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team']);
79
92
 
80
93
  function parseArgs(argv) {
81
94
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -85,8 +98,21 @@ function parseArgs(argv) {
85
98
  else if (a === '--force') o.force = true;
86
99
  else if (a === '--contract-change') o.contractChange = true;
87
100
  else if (a === '--no-push') o.noPush = true;
101
+ else if (a === '--merged') o.merged = true;
88
102
  else if (a === '--overview') o.overview = true;
89
- else if (a === '--check') o.check = true;
103
+ // `--check` is a bare boolean for `docs sync --check`, but takes a value for
104
+ // `next <epic> --check <step>`. Only the `next` command consumes the following token as a value —
105
+ // scoping it to `next` keeps `docs sync --check overview` (and any other command) from swallowing a
106
+ // positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
107
+ else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
108
+ else if (a === '--all') o.all = true;
109
+ // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
110
+ else if (a === '--solo') o.solo = true;
111
+ else if (a === '--greenfield') o.greenfield = true;
112
+ else if (a === '--brownfield') o.brownfield = true;
113
+ else if (a === '--monorepo') o.monorepo = true;
114
+ else if (a === '--separate') o.separate = true;
115
+ else if (a === '--tools') o.tools = true;
90
116
  else if (a === '--refresh') o.refresh = true;
91
117
  else if (a === '--wire') o.wire = true;
92
118
  else if (a === '--dry-run') o.dryRun = true;
@@ -117,7 +143,11 @@ async function main() {
117
143
  const today = new Date().toISOString().slice(0, 10);
118
144
  switch (cmd) {
119
145
  case 'setup':
120
- await runSetup(o.dir, { today, force: o.force });
146
+ await runSetup(o.dir, {
147
+ today, force: o.force,
148
+ solo: o.solo, team: o.team, greenfield: o.greenfield, brownfield: o.brownfield,
149
+ monorepo: o.monorepo, separate: o.separate, tools: o.tools,
150
+ });
121
151
  break;
122
152
  case 'check':
123
153
  await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, today });
@@ -128,15 +158,31 @@ async function main() {
128
158
  case 'doctor':
129
159
  await runDoctor(o.dir, { json: o.json });
130
160
  break;
161
+ case 'sync-status': {
162
+ const [, epic] = o._;
163
+ if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
164
+ await syncStatuses(o.dir, { epic, dryRun: o.dryRun });
165
+ break;
166
+ }
167
+ case 'next': {
168
+ const [, epic] = o._;
169
+ // `--check` with no step is a malformed guard call — fail loudly rather than silently print.
170
+ if (o.check === true) { log(c.red('usage: yad next <epic> --check <step>')); process.exitCode = 1; break; }
171
+ await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all });
172
+ break;
173
+ }
131
174
  case 'gate': {
132
175
  const [, action, epic, artifact] = o._;
133
176
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
134
- if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, push: !o.noPush, today }); break; }
177
+ if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
135
178
  if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|ci> <epic> [artifact]')); process.exitCode = 1; break; }
136
179
  // The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
137
180
  if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
181
+ // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
182
+ // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
183
+ // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
138
184
  if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
139
- else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today });
185
+ else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
140
186
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
141
187
  else if (action === 'status') await gateStatus(o.dir, { epic });
142
188
  else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|ci)`)); process.exitCode = 1; }
@@ -0,0 +1,102 @@
1
+ // `yad sync-status [epic]` — reconcile each artifact's frontmatter `status:` with the real
2
+ // source of truth, the per-epic state machine in .sdlc/state.json. The authoring skills hard-code
3
+ // `status: draft` at creation and never update it, so artifacts read as "draft" long after their
4
+ // gate has passed. This derives draft / in-review / approved from the step statuses and rewrites
5
+ // only the `status:` line — advance-only, and never touching build-owned values.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { c, log, ok, info, readJSONStrict } from './lib.mjs';
9
+ import { epicRoot, artifactBase, artifactFromBase, findReviewStep } from './epic-state.mjs';
10
+ import { epicFiles } from './manifest.mjs';
11
+
12
+ // The front-gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
13
+ // ladder, so a re-run never regresses anything.
14
+ const RANK = { draft: 0, 'in-review': 1, approved: 2 };
15
+
16
+ // Values owned by other parts of the workflow — left untouched. `locked` is the contract surface;
17
+ // `in-build` / `shipped` are set by the build half (engineer-review) per story; `ready-for-build`,
18
+ // `done`, `blocked` are roll-ups/states we must not overwrite from the front-gate view.
19
+ const PRESERVE = new Set(['locked', 'in-build', 'shipped', 'ready-for-build', 'done', 'blocked']);
20
+
21
+ // The per-epic artifact files this command considers (bases). Story files are handled separately
22
+ // because they live under stories/ and all map to the single stories / stories-review step pair.
23
+ const ARTIFACT_FILES = ['analysis.md', 'epic.md', 'architecture.md', 'contract.md', 'ui-design.md', 'test-cases.md'];
24
+
25
+ // The desired front-gate status for an artifact base, derived purely from state.json. Returns null
26
+ // when the chain has no steps for this base (nothing to manage) — e.g. contract has no own step.
27
+ export function desiredStatus(state, base) {
28
+ if (!state?.steps) return null;
29
+ const review = findReviewStep(state, artifactFromBase(base));
30
+ const author = state.steps.find((s) => s.type === 'author' && artifactBase(s.artifact) === base);
31
+ if (!review && !author) return null;
32
+ if (review?.status === 'done') return 'approved';
33
+ if (review?.status === 'in_review' || author?.status === 'done') return 'in-review';
34
+ return 'draft';
35
+ }
36
+
37
+ // Rewrite ONLY the `status:` line inside the first `---\n...\n---` frontmatter block, preserving
38
+ // everything else. Returns the prior status when a change was written, or null for a no-op (no
39
+ // frontmatter, no status line, preserved value, or not an advance). Mirrors the frontmatter regex
40
+ // in gate.mjs.
41
+ export function setFrontmatterStatus(file, status) {
42
+ if (!fs.existsSync(file)) return null;
43
+ const text = fs.readFileSync(file, 'utf8');
44
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
45
+ if (!fm) return null;
46
+ const cur = (fm[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
47
+ if (cur === undefined) return null;
48
+ // Advance-only within the managed ladder; anything else (build-owned, roll-ups) is left as-is.
49
+ if (PRESERVE.has(cur)) return null;
50
+ if (!(cur in RANK) || !(status in RANK) || RANK[status] <= RANK[cur]) return null;
51
+ const block = fm[1].replace(/^status:\s*.*$/m, `status: ${status}`);
52
+ fs.writeFileSync(file, text.replace(fm[1], block));
53
+ return cur;
54
+ }
55
+
56
+ // Sweep one epic (or every epic under epics/) and reconcile artifact frontmatter with state.json.
57
+ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
58
+ const epicsDir = path.join(root, 'epics');
59
+ const epics = epic
60
+ ? [epic]
61
+ : (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
62
+ if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0 }; }
63
+
64
+ let changed = 0;
65
+ for (const e of epics) {
66
+ const dir = epicRoot(root, e);
67
+ const state = readJSONStrict(epicFiles(dir).state, null);
68
+ if (!state?.steps) { info(`${e}: no state.json — skipping`); continue; }
69
+
70
+ // Single-file artifacts + each story file (all keyed to the stories step pair).
71
+ const files = ARTIFACT_FILES.map((f) => ({ base: artifactBase(f), file: path.join(dir, f) }));
72
+ const storiesDir = path.join(dir, 'stories');
73
+ if (fs.existsSync(storiesDir)) {
74
+ for (const f of fs.readdirSync(storiesDir).filter((x) => x.endsWith('.md'))) {
75
+ files.push({ base: 'stories', file: path.join(storiesDir, f) });
76
+ }
77
+ }
78
+
79
+ for (const { base, file } of files) {
80
+ if (!fs.existsSync(file)) continue;
81
+ const want = desiredStatus(state, base);
82
+ if (!want) continue;
83
+ if (dryRun) {
84
+ // Peek without writing so --dry-run reports exactly what would change. Scope the match to the
85
+ // frontmatter block so a `status:` line in the Markdown body can't be mistaken for the value.
86
+ const text = fs.readFileSync(file, 'utf8');
87
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
88
+ const cur = (fm?.[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
89
+ if (cur && !PRESERVE.has(cur) && cur in RANK && RANK[want] > RANK[cur]) {
90
+ log(` ${c.dim('• would update')} ${path.relative(root, file)}: ${cur} → ${want}`);
91
+ changed++;
92
+ }
93
+ continue;
94
+ }
95
+ const prev = setFrontmatterStatus(file, want);
96
+ if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
97
+ }
98
+ }
99
+ if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
100
+ else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
101
+ return { changed };
102
+ }
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');
@@ -224,7 +244,20 @@ export function epicChecks(checks, root) {
224
244
  try {
225
245
  const ledger = loadLedger(epicRoot(root, e));
226
246
  if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`, 'author it via yad-epic, or remove the directory');
227
- else check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
247
+ else {
248
+ check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
249
+ // Migration guard (pre-3.0 model): under the current model CI records the ledger on the
250
+ // default branch only at merge (when the step is already done), and writes nothing during
251
+ // review — so an OPEN (non-done) review PR recorded here means it was opened under an older
252
+ // model. Merge/close it under the version that opened it before relying on the CI flow.
253
+ const openPr = (ledger.hubPrs || []).find((p) => {
254
+ const st = (ledger.state.steps.find((s) => s.id === p.step) || {}).status;
255
+ return st && st !== 'done';
256
+ });
257
+ if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
258
+ `${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
259
+ 'opened under a pre-3.0 yadflow? merge/close it before continuing — CI now records the gate ledger on the default branch only at merge');
260
+ }
228
261
  } catch (err) {
229
262
  check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
230
263
  }
@@ -42,7 +42,7 @@ export function artifactFromBase(base) {
42
42
  }
43
43
 
44
44
  // The files (relative to the epic dir) a review of this artifact covers — what `gate open` commits
45
- // on the review branch, and what the CI overlay checks out from the head ref (and never commits).
45
+ // on the review branch (the owner's artifact), and what CI re-reads to bind the approval at merge.
46
46
  // Architecture mirrors artifactHash(): the approval is bound to the locked contract surface too.
47
47
  export function artifactPaths(base) {
48
48
  if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
@@ -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 };