@phi-code-admin/phi-code 0.88.0 → 0.90.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,89 @@
1
+ /**
2
+ * /build outer loop — the pure decision core that composes the two oracles
3
+ * (docs/design/plan-debug-build.md): an acceptance RUN and an executable
4
+ * red-team. It never runs anything itself; given this round's reports it decides
5
+ * SUCCESS, CONTINUE (hand these real failures to /debug), or PARTIAL (budget
6
+ * spent — report what still fails honestly, never a confident-wrong PASS).
7
+ */
8
+
9
+ import type { AcceptanceReport, CriterionResult } from "./acceptance.js";
10
+ import type { FailingState } from "./debug-contract.js";
11
+ import { type BreakingCase, breakingCasesToFailingStates } from "./redteam.js";
12
+
13
+ export type BuildStatus = "success" | "continue" | "partial";
14
+
15
+ export interface BuildDecision {
16
+ status: BuildStatus;
17
+ /** Real failures to route to /debug (continue) or to report (partial). */
18
+ failures: FailingState[];
19
+ /** Manual criteria that could not be executed — surfaced, never counted green. */
20
+ unverified: string[];
21
+ reason: string;
22
+ }
23
+
24
+ export interface BuildRoundInput {
25
+ round: number;
26
+ maxRounds: number;
27
+ acceptance: AcceptanceReport;
28
+ breakingCases: BreakingCase[];
29
+ cwd?: string;
30
+ }
31
+
32
+ /** A failed acceptance criterion becomes a failing state /debug can reproduce. */
33
+ export function criterionToFailingState(r: CriterionResult, cwd?: string): FailingState {
34
+ return {
35
+ reproCommand: r.criterion.check,
36
+ expected: r.criterion.description,
37
+ trace: r.result ? `${r.result.command} exited ${r.result.exitCode}` : undefined,
38
+ cwd,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Decide one round of the build loop. SUCCESS requires: at least one criterion
44
+ * was actually checked, none failed, and the red-team found no break. Manual
45
+ * criteria never count toward success — they are reported as `unverified`.
46
+ */
47
+ export function decideBuildRound(input: BuildRoundInput): BuildDecision {
48
+ const failedStates = input.acceptance.failed.map((r) => criterionToFailingState(r, input.cwd));
49
+ const breakStates = breakingCasesToFailingStates(input.breakingCases, input.cwd);
50
+ const failures = [...failedStates, ...breakStates];
51
+ const unverified = input.acceptance.manual.map((r) => r.criterion.description);
52
+
53
+ if (failures.length === 0) {
54
+ if (!input.acceptance.allCheckablePassed) {
55
+ // Nothing failed, but nothing was executably verified either.
56
+ return {
57
+ status: "partial",
58
+ failures: [],
59
+ unverified,
60
+ reason: "no criterion could be executed — nothing verified; add checkable acceptance criteria",
61
+ };
62
+ }
63
+ const note = unverified.length ? ` (${unverified.length} manual criteria still unverified)` : "";
64
+ return {
65
+ status: "success",
66
+ failures: [],
67
+ unverified,
68
+ reason: `all checkable acceptance criteria passed and the red-team found no break${note}`,
69
+ };
70
+ }
71
+
72
+ if (input.round >= input.maxRounds) {
73
+ return {
74
+ status: "partial",
75
+ failures,
76
+ unverified,
77
+ reason: `budget exhausted after ${input.maxRounds} round(s); ${failures.length} failure(s) still open`,
78
+ };
79
+ }
80
+
81
+ return {
82
+ status: "continue",
83
+ failures,
84
+ unverified,
85
+ reason: `${failures.length} real failure(s) this round → route to /debug (round ${input.round}/${input.maxRounds})`,
86
+ };
87
+ }
88
+
89
+ export const DEFAULT_MAX_ROUNDS = 4;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Minimality guard — pick the best of several candidate fixes.
3
+ *
4
+ * Lesson from the SWE-bench-lite head-to-head: /plan's extra deliberation made
5
+ * it produce a MORE elaborate (and wrong) fix than a single shot on the missed
6
+ * instance, while its diff was better-formed (it applied; the baseline's did
7
+ * not). The winning move is to generate more than one candidate and keep the
8
+ * SIMPLEST one that actually passes an executable acceptance repro — combining
9
+ * /plan's well-formed diffs with single-shot's tendency to stay minimal.
10
+ *
11
+ * This module is the pure decision core (no I/O): the caller runs each
12
+ * candidate against the same acceptance repro and reports `passedRepro`; this
13
+ * picks the winner. "Simplest" = fewest changed source lines in the unified
14
+ * diff (a robust, language-agnostic proxy for over-engineering).
15
+ */
16
+
17
+ export interface FixCandidate {
18
+ /** Where it came from, for diagnostics: "single-shot", "plan", "plan-fix", ... */
19
+ source: string;
20
+ /** Unified diff. */
21
+ patch: string;
22
+ /**
23
+ * Did this candidate pass the model's own falsify-first acceptance repro
24
+ * (the repro that FAILS on the pre-fix code and must PASS after)? Undefined
25
+ * when no repro was available to gate on.
26
+ */
27
+ passedRepro?: boolean;
28
+ }
29
+
30
+ export interface CandidateSelection {
31
+ chosen: FixCandidate | null;
32
+ /** Human-readable rationale (surfaced to the user and asserted in tests). */
33
+ reason: string;
34
+ /** Changed-source-line count of the chosen patch (0 when none). */
35
+ chosenSize: number;
36
+ }
37
+
38
+ /**
39
+ * Count changed source lines in a unified diff: added/removed lines, excluding
40
+ * diff/hunk headers (---, +++, @@, diff --git, index) so the number reflects
41
+ * real edits, not file boilerplate.
42
+ */
43
+ export function diffChangedLines(patch: string): number {
44
+ if (!patch) return 0;
45
+ let n = 0;
46
+ for (const line of patch.split("\n")) {
47
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
48
+ if ((line.startsWith("+") || line.startsWith("-")) && line.length > 0) n++;
49
+ }
50
+ return n;
51
+ }
52
+
53
+ const hasPatch = (c: FixCandidate) => c.patch.trim().length > 0;
54
+
55
+ /**
56
+ * Select the best candidate:
57
+ * 1. Among candidates that PASSED the acceptance repro, pick the smallest diff
58
+ * (ties → the earliest in the list, so callers can order by preference).
59
+ * 2. If none is known to pass but some were never gated (passedRepro undefined),
60
+ * pick the smallest non-empty patch among those (best effort).
61
+ * 3. If a repro existed and every candidate FAILED it, choose nothing — the
62
+ * honest outcome is "no candidate is verified", not "ship the least-bad".
63
+ * 4. No non-empty patches → nothing.
64
+ */
65
+ export function selectMinimalPassingCandidate(candidates: FixCandidate[]): CandidateSelection {
66
+ const nonEmpty = candidates.filter(hasPatch);
67
+ if (nonEmpty.length === 0) {
68
+ return { chosen: null, reason: "no candidate produced a non-empty patch", chosenSize: 0 };
69
+ }
70
+
71
+ const passed = nonEmpty.filter((c) => c.passedRepro === true);
72
+ if (passed.length > 0) {
73
+ const chosen = smallest(passed);
74
+ const others = passed.filter((c) => c !== chosen);
75
+ const note = others.length
76
+ ? ` (over ${others.map((c) => `${c.source}:${diffChangedLines(c.patch)}`).join(", ")})`
77
+ : "";
78
+ return {
79
+ chosen,
80
+ reason: `smallest patch that passes the acceptance repro: ${chosen.source} (${diffChangedLines(chosen.patch)} changed lines)${note}`,
81
+ chosenSize: diffChangedLines(chosen.patch),
82
+ };
83
+ }
84
+
85
+ const anyGated = nonEmpty.some((c) => c.passedRepro === true || c.passedRepro === false);
86
+ if (anyGated) {
87
+ // A repro existed and nothing passed it — do not pretend a candidate is verified.
88
+ return { chosen: null, reason: "an acceptance repro existed but no candidate passed it", chosenSize: 0 };
89
+ }
90
+
91
+ // No repro to gate on: fall back to the smallest non-empty patch (best effort).
92
+ const chosen = smallest(nonEmpty);
93
+ return {
94
+ chosen,
95
+ reason: `no acceptance repro to gate on; smallest non-empty patch: ${chosen.source} (${diffChangedLines(chosen.patch)} changed lines)`,
96
+ chosenSize: diffChangedLines(chosen.patch),
97
+ };
98
+ }
99
+
100
+ /** Smallest by changed-line count; ties keep list order (stable). */
101
+ function smallest(cs: FixCandidate[]): FixCandidate {
102
+ let best = cs[0];
103
+ let bestSize = diffChangedLines(best.patch);
104
+ for (let i = 1; i < cs.length; i++) {
105
+ const s = diffChangedLines(cs[i].patch);
106
+ if (s < bestSize) {
107
+ best = cs[i];
108
+ bestSize = s;
109
+ }
110
+ }
111
+ return best;
112
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Instruction builders for the /debug and /build phase pipelines.
3
+ *
4
+ * These are PURE text builders (no fs, no Pi) so the exact protocol each phase
5
+ * agent receives is unit-testable. They encode the contracts from
6
+ * docs/design/plan-debug-build.md: /debug turns a REAL failure green through
7
+ * REPRODUCE → LOCALIZE → FIX → VERIFY with execution as the only oracle; /build
8
+ * adds an execution-grounded verify (run recipe + acceptance + executable
9
+ * red-team) that routes real failures back to the /debug protocol.
10
+ */
11
+
12
+ import type { FailingState } from "./debug-contract.js";
13
+
14
+ /** Render a failing state as a compact, unambiguous block for an instruction. */
15
+ export function formatFailingState(state: FailingState): string {
16
+ const lines: string[] = [];
17
+ if (state.failingTest?.trim()) lines.push(`- Failing test: \`${state.failingTest.trim()}\``);
18
+ if (state.reproCommand?.trim()) lines.push(`- Repro command: \`${state.reproCommand.trim()}\``);
19
+ if (state.expected?.trim()) lines.push(`- Expected behaviour: ${state.expected.trim()}`);
20
+ if (state.trace?.trim()) lines.push(`- Trace / error:\n\`\`\`\n${state.trace.trim()}\n\`\`\``);
21
+ if (state.cwd?.trim()) lines.push(`- Working directory: \`${state.cwd.trim()}\``);
22
+ return lines.length ? lines.join("\n") : "- (no structured failing state supplied)";
23
+ }
24
+
25
+ export interface DebugInstructions {
26
+ reproduce: string;
27
+ localize: string;
28
+ fix: string;
29
+ verify: string;
30
+ }
31
+
32
+ const DEBUG_RULES = `
33
+ ---
34
+ ## /debug operating rules (non-negotiable)
35
+ - **Execution is the only oracle.** No verdict without a real run whose output you paste. Never write FIXED because the code "looks right".
36
+ - **Use the \`sandbox_run\` tool for every oracle run** (reproduction, suite, acceptance). It runs in the project's guaranteed environment and returns the REAL exit code — a PASS means \`sandbox_run\` returned exit 0, nothing less.
37
+ - **No fabricated PASS.** If \`sandbox_run\` reports \`SANDBOX UNAVAILABLE\` (or you otherwise cannot run the reproduction), emit \`BLOCKED: no executable environment\` — do NOT reconstruct a mock and grade your own reconstruction.
38
+ - **Minimal fix wins.** Prefer the smallest change; every added guard/condition is a liability that can hide the bug (an over-clever guard is exactly how these fixes go wrong).
39
+ - **Root cause, not workaround.** No skipped tests, no \`--no-verify\`, no mock that hides the failure.
40
+ - The user does NOT answer during these phases. Act autonomously; do not end with a question.`;
41
+
42
+ /** The four /debug phase instructions, specialised to the concrete failing state. */
43
+ export function debugPhaseInstructions(state: FailingState): DebugInstructions {
44
+ const failing = formatFailingState(state);
45
+ const repro = state.failingTest?.trim() || state.reproCommand?.trim() || "(the reproduction command)";
46
+
47
+ return {
48
+ reproduce:
49
+ `You are the REPRODUCE agent (phase 1 of /debug). Your only job is to CONFIRM the failure is real.
50
+
51
+ **The reported failing state:**
52
+ ${failing}
53
+
54
+ **Do exactly this:**
55
+ 1. Run the reproduction on the CURRENT, unmodified code with the \`sandbox_run\` tool: \`sandbox_run ${repro}\`.
56
+ 2. Paste the exact command and its full output.
57
+ 3. Decide from what \`sandbox_run\` returned:
58
+ - If it FAILS as reported → capture the precise symptom (assertion, exception, exit code) and hand off to LOCALIZE.
59
+ - If it PASSES → STOP. Write \`BLOCKED: cannot reproduce — passes on current code\` with the run pasted. Do not invent a bug.
60
+ - If \`sandbox_run\` reports \`SANDBOX UNAVAILABLE\` → STOP. Write \`BLOCKED: no executable environment\`.
61
+ 4. Do NOT edit any source yet. This phase only observes.
62
+ 5. **Last action:** call \`phase_result\` — \`verdict: PASS\` if it reproduced (proceed to LOCALIZE), or \`verdict: BLOCKED\` with the reason if you stopped.` +
63
+ DEBUG_RULES,
64
+
65
+ localize: `You are the LOCALIZE agent (phase 2 of /debug). Drive from the REAL symptom the previous phase captured.
66
+
67
+ **The failing state:**
68
+ ${failing}
69
+
70
+ **Do exactly this:**
71
+ 1. Read the traceback / error from the reproduction. Follow it to the exact frame.
72
+ 2. Read the implicated source and the symbols it touches (grep the nearby/changed identifiers).
73
+ 3. Name the fault site precisely — file:line and the wrong assumption. Localization, not more review, is the lever here.
74
+ 4. Hand off a crisp fault description; do NOT fix yet.${DEBUG_RULES}`,
75
+
76
+ fix:
77
+ `You are the FIX agent (phase 3 of /debug). Produce the MINIMAL change that addresses the located root cause.
78
+
79
+ **The failing state:**
80
+ ${failing}
81
+
82
+ **Do exactly this:**
83
+ 1. Write the smallest patch that fixes the root cause at the fault site LOCALIZE named.
84
+ 2. If two approaches are plausible, prefer the one that adds the least surface (fewest new branches/guards).
85
+ 3. Do NOT run the suite to "confirm" here by eye — VERIFY re-runs everything. Just make the change and state precisely what you changed and why it addresses the cause.` +
86
+ DEBUG_RULES,
87
+
88
+ verify: `You are the VERIFY agent (phase 4 of /debug) — the oracle. A green verdict requires TWO real runs.
89
+
90
+ **The failing state:**
91
+ ${failing}
92
+
93
+ **Do exactly this, pasting every command's output:**
94
+ 1. Re-run the reproduction with \`sandbox_run ${repro}\`. It MUST now return exit 0.
95
+ 2. Run the existing test suite with \`sandbox_run <test command>\`. It MUST NOT regress.
96
+ 3. Verdict (from what \`sandbox_run\` returned, not from inspection):
97
+ - Both green → \`FIXED\`, and paste the before(fail)/after(pass) reproduction runs and the green suite as evidence.
98
+ - Reproduction still fails, or the suite regresses → \`BLOCKED\` with the closest diagnostic. Do NOT ship the least-bad patch; a wrong fix is worse than an honest BLOCKED.
99
+ 4. Write the final verdict block, then call \`phase_result\` with \`verdict: PASS\` (FIXED) or \`verdict: BLOCKED\`, plus a one-line handoff:
100
+ \`\`\`
101
+ VERDICT: FIXED | BLOCKED
102
+ Evidence: reproBefore=fail reproAfter=pass suite=green|skipped
103
+ Reason: <only when BLOCKED>
104
+ \`\`\`${DEBUG_RULES}`,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The /build execution-grounded verify phase: run the recipe, check acceptance,
110
+ * red-team the boundary the change touched, and route real failures to /debug.
111
+ */
112
+ export function buildVerifyInstruction(spec: string): string {
113
+ return `You are the BUILD-VERIFY agent — the execution oracle for /build. The code has been written; now PROVE it runs and meets the spec, or report honestly what still fails.
114
+
115
+ **Original spec:** ${spec}
116
+
117
+ **Every run below goes through the \`sandbox_run\` tool** (the project's guaranteed environment). If it reports \`SANDBOX UNAVAILABLE\`, do not fabricate results — mark the affected criteria ❔ and say the environment was unavailable.
118
+
119
+ **Do exactly this, pasting every command's output:**
120
+ 1. **Run recipe.** From the brief's \`## Run Recipe\` (or package.json / Makefile / Dockerfile), build and start the app via \`sandbox_run\`. Distinguish a real failure from a stale launch recipe.
121
+ 2. **Acceptance.** For each acceptance criterion derived from the SPEC (not the code), \`sandbox_run\` a concrete check that exits 0 iff it holds. Mark each ✅ (sandbox_run returned 0), ❌ (non-zero), or ❔ (could not be executed — NEVER count ❔ as passing).
122
+ 3. **Executable red-team.** Attack the specific input regimes the change touched (empty, null, boundary, wrong-type, and buffered-vs-streaming / malformed / auth as relevant). Each attack must be a RUNNABLE test \`sandbox_run\` can execute and that goes RED if it breaks the code — an opinion is not a finding.
123
+ 4. **Route real failures.** For every ❌ criterion and every red-team break, treat it as a concrete failing state (failing test / repro command / expected) and fix it with the /debug protocol: REPRODUCE → LOCALIZE → minimal FIX → VERIFY (re-run the reproduction AND the suite).
124
+ 5. **Honest verdict.** When all checkable criteria pass and the red-team finds no break, write \`BUILD: SUCCESS\`. If rounds/budget run out with failures open, write \`BUILD: PARTIAL\` and LIST exactly which criteria still fail — never a confident-wrong SUCCESS. Then call \`phase_result\` with \`verdict: PASS\` (SUCCESS) or \`verdict: FAIL\`/\`BLOCKED\` and the handoff.
125
+ \`\`\`
126
+ VERDICT: PASS | FAIL | BLOCKED
127
+ Handoff:
128
+ State: <what runs and is proven by a real run>
129
+ Open Risks: <criteria still ❔ unverified or ❌ failing>
130
+ Next: <the single most important remaining action>
131
+ \`\`\`${DEBUG_RULES}`;
132
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * /debug contract — turn a REAL failing state green, or honestly say BLOCKED.
3
+ *
4
+ * The whole point (docs/design/plan-debug-build.md): /debug never guesses what
5
+ * is wrong. It is given a reproducible failure, confirms it fails on the current
6
+ * code, fixes it, and VERIFIES by re-running — repro passes AND the existing
7
+ * suite does not regress — selecting the minimal passing candidate. There is no
8
+ * path to FIXED without execution.
9
+ */
10
+
11
+ import { type FixCandidate, selectMinimalPassingCandidate } from "./candidate-select.js";
12
+ import { type CommandResult, passed } from "./execution.js";
13
+
14
+ /** A concrete, reproducible failing state — /debug's input. */
15
+ export interface FailingState {
16
+ /** A test command, e.g. "pytest tests/x.py::test_y". */
17
+ failingTest?: string;
18
+ /** A stack trace / error output pasted from a real run. */
19
+ trace?: string;
20
+ /** A command that exhibits the bug. */
21
+ reproCommand?: string;
22
+ /** What should happen instead (from the user/spec). */
23
+ expected?: string;
24
+ cwd?: string;
25
+ }
26
+
27
+ /** How to run the existing suite, to catch regressions. */
28
+ export interface SuiteRecipe {
29
+ test?: string;
30
+ }
31
+
32
+ export type DebugVerdict = "FIXED" | "BLOCKED";
33
+
34
+ export interface DebugOutcome {
35
+ verdict: DebugVerdict;
36
+ patch?: string;
37
+ evidence?: { reproBefore: "fail"; reproAfter: "pass"; suite: "green" | "skipped" };
38
+ reason?: string;
39
+ }
40
+
41
+ /** True when the input actually gives something runnable to reproduce. */
42
+ export function hasReproducibleFailure(state: FailingState): boolean {
43
+ return Boolean(state.failingTest?.trim() || state.reproCommand?.trim());
44
+ }
45
+
46
+ /** The command that reproduces the failure (test preferred over generic repro). */
47
+ export function reproCommand(state: FailingState): string | undefined {
48
+ return state.failingTest?.trim() || state.reproCommand?.trim() || undefined;
49
+ }
50
+
51
+ export type ReproduceDecision = { action: "proceed"; symptom: string } | { action: "blocked"; reason: string };
52
+
53
+ /**
54
+ * REPRODUCE gate: the failing state must FAIL on the current code. If there is
55
+ * nothing runnable, or it already passes, /debug stops — it will not fabricate a
56
+ * bug or a repro from imagination.
57
+ */
58
+ export function decideReproduce(state: FailingState, runOnCurrentCode: CommandResult | null): ReproduceDecision {
59
+ if (!hasReproducibleFailure(state)) {
60
+ return { action: "blocked", reason: "no reproducible failing state (need a failing test or a repro command)" };
61
+ }
62
+ if (runOnCurrentCode === null) {
63
+ return { action: "blocked", reason: "could not run the reproduction (no executable environment)" };
64
+ }
65
+ if (passed(runOnCurrentCode)) {
66
+ return { action: "blocked", reason: "the reported failure does not reproduce — it passes on the current code" };
67
+ }
68
+ return { action: "proceed", symptom: `${runOnCurrentCode.command} exited ${runOnCurrentCode.exitCode}` };
69
+ }
70
+
71
+ /** A fix candidate paired with its verification runs. */
72
+ export interface VerifiedCandidate {
73
+ source: string;
74
+ patch: string;
75
+ /** The reproduction re-run WITH this candidate applied. */
76
+ reproAfter: CommandResult | null;
77
+ /** The existing suite re-run WITH this candidate applied (null = no suite). */
78
+ suite: CommandResult | null;
79
+ }
80
+
81
+ /**
82
+ * VERIFY: a candidate is accepted only if the repro now PASSES and the suite (if
83
+ * any) is green. Among accepted candidates, pick the minimal diff. If none is
84
+ * accepted, BLOCKED — never ship the least-bad.
85
+ */
86
+ export function decideVerify(candidates: VerifiedCandidate[], hasSuite: boolean): DebugOutcome {
87
+ const fixCandidates: FixCandidate[] = candidates.map((c) => {
88
+ const reproOk = c.reproAfter !== null && passed(c.reproAfter);
89
+ const suiteOk = !hasSuite || (c.suite !== null && passed(c.suite));
90
+ return { source: c.source, patch: c.patch, passedRepro: reproOk && suiteOk };
91
+ });
92
+ const selection = selectMinimalPassingCandidate(fixCandidates);
93
+ if (!selection.chosen) {
94
+ return {
95
+ verdict: "BLOCKED",
96
+ reason: fixCandidates.some((c) => c.patch.trim())
97
+ ? "no candidate makes the reproduction pass without regressing the suite"
98
+ : "no candidate produced a patch",
99
+ };
100
+ }
101
+ return {
102
+ verdict: "FIXED",
103
+ patch: selection.chosen.patch,
104
+ evidence: { reproBefore: "fail", reproAfter: "pass", suite: hasSuite ? "green" : "skipped" },
105
+ reason: selection.reason,
106
+ };
107
+ }
108
+
109
+ /** Parse a /debug argument string / structured input into a FailingState. */
110
+ export function parseFailingState(arg: string, structured?: Partial<FailingState>): FailingState {
111
+ const a = arg.trim();
112
+ const state: FailingState = { ...structured };
113
+ // Heuristic: a bare test-runner invocation is a failing test; anything else
114
+ // with a command shape is a repro command; free prose is `expected`.
115
+ if (!state.failingTest && !state.reproCommand && a) {
116
+ // Test-runner invocations → a failing test. Bare `node x.js` / `python x.py`
117
+ // / `./x` are script runs → a repro command. Free prose → expected.
118
+ if (
119
+ /^(pytest|jest|vitest|mocha|go test|cargo test|npm (run )?test|npx (vitest|jest|mocha|playwright)|python -m pytest)\b/.test(
120
+ a,
121
+ )
122
+ ) {
123
+ state.failingTest = a;
124
+ } else if (/^(node|python|python3|\.\/|bash|sh|deno|bun|ruby|php)\b|[|&;><]/.test(a)) {
125
+ state.reproCommand = a;
126
+ } else {
127
+ state.expected = state.expected || a;
128
+ }
129
+ }
130
+ return state;
131
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Execution oracle — run real commands and report objective results.
3
+ *
4
+ * This is the ground truth the /plan → /debug → /build design turns on: an
5
+ * accept/reject decision must come from RUNNING code, not from a model reviewing
6
+ * its own output (see docs/design/plan-debug-build.md). Everything here is thin
7
+ * and deterministic so the result interpretation can be unit-tested; the spawn
8
+ * itself is exercised with trivial real commands.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
14
+
15
+ export interface CommandResult {
16
+ command: string;
17
+ exitCode: number | null;
18
+ stdout: string;
19
+ stderr: string;
20
+ durationMs: number;
21
+ timedOut: boolean;
22
+ }
23
+
24
+ /** A command passed iff it exited 0 and did not time out. */
25
+ export function passed(result: CommandResult): boolean {
26
+ return result.exitCode === 0 && !result.timedOut;
27
+ }
28
+
29
+ /** One-line summary for a phase report / handoff. */
30
+ export function summarize(result: CommandResult): string {
31
+ if (result.timedOut) return `\`${result.command}\` → TIMED OUT after ${Math.round(result.durationMs / 1000)}s`;
32
+ return `\`${result.command}\` → exit ${result.exitCode ?? "?"} (${Math.round(result.durationMs / 1000)}s)`;
33
+ }
34
+
35
+ /** Last N lines of the combined output — what a model needs to see to react. */
36
+ export function tail(result: CommandResult, lines = 40): string {
37
+ const combined = `${result.stdout}\n${result.stderr}`.trimEnd();
38
+ const all = combined.split("\n");
39
+ return all.slice(Math.max(0, all.length - lines)).join("\n");
40
+ }
41
+
42
+ export interface RunOptions {
43
+ cwd?: string;
44
+ timeoutMs?: number;
45
+ env?: NodeJS.ProcessEnv;
46
+ }
47
+
48
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
49
+
50
+ /**
51
+ * Run a shell command and capture its result. Never throws — a non-zero exit,
52
+ * a spawn error, or a timeout all come back as a CommandResult so callers make
53
+ * decisions from data, not exceptions.
54
+ */
55
+ export function runCommand(command: string, options: RunOptions = {}): CommandResult {
56
+ const start = Date.now();
57
+ const res = spawnSync(command, {
58
+ cwd: options.cwd,
59
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
60
+ env: options.env ?? process.env,
61
+ shell: true,
62
+ encoding: "utf-8",
63
+ maxBuffer: DEFAULT_MAX_BUFFER,
64
+ });
65
+ const durationMs = Date.now() - start;
66
+ // spawnSync sets error with code "ETIMEDOUT" on timeout, and signal SIGTERM.
67
+ const timedOut = res.error !== undefined && (res.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
68
+ const spawnFailed = res.error !== undefined && !timedOut;
69
+ return {
70
+ command,
71
+ exitCode: spawnFailed ? null : (res.status ?? (timedOut ? null : null)),
72
+ stdout: res.stdout ?? "",
73
+ stderr: spawnFailed ? `${(res.error as Error).message}\n${res.stderr ?? ""}` : (res.stderr ?? ""),
74
+ durationMs,
75
+ timedOut,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Run a program by ARGV with NO shell. This is what the Docker sandbox uses:
81
+ * passing `docker` + its arguments directly avoids shell quoting and — on
82
+ * Windows Git Bash — the MSYS path mangling that corrupts `-v C:\x:/work` and
83
+ * `//var/run/docker.sock`. `label` is the human-readable command echoed back in
84
+ * the result (the argv itself is not a shell string). Never throws.
85
+ */
86
+ export function runArgv(file: string, args: string[], options: RunOptions & { label?: string } = {}): CommandResult {
87
+ const start = Date.now();
88
+ const res = spawnSync(file, args, {
89
+ cwd: options.cwd,
90
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
91
+ env: options.env ?? process.env,
92
+ shell: false,
93
+ encoding: "utf-8",
94
+ maxBuffer: DEFAULT_MAX_BUFFER,
95
+ });
96
+ const durationMs = Date.now() - start;
97
+ const timedOut = res.error !== undefined && (res.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
98
+ const spawnFailed = res.error !== undefined && !timedOut;
99
+ return {
100
+ command: options.label ?? `${file} ${args.join(" ")}`.trim(),
101
+ exitCode: spawnFailed ? null : (res.status ?? null),
102
+ stdout: res.stdout ?? "",
103
+ stderr: spawnFailed ? `${(res.error as Error).message}\n${res.stderr ?? ""}` : (res.stderr ?? ""),
104
+ durationMs,
105
+ timedOut,
106
+ };
107
+ }