mandrel 1.62.0 → 1.63.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 (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. package/package.json +1 -1
@@ -38,6 +38,42 @@ import { runCloseValidation as defaultRunCloseValidation } from '../../close-val
38
38
  import { getBaselines as defaultGetBaselines } from '../../config-resolver.js';
39
39
  import { Logger as DefaultLogger } from '../../Logger.js';
40
40
 
41
+ /**
42
+ * Story #2250 — lifecycle emits fire only when both a positive epicId and a
43
+ * positive storyId are present (the schema requires both) and a bus exists.
44
+ * Legacy resume fixtures pass `storyId: null` and must run without emits.
45
+ * Story #4075 — extracted from `runPreMergeGates`.
46
+ */
47
+ export function lifecycleEmitsActive({ epicId, storyId, bus }) {
48
+ return (
49
+ Number.isInteger(epicId) &&
50
+ epicId > 0 &&
51
+ Number.isInteger(storyId) &&
52
+ storyId > 0 &&
53
+ !!bus
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Build the typed `PRE_MERGE_GATE_FAILED` Error from the first failed gate.
59
+ * Story #2136 / Task #2143 — failure metadata is surfaced as typed Error
60
+ * properties so callers (notably `runPreMergeGatesWithAttribution`)
61
+ * pattern-match exit codes without parsing the human message; the message
62
+ * format is preserved byte-for-byte so existing regex consumers keep
63
+ * matching. Story #4075 — extracted from `runPreMergeGates`.
64
+ */
65
+ export function buildGateFailureError({ gate, status, gateCwd }) {
66
+ const err = new Error(
67
+ `Pre-merge validation failed at "${gate.name}" (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
68
+ (gate.hint ? ` ${gate.hint}` : ''),
69
+ );
70
+ err.code = 'PRE_MERGE_GATE_FAILED';
71
+ err.gateName = gate.name;
72
+ err.exitCode = status;
73
+ err.gateCwd = gateCwd ?? null;
74
+ return err;
75
+ }
76
+
41
77
  /**
42
78
  * Run the pre-merge validation gate chain. On failure throws an `Error`
43
79
  * whose message embeds the first failed gate's name, exit code, hint, and
@@ -103,13 +139,28 @@ export async function runPreMergeGates({
103
139
  // and a storyId are present; the schema requires both, and unit
104
140
  // fixtures that drive the helper with `storyId: null` (legacy resume
105
141
  // tests) must continue to operate without lifecycle observability.
106
- const emitsActive =
107
- Number.isInteger(epicId) &&
108
- epicId > 0 &&
109
- Number.isInteger(storyId) &&
110
- storyId > 0 &&
111
- !!bus;
142
+ const emitsActive = lifecycleEmitsActive({ epicId, storyId, bus });
112
143
  const startedAt = typeof now === 'function' ? now() : Date.now();
144
+
145
+ // Emit the `close-validate.end` boundary, plus the `story.blocked`
146
+ // cascade trigger on failure (Story #2250). No-op when emits are
147
+ // inactive (legacy resume fixtures with `storyId: null`).
148
+ const emitEnd = async ({ ok, extra = {}, blockedReason } = {}) => {
149
+ if (!emitsActive) return;
150
+ const endedAt = typeof now === 'function' ? now() : Date.now();
151
+ await bus.emit('close-validate.end', {
152
+ epicId,
153
+ storyId,
154
+ ok,
155
+ gateCount,
156
+ ...extra,
157
+ durationMs: Math.max(0, endedAt - startedAt),
158
+ });
159
+ if (blockedReason) {
160
+ await bus.emit('story.blocked', { storyId, reason: blockedReason });
161
+ }
162
+ };
163
+
113
164
  if (emitsActive) {
114
165
  await bus.emit('close-validate.start', { epicId, storyId });
115
166
  }
@@ -139,74 +190,27 @@ export async function runPreMergeGates({
139
190
  // `validation.failed` below). Emit the matching `close-validate.end`
140
191
  // with `ok:false` so the ledger always carries the boundary, then
141
192
  // re-throw.
142
- if (emitsActive) {
143
- const endedAt = typeof now === 'function' ? now() : Date.now();
144
- await bus.emit('close-validate.end', {
145
- epicId,
146
- storyId,
147
- ok: false,
148
- gateCount,
149
- failedGate: 'runner-error',
150
- durationMs: Math.max(0, endedAt - startedAt),
151
- });
152
- await bus.emit('story.blocked', {
153
- storyId,
154
- reason: 'close-validate-failed:runner-error',
155
- });
156
- }
193
+ await emitEnd({
194
+ ok: false,
195
+ extra: { failedGate: 'runner-error' },
196
+ blockedReason: 'close-validate-failed:runner-error',
197
+ });
157
198
  throw err;
158
199
  }
159
200
  if (!validation.ok) {
160
- const [first] = validation.failed;
161
- const { gate, status, cwd: gateCwd } = first;
162
- // Story #2250 emit `close-validate.end` with `ok:false` BEFORE
163
- // throwing so the lifecycle ledger captures the boundary even when
164
- // the caller's try/catch swallows the throw. Then emit
165
- // `story.blocked` with the typed `close-validate-failed:<gate>`
166
- // reason so the BlockerHandler listener cascades to `epic.blocked`
167
- // failed validators MUST route through the lifecycle cascade.
168
- if (emitsActive) {
169
- const endedAt = typeof now === 'function' ? now() : Date.now();
170
- await bus.emit('close-validate.end', {
171
- epicId,
172
- storyId,
173
- ok: false,
174
- gateCount,
175
- failedGate: gate.name,
176
- exitCode: status,
177
- durationMs: Math.max(0, endedAt - startedAt),
178
- });
179
- await bus.emit('story.blocked', {
180
- storyId,
181
- reason: `close-validate-failed:${gate.name}`,
182
- });
183
- }
184
- // Story #2136 / Task #2143 — surface the structured failure metadata
185
- // as typed properties on the Error so callers (notably
186
- // `runPreMergeGatesWithAttribution`) can pattern-match exit codes
187
- // without parsing the human message. The message format is preserved
188
- // byte-for-byte so the existing regex consumers in the wiring layer
189
- // keep matching.
190
- const err = new Error(
191
- `Pre-merge validation failed at "${gate.name}" (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
192
- (gate.hint ? ` ${gate.hint}` : ''),
193
- );
194
- err.code = 'PRE_MERGE_GATE_FAILED';
195
- err.gateName = gate.name;
196
- err.exitCode = status;
197
- err.gateCwd = gateCwd ?? null;
198
- throw err;
199
- }
200
- if (emitsActive) {
201
- const endedAt = typeof now === 'function' ? now() : Date.now();
202
- await bus.emit('close-validate.end', {
203
- epicId,
204
- storyId,
205
- ok: true,
206
- gateCount,
207
- durationMs: Math.max(0, endedAt - startedAt),
201
+ const { gate, status, cwd: gateCwd } = validation.failed[0];
202
+ // Story #2250 emit the boundary + `story.blocked` cascade BEFORE
203
+ // throwing so the lifecycle ledger captures the boundary even when the
204
+ // caller's try/catch swallows the throw, and the BlockerHandler
205
+ // listener cascades to `epic.blocked`.
206
+ await emitEnd({
207
+ ok: false,
208
+ extra: { failedGate: gate.name, exitCode: status },
209
+ blockedReason: `close-validate-failed:${gate.name}`,
208
210
  });
211
+ throw buildGateFailureError({ gate, status, gateCwd });
209
212
  }
213
+ await emitEnd({ ok: true });
210
214
  return validation;
211
215
  }
212
216
 
@@ -197,97 +197,127 @@ function detectUncommittedWorktree({
197
197
  *
198
198
  * @returns {{ phase: string, detail: object } | null}
199
199
  */
200
- function detectAlreadyMerged({ cwd, storyId, epicId, lsrOut, detail, git }) {
201
- if (!epicId) return null;
202
-
203
- const storyBranch = `story-${storyId}`;
204
- const epicRefs = ['origin/epic', `origin/epic/${epicId}`];
205
- const probeAncestor = (storyRef, epicRef) =>
206
- git.isAncestor(cwd, storyRef, epicRef)?.status === 0;
207
-
208
- let resolvedDetail = null;
209
-
210
- // a) local story branch still exists.
200
+ /**
201
+ * Probe (a): the local `story-<id>` branch still exists and is an ancestor
202
+ * of `origin/epic/<id>`. Returns the resolved-detail fragment or `null`.
203
+ * Story #4075 — extracted from `detectAlreadyMerged`.
204
+ */
205
+ function probeLocalStoryMerged({
206
+ cwd,
207
+ storyBranch,
208
+ epicId,
209
+ git,
210
+ probeAncestor,
211
+ }) {
211
212
  const localStoryRef = `refs/heads/${storyBranch}`;
212
- if (git.showRef && git.showRef(cwd, localStoryRef)?.status === 0) {
213
- const remoteEpicRef = `origin/epic/${epicId}`;
214
- if (probeAncestor(storyBranch, remoteEpicRef)) {
215
- resolvedDetail = { localStoryRef: storyBranch, remoteEpicRef };
216
- }
213
+ if (!(git.showRef && git.showRef(cwd, localStoryRef)?.status === 0)) {
214
+ return null;
217
215
  }
216
+ const remoteEpicRef = `origin/epic/${epicId}`;
217
+ return probeAncestor(storyBranch, remoteEpicRef)
218
+ ? { localStoryRef: storyBranch, remoteEpicRef }
219
+ : null;
220
+ }
218
221
 
219
- // b) remote story branch present and merged.
220
- if (!resolvedDetail && lsrOut.length > 0) {
221
- for (const epicRef of epicRefs) {
222
- if (probeAncestor(`origin/${storyBranch}`, epicRef)) {
223
- resolvedDetail = {
224
- remoteStoryRef: `origin/${storyBranch}`,
225
- remoteEpicRef: epicRef,
226
- };
227
- break;
228
- }
222
+ /**
223
+ * Probe (b): the remote `origin/story-<id>` ref is present and is an
224
+ * ancestor of an `origin/epic` ref. Story #4075 — extracted.
225
+ */
226
+ function probeRemoteStoryMerged({
227
+ storyBranch,
228
+ epicRefs,
229
+ lsrOut,
230
+ probeAncestor,
231
+ }) {
232
+ if (lsrOut.length === 0) return null;
233
+ for (const epicRef of epicRefs) {
234
+ if (probeAncestor(`origin/${storyBranch}`, epicRef)) {
235
+ return {
236
+ remoteStoryRef: `origin/${storyBranch}`,
237
+ remoteEpicRef: epicRef,
238
+ };
229
239
  }
230
240
  }
241
+ return null;
242
+ }
231
243
 
232
- // c) Rebased equivalents (Story #3161). Story tip is not an ancestor
233
- // of `origin/epic/<id>`, but every commit on the Story branch is
234
- // patch-equivalent (`git cherry`) to a commit already on the Epic.
235
- // Surfaces the manual-recovery case where the operator rebased
236
- // Story content directly onto `epic/<id>` so the diff is present
237
- // as commits with different SHAs and no `(resolves #<id>)` merge
238
- // commit. Without this branch, `assertMergeReachable` throws at
239
- // resume time and strands close at `agent::closing`.
240
- if (!resolvedDetail && git.cherry) {
241
- const candidates = [];
242
- const localStoryRefName = `refs/heads/${storyBranch}`;
243
- if (git.showRef && git.showRef(cwd, localStoryRefName)?.status === 0) {
244
- candidates.push({ ref: storyBranch, kind: 'local' });
245
- }
246
- if (lsrOut.length > 0) {
247
- candidates.push({ ref: `origin/${storyBranch}`, kind: 'remote' });
248
- }
249
- const remoteEpicRef = `origin/epic/${epicId}`;
250
- for (const cand of candidates) {
251
- const cherry = git.cherry(cwd, remoteEpicRef, cand.ref);
252
- if (!cherry || cherry.status !== 0) continue;
253
- const lines = (cherry.stdout ?? '')
254
- .toString()
255
- .split('\n')
256
- .map((l) => l.trim())
257
- .filter(Boolean);
258
- if (lines.length === 0) continue;
259
- if (lines.every((l) => l.startsWith('- '))) {
260
- resolvedDetail = {
261
- [cand.kind === 'local' ? 'localStoryRef' : 'remoteStoryRef']:
262
- cand.ref,
263
- remoteEpicRef,
264
- via: 'rebased-equivalents',
265
- equivalents: lines.length,
266
- };
267
- break;
268
- }
244
+ /**
245
+ * Probe (c): rebased equivalents (Story #3161). The Story tip is not an
246
+ * ancestor of `origin/epic/<id>`, but every commit on the Story branch is
247
+ * patch-equivalent (`git cherry`) to a commit already on the Epic — the
248
+ * manual-recovery case where the operator rebased Story content directly
249
+ * onto `epic/<id>`. Without this branch, `assertMergeReachable` throws at
250
+ * resume time and strands close at `agent::closing`. Story #4075 —
251
+ * extracted from `detectAlreadyMerged`.
252
+ */
253
+ function probeRebasedEquivalents({ cwd, storyBranch, epicId, lsrOut, git }) {
254
+ if (!git.cherry) return null;
255
+ const candidates = [];
256
+ const localStoryRefName = `refs/heads/${storyBranch}`;
257
+ if (git.showRef && git.showRef(cwd, localStoryRefName)?.status === 0) {
258
+ candidates.push({ ref: storyBranch, kind: 'local' });
259
+ }
260
+ if (lsrOut.length > 0) {
261
+ candidates.push({ ref: `origin/${storyBranch}`, kind: 'remote' });
262
+ }
263
+ const remoteEpicRef = `origin/epic/${epicId}`;
264
+ for (const cand of candidates) {
265
+ const cherry = git.cherry(cwd, remoteEpicRef, cand.ref);
266
+ if (!cherry || cherry.status !== 0) continue;
267
+ const lines = (cherry.stdout ?? '')
268
+ .toString()
269
+ .split('\n')
270
+ .map((l) => l.trim())
271
+ .filter(Boolean);
272
+ if (lines.length === 0) continue;
273
+ if (lines.every((l) => l.startsWith('- '))) {
274
+ return {
275
+ [cand.kind === 'local' ? 'localStoryRef' : 'remoteStoryRef']: cand.ref,
276
+ remoteEpicRef,
277
+ via: 'rebased-equivalents',
278
+ equivalents: lines.length,
279
+ };
269
280
  }
270
281
  }
282
+ return null;
283
+ }
271
284
 
272
- // d) Merge-commit-message scan (ref-independent; Story #3327 / Epic #3316).
273
- // Both the local `story-<id>` branch and the remote `origin/story-<id>`
274
- // ref were deleted by a prior partial close run, so branches a–c have no
275
- // ref to anchor on and fall through. Recover the already-merged signal
276
- // from the Epic history itself: locate the integration commit whose
277
- // subject carries `(resolves #<id>)` / `(refs #<id>)`. Without this
278
- // branch, detection falls to FRESH and the resumed close re-enters the
279
- // pre-merge gate chain, which crashes in the scoped format-autofix step on
280
- // `git diff <epicBranch>...story-<id>` because the Story ref is gone.
281
- if (!resolvedDetail) {
282
- const mc = findMergeCommitForStory({ cwd, storyId, epicId, git });
283
- if (mc) {
284
- resolvedDetail = {
285
+ /**
286
+ * Probe (d): ref-independent merge-commit-message scan (Story #3327 /
287
+ * Epic #3316). Both the local and remote Story refs were deleted by a prior
288
+ * partial close, so probes a–c have no ref to anchor on. Recover the
289
+ * already-merged signal from the Epic history itself by locating the
290
+ * integration commit whose subject carries `(resolves #<id>)` / `(refs
291
+ * #<id>)`. Story #4075 extracted from `detectAlreadyMerged`.
292
+ */
293
+ function probeMergeCommitMessage({ cwd, storyId, epicId, git }) {
294
+ const mc = findMergeCommitForStory({ cwd, storyId, epicId, git });
295
+ return mc
296
+ ? {
285
297
  via: 'merge-commit-message',
286
298
  mergeCommit: mc.sha,
287
299
  remoteEpicRef: mc.epicRef,
288
- };
289
- }
290
- }
300
+ }
301
+ : null;
302
+ }
303
+
304
+ function detectAlreadyMerged({ cwd, storyId, epicId, lsrOut, detail, git }) {
305
+ if (!epicId) return null;
306
+
307
+ const storyBranch = `story-${storyId}`;
308
+ const epicRefs = ['origin/epic', `origin/epic/${epicId}`];
309
+ const probeAncestor = (storyRef, epicRef) =>
310
+ git.isAncestor(cwd, storyRef, epicRef)?.status === 0;
311
+
312
+ // Any one signal is enough — the local branch may have been reaped while
313
+ // the remote survived (or vice versa), or both refs may be gone and only
314
+ // the Epic merge-commit message remains. Probes run in cheapest-first
315
+ // order; the first to resolve wins.
316
+ const resolvedDetail =
317
+ probeLocalStoryMerged({ cwd, storyBranch, epicId, git, probeAncestor }) ??
318
+ probeRemoteStoryMerged({ storyBranch, epicRefs, lsrOut, probeAncestor }) ??
319
+ probeRebasedEquivalents({ cwd, storyBranch, epicId, lsrOut, git }) ??
320
+ probeMergeCommitMessage({ cwd, storyId, epicId, git });
291
321
 
292
322
  if (!resolvedDetail) return null;
293
323
  return {
@@ -34,3 +34,110 @@ export function extractTool(rec) {
34
34
  }
35
35
  return null;
36
36
  }
37
+
38
+ /**
39
+ * Validate and normalize the shared detector argument preamble.
40
+ *
41
+ * `detectRework`, `detectRetry`, and `detectHotspot` previously shipped a
42
+ * near-identical guard block: the `args` object-shape `TypeError`, the
43
+ * `nowFn` function-type `TypeError`, the positive-integer `RangeError`s for
44
+ * the id fields, the non-empty-string `tracesPath` check, and the
45
+ * non-negative-integer `threshold` check. Story #4077 hoists that preamble
46
+ * here so the three detectors share one error-message contract.
47
+ *
48
+ * Error wording stays per-detector-accurate by prefixing every message with
49
+ * `fnName` (e.g. `detectRework: …`). Error *types* are preserved exactly:
50
+ * the `args`/`tracesPath`/`nowFn` guards throw `TypeError`; the id and
51
+ * `threshold` guards throw `RangeError`.
52
+ *
53
+ * The validated fields are gated by the `require*` flags so the same helper
54
+ * serves both the Story-scoped detectors (rework/retry — full preamble) and
55
+ * the Epic-scoped hotspot detector (only `epicId` + `nowFn`). A field that
56
+ * is not required is neither validated nor read.
57
+ *
58
+ * @param {object} args — the detector's raw argument object.
59
+ * @param {object} opts
60
+ * @param {string} opts.fnName — the calling detector's name, used verbatim as
61
+ * the prefix on every thrown error message (e.g. `'detectRework'`).
62
+ * @param {boolean} [opts.requireTracesPath=true] — validate + return
63
+ * `tracesPath` (non-empty string).
64
+ * @param {boolean} [opts.requireStoryId=true] — validate + return `storyId`
65
+ * (positive integer) and the optional `taskId` (positive integer or null).
66
+ * @param {boolean} [opts.requireThreshold=true] — validate + return
67
+ * `threshold` (non-negative integer).
68
+ * @returns {{
69
+ * tracesPath: string|undefined,
70
+ * epicId: number,
71
+ * storyId: number|undefined,
72
+ * taskId: number|null|undefined,
73
+ * threshold: number|undefined,
74
+ * nowFn: () => string,
75
+ * }} the normalized argument set. Fields gated off by a `require*` flag are
76
+ * omitted (left `undefined`).
77
+ */
78
+ export function validateDetectorArgs(args, opts) {
79
+ const { fnName } = opts;
80
+ const requireTracesPath = opts.requireTracesPath ?? true;
81
+ const requireStoryId = opts.requireStoryId ?? true;
82
+ const requireThreshold = opts.requireThreshold ?? true;
83
+
84
+ if (args == null || typeof args !== 'object') {
85
+ throw new TypeError(
86
+ `${fnName}: args must be an object with at minimum { tracesPath, epicId, storyId, threshold }; got ${args}`,
87
+ );
88
+ }
89
+
90
+ const { tracesPath, epicId, storyId, threshold } = args;
91
+ const taskId = args.taskId ?? null;
92
+
93
+ if (args.nowFn != null && typeof args.nowFn !== 'function') {
94
+ throw new TypeError(
95
+ `${fnName}: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
96
+ );
97
+ }
98
+ const nowFn = args.nowFn ?? (() => new Date().toISOString());
99
+
100
+ if (requireTracesPath) {
101
+ if (typeof tracesPath !== 'string' || tracesPath.length === 0) {
102
+ throw new TypeError(
103
+ `${fnName}: tracesPath must be a non-empty string (got ${tracesPath})`,
104
+ );
105
+ }
106
+ }
107
+
108
+ if (!isPositiveInt(epicId)) {
109
+ throw new RangeError(
110
+ `${fnName}: epicId must be a positive integer (got ${epicId})`,
111
+ );
112
+ }
113
+
114
+ if (requireStoryId) {
115
+ if (!isPositiveInt(storyId)) {
116
+ throw new RangeError(
117
+ `${fnName}: storyId must be a positive integer (got ${storyId})`,
118
+ );
119
+ }
120
+ if (taskId !== null && !isPositiveInt(taskId)) {
121
+ throw new RangeError(
122
+ `${fnName}: taskId must be a positive integer or null (got ${taskId})`,
123
+ );
124
+ }
125
+ }
126
+
127
+ if (requireThreshold) {
128
+ if (!Number.isInteger(threshold) || threshold < 0) {
129
+ throw new RangeError(
130
+ `${fnName}: threshold must be a non-negative integer (got ${threshold})`,
131
+ );
132
+ }
133
+ }
134
+
135
+ return {
136
+ tracesPath: requireTracesPath ? tracesPath : undefined,
137
+ epicId,
138
+ storyId: requireStoryId ? storyId : undefined,
139
+ taskId: requireStoryId ? taskId : undefined,
140
+ threshold: requireThreshold ? threshold : undefined,
141
+ nowFn,
142
+ };
143
+ }
@@ -73,7 +73,7 @@ import path from 'node:path';
73
73
  import { createInterface } from 'node:readline';
74
74
  import { epicTempDir } from '../../config/temp-paths.js';
75
75
  import { parseStoryBranch } from '../../git-utils.js';
76
- import { extractTool, isPositiveInt } from './common.js';
76
+ import { extractTool, validateDetectorArgs } from './common.js';
77
77
 
78
78
  /**
79
79
  * Tools that mutate files. Only these contribute to the per-target edit
@@ -207,24 +207,18 @@ export function nearestRankP95(values) {
207
207
  * p95Threshold, multiplier }`.
208
208
  */
209
209
  export async function detectHotspot(args) {
210
- if (args == null || typeof args !== 'object') {
211
- throw new TypeError(
212
- `detectHotspot: args must be an object with at minimum { epicId, multiplier }; got ${args}`,
213
- );
214
- }
215
- const { epicId, multiplier, tempRoot } = args;
216
- if (args.nowFn != null && typeof args.nowFn !== 'function') {
217
- throw new TypeError(
218
- `detectHotspot: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
219
- );
220
- }
221
- const nowFn = args.nowFn ?? (() => new Date().toISOString());
210
+ // Hotspot shares only the args-shape, nowFn, and epicId guards with the
211
+ // Story-scoped detectors — it has no tracesPath/storyId/taskId/threshold.
212
+ // Gate those three off and validate multiplier/tempRoot (hotspot-specific)
213
+ // inline below.
214
+ const { epicId, nowFn } = validateDetectorArgs(args, {
215
+ fnName: 'detectHotspot',
216
+ requireTracesPath: false,
217
+ requireStoryId: false,
218
+ requireThreshold: false,
219
+ });
220
+ const { multiplier, tempRoot } = args;
222
221
 
223
- if (!isPositiveInt(epicId)) {
224
- throw new RangeError(
225
- `detectHotspot: epicId must be a positive integer (got ${epicId})`,
226
- );
227
- }
228
222
  if (!isPositiveNumber(multiplier)) {
229
223
  throw new RangeError(
230
224
  `detectHotspot: multiplier must be a positive number (got ${multiplier})`,
@@ -82,7 +82,7 @@ import { createReadStream } from 'node:fs';
82
82
  import fs from 'node:fs/promises';
83
83
  import { createInterface } from 'node:readline';
84
84
 
85
- import { extractTool, isPositiveInt } from './common.js';
85
+ import { extractTool, validateDetectorArgs } from './common.js';
86
86
 
87
87
  /**
88
88
  * Documented argv-normalisation rules — emitted verbatim onto every
@@ -220,45 +220,8 @@ async function tallyFailuresByIdentity(tracesPath) {
220
220
  * to `.agents/schemas/signal-event.schema.json`.
221
221
  */
222
222
  export async function detectRetry(args) {
223
- if (args == null || typeof args !== 'object') {
224
- throw new TypeError(
225
- `detectRetry: args must be an object with at minimum { tracesPath, epicId, storyId, threshold }; got ${args}`,
226
- );
227
- }
228
- const { tracesPath, epicId, storyId, threshold } = args;
229
- const taskId = args.taskId ?? null;
230
- if (args.nowFn != null && typeof args.nowFn !== 'function') {
231
- throw new TypeError(
232
- `detectRetry: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
233
- );
234
- }
235
- const nowFn = args.nowFn ?? (() => new Date().toISOString());
236
-
237
- if (typeof tracesPath !== 'string' || tracesPath.length === 0) {
238
- throw new TypeError(
239
- `detectRetry: tracesPath must be a non-empty string (got ${tracesPath})`,
240
- );
241
- }
242
- if (!isPositiveInt(epicId)) {
243
- throw new RangeError(
244
- `detectRetry: epicId must be a positive integer (got ${epicId})`,
245
- );
246
- }
247
- if (!isPositiveInt(storyId)) {
248
- throw new RangeError(
249
- `detectRetry: storyId must be a positive integer (got ${storyId})`,
250
- );
251
- }
252
- if (taskId !== null && !isPositiveInt(taskId)) {
253
- throw new RangeError(
254
- `detectRetry: taskId must be a positive integer or null (got ${taskId})`,
255
- );
256
- }
257
- if (!Number.isInteger(threshold) || threshold < 0) {
258
- throw new RangeError(
259
- `detectRetry: threshold must be a non-negative integer (got ${threshold})`,
260
- );
261
- }
223
+ const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
224
+ validateDetectorArgs(args, { fnName: 'detectRetry' });
262
225
 
263
226
  const counts = await tallyFailuresByIdentity(tracesPath);
264
227
 
@@ -52,7 +52,7 @@ import { createReadStream } from 'node:fs';
52
52
  import fs from 'node:fs/promises';
53
53
  import { createInterface } from 'node:readline';
54
54
 
55
- import { extractTool, isPositiveInt } from './common.js';
55
+ import { extractTool, validateDetectorArgs } from './common.js';
56
56
 
57
57
  /**
58
58
  * Tools that mutate files. Only these contribute to the per-target edit
@@ -140,45 +140,8 @@ async function tallyEditsByTarget(tracesPath) {
140
140
  * conforming to `.agents/schemas/signal-event.schema.json`.
141
141
  */
142
142
  export async function detectRework(args) {
143
- if (args == null || typeof args !== 'object') {
144
- throw new TypeError(
145
- `detectRework: args must be an object with at minimum { tracesPath, epicId, storyId, threshold }; got ${args}`,
146
- );
147
- }
148
- const { tracesPath, epicId, storyId, threshold } = args;
149
- const taskId = args.taskId ?? null;
150
- if (args.nowFn != null && typeof args.nowFn !== 'function') {
151
- throw new TypeError(
152
- `detectRework: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
153
- );
154
- }
155
- const nowFn = args.nowFn ?? (() => new Date().toISOString());
156
-
157
- if (typeof tracesPath !== 'string' || tracesPath.length === 0) {
158
- throw new TypeError(
159
- `detectRework: tracesPath must be a non-empty string (got ${tracesPath})`,
160
- );
161
- }
162
- if (!isPositiveInt(epicId)) {
163
- throw new RangeError(
164
- `detectRework: epicId must be a positive integer (got ${epicId})`,
165
- );
166
- }
167
- if (!isPositiveInt(storyId)) {
168
- throw new RangeError(
169
- `detectRework: storyId must be a positive integer (got ${storyId})`,
170
- );
171
- }
172
- if (taskId !== null && !isPositiveInt(taskId)) {
173
- throw new RangeError(
174
- `detectRework: taskId must be a positive integer or null (got ${taskId})`,
175
- );
176
- }
177
- if (!Number.isInteger(threshold) || threshold < 0) {
178
- throw new RangeError(
179
- `detectRework: threshold must be a non-negative integer (got ${threshold})`,
180
- );
181
- }
143
+ const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
144
+ validateDetectorArgs(args, { fnName: 'detectRework' });
182
145
 
183
146
  const counts = await tallyEditsByTarget(tracesPath);
184
147