mandrel 1.67.0 → 1.69.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/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config-settings-schema-delivery.js +17 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -2
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +36 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +312 -206
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/docs/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/wave-runner/ready-set.js — the path-agnostic ready-set scheduling
|
|
3
|
+
* core.
|
|
4
|
+
*
|
|
5
|
+
* This module is the scheduling kernel both the Epic and standalone
|
|
6
|
+
* delivery paths dispatch through. It replaces wave-*batch* selection
|
|
7
|
+
* (group N must fully drain before group N+1 opens) with *continuous*,
|
|
8
|
+
* dependency-driven selection: a Story becomes dispatchable the instant
|
|
9
|
+
* **its own** dependencies are satisfied, regardless of whether unrelated
|
|
10
|
+
* Stories in some nominal wave are still running. There is no false
|
|
11
|
+
* barrier — a Story C that depends only on a done Story A is selected even
|
|
12
|
+
* while an unrelated Story B is still pending.
|
|
13
|
+
*
|
|
14
|
+
* It is deliberately **path-agnostic and side-effect-free**: it neither
|
|
15
|
+
* reads GitHub, the lifecycle ledger, nor a checkpoint, and it dispatches
|
|
16
|
+
* nothing. Callers supply the live Story records (already fetched), the
|
|
17
|
+
* resolved `inFlight` count, and the `globalCap`, and receive back the set
|
|
18
|
+
* of Stories that are safe to dispatch on this beat. Later Stories wire the
|
|
19
|
+
* Epic / standalone adapters on top of this core; this Story ships the core
|
|
20
|
+
* alone and does not modify `tick.js` or `stories-wave-tick.js`.
|
|
21
|
+
*
|
|
22
|
+
* Three exports:
|
|
23
|
+
* - `classifyStory(story)` — live-label classifier mapping a Story
|
|
24
|
+
* record's labels + issue state to one of `done | blocked | executing |
|
|
25
|
+
* ready`. Mirrors the done-predicate `tick.js` already uses
|
|
26
|
+
* (`agent::done` OR closed issue) so a Story closed manually through
|
|
27
|
+
* the GitHub UI is recognised as done.
|
|
28
|
+
* - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
|
|
29
|
+
* when two Stories' declared file footprints intersect. Two Stories
|
|
30
|
+
* that would touch the same file MUST NOT be dispatched onto parallel
|
|
31
|
+
* `story-<id>` branches in the same beat (they would race the same
|
|
32
|
+
* path and produce a merge conflict at close).
|
|
33
|
+
* - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
|
|
34
|
+
* scheduler. Returns the deterministic, overlap-free set of ready
|
|
35
|
+
* Stories, capped at `globalCap − inFlight`.
|
|
36
|
+
*
|
|
37
|
+
* Adjacency is re-derived from the supplied records via the shared
|
|
38
|
+
* `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
|
|
39
|
+
* `blocked by #NNN` / `dependencies[]` source order the dispatch manifest
|
|
40
|
+
* and the existing wave wrappers use — so this core never disagrees with
|
|
41
|
+
* the manifest about what depends on what.
|
|
42
|
+
*
|
|
43
|
+
* @module lib/wave-runner/ready-set
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { AGENT_LABELS } from '../label-constants.js';
|
|
47
|
+
import { buildStoryAdjacency } from '../story-adjacency.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} StoryRecord
|
|
51
|
+
* @property {number|string} [id] Story id (preferred).
|
|
52
|
+
* @property {number} [number] Story id (GitHub issue-number shape).
|
|
53
|
+
* @property {string} [title]
|
|
54
|
+
* @property {string} [body] Used by `buildStoryAdjacency` to parse
|
|
55
|
+
* `blocked by #NNN` / `depends on #NNN` references.
|
|
56
|
+
* @property {string[]} [labels] Live `agent::*` labels.
|
|
57
|
+
* @property {string} [state] GitHub issue state (`open` | `closed`).
|
|
58
|
+
* @property {Array<number|string>} [dependencies] Explicit dependency ids.
|
|
59
|
+
* @property {Array<number|string>} [dependsOn] Operator-DAG dependency ids.
|
|
60
|
+
* @property {string[]} [files] Declared file footprint (one of the
|
|
61
|
+
* accepted footprint shapes — see `storyFootprint`).
|
|
62
|
+
* @property {string[]} [changes] Alternate footprint shape.
|
|
63
|
+
* @property {Array<{path?: string}>} [changeset] Alternate footprint shape.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** @typedef {'done'|'blocked'|'executing'|'ready'} StoryClass */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a Story record's id to a positive integer, or `null` when it is
|
|
70
|
+
* absent / non-integer. Accepts both the ticket shape (`id`) and the raw
|
|
71
|
+
* GitHub issue shape (`number`), matching `buildStoryAdjacency`.
|
|
72
|
+
*
|
|
73
|
+
* @param {StoryRecord|number|string} story
|
|
74
|
+
* @returns {number|null}
|
|
75
|
+
*/
|
|
76
|
+
export function storyIdOf(story) {
|
|
77
|
+
if (typeof story === 'number') {
|
|
78
|
+
return Number.isInteger(story) && story > 0 ? story : null;
|
|
79
|
+
}
|
|
80
|
+
const raw = story?.id ?? story?.number;
|
|
81
|
+
const id = Number(raw);
|
|
82
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a Story from its **live** labels and issue state.
|
|
87
|
+
*
|
|
88
|
+
* Precedence (highest first):
|
|
89
|
+
* 1. `done` — carries `agent::done` OR the issue is `state === 'closed'`.
|
|
90
|
+
* The closed-state arm aligns with `tick.js#isStoryDone`
|
|
91
|
+
* so a Story closed manually in the GitHub UI (issue
|
|
92
|
+
* closed, label not flipped) still reads as done and is
|
|
93
|
+
* never re-dispatched.
|
|
94
|
+
* 2. `blocked` — carries `agent::blocked`.
|
|
95
|
+
* 3. `executing` — carries `agent::executing` OR `agent::closing` (both
|
|
96
|
+
* are in-flight: an executing or closing Story occupies a
|
|
97
|
+
* slot and must not be re-dispatched).
|
|
98
|
+
* 4. `ready` — none of the above; the Story is eligible for dispatch
|
|
99
|
+
* once its dependencies are satisfied.
|
|
100
|
+
*
|
|
101
|
+
* `done` wins over every in-progress label so a stale `agent::executing`
|
|
102
|
+
* left behind on an issue that has since closed never masks completion.
|
|
103
|
+
*
|
|
104
|
+
* @param {StoryRecord} story
|
|
105
|
+
* @returns {StoryClass}
|
|
106
|
+
*/
|
|
107
|
+
export function classifyStory(story) {
|
|
108
|
+
const labels = Array.isArray(story?.labels) ? story.labels : [];
|
|
109
|
+
if (labels.includes(AGENT_LABELS.DONE) || story?.state === 'closed') {
|
|
110
|
+
return 'done';
|
|
111
|
+
}
|
|
112
|
+
if (labels.includes(AGENT_LABELS.BLOCKED)) return 'blocked';
|
|
113
|
+
if (
|
|
114
|
+
labels.includes(AGENT_LABELS.EXECUTING) ||
|
|
115
|
+
labels.includes(AGENT_LABELS.CLOSING)
|
|
116
|
+
) {
|
|
117
|
+
return 'executing';
|
|
118
|
+
}
|
|
119
|
+
return 'ready';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract a Story's declared file footprint as a normalized set of path
|
|
124
|
+
* strings. Accepts the three footprint shapes a Story record can carry:
|
|
125
|
+
*
|
|
126
|
+
* - `files: string[]` — explicit footprint.
|
|
127
|
+
* - `changes: string[]` — string-array sketch.
|
|
128
|
+
* - `changeset: Array<{ path }>` / — object-array sketch (the
|
|
129
|
+
* `changes: Array<{ path }>` `{ path, assumption }`
|
|
130
|
+
* shape from a Story body).
|
|
131
|
+
*
|
|
132
|
+
* Paths are trimmed; empty / non-string entries are dropped. A Story with
|
|
133
|
+
* no declared footprint yields an empty set, which (by `storiesOverlap`'s
|
|
134
|
+
* contract) means it overlaps with nothing and is never withheld by the
|
|
135
|
+
* co-dispatch guard.
|
|
136
|
+
*
|
|
137
|
+
* @param {StoryRecord} story
|
|
138
|
+
* @returns {Set<string>}
|
|
139
|
+
*/
|
|
140
|
+
export function storyFootprint(story) {
|
|
141
|
+
const out = new Set();
|
|
142
|
+
const push = (entry) => {
|
|
143
|
+
const path =
|
|
144
|
+
typeof entry === 'string'
|
|
145
|
+
? entry
|
|
146
|
+
: typeof entry?.path === 'string'
|
|
147
|
+
? entry.path
|
|
148
|
+
: null;
|
|
149
|
+
if (!path) return;
|
|
150
|
+
const trimmed = path.trim();
|
|
151
|
+
if (trimmed) out.add(trimmed);
|
|
152
|
+
};
|
|
153
|
+
if (Array.isArray(story?.files)) for (const e of story.files) push(e);
|
|
154
|
+
if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
|
|
155
|
+
if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* File-overlap co-dispatch guard. Returns `true` when two Stories' declared
|
|
161
|
+
* file footprints intersect on at least one path — meaning they would race
|
|
162
|
+
* the same file if dispatched onto parallel `story-<id>` branches in the
|
|
163
|
+
* same beat. Two Stories that overlap MUST NOT both appear in one dispatch
|
|
164
|
+
* set; one is withheld until the other clears.
|
|
165
|
+
*
|
|
166
|
+
* An empty footprint on either side means "no known overlap" → `false`. A
|
|
167
|
+
* Story that declares no files is therefore never withheld by this guard.
|
|
168
|
+
*
|
|
169
|
+
* @param {StoryRecord} a
|
|
170
|
+
* @param {StoryRecord} b
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function storiesOverlap(a, b) {
|
|
174
|
+
const fa = storyFootprint(a);
|
|
175
|
+
if (fa.size === 0) return false;
|
|
176
|
+
const fb = storyFootprint(b);
|
|
177
|
+
if (fb.size === 0) return false;
|
|
178
|
+
for (const path of fa) {
|
|
179
|
+
if (fb.has(path)) return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Select the set of Stories safe to dispatch on this beat.
|
|
186
|
+
*
|
|
187
|
+
* Algorithm (continuous, dependency-driven — no wave barrier):
|
|
188
|
+
*
|
|
189
|
+
* 1. **Adjacency.** Re-derive `Map<id, depIds[]>` from the supplied
|
|
190
|
+
* records via `buildStoryAdjacency`. The `dropForeign` flag controls
|
|
191
|
+
* how a dependency on an id **outside** the supplied set is treated:
|
|
192
|
+
* - `dropForeign: false` (default, standalone-path semantics) — the
|
|
193
|
+
* foreign dependency still gates the dependent: an absent dependency
|
|
194
|
+
* is treated as not-yet-done and withholds the dependent until it
|
|
195
|
+
* completes (preserves the operator-DAG contract).
|
|
196
|
+
* - `dropForeign: true` (Epic-path semantics) — a foreign edge is
|
|
197
|
+
* pruned so the DAG stays closed over the scheduled Story set. An
|
|
198
|
+
* Epic's Stories depend only on siblings, so a `blocked by #N` whose
|
|
199
|
+
* target is out-of-scope (a foreign id, or a typo) must be dropped,
|
|
200
|
+
* not treated as a permanent unsatisfiable gate — otherwise the
|
|
201
|
+
* dependent Story is never schedulable and the run silently strands
|
|
202
|
+
* it. This matches `build-wave-dag.js`, which builds the Epic
|
|
203
|
+
* wave DAG with the same default-`dropForeign` builder.
|
|
204
|
+
* 2. **Done set.** Union the caller-supplied `doneIds` with every record
|
|
205
|
+
* that classifies as `done` (live label / closed issue). A Story's
|
|
206
|
+
* dependency counts as satisfied iff it is in this union.
|
|
207
|
+
* 3. **Eligibility.** A Story is *eligible* when it classifies as `ready`
|
|
208
|
+
* (not done / blocked / executing) **and** every one of its
|
|
209
|
+
* dependencies is in the done set. This is the no-false-barrier
|
|
210
|
+
* property: C depending only on A is eligible the instant A is done,
|
|
211
|
+
* even while an unrelated B is still pending.
|
|
212
|
+
* 4. **Capacity.** The dispatch set never exceeds `slots = max(0,
|
|
213
|
+
* globalCap − inFlight)`. `inFlight` is the caller's count of Stories
|
|
214
|
+
* already occupying a slot (executing / closing / dispatched-not-yet-
|
|
215
|
+
* labelled). When `slots <= 0`, the result is empty.
|
|
216
|
+
* 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
|
|
217
|
+
* order, skipping any whose file footprint overlaps an
|
|
218
|
+
* already-admitted Story (`storiesOverlap`). A withheld Story stays
|
|
219
|
+
* eligible and is naturally re-considered on the next beat once its
|
|
220
|
+
* overlapping peer has cleared.
|
|
221
|
+
*
|
|
222
|
+
* The result is deterministic: eligible Stories are considered in
|
|
223
|
+
* ascending-id order, so the same inputs always yield the same set.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} args
|
|
226
|
+
* @param {StoryRecord[]} args.stories Live Story records in scope.
|
|
227
|
+
* @param {Array<number|string>|Set<number|string>} [args.doneIds]
|
|
228
|
+
* Ids the caller already knows are done (e.g. from a prior beat). Merged
|
|
229
|
+
* with records that classify as done.
|
|
230
|
+
* @param {number} [args.inFlight=0] Count of Stories already occupying a
|
|
231
|
+
* slot. Subtracted from `globalCap` to compute remaining capacity.
|
|
232
|
+
* @param {number} args.globalCap Hard ceiling on total concurrent
|
|
233
|
+
* Stories.
|
|
234
|
+
* @param {boolean} [args.dropForeign=false] Adjacency closure policy (see
|
|
235
|
+
* step 1 above). `false` keeps a foreign dependency as a gate
|
|
236
|
+
* (standalone / operator-DAG semantics); `true` prunes foreign edges so
|
|
237
|
+
* the DAG stays closed over the scheduled set (Epic semantics).
|
|
238
|
+
* @returns {StoryRecord[]} The dispatch set: a subset of `stories`,
|
|
239
|
+
* ascending by id, overlap-free, length ≤ `globalCap − inFlight`.
|
|
240
|
+
*/
|
|
241
|
+
export function selectReadySet({
|
|
242
|
+
stories,
|
|
243
|
+
doneIds = [],
|
|
244
|
+
inFlight = 0,
|
|
245
|
+
globalCap,
|
|
246
|
+
dropForeign = false,
|
|
247
|
+
} = {}) {
|
|
248
|
+
const records = Array.isArray(stories) ? stories : [];
|
|
249
|
+
const cap = Number.isInteger(globalCap) ? globalCap : 0;
|
|
250
|
+
const inFlightCount =
|
|
251
|
+
Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
|
|
252
|
+
const slots = Math.max(0, cap - inFlightCount);
|
|
253
|
+
if (slots <= 0 || records.length === 0) return [];
|
|
254
|
+
|
|
255
|
+
// Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
|
|
256
|
+
// a dependency on an id outside the supplied set gates the dependent
|
|
257
|
+
// (false) or is pruned (true). See the JSDoc above for the per-path
|
|
258
|
+
// rationale.
|
|
259
|
+
const adjacency = buildStoryAdjacency(records, { dropForeign });
|
|
260
|
+
|
|
261
|
+
// Step 2 — done set = caller-supplied ids ∪ records that classify done.
|
|
262
|
+
const done = new Set();
|
|
263
|
+
for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
|
|
264
|
+
const id = Number(raw);
|
|
265
|
+
if (Number.isInteger(id)) done.add(id);
|
|
266
|
+
}
|
|
267
|
+
const byId = new Map();
|
|
268
|
+
for (const rec of records) {
|
|
269
|
+
const id = storyIdOf(rec);
|
|
270
|
+
if (id === null) continue;
|
|
271
|
+
byId.set(id, rec);
|
|
272
|
+
if (classifyStory(rec) === 'done') done.add(id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 3 — eligible: ready AND all dependencies done. Ascending id for
|
|
276
|
+
// deterministic admission order.
|
|
277
|
+
const eligibleIds = [];
|
|
278
|
+
for (const id of [...byId.keys()].sort((a, b) => a - b)) {
|
|
279
|
+
const rec = byId.get(id);
|
|
280
|
+
if (classifyStory(rec) !== 'ready') continue;
|
|
281
|
+
const deps = adjacency.get(id) ?? [];
|
|
282
|
+
if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
|
|
286
|
+
// collisions against the already-admitted set.
|
|
287
|
+
const selected = [];
|
|
288
|
+
for (const id of eligibleIds) {
|
|
289
|
+
if (selected.length >= slots) break;
|
|
290
|
+
const rec = byId.get(id);
|
|
291
|
+
if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
|
|
292
|
+
selected.push(rec);
|
|
293
|
+
}
|
|
294
|
+
return selected;
|
|
295
|
+
}
|