mandrel 2.35.0 → 2.37.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 (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -23,6 +23,22 @@
23
23
  * The lock is never load-bearing: the caller (`single-story-init.js`)
24
24
  * skips the sweep when the lock is contended and continues with init.
25
25
  * That matches the existing "sweep never blocks init" contract.
26
+ *
27
+ * **Story #5112 — a live holder is never mistaken for a crashed one.**
28
+ * The critical section this guards (per-candidate `gh pr view`, `getTicket`,
29
+ * `push --delete`) is unbounded, so a healthy sweep can easily outlive the
30
+ * 60 s staleness threshold. Three changes close that:
31
+ *
32
+ * 1. **Heartbeat.** The holder refreshes the lockfile mtime on an
33
+ * unref'd interval below `timeoutMs`, so an alive holder never reads
34
+ * stale no matter how long its critical section runs.
35
+ * 2. **Identity-checked steal.** A stale-breaker re-stats before it
36
+ * unlinks and only removes the *exact* file it observed (same
37
+ * dev/ino/mtime). Two concurrent breakers therefore yield exactly one
38
+ * acquisition — the loser cannot unlink the winner's fresh lockfile.
39
+ * 3. **Owner-checked release.** `release()` unlinks only a lockfile whose
40
+ * owner line still matches this holder, so a late release never drops
41
+ * someone else's lock.
26
42
  */
27
43
 
28
44
  import fs from 'node:fs';
@@ -31,18 +47,135 @@ import path from 'node:path';
31
47
  const DEFAULT_TIMEOUT_MS = 60_000;
32
48
 
33
49
  /**
34
- * Pure: read the lockfile mtime. Returns `null` when the file is
35
- * absent or stat fails (treat as "no holder"). Exported for tests.
50
+ * Floor for the heartbeat interval. Below this the refresh cost starts to
51
+ * matter for a short critical section, and no `timeoutMs` this framework
52
+ * configures is small enough to need it.
36
53
  */
37
- export function readLockMtime(lockPath, fsImpl = fs) {
54
+ const MIN_HEARTBEAT_MS = 1_000;
55
+
56
+ /**
57
+ * Divisor applied to `timeoutMs` to derive the default heartbeat interval.
58
+ * Three refreshes per staleness window means two consecutive missed refreshes
59
+ * (a stalled event loop, a slow disk) still do not make a live holder look
60
+ * dead.
61
+ */
62
+ const HEARTBEAT_DIVISOR = 3;
63
+
64
+ /**
65
+ * Canonical filename for the merged-branch sweep lock. One critical section
66
+ * (`sweepMergedBranches` over `story-*`) reached by two entry points —
67
+ * `single-story-init.js` and `boot-sweep.js` — so it gets one lockfile.
68
+ * Before Story #5112 they used `single-story-sweep.lock` and
69
+ * `boot-sweep.lock` respectively and could therefore run the same reap
70
+ * concurrently, each acting on branches the other was mid-delete on. Callers
71
+ * resolve it through {@link resolveSweepLockPath} rather than by name.
72
+ */
73
+ const MERGED_BRANCH_SWEEP_LOCK_FILENAME = 'merged-branch-sweep.lock';
74
+
75
+ /**
76
+ * Resolve the one merged-branch sweep lock path. Both sweep entry points
77
+ * MUST route through this helper — that is what makes "one critical section,
78
+ * one lock" checkable rather than a convention two files can silently drift
79
+ * apart on.
80
+ *
81
+ * @param {{ cwd: string, tempRoot?: string }} args
82
+ * @returns {string} absolute path to the shared lockfile.
83
+ */
84
+ export function resolveSweepLockPath({ cwd, tempRoot = 'temp' } = {}) {
85
+ return path.resolve(cwd, tempRoot, MERGED_BRANCH_SWEEP_LOCK_FILENAME);
86
+ }
87
+
88
+ /**
89
+ * Derive the heartbeat interval for a given staleness threshold. Module-
90
+ * private: the contract that matters ("strictly below `timeoutMs`") is
91
+ * observable at the `setIntervalFn` seam {@link acquireSweepLock} accepts, so
92
+ * a test pins it there rather than reaching past the public surface.
93
+ *
94
+ * @param {number} timeoutMs
95
+ * @returns {number}
96
+ */
97
+ function heartbeatIntervalFor(timeoutMs) {
98
+ const derived = Math.floor(
99
+ (Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS) /
100
+ HEARTBEAT_DIVISOR,
101
+ );
102
+ return Math.max(MIN_HEARTBEAT_MS, derived);
103
+ }
104
+
105
+ /**
106
+ * Read the lockfile's *identity* — the tuple that distinguishes "the file I
107
+ * observed" from "a different file that now sits at the same path". `dev` +
108
+ * `ino` change when a lockfile is unlinked and re-created, and `mtimeMs`
109
+ * changes on every heartbeat, so a steal that re-checks all three cannot
110
+ * remove a lock some other process created (or refreshed) in the interim.
111
+ *
112
+ * Returns `null` when the file is absent or stat fails ("no holder").
113
+ *
114
+ * @param {string} lockPath
115
+ * @param {object} [fsImpl]
116
+ * Module-private: the two readers that need it (the stale takeover and the
117
+ * `readLockMtime` projection below) both live here, and nothing outside this
118
+ * primitive should be reasoning about a lockfile's inode.
119
+ *
120
+ * @returns {{ mtimeMs: number, ino: number|null, dev: number|null }|null}
121
+ */
122
+ function readLockIdentity(lockPath, fsImpl = fs) {
38
123
  try {
39
124
  const stat = fsImpl.statSync(lockPath);
40
- return stat.mtimeMs;
125
+ return {
126
+ mtimeMs: stat.mtimeMs,
127
+ ino: stat.ino ?? null,
128
+ dev: stat.dev ?? null,
129
+ };
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Pure: read the lockfile mtime. Returns `null` when the file is absent or
137
+ * stat fails (treat as "no holder"). The mtime projection of
138
+ * {@link readLockIdentity}. Exported for tests.
139
+ */
140
+ export function readLockMtime(lockPath, fsImpl = fs) {
141
+ return readLockIdentity(lockPath, fsImpl)?.mtimeMs ?? null;
142
+ }
143
+
144
+ /**
145
+ * Read the owner id a lockfile was created with (its first line). Returns
146
+ * `null` when the file is absent, unreadable, or empty. Module-private — the
147
+ * owner line is an implementation detail of this primitive; callers observe
148
+ * ownership through which acquire wins and which `release()` is a no-op.
149
+ *
150
+ * @param {string} lockPath
151
+ * @param {object} [fsImpl]
152
+ * @returns {string|null}
153
+ */
154
+ function readLockOwner(lockPath, fsImpl = fs) {
155
+ try {
156
+ const raw = fsImpl.readFileSync(lockPath, 'utf8');
157
+ const first = String(raw).split('\n', 1)[0];
158
+ return first.length > 0 ? first : null;
41
159
  } catch {
42
160
  return null;
43
161
  }
44
162
  }
45
163
 
164
+ /**
165
+ * Pure: do two identity tuples describe the same lockfile instance? A `null`
166
+ * on either side is "not the same" — an absent file is never the file we
167
+ * observed. Module-private, like {@link readLockIdentity} it compares:
168
+ * nothing outside this primitive should reason about a lockfile's inode.
169
+ *
170
+ * @param {ReturnType<typeof readLockIdentity>} a
171
+ * @param {ReturnType<typeof readLockIdentity>} b
172
+ * @returns {boolean}
173
+ */
174
+ function sameLockIdentity(a, b) {
175
+ if (!a || !b) return false;
176
+ return a.mtimeMs === b.mtimeMs && a.ino === b.ino && a.dev === b.dev;
177
+ }
178
+
46
179
  /**
47
180
  * Pure: is the lockfile mtime older than `timeoutMs`? A `null` mtime
48
181
  * (no file) returns `false` — the lock isn't held, there's nothing to
@@ -103,6 +236,11 @@ function tryCreateLock(lockPath, ownerId, fsImpl = fs) {
103
236
  * @param {object} [opts.nowFn] `() => number` (ms epoch);
104
237
  * injection seam for tests.
105
238
  * @param {object} [opts.fsImpl] Node `fs` shim for tests.
239
+ * @param {number} [opts.heartbeatMs] Mtime-refresh interval for a live
240
+ * holder; defaults to a third of
241
+ * `timeoutMs`. `0` disables it.
242
+ * @param {Function} [opts.setIntervalFn] Timer seam for tests.
243
+ * @param {Function} [opts.clearIntervalFn] Timer seam for tests.
106
244
  * @returns {{ acquired: true, release: () => void, ownerId: string }
107
245
  * | { acquired: false, reason: 'contended' | 'error', detail?: string }}
108
246
  */
@@ -112,6 +250,9 @@ export function acquireSweepLock({
112
250
  ownerId,
113
251
  nowFn = Date.now,
114
252
  fsImpl = fs,
253
+ heartbeatMs,
254
+ setIntervalFn = setInterval,
255
+ clearIntervalFn = clearInterval,
115
256
  } = {}) {
116
257
  if (typeof lockPath !== 'string' || lockPath.length === 0) {
117
258
  return {
@@ -121,22 +262,21 @@ export function acquireSweepLock({
121
262
  };
122
263
  }
123
264
  const id = ownerId ?? `pid-${process.pid}-${nowFn()}`;
265
+ const holder = {
266
+ lockPath,
267
+ ownerId: id,
268
+ fsImpl,
269
+ nowFn,
270
+ heartbeatMs: heartbeatMs ?? heartbeatIntervalFor(timeoutMs),
271
+ setIntervalFn,
272
+ clearIntervalFn,
273
+ };
124
274
  try {
125
- if (tryCreateLock(lockPath, id, fsImpl)) {
126
- return buildAcquired(lockPath, id, fsImpl);
127
- }
128
- // Contended. Check for stale and retry once if so.
129
- const mtime = readLockMtime(lockPath, fsImpl);
130
- if (isLockStale(mtime, nowFn(), timeoutMs)) {
131
- try {
132
- fsImpl.unlinkSync(lockPath);
133
- } catch {
134
- // Race: another holder may have refreshed the lock between
135
- // our stat and our unlink. Fall through and report contended.
136
- }
137
- if (tryCreateLock(lockPath, id, fsImpl)) {
138
- return buildAcquired(lockPath, id, fsImpl);
139
- }
275
+ if (
276
+ tryCreateLock(lockPath, id, fsImpl) ||
277
+ tryStaleTakeover(holder, timeoutMs)
278
+ ) {
279
+ return buildAcquired(holder);
140
280
  }
141
281
  return { acquired: false, reason: 'contended' };
142
282
  } catch (err) {
@@ -148,16 +288,126 @@ export function acquireSweepLock({
148
288
  }
149
289
  }
150
290
 
151
- function buildAcquired(lockPath, ownerId, fsImpl) {
291
+ /**
292
+ * Take over a lockfile whose holder looks dead. Returns `true` only when this
293
+ * call both removed the exact stale file it observed *and* won the re-create,
294
+ * so two concurrent breakers yield exactly one acquisition.
295
+ *
296
+ * @param {{ lockPath: string, ownerId: string, fsImpl: object, nowFn: () => number }} holder
297
+ * @param {number} timeoutMs
298
+ * @returns {boolean}
299
+ */
300
+ function tryStaleTakeover({ lockPath, ownerId, fsImpl, nowFn }, timeoutMs) {
301
+ const observed = readLockIdentity(lockPath, fsImpl);
302
+ if (observed === null) return false;
303
+ if (!isLockStale(observed.mtimeMs, nowFn(), timeoutMs)) return false;
304
+ return (
305
+ breakStaleLock(lockPath, observed, fsImpl) &&
306
+ tryCreateLock(lockPath, ownerId, fsImpl)
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Unlink a stale lockfile — but only when it is still byte-for-byte the
312
+ * instance the caller observed. Returns `true` when this call removed that
313
+ * exact file, `false` when the file changed underneath us (a heartbeat, or
314
+ * another breaker's replacement) or the unlink failed. A `false` return means
315
+ * "someone else owns this now": the caller reports contended rather than
316
+ * racing on.
317
+ *
318
+ * @param {string} lockPath
319
+ * @param {ReturnType<typeof readLockIdentity>} observed
320
+ * @param {object} fsImpl
321
+ * @returns {boolean}
322
+ */
323
+ function breakStaleLock(lockPath, observed, fsImpl) {
324
+ if (!sameLockIdentity(observed, readLockIdentity(lockPath, fsImpl))) {
325
+ return false;
326
+ }
327
+ try {
328
+ fsImpl.unlinkSync(lockPath);
329
+ return true;
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Refresh a held lockfile's mtime so a long critical section never reads
337
+ * stale to a concurrent acquirer. Refuses to touch a lockfile whose owner
338
+ * line is no longer ours — after a steal the file belongs to someone else and
339
+ * bumping its mtime would keep *their* lock alive on our behalf.
340
+ *
341
+ * @returns {boolean} `true` when the refresh landed; `false` when the lock is
342
+ * no longer ours (the caller stops heartbeating).
343
+ */
344
+ function refreshLockMtime({ lockPath, ownerId, fsImpl, nowFn }) {
345
+ if (readLockOwner(lockPath, fsImpl) !== ownerId) return false;
346
+ try {
347
+ const stamp = new Date(nowFn());
348
+ fsImpl.utimesSync(lockPath, stamp, stamp);
349
+ return true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Start the holder's mtime heartbeat. Returns a `stop()` that is safe to call
357
+ * repeatedly. The timer is unref'd where the platform supports it, so a
358
+ * forgotten release can never hold the process open.
359
+ */
360
+ function startHeartbeat(holder) {
361
+ const { heartbeatMs, setIntervalFn, clearIntervalFn } = holder;
362
+ if (!(heartbeatMs > 0) || typeof setIntervalFn !== 'function') {
363
+ return () => {};
364
+ }
365
+ let timer = null;
366
+ const stop = () => {
367
+ if (timer === null) return;
368
+ const handle = timer;
369
+ timer = null;
370
+ try {
371
+ clearIntervalFn(handle);
372
+ } catch {
373
+ // Best-effort: a fake timer seam may not implement clear.
374
+ }
375
+ };
376
+ timer = setIntervalFn(() => {
377
+ if (!refreshLockMtime(holder)) stop();
378
+ }, heartbeatMs);
379
+ if (timer && typeof timer.unref === 'function') timer.unref();
380
+ return stop;
381
+ }
382
+
383
+ /**
384
+ * Drop a lockfile, but only when it is still stamped with `ownerId`. A
385
+ * lockfile another holder created after ours was stolen (or stale-broken) is
386
+ * theirs — dropping it would hand a third caller a lock the current holder
387
+ * still believes it owns.
388
+ *
389
+ * @param {string} lockPath
390
+ * @param {string} ownerId
391
+ * @param {object} fsImpl
392
+ */
393
+ function unlinkIfOwned(lockPath, ownerId, fsImpl) {
394
+ if (readLockOwner(lockPath, fsImpl) !== ownerId) return;
395
+ try {
396
+ fsImpl.unlinkSync(lockPath);
397
+ } catch {
398
+ // Already gone — nothing to do.
399
+ }
400
+ }
401
+
402
+ function buildAcquired(holder) {
403
+ const { lockPath, ownerId, fsImpl } = holder;
404
+ const stopHeartbeat = startHeartbeat(holder);
152
405
  let released = false;
153
406
  const release = () => {
154
407
  if (released) return;
155
408
  released = true;
156
- try {
157
- fsImpl.unlinkSync(lockPath);
158
- } catch {
159
- // Already gone — nothing to do.
160
- }
409
+ stopHeartbeat();
410
+ unlinkIfOwned(lockPath, ownerId, fsImpl);
161
411
  };
162
412
  // Belt-and-braces: process exit also clears the lockfile so a
163
413
  // crashed run doesn't leave a stale-but-not-yet-old artifact behind.
@@ -209,6 +459,9 @@ function defaultSleep(ms) {
209
459
  * @param {() => number} [opts.nowFn]
210
460
  * @param {(ms: number) => Promise<void>} [opts.sleepFn]
211
461
  * @param {object} [opts.fsImpl]
462
+ * @param {number} [opts.heartbeatMs] Forwarded to the underlying acquire.
463
+ * @param {Function} [opts.setIntervalFn] Forwarded to the underlying acquire.
464
+ * @param {Function} [opts.clearIntervalFn] Forwarded to the underlying acquire.
212
465
  * @returns {Promise<{ acquired: true, release: () => void, ownerId: string }
213
466
  * | { acquired: false, reason: 'contended-after-wait' | 'error', detail?: string }>}
214
467
  */
@@ -221,6 +474,9 @@ export async function acquireLockWithWait({
221
474
  nowFn = Date.now,
222
475
  sleepFn = defaultSleep,
223
476
  fsImpl = fs,
477
+ heartbeatMs,
478
+ setIntervalFn = setInterval,
479
+ clearIntervalFn = clearInterval,
224
480
  } = {}) {
225
481
  const deadline = nowFn() + Math.max(0, waitMs);
226
482
  for (;;) {
@@ -230,6 +486,9 @@ export async function acquireLockWithWait({
230
486
  ownerId,
231
487
  nowFn,
232
488
  fsImpl,
489
+ heartbeatMs,
490
+ setIntervalFn,
491
+ clearIntervalFn,
233
492
  });
234
493
  if (res.acquired) return res;
235
494
  // A hard error will not resolve by retrying — surface it immediately.
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  * @param {string} rootDir
14
14
  * @returns {string[]} absolute paths
15
15
  */
16
- export function walkSkillFiles(rootDir) {
16
+ function walkSkillFiles(rootDir) {
17
17
  const out = [];
18
18
  if (!fs.existsSync(rootDir)) return out;
19
19
  const stack = [rootDir];
@@ -0,0 +1,115 @@
1
+ // .agents/scripts/lib/source-text/strip-js-comments.js
2
+ /**
3
+ * The one string-literal-aware JavaScript comment stripper.
4
+ *
5
+ * Several guards in this repository grep source text for a pattern that must
6
+ * appear in real code rather than in a docblock — a CLI citation, an import
7
+ * specifier, a schema path. Each grew its own stripper, and the four
8
+ * implementations disagreed on what a stripped block comment leaves behind:
9
+ * one preserved newlines only, one deleted the comment outright (losing line
10
+ * numbers), one blanked it to whitespace, and one used a regex that did not
11
+ * honour string literals at all. A fix to any of them silently missed the
12
+ * others, and a guard whose own rationale mentions the pattern it greps for
13
+ * can satisfy itself if the stripping is wrong.
14
+ *
15
+ * **Semantics: comment bodies become equivalent whitespace.** Every character
16
+ * of a comment is replaced by a space except newlines, which survive. That is
17
+ * the most information-preserving of the four behaviours and the only one that
18
+ * is safe for all of them:
19
+ *
20
+ * - line and column positions are unchanged, so a guard can report a
21
+ * `file:line` that matches the original source;
22
+ * - a regex looking for code cannot match comment text, because none
23
+ * survives;
24
+ * - the output is the same length as the input, so byte offsets hold.
25
+ *
26
+ * String and template literals are copied through verbatim, so a `//` inside a
27
+ * URL or a `/*` inside a message is not mistaken for a comment opener. Escape
28
+ * sequences are honoured, so an escaped quote does not end the literal early.
29
+ *
30
+ * An unterminated comment or literal runs to end of input rather than
31
+ * throwing: this is a lint helper reading files that may be mid-edit, and a
32
+ * crash there would be a worse failure than a slightly over-stripped tail.
33
+ *
34
+ * Builtins only — no dependency, so the guards that run before a consumer's
35
+ * install can use it.
36
+ */
37
+
38
+ /**
39
+ * Replace every non-newline character of `text` with a space.
40
+ *
41
+ * @param {string} text
42
+ * @returns {string}
43
+ */
44
+ function blank(text) {
45
+ return text.replace(/[^\n]/g, ' ');
46
+ }
47
+
48
+ /**
49
+ * Index just past the string or template literal opening at `start`.
50
+ * Runs to end of input when the literal is never closed.
51
+ *
52
+ * @param {string} text
53
+ * @param {number} start - index of the opening quote
54
+ * @returns {number}
55
+ */
56
+ function endOfLiteral(text, start) {
57
+ const quote = text[start];
58
+ let i = start + 1;
59
+ while (i < text.length) {
60
+ const ch = text[i];
61
+ if (ch === '\\') {
62
+ i += 2;
63
+ continue;
64
+ }
65
+ if (ch === quote) return i + 1;
66
+ i += 1;
67
+ }
68
+ return text.length;
69
+ }
70
+
71
+ /**
72
+ * Strip JavaScript comments, replacing their bodies with equivalent
73
+ * whitespace so line numbers, columns and byte offsets all survive.
74
+ *
75
+ * @param {string} source - JavaScript source text. Nullish is treated as empty.
76
+ * @returns {string} the source with every comment body blanked to whitespace
77
+ */
78
+ export function stripJsComments(source) {
79
+ const text = String(source ?? '');
80
+ let out = '';
81
+ let i = 0;
82
+
83
+ while (i < text.length) {
84
+ const two = text.slice(i, i + 2);
85
+
86
+ if (two === '//') {
87
+ const newline = text.indexOf('\n', i);
88
+ const stop = newline === -1 ? text.length : newline;
89
+ out += blank(text.slice(i, stop));
90
+ i = stop;
91
+ continue;
92
+ }
93
+
94
+ if (two === '/*') {
95
+ const close = text.indexOf('*/', i + 2);
96
+ const stop = close === -1 ? text.length : close + 2;
97
+ out += blank(text.slice(i, stop));
98
+ i = stop;
99
+ continue;
100
+ }
101
+
102
+ const ch = text[i];
103
+ if (ch === "'" || ch === '"' || ch === '`') {
104
+ const stop = endOfLiteral(text, i);
105
+ out += text.slice(i, stop);
106
+ i = stop;
107
+ continue;
108
+ }
109
+
110
+ out += ch;
111
+ i += 1;
112
+ }
113
+
114
+ return out;
115
+ }
@@ -58,7 +58,7 @@ const NODE_TEST_BASE = [
58
58
  * @param {typeof spawn} [opts.spawnFn]
59
59
  * @returns {Promise<IsolatedResult>}
60
60
  */
61
- export function runFileIsolated({
61
+ function runFileIsolated({
62
62
  repoRoot,
63
63
  file,
64
64
  envOutDir,
@@ -195,7 +195,7 @@ function sanitizeEnv(source) {
195
195
  * @param {typeof spawn} [opts.spawnFn]
196
196
  * @returns {Promise<{ results: SuiteResult[], exitCode: number, stdout: string, stderr: string }>}
197
197
  */
198
- export function runSuite({
198
+ function runSuite({
199
199
  repoRoot,
200
200
  files,
201
201
  concurrency = 8,
@@ -266,7 +266,7 @@ export function runSuite({
266
266
  * @param {(r: IsolatedResult) => void} [opts.onResult]
267
267
  * @returns {Promise<IsolatedResult[]>}
268
268
  */
269
- export async function runIsolatedPool({
269
+ async function runIsolatedPool({
270
270
  repoRoot,
271
271
  files,
272
272
  envOutDir,
@@ -90,13 +90,20 @@ export const TEST_RUNNER_FLAGS = Object.freeze([
90
90
  * the spawns `runTierPreflight` actually issues — which is the thing that
91
91
  * has to be true.
92
92
  */
93
+ const STATE_PROBE_ONLY = Object.freeze(['.agents/scripts/test-wrapper.js']);
94
+
93
95
  const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
94
96
  full: Object.freeze([
95
- '.agents/scripts/test-wrapper.js',
97
+ ...STATE_PROBE_ONLY,
96
98
  '.agents/scripts/validate-skills.js',
97
99
  ]),
98
- quick: Object.freeze(['.agents/scripts/test-wrapper.js']),
99
- integration: Object.freeze(['.agents/scripts/test-wrapper.js']),
100
+ quick: STATE_PROBE_ONLY,
101
+ integration: STATE_PROBE_ONLY,
102
+ // Story #5111: the `e2e` tier gets the state probe like every other
103
+ // non-full tier. An absent entry is not a neutral default — the lookup
104
+ // below falls through to `[]`, so a tier missing from this map runs *no*
105
+ // preflight at all and the omission is invisible in a green run.
106
+ e2e: STATE_PROBE_ONLY,
100
107
  });
101
108
 
102
109
  /**
@@ -108,7 +115,7 @@ const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
108
115
  * executes, which the npm hook does not under `ignore-scripts=true`.
109
116
  *
110
117
  * @param {object} [opts]
111
- * @param {'full' | 'quick' | 'integration'} [opts.tier]
118
+ * @param {'full' | 'quick' | 'integration' | 'e2e'} [opts.tier]
112
119
  * @param {string} [opts.repoRoot] Absolute repository root.
113
120
  * @param {typeof spawnSync} [opts.spawn] Injected in tests.
114
121
  * @param {string} [opts.execPath] Node binary to spawn; injected in tests.
@@ -127,8 +134,9 @@ export function runTierPreflight({
127
134
  stdio: 'inherit',
128
135
  });
129
136
  if (run.error) throw run.error;
130
- const status = run.status ?? 1;
131
- if (status !== 0) return status;
137
+ // A signal-killed child reports `status: null`; treat that as the
138
+ // reserved failure code rather than letting it read as "not zero, no code".
139
+ if (run.status !== 0) return run.status ?? 1;
132
140
  }
133
141
  return 0;
134
142
  }