mandrel 2.32.0 → 2.34.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 (48) hide show
  1. package/.agents/docs/SDLC.md +8 -5
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +3 -2
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +8 -2
  6. package/.agents/scripts/README.md +9 -0
  7. package/.agents/scripts/audit-to-stories.js +160 -41
  8. package/.agents/scripts/check-knip-entries.js +47 -24
  9. package/.agents/scripts/check-lifecycle-lint.js +72 -12
  10. package/.agents/scripts/coverage-capture.js +7 -1
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
  12. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  13. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  14. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  15. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  16. package/.agents/scripts/lib/config/quality.js +7 -0
  17. package/.agents/scripts/lib/config/runners.js +38 -16
  18. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  19. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  20. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  21. package/.agents/scripts/lib/coverage-capture.js +10 -15
  22. package/.agents/scripts/lib/dependency-parser.js +20 -7
  23. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  24. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  25. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  26. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  27. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  29. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
  30. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  31. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  32. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  34. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  35. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  36. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  37. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  38. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  39. package/.agents/scripts/resolve-stories.js +21 -5
  40. package/.agents/scripts/stories-wave-tick.js +192 -9
  41. package/.agents/workflows/audit-to-stories.md +26 -0
  42. package/.agents/workflows/helpers/deliver-light.md +5 -2
  43. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  44. package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
  45. package/.agents/workflows/helpers/deliver-story.md +4 -2
  46. package/.agents/workflows/helpers/plan-reference.md +76 -0
  47. package/docs/CHANGELOG.md +26 -0
  48. package/package.json +3 -3
@@ -61,6 +61,35 @@
61
61
 
62
62
  import { AGENT_LABELS } from '../label-constants.js';
63
63
  import { buildStoryAdjacency } from '../story-adjacency.js';
64
+ import { detectCollision } from './footprint.js';
65
+
66
+ /**
67
+ * How a footprint collision affects dispatch — the `footprintGuard` config
68
+ * knob (`delivery.deliverRunner.footprintGuard`, Story #5044).
69
+ *
70
+ * `enforce` is the default and encodes delivery-time-only knowledge the
71
+ * planner cannot have: which implementation windows are open right now, which
72
+ * Stories a foreign lease holds, how far the ground has moved since the plan
73
+ * was authored. It is never demoted automatically.
74
+ *
75
+ * `advisory` is an explicit operator trade for throughput on a run whose
76
+ * `depends_on` edges are known to be complete. Detection still runs; only the
77
+ * withholding stops.
78
+ */
79
+ export const GUARD_MODES = Object.freeze({
80
+ ENFORCE: 'enforce',
81
+ ADVISORY: 'advisory',
82
+ });
83
+
84
+ /**
85
+ * Which guard produced a withhold: a peer admitted **this beat**, or a Story
86
+ * still in flight from an **earlier** one. The two clear on different events,
87
+ * so an operator reading the report needs them apart.
88
+ */
89
+ export const WITHHOLD_SCOPES = Object.freeze({
90
+ BEAT: 'beat',
91
+ IN_FLIGHT: 'in-flight',
92
+ });
64
93
 
65
94
  /**
66
95
  * @typedef {object} StoryRecord
@@ -135,128 +164,6 @@ export function classifyStory(story) {
135
164
  return 'ready';
136
165
  }
137
166
 
138
- /**
139
- * Extract a Story's declared file footprint as a normalized set of path
140
- * strings. Accepts the three footprint shapes a Story record can carry:
141
- *
142
- * - `files: string[]` — explicit footprint.
143
- * - `changes: string[]` — string-array sketch.
144
- * - `changeset: Array<{ path }>` / — object-array sketch (the
145
- * `changes: Array<{ path }>` `{ path, assumption }`
146
- * shape from a Story body).
147
- *
148
- * Paths are trimmed; empty / non-string entries are dropped. A Story with
149
- * no declared footprint yields an empty set, which (by `storiesOverlap`'s
150
- * contract) means it overlaps with nothing and is never withheld by the
151
- * co-dispatch guard.
152
- *
153
- * @param {StoryRecord} story
154
- * @returns {Set<string>}
155
- */
156
- export function storyFootprint(story) {
157
- const out = new Set();
158
- const push = (entry) => {
159
- const path =
160
- typeof entry === 'string'
161
- ? entry
162
- : typeof entry?.path === 'string'
163
- ? entry.path
164
- : null;
165
- if (!path) return;
166
- const trimmed = path.trim();
167
- if (trimmed) out.add(trimmed);
168
- };
169
- if (Array.isArray(story?.files)) for (const e of story.files) push(e);
170
- if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
171
- if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
172
- return out;
173
- }
174
-
175
- /**
176
- * Does a declared path contain a glob metacharacter? Mirrors the detection
177
- * in `story-body.js#extractChangePaths`, whose `isGlob` flag documents an
178
- * "unknown-width footprint" policy that was never implemented downstream.
179
- *
180
- * @param {string} path
181
- * @returns {boolean}
182
- */
183
- function isGlobPath(path) {
184
- return path.includes('*') || path.includes('?') || path.includes('{');
185
- }
186
-
187
- /**
188
- * Repo-relative file paths as they appear in Story prose: at least one `/`
189
- * separator and a short file extension. Deliberately narrow — a token has to
190
- * look like a real path before it can widen a footprint and withhold a Story.
191
- */
192
- const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
193
-
194
- /**
195
- * Scrape file paths a Story's **text** mentions but its `changes[]` never
196
- * declared (Story #4875).
197
- *
198
- * The declared footprint is a planner's *prediction*, and it is systematically
199
- * a lower bound: a Story's `## Spec` names the module it must also touch, its
200
- * acceptance criteria name the caller that must be updated, and none of that
201
- * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
202
- * same file, so trusting the declaration outright means the guard is blind to
203
- * precisely the collisions nobody predicted.
204
- *
205
- * Evidence is only ever **added** — nothing here can shrink a declared
206
- * footprint, so widening can withhold a Story for a beat but can never
207
- * co-dispatch one the declared comparison would have caught.
208
- *
209
- * @param {StoryRecord} story
210
- * @returns {Set<string>}
211
- */
212
- function storyEvidencePaths(story) {
213
- const out = new Set();
214
- for (const field of [story?.title, story?.body, story?.spec]) {
215
- if (typeof field !== 'string' || field === '') continue;
216
- for (const match of field.matchAll(PROSE_PATH_RE)) {
217
- const trimmed = match[0].trim();
218
- if (trimmed) out.add(trimmed);
219
- }
220
- }
221
- return out;
222
- }
223
-
224
- /**
225
- * A Story's footprint **widened from observable evidence** — the set the
226
- * co-dispatch guard actually compares (Story #4875).
227
- *
228
- * `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
229
- * declaration is treated as a lower bound rather than the answer.
230
- *
231
- * @param {StoryRecord} story
232
- * @returns {Set<string>}
233
- */
234
- function storyWidenedFootprint(story) {
235
- const out = storyFootprint(story);
236
- for (const path of storyEvidencePaths(story)) out.add(path);
237
- return out;
238
- }
239
-
240
- /**
241
- * Both Stories' widened footprints, or `null` when either is empty.
242
- *
243
- * **An empty footprint means "no known overlap"**, so both guards below
244
- * short-circuit to `false` on one. This is permissive by necessity: a Story
245
- * with no declared footprint and no path evidence in its text carries no
246
- * information, and withholding on absence would serialize every run.
247
- *
248
- * @param {StoryRecord} a
249
- * @param {StoryRecord} b
250
- * @returns {[Set<string>, Set<string>]|null}
251
- */
252
- function widenedFootprintPair(a, b) {
253
- const fa = storyWidenedFootprint(a);
254
- if (fa.size === 0) return null;
255
- const fb = storyWidenedFootprint(b);
256
- if (fb.size === 0) return null;
257
- return [fa, fb];
258
- }
259
-
260
167
  /**
261
168
  * **Beat-local** file-overlap co-dispatch guard. Returns `true` when two
262
169
  * Stories' file footprints intersect — meaning they would race the same file
@@ -282,17 +189,8 @@ function widenedFootprintPair(a, b) {
282
189
  * @param {StoryRecord} b
283
190
  * @returns {boolean}
284
191
  */
285
- export function storiesOverlap(a, b) {
286
- const pair = widenedFootprintPair(a, b);
287
- if (pair === null) return false;
288
- const [fa, fb] = pair;
289
- for (const path of fa) {
290
- if (isGlobPath(path) || fb.has(path)) return true;
291
- }
292
- for (const path of fb) {
293
- if (isGlobPath(path)) return true;
294
- }
295
- return false;
192
+ export function storiesOverlap(a, b, options = {}) {
193
+ return detectCollision(a, b, options) !== null;
296
194
  }
297
195
 
298
196
  /**
@@ -319,16 +217,11 @@ export function storiesOverlap(a, b) {
319
217
  *
320
218
  * @param {StoryRecord} held The in-flight Story holding the reservation.
321
219
  * @param {StoryRecord} candidate The Story being considered for admission.
322
- * @returns {boolean}
220
+ * @param {object} [options] Evidence-scrape options (see {@link storyEvidencePaths}).
221
+ * @returns {{ paths: string[], source: string }|null}
323
222
  */
324
- function reservesConcretePath(held, candidate) {
325
- const pair = widenedFootprintPair(held, candidate);
326
- if (pair === null) return false;
327
- const [fa, fb] = pair;
328
- for (const path of fa) {
329
- if (!isGlobPath(path) && fb.has(path)) return true;
330
- }
331
- return false;
223
+ function reservesConcretePath(held, candidate, options = {}) {
224
+ return detectCollision(held, candidate, { ...options, concreteOnly: true });
332
225
  }
333
226
 
334
227
  /**
@@ -396,11 +289,28 @@ function reservesConcretePath(held, candidate) {
396
289
  * footprint reserves nothing (Story #4960). Callers that hold only ids (the
397
290
  * `--dag`/`--in-flight` flag mode) pass nothing and get the pre-#4950
398
291
  * same-beat-only behaviour.
399
- * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
292
+ * @param {'enforce'|'advisory'} [args.footprintGuard='enforce'] Whether a
293
+ * footprint collision **withholds** a Story (`enforce`, the default and
294
+ * today's behaviour) or merely **reports** one while dispatch follows the
295
+ * declared `depends_on` edges alone (`advisory`). Advisory never changes what
296
+ * the guard *detects* — every would-be withhold is still computed and
297
+ * returned in `footprintWithholds` with `enforced: false` — so turning it on
298
+ * trades serialization for throughput without going blind (Story #5044).
299
+ * @param {string} [args.tempRoot] Resolved `project.paths.tempRoot`, threaded
300
+ * so the evidence scrape can ignore gitignored scratch paths.
301
+ * @returns {{
302
+ * selected: StoryRecord[],
303
+ * withheldByInFlight: Array<{id: number, blockedBy: number}>,
304
+ * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
305
+ * guardMode: 'enforce'|'advisory'
306
+ * }}
400
307
  * `selected` is the dispatch set: a subset of `stories`, ascending by id,
401
308
  * overlap-free, length ≤ `globalCap − inFlight`. `withheldByInFlight`
402
309
  * names each eligible Story a reservation held back and the in-flight
403
- * Story that holds it.
310
+ * Story that holds it. `footprintWithholds` is the **complete** ledger —
311
+ * beat-local skips as well as cross-beat reservations, each with the
312
+ * colliding paths and its `declared-overlap` / `scraped-overlap` source — so
313
+ * no withheld dispatch is unexplained (Story #5044).
404
314
  */
405
315
  export function planReadySet({
406
316
  stories,
@@ -409,14 +319,25 @@ export function planReadySet({
409
319
  globalCap,
410
320
  dropForeign = false,
411
321
  inFlightRecords = [],
322
+ footprintGuard = GUARD_MODES.ENFORCE,
323
+ tempRoot,
412
324
  } = {}) {
413
325
  const records = Array.isArray(stories) ? stories : [];
326
+ const guardMode =
327
+ footprintGuard === GUARD_MODES.ADVISORY
328
+ ? GUARD_MODES.ADVISORY
329
+ : GUARD_MODES.ENFORCE;
414
330
  const cap = Number.isInteger(globalCap) ? globalCap : 0;
415
331
  const inFlightCount =
416
332
  Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
417
333
  const slots = Math.max(0, cap - inFlightCount);
418
334
  if (slots <= 0 || records.length === 0) {
419
- return { selected: [], withheldByInFlight: [] };
335
+ return {
336
+ selected: [],
337
+ withheldByInFlight: [],
338
+ footprintWithholds: [],
339
+ guardMode,
340
+ };
420
341
  }
421
342
 
422
343
  // Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
@@ -425,7 +346,47 @@ export function planReadySet({
425
346
  // rationale.
426
347
  const adjacency = buildStoryAdjacency(records, { dropForeign });
427
348
 
428
- // Step 2 — done set = caller-supplied ids records that classify done.
349
+ // Steps 2 + 3 who is eligible at all, before any footprint reasoning.
350
+ const { eligibleIds, byId } = resolveEligibility({
351
+ records,
352
+ doneIds,
353
+ adjacency,
354
+ });
355
+
356
+ // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
357
+ // collisions against the already-admitted set AND against the footprints
358
+ // reserved by Stories still in flight from an earlier beat.
359
+ return admitStories({
360
+ eligibleIds,
361
+ byId,
362
+ slots,
363
+ reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
364
+ guardMode,
365
+ evidence: { tempRoot },
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Resolve which Stories are eligible to dispatch on dependency grounds alone —
371
+ * `agent::ready` with every declared blocker done — plus the id→record index
372
+ * the admission loop reads.
373
+ *
374
+ * Separated from {@link planReadySet} because it answers a different question:
375
+ * this is the *declared graph* half of the decision (edges and lifecycle
376
+ * state), while everything after it reasons about footprints. Keeping the two
377
+ * apart is also what holds `planReadySet` under the cyclomatic ceiling ratchet.
378
+ *
379
+ * The done set is the union of two sources: ids the caller resolved from live
380
+ * state, and records in this batch that classify done — a Story can be both,
381
+ * and neither alone is complete.
382
+ *
383
+ * @param {object} args
384
+ * @param {StoryRecord[]} args.records
385
+ * @param {number[]|Set<number>} args.doneIds
386
+ * @param {Map<number, number[]>} args.adjacency
387
+ * @returns {{ eligibleIds: number[], byId: Map<number, StoryRecord> }}
388
+ */
389
+ function resolveEligibility({ records, doneIds, adjacency }) {
429
390
  const done = new Set();
430
391
  for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
431
392
  const id = Number(raw);
@@ -439,25 +400,14 @@ export function planReadySet({
439
400
  if (classifyStory(rec) === 'done') done.add(id);
440
401
  }
441
402
 
442
- // Step 3 eligible: ready AND all dependencies done. Ascending id for
443
- // deterministic admission order.
403
+ // Ascending id for deterministic admission order.
444
404
  const eligibleIds = [];
445
405
  for (const id of [...byId.keys()].sort((a, b) => a - b)) {
446
- const rec = byId.get(id);
447
- if (classifyStory(rec) !== 'ready') continue;
406
+ if (classifyStory(byId.get(id)) !== 'ready') continue;
448
407
  const deps = adjacency.get(id) ?? [];
449
408
  if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
450
409
  }
451
-
452
- // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
453
- // collisions against the already-admitted set AND against the footprints
454
- // reserved by Stories still in flight from an earlier beat.
455
- return admitStories({
456
- eligibleIds,
457
- byId,
458
- slots,
459
- reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
460
- });
410
+ return { eligibleIds, byId };
461
411
  }
462
412
 
463
413
  /**
@@ -469,37 +419,102 @@ export function planReadySet({
469
419
  * ({@link reservesConcretePath}), while the same-beat guard also serializes
470
420
  * unknown-width footprints ({@link storiesOverlap}). See both for why.
471
421
  *
472
- * The reservation check runs **first**, so a Story racing both an in-flight
473
- * Story and a same-beat peer is reported against the in-flight one: that is
474
- * the longer-lived and more informative blocker (a Story that has been
475
- * implementing for beats, not one merely admitted a moment ago), and checking
476
- * it first is what makes the report complete — every withheld-by-reservation
477
- * Story appears in it. Ordering cannot change `selected`: a candidate either
478
- * rule rejects is skipped whichever runs first; only which list it is
479
- * reported in depends on the order.
422
+ * Which of the two a candidate is reported against is {@link blockingCollision}'s
423
+ * decision, not this loop's.
424
+ *
425
+ * Under `footprintGuard: 'advisory'` neither rule withholds: dispatch follows
426
+ * the declared `depends_on` edges alone. Detection is unchanged — every hit is
427
+ * still computed and recorded with `enforced: false` so advisory mode is a
428
+ * deliberate throughput trade an operator can read the cost of, not a blind
429
+ * spot (Story #5044).
480
430
  *
481
431
  * @param {object} args
482
432
  * @param {number[]} args.eligibleIds Ascending eligible Story ids.
483
433
  * @param {Map<number, StoryRecord>} args.byId
484
434
  * @param {number} args.slots Remaining dispatch capacity.
485
435
  * @param {StoryRecord[]} args.reserved In-flight Story records.
486
- * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
436
+ * @param {'enforce'|'advisory'} args.guardMode
437
+ * @param {object} args.evidence Evidence-scrape options.
438
+ * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}>, footprintWithholds: object[], guardMode: string }}
487
439
  */
488
- function admitStories({ eligibleIds, byId, slots, reserved }) {
440
+ function admitStories({
441
+ eligibleIds,
442
+ byId,
443
+ slots,
444
+ reserved,
445
+ guardMode,
446
+ evidence,
447
+ }) {
448
+ const enforced = guardMode !== GUARD_MODES.ADVISORY;
489
449
  const selected = [];
490
- const withheldByInFlight = [];
450
+ const footprintWithholds = [];
491
451
  for (const id of eligibleIds) {
492
452
  if (selected.length >= slots) break;
493
453
  const rec = byId.get(id);
494
- const blockedBy = findInFlightBlocker(rec, id, reserved);
495
- if (blockedBy !== null) {
496
- withheldByInFlight.push({ id, blockedBy });
497
- continue;
498
- }
499
- if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
454
+ const hit = blockingCollision({ rec, id, selected, reserved, evidence });
455
+ if (hit) footprintWithholds.push({ id, ...hit, enforced });
456
+ if (hit && enforced) continue;
500
457
  selected.push(rec);
501
458
  }
502
- return { selected, withheldByInFlight };
459
+ return {
460
+ selected,
461
+ // The legacy cross-beat projection, kept at its original `{ id, blockedBy }`
462
+ // shape: it is `stories-wave-tick.js`'s long-standing reservation input and
463
+ // narrowing the scrape must not reshape it.
464
+ withheldByInFlight: footprintWithholds
465
+ .filter((w) => w.enforced && w.scope === WITHHOLD_SCOPES.IN_FLIGHT)
466
+ .map(({ id, blockedBy }) => ({ id, blockedBy })),
467
+ footprintWithholds,
468
+ guardMode,
469
+ };
470
+ }
471
+
472
+ /**
473
+ * The one footprint collision withholding this candidate, or `null`.
474
+ *
475
+ * The in-flight reservation is checked **first**, so a Story racing both an
476
+ * in-flight Story and a same-beat peer is reported against the in-flight one:
477
+ * that is the longer-lived and more informative blocker (a Story that has been
478
+ * implementing for beats, not one merely admitted a moment ago), and checking it
479
+ * first is what makes the reservation report complete. Order cannot change
480
+ * `selected` — a candidate either rule rejects is skipped whichever runs first.
481
+ *
482
+ * @param {object} args
483
+ * @param {StoryRecord} args.rec
484
+ * @param {number} args.id
485
+ * @param {StoryRecord[]} args.selected Peers already admitted this beat.
486
+ * @param {StoryRecord[]} args.reserved In-flight Story records.
487
+ * @param {object} args.evidence
488
+ * @returns {{ blockedBy: number, scope: string, paths: string[], source: string }|null}
489
+ */
490
+ function blockingCollision({ rec, id, selected, reserved, evidence }) {
491
+ const held = findInFlightBlocker(rec, id, reserved, evidence);
492
+ if (held) return { ...held, scope: WITHHOLD_SCOPES.IN_FLIGHT };
493
+ const peer = findBeatBlocker(rec, selected, evidence);
494
+ return peer ? { ...peer, scope: WITHHOLD_SCOPES.BEAT } : null;
495
+ }
496
+
497
+ /**
498
+ * The **already-admitted peer** whose footprint this candidate would race on
499
+ * this beat, with the colliding paths — or `null` when none does.
500
+ *
501
+ * Until Story #5044 this was an anonymous `continue`: the candidate was
502
+ * silently dropped from the beat and nothing in any envelope said why. An
503
+ * unfilled slot with no explanation is indistinguishable from a cap that was
504
+ * simply not reached, which is what let a footprint-widening artifact
505
+ * serialize a whole audit-derived plan without leaving a trace to notice.
506
+ *
507
+ * @param {StoryRecord} candidate
508
+ * @param {StoryRecord[]} selected Stories already admitted this beat.
509
+ * @param {object} [options]
510
+ * @returns {{ blockedBy: number, paths: string[], source: string }|null}
511
+ */
512
+ function findBeatBlocker(candidate, selected, options = {}) {
513
+ for (const picked of selected) {
514
+ const collision = detectCollision(picked, candidate, options);
515
+ if (collision) return { blockedBy: storyIdOf(picked), ...collision };
516
+ }
517
+ return null;
503
518
  }
504
519
 
505
520
  /**
@@ -520,13 +535,15 @@ function admitStories({ eligibleIds, byId, slots, reserved }) {
520
535
  * @param {StoryRecord} candidate
521
536
  * @param {number} candidateId
522
537
  * @param {StoryRecord[]} reserved
523
- * @returns {number|null}
538
+ * @param {object} [options]
539
+ * @returns {{ blockedBy: number, paths: string[], source: string }|null}
524
540
  */
525
- function findInFlightBlocker(candidate, candidateId, reserved) {
541
+ function findInFlightBlocker(candidate, candidateId, reserved, options = {}) {
526
542
  for (const held of reserved) {
527
543
  const heldId = storyIdOf(held);
528
544
  if (heldId === null || heldId === candidateId) continue;
529
- if (reservesConcretePath(held, candidate)) return heldId;
545
+ const collision = reservesConcretePath(held, candidate, options);
546
+ if (collision) return { blockedBy: heldId, ...collision };
530
547
  }
531
548
  return null;
532
549
  }
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { Logger } from '../../lib/Logger.js';
24
24
  import { concurrentMap } from '../../lib/util/concurrent-map.js';
25
- import { parseApiJson } from './request-helpers.js';
25
+ import { paginateRest } from './request-helpers.js';
26
26
 
27
27
  /**
28
28
  * Bounded concurrency for the GitHub dependency-edge round-trips. Kept modest
@@ -32,22 +32,37 @@ import { parseApiJson } from './request-helpers.js';
32
32
  const EDGE_CONCURRENCY = 5;
33
33
 
34
34
  /**
35
- * Fetch the existing blocked-by issue numbers for a given issue.
35
+ * Fetch the existing blocked-by database ids for a given issue, **paginated
36
+ * to exhaustion**.
37
+ *
38
+ * This read is the idempotency check: an edge it fails to see is re-POSTed.
39
+ * Reading only the first page therefore made the writer non-idempotent past
40
+ * the page boundary — every edge beyond it looked missing on every run
41
+ * (Story #5046). `paginateRest` walks the pages and carries the shared
42
+ * transient-retry and page-cap guards.
36
43
  *
37
44
  * Returns `[]` on any error so the caller falls back to posting the full
38
45
  * set of missing edges (worst case: a duplicate POST, which GitHub
39
- * handles idempotently).
46
+ * handles idempotently). That non-fatal contract is deliberate and is the
47
+ * inverse of the READ path in `lib/orchestration/resolve-stories.js`: a lost
48
+ * write-side edge is cosmetic, a lost read-side edge removes a dispatch gate.
40
49
  *
41
- * @param {{ gh: object, owner: string, repo: string, issueNumber: number }} opts
50
+ * @param {{ gh: object, owner: string, repo: string, issueNumber: number, paginate?: Function }} opts
42
51
  * @returns {Promise<number[]>} Database ids of the issues that currently block `issueNumber`.
43
52
  */
44
- async function fetchExistingBlockedBy({ gh, owner, repo, issueNumber }) {
53
+ async function fetchExistingBlockedBy({
54
+ gh,
55
+ owner,
56
+ repo,
57
+ issueNumber,
58
+ paginate = paginateRest,
59
+ }) {
45
60
  try {
46
- const result = await gh.api({
47
- method: 'GET',
48
- endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
49
- });
50
- const data = parseApiJson(result);
61
+ const data = await paginate(
62
+ gh,
63
+ `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
64
+ { label: `[blocked-by-add] blocked_by #${issueNumber}` },
65
+ );
51
66
  if (!Array.isArray(data)) return [];
52
67
  return data.map((item) => item?.id).filter((id) => typeof id === 'number');
53
68
  } catch (err) {
@@ -11,8 +11,11 @@
11
11
  * What it resolves, per Story:
12
12
  * - the issue itself, fetched with **state=all** so an already-landed
13
13
  * sibling is present rather than silently dropped;
14
- * - its dependency edges: the union of body-parsed `blocked by #N` /
15
- * `depends on #N` and native GitHub `blocked_by` edges;
14
+ * - its dependency edges: the union of the body's `---` footer
15
+ * (`blocked by #N`, footer-scoped and strict prose mentioning a blocker
16
+ * elsewhere in the body declares nothing) and native GitHub `blocked_by`
17
+ * edges, read to exhaustion and failing loud rather than degrading to
18
+ * "no edges";
16
19
  * - its declared file footprint, as plain path strings, so the scheduler's
17
20
  * co-dispatch overlap guard has something to work with.
18
21
  *
@@ -45,7 +48,7 @@ import {
45
48
  } from './lib/orchestration/resolve-stories.js';
46
49
  import { createProvider } from './lib/provider-factory.js';
47
50
  import { concurrentMap } from './lib/util/concurrent-map.js';
48
- import { parseApiJson } from './providers/github/request-helpers.js';
51
+ import { paginateRest } from './providers/github/request-helpers.js';
49
52
 
50
53
  export { buildStoriesEnvelope, parseIds, readNativeBlockedBy, toStoryRecord };
51
54
 
@@ -111,9 +114,21 @@ export async function fetchStories(provider, ids) {
111
114
  /**
112
115
  * Read native blocked_by edges for every Story in the set.
113
116
  *
117
+ * `paginate` is injected rather than imported inside the lib layer so
118
+ * `readNativeBlockedBy` stays provider-agnostic and unit-testable; production
119
+ * passes `paginateRest`, which walks every page (the read used to stop at the
120
+ * first, silently truncating a Story's gates — Story #5046).
121
+ *
114
122
  * @returns {Promise<Map<number, number[]>>}
115
123
  */
116
- export async function readNativeEdges({ provider, stories, owner, repo }) {
124
+ export async function readNativeEdges({
125
+ provider,
126
+ stories,
127
+ owner,
128
+ repo,
129
+ paginate = paginateRest,
130
+ warn = (m) => Logger.warn(m),
131
+ }) {
117
132
  const entries = await concurrentMap(
118
133
  stories,
119
134
  async (story) => [
@@ -123,7 +138,8 @@ export async function readNativeEdges({ provider, stories, owner, repo }) {
123
138
  owner,
124
139
  repo,
125
140
  issueNumber: story.id,
126
- parseJson: parseApiJson,
141
+ paginate,
142
+ warn,
127
143
  }),
128
144
  ],
129
145
  { concurrency: FETCH_CONCURRENCY },