@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,149 @@
1
+ import {
2
+ type ChildProcess,
3
+ type SpawnOptions,
4
+ spawn,
5
+ } from "node:child_process";
6
+ import { log } from "./log.js";
7
+
8
+ const TAG = "pgroup";
9
+
10
+ /**
11
+ * Spawn a child in its own process group so we can reliably kill the
12
+ * whole subtree later. The Claude CLI shells out to git, build tools,
13
+ * dev servers, etc. — signalling only the direct child leaves orphans.
14
+ *
15
+ * - POSIX: `detached: true` puts the child in a new process group whose
16
+ * pgid equals its pid. Killing the negative pid signals every member.
17
+ * - Windows: `detached: true` creates a new process group that can be
18
+ * signalled via the child's pid (no negation).
19
+ *
20
+ * `stripEnvKeys` (Task 15) DELETES the named variables from the environment
21
+ * assembled below. A caller cannot get that by passing a smaller `options.env`:
22
+ * the merge starts from `process.env`, so a key the caller merely omits still
23
+ * survives from the parent. This is the only place a child's environment is
24
+ * assembled here, so it is the only place a deletion sticks. It removes exactly
25
+ * the named keys and nothing else.
26
+ */
27
+ export function spawnInGroup(
28
+ command: string,
29
+ args: readonly string[],
30
+ options: SpawnOptions & { stripEnvKeys?: readonly string[] } = {},
31
+ ): ChildProcess {
32
+ const { stripEnvKeys, ...spawnOptions } = options;
33
+ const env: NodeJS.ProcessEnv = {
34
+ ...process.env,
35
+ ...options.env,
36
+ // When the daemon is launched from a cmux terminal, a bare `claude`
37
+ // resolves to cmux's wrapper script, which injects a `--settings` block
38
+ // with a SessionEnd hook (`cmux hooks claude session-end`). Our headless
39
+ // run is not a tracked cmux surface, so that hook is cancelled and Claude
40
+ // exits with code 1 — a *false* run failure (the work itself succeeded).
41
+ // `CMUX_CLAUDE_HOOKS_DISABLED=1` is the wrapper's documented passthrough
42
+ // escape hatch: it execs the real claude with no hook injection. Harmless
43
+ // for non-claude children (only the cmux wrapper reads it).
44
+ CMUX_CLAUDE_HOOKS_DISABLED: "1",
45
+ };
46
+ // DELETE, after the merge. Omitting a key from `options.env` would not remove
47
+ // it — the merge above re-supplies it from `process.env`.
48
+ for (const key of stripEnvKeys ?? []) delete env[key];
49
+
50
+ return spawn(command, args as string[], {
51
+ ...spawnOptions,
52
+ detached: true,
53
+ // Keep stdio wired up so streaming still works.
54
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
55
+ env,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Send a signal to every process in the group whose leader is `proc`.
61
+ * On POSIX, this is `process.kill(-pid, signal)`. If the group has
62
+ * already exited, returns silently.
63
+ */
64
+ export function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
65
+ if (!proc.pid || proc.killed) return;
66
+ try {
67
+ if (process.platform === "win32") {
68
+ // No process groups on Windows; best effort tree kill via the child.
69
+ proc.kill(signal);
70
+ return;
71
+ }
72
+ process.kill(-proc.pid, signal);
73
+ } catch (err) {
74
+ // ESRCH means the group is already gone — that is the goal, not an error.
75
+ const code = (err as NodeJS.ErrnoException).code;
76
+ if (code !== "ESRCH") {
77
+ log.warn(
78
+ TAG,
79
+ `signal ${signal} to pgid ${proc.pid} failed: ${err instanceof Error ? err.message : err}`,
80
+ );
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Force-kill every process still in group `pgid`, addressed by the RECORDED
87
+ * pgid rather than a live ChildProcess. Unlike {@link signalGroup} /
88
+ * {@link terminateGroup} — which early-return once the group LEADER has exited —
89
+ * this still fires after a normal run finish, so it reaps backgrounded /
90
+ * `disown`ed / `nohup`ed grandchildren that reparented to pid 1 but kept the
91
+ * pgid (card #436: an SDK/CLI run that ends on its own used to leak them).
92
+ *
93
+ * Limitation: a grandchild that calls `setsid()` itself leaves the group and is
94
+ * NOT reachable this way. We accept that rare, deliberate escape — attributing
95
+ * it would require reading other processes' environments, which is unreliable on
96
+ * macOS (`ps -E` is restricted there and `setsid` isn't even available).
97
+ *
98
+ * Safety: pgid 0/1 (and the caller's own pid) are refused — `kill(-1)` would
99
+ * signal every process the user owns, and `kill(-0)` the caller's own group.
100
+ */
101
+ export function reapGroup(pgid: number | undefined): void {
102
+ if (!pgid || pgid <= 1 || pgid === process.pid) return;
103
+ if (process.platform === "win32") return; // no process groups to sweep
104
+ try {
105
+ process.kill(-pgid, "SIGKILL");
106
+ } catch (err) {
107
+ // ESRCH means the group is already empty — that is the goal, not an error.
108
+ const code = (err as NodeJS.ErrnoException).code;
109
+ if (code !== "ESRCH") {
110
+ log.warn(
111
+ TAG,
112
+ `reapGroup(${pgid}) failed: ${err instanceof Error ? err.message : err}`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Escalating termination: SIGINT → wait → SIGTERM → wait → SIGKILL.
120
+ * Returns when the process has exited or all signals have been sent.
121
+ */
122
+ export async function terminateGroup(
123
+ proc: ChildProcess,
124
+ opts: { sigintTimeoutMs: number; sigtermTimeoutMs: number },
125
+ ): Promise<void> {
126
+ if (!proc.pid || proc.killed) return;
127
+
128
+ // Unpause first in case the process was suspended — otherwise it
129
+ // can't react to signals.
130
+ signalGroup(proc, "SIGCONT");
131
+
132
+ const waitForExit = (timeout: number): Promise<boolean> =>
133
+ new Promise((resolve) => {
134
+ if (proc.killed || proc.exitCode !== null) return resolve(true);
135
+ const timer = setTimeout(() => resolve(false), timeout);
136
+ proc.once("exit", () => {
137
+ clearTimeout(timer);
138
+ resolve(true);
139
+ });
140
+ });
141
+
142
+ signalGroup(proc, "SIGINT");
143
+ if (await waitForExit(opts.sigintTimeoutMs)) return;
144
+
145
+ signalGroup(proc, "SIGTERM");
146
+ if (await waitForExit(opts.sigtermTimeoutMs)) return;
147
+
148
+ signalGroup(proc, "SIGKILL");
149
+ }
@@ -0,0 +1,303 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { log } from "./log.js";
4
+ import { spawnRunArgs } from "./pm.js";
5
+
6
+ const TAG = "project-type";
7
+
8
+ /**
9
+ * Known repo toolchains the daemon can build/verify.
10
+ *
11
+ * `node` delegates the package-manager variant (bun/npm/pnpm/yarn) down to
12
+ * {@link pm.ts}; the Swift kinds map onto `swift build` / `xcodebuild`.
13
+ * `unknown` means we have no idea how to build this repo and must skip
14
+ * verification gracefully rather than fail the card.
15
+ */
16
+ export type ProjectKind = "node" | "swift-spm" | "swift-xcode" | "unknown";
17
+
18
+ export interface ProjectType {
19
+ kind: ProjectKind;
20
+ /** Absolute path to the `.xcworkspace` / `.xcodeproj` (swift-xcode only). */
21
+ xcodeContainer?: string;
22
+ /** True when {@link xcodeContainer} is a workspace (vs a bare project). */
23
+ xcodeIsWorkspace?: boolean;
24
+ }
25
+
26
+ /** A resolved executable + args, ready for `execFileSync` / `spawn`. */
27
+ export interface ToolchainCommand {
28
+ cmd: string;
29
+ args: string[];
30
+ }
31
+
32
+ // Detection touches the filesystem (and `xcodebuild -list` for schemes), so
33
+ // cache per directory. Tests reset via `_resetCache()`.
34
+ const _cache = new Map<string, ProjectType>();
35
+
36
+ /** Clear the per-directory detection cache. Test-only. */
37
+ export function _resetCache(): void {
38
+ _cache.clear();
39
+ }
40
+
41
+ /**
42
+ * Detect the project toolchain rooted at `dir`.
43
+ *
44
+ * Priority: `package.json` (Node) → `Package.swift` (Swift SPM) →
45
+ * `*.xcworkspace` → `*.xcodeproj` → `unknown`. Node wins first because a
46
+ * Node repo may legitimately vendor Swift tooling without being a Swift app,
47
+ * and the Node path is what the vast majority of Harmony repos need.
48
+ */
49
+ export function detect(dir: string): ProjectType {
50
+ const cached = _cache.get(dir);
51
+ if (cached) return cached;
52
+
53
+ const result = detectUncached(dir);
54
+ _cache.set(dir, result);
55
+ log.info(TAG, `Detected project type in ${dir}: ${result.kind}`);
56
+ return result;
57
+ }
58
+
59
+ function detectUncached(dir: string): ProjectType {
60
+ if (existsSync(`${dir}/package.json`)) return { kind: "node" };
61
+ if (existsSync(`${dir}/Package.swift`)) return { kind: "swift-spm" };
62
+
63
+ const entries = safeReaddir(dir);
64
+ const workspace = entries.find((e) => e.endsWith(".xcworkspace"));
65
+ if (workspace) {
66
+ return {
67
+ kind: "swift-xcode",
68
+ xcodeContainer: `${dir}/${workspace}`,
69
+ xcodeIsWorkspace: true,
70
+ };
71
+ }
72
+ const project = entries.find((e) => e.endsWith(".xcodeproj"));
73
+ if (project) {
74
+ return {
75
+ kind: "swift-xcode",
76
+ xcodeContainer: `${dir}/${project}`,
77
+ xcodeIsWorkspace: false,
78
+ };
79
+ }
80
+
81
+ return { kind: "unknown" };
82
+ }
83
+
84
+ function safeReaddir(dir: string): string[] {
85
+ try {
86
+ return readdirSync(dir);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Resolve the build command for the repo at `dir`, or `null` when there's no
94
+ * known/usable build step (caller must skip-and-warn, never fail the card).
95
+ */
96
+ export function buildCommand(dir: string): ToolchainCommand | null {
97
+ const pt = detect(dir);
98
+ switch (pt.kind) {
99
+ case "node": {
100
+ const [cmd, args] = spawnRunArgs("build");
101
+ return { cmd, args };
102
+ }
103
+ case "swift-spm":
104
+ return { cmd: "swift", args: ["build"] };
105
+ case "swift-xcode":
106
+ return xcodeBuildCommand(pt);
107
+ case "unknown":
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolve the lint command for the repo at `dir`, or `null` when the
114
+ * toolchain has no separate lint step (Swift verifies via the build).
115
+ */
116
+ export function lintCommand(dir: string): ToolchainCommand | null {
117
+ const pt = detect(dir);
118
+ switch (pt.kind) {
119
+ case "node": {
120
+ const [cmd, args] = spawnRunArgs("lint");
121
+ return { cmd, args };
122
+ }
123
+ // Swift has no standard lint gate wired here — `swift build` /
124
+ // `xcodebuild build` already surface compile errors. SwiftLint is opt-in
125
+ // and absent on most machines, so skip rather than risk a false ENOENT.
126
+ case "swift-spm":
127
+ case "swift-xcode":
128
+ case "unknown":
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Resolve a write-mode auto-fix command for the repo at `dir`, run *before* the
135
+ * lint check so deterministic formatter drift never reaches CI as a red Lint
136
+ * job (#691: a PR blocked purely on `biome check` formatter rules in new test
137
+ * files that the daemon's warn-only lint step let through). Toolchain-agnostic:
138
+ * it runs whatever auto-fixer the repo declares, preferring `lint:fix` (the
139
+ * write-mode of a `biome check` / `eslint` lint gate) over `format`. Returns
140
+ * `null` when the repo declares neither (or isn't Node) — the caller skips
141
+ * silently. Best-effort by contract: the lint *check* still runs afterwards and
142
+ * reports whatever the fixer left behind, so this never substitutes for lint.
143
+ */
144
+ export function formatFixCommand(dir: string): ToolchainCommand | null {
145
+ if (detect(dir).kind !== "node") return null;
146
+ const script = firstNodeScript(dir, ["lint:fix", "format"]);
147
+ if (!script) return null;
148
+ const [cmd, args] = spawnRunArgs(script);
149
+ return { cmd, args };
150
+ }
151
+
152
+ /**
153
+ * Resolve the test command for the repo at `dir`, or `null` when the repo has
154
+ * no runnable suite (caller must skip-and-warn, never fail the card — #688).
155
+ *
156
+ * Unlike build/lint, a missing test step is the *normal* case for many repos,
157
+ * so Node resolution reads `package.json` and only returns a command when a
158
+ * real `test` script exists — running `<pm> run test` against a repo without
159
+ * one would fail the card on a missing script rather than on a real regression.
160
+ */
161
+ export function testCommand(dir: string): ToolchainCommand | null {
162
+ const pt = detect(dir);
163
+ switch (pt.kind) {
164
+ case "node": {
165
+ if (!hasNodeTestScript(dir)) return null;
166
+ const [cmd, args] = spawnRunArgs("test");
167
+ return { cmd, args };
168
+ }
169
+ case "swift-spm":
170
+ return { cmd: "swift", args: ["test"] };
171
+ // `xcodebuild test` needs a booted simulator destination (and often
172
+ // signing) that a headless verify pass can't assume. Skip rather than
173
+ // fail the card on an environment problem — same contract as lint.
174
+ case "swift-xcode":
175
+ case "unknown":
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /** The `npm init` placeholder — present but not a suite. Treat as no tests. */
181
+ const NPM_PLACEHOLDER_TEST = /no test specified/i;
182
+
183
+ function hasNodeTestScript(dir: string): boolean {
184
+ let script: unknown;
185
+ try {
186
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, "utf-8")) as {
187
+ scripts?: Record<string, string>;
188
+ };
189
+ script = pkg.scripts?.test;
190
+ } catch (err) {
191
+ log.warn(
192
+ TAG,
193
+ `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`,
194
+ );
195
+ return false;
196
+ }
197
+
198
+ if (typeof script !== "string" || script.trim().length === 0) return false;
199
+ if (NPM_PLACEHOLDER_TEST.test(script)) {
200
+ log.info(
201
+ TAG,
202
+ `package.json 'test' is the npm placeholder — skipping tests`,
203
+ );
204
+ return false;
205
+ }
206
+ return true;
207
+ }
208
+
209
+ /**
210
+ * First of `candidates` that names a real, non-empty script in the repo's
211
+ * `package.json`. Returns `null` when none match or the manifest can't be read.
212
+ */
213
+ function firstNodeScript(dir: string, candidates: string[]): string | null {
214
+ let scripts: Record<string, string>;
215
+ try {
216
+ const pkg = JSON.parse(readFileSync(`${dir}/package.json`, "utf-8")) as {
217
+ scripts?: Record<string, string>;
218
+ };
219
+ scripts = pkg.scripts ?? {};
220
+ } catch (err) {
221
+ log.warn(
222
+ TAG,
223
+ `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`,
224
+ );
225
+ return null;
226
+ }
227
+
228
+ for (const name of candidates) {
229
+ const script = scripts[name];
230
+ if (typeof script === "string" && script.trim().length > 0) return name;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Whether the repo supports the web dev-server deep review. Only Node repos
237
+ * boot a `dev` server on a port the reviewer can probe over HTTP.
238
+ */
239
+ export function supportsDevServer(dir: string): boolean {
240
+ return detect(dir).kind === "node";
241
+ }
242
+
243
+ function xcodeBuildCommand(pt: ProjectType): ToolchainCommand | null {
244
+ const container = pt.xcodeContainer;
245
+ if (!container) return null;
246
+
247
+ const scheme = resolveXcodeScheme(pt);
248
+ if (!scheme) {
249
+ log.warn(
250
+ TAG,
251
+ "Could not resolve an Xcode scheme — skipping build (best-effort)",
252
+ );
253
+ return null;
254
+ }
255
+
256
+ const containerFlag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
257
+ return {
258
+ cmd: "xcodebuild",
259
+ args: [
260
+ containerFlag,
261
+ container,
262
+ "-scheme",
263
+ scheme,
264
+ // Build for a generic iOS device — no simulator/signing needed for a
265
+ // compile-only verification pass.
266
+ "-destination",
267
+ "generic/platform=iOS",
268
+ "CODE_SIGNING_ALLOWED=NO",
269
+ "build",
270
+ ],
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Resolve the first build scheme from `xcodebuild -list -json`. Best-effort:
276
+ * if xcodebuild is missing or the listing fails, return `null` so the caller
277
+ * skips the build instead of failing the card.
278
+ */
279
+ function resolveXcodeScheme(pt: ProjectType): string | null {
280
+ if (!pt.xcodeContainer) return null;
281
+ const flag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
282
+ try {
283
+ const out = execFileSync(
284
+ "xcodebuild",
285
+ ["-list", "-json", flag, pt.xcodeContainer],
286
+ { encoding: "utf-8", timeout: 30_000, stdio: "pipe" },
287
+ );
288
+ const parsed = JSON.parse(out) as {
289
+ workspace?: { schemes?: string[] };
290
+ project?: { schemes?: string[] };
291
+ };
292
+ const schemes = pt.xcodeIsWorkspace
293
+ ? (parsed.workspace?.schemes ?? [])
294
+ : (parsed.project?.schemes ?? []);
295
+ return schemes[0] ?? null;
296
+ } catch (err) {
297
+ log.warn(
298
+ TAG,
299
+ `xcodebuild -list failed: ${err instanceof Error ? err.message : err}`,
300
+ );
301
+ return null;
302
+ }
303
+ }
@@ -0,0 +1,99 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { log } from "./log.js";
3
+
4
+ const TAG = "revert-guard";
5
+
6
+ /**
7
+ * Pre-Review guardrail against an implement branch silently reverting
8
+ * already-merged work (card #408).
9
+ *
10
+ * The classic failure mode: a branch built on a stale base (or an agent that
11
+ * resolved a conflict by keeping its own side) drops commits that landed on
12
+ * main — most damagingly the regression tests those commits added. Deleting a
13
+ * bound test removes the very safety net that would catch the regression, so a
14
+ * branch that deletes a test file relative to *current* main is treated as a
15
+ * likely accidental revert and blocked before it reaches Review.
16
+ *
17
+ * This is intentionally conservative: it flags only DELETED test files (not
18
+ * reverted hunks of source), which has a near-zero legitimate rate for an agent
19
+ * implementing a card. Broader reverted-hunk detection is deliberately deferred
20
+ * (noisier; needs per-commit analysis).
21
+ */
22
+
23
+ /** Matches test/spec files across the common JS/TS conventions. */
24
+ const TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
25
+
26
+ /** True when a repo-relative path names a test/spec file. */
27
+ export function isTestFile(path: string): boolean {
28
+ return TEST_FILE.test(path);
29
+ }
30
+
31
+ /**
32
+ * Filter a list of repo-relative paths down to the test/spec files. Pure
33
+ * helper, split out so the classification is unit-testable without git.
34
+ */
35
+ export function filterTestFiles(paths: string[]): string[] {
36
+ return paths.filter(isTestFile);
37
+ }
38
+
39
+ /**
40
+ * Re-fetch the base branch so the deleted-file comparison runs against the
41
+ * CURRENT remote tip, not whatever was last fetched at worktree creation.
42
+ * Best-effort — a fetch failure here only weakens the guard, it must not crash
43
+ * verification (the worktree-creation fetch is the authoritative freshness gate).
44
+ */
45
+ function refetchBase(worktreePath: string, baseBranch: string): void {
46
+ try {
47
+ execFileSync("git", ["fetch", "origin", baseBranch], {
48
+ cwd: worktreePath,
49
+ stdio: "pipe",
50
+ });
51
+ } catch {
52
+ log.warn(
53
+ TAG,
54
+ "Failed to re-fetch base for revert guard — using last fetch",
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Files DELETED by the branch relative to the (re-fetched) base tip.
61
+ * `--diff-filter=D` selects deletions only; the three-dot range compares the
62
+ * branch tip against the merge-base with the base, so unrelated deletions that
63
+ * happened on main don't count.
64
+ */
65
+ export function listDeletedFilesAgainstBase(
66
+ worktreePath: string,
67
+ baseBranch: string,
68
+ ): string[] {
69
+ try {
70
+ const out = execFileSync(
71
+ "git",
72
+ ["diff", "--diff-filter=D", "--name-only", `origin/${baseBranch}...HEAD`],
73
+ { cwd: worktreePath, encoding: "utf-8" },
74
+ );
75
+ return out
76
+ .split("\n")
77
+ .map((l) => l.trim())
78
+ .filter((l) => l.length > 0);
79
+ } catch (err) {
80
+ log.warn(
81
+ TAG,
82
+ `Failed to list deleted files: ${err instanceof Error ? err.message : err}`,
83
+ );
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Returns the test files the branch deletes relative to current main. A
90
+ * non-empty result means the branch likely reverted merged work and should be
91
+ * blocked before Review. Re-fetches the base first so the comparison is fresh.
92
+ */
93
+ export function findDeletedTestFiles(
94
+ worktreePath: string,
95
+ baseBranch: string,
96
+ ): string[] {
97
+ refetchBase(worktreePath, baseBranch);
98
+ return filterTestFiles(listDeletedFilesAgainstBase(worktreePath, baseBranch));
99
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `ReviewResult` family — pure data shapes for a code-review verdict.
3
+ *
4
+ * These types are produced by the daemon's `review-completion.ts` (which parses a
5
+ * review run's structured output) and consumed by the motor's `gate-collectors.ts`
6
+ * (which reads a `ReviewResult` to build `review_passed` gate evidence). Moving
7
+ * `review-completion.ts` itself into the motor is not possible — it also imports
8
+ * `board-helpers`, `completion`, `episode-writer`, `git-pr`, `state-store`, `types`,
9
+ * and `worktree`, all daemon-only, and the dependency direction is
10
+ * agent → harness → shared. So the type declarations move here (pure data, no
11
+ * daemon dependency) and the daemon's `review-completion.ts` imports them back
12
+ * from `@gethmy/harness` — same pattern used for `PlaybookMetricDef` in
13
+ * `exec-types.ts`: one definition, the daemon imports it, no duplication.
14
+ */
15
+
16
+ export interface ReviewFinding {
17
+ severity: "critical" | "major" | "minor";
18
+ title: string;
19
+ description: string;
20
+ category?: string;
21
+ location?: string;
22
+ /**
23
+ * Whether the change under review introduced/exposed this issue (true) or it
24
+ * is a pre-existing issue the reviewer happened to notice (false). Only
25
+ * diff-caused findings gate the verdict (#478). Absent ⇒ treated as true (the
26
+ * verdict-leaning default).
27
+ */
28
+ relatedToDiff?: boolean;
29
+ }
30
+
31
+ export interface ScopeCheck {
32
+ status: "clean" | "drift" | "missing";
33
+ notes?: string;
34
+ }
35
+
36
+ /**
37
+ * One verified acceptance criterion: a requirement or subtask the reviewer
38
+ * checked against the diff. `fail`/`partial` are hard verdict gates (#478).
39
+ */
40
+ export interface AcceptanceCheck {
41
+ criterion: string;
42
+ status: "pass" | "partial" | "fail" | "unverifiable";
43
+ evidence?: string;
44
+ }
45
+
46
+ export interface ReviewResult {
47
+ verdict: "approved" | "rejected" | "error";
48
+ summary: string;
49
+ scopeCheck?: ScopeCheck;
50
+ acceptanceChecks?: AcceptanceCheck[];
51
+ findings: ReviewFinding[];
52
+ }