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.
Files changed (50) hide show
  1. package/.agents/docs/agentrc-reference.json +1 -2
  2. package/.agents/docs/configuration.md +2 -4
  3. package/.agents/schemas/agentrc.schema.json +1 -5
  4. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  5. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  6. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  7. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  8. package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
  9. package/.agents/scripts/lib/config/explain.js +0 -2
  10. package/.agents/scripts/lib/config/limits.js +19 -8
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +17 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -2
  13. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  14. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  17. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
  18. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  21. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
  24. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  25. package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  27. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  28. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  29. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  30. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  31. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  32. package/.agents/scripts/lib/story-adjacency.js +14 -10
  33. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  35. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  36. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  37. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  38. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  39. package/.agents/scripts/stories-wave-tick.js +262 -161
  40. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  41. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  42. package/.agents/skills/skills.index.json +2 -2
  43. package/.agents/workflows/deliver.md +12 -9
  44. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  45. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  46. package/.agents/workflows/helpers/plan-epic.md +13 -10
  47. package/.agents/workflows/plan.md +1 -1
  48. package/docs/CHANGELOG.md +26 -0
  49. package/package.json +1 -1
  50. 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
+ }