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.
- package/.agents/agents/acceptance-critic.md +11 -2
- package/.agents/agents/story-worker.md +4 -2
- package/.agents/docs/SDLC.md +11 -4
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +24 -35
- package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/story-body/story-body.js +75 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/plan-context.js +0 -1
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/stories-wave-tick.js +307 -55
- package/.agents/workflows/deliver.md +50 -15
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
- package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
- package/.agents/workflows/helpers/code-review.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +22 -6
- package/.agents/workflows/plan.md +55 -0
- package/docs/CHANGELOG.md +22 -0
- package/lib/migrations/index.js +6 -1
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- 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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
595
|
+
return inputErrorResult(inFlightError);
|
|
500
596
|
}
|
|
501
597
|
|
|
502
598
|
const { ids: doneIds, error: doneError } = parseDoneIds(done);
|
|
503
599
|
if (doneError) {
|
|
504
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
575
|
-
|
|
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: ${
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
--
|
|
88
|
+
--stories <id,id,...> --probe-live --concurrency <n> \
|
|
89
|
+
--dispatched <every id you have dispatched so far>
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
97
|
-
means "waiting";
|
|
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).**
|
|
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
|
|
17
|
-
|
|
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
|
|
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
|
|
91
|
-
Story's inline `acceptance[]` / `verify[]`
|
|
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
|