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.
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- 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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
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
|
-
|
|
224
|
-
|
|
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,
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|