@gethmy/harness 1.0.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Execution-only types. The daemon's types.ts keeps its config, claim and queue
3
+ * types; this file holds what the motor's build, metric and git modules need.
4
+ *
5
+ * The two config interfaces are deliberately NARROW: they name only the LEAF
6
+ * fields the motor actually reads, not the whole `claude` / `worktree` /
7
+ * `verification` sub-shape. The daemon's AgentConfig satisfies them structurally,
8
+ * so its call sites are unchanged — and the motor never learns the daemon's config
9
+ * shape beyond the handful of fields it touches. If a new field is needed here,
10
+ * add that one field, never the whole config object or a whole sub-shape.
11
+ */
12
+
13
+ /**
14
+ * One operator-declared, allowlisted measurement a `custom` gate can run (#690).
15
+ *
16
+ * Executed in argv form (`command` + `args`) — never through a shell — so nothing
17
+ * in the value is ever word-split, glob-expanded, or interpolated. Both fields are
18
+ * fixed by the operator; a gate contributes only the *choice* of which metric runs.
19
+ */
20
+ export interface PlaybookMetricDef {
21
+ /** Executable to run, resolved on PATH. Never a shell string. */
22
+ command: string;
23
+ /** Fixed arguments. Operator-declared; a gate can never append to these. */
24
+ args?: string[];
25
+ /**
26
+ * How to read the measured value out of the command's stdout:
27
+ * - `"number"` — the whole trimmed stdout parsed as a number.
28
+ * - `"json:<path>"` — stdout parsed as JSON, then the dot-path resolved
29
+ * (e.g. `"json:categories.performance.score"`), reusing
30
+ * the evaluator's own prototype-safe `resolvePath`.
31
+ * Anything else is refused at collection time (fail closed), never guessed.
32
+ */
33
+ parse: string;
34
+ /**
35
+ * Per-metric timeout in ms. Defaults to {@link DEFAULT_METRIC_TIMEOUT_MS} and
36
+ * is CLAMPED to a 15-minute ceiling — the run is synchronous and the daemon is
37
+ * a single process, so a larger value would stall the watcher, the health
38
+ * endpoint and every other worker rather than just this measurement. A timeout
39
+ * is `blocked` (no measurement was produced), never a silent pass.
40
+ */
41
+ timeoutMs?: number;
42
+ }
43
+
44
+ /** Default wall-clock cap for one metric command (#690). Real measurement tools
45
+ * (a Lighthouse run, a benchmark) outrun a build, so this is deliberately generous
46
+ * — but never unbounded. */
47
+ export const DEFAULT_METRIC_TIMEOUT_MS = 300_000; // 5 minutes
48
+
49
+ /**
50
+ * The slice of the daemon's config that verification actually reads — leaf
51
+ * fields only, not the whole `claude` / `worktree` / `verification` sub-shape.
52
+ */
53
+ export interface VerificationConfig {
54
+ claude: {
55
+ /**
56
+ * Base model for implement runs — the default tier every card lands on
57
+ * unless it escalates per below. (#354 will let a card pick its own model;
58
+ * until then this is the fallback for all cards.)
59
+ */
60
+ model: string;
61
+ /**
62
+ * `--setting-sources` value for lean spawns (review, auto-fix, deep-review)
63
+ * that don't need the project CLAUDE.md / @-imports (~23KB). `"local,user"`
64
+ * keeps the local-scoped harmony MCP server + user settings while dropping
65
+ * the project docs. Empty string omits the flag (full default behaviour).
66
+ */
67
+ leanSettingSources: string;
68
+ };
69
+ worktree: {
70
+ baseBranch: string;
71
+ };
72
+ verification: {
73
+ build: boolean;
74
+ lint: boolean;
75
+ /**
76
+ * Run the repo's test suite before a branch reaches Review (#688). Unlike
77
+ * lint, a failing suite BLOCKS completion — a run that breaks tests is a
78
+ * failed attempt, not a Review-ready branch. Repos with no resolvable test
79
+ * command (see `project-type.ts`) skip-and-warn regardless of this flag.
80
+ * Default on; set `false` to opt out per repo (e.g. a suite too slow to run
81
+ * on every attempt).
82
+ */
83
+ test: boolean;
84
+ deepReview: boolean;
85
+ /**
86
+ * Block a branch that deletes a test/spec file relative to current main
87
+ * before it reaches Review — a deleted regression test is the signature of
88
+ * an accidental revert of already-merged work (#408). Default on.
89
+ */
90
+ revertGuard: boolean;
91
+ devServerBasePort: number;
92
+ /** Timeout for the build / lint / auto-fix steps. */
93
+ timeout: number;
94
+ /**
95
+ * Timeout for the test step (#688). Separate from `timeout` because a real
96
+ * suite routinely outruns a build: a 2-minute cap would kill it mid-run and
97
+ * report a timeout as a test failure.
98
+ */
99
+ testTimeout: number;
100
+ };
101
+ }
102
+
103
+ /** The slice of the daemon's config that the git helpers actually read. */
104
+ export interface WorktreeConfig {
105
+ worktree: VerificationConfig["worktree"];
106
+ }
107
+
108
+ /** How a PR is integrated. Re-declared here so the motor needs no daemon import. */
109
+ export type MergeStrategy = "squash" | "merge" | "rebase";
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Gate evidence collectors + dispatcher (Playbooks P1 #3, card #516).
3
+ *
4
+ * This is the daemon half of the #515 gate runner. Each collector turns the live
5
+ * world into a `GateEvidence`:
6
+ * - build_green → ran the worktree build/lint (verification.ts) + dev-server probe
7
+ * - review_passed → the parsed review verdict + #478 gated-acceptance checks
8
+ * - checklist/dod → the card's subtasks / done state
9
+ * - artifact → #517's LLM rubric judge over the produced artifact
10
+ * - custom → #690's allowlisted command metric (a number to gate on)
11
+ * - oracle_passed → the held test authored by the stage's `author` role, run
12
+ * against the implementer's worktree (Playbooks P2, Task 12)
13
+ *
14
+ * The flow per gate, run daemon-side after a stage's work completes:
15
+ * collector.collect(ctx) → GateEvidence
16
+ * → gateEvaluate(gate, evidence) // shared, pure — the single verdict
17
+ * → toStageGateEvidenceInsert(ctx, evid) // shared, pure — the row shape
18
+ * → client.recordStageGateEvidence(insert) // service-role write via the API
19
+ *
20
+ * The daemon has NO service-role Supabase client (it only holds an anon Realtime
21
+ * client + the API-key REST client). So evidence is persisted by POSTing to a new
22
+ * harmony-api endpoint that writes with the edge's service-role client — one write
23
+ * path, RLS-honest. See `recordStageGateEvidence` on the API client.
24
+ *
25
+ * Registry: {@link buildGateCollectorRegistry} maps `GateKind → GateEvidenceCollector`.
26
+ * #517's artifact collector is added by registering one more entry — the dispatcher
27
+ * (`collectGateEvidence`) is closed for modification, open for extension.
28
+ */
29
+
30
+ import type { Card, Subtask } from "@harmony/shared";
31
+ import {
32
+ type GateEvidence,
33
+ type GateEvidenceCollector,
34
+ type GateEvidenceContext,
35
+ type GateKind,
36
+ type GateResult,
37
+ type GateSpec,
38
+ isGateKind,
39
+ type PlaybookStageDef,
40
+ resolveStageDef,
41
+ } from "@harmony/shared";
42
+ import { ArtifactCollector, type ArtifactJudgeDeps } from "./artifact-judge.js";
43
+ import {
44
+ CommandMetricCollector,
45
+ type CommandMetricDeps,
46
+ } from "./command-metric.js";
47
+ import { log } from "./log.js";
48
+ import type { OracleDeps } from "./oracle.js";
49
+ import { OracleCollector } from "./oracle-collector.js";
50
+ import type { ReviewResult } from "./review-types.js";
51
+ import { runBuild, runLint } from "./verification.js";
52
+
53
+ const TAG = "gate-collectors";
54
+
55
+ /** The minimal client request seam the gate helpers need. */
56
+ type GateRequestFn = (
57
+ method: string,
58
+ path: string,
59
+ ) => Promise<{ version: { steps: unknown; steps_version: number } }>;
60
+
61
+ /**
62
+ * Resolve a card's bound stage and its normalized gate from the PINNED
63
+ * `playbook_versions` snapshot (never live `playbooks.steps` — editing a playbook
64
+ * must not retroactively change an in-flight card; same rule as the #514 stage
65
+ * executor). Returns `null` when playbooks are off, the card isn't staged, the pin
66
+ * is missing, or the stage / its gate can't be resolved.
67
+ *
68
+ * FAIL-CLOSED + total — never throws. Shared by the implement worker (build_green)
69
+ * and the review worker (review_passed) so both read the gate the same way.
70
+ */
71
+ export async function resolveStageGate(
72
+ client: { request: GateRequestFn },
73
+ card: Pick<Card, "current_stage" | "playbook_id" | "playbook_version">,
74
+ ): Promise<{ stage: PlaybookStageDef; gate: GateSpec } | null> {
75
+ const currentStage = card.current_stage;
76
+ const playbookId = card.playbook_id;
77
+ const version = card.playbook_version;
78
+ if (!currentStage || !playbookId || version == null) return null;
79
+ try {
80
+ const res = await client.request(
81
+ "GET",
82
+ `/playbooks/${encodeURIComponent(playbookId)}/versions/${version}`,
83
+ );
84
+ const def = res.version;
85
+ const resolution = resolveStageDef(def, currentStage);
86
+ if (resolution.kind !== "found") return null;
87
+ const gate = normalizeGateSpec(resolution.stage.gate);
88
+ if (!gate) return null;
89
+ return { stage: resolution.stage, gate };
90
+ } catch (err) {
91
+ log.warn(
92
+ TAG,
93
+ `resolveStageGate failed for stage "${currentStage}": ${err instanceof Error ? err.message : err}`,
94
+ );
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Gate spec normalization (stage.gate Record → GateSpec)
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Normalize a stage's stored `gate` (`PlaybookStageDef.gate`, a
105
+ * `Record<string, unknown> | null`) into a typed {@link GateSpec}, or `null` when
106
+ * the stage declares no gate / the record is malformed.
107
+ *
108
+ * Fail-closed on `kind`: an unknown/missing kind yields `null` so the caller treats
109
+ * the stage as ungated rather than inventing a gate. (The evaluator also fails
110
+ * closed on a bad spec — this just gives the collector layer a typed handle and a
111
+ * clean "no gate here" signal.) `conditions`/`mode`/`pendingEngine` pass through
112
+ * untouched; the evaluator owns their validation.
113
+ *
114
+ * `metric` (#690) also passes through as an opaque string — it is the name of an
115
+ * operator-allowlisted measurement the `custom` collector looks up. Validating it
116
+ * here would be wrong: whether a name is permitted depends on the *daemon's* own
117
+ * config, which this pure normalizer has no view of, and the collector reports an
118
+ * undeclared name as a legible `blocked` rather than a silently dropped field.
119
+ */
120
+ export function normalizeGateSpec(
121
+ gate: Record<string, unknown> | null | undefined,
122
+ ): GateSpec | null {
123
+ if (!gate || typeof gate !== "object") return null;
124
+ const kind = (gate as { kind?: unknown }).kind;
125
+ if (!isGateKind(kind)) return null;
126
+ const spec: GateSpec = { kind: kind as GateKind };
127
+ const pendingEngine = (gate as { pendingEngine?: unknown }).pendingEngine;
128
+ if (typeof pendingEngine === "boolean") spec.pendingEngine = pendingEngine;
129
+ const conditions = (gate as { conditions?: unknown }).conditions;
130
+ if (Array.isArray(conditions))
131
+ spec.conditions = conditions as GateSpec["conditions"];
132
+ const mode = (gate as { mode?: unknown }).mode;
133
+ if (mode === "all" || mode === "any") spec.mode = mode;
134
+ const metric = (gate as { metric?: unknown }).metric;
135
+ if (typeof metric === "string" && metric.length > 0) spec.metric = metric;
136
+ return spec;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // build_green collector
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Dependencies the build-green collector needs to inspect the live worktree. The
145
+ * daemon injects them at construction time; `collect(ctx)` is then a thin call into
146
+ * the existing verification primitives. Injecting (rather than reaching for module
147
+ * globals) keeps the collector unit-testable with a fake build/lint.
148
+ */
149
+ export interface BuildGreenDeps {
150
+ worktreePath: string;
151
+ buildTimeout: number;
152
+ lintTimeout: number;
153
+ /** Inject for tests; defaults to verification.ts `runBuild`. Returns error lines. */
154
+ runBuild?: (worktreePath: string, timeout: number) => string[];
155
+ /** Inject for tests; defaults to verification.ts `runLint`. Returns warning lines. */
156
+ runLint?: (worktreePath: string, timeout: number) => string[];
157
+ }
158
+
159
+ /**
160
+ * build_green — Slice-1 gate. Runs the worktree's build + lint and reports
161
+ * structured pass/fail. The dev-server probe is review-pipeline state, so the
162
+ * implement-side collector reports build + lint only.
163
+ *
164
+ * The coarse `result` MIRRORS `verification.ts`: a build is "green" when the
165
+ * **build** passes (exit 0). Lint findings are **non-fatal warnings** — exactly
166
+ * as `runVerification` treats them (it logs lint issues but only `buildErrors`
167
+ * flip `passed`). This keeps the gate from being *stricter* than the daemon's own
168
+ * verification: a repo with a pre-existing, repo-wide lint baseline (e.g. the
169
+ * known `harmony-api/index.ts` issues) would otherwise make EVERY card's
170
+ * build_green gate `failed` even when the card's own change is clean and the
171
+ * daemon already advanced it to Review (#552, surfaced by the #523 dogfood).
172
+ *
173
+ * The full lint detail is still carried in `structured.lint` (`passed`,
174
+ * `warnings`), so a playbook author who wants a lint-clean bar can OPT IN with a
175
+ * gate condition `{ path: "lint.passed", op: "eq", value: true }` (#515's
176
+ * predicate mechanism). The default for a bare `{ kind: "build_green" }` gate is
177
+ * the verification-matching "build passes" semantics; strict-lint is a choice,
178
+ * not the floor.
179
+ *
180
+ * structured shape (predicate paths address into this):
181
+ * {
182
+ * build: { passed: boolean, errors: string[] },
183
+ * lint: { passed: boolean, warnings: string[] }
184
+ * }
185
+ */
186
+ export class BuildGreenCollector implements GateEvidenceCollector {
187
+ readonly kind: GateKind = "build_green";
188
+ constructor(private readonly deps: BuildGreenDeps) {}
189
+
190
+ async collect(_context: GateEvidenceContext): Promise<GateEvidence> {
191
+ const doBuild = this.deps.runBuild ?? runBuild;
192
+ const doLint = this.deps.runLint ?? runLint;
193
+ const buildErrors = doBuild(this.deps.worktreePath, this.deps.buildTimeout);
194
+ const lintWarnings = doLint(this.deps.worktreePath, this.deps.lintTimeout);
195
+ const buildPassed = buildErrors.length === 0;
196
+ const lintPassed = lintWarnings.length === 0;
197
+ // Match verification.ts: only a failing BUILD makes the gate red. Lint
198
+ // warnings are non-fatal (kept in `structured.lint` for an opt-in condition).
199
+ const result: GateResult = buildPassed ? "passed" : "failed";
200
+ return {
201
+ result,
202
+ structured: {
203
+ build: { passed: buildPassed, errors: buildErrors },
204
+ lint: { passed: lintPassed, warnings: lintWarnings },
205
+ },
206
+ };
207
+ }
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // review_passed collector
212
+ // ---------------------------------------------------------------------------
213
+
214
+ export interface ReviewPassedDeps {
215
+ /** The parsed review verdict from review-completion's `parseReviewOutput`. */
216
+ review: ReviewResult;
217
+ }
218
+
219
+ /**
220
+ * review_passed — reads the Playbook DoD contract via the #478 gated-acceptance
221
+ * review output. The verdict is the coarse signal; the per-criterion acceptance
222
+ * checks are the structured detail a predicate can gate on.
223
+ *
224
+ * `parseReviewOutput` already folds any failed/partial acceptance criterion into a
225
+ * `rejected` verdict (#478), so `verdict === "approved"` already implies every
226
+ * criterion passed. We surface both so a gate can predicate on either the coarse
227
+ * verdict or a specific criterion.
228
+ *
229
+ * structured shape:
230
+ * {
231
+ * verdict: "approved" | "rejected" | "error",
232
+ * acceptanceChecks: [{ criterion, status }],
233
+ * unmetCount: number
234
+ * }
235
+ */
236
+ export class ReviewPassedCollector implements GateEvidenceCollector {
237
+ readonly kind: GateKind = "review_passed";
238
+ constructor(private readonly deps: ReviewPassedDeps) {}
239
+
240
+ async collect(_context: GateEvidenceContext): Promise<GateEvidence> {
241
+ const { review } = this.deps;
242
+ const checks = review.acceptanceChecks ?? [];
243
+ const unmetCount = checks.filter(
244
+ (c) => c.status === "fail" || c.status === "partial",
245
+ ).length;
246
+ // An "error" verdict means the reviewer produced no parseable judgement — the
247
+ // collector can't assert pass/fail, so it's `blocked` (the gate can't be met
248
+ // on an unparseable review).
249
+ const result: GateResult =
250
+ review.verdict === "approved"
251
+ ? "passed"
252
+ : review.verdict === "rejected"
253
+ ? "failed"
254
+ : "blocked";
255
+ return {
256
+ result,
257
+ structured: {
258
+ verdict: review.verdict,
259
+ acceptanceChecks: checks.map((c) => ({
260
+ criterion: c.criterion,
261
+ status: c.status,
262
+ })),
263
+ unmetCount,
264
+ },
265
+ };
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // checklist / dod collector
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export interface ChecklistDodDeps {
274
+ subtasks: Subtask[];
275
+ /** Whether the card itself is marked done (the DoD coarse signal). */
276
+ cardDone: boolean;
277
+ }
278
+
279
+ /**
280
+ * checklist + dod — reads the card's subtasks (the checklist) and `done` state (the
281
+ * definition-of-done signal). Both kinds share one collector body; the gate `kind`
282
+ * picks which the dispatcher routes here. A gate with no explicit conditions passes
283
+ * iff `result === "passed"`, which for this collector means: every subtask complete
284
+ * AND (for `dod`) the card marked done.
285
+ *
286
+ * structured shape:
287
+ * {
288
+ * total: number, completed: number, allComplete: boolean,
289
+ * cardDone: boolean,
290
+ * items: [{ id, title, completed }]
291
+ * }
292
+ */
293
+ export class ChecklistDodCollector implements GateEvidenceCollector {
294
+ constructor(
295
+ readonly kind: GateKind,
296
+ private readonly deps: ChecklistDodDeps,
297
+ ) {}
298
+
299
+ async collect(_context: GateEvidenceContext): Promise<GateEvidence> {
300
+ const { subtasks, cardDone } = this.deps;
301
+ const total = subtasks.length;
302
+ const completed = subtasks.filter((s) => s.completed).length;
303
+ const allComplete = total === 0 ? cardDone : completed === total;
304
+ // `dod` additionally requires the card itself to be marked done; `checklist`
305
+ // only cares about the subtasks.
306
+ const passed = this.kind === "dod" ? allComplete && cardDone : allComplete;
307
+ const result: GateResult = passed ? "passed" : "failed";
308
+ return {
309
+ result,
310
+ structured: {
311
+ total,
312
+ completed,
313
+ allComplete,
314
+ cardDone,
315
+ items: subtasks.map((s) => ({
316
+ id: s.id,
317
+ title: s.title,
318
+ completed: s.completed,
319
+ })),
320
+ },
321
+ };
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Registry + dispatcher
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * The dependencies the daemon has on hand at the gate-collection point, from which
331
+ * the registry constructs each kind's collector. A field may be absent if the
332
+ * pipeline that produces it hasn't run for this gate (e.g. no `review` on the
333
+ * implement side) — the dispatcher only instantiates a collector when the gate's
334
+ * kind has what it needs, else it reports the gate as `blocked`.
335
+ */
336
+ export interface GateCollectorDeps {
337
+ build?: BuildGreenDeps;
338
+ review?: ReviewResult;
339
+ checklist?: ChecklistDodDeps;
340
+ /** #517 artifact gate — deps for the LLM-judge collector (worktree + rubric judge). */
341
+ artifact?: ArtifactJudgeDeps;
342
+ /**
343
+ * #690 `custom` command gate — the worktree to measure in plus the operator's
344
+ * metric allowlist. Supply it whenever a worktree exists, even with an EMPTY
345
+ * allowlist: the collector then reports "metric X is not declared" and names the
346
+ * config key to edit, which is a far better hold reason than the dispatcher's
347
+ * generic "no collector for kind custom". Both are `blocked` — only one tells
348
+ * the operator what to do about it.
349
+ */
350
+ command?: CommandMetricDeps;
351
+ /**
352
+ * The oracle_passed gate: the held test fetched from Harmony, placed in the
353
+ * worktree, run, removed. Supply it whenever a worktree exists.
354
+ */
355
+ oracle?: OracleDeps;
356
+ }
357
+
358
+ /**
359
+ * Build the `GateKind → GateEvidenceCollector` registry for the available deps.
360
+ * Only kinds whose deps are present get a collector; the rest are absent (the
361
+ * dispatcher treats an absent collector as `blocked`).
362
+ *
363
+ * Open for extension (#517): register an `artifact` collector here once its deps
364
+ * are wired. The dispatcher below never changes.
365
+ */
366
+ export function buildGateCollectorRegistry(
367
+ deps: GateCollectorDeps,
368
+ ): Partial<Record<GateKind, GateEvidenceCollector>> {
369
+ const registry: Partial<Record<GateKind, GateEvidenceCollector>> = {};
370
+ if (deps.build) {
371
+ registry.build_green = new BuildGreenCollector(deps.build);
372
+ }
373
+ if (deps.review) {
374
+ registry.review_passed = new ReviewPassedCollector({ review: deps.review });
375
+ }
376
+ if (deps.checklist) {
377
+ registry.checklist = new ChecklistDodCollector("checklist", deps.checklist);
378
+ registry.dod = new ChecklistDodCollector("dod", deps.checklist);
379
+ }
380
+ if (deps.artifact) {
381
+ registry.artifact = new ArtifactCollector(deps.artifact);
382
+ }
383
+ if (deps.command) {
384
+ registry.custom = new CommandMetricCollector(deps.command);
385
+ }
386
+ if (deps.oracle) {
387
+ registry.oracle_passed = new OracleCollector(deps.oracle);
388
+ }
389
+ return registry;
390
+ }
391
+
392
+ /**
393
+ * Run the collector for `context.gate.kind`, or report `blocked` when no collector
394
+ * is registered for that kind (its producing pipeline didn't run, or the kind has
395
+ * no daemon collector yet — `label` is the last such kind; `custom` gained one in
396
+ * #690 and is registered wherever a worktree exists).
397
+ *
398
+ * `blocked` evidence is honest: the evaluator can't pass a gate it has no signal
399
+ * for, and the human-advance edge renders a "blocked" gate distinctly from a
400
+ * "failed" one. Never throws — a collector that throws is caught and downgraded to
401
+ * `blocked` with the error noted in `structured.error`, so a flaky collector can't
402
+ * crash the worker.
403
+ */
404
+ export async function collectGateEvidence(
405
+ registry: Partial<Record<GateKind, GateEvidenceCollector>>,
406
+ context: GateEvidenceContext,
407
+ ): Promise<GateEvidence> {
408
+ const collector = registry[context.gate.kind];
409
+ if (!collector) {
410
+ log.info(
411
+ TAG,
412
+ `No collector for gate kind "${context.gate.kind}" — reporting blocked`,
413
+ );
414
+ return {
415
+ result: "blocked",
416
+ structured: {
417
+ reason: `No daemon collector for gate kind "${context.gate.kind}".`,
418
+ },
419
+ };
420
+ }
421
+ try {
422
+ return await collector.collect(context);
423
+ } catch (err) {
424
+ const msg = err instanceof Error ? err.message : String(err);
425
+ log.warn(
426
+ TAG,
427
+ `Collector for "${context.gate.kind}" threw: ${msg} — reporting blocked`,
428
+ );
429
+ return { result: "blocked", structured: { error: msg } };
430
+ }
431
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The "this gate cannot be measured as configured" marker (card #823).
3
+ *
4
+ * A gate that reports `blocked` says only that no measurement exists. Two very
5
+ * different situations produce it, and the advancement engine must not treat them
6
+ * alike:
7
+ *
8
+ * - **The measurement failed.** The command exited non-zero, timed out, or
9
+ * printed something unparsable. A re-run can genuinely change the outcome —
10
+ * the tool may be flaky, the branch may be fixed by the next attempt — so this
11
+ * keeps the existing retry path (`handleGateUnmet`).
12
+ * - **The gate is misconfigured.** The metric name is undeclared, the
13
+ * declaration carries no `command`, or its `parse` mode is unknown. Nothing
14
+ * ran and nothing ever will: the inputs are static config, so attempt N+1
15
+ * computes exactly the same answer as attempt 1. Re-running costs a full
16
+ * Claude implementation run each time and cannot help.
17
+ *
18
+ * Before this marker existed, both routed into the generic "gate unmet → re-run
19
+ * this stage" path, so a typo'd metric name burned the card's whole `maxAttempts`
20
+ * budget (and a converge loop's whole iteration budget) before holding. The
21
+ * collector now tags the config-defect blocks, and `stage-advance.ts` holds on
22
+ * them immediately with the attempt rolled back.
23
+ *
24
+ * The marker is a plain own key on the evidence's `structured` doc, so it survives
25
+ * the round-trip every other field takes: `gateEvaluate` echoes `structured` into
26
+ * its `GateEvaluation` verbatim, and `stage_gate_evidence.structured` persists it
27
+ * for the board/edge to read later. It is deliberately NOT part of the gate
28
+ * predicate surface — `blockedDetail` composes the human-facing finding from
29
+ * `structured.reason` alone, so adding this key changes no verdict text.
30
+ *
31
+ * Fail-safe by omission: evidence without the key reads as retryable, which is the
32
+ * pre-#823 behavior. A collector opts a block IN to the hold-immediately path; it
33
+ * can never opt one out by accident.
34
+ */
35
+
36
+ /** Own key on `GateEvidence.structured` marking a block as a configuration defect. */
37
+ export const GATE_CONFIG_ERROR_KEY = "configError";
38
+
39
+ /** The structured fragment a collector spreads into config-defect `blocked` evidence. */
40
+ export const GATE_CONFIG_ERROR_MARK: Readonly<Record<string, unknown>> =
41
+ Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
42
+
43
+ /** The minimal evaluation shape this predicate reads — structurally a `GateEvaluation`. */
44
+ interface EvaluationLike {
45
+ passed: boolean;
46
+ structured: Record<string, unknown>;
47
+ }
48
+
49
+ /**
50
+ * The reason a gate cannot be measured as configured, or `null` when this is a
51
+ * normal (retryable) verdict. PURE + TOTAL — never throws, so a malformed
52
+ * structured doc degrades to "retryable" rather than crashing the engine.
53
+ *
54
+ * A *passing* evaluation is never a config error: `gateEvaluate` cannot pass
55
+ * blocked evidence, so a marked-and-passed evaluation would mean the marker was
56
+ * forged into a passing doc. Reading `passed` first makes that unreachable instead
57
+ * of merely unlikely.
58
+ */
59
+ export function gateConfigErrorReason(
60
+ evaluation: EvaluationLike | null | undefined,
61
+ ): string | null {
62
+ if (!evaluation || evaluation.passed) return null;
63
+ const structured = evaluation.structured;
64
+ if (!structured || typeof structured !== "object") return null;
65
+ if (!Object.hasOwn(structured, GATE_CONFIG_ERROR_KEY)) return null;
66
+ if ((structured as Record<string, unknown>)[GATE_CONFIG_ERROR_KEY] !== true) {
67
+ return null;
68
+ }
69
+ const reason = (structured as Record<string, unknown>).reason;
70
+ return typeof reason === "string" && reason.trim().length > 0
71
+ ? reason.trim()
72
+ : "the gate cannot be measured as configured";
73
+ }