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.
Files changed (56) hide show
  1. package/.agents/agents/acceptance-critic.md +5 -5
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/execution-reference.md +27 -5
  6. package/.agents/instructions.md +10 -12
  7. package/.agents/rules/ci-remediation.md +3 -3
  8. package/.agents/rules/gherkin-standards.md +3 -2
  9. package/.agents/rules/git-conventions-reference.md +12 -3
  10. package/.agents/rules/git-conventions.md +9 -7
  11. package/.agents/rules/testing-standards.md +8 -7
  12. package/.agents/runtime-deps.json +1 -1
  13. package/.agents/scripts/bootstrap.js +94 -89
  14. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  15. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  16. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  17. package/.agents/scripts/lib/cli-args.js +26 -0
  18. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  19. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  20. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  21. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  22. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  23. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  24. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  25. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  26. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  27. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  34. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  35. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  36. package/.agents/scripts/lib/transpile.js +28 -3
  37. package/.agents/scripts/single-story-close.js +10 -2
  38. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  39. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  40. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  41. package/.agents/workflows/audit-architecture.md +5 -4
  42. package/.agents/workflows/audit-documentation.md +5 -5
  43. package/.agents/workflows/audit-performance.md +10 -10
  44. package/.agents/workflows/helpers/acceptance-self-eval.md +8 -8
  45. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  46. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  47. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  48. package/.agents/workflows/helpers/deliver-story.md +6 -1
  49. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  50. package/.agents/workflows/mandrel-deliver.md +1 -1
  51. package/.agents/workflows/mandrel-plan.md +6 -5
  52. package/docs/CHANGELOG.md +26 -0
  53. package/lib/cli/guarded-sync.js +87 -0
  54. package/lib/cli/sync-agents.js +9 -92
  55. package/lib/cli/sync-commands.js +9 -101
  56. 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
- const _validate = buildValidator();
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
- if (_validate === null) {
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 = _validate(record);
99
+ const valid = validate(record);
88
100
  if (valid) return { valid: true, violatingField: null, message: null };
89
- const field = violatingFieldOf(_validate.errors);
90
- const message = _validate.errors?.[0]?.message ?? 'schema validation failed';
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 all = ledger?.entries ?? [];
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 byCli = {};
246
- const byMode = {};
247
- const criticSkipsByCritic = {};
248
- let criticSkips = 0;
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: invocationEntries.length,
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),