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 +2 -2
- package/README.md +28 -16
- package/bin/yad.mjs +55 -9
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +35 -2
- package/cli/epic-state.mjs +86 -11
- package/cli/gate.mjs +130 -60
- package/cli/lib.mjs +3 -0
- package/cli/manifest.mjs +2 -0
- package/cli/next.mjs +123 -0
- package/cli/platform.mjs +44 -6
- 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-checks/SKILL.md +7 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +8 -0
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +7 -0
- package/skills/yad-epic/SKILL.md +4 -1
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-review-gate/SKILL.md +12 -10
- 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.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
|
-
*
|
|
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 (
|
|
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,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 (
|
|
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):
|
|
43
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
}
|
package/cli/epic-state.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 };
|