@pi-agents/orchid 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Verification baseline fingerprinting system.
3
+ *
4
+ * Captures test output before and after merge, parses it into normalized
5
+ * fingerprints, and diffs to identify genuinely new failures vs pre-existing ones.
6
+ *
7
+ * Design notes:
8
+ *
9
+ * **Runner result schema:** Each command produces a CommandResult with:
10
+ * - commandId: string key from testing.commands config
11
+ * - exitCode: number (process exit code, -1 for spawn errors)
12
+ * - stdout: string (captured raw stdout)
13
+ * - stderr: string (captured raw stderr)
14
+ * - durationMs: number
15
+ * - error: string | null (spawn/timeout error message)
16
+ *
17
+ * **Fingerprint equality key:** Composite of all five fields joined by \0:
18
+ * `${commandId}\0${file}\0${case}\0${kind}\0${messageNorm}`
19
+ * Duplicates within a single run are collapsed before diffing.
20
+ *
21
+ * **messageNorm normalization rules:**
22
+ * 1. Strip ANSI escape sequences
23
+ * 2. Normalize path separators (backslash → forward slash)
24
+ * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
25
+ * 4. Remove ISO-8601 timestamps
26
+ * 5. Collapse whitespace (runs of space/tab/newline → single space, then trim)
27
+ * 6. Truncate to 512 chars (bound fingerprint size)
28
+ *
29
+ * **Fallback for non-JSON output:**
30
+ * If legacy Vitest JSON parsing fails (truncated, missing, non-JSON), produce
31
+ * a single fingerprint with kind: "command_error" and the first 512 chars
32
+ * of stderr (or stdout) as messageNorm.
33
+ *
34
+ * **Compatibility note:**
35
+ * OrchID's default tests use Node.js native `node:test`. The Vitest parser
36
+ * in this module is retained only for backward compatibility when projects
37
+ * provide custom `testing.commands` that still emit Vitest JSON.
38
+ *
39
+ * @module orch/verification
40
+ */
41
+ import { spawnSync } from "child_process";
42
+
43
+ // ── Types ────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A configured verification command from testing.commands config.
47
+ */
48
+ export interface VerificationCommand {
49
+ /** Stable key from config (e.g., "test", "build") — used as commandId */
50
+ id: string;
51
+ /** Shell command string to execute */
52
+ command: string;
53
+ }
54
+
55
+ /**
56
+ * Result of running a single verification command.
57
+ */
58
+ export interface CommandResult {
59
+ /** Key from testing.commands config (e.g., "test", "build") */
60
+ commandId: string;
61
+ /** Process exit code. -1 for spawn/timeout errors. */
62
+ exitCode: number;
63
+ /** Captured stdout */
64
+ stdout: string;
65
+ /** Captured stderr */
66
+ stderr: string;
67
+ /** Wall-clock duration in milliseconds */
68
+ durationMs: number;
69
+ /** Error message if command failed to spawn or timed out; null otherwise */
70
+ error: string | null;
71
+ }
72
+
73
+ /**
74
+ * Normalized test fingerprint identifying a single test outcome.
75
+ *
76
+ * Equality is determined by ALL five fields — the composite key.
77
+ */
78
+ export interface TestFingerprint {
79
+ /** Command that produced this result (key from testing.commands) */
80
+ commandId: string;
81
+ /** Source file path (normalized to forward slashes) */
82
+ file: string;
83
+ /** Test case full name (describe > it chain) */
84
+ case: string;
85
+ /** Failure classification */
86
+ kind: "assertion_error" | "runtime_error" | "timeout" | "command_error" | "unknown";
87
+ /** Normalized failure message (see normalization rules in module doc) */
88
+ messageNorm: string;
89
+ }
90
+
91
+ /**
92
+ * A captured verification baseline or post-merge snapshot.
93
+ */
94
+ export interface VerificationBaseline {
95
+ /** When this baseline was captured (ISO 8601) */
96
+ capturedAt: string;
97
+ /** Command results (one per configured command) */
98
+ commandResults: CommandResult[];
99
+ /** Deduplicated fingerprints extracted from all command results */
100
+ fingerprints: TestFingerprint[];
101
+ }
102
+
103
+ /**
104
+ * Result of diffing two fingerprint sets.
105
+ */
106
+ export interface FingerprintDiff {
107
+ /** Failures present in postMerge but not in baseline */
108
+ newFailures: TestFingerprint[];
109
+ /** Failures present in both baseline and postMerge (pre-existing) */
110
+ preExisting: TestFingerprint[];
111
+ /** Failures in baseline that disappeared in postMerge (fixed) */
112
+ fixed: TestFingerprint[];
113
+ }
114
+
115
+ // ── Normalization Helpers ────────────────────────────────────────────
116
+
117
+ /** Max length for normalized message strings */
118
+ const MESSAGE_NORM_MAX_LENGTH = 512;
119
+
120
+ // Built via `new RegExp` so Biome's noControlCharactersInRegex (which only
121
+ // inspects regex literals) does not flag the \u001b/\u009b escapes that are
122
+ // fundamental to ANSI sequence detection. Runtime behavior is identical to
123
+ // the prior literal regex; this is a static-analysis adjustment only.
124
+ const ANSI_REGEX = new RegExp(
125
+ "[\\u001b\\u009b]\\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]",
126
+ "g",
127
+ );
128
+
129
+ /** Match duration strings like (42ms), (1.2s), (3m 12s), 42 ms, 1200ms */
130
+ const DURATION_REGEX = /\(?\d+(?:\.\d+)?\s*(?:ms|s|m)\s*(?:\d+(?:\.\d+)?\s*(?:ms|s))?\)?/g;
131
+
132
+ /** Match ISO-8601 timestamps like 2026-03-20T12:34:56.789Z */
133
+ const TIMESTAMP_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g;
134
+
135
+ /**
136
+ * Normalize a failure message for stable fingerprinting.
137
+ *
138
+ * 1. Strip ANSI escape sequences
139
+ * 2. Normalize path separators (\ → /)
140
+ * 3. Remove duration strings (e.g., "(42ms)", "(1.2s)")
141
+ * 4. Remove ISO-8601 timestamps
142
+ * 5. Collapse whitespace
143
+ * 6. Truncate to MESSAGE_NORM_MAX_LENGTH
144
+ */
145
+ export function normalizeMessage(raw: string): string {
146
+ let msg = raw;
147
+ // 1. Strip ANSI
148
+ msg = msg.replace(ANSI_REGEX, "");
149
+ // 2. Normalize path separators
150
+ msg = msg.replace(/\\/g, "/");
151
+ // 3. Remove duration strings
152
+ msg = msg.replace(DURATION_REGEX, "");
153
+ // 4. Remove ISO-8601 timestamps
154
+ msg = msg.replace(TIMESTAMP_REGEX, "");
155
+ // 5. Collapse whitespace
156
+ msg = msg.replace(/\s+/g, " ").trim();
157
+ // 6. Truncate
158
+ if (msg.length > MESSAGE_NORM_MAX_LENGTH) {
159
+ msg = msg.slice(0, MESSAGE_NORM_MAX_LENGTH);
160
+ }
161
+ return msg;
162
+ }
163
+
164
+ /**
165
+ * Normalize a file path for stable fingerprinting.
166
+ * Converts backslashes to forward slashes.
167
+ */
168
+ export function normalizeFilePath(raw: string): string {
169
+ return raw.replace(/\\/g, "/");
170
+ }
171
+
172
+ /**
173
+ * Compute a stable string key for a fingerprint used in set operations.
174
+ * Fields joined by null byte (unlikely in test output).
175
+ */
176
+ export function fingerprintKey(fp: TestFingerprint): string {
177
+ return `${fp.commandId}\0${fp.file}\0${fp.case}\0${fp.kind}\0${fp.messageNorm}`;
178
+ }
179
+
180
+ // ── Command Runner ───────────────────────────────────────────────────
181
+
182
+ /** Default timeout for verification commands: 5 minutes */
183
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
184
+
185
+ /**
186
+ * Run configured verification commands and return per-command results.
187
+ *
188
+ * Commands are iterated in deterministic insertion order of the
189
+ * `testing.commands` config map. Each command runs synchronously in
190
+ * the specified working directory (typically the merge worktree).
191
+ *
192
+ * @param commands - Map of commandId → shell command string (from testing.commands config)
193
+ * @param cwd - Working directory to run commands in
194
+ * @param timeoutMs - Per-command timeout in milliseconds (default: 5 min)
195
+ * @returns Array of CommandResult in config iteration order
196
+ */
197
+ export function runVerificationCommands(
198
+ commands: Record<string, string>,
199
+ cwd: string,
200
+ timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
201
+ ): CommandResult[] {
202
+ const results: CommandResult[] = [];
203
+
204
+ for (const [commandId, command] of Object.entries(commands)) {
205
+ const start = Date.now();
206
+ try {
207
+ const isWindows = process.platform === "win32";
208
+ const shell = isWindows ? "cmd" : "/bin/sh";
209
+ const shellArgs = isWindows ? ["/c", command] : ["-c", command];
210
+
211
+ const proc = spawnSync(shell, shellArgs, {
212
+ cwd,
213
+ encoding: "utf-8",
214
+ timeout: timeoutMs,
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ // Ensure child processes don't inherit stdin
217
+ env: { ...process.env },
218
+ });
219
+
220
+ const durationMs = Date.now() - start;
221
+
222
+ if (proc.error) {
223
+ // Spawn error or timeout
224
+ const isTimeout = (proc.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
225
+ results.push({
226
+ commandId,
227
+ exitCode: -1,
228
+ stdout: proc.stdout || "",
229
+ stderr: proc.stderr || "",
230
+ durationMs,
231
+ error: isTimeout
232
+ ? `Command timed out after ${timeoutMs}ms`
233
+ : `Spawn error: ${proc.error.message}`,
234
+ });
235
+ } else {
236
+ results.push({
237
+ commandId,
238
+ exitCode: proc.status ?? -1,
239
+ stdout: proc.stdout || "",
240
+ stderr: proc.stderr || "",
241
+ durationMs,
242
+ error: null,
243
+ });
244
+ }
245
+ } catch (err: unknown) {
246
+ const durationMs = Date.now() - start;
247
+ const message = err instanceof Error ? err.message : String(err);
248
+ results.push({
249
+ commandId,
250
+ exitCode: -1,
251
+ stdout: "",
252
+ stderr: "",
253
+ durationMs,
254
+ error: `Unexpected error: ${message}`,
255
+ });
256
+ }
257
+ }
258
+
259
+ return results;
260
+ }
261
+
262
+ // ── Test Output Parsers ──────────────────────────────────────────────
263
+
264
+ /**
265
+ * Vitest JSON reporter output shape (subset of fields we care about).
266
+ */
267
+ interface VitestJsonResult {
268
+ testResults?: Array<{
269
+ name?: string;
270
+ status?: string;
271
+ message?: string;
272
+ assertionResults?: Array<{
273
+ fullName?: string;
274
+ status?: string;
275
+ failureMessages?: string[];
276
+ }>;
277
+ }>;
278
+ }
279
+
280
+ /**
281
+ * Classify a failure message into a kind.
282
+ */
283
+ function classifyFailureKind(message: string): TestFingerprint["kind"] {
284
+ const lower = message.toLowerCase();
285
+ if (lower.includes("timeout") || lower.includes("timed out")) {
286
+ return "timeout";
287
+ }
288
+ if (
289
+ lower.includes("assert") ||
290
+ lower.includes("expect") ||
291
+ lower.includes("tobe") ||
292
+ lower.includes("toequal") ||
293
+ lower.includes("tohave")
294
+ ) {
295
+ return "assertion_error";
296
+ }
297
+ if (
298
+ lower.includes("referenceerror") ||
299
+ lower.includes("typeerror") ||
300
+ lower.includes("syntaxerror") ||
301
+ lower.includes("cannot find module") ||
302
+ lower.includes("is not defined") ||
303
+ lower.includes("is not a function")
304
+ ) {
305
+ return "runtime_error";
306
+ }
307
+ return "unknown";
308
+ }
309
+
310
+ /**
311
+ * Parse legacy Vitest JSON reporter output into test fingerprints.
312
+ *
313
+ * Expects stdout to contain a JSON object matching Vitest's JSON reporter format.
314
+ * Only failed tests produce fingerprints (passed tests are irrelevant for baseline diffing).
315
+ *
316
+ * If JSON parsing fails or the structure is unexpected, returns null to signal
317
+ * that the caller should use fallback fingerprinting.
318
+ *
319
+ * @param commandId - The command that produced this output
320
+ * @param stdout - Raw stdout from the Vitest command (legacy compatibility path)
321
+ * @returns Array of fingerprints for failed tests, or null if parsing fails
322
+ */
323
+ export function parseVitestOutput(commandId: string, stdout: string): TestFingerprint[] | null {
324
+ // Try to extract JSON from stdout (Vitest may prepend/append non-JSON lines)
325
+ let json: VitestJsonResult;
326
+ try {
327
+ // First attempt: parse the whole stdout as JSON
328
+ json = JSON.parse(stdout);
329
+ } catch {
330
+ // Second attempt: find the first { and last } to extract JSON block
331
+ const firstBrace = stdout.indexOf("{");
332
+ const lastBrace = stdout.lastIndexOf("}");
333
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
334
+ return null;
335
+ }
336
+ try {
337
+ json = JSON.parse(stdout.slice(firstBrace, lastBrace + 1));
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ if (!json || !Array.isArray(json.testResults)) {
344
+ return null;
345
+ }
346
+
347
+ const fingerprints: TestFingerprint[] = [];
348
+
349
+ for (const testFile of json.testResults) {
350
+ const file = normalizeFilePath(testFile.name || "unknown");
351
+ const assertions = testFile.assertionResults;
352
+ const hasAssertions = Array.isArray(assertions) && assertions.length > 0;
353
+
354
+ if (hasAssertions) {
355
+ for (const assertion of assertions!) {
356
+ // Only fingerprint failures
357
+ if (assertion.status !== "failed") continue;
358
+
359
+ const caseName = assertion.fullName || "unknown";
360
+ const messages = assertion.failureMessages || [];
361
+ const rawMessage = messages.join("\n") || "no failure message";
362
+
363
+ fingerprints.push({
364
+ commandId,
365
+ file,
366
+ case: caseName,
367
+ kind: classifyFailureKind(rawMessage),
368
+ messageNorm: normalizeMessage(rawMessage),
369
+ });
370
+ }
371
+ }
372
+
373
+ // Suite-level failures: testResults[].status === "failed" with no assertion-level details.
374
+ // This covers setup/import/runtime-at-file-load errors where Vitest marks the file as
375
+ // failed but produces no assertionResults (or only non-failed ones).
376
+ if (testFile.status === "failed") {
377
+ const hasFailedAssertions = hasAssertions && assertions!.some((a) => a.status === "failed");
378
+ if (!hasFailedAssertions) {
379
+ // No assertion-level failures captured — emit suite-level runtime_error fingerprint
380
+ const suiteMessage = testFile.message || "Suite failed with no message";
381
+ fingerprints.push({
382
+ commandId,
383
+ file,
384
+ case: "<suite>",
385
+ kind: "runtime_error",
386
+ messageNorm: normalizeMessage(suiteMessage),
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ return fingerprints;
393
+ }
394
+
395
+ /**
396
+ * Parse test output into normalized fingerprints.
397
+ *
398
+ * Strategy:
399
+ * 1. Try legacy Vitest JSON adapter
400
+ * 2. If parsing fails: produce a fallback command_error fingerprint
401
+ *
402
+ * The adapter pattern is extensible — future parsers for jest, pytest, etc.
403
+ * can be added here as additional try paths before the fallback.
404
+ *
405
+ * @param commandResult - Result from runVerificationCommands
406
+ * @returns Array of fingerprints (always non-empty for failed commands)
407
+ */
408
+ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[] {
409
+ const { commandId, exitCode, stdout, stderr, error } = commandResult;
410
+
411
+ // If command had a spawn/timeout error, produce a command_error fingerprint
412
+ if (error) {
413
+ return [
414
+ {
415
+ commandId,
416
+ file: "",
417
+ case: "",
418
+ kind: "command_error",
419
+ messageNorm: normalizeMessage(error),
420
+ },
421
+ ];
422
+ }
423
+
424
+ // If exit code is 0, no failures to fingerprint
425
+ if (exitCode === 0) {
426
+ return [];
427
+ }
428
+
429
+ // Try legacy Vitest JSON adapter
430
+ const vitestFingerprints = parseVitestOutput(commandId, stdout);
431
+ if (vitestFingerprints !== null && vitestFingerprints.length > 0) {
432
+ return vitestFingerprints;
433
+ }
434
+
435
+ // Vitest JSON parsed successfully but produced zero fingerprints with non-zero exit.
436
+ // This can happen if the JSON structure is valid but contains no failure details
437
+ // we could extract. Fall through to the generic fallback below.
438
+
439
+ // Fallback: command_error fingerprint with stderr (or stdout if stderr is empty)
440
+ const fallbackMessage = stderr.trim() || stdout.trim() || "Command failed with no output";
441
+ return [
442
+ {
443
+ commandId,
444
+ file: "",
445
+ case: "",
446
+ kind: "command_error",
447
+ messageNorm: normalizeMessage(fallbackMessage),
448
+ },
449
+ ];
450
+ }
451
+
452
+ // ── Fingerprint Diffing ──────────────────────────────────────────────
453
+
454
+ /**
455
+ * Deduplicate fingerprints by their composite key.
456
+ * Preserves the first occurrence of each unique fingerprint.
457
+ */
458
+ export function deduplicateFingerprints(fingerprints: TestFingerprint[]): TestFingerprint[] {
459
+ const seen = new Set<string>();
460
+ const result: TestFingerprint[] = [];
461
+
462
+ for (const fp of fingerprints) {
463
+ const key = fingerprintKey(fp);
464
+ if (!seen.has(key)) {
465
+ seen.add(key);
466
+ result.push(fp);
467
+ }
468
+ }
469
+
470
+ return result;
471
+ }
472
+
473
+ /**
474
+ * Diff two fingerprint sets to identify new failures, pre-existing failures, and fixes.
475
+ *
476
+ * Uses set-based comparison on the composite fingerprint key.
477
+ * Both sets are deduplicated before comparison.
478
+ *
479
+ * @param baseline - Fingerprints from pre-merge verification run
480
+ * @param postMerge - Fingerprints from post-merge verification run
481
+ * @returns FingerprintDiff with new failures, pre-existing, and fixed sets
482
+ */
483
+ export function diffFingerprints(
484
+ baseline: TestFingerprint[],
485
+ postMerge: TestFingerprint[],
486
+ ): FingerprintDiff {
487
+ const dedupBaseline = deduplicateFingerprints(baseline);
488
+ const dedupPostMerge = deduplicateFingerprints(postMerge);
489
+
490
+ const baselineKeys = new Set(dedupBaseline.map(fingerprintKey));
491
+ const postMergeKeys = new Set(dedupPostMerge.map(fingerprintKey));
492
+
493
+ const newFailures: TestFingerprint[] = [];
494
+ const preExisting: TestFingerprint[] = [];
495
+ const fixed: TestFingerprint[] = [];
496
+
497
+ // Classify post-merge fingerprints
498
+ for (const fp of dedupPostMerge) {
499
+ const key = fingerprintKey(fp);
500
+ if (baselineKeys.has(key)) {
501
+ preExisting.push(fp);
502
+ } else {
503
+ newFailures.push(fp);
504
+ }
505
+ }
506
+
507
+ // Find fixed: in baseline but not in post-merge
508
+ for (const fp of dedupBaseline) {
509
+ const key = fingerprintKey(fp);
510
+ if (!postMergeKeys.has(key)) {
511
+ fixed.push(fp);
512
+ }
513
+ }
514
+
515
+ return { newFailures, preExisting, fixed };
516
+ }
517
+
518
+ // ── Baseline Capture ─────────────────────────────────────────────────
519
+
520
+ /**
521
+ * Run verification commands and capture a complete baseline snapshot.
522
+ *
523
+ * @param commands - Map of commandId → shell command string
524
+ * @param cwd - Working directory (merge worktree)
525
+ * @param timeoutMs - Per-command timeout
526
+ * @returns VerificationBaseline with command results and extracted fingerprints
527
+ */
528
+ export function captureBaseline(
529
+ commands: Record<string, string>,
530
+ cwd: string,
531
+ timeoutMs?: number,
532
+ ): VerificationBaseline {
533
+ const commandResults = runVerificationCommands(commands, cwd, timeoutMs);
534
+
535
+ // Extract fingerprints from all command results
536
+ const allFingerprints: TestFingerprint[] = [];
537
+ for (const result of commandResults) {
538
+ const fps = parseTestOutput(result);
539
+ allFingerprints.push(...fps);
540
+ }
541
+
542
+ return {
543
+ capturedAt: new Date().toISOString(),
544
+ commandResults,
545
+ fingerprints: deduplicateFingerprints(allFingerprints),
546
+ };
547
+ }