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 +2 -2
- package/README.md +28 -16
- package/bin/yad.mjs +36 -4
- package/cli/doctor.mjs +21 -1
- package/cli/epic-state.mjs +85 -10
- package/cli/gate.mjs +9 -2
- package/cli/lib.mjs +3 -0
- package/cli/next.mjs +123 -0
- package/cli/repo.mjs +8 -4
- package/cli/setup.mjs +215 -80
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +8 -0
- package/skills/yad-analysis/SKILL.md +6 -3
- package/skills/yad-architecture/SKILL.md +8 -3
- package/skills/yad-epic/SKILL.md +4 -1
- package/skills/yad-stories/SKILL.md +8 -3
- package/skills/yad-test-cases/SKILL.md +10 -5
- package/skills/yad-ui/SKILL.md +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# [2.
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
142
|
-
4. **
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
6. **
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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');
|
package/cli/epic-state.mjs
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
314
|
-
|
|
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
|
-
//
|
|
325
|
-
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
//
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
tool =
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
//
|
|
453
|
-
|
|
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
|
-
//
|
|
467
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
482
|
-
// repos you add now; this closes the gap so a member's
|
|
483
|
-
//
|
|
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
|
-
//
|
|
500
|
-
|
|
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
|
-
//
|
|
520
|
-
|
|
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
|
-
//
|
|
531
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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.
|
|
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",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -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.
|
|
30
|
-
|
|
31
|
-
|
|
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).
|
|
27
|
-
|
|
28
|
-
|
|
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
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
28
|
-
|
|
29
|
-
|
|
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).
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
**
|
|
43
|
-
|
|
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
|
package/skills/yad-ui/SKILL.md
CHANGED
|
@@ -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).
|
|
37
|
-
|
|
38
|
-
|
|
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
|