mandrel 2.53.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -28,12 +28,14 @@
28
28
  */
29
29
  import fs from 'node:fs';
30
30
  import path from 'node:path';
31
+ import { Worker } from 'node:worker_threads';
31
32
 
32
33
  import { mainCheckoutRoot } from './config/temp-paths.js';
33
34
  import {
34
35
  acquireLockWithWait,
35
36
  acquireSweepLock,
36
37
  readLockHolderPid,
38
+ refreshLockSync,
37
39
  } from './single-story-sweep/sweep-lock.js';
38
40
 
39
41
  /** Environment escape hatch: set to `0`/`false`/`off`/`no` to disable. */
@@ -56,6 +58,13 @@ const DEFAULT_WAIT_MS = 20 * 60_000;
56
58
  /** Poll interval while waiting. */
57
59
  const DEFAULT_POLL_MS = 2_000;
58
60
 
61
+ /**
62
+ * Mtime-refresh interval for the spawn heartbeat. A third of the staleness
63
+ * window, matching the sweep primitive's own divisor: two consecutive missed
64
+ * beats still leave a live holder looking live.
65
+ */
66
+ const DEFAULT_SPAWN_HEARTBEAT_MS = DEFAULT_STALE_MS / 3;
67
+
59
68
  const FALSEY = /^(0|false|off|no)$/i;
60
69
 
61
70
  /**
@@ -144,6 +153,170 @@ function beginLock({
144
153
  return { lock: null, lockPath };
145
154
  }
146
155
 
156
+ /**
157
+ * The heartbeat body, run on a worker thread (Story #5278).
158
+ *
159
+ * Sleeps in `Atomics.wait` slices and refreshes the lockfile's mtime between
160
+ * them, stopping the moment the main thread flips the shared stop flag (and
161
+ * `Atomics.notify`s it), the lockfile stops being ours, or any I/O fails.
162
+ * Ownership is re-read from the file's first line on every beat for the same
163
+ * reason the in-process heartbeat does it: after a steal the file belongs to
164
+ * someone else, and bumping its mtime would keep *their* lock alive on our
165
+ * behalf.
166
+ *
167
+ * Inline source rather than a module of its own: it is nine lines of loop
168
+ * whose whole meaning is the lock it refreshes, and a separate worker entry
169
+ * point would be a second file that no reader of this one can see.
170
+ */
171
+ const HEARTBEAT_WORKER_SOURCE = `
172
+ const fs = require('node:fs');
173
+ const { workerData } = require('node:worker_threads');
174
+ const { lockPath, ownerId, intervalMs, stopBuffer } = workerData;
175
+ const stop = new Int32Array(stopBuffer);
176
+ while (Atomics.load(stop, 0) === 0) {
177
+ Atomics.wait(stop, 0, 0, intervalMs);
178
+ if (Atomics.load(stop, 0) !== 0) break;
179
+ try {
180
+ if (String(fs.readFileSync(lockPath, 'utf8')).split('\\n', 1)[0] !== ownerId) break;
181
+ const now = new Date();
182
+ fs.utimesSync(lockPath, now, now);
183
+ } catch {
184
+ break;
185
+ }
186
+ }
187
+ `;
188
+
189
+ /**
190
+ * Keep a held lock's mtime advancing while this thread is blocked inside a
191
+ * synchronous spawn (Story #5278).
192
+ *
193
+ * The primitive's own heartbeat is a `setInterval`, so it only fires when the
194
+ * holder's event loop gets a turn. `runCapture` spawns the suite with
195
+ * `spawnSync`: the loop stops turning for the entire run, the mtime freezes
196
+ * at acquisition time, and a sibling reading that mtime concludes — correctly,
197
+ * on the evidence available to it — that the holder died, breaks the lock, and
198
+ * starts a second full suite beside the first. That is the exact collision
199
+ * this lock exists to prevent, and it fires most reliably on the slowest
200
+ * suites, where it costs the most.
201
+ *
202
+ * A worker thread has its own event loop, unaffected by the main thread's
203
+ * blocking spawn, so it is the only place a refresh can happen at all here.
204
+ *
205
+ * **Best-effort, like everything else on this path.** A worker that cannot
206
+ * start (a runtime with threads disabled, a resource limit) leaves the
207
+ * pre-#5278 behaviour exactly as it was; it never throws and never delays the
208
+ * spawn it guards.
209
+ *
210
+ * @param {{ lockPath: string|null, ownerId?: string, heartbeatMs: number }} opts
211
+ * @returns {() => void} `stop()` — idempotent, safe when no worker started.
212
+ */
213
+ function startSpawnHeartbeat({ lockPath, ownerId, heartbeatMs }) {
214
+ if (!(lockPath && typeof ownerId === 'string' && heartbeatMs > 0)) {
215
+ return () => {};
216
+ }
217
+ let worker = null;
218
+ let stop = null;
219
+ try {
220
+ const stopBuffer = new SharedArrayBuffer(4);
221
+ stop = new Int32Array(stopBuffer);
222
+ worker = new Worker(HEARTBEAT_WORKER_SOURCE, {
223
+ eval: true,
224
+ workerData: { lockPath, ownerId, intervalMs: heartbeatMs, stopBuffer },
225
+ });
226
+ // A heartbeat must never be the reason the process stays alive.
227
+ worker.unref();
228
+ worker.on('error', () => {});
229
+ } catch {
230
+ return () => {};
231
+ }
232
+ let stopped = false;
233
+ return () => {
234
+ if (stopped) return;
235
+ stopped = true;
236
+ try {
237
+ Atomics.store(stop, 0, 1);
238
+ Atomics.notify(stop, 0);
239
+ worker.terminate();
240
+ } catch {
241
+ // Already gone.
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Start the spawn heartbeat without ever letting it become a reason the suite
248
+ * does not run.
249
+ *
250
+ * The whole module's posture is that a lock defect may slow a suite down and
251
+ * may never skip one, and a heartbeat is the least load-bearing thing on the
252
+ * path — it exists only so a *rival* reads the mtime correctly. So a starter
253
+ * that throws (an environment without worker threads, a resource limit, an
254
+ * injected seam) resolves to "no heartbeat", never to a failed close.
255
+ *
256
+ * @param {Function} startFn
257
+ * @param {{ lockPath: string|null, ownerId?: string, heartbeatMs: number }} opts
258
+ * @returns {() => void}
259
+ */
260
+ function safeStartHeartbeat(startFn, opts) {
261
+ try {
262
+ return startFn(opts) ?? (() => {});
263
+ } catch {
264
+ return () => {};
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Stamp a held lock as current immediately before its critical section
270
+ * begins (Story #5278).
271
+ *
272
+ * A lock acquired at the end of a twenty-minute wait was created — or last
273
+ * heartbeat-refreshed — long before the spawn it is about to guard, and the
274
+ * synchronous caller's event loop is about to stop turning for the whole
275
+ * duration of that spawn. Refreshing here is the last chance to put a current
276
+ * mtime on the file.
277
+ *
278
+ * Routed through the primitive's own {@link refreshLockSync} rather than the
279
+ * holder's `refresh()` so an injected `acquireOnceFn` seam that returns a
280
+ * bare `{ acquired, release }` is refreshed identically to the real one.
281
+ *
282
+ * @param {{ ownerId?: string }|null} held
283
+ * @param {string|null} lockPath
284
+ * @param {object} fsImpl
285
+ */
286
+ function refreshBeforeSpawn(held, lockPath, fsImpl) {
287
+ if (!(held?.acquired && typeof held.ownerId === 'string' && lockPath)) return;
288
+ refreshLockSync({ lockPath, ownerId: held.ownerId, fsImpl });
289
+ }
290
+
291
+ /**
292
+ * The post-wait re-probe (Story #5278).
293
+ *
294
+ * Waiting for the lock is waiting for *someone else's* full suite against
295
+ * this same checkout. By the time it finishes, the thing this caller was
296
+ * about to spawn the suite to establish may already be true — the holder
297
+ * deposited the stamp, or a sibling recorded the evidence. Spawning anyway
298
+ * pays for a whole suite to re-derive a fact that is already on disk, which
299
+ * is exactly the cost the lock exists to avoid.
300
+ *
301
+ * Consulted **only after a real wait**: an uncontended caller's freshness
302
+ * probe ran moments ago and nothing has happened since, so re-running it
303
+ * would be pure overhead on the hot path.
304
+ *
305
+ * @template T
306
+ * @param {(() => T|undefined)|undefined} skipIfSatisfied
307
+ * @param {boolean} waited
308
+ * @returns {{ satisfied: boolean, value?: T }}
309
+ */
310
+ function probeAlreadySatisfied(skipIfSatisfied, waited) {
311
+ if (!(waited && typeof skipIfSatisfied === 'function')) {
312
+ return { satisfied: false };
313
+ }
314
+ const value = skipIfSatisfied();
315
+ return value === undefined
316
+ ? { satisfied: false }
317
+ : { satisfied: true, value };
318
+ }
319
+
147
320
  /**
148
321
  * Block a synchronous caller for `ms` without a timer. `runCapture` spawns the
149
322
  * suite with `spawnSync`, so its whole call stack is synchronous and there is
@@ -178,7 +351,13 @@ function sleepSync(ms) {
178
351
  * sleepFn?: (ms: number) => void,
179
352
  * acquireOnceFn?: typeof acquireSweepLock,
180
353
  * lockPath?: string,
181
- * }} opts
354
+ * skipIfSatisfied?: () => T|undefined,
355
+ * spawnHeartbeatMs?: number,
356
+ * startSpawnHeartbeatFn?: typeof startSpawnHeartbeat,
357
+ * }} opts `skipIfSatisfied` is the post-wait re-probe (Story #5278): after a
358
+ * contended wait it decides whether the thing this spawn would establish is
359
+ * already true, and a non-`undefined` return is returned in the spawn's
360
+ * place. Never consulted on the uncontended path.
182
361
  * @param {() => T} spawn
183
362
  * @returns {T}
184
363
  */
@@ -195,6 +374,9 @@ export function withFullSuiteLockSync(
195
374
  sleepFn = sleepSync,
196
375
  acquireOnceFn = acquireSweepLock,
197
376
  lockPath: explicitLockPath,
377
+ skipIfSatisfied,
378
+ spawnHeartbeatMs = DEFAULT_SPAWN_HEARTBEAT_MS,
379
+ startSpawnHeartbeatFn = startSpawnHeartbeat,
198
380
  },
199
381
  spawn,
200
382
  ) {
@@ -208,10 +390,12 @@ export function withFullSuiteLockSync(
208
390
  lockPath: explicitLockPath,
209
391
  });
210
392
  let held = lock;
393
+ let waited = false;
211
394
  if (held === null && lockPath !== null) {
212
395
  const deadline = nowFn() + Math.max(0, waitMs);
213
396
  for (;;) {
214
397
  if (nowFn() >= deadline) break;
398
+ waited = true;
215
399
  sleepFn(Math.max(0, pollMs));
216
400
  const attempt = acquireOnceFn({ lockPath, timeoutMs: staleMs, fsImpl });
217
401
  if (attempt.acquired) {
@@ -222,7 +406,26 @@ export function withFullSuiteLockSync(
222
406
  }
223
407
  }
224
408
  try {
225
- return spawn();
409
+ const probe = probeAlreadySatisfied(skipIfSatisfied, waited);
410
+ if (probe.satisfied) {
411
+ log(
412
+ '[full-suite-lock] ⏭ the run we waited for already covered this tree — skipping the spawn.',
413
+ );
414
+ return probe.value;
415
+ }
416
+ refreshBeforeSpawn(held, lockPath, fsImpl);
417
+ // The main thread is about to stop turning for the whole spawn, so the
418
+ // holder's own interval heartbeat cannot fire; this one runs off-thread.
419
+ const stopHeartbeat = safeStartHeartbeat(startSpawnHeartbeatFn, {
420
+ lockPath: held?.acquired ? lockPath : null,
421
+ ownerId: held?.ownerId,
422
+ heartbeatMs: spawnHeartbeatMs,
423
+ });
424
+ try {
425
+ return spawn();
426
+ } finally {
427
+ stopHeartbeat();
428
+ }
226
429
  } finally {
227
430
  if (held?.acquired) held.release();
228
431
  }
@@ -248,7 +451,20 @@ export function lockedCapture(runCaptureFn, config) {
248
451
  const enabled = isFullSuiteLockEnabled({ config });
249
452
  return (captureOpts = {}) =>
250
453
  withFullSuiteLockSync(
251
- { cwd: captureOpts.cwd, log: captureOpts.log, enabled },
454
+ {
455
+ cwd: captureOpts.cwd,
456
+ log: captureOpts.log,
457
+ enabled,
458
+ // Story #5278 — the capture path supplies its own freshness re-probe
459
+ // per call, because only it knows which scope ('full' /
460
+ // 'incremental') the stamp has to satisfy. A `true` means the suite
461
+ // we queued behind already stamped this tree, so this caller reports
462
+ // success (exit 0) without spawning a second one.
463
+ skipIfSatisfied:
464
+ typeof captureOpts.recheckFresh === 'function'
465
+ ? () => (captureOpts.recheckFresh() ? 0 : undefined)
466
+ : undefined,
467
+ },
252
468
  () => runCaptureFn(captureOpts),
253
469
  );
254
470
  }
@@ -264,7 +480,7 @@ export function lockedCapture(runCaptureFn, config) {
264
480
  * @template T
265
481
  * @param {Parameters<typeof withFullSuiteLockSync>[0] & {
266
482
  * acquireWithWaitFn?: typeof acquireLockWithWait,
267
- * }} opts
483
+ * }} opts `skipIfSatisfied` behaves exactly as in the sync wrapper.
268
484
  * @param {() => Promise<T>} spawn
269
485
  * @returns {Promise<T>}
270
486
  */
@@ -280,6 +496,7 @@ export async function withFullSuiteLockAsync(
280
496
  acquireOnceFn = acquireSweepLock,
281
497
  acquireWithWaitFn = acquireLockWithWait,
282
498
  lockPath: explicitLockPath,
499
+ skipIfSatisfied,
283
500
  },
284
501
  spawn,
285
502
  ) {
@@ -293,17 +510,26 @@ export async function withFullSuiteLockAsync(
293
510
  lockPath: explicitLockPath,
294
511
  });
295
512
  let held = lock;
513
+ let waited = false;
296
514
  if (held === null && lockPath !== null) {
297
- const waited = await acquireWithWaitFn({
515
+ waited = true;
516
+ const attempt = await acquireWithWaitFn({
298
517
  lockPath,
299
518
  waitMs,
300
519
  pollMs,
301
520
  timeoutMs: staleMs,
302
521
  fsImpl,
303
522
  });
304
- if (waited.acquired) held = waited;
523
+ if (attempt.acquired) held = attempt;
305
524
  }
306
525
  try {
526
+ const probe = probeAlreadySatisfied(skipIfSatisfied, waited);
527
+ if (probe.satisfied) {
528
+ log(
529
+ '[full-suite-lock] ⏭ the run we waited for already covered this tree — skipping the spawn.',
530
+ );
531
+ return probe.value;
532
+ }
307
533
  return await spawn();
308
534
  } finally {
309
535
  if (held?.acquired) held.release();