@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.
- package/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -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
|
+
}
|