mandrel 2.65.0 → 2.66.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 +5 -5
- 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/execution-reference.md +27 -5
- package/.agents/instructions.md +10 -12
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +12 -3
- package/.agents/rules/git-conventions.md +9 -7
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- 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/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/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/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- 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/transpile.js +28 -3
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- 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/helpers/acceptance-self-eval.md +8 -8
- 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.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 +26 -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
|
}
|
|
@@ -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}
|
|
@@ -195,6 +195,16 @@ export function buildProviderChain(entries, ctx) {
|
|
|
195
195
|
return { inline, prompts };
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* @param {Record<string, number>} tally
|
|
200
|
+
* @param {string} name
|
|
201
|
+
* @param {Finding[]} findings
|
|
202
|
+
*/
|
|
203
|
+
function recordCritical(tally, name, findings) {
|
|
204
|
+
const critical = findings.filter((f) => f?.severity === 'critical').length;
|
|
205
|
+
if (critical > 0) tally[name] = critical;
|
|
206
|
+
}
|
|
207
|
+
|
|
198
208
|
/**
|
|
199
209
|
* Inline findings merge in declaration order; prompt entries render via
|
|
200
210
|
* `getPromptMessages`.
|
|
@@ -203,11 +213,14 @@ export function buildProviderChain(entries, ctx) {
|
|
|
203
213
|
* @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
|
|
204
214
|
* @returns {ReviewProvider & {
|
|
205
215
|
* getPromptMessages: (input: ReviewInput) => Promise<string[]>,
|
|
216
|
+
* getCriticalByProvider: () => Record<string, number>,
|
|
206
217
|
* chain: ProviderChain,
|
|
207
218
|
* }}
|
|
208
219
|
*/
|
|
209
220
|
export function createChainProvider(chain, opts = {}) {
|
|
210
221
|
const logger = opts.logger;
|
|
222
|
+
/** Critical findings per entry from the last `runReview`, for attribution. */
|
|
223
|
+
let criticalByProvider = {};
|
|
211
224
|
|
|
212
225
|
return {
|
|
213
226
|
chain,
|
|
@@ -218,6 +231,7 @@ export function createChainProvider(chain, opts = {}) {
|
|
|
218
231
|
async runReview(input) {
|
|
219
232
|
/** @type {Finding[]} */
|
|
220
233
|
const merged = [];
|
|
234
|
+
criticalByProvider = {};
|
|
221
235
|
const ctx = {
|
|
222
236
|
scope: input?.scope,
|
|
223
237
|
labels: /** @type {ReadonlyArray<string>} */ (
|
|
@@ -238,9 +252,18 @@ export function createChainProvider(chain, opts = {}) {
|
|
|
238
252
|
);
|
|
239
253
|
}
|
|
240
254
|
for (const f of findings) merged.push(f);
|
|
255
|
+
recordCritical(criticalByProvider, entry.name, findings);
|
|
241
256
|
}
|
|
242
257
|
return merged;
|
|
243
258
|
},
|
|
259
|
+
/**
|
|
260
|
+
* Which entries raised the last run's critical findings, and how many.
|
|
261
|
+
*
|
|
262
|
+
* @returns {Record<string, number>}
|
|
263
|
+
*/
|
|
264
|
+
getCriticalByProvider() {
|
|
265
|
+
return { ...criticalByProvider };
|
|
266
|
+
},
|
|
244
267
|
/**
|
|
245
268
|
* Called after `runReview`.
|
|
246
269
|
*
|
|
@@ -10,6 +10,7 @@ import { selectAudits } from '../audit-suite/index.js';
|
|
|
10
10
|
import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
|
|
11
11
|
import { gitSpawn } from '../git-utils.js';
|
|
12
12
|
import { Logger } from '../Logger.js';
|
|
13
|
+
import { gatherRunTelemetry } from '../observability/close-telemetry.js';
|
|
13
14
|
import { isEpicTicket } from './epic-container.js';
|
|
14
15
|
import { composeRoutedProposals } from './retro-proposals.js';
|
|
15
16
|
import {
|
|
@@ -657,6 +658,8 @@ async function executeFollowUpRollup({
|
|
|
657
658
|
graduateFn,
|
|
658
659
|
});
|
|
659
660
|
const categories = summarizeSignalCategories(signals);
|
|
661
|
+
// Step result only, never the roll-up comment: telemetry stays local.
|
|
662
|
+
const telemetry = await gatherRunTelemetry(stories, config);
|
|
660
663
|
const proposalCount = proposals.framework.length + proposals.consumer.length;
|
|
661
664
|
const outcome = assessRollupOutcome({
|
|
662
665
|
signalCount: signals.length,
|
|
@@ -687,6 +690,7 @@ async function executeFollowUpRollup({
|
|
|
687
690
|
categories,
|
|
688
691
|
outcome,
|
|
689
692
|
frictionWindow,
|
|
693
|
+
telemetry,
|
|
690
694
|
});
|
|
691
695
|
}
|
|
692
696
|
|
|
@@ -743,6 +747,7 @@ function buildRollupStepResult({
|
|
|
743
747
|
categories,
|
|
744
748
|
outcome,
|
|
745
749
|
frictionWindow,
|
|
750
|
+
telemetry = null,
|
|
746
751
|
}) {
|
|
747
752
|
return {
|
|
748
753
|
kind: 'follow-up-rollup',
|
|
@@ -766,6 +771,7 @@ function buildRollupStepResult({
|
|
|
766
771
|
fingerprint: item.fingerprint ?? null,
|
|
767
772
|
})),
|
|
768
773
|
emptyRollupSuspect: signals.length === 0 && storyCount > 1,
|
|
774
|
+
telemetry,
|
|
769
775
|
};
|
|
770
776
|
}
|
|
771
777
|
|
|
@@ -134,6 +134,7 @@ async function postStoryReviewCrossRef({
|
|
|
134
134
|
* halted: boolean,
|
|
135
135
|
* skipped?: boolean,
|
|
136
136
|
* severity?: { critical: number, high: number, medium: number, suggestion: number },
|
|
137
|
+
* criticalByProvider?: Record<string, number>,
|
|
137
138
|
* posted?: boolean,
|
|
138
139
|
* postedCommentId?: number|null,
|
|
139
140
|
* degraded?: boolean,
|
|
@@ -214,6 +215,7 @@ export async function runStoryScopeReview({
|
|
|
214
215
|
return {
|
|
215
216
|
halted: !!result.halted,
|
|
216
217
|
severity: sev,
|
|
218
|
+
criticalByProvider: result.criticalByProvider,
|
|
217
219
|
posted: result.posted,
|
|
218
220
|
postedCommentId: result.postedCommentId ?? null,
|
|
219
221
|
...degradationEnvelope(result.degradations),
|