yadflow 3.13.2 → 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 CHANGED
@@ -1,3 +1,10 @@
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
+
1
8
  ## [3.13.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.1...v3.13.2) (2026-08-10)
2
9
 
3
10
 
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/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
- fail(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`);
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.13.2",
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",