mandrel 2.36.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 (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -15,14 +15,39 @@
15
15
  *
16
16
  * The manifest itself lives under `.worktrees/`, which is already
17
17
  * git-ignored; no tracked state is mutated.
18
+ *
19
+ * **Story #5112 — the manifest is shared mutable state.** `recordPendingCleanup`
20
+ * runs from the close reap and `drainPendingCleanup` runs from the `/plan`
21
+ * boot, so two concurrent delivery sessions can read-modify-write the same
22
+ * file. Three properties close that:
23
+ *
24
+ * 1. **Atomic write.** Every write lands in a pid-scoped temp file and is
25
+ * then `rename`d over the manifest, so a reader never observes a half-
26
+ * written array (`readManifest` treats a torn or absent file as empty,
27
+ * which used to silently *drop* the backlog).
28
+ * 2. **Mutual exclusion.** Read-modify-write runs under the shared
29
+ * `acquireSweepLock` primitive on a manifest-adjacent lockfile.
30
+ * 3. **Merge before write.** The drain holds its snapshot across awaited
31
+ * removals, so it re-reads and merges by `storyId` before its final
32
+ * write — an entry recorded mid-drain survives instead of being erased
33
+ * by a stale snapshot.
18
34
  */
19
35
 
20
36
  import fs from 'node:fs';
21
37
  import { rm as fsPromisesRm } from 'node:fs/promises';
22
38
  import path from 'node:path';
23
39
  import { NOOP_LOGGER } from '../../Logger.js';
40
+ import { acquireSweepLock } from '../../single-story-sweep/sweep-lock.js';
24
41
 
25
42
  const MANIFEST_FILENAME = '.pending-cleanup.json';
43
+ const MANIFEST_LOCK_FILENAME = '.pending-cleanup.lock';
44
+
45
+ /**
46
+ * Staleness threshold for the manifest lock. Deliberately short: the guarded
47
+ * section is a read, an in-memory merge and a rename — milliseconds — so a
48
+ * lockfile older than this is a crashed holder, not a slow one.
49
+ */
50
+ const MANIFEST_LOCK_TIMEOUT_MS = 5_000;
26
51
  /**
27
52
  * After a reap hands off to the manifest, `attempts` counts failed
28
53
  * `drainPendingCleanup` passes (initial hand-off uses `attempts: 0`). The entry
@@ -35,6 +60,49 @@ export function manifestPath(worktreeRoot) {
35
60
  return path.join(worktreeRoot, MANIFEST_FILENAME);
36
61
  }
37
62
 
63
+ /**
64
+ * Path of the lockfile guarding manifest read-modify-write. Manifest-adjacent
65
+ * (same gitignored `.worktrees/` directory) so it needs no extra config and
66
+ * shares the manifest's lifetime. Module-private: the lock is internal to
67
+ * this manifest's read-modify-write, and callers only ever observe that a
68
+ * mutation completed and left nothing behind.
69
+ *
70
+ * @param {string} worktreeRoot
71
+ * @returns {string}
72
+ */
73
+ function manifestLockPath(worktreeRoot) {
74
+ return path.join(worktreeRoot, MANIFEST_LOCK_FILENAME);
75
+ }
76
+
77
+ /**
78
+ * Run `fn` under the manifest lock. Best-effort in the same sense as every
79
+ * other use of this primitive: on contention the work still runs (the
80
+ * merge-before-write below is what makes a lost race non-destructive), so a
81
+ * stuck lockfile can never wedge a reap. Always releases.
82
+ *
83
+ * @template T
84
+ * @param {string} worktreeRoot
85
+ * @param {() => T} fn
86
+ * @returns {T}
87
+ */
88
+ function withManifestLock(worktreeRoot, fn) {
89
+ const lock = acquireSweepLock({
90
+ lockPath: manifestLockPath(worktreeRoot),
91
+ timeoutMs: MANIFEST_LOCK_TIMEOUT_MS,
92
+ });
93
+ try {
94
+ return fn();
95
+ } finally {
96
+ if (lock.acquired) {
97
+ try {
98
+ lock.release();
99
+ } catch {
100
+ // Release is best-effort; a stale lockfile expires on its own.
101
+ }
102
+ }
103
+ }
104
+ }
105
+
38
106
  export function readManifest(worktreeRoot) {
39
107
  const p = manifestPath(worktreeRoot);
40
108
  if (!fs.existsSync(p)) return [];
@@ -47,6 +115,13 @@ export function readManifest(worktreeRoot) {
47
115
  }
48
116
  }
49
117
 
118
+ /**
119
+ * Write the manifest atomically: serialize into a pid-scoped temp file in the
120
+ * same directory (so `rename` stays within one filesystem and is therefore
121
+ * atomic), then rename it over the manifest. A concurrent reader sees either
122
+ * the whole previous manifest or the whole new one — never a truncated array
123
+ * that `readManifest`'s catch would silently turn into "no pending cleanups".
124
+ */
50
125
  function writeManifest(worktreeRoot, entries) {
51
126
  const p = manifestPath(worktreeRoot);
52
127
  if (!Array.isArray(entries) || entries.length === 0) {
@@ -58,7 +133,37 @@ function writeManifest(worktreeRoot, entries) {
58
133
  return;
59
134
  }
60
135
  fs.mkdirSync(worktreeRoot, { recursive: true });
61
- fs.writeFileSync(p, `${JSON.stringify(entries, null, 2)}\n`, 'utf8');
136
+ writeFileAtomic(p, `${JSON.stringify(entries, null, 2)}\n`);
137
+ }
138
+
139
+ /**
140
+ * Write `contents` to `targetPath` atomically: serialize into a pid-scoped
141
+ * temp file beside it (same directory, so the rename stays within one
142
+ * filesystem and is therefore atomic), then rename it into place. On failure
143
+ * the temp file is reaped and the error propagates — a half-written file is
144
+ * never left where a reader could pick it up.
145
+ *
146
+ * @param {string} targetPath
147
+ * @param {string} contents
148
+ */
149
+ function writeFileAtomic(targetPath, contents) {
150
+ const tmp = `${targetPath}.${process.pid}.tmp`;
151
+ try {
152
+ fs.writeFileSync(tmp, contents, 'utf8');
153
+ fs.renameSync(tmp, targetPath);
154
+ } catch (err) {
155
+ reapTempFile(tmp);
156
+ throw err;
157
+ }
158
+ }
159
+
160
+ /** Best-effort removal of an abandoned atomic-write temp file. */
161
+ function reapTempFile(tmp) {
162
+ try {
163
+ fs.unlinkSync(tmp);
164
+ } catch {
165
+ // Temp file already gone (or never created) — nothing to reap.
166
+ }
62
167
  }
63
168
 
64
169
  /**
@@ -71,38 +176,45 @@ export function recordPendingCleanup(
71
176
  worktreeRoot,
72
177
  { storyId, branch, path: wtPath, push = false },
73
178
  ) {
74
- const now = new Date().toISOString();
75
- const entries = readManifest(worktreeRoot);
76
- const idx = entries.findIndex((e) => e.storyId === storyId);
77
- if (idx >= 0) {
78
- entries[idx] = {
79
- ...entries[idx],
80
- branch,
81
- path: wtPath,
82
- push,
83
- lastFailedAt: now,
84
- attempts: (entries[idx].attempts ?? 0) + 1,
85
- };
86
- } else {
87
- entries.push({
88
- storyId,
89
- branch,
90
- path: wtPath,
91
- push,
92
- firstFailedAt: now,
93
- lastFailedAt: now,
94
- attempts: 0,
95
- });
96
- }
97
- writeManifest(worktreeRoot, entries);
98
- return entries.find((e) => e.storyId === storyId);
179
+ // The read, the merge and the write are one critical section: without the
180
+ // lock a concurrent drain's write can land between our read and our write
181
+ // and erase this hand-off entirely.
182
+ return withManifestLock(worktreeRoot, () => {
183
+ const now = new Date().toISOString();
184
+ const entries = readManifest(worktreeRoot);
185
+ const idx = entries.findIndex((e) => e.storyId === storyId);
186
+ if (idx >= 0) {
187
+ entries[idx] = {
188
+ ...entries[idx],
189
+ branch,
190
+ path: wtPath,
191
+ push,
192
+ lastFailedAt: now,
193
+ attempts: (entries[idx].attempts ?? 0) + 1,
194
+ };
195
+ } else {
196
+ entries.push({
197
+ storyId,
198
+ branch,
199
+ path: wtPath,
200
+ push,
201
+ firstFailedAt: now,
202
+ lastFailedAt: now,
203
+ attempts: 0,
204
+ });
205
+ }
206
+ writeManifest(worktreeRoot, entries);
207
+ return entries.find((e) => e.storyId === storyId);
208
+ });
99
209
  }
100
210
 
101
211
  export function removePendingCleanup(worktreeRoot, storyId) {
102
- const entries = readManifest(worktreeRoot).filter(
103
- (e) => e.storyId !== storyId,
104
- );
105
- writeManifest(worktreeRoot, entries);
212
+ withManifestLock(worktreeRoot, () => {
213
+ const entries = readManifest(worktreeRoot).filter(
214
+ (e) => e.storyId !== storyId,
215
+ );
216
+ writeManifest(worktreeRoot, entries);
217
+ });
106
218
  }
107
219
 
108
220
  async function removeStuckWorktreePath(wtPath, { git, repoRoot, fsRm }) {
@@ -176,6 +288,32 @@ async function retryStage1ForEntry(entry, ctx) {
176
288
  return { success: true, ...cleanup };
177
289
  }
178
290
 
291
+ /**
292
+ * Write the drain's result, merged against whatever the manifest holds *now*.
293
+ *
294
+ * The drain awaits a removal per entry, so its `entries` snapshot can be
295
+ * minutes old by the time it writes. Writing that snapshot back wholesale
296
+ * erased any entry a concurrent `recordPendingCleanup` added mid-drain — the
297
+ * exact hand-off that reap had just decided it could not complete. Merging by
298
+ * `storyId` keeps it: rows this pass re-computed win, rows it successfully
299
+ * drained are dropped, and everything else the manifest has gained survives.
300
+ *
301
+ * @param {string} worktreeRoot
302
+ * @param {object[]} next Rows this drain re-computed (still pending).
303
+ * @param {Set<number|string>} drainedIds Story ids this drain cleared.
304
+ */
305
+ function commitDrainedManifest(worktreeRoot, next, drainedIds) {
306
+ withManifestLock(worktreeRoot, () => {
307
+ const byId = new Map(next.map((entry) => [entry.storyId, entry]));
308
+ for (const entry of readManifest(worktreeRoot)) {
309
+ if (byId.has(entry.storyId)) continue;
310
+ if (drainedIds.has(entry.storyId)) continue;
311
+ byId.set(entry.storyId, entry);
312
+ }
313
+ writeManifest(worktreeRoot, [...byId.values()]);
314
+ });
315
+ }
316
+
179
317
  /**
180
318
  * Drain the pending-cleanup manifest: for each entry, retry Stage 1
181
319
  * cleanup. Successful entries are removed; failing entries have their
@@ -252,7 +390,7 @@ export async function drainPendingCleanup({
252
390
  next.push(updated);
253
391
  }
254
392
 
255
- writeManifest(worktreeRoot, next);
393
+ commitDrainedManifest(worktreeRoot, next, new Set(drained));
256
394
  return {
257
395
  drained,
258
396
  drainedDetails,
@@ -401,10 +401,7 @@ export function describeAttemptFailure(result, timeoutMs) {
401
401
  }
402
402
 
403
403
  /** Relative path of the per-machine pnpm-store prime sentinel (under tempRoot). */
404
- export const PNPM_STORE_PRIME_SENTINEL = path.join(
405
- 'temp',
406
- '.pnpm-store-primed',
407
- );
404
+ const PNPM_STORE_PRIME_SENTINEL = path.join('temp', '.pnpm-store-primed');
408
405
 
409
406
  /**
410
407
  * Pure: one-time per-machine pnpm content-addressable-store prime.
@@ -424,7 +421,7 @@ export const PNPM_STORE_PRIME_SENTINEL = path.join(
424
421
  *
425
422
  * @returns {{ primed: 'primed' | 'cached' | 'failed' | 'skipped', reason?: string }}
426
423
  */
427
- export function primePnpmStore({
424
+ function primePnpmStore({
428
425
  strategy,
429
426
  repoRoot,
430
427
  logger,