@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,115 @@
1
+ /**
2
+ * Executable red-team — the adversary's deliverable is a FAILING RUN, not an
3
+ * opinion (docs/design/plan-debug-build.md). This is the pure decision core: it
4
+ * turns the outcome of each adversarial attempt into a loop step, so a "breaking
5
+ * case" is only ever recorded when a real test went RED. Prose can't lie its way
6
+ * to a finding here — execution is the oracle.
7
+ *
8
+ * Why this exists: in the 3362 measurement an over-clever guard survived a
9
+ * different model's careful written review because the reviewer shared the
10
+ * misconception. It would NOT have survived the assertion being *run*. So the
11
+ * adversary must attack the specific input regime the diff touched, and express
12
+ * the attack as an executed test.
13
+ */
14
+
15
+ import type { FailingState } from "./debug-contract.js";
16
+ import { type CommandResult, passed, tail } from "./execution.js";
17
+
18
+ /** One adversarial attempt: a runnable test aimed at a specific input regime. */
19
+ export interface RedTeamAttempt {
20
+ /** Which boundary/regime it targets (e.g. "buffered vs streaming"). */
21
+ regime: string;
22
+ /** The runnable breaking test (a command). */
23
+ test: string;
24
+ /** The execution of that test (null = it could not be run). */
25
+ result: CommandResult | null;
26
+ }
27
+
28
+ /** A confirmed break: an attempt whose test actually ran RED. */
29
+ export interface BreakingCase {
30
+ regime: string;
31
+ test: string;
32
+ symptom: string;
33
+ }
34
+
35
+ export interface RedTeamState {
36
+ /** Consecutive rounds that failed to break the code. */
37
+ dry: number;
38
+ attemptsUsed: number;
39
+ breakingCases: BreakingCase[];
40
+ }
41
+
42
+ export interface RedTeamConfig {
43
+ /** Stop after this many consecutive rounds that could not break the code. */
44
+ dryRoundsToStop: number;
45
+ /** Hard budget on attempts. */
46
+ maxAttempts: number;
47
+ }
48
+
49
+ export const DEFAULT_REDTEAM_CONFIG: RedTeamConfig = { dryRoundsToStop: 2, maxAttempts: 8 };
50
+
51
+ export function initRedTeam(): RedTeamState {
52
+ return { dry: 0, attemptsUsed: 0, breakingCases: [] };
53
+ }
54
+
55
+ /** Keep going only while under budget and not yet K dry rounds in a row. */
56
+ export function shouldContinueRedTeam(state: RedTeamState, config: RedTeamConfig): boolean {
57
+ return state.attemptsUsed < config.maxAttempts && state.dry < config.dryRoundsToStop;
58
+ }
59
+
60
+ /**
61
+ * Fold one attempt into the state. A RED run (test failed) is a breaking case
62
+ * and resets the dry counter; a GREEN run (or one that could not be executed at
63
+ * all) counts as a dry round — you cannot claim a break you did not run.
64
+ */
65
+ export function recordAttempt(state: RedTeamState, attempt: RedTeamAttempt, config: RedTeamConfig): RedTeamState {
66
+ const attemptsUsed = state.attemptsUsed + 1;
67
+ const ran = attempt.result !== null;
68
+ const broke = ran && !passed(attempt.result as CommandResult);
69
+
70
+ if (broke) {
71
+ const result = attempt.result as CommandResult;
72
+ return {
73
+ dry: 0,
74
+ attemptsUsed,
75
+ breakingCases: [
76
+ ...state.breakingCases,
77
+ { regime: attempt.regime, test: attempt.test, symptom: tail(result, 20) || `exit ${result.exitCode}` },
78
+ ],
79
+ };
80
+ }
81
+ // Green, or unrunnable: this round produced no finding.
82
+ void config;
83
+ return { dry: state.dry + 1, attemptsUsed, breakingCases: state.breakingCases };
84
+ }
85
+
86
+ /** Turn confirmed breaks into failing states that /debug can consume directly. */
87
+ export function breakingCasesToFailingStates(cases: BreakingCase[], cwd?: string): FailingState[] {
88
+ return cases.map((c) => ({
89
+ reproCommand: c.test,
90
+ trace: c.symptom,
91
+ expected: `input regime "${c.regime}" must not break the change`,
92
+ cwd,
93
+ }));
94
+ }
95
+
96
+ const STREAMING_HINT = /\b(stream|iter|chunk|read|io|http|response|decode|encode|buffer|socket|pipe)\b/i;
97
+ const PARSE_HINT = /\b(parse|json|yaml|xml|deserialize|decode|token|lexer)\b/i;
98
+ const NUMERIC_HINT = /\b(sum|count|index|offset|length|size|range|price|amount|math|float|int)\b/i;
99
+ const AUTH_HINT = /\b(auth|login|token|session|password|permission|role|jwt)\b/i;
100
+
101
+ /**
102
+ * Cheap enumeration of the input regimes worth attacking, given hints from the
103
+ * change (file names + keywords). Always includes the universal boundaries;
104
+ * adds domain-specific ones when the hints match. The adversary attacks these,
105
+ * not the whole app.
106
+ */
107
+ export function enumerateInputRegimes(hints: { changedFiles?: string[]; keywords?: string } = {}): string[] {
108
+ const blob = `${(hints.changedFiles ?? []).join(" ")} ${hints.keywords ?? ""}`;
109
+ const regimes = ["empty input", "null/undefined", "boundary value", "wrong type", "large input"];
110
+ if (STREAMING_HINT.test(blob)) regimes.push("buffered vs streaming");
111
+ if (PARSE_HINT.test(blob)) regimes.push("malformed/partial input", "non-ASCII / unicode");
112
+ if (NUMERIC_HINT.test(blob)) regimes.push("zero / negative", "overflow / very large number");
113
+ if (AUTH_HINT.test(blob)) regimes.push("missing credentials", "expired / tampered token");
114
+ return regimes;
115
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Sandbox planning — the pure core that turns "what project is this?" into "how
3
+ * do I run its code in a guaranteed environment?" (docs/design/plan-debug-build.md,
4
+ * §Execution grounding).
5
+ *
6
+ * The SWE-bench measurement was defeated because the host Python was too new to
7
+ * run the target library, so the model reconstructed a mock and graded itself.
8
+ * A per-project container fixes that: run the real reproduction/suite with the
9
+ * project's real toolchain. Everything here is pure (no fs, no spawn) so the
10
+ * toolchain detection, the recipe, the backend decision, and — critically — the
11
+ * `docker run` argv (where the Windows path/quoting bugs live) are all unit-tested.
12
+ */
13
+
14
+ export type ToolchainKind = "node" | "python" | "go" | "rust" | "ruby" | "unknown";
15
+
16
+ export interface Toolchain {
17
+ kind: ToolchainKind;
18
+ /** The marker file that determined it (e.g. "package.json"). */
19
+ marker: string | null;
20
+ hasDockerfile: boolean;
21
+ hasCompose: boolean;
22
+ }
23
+
24
+ /** `.phi/sandbox.json` — explicit project override, always wins over detection. */
25
+ export interface SandboxConfig {
26
+ backend?: "docker" | "local" | "auto";
27
+ image?: string;
28
+ setup?: string;
29
+ test?: string;
30
+ workdir?: string;
31
+ env?: Record<string, string>;
32
+ /** Allow network inside the container (default true — deps often need it). */
33
+ network?: boolean;
34
+ memory?: string;
35
+ cpus?: string;
36
+ }
37
+
38
+ export type RecipeSource = "detected" | "dockerfile" | "config";
39
+
40
+ export interface SandboxRecipe {
41
+ image: string;
42
+ /** One-off dependency install, run against the mounted project. */
43
+ setup?: string;
44
+ /** Best-guess suite command (a floor; the caller may override). */
45
+ test?: string;
46
+ workdir: string;
47
+ env: Record<string, string>;
48
+ network: boolean;
49
+ memory?: string;
50
+ cpus?: string;
51
+ source: RecipeSource;
52
+ }
53
+
54
+ const MOUNT_WORKDIR = "/work";
55
+
56
+ const BASE_IMAGES: Record<Exclude<ToolchainKind, "unknown">, string> = {
57
+ node: "node:20-slim",
58
+ python: "python:3.12-slim",
59
+ go: "golang:1.22-bookworm",
60
+ rust: "rust:1-slim",
61
+ ruby: "ruby:3-slim",
62
+ };
63
+
64
+ const DEFAULT_ENV: Record<ToolchainKind, Record<string, string>> = {
65
+ node: { CI: "1" },
66
+ // PYTHONSAFEPATH stops a stray local module from shadowing the stdlib (the
67
+ // `select.py`/`resource` class of failures seen in the SWE-bench harness).
68
+ python: { PYTHONSAFEPATH: "1", PYTHONDONTWRITEBYTECODE: "1" },
69
+ go: {},
70
+ rust: {},
71
+ ruby: {},
72
+ unknown: {},
73
+ };
74
+
75
+ /** Detect the toolchain from a project's top-level file names. Order = priority. */
76
+ export function detectToolchain(files: string[]): Toolchain {
77
+ const set = new Set(files.map((f) => f.trim()).filter(Boolean));
78
+ const has = (name: string) => set.has(name);
79
+ const hasDockerfile = has("Dockerfile");
80
+ const hasCompose = has("docker-compose.yml") || has("compose.yml") || has("docker-compose.yaml");
81
+
82
+ let kind: ToolchainKind = "unknown";
83
+ let marker: string | null = null;
84
+ if (has("package.json")) {
85
+ kind = "node";
86
+ marker = "package.json";
87
+ } else if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) {
88
+ kind = "python";
89
+ marker = has("pyproject.toml") ? "pyproject.toml" : has("requirements.txt") ? "requirements.txt" : "setup.py";
90
+ } else if (has("go.mod")) {
91
+ kind = "go";
92
+ marker = "go.mod";
93
+ } else if (has("Cargo.toml")) {
94
+ kind = "rust";
95
+ marker = "Cargo.toml";
96
+ } else if (has("Gemfile")) {
97
+ kind = "ruby";
98
+ marker = "Gemfile";
99
+ }
100
+ return { kind, marker, hasDockerfile, hasCompose };
101
+ }
102
+
103
+ function detectedSetup(tc: Toolchain, files: Set<string>): string | undefined {
104
+ switch (tc.kind) {
105
+ case "node":
106
+ return files.has("package-lock.json") ? "npm ci || npm install" : "npm install";
107
+ case "python":
108
+ if (files.has("pyproject.toml") || files.has("setup.py")) return "pip install -e . || pip install .";
109
+ if (files.has("requirements.txt")) return "pip install -r requirements.txt";
110
+ return undefined;
111
+ case "go":
112
+ return "go mod download";
113
+ case "rust":
114
+ return "cargo fetch";
115
+ case "ruby":
116
+ return "bundle install";
117
+ default:
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ function detectedTest(tc: Toolchain): string | undefined {
123
+ switch (tc.kind) {
124
+ case "node":
125
+ return "npm test";
126
+ case "python":
127
+ return "pytest";
128
+ case "go":
129
+ return "go test ./...";
130
+ case "rust":
131
+ return "cargo test";
132
+ case "ruby":
133
+ return "bundle exec rake test";
134
+ default:
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Build the environment recipe from detected toolchain + files. A project
141
+ * Dockerfile is honored (source "dockerfile"); otherwise a base image is chosen.
142
+ */
143
+ export function defaultRecipe(tc: Toolchain, files: string[] = []): SandboxRecipe {
144
+ const set = new Set(files);
145
+ const base: SandboxRecipe = {
146
+ image: tc.kind === "unknown" ? "debian:bookworm-slim" : BASE_IMAGES[tc.kind],
147
+ setup: detectedSetup(tc, set),
148
+ test: detectedTest(tc),
149
+ workdir: MOUNT_WORKDIR,
150
+ env: { ...DEFAULT_ENV[tc.kind] },
151
+ network: true,
152
+ source: tc.hasDockerfile ? "dockerfile" : "detected",
153
+ };
154
+ return base;
155
+ }
156
+
157
+ /** Merge an explicit `.phi/sandbox.json` over a detected recipe (config wins). */
158
+ export function applyConfig(recipe: SandboxRecipe, config?: SandboxConfig): SandboxRecipe {
159
+ if (!config) return recipe;
160
+ return {
161
+ image: config.image ?? recipe.image,
162
+ setup: config.setup ?? recipe.setup,
163
+ test: config.test ?? recipe.test,
164
+ workdir: config.workdir ?? recipe.workdir,
165
+ env: { ...recipe.env, ...(config.env ?? {}) },
166
+ network: config.network ?? recipe.network,
167
+ memory: config.memory ?? recipe.memory,
168
+ cpus: config.cpus ?? recipe.cpus,
169
+ source: config.image || config.setup ? "config" : recipe.source,
170
+ };
171
+ }
172
+
173
+ export type Backend = "docker" | "local" | "unavailable";
174
+
175
+ export interface BackendSignals {
176
+ dockerAvailable: boolean;
177
+ requested?: "docker" | "local" | "auto";
178
+ toolchainKnown: boolean;
179
+ hasDockerfile: boolean;
180
+ }
181
+
182
+ export interface BackendDecision {
183
+ backend: Backend;
184
+ reason: string;
185
+ }
186
+
187
+ /**
188
+ * Choose the execution backend, honestly. Docker is the guaranteed oracle but
189
+ * only helps when we can containerize (a known toolchain or a Dockerfile). When
190
+ * the caller explicitly demanded docker and it is absent, the answer is
191
+ * `unavailable` — NOT a silent downgrade to a non-guaranteed local run.
192
+ */
193
+ export function decideBackend(s: BackendSignals): BackendDecision {
194
+ const req = s.requested ?? "auto";
195
+ if (req === "local") return { backend: "local", reason: "backend: local (requested)" };
196
+ if (req === "docker") {
197
+ return s.dockerAvailable
198
+ ? { backend: "docker", reason: "backend: docker (requested, available)" }
199
+ : { backend: "unavailable", reason: "docker requested but the daemon is not available" };
200
+ }
201
+ // auto
202
+ if (s.dockerAvailable && (s.toolchainKnown || s.hasDockerfile)) {
203
+ return { backend: "docker", reason: "backend: docker (guaranteed environment)" };
204
+ }
205
+ if (!s.dockerAvailable)
206
+ return {
207
+ backend: "local",
208
+ reason: "backend: local (docker unavailable — runs are real but not dependency-guaranteed)",
209
+ };
210
+ return { backend: "local", reason: "backend: local (unknown toolchain, no Dockerfile — nothing to containerize)" };
211
+ }
212
+
213
+ /**
214
+ * Normalize a host path into a Docker bind source. On Windows, forward-slash the
215
+ * path but keep the drive colon (`C:/Users/…`) — the form Docker Desktop accepts
216
+ * and, crucially, one that survives argv passing without MSYS mangling.
217
+ */
218
+ export function toBindSource(hostPath: string, platform: NodeJS.Platform = process.platform): string {
219
+ if (platform === "win32") return hostPath.replace(/\\/g, "/");
220
+ return hostPath;
221
+ }
222
+
223
+ export interface DockerRunSpec {
224
+ image: string;
225
+ /** The command run inside the container via `sh -c`. */
226
+ command: string;
227
+ /** Host path to bind-mount as the workdir. */
228
+ mountSource: string;
229
+ workdir: string;
230
+ env: Record<string, string>;
231
+ network: boolean;
232
+ memory?: string;
233
+ cpus?: string;
234
+ platform?: NodeJS.Platform;
235
+ }
236
+
237
+ /**
238
+ * Build the argv passed to `docker` (after the program name). Pure and total, so
239
+ * the exact invocation — mount, workdir, env, network isolation, resource caps —
240
+ * is asserted in tests instead of discovered in production.
241
+ */
242
+ export function buildDockerRunArgs(spec: DockerRunSpec): string[] {
243
+ const bind = `${toBindSource(spec.mountSource, spec.platform)}:${spec.workdir}`;
244
+ const args: string[] = ["run", "--rm", "-v", bind, "-w", spec.workdir];
245
+ for (const [k, v] of Object.entries(spec.env)) {
246
+ args.push("-e", `${k}=${v}`);
247
+ }
248
+ if (!spec.network) args.push("--network", "none");
249
+ if (spec.memory) args.push("--memory", spec.memory);
250
+ if (spec.cpus) args.push("--cpus", spec.cpus);
251
+ args.push(spec.image, "sh", "-c", spec.command);
252
+ return args;
253
+ }
254
+
255
+ /** A small, dependency-free deterministic hash (djb2) for cache tags. */
256
+ function djb2(input: string): string {
257
+ let h = 5381;
258
+ for (let i = 0; i < input.length; i++) {
259
+ h = ((h << 5) + h + input.charCodeAt(i)) >>> 0;
260
+ }
261
+ return h.toString(16).padStart(8, "0");
262
+ }
263
+
264
+ /** Deterministic image tag for a built recipe (project Dockerfile / config). */
265
+ export function imageTagFor(recipe: SandboxRecipe): string {
266
+ return `phi-sandbox:${djb2(`${recipe.image}|${recipe.setup ?? ""}|${recipe.workdir}`)}`;
267
+ }
268
+
269
+ /** Generate a minimal Dockerfile for a detected recipe (utility / persistence). */
270
+ export function generatedDockerfile(recipe: SandboxRecipe): string {
271
+ const envLines = Object.entries(recipe.env).map(([k, v]) => `ENV ${k}=${v}`);
272
+ return [`FROM ${recipe.image}`, `WORKDIR ${recipe.workdir}`, ...envLines, ""].join("\n");
273
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Sandbox — the thin IO shell over the pure planner (sandbox-plan.ts). It turns
3
+ * a project directory into a runnable environment and executes commands in it,
4
+ * returning the same CommandResult the oracle cores already consume. Three
5
+ * backends: `docker` (the guaranteed environment), `local` (real runs, but not
6
+ * dependency-guaranteed), and `unavailable` (honest — every exec reports it
7
+ * could not run, so /debug and /build emit BLOCKED instead of a fabricated pass).
8
+ *
9
+ * The fs reads and spawns are injectable so the routing/interpretation is
10
+ * unit-tested without a Docker daemon; a separate docker-gated smoke test does
11
+ * one real container run.
12
+ */
13
+
14
+ import { readdirSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import {
17
+ type CommandResult,
18
+ passed,
19
+ type RunOptions,
20
+ runArgv as realRunArgv,
21
+ runCommand as realRunCommand,
22
+ } from "./execution.js";
23
+ import {
24
+ applyConfig,
25
+ type Backend,
26
+ buildDockerRunArgs,
27
+ decideBackend,
28
+ defaultRecipe,
29
+ detectToolchain,
30
+ imageTagFor,
31
+ type SandboxConfig,
32
+ type SandboxRecipe,
33
+ } from "./sandbox-plan.js";
34
+
35
+ export interface PrepareResult {
36
+ ok: boolean;
37
+ backend: Backend;
38
+ detail: string;
39
+ result?: CommandResult;
40
+ }
41
+
42
+ export interface Sandbox {
43
+ readonly backend: Backend;
44
+ readonly recipe: SandboxRecipe;
45
+ readonly reason: string;
46
+ describe(): string;
47
+ available(): boolean;
48
+ /** Run a command in the environment. Never throws (see execution.ts). */
49
+ exec(command: string, options?: RunOptions): CommandResult;
50
+ /** Build the image / install deps. Idempotent, best-effort. */
51
+ prepare(): PrepareResult;
52
+ }
53
+
54
+ type RunCommandFn = (command: string, options?: RunOptions) => CommandResult;
55
+ type RunArgvFn = (file: string, args: string[], options?: RunOptions & { label?: string }) => CommandResult;
56
+
57
+ /** Injectable seams — real fs/spawn by default, fakes in tests. */
58
+ export interface SandboxDeps {
59
+ runCommand?: RunCommandFn;
60
+ runArgv?: RunArgvFn;
61
+ listFiles?: (cwd: string) => string[];
62
+ readConfig?: (cwd: string) => SandboxConfig | undefined;
63
+ /** Force docker availability in tests; otherwise probed via `docker version`. */
64
+ dockerAvailable?: boolean;
65
+ }
66
+
67
+ export interface ResolveOptions {
68
+ cwd: string;
69
+ requested?: "docker" | "local" | "auto";
70
+ deps?: SandboxDeps;
71
+ }
72
+
73
+ /** List a project's top-level entries (best-effort; empty on error). */
74
+ export function listProjectFiles(cwd: string): string[] {
75
+ try {
76
+ return readdirSync(cwd);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /** Read and parse `.phi/sandbox.json` if present. */
83
+ export function readSandboxConfig(cwd: string): SandboxConfig | undefined {
84
+ try {
85
+ const raw = readFileSync(join(cwd, ".phi", "sandbox.json"), "utf-8");
86
+ const parsed = JSON.parse(raw);
87
+ return parsed && typeof parsed === "object" ? (parsed as SandboxConfig) : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ /** Probe the Docker daemon (server version reachable). Cheap, capped timeout. */
94
+ export function probeDocker(ra: RunArgvFn): boolean {
95
+ const r = ra("docker", ["version", "--format", "{{.Server.Version}}"], { timeoutMs: 8000, label: "docker version" });
96
+ return passed(r) && r.stdout.trim().length > 0;
97
+ }
98
+
99
+ const UNAVAILABLE_MESSAGE = "SANDBOX UNAVAILABLE — no executable environment; do not fabricate a result, emit BLOCKED.";
100
+
101
+ function unavailableResult(command: string): CommandResult {
102
+ return { command, exitCode: null, stdout: "", stderr: UNAVAILABLE_MESSAGE, durationMs: 0, timedOut: false };
103
+ }
104
+
105
+ /**
106
+ * Resolve the sandbox for a project directory: detect toolchain, merge config,
107
+ * decide the backend honestly, and return an executor. Docker exec runs
108
+ * `docker run --rm -v <cwd>:/work …`; edits land on the host mount, so /debug's
109
+ * fix-then-rerun works and the container stays ephemeral.
110
+ */
111
+ export function resolveSandbox(opts: ResolveOptions): Sandbox {
112
+ const deps = opts.deps ?? {};
113
+ const rc = deps.runCommand ?? realRunCommand;
114
+ const ra = deps.runArgv ?? realRunArgv;
115
+ const files = (deps.listFiles ?? listProjectFiles)(opts.cwd);
116
+ const config = (deps.readConfig ?? readSandboxConfig)(opts.cwd);
117
+ const tc = detectToolchain(files);
118
+ const recipe = applyConfig(defaultRecipe(tc, files), config);
119
+ const requested = opts.requested ?? config?.backend ?? "auto";
120
+ const dockerAvailable = deps.dockerAvailable ?? probeDocker(ra);
121
+ const decision = decideBackend({
122
+ dockerAvailable,
123
+ requested,
124
+ toolchainKnown: tc.kind !== "unknown",
125
+ hasDockerfile: tc.hasDockerfile,
126
+ });
127
+
128
+ // Effective image can change after prepare() builds a project Dockerfile.
129
+ const state = { image: recipe.image };
130
+
131
+ const dockerExec = (command: string, options?: RunOptions): CommandResult => {
132
+ const args = buildDockerRunArgs({
133
+ image: state.image,
134
+ command,
135
+ mountSource: opts.cwd,
136
+ workdir: recipe.workdir,
137
+ env: recipe.env,
138
+ network: recipe.network,
139
+ memory: recipe.memory,
140
+ cpus: recipe.cpus,
141
+ });
142
+ return ra("docker", args, {
143
+ cwd: opts.cwd,
144
+ timeoutMs: options?.timeoutMs,
145
+ label: `docker[${state.image}] ${command}`,
146
+ });
147
+ };
148
+
149
+ const base = { backend: decision.backend, recipe, reason: decision.reason };
150
+
151
+ if (decision.backend === "unavailable") {
152
+ return {
153
+ ...base,
154
+ describe: () => `unavailable — ${decision.reason}`,
155
+ available: () => false,
156
+ exec: (command) => unavailableResult(command),
157
+ prepare: () => ({ ok: false, backend: "unavailable", detail: decision.reason }),
158
+ };
159
+ }
160
+
161
+ if (decision.backend === "local") {
162
+ return {
163
+ ...base,
164
+ describe: () => `local host (${opts.cwd})`,
165
+ available: () => true,
166
+ exec: (command, options) => rc(command, { cwd: opts.cwd, ...options }),
167
+ // Deliberately no host-side dependency install — running `npm install`
168
+ // etc. on the user's host is intrusive; local is best-effort as-is.
169
+ prepare: () => ({ ok: true, backend: "local", detail: "local host — no preparation performed" }),
170
+ };
171
+ }
172
+
173
+ // docker
174
+ return {
175
+ ...base,
176
+ describe: () => `docker (${state.image})`,
177
+ available: () => true,
178
+ exec: dockerExec,
179
+ prepare: () => {
180
+ if (recipe.source === "dockerfile") {
181
+ const tag = imageTagFor(recipe);
182
+ const built = ra("docker", ["build", "-t", tag, "-f", "Dockerfile", "."], {
183
+ cwd: opts.cwd,
184
+ label: `docker build ${tag}`,
185
+ });
186
+ if (passed(built)) state.image = tag;
187
+ return {
188
+ ok: passed(built),
189
+ backend: "docker",
190
+ detail: passed(built) ? `built image ${tag} from Dockerfile` : "docker build failed",
191
+ result: built,
192
+ };
193
+ }
194
+ // Base image: pull it, then run the dependency setup against the mount.
195
+ const pulled = ra("docker", ["pull", state.image], { cwd: opts.cwd, label: `docker pull ${state.image}` });
196
+ if (!recipe.setup) {
197
+ return {
198
+ ok: passed(pulled),
199
+ backend: "docker",
200
+ detail: `pulled ${state.image} (no setup step)`,
201
+ result: pulled,
202
+ };
203
+ }
204
+ const setup = dockerExec(recipe.setup);
205
+ return {
206
+ ok: passed(setup),
207
+ backend: "docker",
208
+ detail: passed(setup)
209
+ ? `pulled ${state.image}; ran setup \`${recipe.setup}\``
210
+ : `setup failed: \`${recipe.setup}\``,
211
+ result: setup,
212
+ };
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Triage / adaptive depth — pick the CHEAPEST mode consistent with the signals.
3
+ *
4
+ * The measured lesson (docs/design/plan-debug-build.md): the 6–14× overhead of
5
+ * the full pipeline is only worth paying when a single shot fails the real
6
+ * oracle. So this classifier defaults to the least machinery and escalates only
7
+ * on concrete signals — a supplied failing state routes to /debug, a large or
8
+ * under-specified build routes to /build, everything else is a single shot.
9
+ */
10
+
11
+ export type Route = "debug" | "single-shot" | "build" | "plan";
12
+
13
+ export interface TriageSignals {
14
+ /** A concrete failing test / trace / repro command was supplied. */
15
+ hasFailingState?: boolean;
16
+ /** Rough number of files the task will touch (undefined = estimate from text). */
17
+ estimatedFiles?: number;
18
+ /** The task text, for cheap keyword signals. */
19
+ text?: string;
20
+ /** Caller forces a specific mode (an explicit /debug, /build, /plan). */
21
+ forced?: Route;
22
+ /** Caller wants only a plan artifact, not the execution loop. */
23
+ planOnly?: boolean;
24
+ }
25
+
26
+ export interface TriageDecision {
27
+ route: Route;
28
+ /** "minimal" = do the least; "full" = the multi-phase pipeline is justified. */
29
+ depth: "minimal" | "full";
30
+ reason: string;
31
+ }
32
+
33
+ const BUILD_KEYWORDS =
34
+ /\b(build|scaffold|application|app|feature|end[- ]?to[- ]?end|full[- ]?stack|from scratch|new (project|service|module|api|endpoint|component|page)|several files|multiple files)\b/i;
35
+
36
+ /** At/above this many touched files, a single shot is unlikely to stay coherent. */
37
+ export const MULTI_FILE_THRESHOLD = 3;
38
+
39
+ /**
40
+ * Cheap file-count estimate from a request: count distinct path-like tokens.
41
+ * Deliberately conservative — defaults to 1 when nothing looks like a path.
42
+ */
43
+ export function estimateFiles(text: string): number {
44
+ if (!text.trim()) return 1;
45
+ const paths = new Set<string>();
46
+ for (const m of text.matchAll(/\b[\w./-]+\.[a-z]{1,6}\b/gi)) {
47
+ paths.add(m[0].toLowerCase());
48
+ }
49
+ return Math.max(1, paths.size);
50
+ }
51
+
52
+ /**
53
+ * Map signals to a route + depth. Pure and deterministic: same signals in, same
54
+ * decision out. Order matters — a forced mode wins, then a real failing state
55
+ * (cheapest useful oracle), then build-scale, else single shot.
56
+ */
57
+ export function triage(signals: TriageSignals): TriageDecision {
58
+ if (signals.forced) {
59
+ return {
60
+ route: signals.forced,
61
+ depth: signals.forced === "single-shot" ? "minimal" : "full",
62
+ reason: `explicit /${signals.forced}`,
63
+ };
64
+ }
65
+
66
+ if (signals.hasFailingState) {
67
+ return {
68
+ route: "debug",
69
+ depth: "minimal",
70
+ reason: "a reproducible failing state was supplied — fix it directly, skip planning",
71
+ };
72
+ }
73
+
74
+ const text = signals.text ?? "";
75
+ const files = signals.estimatedFiles ?? estimateFiles(text);
76
+ const buildy = BUILD_KEYWORDS.test(text);
77
+ const large = files >= MULTI_FILE_THRESHOLD;
78
+
79
+ if (buildy || large) {
80
+ const why = large ? `~${files} files touched (≥ ${MULTI_FILE_THRESHOLD})` : "build-scale request";
81
+ if (signals.planOnly) {
82
+ return {
83
+ route: "plan",
84
+ depth: "full",
85
+ reason: `${why}; plan-only requested — decompose without the run loop`,
86
+ };
87
+ }
88
+ return { route: "build", depth: "full", reason: `${why} — decompose and run the build→verify→debug loop` };
89
+ }
90
+
91
+ return {
92
+ route: "single-shot",
93
+ depth: "minimal",
94
+ reason: "small, self-contained — try one shot and verify before escalating",
95
+ };
96
+ }