yadflow 3.13.1 → 3.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 +14 -0
- package/bin/yad.mjs +3 -1
- package/cli/doctor.mjs +32 -10
- package/cli/next.mjs +60 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [3.14.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.2...v3.14.0) (2026-08-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **next:** emit the action object with --json ([40d34dd](https://github.com/abdelrahmannasr/yadflow/commit/40d34ddba2492821700c7a877de28938faa74f3e))
|
|
7
|
+
|
|
8
|
+
## [3.13.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.1...v3.13.2) (2026-08-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **doctor:** fail a done review gate that holds no approval ([b1b23df](https://github.com/abdelrahmannasr/yadflow/commit/b1b23df5426e8523cdb1d95221cb2291200c6249))
|
|
14
|
+
|
|
1
15
|
## [3.13.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.0...v3.13.1) (2026-07-29)
|
|
2
16
|
|
|
3
17
|
|
package/bin/yad.mjs
CHANGED
|
@@ -68,6 +68,8 @@ ${c.bold('Where am I / what next')}
|
|
|
68
68
|
yad next <epic> The single next action for one epic (skill or yad command)
|
|
69
69
|
yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
|
|
70
70
|
yad next --all Every active epic's next action at once
|
|
71
|
+
yad next [<epic>] --json The same answer as a machine-readable action object (for
|
|
72
|
+
agents/CI) — always every epic, so --all is implied
|
|
71
73
|
yad skip <epic> ui-design --reason <text> Mark an optional step N/A for this epic (only
|
|
72
74
|
ui-design today) — a backend/API/data epic with no UI.
|
|
73
75
|
Stays visible & auditable (pre-done, gate short-circuited);
|
|
@@ -257,7 +259,7 @@ async function main() {
|
|
|
257
259
|
const [, epic] = o._;
|
|
258
260
|
// `--check` with no step is a malformed guard call — fail loudly rather than silently print.
|
|
259
261
|
if (o.check === true) { log(c.red('usage: yad next <epic> --check <step>')); process.exitCode = 1; break; }
|
|
260
|
-
await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all });
|
|
262
|
+
await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all, json: o.json });
|
|
261
263
|
break;
|
|
262
264
|
}
|
|
263
265
|
case 'skip': {
|
package/cli/doctor.mjs
CHANGED
|
@@ -368,20 +368,40 @@ function contractLockCheck(checks, root, epic, ledger) {
|
|
|
368
368
|
check(checks, id, 'epics', 'ok', `${epic}: contract surface matches its lock (${short(stored)})`);
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
371
|
+
// Two findings on a review step that is already `done`, both about the approval record behind it.
|
|
372
|
+
//
|
|
373
|
+
// FAIL — it holds NO qualifying approval at all (outside solo mode). This is the state the gate
|
|
374
|
+
// exists to prevent: the step advanced without the record that justifies it. `gatePredicate` counts
|
|
375
|
+
// exactly the same thing (`status === 'approved'`, with `inherited`/`skipped` steps short-circuited
|
|
376
|
+
// before it), so a step doctor reports here is one the gate itself would refuse today.
|
|
377
|
+
//
|
|
378
|
+
// WARN — it holds approvals, but none still bind to the artifact as it stands. The gate is
|
|
379
|
+
// deliberately one-way — nothing pulls a chain backward once work is built on it — so the only way
|
|
380
|
+
// this surfaces is if something reports it. `gate sync` records the gap on the step it is syncing;
|
|
381
|
+
// this reports it for the whole epic, so a re-locked surface that was never re-approved is visible
|
|
382
|
+
// in the one command people run when something looks wrong. A warning, because the state is a fact
|
|
383
|
+
// about history and the fix (re-open the review) is a human decision.
|
|
384
|
+
function staleGateCheck(checks, root, epic, ledger, { solo = false } = {}) {
|
|
378
385
|
const epicDir = epicRoot(root, epic);
|
|
379
386
|
for (const s of ledger.state.steps) {
|
|
380
387
|
if (s.type !== 'review+approve' || s.status !== 'done' || s.inherited || s.skipped) continue;
|
|
388
|
+
const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
|
|
389
|
+
// Checked BEFORE the artifact hash below: "done holding no approval" is a claim about the ledger,
|
|
390
|
+
// not about content, so it must not depend on there being something to hash. Gating it behind the
|
|
391
|
+
// hash would keep hiding it on every epic with no locked surface.
|
|
392
|
+
if (!forStep.length) {
|
|
393
|
+
// Solo mode waives the approval requirement outright (you cannot approve your own PR) — the
|
|
394
|
+
// merge + resolved threads are what advance the step, so an empty record is the documented
|
|
395
|
+
// shape there, not a finding. Everywhere else it is the gate being silently defeated.
|
|
396
|
+
if (solo) continue;
|
|
397
|
+
const records = ledger.approvals.filter((a) => a.step === s.id).length;
|
|
398
|
+
check(checks, `epic:${epic}:${s.id}:unapproved`, 'epics', 'fail',
|
|
399
|
+
`${epic}: ${s.id} is done but holds no approval${records ? ` (${records} record(s), none of them live)` : ''}`,
|
|
400
|
+
'the step advanced without the record the gate exists to keep — re-open the review (a fresh PR/MR) and re-approve, or run `yad gate sync` if the approvals are on the PR but never reached the ledger');
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
381
403
|
const cur = artifactHash(epicDir, s.artifact);
|
|
382
404
|
if (!cur) continue; // nothing to bind to (no locked surface / incomplete set) — not a staleness claim
|
|
383
|
-
const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
|
|
384
|
-
if (!forStep.length) continue; // solo mode waives approvals entirely; absence is not staleness
|
|
385
405
|
const live = forStep.filter((a) => !a.artifactHash || a.artifactHash === cur);
|
|
386
406
|
if (live.length) continue;
|
|
387
407
|
check(checks, `epic:${epic}:${s.id}:stale`, 'epics', 'warn',
|
|
@@ -393,6 +413,8 @@ function staleGateCheck(checks, root, epic, ledger) {
|
|
|
393
413
|
export function epicChecks(checks, root) {
|
|
394
414
|
const epicsDir = path.join(root, 'epics');
|
|
395
415
|
if (!exists(epicsDir)) return;
|
|
416
|
+
// Read once for the whole sweep: whether approval is waived is a project fact, not a per-epic one.
|
|
417
|
+
const solo = isSolo(readJSON(path.join(root, PROJECT_FILES.hubConfig), null));
|
|
396
418
|
for (const e of fs.readdirSync(epicsDir).sort()) {
|
|
397
419
|
if (!fs.statSync(path.join(epicsDir, e)).isDirectory()) continue;
|
|
398
420
|
try {
|
|
@@ -419,7 +441,7 @@ export function epicChecks(checks, root) {
|
|
|
419
441
|
`${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
|
|
420
442
|
'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');
|
|
421
443
|
contractLockCheck(checks, root, e, ledger);
|
|
422
|
-
staleGateCheck(checks, root, e, ledger);
|
|
444
|
+
staleGateCheck(checks, root, e, ledger, { solo });
|
|
423
445
|
}
|
|
424
446
|
} catch (err) {
|
|
425
447
|
check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
|
package/cli/next.mjs
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
// yad next <epic> the single next action for one epic
|
|
10
10
|
// yad next <epic> --check <step> exit 0 if <step> is runnable now, else 1 (the precondition guard)
|
|
11
11
|
// yad next --all every active epic's next action at once
|
|
12
|
+
// yad next [<epic>] --json the same answer as an action object, for an agent or CI
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
|
|
15
|
-
import { PROJECT_FILES } from './manifest.mjs';
|
|
16
|
+
import { PROJECT_FILES, VERSION } from './manifest.mjs';
|
|
16
17
|
import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, epicLineage, kindNoun, DISCOVERY_EPIC } from './epic-state.mjs';
|
|
17
18
|
|
|
18
19
|
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
@@ -37,6 +38,14 @@ function listEpics(root) {
|
|
|
37
38
|
.sort();
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// The action object for ONE epic, with its lineage kind attached. The single shape both surfaces
|
|
42
|
+
// consume — `printAction` renders it, `--json` emits it verbatim — so the prose and the machine
|
|
43
|
+
// answer can never drift apart.
|
|
44
|
+
const actionFor = (root, id) => ({
|
|
45
|
+
...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
|
|
46
|
+
lineageKind: epicLineage(root, id).kind,
|
|
47
|
+
});
|
|
48
|
+
|
|
40
49
|
// EP-istifta-inquiries-S03 → S03 (the compact lane label for the roll-up). Falls back to the full id.
|
|
41
50
|
const shortStory = (s) => (s && s.match(/S\d+$/i)?.[0]) || s || '(story)';
|
|
42
51
|
|
|
@@ -128,9 +137,7 @@ function generalNext(root, { all } = {}) {
|
|
|
128
137
|
const allEpics = listEpics(root);
|
|
129
138
|
const hasDiscovery = allEpics.includes(DISCOVERY_EPIC);
|
|
130
139
|
const featureEpics = allEpics.filter((id) => id !== DISCOVERY_EPIC);
|
|
131
|
-
const discoveryAction = hasDiscovery
|
|
132
|
-
? nextAction(loadLedger(epicRoot(root, DISCOVERY_EPIC)), { epic: DISCOVERY_EPIC })
|
|
133
|
-
: null;
|
|
140
|
+
const discoveryAction = hasDiscovery ? actionFor(root, DISCOVERY_EPIC) : null;
|
|
134
141
|
const discoveryOpen = !!discoveryAction && discoveryAction.kind !== 'discovery-done';
|
|
135
142
|
|
|
136
143
|
if (!featureEpics.length) {
|
|
@@ -142,10 +149,7 @@ function generalNext(root, { all } = {}) {
|
|
|
142
149
|
return;
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
const actions = featureEpics.map((id) => (
|
|
146
|
-
...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
|
|
147
|
-
lineageKind: epicLineage(root, id).kind,
|
|
148
|
-
}));
|
|
152
|
+
const actions = featureEpics.map((id) => actionFor(root, id));
|
|
149
153
|
if (discoveryOpen) printAction(discoveryAction, { solo }); // an unfinished discovery comes first
|
|
150
154
|
|
|
151
155
|
if (featureEpics.length === 1 || all) {
|
|
@@ -171,14 +175,59 @@ function checkPrecondition(root, epic, stepId) {
|
|
|
171
175
|
process.exitCode = 1;
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
// ---- machine-readable output (`--json`) --------------------------------------------------------
|
|
179
|
+
// `nextAction` already computes exactly what a caller needs; until now the ANSI prose renderer was
|
|
180
|
+
// its only consumer, so anything driving yadflow had to regex coloured English. This emits the SAME
|
|
181
|
+
// objects, unrendered. One envelope for every route, so a caller never has to branch on the shape:
|
|
182
|
+
//
|
|
183
|
+
// { version, ok: true, actions: [ <action>, … ] } next / next <epic>
|
|
184
|
+
// { version, ok, check: { epic, step, ok, reason } } next <epic> --check <step>
|
|
185
|
+
// { version, ok: true, setUp: false, actions: [] } the project has no `yad setup` yet
|
|
186
|
+
// { version, ok: false, error } bad epic id / no state.json
|
|
187
|
+
//
|
|
188
|
+
// Exit codes are unchanged from the prose path — only the rendering differs.
|
|
189
|
+
const emitJSON = (payload) => log(JSON.stringify({ version: VERSION, ...payload }, null, 2));
|
|
190
|
+
|
|
191
|
+
// A JSON error still leaves stdout parseable: a caller that pipes us into a parser gets an object
|
|
192
|
+
// explaining the failure, never half a document or a bare ANSI line.
|
|
193
|
+
function jsonError(message) {
|
|
194
|
+
emitJSON({ ok: false, error: message });
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `--json` counterpart of runNext's three routes. Kept in one function so the routing reads next to
|
|
199
|
+
// the prose routing it mirrors.
|
|
200
|
+
function jsonNext(root, { epic, check }) {
|
|
201
|
+
if (epic && check) {
|
|
202
|
+
const res = preconditionsMet(loadLedger(epicRoot(root, epic)).state, check);
|
|
203
|
+
emitJSON({ ok: !!res.ok, check: { epic, step: check, ok: !!res.ok, ...(res.reason ? { reason: res.reason } : {}) } });
|
|
204
|
+
if (!res.ok) process.exitCode = 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (epic) {
|
|
208
|
+
if (!exists(path.join(epicRoot(root, epic), '.sdlc', 'state.json'))) {
|
|
209
|
+
return jsonError(`no epic state at epics/${epic}/.sdlc/state.json`);
|
|
210
|
+
}
|
|
211
|
+
return emitJSON({ ok: true, actions: [actionFor(root, epic)] });
|
|
212
|
+
}
|
|
213
|
+
if (!isSetUp(root)) return emitJSON({ ok: true, setUp: false, actions: [] });
|
|
214
|
+
// Every epic that HAS a ledger, discovery included — its `kind` already says whether it is open
|
|
215
|
+
// (`discovery-*`) or finished, so filtering it out would hide a fact rather than clarify one.
|
|
216
|
+
// `--all` is implied: an array always carries everything, so there is nothing left to expand.
|
|
217
|
+
return emitJSON({ ok: true, actions: listEpics(root).map((id) => actionFor(root, id)) });
|
|
218
|
+
}
|
|
219
|
+
|
|
174
220
|
// Entry point for the `next` command: route to the precondition check, a single epic's action, or the
|
|
175
221
|
// project-wide general view. Validates the epic id first.
|
|
176
|
-
export async function runNext(root, { epic, check, all } = {}) {
|
|
222
|
+
export async function runNext(root, { epic, check, all, json } = {}) {
|
|
177
223
|
if (epic && !isValidEpicId(epic)) {
|
|
178
|
-
|
|
224
|
+
const message = `invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`;
|
|
225
|
+
if (json) return jsonError(message);
|
|
226
|
+
fail(message);
|
|
179
227
|
process.exitCode = 1;
|
|
180
228
|
return;
|
|
181
229
|
}
|
|
230
|
+
if (json) return jsonNext(root, { epic, check });
|
|
182
231
|
if (epic && check) return checkPrecondition(root, epic, check);
|
|
183
232
|
if (!epic) return generalNext(root, { all });
|
|
184
233
|
|
|
@@ -189,8 +238,5 @@ export async function runNext(root, { epic, check, all } = {}) {
|
|
|
189
238
|
process.exitCode = 1;
|
|
190
239
|
return;
|
|
191
240
|
}
|
|
192
|
-
printAction(
|
|
193
|
-
{ ...nextAction(loadLedger(epicDir), { epic }), lineageKind: epicLineage(root, epic).kind },
|
|
194
|
-
{ solo: isSolo(root) },
|
|
195
|
-
);
|
|
241
|
+
printAction(actionFor(root, epic), { solo: isSolo(root) });
|
|
196
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|