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.
- package/.agents/agents/acceptance-critic.md +8 -7
- package/.agents/agents/auditor.md +20 -20
- package/.agents/agents/plan-critic.md +8 -7
- package/.agents/agents/story-worker.md +7 -7
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +1 -9
- package/.agents/docs/configuration.md +8 -7
- 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/schemas/agentrc.schema.json +6 -13
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
- package/.agents/scripts/bootstrap.js +102 -91
- package/.agents/scripts/check-context-budget.js +1 -1
- package/.agents/scripts/lib/ITicketingProvider.js +1 -3
- package/.agents/scripts/lib/audit-suite/findings.js +1 -17
- package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
- package/.agents/scripts/lib/audit-suite/index.js +0 -6
- package/.agents/scripts/lib/audit-suite/selector.js +0 -31
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
- package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
- package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config/review-chain-default.js +13 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
- package/.agents/scripts/lib/doc-tiers.js +25 -6
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
- 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 +33 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
- package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
- package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/providers/github/issues.js +14 -23
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/sync-claude-agents.js +1 -1
- 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/audit-quality.md +42 -7
- package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/code-review.md +15 -38
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +7 -3
- package/.agents/workflows/helpers/deliver-story.md +9 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/helpers/plan-reference.md +9 -8
- package/.agents/workflows/mandrel-deliver.md +3 -2
- package/.agents/workflows/mandrel-plan.md +11 -7
- package/.agents/workflows/mandrel-update.md +5 -3
- package/docs/CHANGELOG.md +57 -0
- package/lib/cli/claude-code-version.js +73 -0
- package/lib/cli/doctor.js +2 -2
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/registry.js +9 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- package/lib/cli/uninstall.js +37 -9
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
- package/package.json +3 -2
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
- package/.agents/scripts/lib/audit-suite/runner.js +0 -205
- package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
- package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
- 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
|
-
|
|
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,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
|
-
/**
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
|
309
|
+
* @returns {Promise<{ parent: object|null, authoritative: boolean }>}
|
|
310
310
|
*/
|
|
311
311
|
async function parentEpicFor({ storyId, provider }) {
|
|
312
|
-
if (typeof provider?.getParentIssue !== 'function')
|
|
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
|
-
|
|
324
|
-
|
|
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
|
|
363
|
-
|
|
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 (!
|
|
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).
|