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.
Files changed (82) hide show
  1. package/.agents/agents/acceptance-critic.md +7 -7
  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/agentrc-reference.json +2 -1
  6. package/.agents/docs/configuration.md +2 -1
  7. package/.agents/docs/execution-reference.md +27 -5
  8. package/.agents/docs/workflows.md +4 -2
  9. package/.agents/instructions.md +12 -13
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +17 -8
  13. package/.agents/rules/git-conventions.md +10 -8
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -1
  17. package/.agents/scripts/boot-sweep.js +97 -9
  18. package/.agents/scripts/bootstrap.js +94 -89
  19. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  20. package/.agents/scripts/clean-temp.js +54 -0
  21. package/.agents/scripts/clean-worktrees.js +593 -0
  22. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  23. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  24. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  25. package/.agents/scripts/lib/clean-temp.js +440 -0
  26. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  27. package/.agents/scripts/lib/cli-args.js +26 -0
  28. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  29. package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
  30. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  31. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  32. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  34. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  35. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  36. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  37. package/.agents/scripts/lib/observability/source-classifier.js +3 -1
  38. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  39. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  40. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  41. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  42. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  43. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  48. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  49. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  50. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  51. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  52. package/.agents/scripts/lib/temp-removal.js +110 -0
  53. package/.agents/scripts/lib/temp-retention.js +122 -73
  54. package/.agents/scripts/lib/transpile.js +28 -3
  55. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  56. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  57. package/.agents/scripts/single-story-close.js +10 -2
  58. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  59. package/.agents/scripts/single-story-init.js +120 -17
  60. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  61. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  62. package/.agents/workflows/audit-architecture.md +5 -4
  63. package/.agents/workflows/audit-documentation.md +5 -5
  64. package/.agents/workflows/audit-performance.md +10 -10
  65. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  66. package/.agents/workflows/clean-temp.md +67 -0
  67. package/.agents/workflows/clean-worktrees.md +63 -0
  68. package/.agents/workflows/git-deliver.md +1 -1
  69. package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
  70. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  71. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  72. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  73. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  74. package/.agents/workflows/helpers/deliver-story.md +6 -1
  75. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  76. package/.agents/workflows/mandrel-deliver.md +1 -1
  77. package/.agents/workflows/mandrel-plan.md +6 -5
  78. package/docs/CHANGELOG.md +39 -0
  79. package/lib/cli/guarded-sync.js +87 -0
  80. package/lib/cli/sync-agents.js +9 -92
  81. package/lib/cli/sync-commands.js +9 -101
  82. 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
  }
@@ -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-cleanup.js',
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)`
@@ -1,5 +1,5 @@
1
1
  /**
2
- * git-cleanup `main()`: argv, base-branch resolution, the four phases, and
2
+ * `clean-git.js` `main()`: argv, base-branch resolution, the four phases, and
3
3
  * the optional JSON envelope.
4
4
  *
5
5
  * @module lib/orchestration/git-cleanup/phases/cli
@@ -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}