mandrel 2.64.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 (105) hide show
  1. package/.agents/agents/acceptance-critic.md +8 -7
  2. package/.agents/agents/auditor.md +20 -20
  3. package/.agents/agents/plan-critic.md +8 -7
  4. package/.agents/agents/story-worker.md +7 -7
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/docs/execution-reference.md +27 -5
  9. package/.agents/instructions.md +10 -12
  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 +12 -3
  13. package/.agents/rules/git-conventions.md +9 -7
  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 -13
  17. package/.agents/schemas/audit-rules.schema.json +1 -1
  18. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  19. package/.agents/scripts/bootstrap.js +102 -91
  20. package/.agents/scripts/check-context-budget.js +1 -1
  21. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  22. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  23. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  24. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  25. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  26. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  27. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  28. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  29. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  30. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
  31. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  32. package/.agents/scripts/lib/cli-args.js +26 -0
  33. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  34. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  35. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  36. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  37. package/.agents/scripts/lib/doc-tiers.js +25 -6
  38. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  39. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  40. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  43. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  44. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  45. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  46. package/.agents/scripts/lib/orchestration/code-review.js +33 -6
  47. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  48. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  49. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  50. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  51. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  52. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  53. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
  54. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  55. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  57. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
  58. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
  63. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  64. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  65. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  66. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  68. package/.agents/scripts/lib/transpile.js +28 -3
  69. package/.agents/scripts/providers/github/issues.js +14 -23
  70. package/.agents/scripts/single-story-close.js +10 -2
  71. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  72. package/.agents/scripts/sync-claude-agents.js +1 -1
  73. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  74. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  75. package/.agents/workflows/audit-architecture.md +5 -4
  76. package/.agents/workflows/audit-documentation.md +5 -5
  77. package/.agents/workflows/audit-performance.md +10 -10
  78. package/.agents/workflows/audit-quality.md +42 -7
  79. package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
  80. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  81. package/.agents/workflows/helpers/code-review.md +15 -38
  82. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  83. package/.agents/workflows/helpers/deliver-reference.md +7 -3
  84. package/.agents/workflows/helpers/deliver-story.md +9 -1
  85. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  86. package/.agents/workflows/helpers/plan-reference.md +9 -8
  87. package/.agents/workflows/mandrel-deliver.md +3 -2
  88. package/.agents/workflows/mandrel-plan.md +11 -7
  89. package/.agents/workflows/mandrel-update.md +5 -3
  90. package/docs/CHANGELOG.md +57 -0
  91. package/lib/cli/claude-code-version.js +73 -0
  92. package/lib/cli/doctor.js +2 -2
  93. package/lib/cli/guarded-sync.js +87 -0
  94. package/lib/cli/registry.js +9 -0
  95. package/lib/cli/sync-agents.js +9 -92
  96. package/lib/cli/sync-commands.js +9 -101
  97. package/lib/cli/uninstall.js +37 -9
  98. package/lib/migrations/index.js +2 -0
  99. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  100. package/package.json +3 -2
  101. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  102. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  103. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  104. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  105. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -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
+ }
@@ -12,13 +12,11 @@ import {
12
12
  runArtifactPath,
13
13
  tempRootFrom,
14
14
  } from '../config/temp-paths.js';
15
- import { Logger } from '../Logger.js';
16
15
 
17
16
  export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
18
17
  export const PLAN_METRICS_SCHEMA_VERSION = 1;
19
18
 
20
19
  /** Evidence for dropping a lens that stays at zero findings. */
21
- const PLAN_METRICS_KIND_FINDINGS_YIELD = 'findings-yield';
22
20
 
23
21
  /** ~5000 records; rotation only fires on pathological accumulation. */
24
22
  export const MAX_LEDGER_BYTES = 1024 * 1024;
@@ -85,73 +83,3 @@ export async function appendLedgerRecord(record, opts = {}) {
85
83
  );
86
84
  await fs.appendFile(filePath, line, 'utf8');
87
85
  }
88
-
89
- /**
90
- * Append one findings-yield record. Never throws.
91
- *
92
- * @param {{
93
- * storyId: number,
94
- * lenses: Array<{ lens: string, findings?: number, skippedByFloor?: boolean }>,
95
- * cli?: string,
96
- * epicId?: number|null,
97
- * diffFloor?: object|null,
98
- * }} entry
99
- * @param {object} [config]
100
- * @param {{ maxBytes?: number }} [opts]
101
- * @returns {Promise<boolean>} true when the line was written.
102
- */
103
- export async function appendFindingsYield(entry, config, opts = {}) {
104
- try {
105
- if (!entry || typeof entry !== 'object') {
106
- throw new TypeError('appendFindingsYield requires an entry object');
107
- }
108
- const storyId = Number(entry.storyId);
109
- if (!Number.isInteger(storyId) || storyId <= 0) {
110
- throw new TypeError(
111
- 'appendFindingsYield requires a positive integer entry.storyId',
112
- );
113
- }
114
- if (!Array.isArray(entry.lenses) || entry.lenses.length === 0) {
115
- throw new TypeError(
116
- 'appendFindingsYield requires a non-empty entry.lenses array',
117
- );
118
- }
119
- const epicId = entry.epicId ?? null;
120
- const record = {
121
- v: PLAN_METRICS_SCHEMA_VERSION,
122
- kind: PLAN_METRICS_KIND_FINDINGS_YIELD,
123
- cli:
124
- typeof entry.cli === 'string' && entry.cli.length > 0
125
- ? entry.cli
126
- : 'story-close-review',
127
- storyId,
128
- epicId,
129
- lenses: entry.lenses
130
- .filter((l) => l && typeof l.lens === 'string' && l.lens.length > 0)
131
- .map((l) => ({
132
- lens: l.lens,
133
- findings:
134
- typeof l.findings === 'number' && Number.isFinite(l.findings)
135
- ? l.findings
136
- : 0,
137
- skippedByFloor: l.skippedByFloor === true,
138
- })),
139
- diffFloor:
140
- entry.diffFloor && typeof entry.diffFloor === 'object'
141
- ? entry.diffFloor
142
- : null,
143
- at: new Date().toISOString(),
144
- };
145
- await appendLedgerRecord(record, {
146
- epicId,
147
- config,
148
- maxBytes: opts.maxBytes,
149
- });
150
- return true;
151
- } catch (err) {
152
- Logger.warn(
153
- `[plan-metrics] findings-yield append failed (non-fatal): ${err?.message ?? err}`,
154
- );
155
- return false;
156
- }
157
- }
@@ -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,16 +112,22 @@ 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
  * }>}
118
119
  */
119
- /** Display name: the single entry's name, `chain[a,b]`, or `'native'`. */
120
- function resolveProviderName(codeReviewConfig) {
120
+ /**
121
+ * Display name: the single entry's name, `chain[a,b]`, or `'native'`. An
122
+ * unset chain names the entries the factory actually built, so a skipped
123
+ * optional provider is not reported as having run.
124
+ */
125
+ function resolveProviderName(codeReviewConfig, reviewProvider) {
126
+ const configured = Array.isArray(codeReviewConfig?.providers)
127
+ ? codeReviewConfig.providers
128
+ : [];
121
129
  const providers =
122
- codeReviewConfig && Array.isArray(codeReviewConfig.providers)
123
- ? codeReviewConfig.providers
124
- : [];
130
+ configured.length > 0 ? configured : (reviewProvider?.chain?.inline ?? []);
125
131
  if (providers.length === 1) {
126
132
  return providers[0]?.name ?? 'native';
127
133
  }
@@ -224,6 +230,21 @@ async function postReviewComment({
224
230
  }
225
231
  }
226
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
+
227
248
  async function executeReviewPipeline({ opts, config, envelope }) {
228
249
  const {
229
250
  provider,
@@ -236,9 +257,9 @@ async function executeReviewPipeline({ opts, config, envelope }) {
236
257
  const { scope, ticketId, baseRef, headRef, commentTargetId } = envelope;
237
258
 
238
259
  const codeReviewConfig = config?.delivery?.codeReview ?? null;
239
- const providerName = resolveProviderName(codeReviewConfig);
240
260
  const reviewProvider =
241
261
  injectedReviewProvider ?? createReviewProviderFn(codeReviewConfig);
262
+ const providerName = resolveProviderName(codeReviewConfig, reviewProvider);
242
263
 
243
264
  logger?.info?.(
244
265
  `[code-review] Running ${providerName} adapter for Story #${ticketId} (${baseRef}...${headRef})...`,
@@ -272,6 +293,11 @@ async function executeReviewPipeline({ opts, config, envelope }) {
272
293
  );
273
294
  const severity = countBySeverity(findings);
274
295
  const halted = hasSurvivingCritical(severity);
296
+ const criticalByProvider = resolveCriticalByProvider({
297
+ reviewProvider,
298
+ providerName,
299
+ severity,
300
+ });
275
301
  const report = renderFindingsFn({
276
302
  scope,
277
303
  ticketId,
@@ -299,6 +325,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
299
325
  postedCommentId,
300
326
  commentTargetId,
301
327
  halted,
328
+ criticalByProvider,
302
329
  ...degradationEnvelope(degradations),
303
330
  blockerReason: halted
304
331
  ? `code-review reported ${severity.critical} critical blocker(s)`
@@ -24,6 +24,7 @@ import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
24
24
  import {
25
25
  isEpicTicket,
26
26
  nativeChildReader,
27
+ readEpicChildIds,
27
28
  readEpicChildIdsFrom,
28
29
  } from './epic-container.js';
29
30
  import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
@@ -302,26 +303,29 @@ async function rollUpOneEpic({
302
303
 
303
304
  /**
304
305
  * Resolve this Story's container in one request via the native parent edge.
305
- * `null` (never throws) means "no answer here" — linkage may still exist as
306
- * a body checklist row, which the caller's scan covers.
306
+ * `authoritative`: the lookup answered, so no parent means no native edge.
307
307
  *
308
308
  * @param {{ storyId: number, provider: object }} opts
309
- * @returns {Promise<object|null>} Mapped parent Epic, or null.
309
+ * @returns {Promise<{ parent: object|null, authoritative: boolean }>}
310
310
  */
311
311
  async function parentEpicFor({ storyId, provider }) {
312
- if (typeof provider?.getParentIssue !== 'function') return null;
312
+ if (typeof provider?.getParentIssue !== 'function') {
313
+ return { parent: null, authoritative: false };
314
+ }
313
315
  let parent;
314
316
  try {
315
317
  parent = await provider.getParentIssue(storyId);
316
318
  } catch (err) {
317
319
  Logger.warn(
318
320
  `[epic-rollup] Parent lookup for Story #${storyId} degraded ` +
319
- `(${err?.message ?? err}); falling back to the label scan.`,
321
+ `(${err?.message ?? err}); falling back to the full label scan.`,
320
322
  );
321
- return null;
323
+ return { parent: null, authoritative: false };
322
324
  }
323
- if (!parent || !isEpicTicket(parent)) return null;
324
- return parent;
325
+ return {
326
+ parent: parent && isEpicTicket(parent) ? parent : null,
327
+ authoritative: true,
328
+ };
325
329
  }
326
330
 
327
331
  /**
@@ -349,6 +353,18 @@ async function scanContainerEpics({ provider }) {
349
353
  return (Array.isArray(epics) ? epics : []).filter(isEpicTicket);
350
354
  }
351
355
 
356
+ /**
357
+ * `bodyOnly` keeps only Epics whose checklist names the Story (no requests).
358
+ *
359
+ * @param {{ storyId: number, provider: object, bodyOnly: boolean }} opts
360
+ * @returns {Promise<object[]>}
361
+ */
362
+ async function candidateEpics({ storyId, provider, bodyOnly }) {
363
+ const epics = await scanContainerEpics({ provider });
364
+ if (!bodyOnly) return epics;
365
+ return epics.filter((epic) => readEpicChildIds(epic?.body).includes(storyId));
366
+ }
367
+
352
368
  /**
353
369
  * Find the container Epics holding a Story (Story bodies carry no parent
354
370
  * pointer). Native edge first, scan otherwise; a scanned Epic must prove it
@@ -358,9 +374,10 @@ async function scanContainerEpics({ provider }) {
358
374
  * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>>}
359
375
  */
360
376
  async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
361
- const parent = await parentEpicFor({ storyId, provider });
362
- const epics = parent ? [parent] : await scanContainerEpics({ provider });
363
- const authoritative = parent !== null;
377
+ const { parent, authoritative } = await parentEpicFor({ storyId, provider });
378
+ const epics = parent
379
+ ? [parent]
380
+ : await candidateEpics({ storyId, provider, bodyOnly: authoritative });
364
381
 
365
382
  const matches = [];
366
383
  for (const epic of epics) {
@@ -378,7 +395,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
378
395
  readNativeChildIds: nativeChildReader(provider),
379
396
  onWarn: (message) => Logger.warn(message),
380
397
  });
381
- if (!authoritative && !childIds.includes(storyId)) {
398
+ if (!parent && !childIds.includes(storyId)) {
382
399
  // A degraded read may have truncated this Story out; skip, but say so.
383
400
  if (nativeReadFailed) {
384
401
  Logger.warn(
@@ -77,6 +77,25 @@ function describeApiRaceFallback(prProbe, budget) {
77
77
  return 'no definitive block signal observed; classified as a transient API race or other condition';
78
78
  }
79
79
 
80
+ /**
81
+ * Positive evidence a check is still running. Attributed evidence scopes
82
+ * `requiredRunInFlight` to re-runs; `runInFlight` still says "something is
83
+ * running".
84
+ *
85
+ * @param {object} [prProbe]
86
+ * @returns {boolean}
87
+ */
88
+ function hasChecksPendingEvidence(prProbe) {
89
+ const status = prProbe?.checksStatus;
90
+ const evidence = prProbe?.requiredRunEvidence;
91
+ return (
92
+ status === 'pending' ||
93
+ status === 'still-running' ||
94
+ evidence?.requiredRunInFlight === true ||
95
+ evidence?.runInFlight === true
96
+ );
97
+ }
98
+
80
99
  /**
81
100
  * Classify why a delivery run finished without a confirmed merge. First match
82
101
  * wins: (1) arm failure (a protection rejection at arm time still routes to
@@ -123,10 +142,7 @@ export function classifyMergeBlock(input) {
123
142
  // Positive evidence only: `unknown` routes to the fallback; `undefined`
124
143
  // (no probe) keeps the step-2 mapping.
125
144
  const checksStatus = prProbe?.checksStatus;
126
- const checksPendingEvidence =
127
- checksStatus === 'pending' ||
128
- checksStatus === 'still-running' ||
129
- prProbe?.requiredRunEvidence?.requiredRunInFlight === true;
145
+ const checksPendingEvidence = hasChecksPendingEvidence(prProbe);
130
146
 
131
147
  // 1b. Definitive, and before step 3 since it also presents as BLOCKED.
132
148
  // Head-anchored evidence, not the raw rollup (optional/superseded runs).