mandrel 2.65.0 → 2.67.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/agents/acceptance-critic.md +7 -7
- package/.agents/agents/auditor.md +17 -18
- package/.agents/agents/plan-critic.md +5 -5
- package/.agents/agents/story-worker.md +5 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +12 -13
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +17 -8
- package/.agents/rules/git-conventions.md +10 -8
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +3 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +6 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/mandrel-deliver.md +1 -1
- package/.agents/workflows/mandrel-plan.md +6 -5
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- package/package.json +2 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Story cost, as close observes it: retries by cause, review halts and
|
|
3
|
+
* overrides by provider, acceptance rounds, worker tokens. Best-effort and
|
|
4
|
+
* local only — a failed write is a missing metric, never a failed close.
|
|
5
|
+
*
|
|
6
|
+
* @module lib/observability/close-telemetry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
import { parseWorkerTokens } from '../cli-args.js';
|
|
12
|
+
import { Logger } from '../Logger.js';
|
|
13
|
+
import { EVENT_KINDS } from '../signals/schema.js';
|
|
14
|
+
import {
|
|
15
|
+
emitRuntimeFriction,
|
|
16
|
+
emitTerminalFriction,
|
|
17
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
18
|
+
} from './runtime-friction.js';
|
|
19
|
+
import { appendSignal, forEachLine } from './signals-writer.js';
|
|
20
|
+
|
|
21
|
+
const RETRY_CAUSES = Object.freeze({
|
|
22
|
+
CI_RED: 'ci-red',
|
|
23
|
+
REVIEW_BLOCK: 'review-block',
|
|
24
|
+
GATE_FAILED: 'gate-failed',
|
|
25
|
+
MERGE_WAIT: 'merge-wait',
|
|
26
|
+
OTHER: 'other',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const BLOCK_CLASS_CAUSES = Object.freeze({
|
|
30
|
+
'checks-failed': RETRY_CAUSES.CI_RED,
|
|
31
|
+
'advisory-gate-red': RETRY_CAUSES.CI_RED,
|
|
32
|
+
'checks-pending-timeout': RETRY_CAUSES.MERGE_WAIT,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const FAILED_PHASE_CAUSES = Object.freeze({
|
|
36
|
+
'code-review': RETRY_CAUSES.REVIEW_BLOCK,
|
|
37
|
+
'close-validation': RETRY_CAUSES.GATE_FAILED,
|
|
38
|
+
'confirm-merge': RETRY_CAUSES.MERGE_WAIT,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object|null|undefined} envelope
|
|
43
|
+
* @returns {string|null} `null` for a landed or pending close.
|
|
44
|
+
*/
|
|
45
|
+
function retryCauseForTerminal(envelope) {
|
|
46
|
+
const status = envelope?.status;
|
|
47
|
+
if (status === 'blocked') {
|
|
48
|
+
return (
|
|
49
|
+
BLOCK_CLASS_CAUSES[envelope.blocked?.blockClass] ?? RETRY_CAUSES.OTHER
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (status === 'failed') {
|
|
53
|
+
return FAILED_PHASE_CAUSES[envelope.phase] ?? RETRY_CAUSES.OTHER;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {{ envelope: object, config?: object }} args
|
|
60
|
+
* @returns {Promise<boolean>}
|
|
61
|
+
*/
|
|
62
|
+
async function emitCloseRetrySignal({ envelope, config }) {
|
|
63
|
+
const cause = retryCauseForTerminal(envelope);
|
|
64
|
+
const storyId = Number(envelope?.storyId);
|
|
65
|
+
if (cause === null || !Number.isInteger(storyId) || storyId <= 0) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const signal = {
|
|
69
|
+
kind: EVENT_KINDS.RETRY,
|
|
70
|
+
eventId: crypto.randomUUID(),
|
|
71
|
+
ts: new Date().toISOString(),
|
|
72
|
+
epicId: null,
|
|
73
|
+
storyId,
|
|
74
|
+
taskId: null,
|
|
75
|
+
phase: 'close',
|
|
76
|
+
emitter: { tool: 'single-story-close' },
|
|
77
|
+
details: {
|
|
78
|
+
cause,
|
|
79
|
+
status: envelope.status,
|
|
80
|
+
closePhase: envelope.phase ?? null,
|
|
81
|
+
blockClass: envelope.blocked?.blockClass ?? null,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return appendSignal({ epicId: null, storyId, signal, config });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{ envelope: object, config?: object }} args
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
export async function emitCloseTerminalSignals({ envelope, config } = {}) {
|
|
92
|
+
await emitCloseRetrySignal({ envelope, config });
|
|
93
|
+
await emitTerminalFriction({ envelope, config });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {{ storyId: number, prNumber?: number|null, criticalCount: number,
|
|
98
|
+
* criticalByProvider?: Record<string, number>, config?: object }} args
|
|
99
|
+
* @returns {Promise<boolean>} true when a record was appended.
|
|
100
|
+
*/
|
|
101
|
+
export function emitReviewBlockedFriction({
|
|
102
|
+
storyId,
|
|
103
|
+
prNumber = null,
|
|
104
|
+
criticalCount,
|
|
105
|
+
criticalByProvider = {},
|
|
106
|
+
config,
|
|
107
|
+
}) {
|
|
108
|
+
return emitRuntimeFriction({
|
|
109
|
+
storyId,
|
|
110
|
+
category: RUNTIME_FRICTION_CATEGORIES.REVIEW_BLOCKED,
|
|
111
|
+
tool: 'single-story-close',
|
|
112
|
+
details: { prNumber, criticalCount, criticalByProvider },
|
|
113
|
+
config,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {Record<string, number>} into
|
|
119
|
+
* @param {Record<string, number>} from
|
|
120
|
+
* @returns {Record<string, number>} `into`, mutated.
|
|
121
|
+
*/
|
|
122
|
+
function addCounts(into, from) {
|
|
123
|
+
for (const [key, count] of Object.entries(from)) {
|
|
124
|
+
into[key] = (into[key] ?? 0) + count;
|
|
125
|
+
}
|
|
126
|
+
return into;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emptyTelemetryTally() {
|
|
130
|
+
return {
|
|
131
|
+
acceptanceRounds: 0,
|
|
132
|
+
retries: { total: 0, byCause: {} },
|
|
133
|
+
review: { haltsByProvider: {}, overridesByProvider: {} },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One incident per provider that raised a critical finding, not per finding.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} details
|
|
141
|
+
* @returns {Record<string, number>}
|
|
142
|
+
*/
|
|
143
|
+
function providerIncidents(details) {
|
|
144
|
+
const out = {};
|
|
145
|
+
const byProvider = details?.criticalByProvider;
|
|
146
|
+
for (const [name, count] of Object.entries(byProvider ?? {})) {
|
|
147
|
+
if (Number.isInteger(count) && count > 0) out[name] = 1;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const RECORD_TALLIERS = Object.freeze({
|
|
153
|
+
[EVENT_KINDS.RETRY]: (tally, record) => {
|
|
154
|
+
const cause =
|
|
155
|
+
typeof record.details?.cause === 'string'
|
|
156
|
+
? record.details.cause
|
|
157
|
+
: RETRY_CAUSES.OTHER;
|
|
158
|
+
tally.retries.total += 1;
|
|
159
|
+
addCounts(tally.retries.byCause, { [cause]: 1 });
|
|
160
|
+
},
|
|
161
|
+
[EVENT_KINDS.ACCEPTANCE_EVAL]: (tally, record) => {
|
|
162
|
+
const round = Number(record.details?.round);
|
|
163
|
+
const seen = Number.isInteger(round) && round > 0 ? round : 1;
|
|
164
|
+
tally.acceptanceRounds = Math.max(tally.acceptanceRounds, seen);
|
|
165
|
+
},
|
|
166
|
+
[EVENT_KINDS.FRICTION]: (tally, record) => {
|
|
167
|
+
if (record.details?.recovered === true) return;
|
|
168
|
+
if (record.category === RUNTIME_FRICTION_CATEGORIES.REVIEW_BLOCKED) {
|
|
169
|
+
addCounts(
|
|
170
|
+
tally.review.haltsByProvider,
|
|
171
|
+
providerIncidents(record.details),
|
|
172
|
+
);
|
|
173
|
+
} else if (
|
|
174
|
+
record.category === RUNTIME_FRICTION_CATEGORIES.REVIEW_BLOCK_OVERRIDDEN
|
|
175
|
+
) {
|
|
176
|
+
addCounts(
|
|
177
|
+
tally.review.overridesByProvider,
|
|
178
|
+
providerIncidents(record.details),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {Iterable<unknown>} records
|
|
186
|
+
* @returns {ReturnType<typeof emptyTelemetryTally>}
|
|
187
|
+
*/
|
|
188
|
+
function tallyStoryTelemetry(records) {
|
|
189
|
+
const tally = emptyTelemetryTally();
|
|
190
|
+
for (const record of records ?? []) {
|
|
191
|
+
if (!record || typeof record !== 'object') continue;
|
|
192
|
+
RECORD_TALLIERS[record.kind]?.(tally, record);
|
|
193
|
+
}
|
|
194
|
+
return tally;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {Array<ReturnType<typeof emptyTelemetryTally>>} tallies
|
|
199
|
+
* @returns {ReturnType<typeof emptyTelemetryTally>}
|
|
200
|
+
*/
|
|
201
|
+
function mergeTelemetryTallies(tallies) {
|
|
202
|
+
const total = emptyTelemetryTally();
|
|
203
|
+
for (const t of tallies ?? []) {
|
|
204
|
+
total.acceptanceRounds += t.acceptanceRounds;
|
|
205
|
+
total.retries.total += t.retries.total;
|
|
206
|
+
addCounts(total.retries.byCause, t.retries.byCause);
|
|
207
|
+
addCounts(total.review.haltsByProvider, t.review.haltsByProvider);
|
|
208
|
+
addCounts(total.review.overridesByProvider, t.review.overridesByProvider);
|
|
209
|
+
}
|
|
210
|
+
return total;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A halt no override rejected, on a Story that then landed.
|
|
215
|
+
*
|
|
216
|
+
* @param {ReturnType<typeof emptyTelemetryTally>} tally
|
|
217
|
+
* @param {boolean} landed
|
|
218
|
+
* @returns {Record<string, number>}
|
|
219
|
+
*/
|
|
220
|
+
function confirmedHalts(tally, landed) {
|
|
221
|
+
if (!landed) return {};
|
|
222
|
+
const out = {};
|
|
223
|
+
for (const [name, count] of Object.entries(tally.review.haltsByProvider)) {
|
|
224
|
+
if (!tally.review.overridesByProvider[name]) out[name] = count;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {{ storyId: number, config?: object, readFn?: typeof forEachLine }} args
|
|
231
|
+
* @returns {Promise<ReturnType<typeof emptyTelemetryTally>>}
|
|
232
|
+
*/
|
|
233
|
+
async function readStoryTally({ storyId, config, readFn = forEachLine }) {
|
|
234
|
+
const rows = [];
|
|
235
|
+
try {
|
|
236
|
+
await readFn(null, storyId, (parsed) => rows.push(parsed), config);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
Logger.warn(
|
|
239
|
+
`[close-telemetry] signal read failed for Story #${storyId}: ${
|
|
240
|
+
err instanceof Error ? err.message : String(err)
|
|
241
|
+
}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return tallyStoryTelemetry(rows);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {{ storyId: number, config?: object, workerTokens?: number|null,
|
|
249
|
+
* landed?: boolean, readFn?: typeof forEachLine }} args
|
|
250
|
+
* @returns {Promise<object>}
|
|
251
|
+
*/
|
|
252
|
+
async function buildCloseTelemetry({
|
|
253
|
+
storyId,
|
|
254
|
+
config,
|
|
255
|
+
workerTokens = null,
|
|
256
|
+
landed = false,
|
|
257
|
+
readFn,
|
|
258
|
+
}) {
|
|
259
|
+
const tally = await readStoryTally({ storyId, config, readFn });
|
|
260
|
+
return {
|
|
261
|
+
acceptanceRounds: tally.acceptanceRounds,
|
|
262
|
+
retries: tally.retries,
|
|
263
|
+
review: {
|
|
264
|
+
...tally.review,
|
|
265
|
+
confirmedHaltsByProvider: confirmedHalts(tally, landed),
|
|
266
|
+
},
|
|
267
|
+
workerTokens: Number.isInteger(workerTokens) ? workerTokens : null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Over the run's own Stories, not the friction recurrence window.
|
|
273
|
+
*
|
|
274
|
+
* @param {Array<string|number>} storyIds
|
|
275
|
+
* @param {object} [config]
|
|
276
|
+
* @param {{ readFn?: typeof forEachLine }} [opts]
|
|
277
|
+
* @returns {Promise<ReturnType<typeof emptyTelemetryTally> & { storyCount: number }>}
|
|
278
|
+
*/
|
|
279
|
+
export async function gatherRunTelemetry(storyIds, config, { readFn } = {}) {
|
|
280
|
+
const tallies = [];
|
|
281
|
+
for (const raw of Array.isArray(storyIds) ? storyIds : []) {
|
|
282
|
+
const storyId = Number(raw);
|
|
283
|
+
if (!Number.isInteger(storyId) || storyId <= 0) continue;
|
|
284
|
+
tallies.push(await readStoryTally({ storyId, config, readFn }));
|
|
285
|
+
}
|
|
286
|
+
return { ...mergeTelemetryTallies(tallies), storyCount: tallies.length };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {unknown} raw
|
|
291
|
+
* @returns {number|null}
|
|
292
|
+
*/
|
|
293
|
+
export function resolveWorkerTokens(raw) {
|
|
294
|
+
const { tokens, warning } = parseWorkerTokens(raw);
|
|
295
|
+
if (warning) Logger.warn(`[single-story-close] ${warning}`);
|
|
296
|
+
return tokens;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The retry goes first so the summary counts it; a failure leaves
|
|
301
|
+
* `telemetry: null` and the close's status untouched.
|
|
302
|
+
*
|
|
303
|
+
* @param {{ terminal: object, result?: object|null, config?: object,
|
|
304
|
+
* workerTokens?: number|null, readFn?: typeof forEachLine }} args
|
|
305
|
+
* @returns {Promise<void>}
|
|
306
|
+
*/
|
|
307
|
+
export async function recordCloseTelemetry({
|
|
308
|
+
terminal,
|
|
309
|
+
result,
|
|
310
|
+
config,
|
|
311
|
+
workerTokens = null,
|
|
312
|
+
readFn,
|
|
313
|
+
}) {
|
|
314
|
+
await emitCloseRetrySignal({ envelope: terminal, config });
|
|
315
|
+
if (!result) return;
|
|
316
|
+
try {
|
|
317
|
+
result.telemetry = await buildCloseTelemetry({
|
|
318
|
+
storyId: result.storyId,
|
|
319
|
+
config,
|
|
320
|
+
workerTokens,
|
|
321
|
+
landed: terminal?.status === 'landed',
|
|
322
|
+
readFn,
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
Logger.warn(
|
|
326
|
+
`[single-story-close] telemetry summary unavailable: ${err?.message ?? err}`,
|
|
327
|
+
);
|
|
328
|
+
result.telemetry = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -23,6 +23,8 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
|
|
|
23
23
|
/** A tool failed to execute — kept out of code-finding severity tiers. */
|
|
24
24
|
TOOL_DEGRADED: 'tool-degraded',
|
|
25
25
|
LIGHT_SCOPE_REJECTED: 'light-scope-rejected',
|
|
26
|
+
/** A critical code-review halt, attributed to the provider(s) that raised it. */
|
|
27
|
+
REVIEW_BLOCKED: 'review-blocked',
|
|
26
28
|
/** A human shipped over a review blocker; a rising count means miscalibration. */
|
|
27
29
|
REVIEW_BLOCK_OVERRIDDEN: 'review-block-overridden',
|
|
28
30
|
});
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* signal-validator.js — write-time validation of signal records against the
|
|
3
3
|
* on-disk `signal-event.schema.json`, compiled once so the writer and the
|
|
4
4
|
* contract test validate against the same document. Never throws.
|
|
5
|
+
*
|
|
6
|
+
* The schema is compiled on the first `validateSignal` call, not at import:
|
|
7
|
+
* every CLI that imports the signals writer (including for `--help`) would
|
|
8
|
+
* otherwise pay the AJV compile at start-up.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import { readFileSync } from 'node:fs';
|
|
@@ -45,7 +49,14 @@ function buildValidator() {
|
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
/** `undefined` until the first `validateSignal` call builds it. */
|
|
53
|
+
let _validate;
|
|
54
|
+
|
|
55
|
+
/** @returns {import('ajv').ValidateFunction | null} */
|
|
56
|
+
function getValidator() {
|
|
57
|
+
if (_validate === undefined) _validate = buildValidator();
|
|
58
|
+
return _validate;
|
|
59
|
+
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
51
62
|
* @param {import('ajv').ErrorObject[] | null | undefined} errors
|
|
@@ -74,7 +85,8 @@ function violatingFieldOf(errors) {
|
|
|
74
85
|
* @returns {{ valid: boolean, violatingField: string|null, message: string|null }}
|
|
75
86
|
*/
|
|
76
87
|
export function validateSignal(record) {
|
|
77
|
-
|
|
88
|
+
const validate = getValidator();
|
|
89
|
+
if (validate === null) {
|
|
78
90
|
return { valid: true, violatingField: null, message: null };
|
|
79
91
|
}
|
|
80
92
|
if (record === null || typeof record !== 'object' || Array.isArray(record)) {
|
|
@@ -84,9 +96,9 @@ export function validateSignal(record) {
|
|
|
84
96
|
message: 'signal record must be a plain object',
|
|
85
97
|
};
|
|
86
98
|
}
|
|
87
|
-
const valid =
|
|
99
|
+
const valid = validate(record);
|
|
88
100
|
if (valid) return { valid: true, violatingField: null, message: null };
|
|
89
|
-
const field = violatingFieldOf(
|
|
90
|
-
const message =
|
|
101
|
+
const field = violatingFieldOf(validate.errors);
|
|
102
|
+
const message = validate.errors?.[0]?.message ?? 'schema validation failed';
|
|
91
103
|
return { valid: false, violatingField: field, message };
|
|
92
104
|
}
|
|
@@ -37,6 +37,8 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
37
37
|
'check-test-temp-hygiene.js',
|
|
38
38
|
'check-windows-git-perf.js',
|
|
39
39
|
'ceremony-derive.js',
|
|
40
|
+
'clean-temp.js',
|
|
41
|
+
'clean-worktrees.js',
|
|
40
42
|
'cleanup-repo-test-temp.js',
|
|
41
43
|
'coverage-capture.js',
|
|
42
44
|
'deliver-light.js',
|
|
@@ -51,7 +53,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
51
53
|
'generate-lens-checklists.js',
|
|
52
54
|
'generate-skills-index.js',
|
|
53
55
|
'generate-workflows-doc.js',
|
|
54
|
-
'git
|
|
56
|
+
'clean-git.js',
|
|
55
57
|
'lint-issue-body.js',
|
|
56
58
|
'mandrel-update-preflight.js',
|
|
57
59
|
'merge-baseline.js',
|
|
@@ -112,6 +112,7 @@ function resolveScopeEnvelope(opts, config) {
|
|
|
112
112
|
* postedCommentId: number|null,
|
|
113
113
|
* commentTargetId: number,
|
|
114
114
|
* halted: boolean,
|
|
115
|
+
* criticalByProvider?: Record<string, number>,
|
|
115
116
|
* degraded: boolean, degradations: Array<object>,
|
|
116
117
|
* blockerReason: string|null,
|
|
117
118
|
* }>}
|
|
@@ -229,6 +230,21 @@ async function postReviewComment({
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Which review provider(s) raised the critical findings. A chain reports its
|
|
235
|
+
* own per-entry attribution; any other provider owns every critical.
|
|
236
|
+
*
|
|
237
|
+
* @param {{ reviewProvider: object, providerName: string,
|
|
238
|
+
* severity: { critical: number } }} args
|
|
239
|
+
* @returns {Record<string, number>}
|
|
240
|
+
*/
|
|
241
|
+
function resolveCriticalByProvider({ reviewProvider, providerName, severity }) {
|
|
242
|
+
if (typeof reviewProvider?.getCriticalByProvider === 'function') {
|
|
243
|
+
return reviewProvider.getCriticalByProvider();
|
|
244
|
+
}
|
|
245
|
+
return severity.critical > 0 ? { [providerName]: severity.critical } : {};
|
|
246
|
+
}
|
|
247
|
+
|
|
232
248
|
async function executeReviewPipeline({ opts, config, envelope }) {
|
|
233
249
|
const {
|
|
234
250
|
provider,
|
|
@@ -277,6 +293,11 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
277
293
|
);
|
|
278
294
|
const severity = countBySeverity(findings);
|
|
279
295
|
const halted = hasSurvivingCritical(severity);
|
|
296
|
+
const criticalByProvider = resolveCriticalByProvider({
|
|
297
|
+
reviewProvider,
|
|
298
|
+
providerName,
|
|
299
|
+
severity,
|
|
300
|
+
});
|
|
280
301
|
const report = renderFindingsFn({
|
|
281
302
|
scope,
|
|
282
303
|
ticketId,
|
|
@@ -304,6 +325,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
304
325
|
postedCommentId,
|
|
305
326
|
commentTargetId,
|
|
306
327
|
halted,
|
|
328
|
+
criticalByProvider,
|
|
307
329
|
...degradationEnvelope(degradations),
|
|
308
330
|
blockerReason: halted
|
|
309
331
|
? `code-review reported ${severity.critical} critical blocker(s)`
|
|
@@ -231,75 +231,88 @@ function recordTimestamp(entry) {
|
|
|
231
231
|
* }|null}
|
|
232
232
|
*/
|
|
233
233
|
export function summarizePlanMetrics(ledger, opts = {}) {
|
|
234
|
-
const
|
|
235
|
-
const since = typeof opts.since === 'string' ? opts.since : null;
|
|
236
|
-
// ISO-8601 UTC strings compare correctly as strings.
|
|
237
|
-
const entries =
|
|
238
|
-
since === null
|
|
239
|
-
? all
|
|
240
|
-
: all.filter((e) => {
|
|
241
|
-
const stamp = recordTimestamp(e);
|
|
242
|
-
return stamp !== null && stamp >= since;
|
|
243
|
-
});
|
|
234
|
+
const entries = scopeEntries(ledger?.entries ?? [], opts.since);
|
|
244
235
|
if (entries.length === 0) return null;
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
let failures = 0;
|
|
250
|
-
let totalDurationMs = 0;
|
|
251
|
-
let firstStartedAt = null;
|
|
252
|
-
let lastEndedAt = null;
|
|
253
|
-
const invocationEntries = [];
|
|
254
|
-
for (const e of entries) {
|
|
255
|
-
if (e.kind === PLAN_METRICS_KIND_CRITIC_SKIP) {
|
|
256
|
-
criticSkips += 1;
|
|
257
|
-
if (typeof e.critic === 'string') {
|
|
258
|
-
criticSkipsByCritic[e.critic] =
|
|
259
|
-
(criticSkipsByCritic[e.critic] ?? 0) + 1;
|
|
260
|
-
}
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (typeof e.kind === 'string') {
|
|
264
|
-
// Other kinded records (e.g. `findings-yield`) are not invocations.
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
invocationEntries.push(e);
|
|
268
|
-
byCli[e.cli] = (byCli[e.cli] ?? 0) + 1;
|
|
269
|
-
if (typeof e.mode === 'string') byMode[e.mode] = (byMode[e.mode] ?? 0) + 1;
|
|
270
|
-
if (e.ok !== true) failures += 1;
|
|
271
|
-
if (typeof e.durationMs === 'number') totalDurationMs += e.durationMs;
|
|
272
|
-
if (typeof e.startedAt === 'string') {
|
|
273
|
-
if (firstStartedAt === null || e.startedAt < firstStartedAt) {
|
|
274
|
-
firstStartedAt = e.startedAt;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (typeof e.endedAt === 'string') {
|
|
278
|
-
if (lastEndedAt === null || e.endedAt > lastEndedAt) {
|
|
279
|
-
lastEndedAt = e.endedAt;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
let spanMs = null;
|
|
284
|
-
if (firstStartedAt !== null && lastEndedAt !== null) {
|
|
285
|
-
const span = Date.parse(lastEndedAt) - Date.parse(firstStartedAt);
|
|
286
|
-
if (Number.isFinite(span)) spanMs = Math.max(0, span);
|
|
287
|
-
}
|
|
236
|
+
const skips = tallyCriticSkips(entries);
|
|
237
|
+
const invocations = tallyInvocations(
|
|
238
|
+
entries.filter((e) => typeof e.kind !== 'string'),
|
|
239
|
+
);
|
|
288
240
|
return {
|
|
289
|
-
invocations:
|
|
290
|
-
failures,
|
|
291
|
-
byCli,
|
|
292
|
-
byMode,
|
|
293
|
-
criticSkips,
|
|
294
|
-
criticSkipsByCritic,
|
|
295
|
-
firstStartedAt,
|
|
296
|
-
lastEndedAt,
|
|
297
|
-
spanMs,
|
|
298
|
-
totalDurationMs,
|
|
241
|
+
invocations: invocations.count,
|
|
242
|
+
failures: invocations.failures,
|
|
243
|
+
byCli: invocations.byCli,
|
|
244
|
+
byMode: invocations.byMode,
|
|
245
|
+
criticSkips: skips.count,
|
|
246
|
+
criticSkipsByCritic: skips.byCritic,
|
|
247
|
+
firstStartedAt: invocations.firstStartedAt,
|
|
248
|
+
lastEndedAt: invocations.lastEndedAt,
|
|
249
|
+
spanMs: spanBetween(invocations.firstStartedAt, invocations.lastEndedAt),
|
|
250
|
+
totalDurationMs: invocations.totalDurationMs,
|
|
299
251
|
malformedLines: ledger?.malformedLines ?? 0,
|
|
300
252
|
};
|
|
301
253
|
}
|
|
302
254
|
|
|
255
|
+
function scopeEntries(all, since) {
|
|
256
|
+
if (typeof since !== 'string') return all;
|
|
257
|
+
// ISO-8601 UTC strings compare correctly as strings.
|
|
258
|
+
return all.filter((e) => {
|
|
259
|
+
const stamp = recordTimestamp(e);
|
|
260
|
+
return stamp !== null && stamp >= since;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function increment(counts, key) {
|
|
265
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function tallyCriticSkips(entries) {
|
|
269
|
+
const byCritic = {};
|
|
270
|
+
let count = 0;
|
|
271
|
+
for (const e of entries) {
|
|
272
|
+
if (e.kind !== PLAN_METRICS_KIND_CRITIC_SKIP) continue;
|
|
273
|
+
count += 1;
|
|
274
|
+
if (typeof e.critic === 'string') increment(byCritic, e.critic);
|
|
275
|
+
}
|
|
276
|
+
return { count, byCritic };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function earlierStamp(current, candidate) {
|
|
280
|
+
if (typeof candidate !== 'string') return current;
|
|
281
|
+
return current === null || candidate < current ? candidate : current;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function laterStamp(current, candidate) {
|
|
285
|
+
if (typeof candidate !== 'string') return current;
|
|
286
|
+
return current === null || candidate > current ? candidate : current;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function tallyInvocations(entries) {
|
|
290
|
+
const tally = {
|
|
291
|
+
count: entries.length,
|
|
292
|
+
failures: 0,
|
|
293
|
+
byCli: {},
|
|
294
|
+
byMode: {},
|
|
295
|
+
totalDurationMs: 0,
|
|
296
|
+
firstStartedAt: null,
|
|
297
|
+
lastEndedAt: null,
|
|
298
|
+
};
|
|
299
|
+
for (const e of entries) {
|
|
300
|
+
increment(tally.byCli, e.cli);
|
|
301
|
+
if (typeof e.mode === 'string') increment(tally.byMode, e.mode);
|
|
302
|
+
if (e.ok !== true) tally.failures += 1;
|
|
303
|
+
if (typeof e.durationMs === 'number') tally.totalDurationMs += e.durationMs;
|
|
304
|
+
tally.firstStartedAt = earlierStamp(tally.firstStartedAt, e.startedAt);
|
|
305
|
+
tally.lastEndedAt = laterStamp(tally.lastEndedAt, e.endedAt);
|
|
306
|
+
}
|
|
307
|
+
return tally;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function spanBetween(first, last) {
|
|
311
|
+
if (first === null || last === null) return null;
|
|
312
|
+
const span = Date.parse(last) - Date.parse(first);
|
|
313
|
+
return Number.isFinite(span) ? Math.max(0, span) : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
/**
|
|
304
317
|
* @param {ReturnType<typeof summarizePlanMetrics>} summary
|
|
305
318
|
* @returns {string}
|