yadflow 3.9.3 → 3.10.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,5 +1,24 @@
1
+ # [3.10.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.4...v3.10.0) (2026-07-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **sdlc:** satisfy lint gate and cover the yad skip CLI ([838eabc](https://github.com/abdelrahmannasr/yadflow/commit/838eabc0ba597eaeac3e9249c514e3d479de4830))
7
+
8
+
9
+ ### Features
10
+
11
+ * **sdlc:** make the ui-design step optional (skippable N/A) ([2bc5583](https://github.com/abdelrahmannasr/yadflow/commit/2bc5583b58626ee0cf8f8cc993a8566e4b206221))
12
+
13
+ ## [3.9.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.3...v3.9.4) (2026-07-07)
14
+
1
15
  ## [3.9.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.2...v3.9.3) (2026-07-07)
2
16
 
17
+
18
+ _Maintenance release — CHANGELOG backfill and dependency-audit fixes (`chore`/`docs` commits carry no user-facing changes)._
19
+
20
+
21
+
3
22
  ## [3.9.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.1...v3.9.2) (2026-07-06)
4
23
 
5
24
 
package/bin/yad.mjs CHANGED
@@ -17,6 +17,7 @@ import { runRoster } from '../cli/roster.mjs';
17
17
  import { runDocs } from '../cli/docs.mjs';
18
18
  import { runDoctor } from '../cli/doctor.mjs';
19
19
  import { runNext } from '../cli/next.mjs';
20
+ import { runSkip } from '../cli/skip.mjs';
20
21
  import { syncStatuses } from '../cli/artifact-status.mjs';
21
22
  import { runThread, runReconcile } from '../cli/thread.mjs';
22
23
  import { runReport } from '../cli/report.mjs';
@@ -66,6 +67,10 @@ ${c.bold('Where am I / what next')}
66
67
  yad next <epic> The single next action for one epic (skill or yad command)
67
68
  yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
68
69
  yad next --all Every active epic's next action at once
70
+ yad skip <epic> ui-design --reason <text> Mark an optional step N/A for this epic (only
71
+ ui-design today) — a backend/API/data epic with no UI.
72
+ Stays visible & auditable (pre-done, gate short-circuited);
73
+ --undo reverses it until the stories review opens
69
74
 
70
75
  ${c.bold('Review gate (front half)')}
71
76
  yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
@@ -143,7 +148,7 @@ ${c.bold('Options')}
143
148
  -h, --help Show this help
144
149
  -v, --version Print version`;
145
150
 
146
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format']);
151
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
147
152
 
148
153
  function parseArgs(argv) {
149
154
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -163,6 +168,7 @@ function parseArgs(argv) {
163
168
  // positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
164
169
  else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
165
170
  else if (a === '--all') o.all = true;
171
+ else if (a === '--undo') o.undo = true;
166
172
  // setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
167
173
  else if (a === '--solo') o.solo = true;
168
174
  else if (a === '--greenfield') o.greenfield = true;
@@ -238,6 +244,12 @@ async function main() {
238
244
  await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all });
239
245
  break;
240
246
  }
247
+ case 'skip': {
248
+ const [, epic, step] = o._;
249
+ if (!epic || !isValidEpicId(epic)) { log(c.red(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
250
+ await runSkip(o.dir, { epic, step, reason: o.reason, undo: o.undo, today });
251
+ break;
252
+ }
241
253
  case 'gate': {
242
254
  const [, action, epic, artifact] = o._;
243
255
  // `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
@@ -219,6 +219,20 @@ export function gatePredicate({
219
219
  };
220
220
  }
221
221
 
222
+ // A SKIPPED step (an optional step the team marked N/A for this epic — e.g. `ui-design` on a
223
+ // backend-only epic) is satisfied without review. Like `inherited`, it is pre-marked `done` in
224
+ // state.json so the gate is normally never invoked on it; this short-circuit makes a direct call
225
+ // safe and keeps the skip a first-class, auditable outcome (the reason lives on the step).
226
+ // GUARD: only honour the flag on a genuinely skippable step (the author step or its `-review` gate).
227
+ // A corrupted/hand-edited `skipped: true` on a non-optional step (e.g. `stories-review`) must NOT
228
+ // bypass approvals — it falls through to the real predicate below and fails for lack of approvals.
229
+ if (step?.skipped && isSkippableStep(step.id)) {
230
+ return {
231
+ approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
232
+ passed: true, missing: [], rule: 'skipped',
233
+ };
234
+ }
235
+
222
236
  const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
223
237
  // Revoke-on-change: an approval bound to a stale content hash no longer counts.
224
238
  const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
@@ -296,7 +310,12 @@ export function advanceState(state, step) {
296
310
  state.currentStep = 'discovery-done';
297
311
  return state;
298
312
  }
299
- const next = state.steps[i + 1];
313
+ // Step over any SKIPPED steps (an optional step marked N/A for this epic — e.g. a skipped
314
+ // `ui-design`/`ui-design-review` pair). They are pre-marked `done`, so the next runnable step is the
315
+ // first later step that is not skipped. When the whole tail is skipped, fall through to ready-for-build.
316
+ let j = i + 1;
317
+ while (state.steps[j]?.skipped) j++;
318
+ const next = state.steps[j];
300
319
  if (next) {
301
320
  next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
302
321
  state.currentStep = next.id;
@@ -306,6 +325,117 @@ export function advanceState(state, step) {
306
325
  return state;
307
326
  }
308
327
 
328
+ // The front steps that may be marked N/A ("skipped") for an epic that does not need them. Only the
329
+ // UI-design step is optional today: an epic with no user-facing surface (backend/API, data, infra)
330
+ // can skip it. A skip carries a recorded reason and stays VISIBLE in the chain (both the author step
331
+ // and its review gate pre-marked `done`, short-circuited by `gatePredicate`) — the auditable,
332
+ // reversible counterpart to omitting `analysis` from the chain entirely.
333
+ export const SKIPPABLE_STEPS = new Set(['ui-design']);
334
+
335
+ // True for a genuinely skippable step id — the author step (`ui-design`) OR its paired review gate
336
+ // (`ui-design-review`). Used to gate the `gatePredicate` skip short-circuit so a corrupted/hand-edited
337
+ // `skipped: true` on a non-optional step cannot bypass its real approvals.
338
+ export function isSkippableStep(id) {
339
+ return SKIPPABLE_STEPS.has(String(id || '').replace(/-review$/, ''));
340
+ }
341
+
342
+ // Strip the skip-provenance fields off a step — the inverse of the stamp `skipStep` applies.
343
+ function withoutSkip(step) {
344
+ const rest = { ...step };
345
+ delete rest.skipped;
346
+ delete rest.skipReason;
347
+ delete rest.skippedBy;
348
+ delete rest.skippedAt;
349
+ return rest;
350
+ }
351
+
352
+ // PURE. Mark a skippable step (its author step + paired `<id>-review` gate) N/A for this epic: pre-mark
353
+ // both `done` with a recorded reason, and — if currentStep is sitting on the pair — advance currentStep
354
+ // past them to the next non-skipped step. Idempotent on an already-skipped step. Refuses once the step
355
+ // was authored, once its review gate has opened, or once its downstream `stories` has started — the
356
+ // step is optional only up to authoring it. Throws on a non-skippable id or a malformed (unpaired) chain.
357
+ export function skipStep(state, stepId, { reason, by = null, at = null } = {}) {
358
+ if (!SKIPPABLE_STEPS.has(stepId)) {
359
+ throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
360
+ }
361
+ const ai = state.steps.findIndex((s) => s.id === stepId);
362
+ if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to skip');
363
+ const author = state.steps[ai];
364
+ // Idempotent BEFORE the reason check: a repeat skip on an already-N/A step is a no-op that keeps the
365
+ // original reason/actor, so it must not fail merely for lacking a fresh --reason.
366
+ if (author.skipped) return state;
367
+ if (!reason || !String(reason).trim()) {
368
+ throw err('YAD-STATE-004', 'a skip needs a reason', 'pass a reason, e.g. "backend-only epic, no UI"');
369
+ }
370
+ // A skippable step must carry its paired `-review` gate — the change keeps BOTH in the chain. A
371
+ // missing gate is a malformed chain; refuse rather than half-stamp only the author step.
372
+ const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
373
+ if (ri === -1) throw err('YAD-STATE-004', `malformed chain: ${stepId} has no ${stepId}-review gate`, 'restore state.json from git');
374
+ const review = state.steps[ri];
375
+ if (author.status === 'done') {
376
+ throw err('YAD-STATE-004', `${stepId} is already authored`, 'cannot skip a step whose artifact was already written');
377
+ }
378
+ // Once the review gate has opened (in_review / done), the UI work is effectively committed — skipping
379
+ // then would orphan a live review PR. Refuse; the step is optional only up to authoring it.
380
+ if (review.status !== 'blocked') {
381
+ throw err('YAD-STATE-004', `cannot skip ${stepId} — its review has already opened`, 'skip the UI step before its review begins');
382
+ }
383
+ const stories = state.steps.find((s) => s.id === 'stories');
384
+ if (stories && stories.status !== 'blocked') {
385
+ throw err('YAD-STATE-004', `cannot skip ${stepId} — stories have already started`, 'skip the UI step before stories begin');
386
+ }
387
+ const stamp = { skipped: true, skipReason: String(reason).trim(), skippedBy: by, skippedAt: at, status: 'done' };
388
+ state.steps[ai] = { ...author, ...stamp };
389
+ state.steps[ri] = { ...review, ...stamp };
390
+ // If currentStep was on the pair we just skipped, move it to the next non-skipped step.
391
+ if (state.currentStep === stepId || state.currentStep === `${stepId}-review`) {
392
+ let j = ri + 1;
393
+ while (state.steps[j]?.skipped) j++;
394
+ const next = state.steps[j];
395
+ if (next) {
396
+ if (next.status === 'blocked') next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
397
+ state.currentStep = next.id;
398
+ } else {
399
+ state.currentStep = 'ready-for-build';
400
+ }
401
+ }
402
+ return state;
403
+ }
404
+
405
+ // PURE. Reverse a skip: clear the N/A stamp on the pair and restore the chain. Allowed only while the
406
+ // downstream `stories-review` has not opened (state-only signal for "stories authoring is under way").
407
+ // If every earlier step is done, the restored author step becomes the active step again (and a
408
+ // downstream that the skip auto-opened is pushed back to `blocked` behind it); otherwise it just
409
+ // returns to `blocked`. Throws if the step is not skipped or it is too late.
410
+ export function unskipStep(state, stepId) {
411
+ if (!SKIPPABLE_STEPS.has(stepId)) {
412
+ throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
413
+ }
414
+ const ai = state.steps.findIndex((s) => s.id === stepId);
415
+ if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to un-skip');
416
+ if (!state.steps[ai].skipped) throw err('YAD-STATE-004', `${stepId} is not skipped`, 'nothing to un-skip');
417
+ const storiesReview = state.steps.find((s) => s.id === 'stories-review');
418
+ if (storiesReview && storiesReview.status !== 'blocked') {
419
+ throw err('YAD-STATE-004', `cannot un-skip ${stepId} — the stories review has already opened`, 'un-skip before the stories review begins');
420
+ }
421
+ const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
422
+ const priorAllDone = state.steps.slice(0, ai).every((s) => s.status === 'done');
423
+ state.steps[ai] = { ...withoutSkip(state.steps[ai]), status: priorAllDone ? 'in_progress' : 'blocked' };
424
+ if (ri !== -1) state.steps[ri] = { ...withoutSkip(state.steps[ri]), status: 'blocked' };
425
+ if (priorAllDone) {
426
+ // The restored author step is the active step again. Push the downstream the skip auto-opened
427
+ // back to `blocked` (it must wait behind the now-live step), and re-point currentStep here. Scan
428
+ // past any still-skipped steps (mirrors skipStep's step-over) and reset whether it was opened as
429
+ // an author step (`in_progress`) or a review gate (`in_review`).
430
+ let j = (ri !== -1 ? ri : ai) + 1;
431
+ while (state.steps[j]?.skipped) j++;
432
+ const after = state.steps[j];
433
+ if (after && (after.status === 'in_progress' || after.status === 'in_review')) after.status = 'blocked';
434
+ state.currentStep = stepId;
435
+ }
436
+ return state;
437
+ }
438
+
309
439
  // Mark a step in-review (idempotent) and point currentStep at it — EXCEPT once the epic is
310
440
  // `ready-for-build`: the parallel `test-cases` track must not pull currentStep back (the build half
311
441
  // runs alongside the tester, and only the test-cases review is in flight at that point).
package/cli/errors.mjs CHANGED
@@ -19,6 +19,7 @@ export const CODES = {
19
19
  'YAD-STATE-001': 'a ledger/config JSON file exists but does not parse',
20
20
  'YAD-STATE-002': 'a ledger/config JSON file parses but has the wrong shape',
21
21
  'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
22
+ 'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
22
23
  'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
23
24
  'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
24
25
  'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
package/cli/skip.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // `yad skip <epic> <step> --reason "<why>"` (and `--undo`) — mark an OPTIONAL front step N/A for one
2
+ // epic. Today only `ui-design` is skippable: an epic with no user-facing surface (backend/API, data,
3
+ // infra) does not need a UI-design artifact + review gate. The skip stays VISIBLE and auditable — the
4
+ // step is pre-marked `done` with a recorded reason (and actor/date), short-circuited at the gate — and
5
+ // is reversible with `--undo` until the stories review opens. All state logic is the pure
6
+ // `skipStep`/`unskipStep` in epic-state.mjs; this is the thin file-load/save + attribution wrapper.
7
+ import { ok, info, hand, fail, run, writeJSON } from './lib.mjs';
8
+ import { epicRoot, loadLedger, skipStep, unskipStep } from './epic-state.mjs';
9
+ import { loadHub } from './gate.mjs';
10
+ import { resolveCommitterLogin } from './platform.mjs';
11
+
12
+ // Best-effort auditable actor for `skippedBy`: the roster login for the local git identity, else the
13
+ // raw git user.name, else null. A malformed/absent hub degrades to the raw name — attribution is a
14
+ // nicety on the audit trail, never a gate, so it must not block the skip.
15
+ function skipActor(root) {
16
+ let roster = [];
17
+ try { roster = loadHub(root)?.hub?.roster || []; } catch { /* no hub / malformed — attribute by raw git name */ }
18
+ return resolveCommitterLogin(root, roster)
19
+ || (run('git', ['config', 'user.name'], { cwd: root }).stdout || '').trim()
20
+ || null;
21
+ }
22
+
23
+ export async function runSkip(root, { epic, step, reason, undo = false, today } = {}) {
24
+ const epicDir = epicRoot(root, epic);
25
+ const ledger = loadLedger(epicDir);
26
+ if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
27
+ if (!step) { fail('usage: yad skip <epic> <step> --reason "<why>" (or: yad skip <epic> <step> --undo)'); process.exitCode = 1; return; }
28
+
29
+ // Guard violations throw a YadError (YAD-STATE-004) with a hint — the top-level catch in bin/yad.mjs
30
+ // renders those. Here we only handle the happy path + the two plain-arg checks above.
31
+ if (undo) {
32
+ unskipStep(ledger.state, step);
33
+ writeJSON(ledger.files.state, ledger.state);
34
+ ok(`${step} un-skipped — back in the chain`);
35
+ hand(`currentStep is now ${ledger.state.currentStep}`);
36
+ return;
37
+ }
38
+
39
+ const by = skipActor(root);
40
+ skipStep(ledger.state, step, { reason, by, at: today });
41
+ writeJSON(ledger.files.state, ledger.state);
42
+ ok(`${step} marked N/A${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
43
+ info(`reason: ${String(reason).trim()}`);
44
+ hand(`its review gate is short-circuited; currentStep is now ${ledger.state.currentStep} (reverse with \`yad skip ${epic} ${step} --undo\`)`);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.9.3",
3
+ "version": "3.10.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",
@@ -63,6 +63,7 @@
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.1",
65
65
  "@semantic-release/changelog": "^6.0.3",
66
+ "@semantic-release/git": "^10.0.1",
66
67
  "eslint": "^10.5.0",
67
68
  "semantic-release": "^25.0.3"
68
69
  }
@@ -33,7 +33,8 @@ output_folder: "{project-root}/_bmad-output"
33
33
  defaults:
34
34
  assistance: review # none | review | heavy
35
35
  automation: human_approve # human_approve | machine_advance
36
- # Front steps (discovery [optional front-zero], analysis [optional], epic, architecture, ui-design,
36
+ # Front steps (discovery [optional front-zero], analysis [optional], epic, architecture,
37
+ # ui-design [optional — skippable N/A for UI-less epics via `yad skip <epic> ui-design --reason "<why>"`],
37
38
  # stories, test-cases) are locked to human_approve and may NOT be set to machine_advance in this
38
39
  # version (build plan §1, §8.7).
39
40
  front_steps_locked: true