mandrel 2.1.0 → 2.2.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.
Files changed (40) hide show
  1. package/.agents/agents/acceptance-critic.md +11 -2
  2. package/.agents/agents/story-worker.md +4 -2
  3. package/.agents/docs/SDLC.md +11 -4
  4. package/.agents/docs/configuration.md +1 -1
  5. package/.agents/docs/quality-gates.md +3 -3
  6. package/.agents/rules/gherkin-standards.md +10 -0
  7. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  8. package/.agents/schemas/agentrc.schema.json +1 -1
  9. package/.agents/scripts/acceptance-eval.js +2 -2
  10. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  12. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  13. package/.agents/scripts/lib/orchestration/code-review.js +24 -35
  14. package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
  15. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
  16. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
  17. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
  18. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  19. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  20. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
  22. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  23. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +75 -8
  25. package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
  26. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  27. package/.agents/scripts/plan-context.js +0 -1
  28. package/.agents/scripts/plan-critics.js +203 -0
  29. package/.agents/scripts/quality-preview.js +13 -6
  30. package/.agents/scripts/stories-wave-tick.js +307 -55
  31. package/.agents/workflows/deliver.md +50 -15
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
  33. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
  34. package/.agents/workflows/helpers/code-review.md +2 -2
  35. package/.agents/workflows/helpers/deliver-story.md +22 -6
  36. package/.agents/workflows/plan.md +55 -0
  37. package/docs/CHANGELOG.md +22 -0
  38. package/lib/migrations/index.js +6 -1
  39. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  40. package/package.json +2 -2
@@ -5,12 +5,10 @@
5
5
  * `/deliver` story-list path.
6
6
  *
7
7
  * Thin **adapter** over the path-agnostic ready-set scheduling core
8
- * (`lib/wave-runner/ready-set.js#selectReadySet`). It consumes an
9
- * operator-supplied dependency DAG of standalone Story IDs plus the live
10
- * progress of the run (which Stories are done, how many are in flight) and
11
- * emits the set of Stories safe to dispatch **on this beat** — a Story
12
- * becomes dispatchable the instant its own dependencies are done, under the
13
- * same global concurrency cap and the same file-overlap co-dispatch guard
8
+ * (`lib/wave-runner/ready-set.js#selectReadySet`). It emits the set of
9
+ * Stories safe to dispatch **on this beat** a Story becomes dispatchable
10
+ * the instant its own dependencies are done, under the same global
11
+ * concurrency cap and the same file-overlap co-dispatch guard
14
12
  * `lib/wave-runner/ready-set.js` applies everywhere. There is no wave barrier: this no longer batches
15
13
  * Stories into fully-draining waves; it selects continuously.
16
14
  *
@@ -18,9 +16,27 @@
18
16
  * group N+1 opens, via `Graph.js#assignLayers`) is gone. The scheduling
19
17
  * kernel — adjacency derivation, the done-predicate classifier, the
20
18
  * eligibility rule, and the overlap guard — lives once in `selectReadySet`;
21
- * this file only parses input, resolves the cap, and renders the envelope.
19
+ * this file only gathers input, resolves the cap, and renders the envelope.
20
+ *
21
+ * **Two modes, one kernel.**
22
+ *
23
+ * - **Probe mode** (`--stories <csv> --probe-live [--dispatched <csv>]`) is
24
+ * the canonical `/deliver` beat: the graph, the done set, and the in-flight
25
+ * count are resolved from **live state** via `lib/wave-runner/live-probe.js`.
26
+ * The caller supplies ids, so there is no accounting to hand-maintain
27
+ * across beats — the seed-the-first-beat's-`--done` footgun the workflow
28
+ * used to warn about is structurally impossible rather than merely
29
+ * documented. `--dispatched` is the one fact live state cannot yet report
30
+ * ("I spawned this id; its label has not appeared"); it is additive and
31
+ * live-state-filtered, never authoritative (Story #4601).
32
+ * - **Flag mode** (`--dag`/`--dag-file` + `--done`/`--in-flight`) keeps the
33
+ * caller-supplied contract byte-compatible for tests and hand-driven
34
+ * runs. The two are mutually exclusive: honouring a supplied `--done`
35
+ * under `--probe-live` would silently reintroduce exactly the
36
+ * hand-maintained state probe mode retires.
22
37
  *
23
38
  * Usage:
39
+ * node .agents/scripts/stories-wave-tick.js --stories 101,102 --probe-live
24
40
  * node .agents/scripts/stories-wave-tick.js --dag '<json>'
25
41
  * node .agents/scripts/stories-wave-tick.js --dag-file <path>
26
42
  * node .agents/scripts/stories-wave-tick.js --dag '<json>' --concurrency 5
@@ -41,11 +57,19 @@
41
57
  * wedged: { reason, stories: [{ id, unmetBlockers }] } | null
42
58
  * }
43
59
  *
44
- * The standalone loop calls this once per beat: after each Story closes it
45
- * re-runs with the closed Story added to `--done` and the live in-flight
46
- * count in `--in-flight`, dispatching the returned `ready` set (already
47
- * capped at `concurrencyCap inFlight` by the core). The run is complete
48
- * when every Story is in `--done` and `ready` is empty.
60
+ * Probe mode adds fields the caller can no longer compute for itself:
61
+ * `done: number[]` (the resolved done set, in-set satisfied foreign
62
+ * blockers), `epilogueDue: boolean` (true exactly when every listed Story
63
+ * is done the run-end signal for `plan-run-epilogue.js`), and `blocked:
64
+ * number[]` + `blockedReason: string|null` (Story #4601 the `agent::blocked`
65
+ * HITL pause, which ends the loop rather than being polled).
66
+ *
67
+ * The standalone loop calls this once per beat and dispatches the returned
68
+ * `ready` set (already capped at `concurrencyCap − inFlight` by the core).
69
+ * Under `--probe-live` each beat re-reads reality, so the run is complete
70
+ * when `epilogueDue` is true; under flag mode the caller re-supplies `--done`
71
+ * and `--in-flight` itself, and the run is complete when every Story is in
72
+ * `--done` and `ready` is empty.
49
73
  *
50
74
  * The per-beat concurrency cap is resolved from the same config seam
51
75
  * `/deliver` uses — `resolveConfig` + `getRunners` reading
@@ -57,11 +81,15 @@
57
81
  *
58
82
  * Exit codes: 0 ok · 1 input error · 2 dependency cycle (`cycleError`) ·
59
83
  * 3 wedged (`wedged`) — ready is empty, nothing is in flight, and undone
60
- * Stories are waiting on blockers that are not done. A cycle is a
61
- * self-referential DAG the operator must fix; a wedge is a well-formed DAG
62
- * whose gates cannot be satisfied from the supplied `--done` set (usually a
63
- * blocker outside the delivered set that has not landed). Both are distinct
64
- * from the ordinary `ready: []` that means "waiting on in-flight work".
84
+ * Stories are waiting on blockers that are not done · 4 blocked (`blocked`) —
85
+ * a Story carries `agent::blocked`. A cycle is a self-referential DAG the
86
+ * operator must fix; a wedge is a well-formed DAG whose gates cannot be
87
+ * satisfied from the supplied `--done` set (usually a blocker outside the
88
+ * delivered set that has not landed); a block is the protocol's HITL pause,
89
+ * where a human owes a decision no beat can supply. All three are distinct
90
+ * from the ordinary `ready: []` that means "waiting on in-flight work" — and
91
+ * that distinction is the point: each of them previously presented AS that
92
+ * ordinary empty set, so the loop polled a state that could never improve.
65
93
  */
66
94
 
67
95
  import { readFileSync } from 'node:fs';
@@ -72,7 +100,13 @@ import { getRunners, resolveConfig } from './lib/config-resolver.js';
72
100
  import { detectCycle } from './lib/Graph.js';
73
101
  import { Logger } from './lib/Logger.js';
74
102
  import { AGENT_LABELS } from './lib/label-constants.js';
103
+ import { parseIds } from './lib/orchestration/resolve-stories.js';
75
104
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
105
+ import {
106
+ createProbeContext,
107
+ probeLiveState,
108
+ validateProbeFlags,
109
+ } from './lib/wave-runner/live-probe.js';
76
110
  import { selectReadySet } from './lib/wave-runner/ready-set.js';
77
111
 
78
112
  /**
@@ -82,13 +116,32 @@ import { selectReadySet } from './lib/wave-runner/ready-set.js';
82
116
  */
83
117
  export const WEDGED_EXIT_CODE = 3;
84
118
 
85
- const HELP = `Usage: node .agents/scripts/stories-wave-tick.js --dag '<json>' | --dag-file <path> [--concurrency <n>] [--done <csv>] [--in-flight <n>]
119
+ /**
120
+ * Exit code for a run holding an `agent::blocked` Story — distinct from the
121
+ * cycle (2) and wedge (3) exits because the remediation is categorically
122
+ * different: a cycle is a malformed DAG and a wedge is an unlanded blocker,
123
+ * whereas this is the protocol's one runtime HITL pause. A human must decide
124
+ * something before any beat can help. Probe-mode only: flag-mode nodes carry
125
+ * no labels, so nothing there can classify blocked.
126
+ */
127
+ export const BLOCKED_EXIT_CODE = 4;
128
+
129
+ const HELP = `Usage:
130
+ node .agents/scripts/stories-wave-tick.js --stories <csv> --probe-live [--dispatched <csv>] [--concurrency <n>]
131
+ node .agents/scripts/stories-wave-tick.js --dag '<json>' | --dag-file <path> [--concurrency <n>] [--done <csv>] [--in-flight <n>]
86
132
 
87
- Continuous ready-set planner for standalone Story delivery. Consumes a
88
- dependency graph of Story IDs plus the live run progress and emits the set
89
- of Stories safe to dispatch on this beat a Story is dispatchable the
90
- instant its own dependencies are done — plus the resolved per-beat
91
- concurrency cap and the same file-overlap guard as selectReadySet.
133
+ Continuous ready-set planner for standalone Story delivery. Emits the set of
134
+ Stories safe to dispatch on this beat a Story is dispatchable the instant
135
+ its own dependencies are done plus the resolved per-beat concurrency cap
136
+ and the same file-overlap guard as selectReadySet.
137
+
138
+ Two modes:
139
+ --probe-live Resolve the graph and derive done / in-flight from LIVE state
140
+ (the canonical /deliver beat). Nothing is hand-maintained
141
+ across beats. Mutually exclusive with --dag/--dag-file/--done/
142
+ --in-flight. Adds "done" and "epilogueDue" to the envelope.
143
+ --dag Legacy flag mode: the caller supplies the graph and the run
144
+ progress. Kept for tests and hand-driven runs.
92
145
 
93
146
  Input DAG format (JSON array):
94
147
  [{ "id": 101, "dependsOn": [] }, { "id": 102, "dependsOn": [101] }]
@@ -98,6 +151,19 @@ Each entry must include:
98
151
  dependsOn - Array of Story IDs that must complete before this Story runs
99
152
 
100
153
  Options:
154
+ --stories <csv> Story ids to deliver (probe mode). The graph, the done
155
+ set, and the in-flight count are resolved from live
156
+ state — no --done / --in-flight bookkeeping.
157
+ --probe-live Enable probe mode. Requires --stories.
158
+ --dispatched <csv> Probe mode only. Ids you have SPAWNED this run. Unioned
159
+ into the live-derived in-flight set, then filtered by
160
+ live state, so it closes the init window: a Story reads
161
+ agent::ready for the 3-6 minutes single-story-init.js
162
+ takes to flip agent::executing, and without this it is
163
+ dispatched a second time onto the same branch. Append
164
+ every id you dispatch and never remove one — a stale id
165
+ that has since gone done is dropped automatically, so
166
+ over-supplying is free and forgetting is the only error.
101
167
  --concurrency <n> Override the per-beat concurrency cap for this run only.
102
168
  Must be a positive integer. When omitted, the cap is
103
169
  resolved from delivery.deliverRunner.concurrencyCap in
@@ -127,8 +193,36 @@ Exit codes:
127
193
  3 - Wedged: ready is empty, nothing is in flight, and undone Stories are
128
194
  waiting on blockers that are not done. Distinct from an ordinary empty
129
195
  ready set (which means "waiting on in-flight work") and from a cycle.
196
+ 4 - Blocked: a Story carries agent::blocked (probe mode only). The HITL
197
+ pause — no beat can clear it. STOP the loop; do not poll.
130
198
  `;
131
199
 
200
+ /**
201
+ * Build the exit-1 input-error result. Shared by both modes so a malformed
202
+ * `--concurrency` reports identically whether it arrived alongside `--dag` or
203
+ * `--probe-live`.
204
+ *
205
+ * @param {string} message
206
+ * @param {number|null} [concurrencyCap]
207
+ * @param {number} [inFlightValue]
208
+ * @returns {{ envelope: object, exitCode: 1 }}
209
+ */
210
+ function inputErrorResult(message, concurrencyCap = null, inFlightValue = 0) {
211
+ return {
212
+ envelope: {
213
+ kind: 'stories-ready-set',
214
+ ready: [],
215
+ totalStories: 0,
216
+ concurrencyCap,
217
+ inFlight: inFlightValue,
218
+ cycleError: null,
219
+ wedged: null,
220
+ inputError: message,
221
+ },
222
+ exitCode: 1,
223
+ };
224
+ }
225
+
132
226
  /**
133
227
  * Parse and validate the raw DAG input array.
134
228
  *
@@ -200,15 +294,16 @@ export function parseDag(raw) {
200
294
  }
201
295
 
202
296
  /**
203
- * Parse a comma-separated `--done` list of Story IDs into a deduped set of
204
- * positive integers. Empty / absent input yields an empty set. Rejects any
205
- * token that is not a positive integer so a typo never silently drops a
206
- * dependency gate.
297
+ * Parse a comma-separated list of Story IDs into a deduped set of positive
298
+ * integers. Empty / absent input yields an empty set. Rejects any token that
299
+ * is not a positive integer so a typo never silently drops a dependency gate
300
+ * (`--done`) or a held dispatch slot (`--dispatched`).
207
301
  *
208
302
  * @param {string|undefined} raw
303
+ * @param {string} flag Flag name, for the error message.
209
304
  * @returns {{ ids: Set<number>|null, error: string|null }}
210
305
  */
211
- export function parseDoneIds(raw) {
306
+ export function parseIdCsv(raw, flag) {
212
307
  if (raw == null || raw === '') {
213
308
  return { ids: new Set(), error: null };
214
309
  }
@@ -220,7 +315,7 @@ export function parseDoneIds(raw) {
220
315
  if (!Number.isInteger(num) || num <= 0) {
221
316
  return {
222
317
  ids: null,
223
- error: `--done must be a comma-separated list of positive integers, got "${trimmed}"`,
318
+ error: `${flag} must be a comma-separated list of positive integers, got "${trimmed}"`,
224
319
  };
225
320
  }
226
321
  ids.add(num);
@@ -228,6 +323,16 @@ export function parseDoneIds(raw) {
228
323
  return { ids, error: null };
229
324
  }
230
325
 
326
+ /**
327
+ * Parse the `--done` CSV of already-completed Story IDs (flag mode).
328
+ *
329
+ * @param {string|undefined} raw
330
+ * @returns {{ ids: Set<number>|null, error: string|null }}
331
+ */
332
+ export function parseDoneIds(raw) {
333
+ return parseIdCsv(raw, '--done');
334
+ }
335
+
231
336
  /**
232
337
  * Parse the raw `--in-flight` value into a non-negative integer. Absent
233
338
  * input defaults to 0. Rejects negatives and non-integers.
@@ -371,7 +476,12 @@ export function buildReadySetEnvelope(
371
476
  const rec = {
372
477
  id: node.id,
373
478
  dependsOn: node.dependsOn,
374
- labels: doneIds.has(node.id) ? [AGENT_LABELS.DONE] : [],
479
+ // A node's own live labels (probe mode) are preserved so the core's
480
+ // classifier withholds an in-flight `agent::executing` / `agent::closing`
481
+ // Story rather than re-dispatching it onto a second branch. Flag-mode
482
+ // nodes carry none — `parseDag` accepts no labels — so this is inert
483
+ // there and the legacy contract is unchanged.
484
+ labels: doneIds.has(node.id) ? [AGENT_LABELS.DONE] : (node.labels ?? []),
375
485
  };
376
486
  if (node.files !== undefined) rec.files = node.files;
377
487
  return rec;
@@ -471,37 +581,23 @@ export function runStoriesWaveTick({
471
581
  cwd,
472
582
  config,
473
583
  } = {}) {
474
- const inputError = (message, concurrencyCap = null, inFlightValue = 0) => ({
475
- envelope: {
476
- kind: 'stories-ready-set',
477
- ready: [],
478
- totalStories: 0,
479
- concurrencyCap,
480
- inFlight: inFlightValue,
481
- cycleError: null,
482
- wedged: null,
483
- inputError: message,
484
- },
485
- exitCode: 1,
486
- });
487
-
488
584
  // Validate the --concurrency override before resolving config so an invalid
489
585
  // value fails fast with exit code 1 regardless of DAG validity.
490
586
  const { value: override, error: concurrencyError } =
491
587
  parseConcurrencyOverride(concurrency);
492
588
  if (concurrencyError) {
493
- return inputError(concurrencyError);
589
+ return inputErrorResult(concurrencyError);
494
590
  }
495
591
 
496
592
  const { value: inFlightValue, error: inFlightError } =
497
593
  parseInFlight(inFlight);
498
594
  if (inFlightError) {
499
- return inputError(inFlightError);
595
+ return inputErrorResult(inFlightError);
500
596
  }
501
597
 
502
598
  const { ids: doneIds, error: doneError } = parseDoneIds(done);
503
599
  if (doneError) {
504
- return inputError(doneError, null, inFlightValue);
600
+ return inputErrorResult(doneError, null, inFlightValue);
505
601
  }
506
602
 
507
603
  const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
@@ -512,7 +608,7 @@ export function runStoriesWaveTick({
512
608
  try {
513
609
  rawJson = readFileSync(dagFile, 'utf8');
514
610
  } catch (err) {
515
- return inputError(
611
+ return inputErrorResult(
516
612
  `Could not read DAG file "${dagFile}": ${err.message}`,
517
613
  concurrencyCap,
518
614
  inFlightValue,
@@ -521,7 +617,7 @@ export function runStoriesWaveTick({
521
617
  } else if (dagJson) {
522
618
  rawJson = dagJson;
523
619
  } else {
524
- return inputError(
620
+ return inputErrorResult(
525
621
  'Either --dag <json> or --dag-file <path> is required',
526
622
  concurrencyCap,
527
623
  inFlightValue,
@@ -532,7 +628,7 @@ export function runStoriesWaveTick({
532
628
  try {
533
629
  parsed = JSON.parse(rawJson);
534
630
  } catch (err) {
535
- return inputError(
631
+ return inputErrorResult(
536
632
  `Invalid JSON: ${err.message}`,
537
633
  concurrencyCap,
538
634
  inFlightValue,
@@ -541,7 +637,7 @@ export function runStoriesWaveTick({
541
637
 
542
638
  const { nodes, error: parseError } = parseDag(parsed);
543
639
  if (parseError) {
544
- return inputError(parseError, concurrencyCap, inFlightValue);
640
+ return inputErrorResult(parseError, concurrencyCap, inFlightValue);
545
641
  }
546
642
 
547
643
  return buildReadySetEnvelope(nodes, {
@@ -551,12 +647,144 @@ export function runStoriesWaveTick({
551
647
  });
552
648
  }
553
649
 
650
+ /**
651
+ * Probe mode: resolve the graph and the run's progress from **live state**,
652
+ * then run the same scheduling kernel the flag mode does.
653
+ *
654
+ * This is the flag-free beat. The caller supplies only the Story ids it was
655
+ * asked to deliver; `done` and `inFlight` are probed rather than transcribed,
656
+ * which is what makes the `/deliver` loop's old seed-the-first-beat footgun
657
+ * structurally impossible instead of merely documented.
658
+ *
659
+ * The envelope is the flag mode's, plus three probe-only fields the caller can
660
+ * no longer compute for itself:
661
+ * - `done` — the resolved done set (in-set ∪ satisfied foreign blockers).
662
+ * - `epilogueDue` — true exactly when every listed Story is done, which is
663
+ * the run-end signal for `plan-run-epilogue.js`.
664
+ * - `blocked` — ids carrying `agent::blocked` (Story #4601). Non-empty means
665
+ * the loop must END, not poll: see `BLOCKED_EXIT_CODE`.
666
+ *
667
+ * @param {object} args
668
+ * @param {string} args.stories Raw `--stories` CSV of Story ids.
669
+ * @param {string|number} [args.concurrency] Raw `--concurrency` override.
670
+ * @param {string} [args.dispatched] Raw `--dispatched` CSV of ids the host
671
+ * has spawned but may not yet have observed labelled.
672
+ * @param {string} [args.cwd] Repo root for config resolution.
673
+ * @param {object} [args.config] Pre-resolved config (test injection).
674
+ * @param {Function} [args.probe] Probe seam (test injection).
675
+ * @param {Function} [args.context] Provider-context seam (test injection).
676
+ * @returns {Promise<{ envelope: object, exitCode: number }>}
677
+ */
678
+ export async function runProbedStoriesWaveTick({
679
+ stories,
680
+ concurrency,
681
+ dispatched,
682
+ cwd,
683
+ config,
684
+ probe = probeLiveState,
685
+ context = createProbeContext,
686
+ } = {}) {
687
+ const { value: override, error: concurrencyError } =
688
+ parseConcurrencyOverride(concurrency);
689
+ if (concurrencyError) {
690
+ return inputErrorResult(concurrencyError);
691
+ }
692
+
693
+ let ids;
694
+ try {
695
+ ids = parseIds(stories);
696
+ } catch (err) {
697
+ return inputErrorResult(err.message);
698
+ }
699
+
700
+ const { ids: dispatchedIds, error: dispatchedError } = parseIdCsv(
701
+ dispatched,
702
+ '--dispatched',
703
+ );
704
+ if (dispatchedError) {
705
+ return inputErrorResult(dispatchedError);
706
+ }
707
+
708
+ const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
709
+
710
+ let probed;
711
+ try {
712
+ const { provider, owner, repo } = context();
713
+ probed = await probe({
714
+ ids,
715
+ provider,
716
+ owner,
717
+ repo,
718
+ dispatched: [...dispatchedIds],
719
+ warn: (m) => Logger.warn(m),
720
+ });
721
+ } catch (err) {
722
+ // A failed probe must never degrade into "nothing is ready" — that is
723
+ // indistinguishable from a healthy waiting beat and would silently stall
724
+ // the run. Fail loud with the input-error contract instead.
725
+ return inputErrorResult(
726
+ `Could not probe live state: ${err?.message ?? err}`,
727
+ concurrencyCap,
728
+ );
729
+ }
730
+
731
+ const { nodes, doneIds, inFlight, blockedIds = [] } = probed;
732
+ const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
733
+ concurrencyCap,
734
+ doneIds,
735
+ inFlight,
736
+ });
737
+
738
+ const done = [...doneIds].sort((a, b) => a - b);
739
+ const epilogueDue =
740
+ nodes.length > 0 && nodes.every((node) => doneIds.has(node.id));
741
+ return {
742
+ envelope: {
743
+ ...envelope,
744
+ done,
745
+ epilogueDue,
746
+ blocked: blockedIds,
747
+ blockedReason: blockedReasonFor(blockedIds),
748
+ },
749
+ // A blocked Story outranks the scheduler's own verdict — including a
750
+ // wedge, whose named blockers are moot while a human owes a decision.
751
+ // A cycle (2) does not yield: a self-referential DAG is a planning error
752
+ // that must be fixed before any of this run's state means anything.
753
+ exitCode:
754
+ blockedIds.length > 0 && !envelope.cycleError
755
+ ? BLOCKED_EXIT_CODE
756
+ : exitCode,
757
+ };
758
+ }
759
+
760
+ /**
761
+ * Render the operator-facing reason for a blocked run, or `null` when nothing
762
+ * is blocked.
763
+ *
764
+ * @param {number[]} blockedIds
765
+ * @returns {string|null}
766
+ */
767
+ function blockedReasonFor(blockedIds) {
768
+ if (blockedIds.length === 0) return null;
769
+ const list = blockedIds.map((id) => `#${id}`).join(', ');
770
+ return (
771
+ `${blockedIds.length} Story(ies) carry agent::blocked — ${list}. ` +
772
+ `agent::blocked is the protocol's HITL pause: no beat can clear it and ` +
773
+ `the loop must stop rather than poll. Read each Story's friction comment ` +
774
+ `(gh issue view <id> --comments), resolve the blocker, then flip it back ` +
775
+ `with: node .agents/scripts/update-ticket-state.js --ticket <id> --state agent::ready`
776
+ );
777
+ }
778
+
554
779
  async function main(argv) {
555
780
  const { values } = parseArgs({
556
781
  args: argv,
557
782
  options: {
558
783
  dag: { type: 'string' },
559
784
  'dag-file': { type: 'string' },
785
+ stories: { type: 'string' },
786
+ 'probe-live': { type: 'boolean' },
787
+ dispatched: { type: 'string' },
560
788
  concurrency: { type: 'string' },
561
789
  done: { type: 'string' },
562
790
  'in-flight': { type: 'string' },
@@ -571,19 +799,43 @@ async function main(argv) {
571
799
  return;
572
800
  }
573
801
 
574
- const { envelope, exitCode } = runStoriesWaveTick({
575
- dagJson: values.dag,
802
+ const flagError = validateProbeFlags({
803
+ probeLive: values['probe-live'],
804
+ stories: values.stories,
805
+ dag: values.dag,
576
806
  dagFile: values['dag-file'],
577
- concurrency: values.concurrency,
578
807
  done: values.done,
579
808
  inFlight: values['in-flight'],
809
+ dispatched: values.dispatched,
580
810
  });
581
811
 
812
+ const { envelope, exitCode } = flagError
813
+ ? inputErrorResult(flagError)
814
+ : values['probe-live']
815
+ ? await runProbedStoriesWaveTick({
816
+ stories: values.stories,
817
+ concurrency: values.concurrency,
818
+ dispatched: values.dispatched,
819
+ })
820
+ : runStoriesWaveTick({
821
+ dagJson: values.dag,
822
+ dagFile: values['dag-file'],
823
+ concurrency: values.concurrency,
824
+ done: values.done,
825
+ inFlight: values['in-flight'],
826
+ });
827
+
582
828
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
583
829
 
584
830
  if (exitCode !== 0) {
585
831
  Logger.error(
586
- `stories-wave-tick: ${envelope.inputError ?? envelope.cycleError ?? 'error'}`,
832
+ `stories-wave-tick: ${
833
+ envelope.inputError ??
834
+ envelope.cycleError ??
835
+ envelope.blockedReason ??
836
+ envelope.wedged?.reason ??
837
+ 'error'
838
+ }`,
587
839
  );
588
840
  process.exitCode = exitCode;
589
841
  }
@@ -67,9 +67,12 @@ to respect.
67
67
  node .agents/scripts/resolve-stories.js --ids <id,id,...>
68
68
  ```
69
69
 
70
- Capture `stories[]`, `dag[]`, and `done[]` from the envelope. Do **not**
71
- rebuild the graph by hand it is discovered from live state, including
72
- edges a body does not spell out and blockers outside the delivered set.
70
+ This validates the set and shows the operator what will run: read
71
+ `stories[]`, `dag[]`, and `done[]` to present the order in step 2. You do
72
+ **not** thread them into step 3 the tick re-resolves the graph itself
73
+ from the same machinery, every beat. Do **not** rebuild the graph by hand;
74
+ it is discovered from live state, including edges a body does not spell
75
+ out and blockers outside the delivered set.
73
76
 
74
77
  Resolution hard-errors (exit 1) on a named id that is not a Story, still
75
78
  carries an `Epic: #N` footer, or whose native dependency edges cannot be
@@ -78,23 +81,41 @@ to respect.
78
81
 
79
82
  2. **Confirm (N>1).** Present the order and wait unless `--yes`.
80
83
 
81
- 3. **Sequence.** Loop until every Story is done:
84
+ 3. **Sequence.** Loop until the tick reports `epilogueDue: true`:
82
85
 
83
86
  ```bash
84
87
  node .agents/scripts/stories-wave-tick.js \
85
- --dag '<dag from step 1>' --done <csv> --in-flight <n> --concurrency <n>
88
+ --stories <id,id,...> --probe-live --concurrency <n> \
89
+ --dispatched <every id you have dispatched so far>
86
90
  ```
87
91
 
88
- **Seed the first beat's `--done` from the resolver's `done[]`** — not from
89
- an empty string. That array carries the blockers that have already landed,
90
- including foreign ones outside the delivered set. Seeding it empty
91
- discards exactly the cross-run resolution this step exists for, and the
92
- run wedges on a blocker that finished weeks ago. On later beats, `--done`
93
- is `done[]` plus every Story that has since closed.
92
+ Each beat re-probes live state: it re-resolves the graph, classifies done
93
+ (`agent::done` or a closed issue including foreign blockers that landed
94
+ in another run), and derives in-flight from live `agent::executing` /
95
+ `agent::closing` labels. You never compute `done` or `in-flight` that
96
+ accounting is read from reality every beat (Story #4594).
97
+
98
+ **`--dispatched` is the one thing you must tell it (Story #4601).** List
99
+ every Story id you have spawned this run. Live state cannot report a Story
100
+ you dispatched ninety seconds ago: `single-story-init.js` flips
101
+ `agent::executing` at step 6 of 6, *after* a 3–6 minute worktree install,
102
+ so until then the Story still reads `agent::ready` and the next beat hands
103
+ it back — a second sub-agent then joins the first on the same branch and
104
+ worktree, interleaving commits.
105
+
106
+ The rule is **append-only: add each id as you dispatch it and never remove
107
+ one.** The flag is additive, not authoritative — the probe unions it into
108
+ the label-derived set and then filters it against live state, so an id that
109
+ has since gone `agent::done` is dropped for you. Re-listing an id costs
110
+ nothing and cannot double-count a slot; *omitting* one is the only way to
111
+ get this wrong. This is why `--dispatched` is not the `--done` bookkeeping
112
+ #4594 retired, and why `--in-flight` remains rejected under `--probe-live`.
94
113
 
95
114
  Branch on the exit code:
96
- - **0** — dispatch each `ready` id. An empty `ready` with work in flight
97
- means "waiting"; keep looping.
115
+ - **0** — dispatch each `ready` id (the set is already capped and
116
+ overlap-free). An empty `ready` with work in flight means "waiting";
117
+ keep looping. `epilogueDue: true` means every Story is done — leave the
118
+ loop and go to step 4.
98
119
  - **2** — `cycleError`: the graph is self-referential. Fix the
99
120
  `depends_on` declarations; do not retry.
100
121
  - **3** — `wedged`: nothing is dispatchable, nothing is in flight, and
@@ -102,6 +123,20 @@ to respect.
102
123
  names the stuck ids and their unmet blockers. Either land the blocker
103
124
  first or include it in `--ids`. Do not retry unchanged — the state
104
125
  cannot improve on its own.
126
+ - **4** — `blocked`: one or more Stories carry `agent::blocked`, named in
127
+ `blocked[]` with `blockedReason`. This is the protocol's HITL pause
128
+ ([`instructions.md` § 1.J](../instructions.md)) — **stop the loop and
129
+ surface it to the operator; do not poll.** No beat can clear it, because
130
+ a human owes a decision. Read the Story's friction comment, and resume
131
+ only once the operator has unblocked it:
132
+
133
+ ```bash
134
+ gh issue view <id> --comments
135
+ node .agents/scripts/update-ticket-state.js --ticket <id> --state agent::ready
136
+ ```
137
+
138
+ A blocked Story outranks a wedge (its blockers are moot while a human
139
+ owes a decision) but not a cycle (exit 2 — fix the graph first).
105
140
 
106
141
  For each `ready` Story id, read
107
142
  [`helpers/deliver-story.md`](helpers/deliver-story.md) **in full** and
@@ -109,8 +144,8 @@ to respect.
109
144
  `--yes` / injected helper content, execute directly without a re-read
110
145
  turn.
111
146
 
112
- 4. **Per-run epilogue (N>1).** After the last Story lands, keyed on the
113
- delivered id set:
147
+ 4. **Per-run epilogue (N>1).** Once step 3 reports `epilogueDue: true`
148
+ (every Story done), keyed on the delivered id set:
114
149
 
115
150
  ```bash
116
151
  node .agents/scripts/plan-run-epilogue.js --stories 101,102
@@ -13,8 +13,10 @@ description: >-
13
13
  > only its wrapper (Story label transitions).
14
14
 
15
15
  After the implementation commits land and **before** the Story proceeds to
16
- close, run an explicit, **independent** eval pass that scores the working diff
17
- against **each** `acceptance[]` item individually. This is the acceptance gate
16
+ close, run an explicit, **independent** eval pass that scores the change set
17
+ computed once for this Story and injected into the critic — never one the
18
+ critic re-derives (Story #4593) — against **each** `acceptance[]` item
19
+ individually. This is the acceptance gate
18
20
  the close-validation chain does not provide: that chain (lint / test / format /
19
21
  maintainability / coverage / crap) proves the code is *healthy*, not that it
20
22
  satisfies *this Story's* acceptance criteria.
@@ -46,7 +48,10 @@ mid-delivery, and evaluates the actual work product.
46
48
  > — the same signal `review-depth.js` resolves depth from, so the two
47
49
  > decisions cannot disagree. Derive it with `deriveChangeLevel` from
48
50
  > [`review-depth.js`](../../scripts/lib/orchestration/review-depth.js) over
49
- > the Story's changed files (`git diff --name-only main...story-<id>`), then
51
+ > the **change set your caller computed once** for this Story (Story #4593 —
52
+ > `computeChangeSet` from
53
+ > [`change-set.js`](../../scripts/lib/orchestration/change-set.js); see
54
+ > [`deliver-story.md`](deliver-story.md) Step 2), then
50
55
  > resolve the ceremony per cluster with `resolveCeremonyForRisk` from
51
56
  > [`ceremony-routing.js`](../../scripts/lib/orchestration/ceremony-routing.js)
52
57
  > using that `derivedLevel` and `delivery.routing.freshCriticSampleRate`:
@@ -87,8 +92,12 @@ mid-delivery, and evaluates the actual work product.
87
92
  > comment (if you block) that the inline fallback was used.
88
93
 
89
94
  The critic:
90
- - Inspects the working diff (`git diff origin/<baseBranch>...HEAD`) and the
91
- Story's inline `acceptance[]` / `verify[]` arrays.
95
+ - Inspects the **change set handed to it in its spawn context** — the one
96
+ list computed above — and the Story's inline `acceptance[]` / `verify[]`
97
+ arrays. Pass the file list explicitly when you dispatch the critic; it
98
+ does not re-enumerate the diff for itself (Story #4593), so a commit
99
+ landing mid-ceremony cannot leave the critic scoring a different change
100
+ than the one that routed it.
92
101
  - **Runs the `verify[]` commands** and consumes their output as **required
93
102
  evidence** when scoring the relevant acceptance items. `verify[]` is not
94
103
  optional advisory pre-flight — a criterion cannot be scored `met` without