@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.
- package/CHANGELOG.md +94 -0
- package/docs/design/plan-debug-build.md +283 -0
- package/extensions/phi/orchestrator.ts +486 -7
- package/extensions/phi/providers/acceptance.ts +102 -0
- package/extensions/phi/providers/build-loop.ts +89 -0
- package/extensions/phi/providers/candidate-select.ts +112 -0
- package/extensions/phi/providers/debug-build-commands.ts +132 -0
- package/extensions/phi/providers/debug-contract.ts +131 -0
- package/extensions/phi/providers/execution.ts +107 -0
- package/extensions/phi/providers/redteam.ts +115 -0
- package/extensions/phi/providers/sandbox-plan.ts +273 -0
- package/extensions/phi/providers/sandbox.ts +215 -0
- package/extensions/phi/providers/triage.ts +96 -0
- package/package.json +1 -1
|
@@ -23,6 +23,9 @@ import { join } from "node:path";
|
|
|
23
23
|
import { Type } from "@sinclair/typebox";
|
|
24
24
|
import type { ExtensionAPI } from "phi-code";
|
|
25
25
|
import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
|
|
26
|
+
import { buildVerifyInstruction, debugPhaseInstructions } from "./providers/debug-build-commands.js";
|
|
27
|
+
import { type FailingState, parseFailingState } from "./providers/debug-contract.js";
|
|
28
|
+
import { passed, tail } from "./providers/execution.js";
|
|
26
29
|
import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
|
|
27
30
|
import {
|
|
28
31
|
analyzePhaseMessages,
|
|
@@ -31,6 +34,8 @@ import {
|
|
|
31
34
|
resolvePhaseOutcome,
|
|
32
35
|
type StructuredPhaseResult,
|
|
33
36
|
} from "./providers/phase-machine.js";
|
|
37
|
+
import { resolveSandbox, type Sandbox } from "./providers/sandbox.js";
|
|
38
|
+
import { triage } from "./providers/triage.js";
|
|
34
39
|
|
|
35
40
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
36
41
|
|
|
@@ -289,6 +294,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
289
294
|
|
|
290
295
|
let phaseQueue: OrchestratorPhase[] = [];
|
|
291
296
|
let orchestrationActive = false;
|
|
297
|
+
// Which pipeline is running. "plan" keeps the existing 5-phase agent_end path
|
|
298
|
+
// untouched; "debug"/"build" are driven by the separate linear generic driver
|
|
299
|
+
// so /plan's fragile review-fix + "/5" logic is never engaged for them.
|
|
300
|
+
let orchestrationMode: "plan" | "debug" | "build" = "plan";
|
|
292
301
|
let activeAgentPrompt: string | null = null;
|
|
293
302
|
let _activeAgentTools: string[] | null = null;
|
|
294
303
|
let savedTools: string[] | null = null;
|
|
@@ -383,6 +392,66 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
383
392
|
},
|
|
384
393
|
});
|
|
385
394
|
|
|
395
|
+
// ─── Execution sandbox — the guaranteed oracle ──────────────────
|
|
396
|
+
// Resolved once per cwd and cached (resolveSandbox probes the Docker daemon).
|
|
397
|
+
// This is what turns "the agent says it ran the test" into "a real container
|
|
398
|
+
// ran the test and here is its exit code".
|
|
399
|
+
let sessionSandbox: { cwd: string; sandbox: Sandbox } | null = null;
|
|
400
|
+
function getSessionSandbox(cwd: string): Sandbox {
|
|
401
|
+
if (!sessionSandbox || sessionSandbox.cwd !== cwd) {
|
|
402
|
+
sessionSandbox = { cwd, sandbox: resolveSandbox({ cwd }) };
|
|
403
|
+
}
|
|
404
|
+
return sessionSandbox.sandbox;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
pi.registerTool({
|
|
408
|
+
name: "sandbox_run",
|
|
409
|
+
label: "Sandbox Run",
|
|
410
|
+
description:
|
|
411
|
+
"Run a command in the project's guaranteed environment (a Docker container when available) and get its REAL exit code. Use this for the reproduction, the test suite, and acceptance checks in /debug and /build — the verdict MUST come from this tool's result, not from your own reasoning. If it reports SANDBOX UNAVAILABLE, do not fabricate a result — emit BLOCKED.",
|
|
412
|
+
promptGuidelines: [
|
|
413
|
+
"In /debug and /build, run the reproduction, the existing suite, and acceptance/red-team checks with sandbox_run — never claim a pass you did not get back from it.",
|
|
414
|
+
"A command PASSES iff sandbox_run returns exit 0. Treat any non-zero exit, TIMEOUT, or UNAVAILABLE as not-passing.",
|
|
415
|
+
"The sandbox mounts the project, so edits you make are visible to the next sandbox_run (fix, then re-run to verify).",
|
|
416
|
+
],
|
|
417
|
+
parameters: Type.Object({
|
|
418
|
+
command: Type.String({
|
|
419
|
+
description: "The shell command to run in the project sandbox (e.g. 'pytest tests/x.py::test_y').",
|
|
420
|
+
}),
|
|
421
|
+
timeoutSeconds: Type.Optional(
|
|
422
|
+
Type.Number({ description: "Max seconds before the run is killed (default 300)." }),
|
|
423
|
+
),
|
|
424
|
+
}),
|
|
425
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
426
|
+
const p = params as { command: string; timeoutSeconds?: number };
|
|
427
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
428
|
+
const sandbox = getSessionSandbox(cwd);
|
|
429
|
+
const result = sandbox.exec(p.command, {
|
|
430
|
+
timeoutMs: Math.max(1, Math.min(1800, p.timeoutSeconds ?? 300)) * 1000,
|
|
431
|
+
});
|
|
432
|
+
const verdict = !sandbox.available()
|
|
433
|
+
? "UNAVAILABLE"
|
|
434
|
+
: result.timedOut
|
|
435
|
+
? "TIMEOUT"
|
|
436
|
+
: passed(result)
|
|
437
|
+
? "PASS"
|
|
438
|
+
: "FAIL";
|
|
439
|
+
const text =
|
|
440
|
+
`[sandbox: ${sandbox.describe()}]\n$ ${p.command}\n→ exit ${result.exitCode ?? "?"} ${verdict}\n\n` +
|
|
441
|
+
`${tail(result, 60) || "(no output)"}`;
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text }],
|
|
444
|
+
details: {
|
|
445
|
+
backend: sandbox.backend,
|
|
446
|
+
exitCode: result.exitCode,
|
|
447
|
+
passed: passed(result),
|
|
448
|
+
timedOut: result.timedOut,
|
|
449
|
+
verdict,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
386
455
|
// A phase's report file is not always named <key>-<ts>.md: PLAN writes its
|
|
387
456
|
// handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
|
|
388
457
|
// stem so the text fallback for HANDOFF/BLOCKING actually finds them.
|
|
@@ -705,6 +774,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
|
|
|
705
774
|
**Ontology update:** Use \`ontology_add\` to update the project status (e.g., entity "implementation" type "Phase" with properties {status: "complete", files: "N"}) and add a relation to the project entity.
|
|
706
775
|
|
|
707
776
|
**CRITICAL RULES:**
|
|
777
|
+
- **Minimal diff, root cause.** Prefer the SMALLEST change that fixes the underlying cause. Every extra condition, guard, or early-return you add is a liability — it can silently make the fix a no-op in the real scenario (a guard that skips the fix on the exact input the bug is about). When in doubt, do LESS and match the existing code's pattern rather than adding cleverness. A fix more elaborate than the problem is a red flag, not thoroughness.
|
|
708
778
|
- Write ONE file per tool call — NEVER combine multiple files in a single response
|
|
709
779
|
- Keep each file under 500 lines. If longer, split into modules
|
|
710
780
|
- After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` +
|
|
@@ -729,13 +799,11 @@ After implementation, use \`memory_write\` to save a summary of what was built,
|
|
|
729
799
|
|
|
730
800
|
**Step 1:** Read \`.phi/plans/todo-*.md\` to know what was planned
|
|
731
801
|
**Step 2:** Read \`.phi/plans/progress-*.md\` to see what was done
|
|
732
|
-
**Step 3:**
|
|
733
|
-
- **
|
|
734
|
-
- **
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
- Running \`npm test\` alone is NOT sufficient proof for a feature.
|
|
738
|
-
**Step 4:** Fix any real errors you find (root cause, not a workaround).
|
|
802
|
+
**Step 3 (GROUND TRUTH — the single most important step):** Derive the expected behaviour from the **Project Request above**, NEVER from the code that was written. Write a reproduction of the EXACT scenario the request describes — same conditions, same inputs. If the request is about a streaming response with no charset, stream a chunked response with no charset; if it is about an empty list, pass an empty list. Assert the behaviour the **request** says is correct, and make each assertion trace to a specific phrase in the request.
|
|
803
|
+
- **CRITICAL ANTI-PATTERN:** do NOT write a test that merely re-states what the CODE now does — that only confirms the code's own assumptions, which is exactly how a wrong fix passes its own tests. The test must be written as if you had NOT seen the fix.
|
|
804
|
+
- **FALSIFY FIRST:** your reproduction MUST FAIL against the ORIGINAL (pre-fix) code — \`git stash\` the fix, run it, confirm it fails, then \`git stash pop\` and confirm it now passes. If it passes on the original code, it does not reproduce the bug: rewrite it. Paste both runs.
|
|
805
|
+
**Step 3b:** ACTUALLY RUN it and paste runtime evidence (stdout + exit code). Add at least one adversarial probe per feature (empty input, wrong type, missing arg). Running \`npm test\`/the existing suite alone is NOT sufficient — it does not cover the new scenario.
|
|
806
|
+
**Step 4:** Fix any real errors you find (root cause, not a workaround). If the fix is a no-op in the request's own scenario, that is a FAIL, not a pass.
|
|
739
807
|
**Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
|
|
740
808
|
**Step 6 (MANDATORY):** Call the \`phase_result\` tool with your \`verdict\` (PASS/FAIL/BLOCKED) and a concise \`handoff\`. This is how the orchestrator reads your outcome exactly; the report file above is the human-readable copy.
|
|
741
809
|
|
|
@@ -805,6 +873,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
|
|
|
805
873
|
- **Angle 1 - Correctness:** read the diff line by line. What behaviour did it change or remove? Off-by-one, null/undefined, wrong condition, broken invariant.
|
|
806
874
|
- **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
|
|
807
875
|
- **Angle 3 - Security & language pitfalls:** input validation, injection, secrets, auth, error handling; plus language traps (async not awaited, shadowed scope, mutation of shared state).
|
|
876
|
+
- **Angle 4 - Over-engineering & dead guards (the failure mode that ships confident-wrong fixes — check this FIRST):** for EVERY new condition, guard, or early-return the diff added, name the concrete input where it takes the SKIP / else branch, and check the behaviour is still correct THERE — *especially against the exact scenario in the Project Request*. A guard that makes the fix a no-op in the request's own scenario (e.g. an \`if buffered\` guard on a bug that is about the streaming path) is a **CONFIRMED** blocking bug. Then compare the diff's size to the problem: if the fix is more elaborate than the request requires, or does not match the surrounding code's existing pattern, that itself is a finding — the minimal change is usually the correct one.
|
|
808
877
|
**Step 3 - VERIFY (3-state, cite the line):** for EACH candidate, quote the exact line and classify:
|
|
809
878
|
- **CONFIRMED** - you can name the input/state that triggers it and the wrong output. Quote the line.
|
|
810
879
|
- **PLAUSIBLE** - the mechanism is real but the trigger is uncertain. Say what would confirm it.
|
|
@@ -933,6 +1002,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
933
1002
|
"ontology_add",
|
|
934
1003
|
"ontology_query",
|
|
935
1004
|
"phase_result",
|
|
1005
|
+
"sandbox_run",
|
|
936
1006
|
];
|
|
937
1007
|
const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
|
|
938
1008
|
_activeAgentTools = agentTools;
|
|
@@ -1096,6 +1166,242 @@ Tag the note with relevant keywords for vector search.
|
|
|
1096
1166
|
});
|
|
1097
1167
|
}
|
|
1098
1168
|
|
|
1169
|
+
// ─── Generic linear driver (/debug, /build) ─────────────────────
|
|
1170
|
+
// A deliberately simpler engine than /plan's: phases run in order, a BLOCKED
|
|
1171
|
+
// verdict halts, and there is no review-fix cycle or "/5" bookkeeping. It
|
|
1172
|
+
// reuses the tested primitives (switchModelForPhase, activateAgent,
|
|
1173
|
+
// resolvePhaseOutcome) but never the /plan-specific agent_end path.
|
|
1174
|
+
|
|
1175
|
+
function routeFor(routeKey: string): { preferred: string; fallback: string } {
|
|
1176
|
+
const routingPath = join(homedir(), ".phi", "agent", "routing.json");
|
|
1177
|
+
try {
|
|
1178
|
+
const routing = JSON.parse(readFileSync(routingPath, "utf-8"));
|
|
1179
|
+
const route = routing.routes?.[routeKey];
|
|
1180
|
+
return {
|
|
1181
|
+
preferred: route?.preferredModel || routing.default?.model || "default",
|
|
1182
|
+
fallback: route?.fallback || routing.default?.model || "default",
|
|
1183
|
+
};
|
|
1184
|
+
} catch {
|
|
1185
|
+
return { preferred: "default", fallback: "default" };
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function genericPhase(
|
|
1190
|
+
key: string,
|
|
1191
|
+
label: string,
|
|
1192
|
+
routeKey: string,
|
|
1193
|
+
agentKey: string,
|
|
1194
|
+
instruction: string,
|
|
1195
|
+
): OrchestratorPhase {
|
|
1196
|
+
const route = routeFor(routeKey);
|
|
1197
|
+
return {
|
|
1198
|
+
key,
|
|
1199
|
+
label,
|
|
1200
|
+
model: route.preferred,
|
|
1201
|
+
fallback: route.fallback,
|
|
1202
|
+
agent: loadAgentDef(agentKey),
|
|
1203
|
+
instruction: instruction + COMMON_PHASE_RULES,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase routes to a model
|
|
1208
|
+
// that does NOT share the coder's blind spot (verify on the test family).
|
|
1209
|
+
function buildDebugPhases(state: FailingState): OrchestratorPhase[] {
|
|
1210
|
+
const ins = debugPhaseInstructions(state);
|
|
1211
|
+
return [
|
|
1212
|
+
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1213
|
+
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1214
|
+
genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
|
|
1215
|
+
genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// /build: reuse /plan's EXPLORE→PLAN→CODE, then an execution-grounded
|
|
1220
|
+
// BUILD-VERIFY that runs the recipe, checks acceptance, red-teams the
|
|
1221
|
+
// boundary, and routes real failures to the /debug protocol.
|
|
1222
|
+
function buildBuildPhases(spec: string, ts: string): OrchestratorPhase[] {
|
|
1223
|
+
const planLike = buildPhases(spec, ts).slice(0, 3); // explore, plan, code
|
|
1224
|
+
const verify = genericPhase(
|
|
1225
|
+
"verify",
|
|
1226
|
+
"🧪 Phase 4 — BUILD-VERIFY",
|
|
1227
|
+
"review",
|
|
1228
|
+
"review",
|
|
1229
|
+
buildVerifyInstruction(spec),
|
|
1230
|
+
);
|
|
1231
|
+
return [...planLike, verify];
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function finishGenericOrchestration(ctx: any, headline: string) {
|
|
1235
|
+
const elapsed = phaseStartTime ? Math.round((Date.now() - phaseStartTime) / 1000) : 0;
|
|
1236
|
+
const minutes = Math.floor(elapsed / 60);
|
|
1237
|
+
const seconds = elapsed % 60;
|
|
1238
|
+
const mode = orchestrationMode;
|
|
1239
|
+
setOrchestrationActive(false);
|
|
1240
|
+
phasePending = false;
|
|
1241
|
+
orchestrationMode = "plan";
|
|
1242
|
+
deactivateAgent();
|
|
1243
|
+
if (phaseTimeoutId) {
|
|
1244
|
+
clearTimeout(phaseTimeoutId);
|
|
1245
|
+
phaseTimeoutId = null;
|
|
1246
|
+
}
|
|
1247
|
+
if (originalModel) {
|
|
1248
|
+
const toRestore = originalModel;
|
|
1249
|
+
originalModel = null;
|
|
1250
|
+
Promise.resolve(pi.setModel(toRestore)).catch(() => {
|
|
1251
|
+
/* best effort */
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
ctx.ui.notify(
|
|
1255
|
+
`\n📊 **/${mode} summary** — ${completedPhases} phase(s) in ${minutes}m ${seconds}s${
|
|
1256
|
+
skippedPhases ? ` (${skippedPhases} skipped on timeout)` : ""
|
|
1257
|
+
}\n${headline}`,
|
|
1258
|
+
"info",
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function sendNextGenericPhase(ctx: any) {
|
|
1263
|
+
if (phaseQueue.length === 0) {
|
|
1264
|
+
finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const phase = phaseQueue.shift()!;
|
|
1268
|
+
phasePending = true;
|
|
1269
|
+
currentPhase = phase;
|
|
1270
|
+
currentPhaseResult = null;
|
|
1271
|
+
currentPhaseResultKey = null;
|
|
1272
|
+
|
|
1273
|
+
switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
|
|
1274
|
+
activateAgent(phase, ctx);
|
|
1275
|
+
const agentName = phase.agent?.name || phase.key;
|
|
1276
|
+
ctx.ui.notify(`\n${phase.label} → \`${modelId}\` (agent: ${agentName})`, "info");
|
|
1277
|
+
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1278
|
+
setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
|
|
1279
|
+
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1280
|
+
phaseTimeoutId = setTimeout(() => {
|
|
1281
|
+
if (orchestrationActive && phasePending) {
|
|
1282
|
+
phasePending = false;
|
|
1283
|
+
internalAbort = true;
|
|
1284
|
+
try {
|
|
1285
|
+
ctx.abort();
|
|
1286
|
+
} catch {
|
|
1287
|
+
/* best effort */
|
|
1288
|
+
}
|
|
1289
|
+
if (!phase.retried) {
|
|
1290
|
+
phase.retried = true;
|
|
1291
|
+
phase.useFallback = true;
|
|
1292
|
+
phaseQueue.unshift(phase);
|
|
1293
|
+
ctx.ui.notify(
|
|
1294
|
+
`\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`,
|
|
1295
|
+
"warning",
|
|
1296
|
+
);
|
|
1297
|
+
} else {
|
|
1298
|
+
skippedPhases++;
|
|
1299
|
+
ctx.ui.notify(`\n⏰ **Phase timed out again** — skipping to next phase.`, "warning");
|
|
1300
|
+
}
|
|
1301
|
+
sendNextGenericPhase(ctx);
|
|
1302
|
+
}
|
|
1303
|
+
}, MAX_PHASE_DURATION_MS);
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function handleGenericPhaseEnd(event: any, ctx: any) {
|
|
1308
|
+
const messages = event.messages || [];
|
|
1309
|
+
const analysis = analyzePhaseMessages(messages);
|
|
1310
|
+
|
|
1311
|
+
if (analysis.userAborted) {
|
|
1312
|
+
ctx.ui.notify(`\n🛑 **/${orchestrationMode} cancelled** by user. Remaining phases skipped.`, "warning");
|
|
1313
|
+
finishGenericOrchestration(ctx, "🛑 Cancelled.");
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (!phasePending) {
|
|
1318
|
+
if (phaseQueue.length === 0) finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (phaseTimeoutId) {
|
|
1322
|
+
clearTimeout(phaseTimeoutId);
|
|
1323
|
+
phaseTimeoutId = null;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// A BLOCKED verdict (REPRODUCE cannot reproduce / VERIFY could not confirm)
|
|
1327
|
+
// halts the pipeline honestly — no fabricated success.
|
|
1328
|
+
const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
|
|
1329
|
+
const outcome = resolvePhaseOutcome(structuredForThisPhase, null);
|
|
1330
|
+
if (outcome.verdict === "BLOCKED" && currentPhase) {
|
|
1331
|
+
ctx.ui.notify(
|
|
1332
|
+
`\n⏸️ **${currentPhase.label} reported BLOCKED.** ${outcome.handoff || "No reproducible/verifiable result."}`,
|
|
1333
|
+
"warning",
|
|
1334
|
+
);
|
|
1335
|
+
finishGenericOrchestration(ctx, `⏸️ **/${orchestrationMode} stopped: BLOCKED at ${currentPhase.label}.**`);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Propagate a concise handoff to the next phase, like /plan does.
|
|
1340
|
+
const nextBrief = buildNextBrief(
|
|
1341
|
+
analysis,
|
|
1342
|
+
outcome.handoff,
|
|
1343
|
+
currentPhase?.label || "previous phase",
|
|
1344
|
+
MAX_TOOL_CALLS_PER_PHASE,
|
|
1345
|
+
);
|
|
1346
|
+
if (nextBrief && phaseQueue.length > 0) {
|
|
1347
|
+
phaseQueue[0].instruction += `\n\n**Previous phase summary:**\n${nextBrief}`;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
completedPhases++;
|
|
1351
|
+
phasePending = false;
|
|
1352
|
+
sendNextGenericPhase(ctx);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function startGenericOrchestration(
|
|
1356
|
+
mode: "debug" | "build",
|
|
1357
|
+
phases: OrchestratorPhase[],
|
|
1358
|
+
ctx: any,
|
|
1359
|
+
headline: string,
|
|
1360
|
+
) {
|
|
1361
|
+
phaseQueue = phases.slice(1);
|
|
1362
|
+
orchestrationMode = mode;
|
|
1363
|
+
setOrchestrationActive(true);
|
|
1364
|
+
phasePending = true;
|
|
1365
|
+
originalModel = ctx.model || null;
|
|
1366
|
+
savedTools = pi.getActiveTools();
|
|
1367
|
+
completedPhases = 0;
|
|
1368
|
+
skippedPhases = 0;
|
|
1369
|
+
internalAbort = false;
|
|
1370
|
+
currentPhaseResult = null;
|
|
1371
|
+
currentPhaseResultKey = null;
|
|
1372
|
+
phaseStartTime = Date.now();
|
|
1373
|
+
const first = phases[0];
|
|
1374
|
+
currentPhase = first;
|
|
1375
|
+
|
|
1376
|
+
ctx.ui.notify(headline, "info");
|
|
1377
|
+
for (const p of phases) {
|
|
1378
|
+
const agentName = p.agent?.name || p.key;
|
|
1379
|
+
ctx.ui.notify(` ${p.label} → \`${p.model}\` (agent: ${agentName})`, "info");
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
switchModelForPhase(first, ctx).then(({ modelId, warning }) => {
|
|
1383
|
+
activateAgent(first, ctx);
|
|
1384
|
+
ctx.ui.notify(`\n${first.label} → \`${modelId}\``, "info");
|
|
1385
|
+
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1386
|
+
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1387
|
+
phaseTimeoutId = setTimeout(() => {
|
|
1388
|
+
if (orchestrationActive && phasePending) {
|
|
1389
|
+
phasePending = false;
|
|
1390
|
+
internalAbort = true;
|
|
1391
|
+
try {
|
|
1392
|
+
ctx.abort();
|
|
1393
|
+
} catch {
|
|
1394
|
+
/* best effort */
|
|
1395
|
+
}
|
|
1396
|
+
skippedPhases++;
|
|
1397
|
+
ctx.ui.notify(`\n⏰ **First phase timed out** — skipping.`, "warning");
|
|
1398
|
+
sendNextGenericPhase(ctx);
|
|
1399
|
+
}
|
|
1400
|
+
}, MAX_PHASE_DURATION_MS);
|
|
1401
|
+
setTimeout(() => pi.sendUserMessage(first.instruction, { deliverAs: "followUp" }), 200);
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1099
1405
|
// ─── System Prompt Injection — Agent personas ────────────────────
|
|
1100
1406
|
|
|
1101
1407
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
@@ -1125,6 +1431,13 @@ Tag the note with relevant keywords for vector search.
|
|
|
1125
1431
|
return;
|
|
1126
1432
|
}
|
|
1127
1433
|
|
|
1434
|
+
// /debug and /build run on the separate linear driver — never through the
|
|
1435
|
+
// /plan-specific review-fix + "/5" transition logic below.
|
|
1436
|
+
if (orchestrationMode !== "plan") {
|
|
1437
|
+
await handleGenericPhaseEnd(event, ctx);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1128
1441
|
const messages = event.messages || [];
|
|
1129
1442
|
// Analyze what happened, read the phase's report, and let the pure state
|
|
1130
1443
|
// machine (providers/phase-machine.ts, unit tested) decide the transition.
|
|
@@ -1516,4 +1829,170 @@ Tag the note with relevant keywords for vector search.
|
|
|
1516
1829
|
ctx.ui.notify(output, "info");
|
|
1517
1830
|
},
|
|
1518
1831
|
});
|
|
1832
|
+
|
|
1833
|
+
// ─── /debug Command — turn a real failure green (execution-grounded) ──
|
|
1834
|
+
|
|
1835
|
+
pi.registerCommand("debug", {
|
|
1836
|
+
description: "Fix a REAL failing state — REPRODUCE → LOCALIZE → FIX → VERIFY, verdict backed by a real run",
|
|
1837
|
+
handler: async (args, ctx) => {
|
|
1838
|
+
if (orchestrationActive) {
|
|
1839
|
+
ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const raw = args.trim();
|
|
1843
|
+
if (!raw) {
|
|
1844
|
+
ctx.ui.notify(
|
|
1845
|
+
`**Usage:** \`/debug <failing test | repro command | description>\`
|
|
1846
|
+
|
|
1847
|
+
/debug never guesses — it needs something reproducible.
|
|
1848
|
+
**Examples:**
|
|
1849
|
+
/debug pytest tests/test_auth.py::test_login_returns_jwt
|
|
1850
|
+
/debug node repro.js (segfaults on empty input, should return [])
|
|
1851
|
+
/debug the /login route 500s when the body is missing
|
|
1852
|
+
|
|
1853
|
+
It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a real before/after run — otherwise BLOCKED.`,
|
|
1854
|
+
"info",
|
|
1855
|
+
);
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const state: FailingState = parseFailingState(raw, { cwd: ctx.cwd || process.cwd() });
|
|
1860
|
+
// Honest gate at the boundary: /debug needs a reproducible failure. A bare
|
|
1861
|
+
// prose description with no test/command still runs (REPRODUCE will try to
|
|
1862
|
+
// derive one), but we tell the user the oracle depends on a real run.
|
|
1863
|
+
if (!state.failingTest && !state.reproCommand) {
|
|
1864
|
+
ctx.ui.notify(
|
|
1865
|
+
`⚠️ No failing test or repro command detected — /debug works best with one (e.g. \`pytest …\` or \`node repro.js\`). Proceeding: REPRODUCE will try to construct a reproduction from your description, and will emit **BLOCKED** if it cannot run one (no fabricated PASS).`,
|
|
1866
|
+
"warning",
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
await ensurePlansDir();
|
|
1871
|
+
const phases = buildDebugPhases(state);
|
|
1872
|
+
startGenericOrchestration(
|
|
1873
|
+
"debug",
|
|
1874
|
+
phases,
|
|
1875
|
+
ctx,
|
|
1876
|
+
`🐛 **/debug started** — 4 execution-grounded phases (verdict requires a real run)\n`,
|
|
1877
|
+
);
|
|
1878
|
+
},
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
// ─── /build Command — build until it runs (plan + execution loop) ────
|
|
1882
|
+
|
|
1883
|
+
pi.registerCommand("build", {
|
|
1884
|
+
description:
|
|
1885
|
+
"Build from a spec AND prove it runs — EXPLORE → PLAN → CODE → BUILD-VERIFY (run + red-team + debug)",
|
|
1886
|
+
handler: async (args, ctx) => {
|
|
1887
|
+
if (orchestrationActive) {
|
|
1888
|
+
ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const spec = args.trim();
|
|
1892
|
+
if (!spec) {
|
|
1893
|
+
ctx.ui.notify(
|
|
1894
|
+
`**Usage:** \`/build <what to build>\`
|
|
1895
|
+
|
|
1896
|
+
/build = /plan then an execution-grounded verify loop (run the recipe, check acceptance, executable red-team, fix real failures via /debug).
|
|
1897
|
+
**Examples:**
|
|
1898
|
+
/build a REST API for user auth with JWT and refresh tokens
|
|
1899
|
+
/build a CLI that converts CSV to JSON with a --pretty flag
|
|
1900
|
+
|
|
1901
|
+
It reports SUCCESS only when a real run meets the acceptance criteria — otherwise an honest PARTIAL listing what still fails. For a pure decomposition use \`/plan\`; to fix a known failure use \`/debug\`.`,
|
|
1902
|
+
"info",
|
|
1903
|
+
);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Cheap triage note (transparency): confirm /build is the right depth.
|
|
1908
|
+
const decision = triage({ text: spec });
|
|
1909
|
+
if (decision.route === "single-shot") {
|
|
1910
|
+
ctx.ui.notify(
|
|
1911
|
+
`ℹ️ This looks small and self-contained (${decision.reason}). /build will still run, but a single request may be cheaper.`,
|
|
1912
|
+
"info",
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
await ensurePlansDir();
|
|
1917
|
+
const ts = timestamp();
|
|
1918
|
+
await writeFile(
|
|
1919
|
+
join(plansDir, `spec-${ts}.md`),
|
|
1920
|
+
`# ${spec}\n\n**Created:** ${new Date().toLocaleString()}\n`,
|
|
1921
|
+
"utf-8",
|
|
1922
|
+
);
|
|
1923
|
+
currentDescription = spec;
|
|
1924
|
+
const phases = buildBuildPhases(spec, ts);
|
|
1925
|
+
startGenericOrchestration(
|
|
1926
|
+
"build",
|
|
1927
|
+
phases,
|
|
1928
|
+
ctx,
|
|
1929
|
+
`🏗️ **/build started** — plan + execution loop (SUCCESS requires a real run, else honest PARTIAL)\n`,
|
|
1930
|
+
);
|
|
1931
|
+
},
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// ─── /sandbox Command — inspect / prepare the guaranteed environment ──
|
|
1935
|
+
|
|
1936
|
+
pi.registerCommand("sandbox", {
|
|
1937
|
+
description:
|
|
1938
|
+
"Inspect or prepare the project's execution sandbox (Docker when available) — status | prepare | run <cmd>",
|
|
1939
|
+
handler: async (args, ctx) => {
|
|
1940
|
+
const cwd = ctx.cwd || process.cwd();
|
|
1941
|
+
const raw = args.trim();
|
|
1942
|
+
const [sub, ...rest] = raw.split(/\s+/);
|
|
1943
|
+
const sandbox = getSessionSandbox(cwd);
|
|
1944
|
+
|
|
1945
|
+
if (!sub || sub === "status") {
|
|
1946
|
+
const r = sandbox.recipe;
|
|
1947
|
+
ctx.ui.notify(
|
|
1948
|
+
`🧪 **Sandbox status**\n` +
|
|
1949
|
+
` Backend: \`${sandbox.backend}\` — ${sandbox.reason}\n` +
|
|
1950
|
+
` Environment: ${sandbox.describe()}\n` +
|
|
1951
|
+
` Image: \`${r.image}\` (source: ${r.source})\n` +
|
|
1952
|
+
` Setup: ${r.setup ? `\`${r.setup}\`` : "—"}\n` +
|
|
1953
|
+
` Test: ${r.test ? `\`${r.test}\`` : "—"}\n` +
|
|
1954
|
+
(sandbox.backend === "unavailable"
|
|
1955
|
+
? `\n⚠️ No guaranteed environment. /debug and /build will emit BLOCKED rather than fabricate a pass. Install/start Docker, or add \`.phi/sandbox.json\`.`
|
|
1956
|
+
: sandbox.backend === "local"
|
|
1957
|
+
? `\n⚠️ Running on the host (not dependency-guaranteed). Start Docker for a guaranteed environment.`
|
|
1958
|
+
: `\n✅ Guaranteed environment ready. Run \`/sandbox prepare\` to pull the image and install deps.`),
|
|
1959
|
+
"info",
|
|
1960
|
+
);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (sub === "prepare") {
|
|
1965
|
+
ctx.ui.notify(`🧪 Preparing sandbox (${sandbox.describe()})… this can take a while on first run.`, "info");
|
|
1966
|
+
const p = sandbox.prepare();
|
|
1967
|
+
ctx.ui.notify(
|
|
1968
|
+
`${p.ok ? "✅" : "❌"} **Sandbox prepare** — ${p.detail}` +
|
|
1969
|
+
(p.result && !p.ok ? `\n\n\`\`\`\n${tail(p.result, 30)}\n\`\`\`` : ""),
|
|
1970
|
+
p.ok ? "info" : "warning",
|
|
1971
|
+
);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (sub === "run") {
|
|
1976
|
+
const command = rest.join(" ").trim();
|
|
1977
|
+
if (!command) {
|
|
1978
|
+
ctx.ui.notify("**Usage:** `/sandbox run <command>`", "warning");
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
if (!sandbox.available()) {
|
|
1982
|
+
ctx.ui.notify(`❌ Sandbox unavailable — ${sandbox.reason}. Cannot run.`, "warning");
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
ctx.ui.notify(`🧪 \`${command}\` in ${sandbox.describe()}…`, "info");
|
|
1986
|
+
const result = sandbox.exec(command);
|
|
1987
|
+
const verdict = result.timedOut ? "TIMEOUT" : passed(result) ? "PASS" : "FAIL";
|
|
1988
|
+
ctx.ui.notify(
|
|
1989
|
+
`${passed(result) ? "✅" : "❌"} exit ${result.exitCode ?? "?"} ${verdict}\n\n\`\`\`\n${tail(result, 40) || "(no output)"}\n\`\`\``,
|
|
1990
|
+
passed(result) ? "info" : "warning",
|
|
1991
|
+
);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
ctx.ui.notify("**Usage:** `/sandbox [status | prepare | run <command>]`", "info");
|
|
1996
|
+
},
|
|
1997
|
+
});
|
|
1519
1998
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance criteria and run recipe — the executable contract for /plan and
|
|
3
|
+
* /build. Criteria are derived from the SPEC (not the code), and each carries an
|
|
4
|
+
* optional `check` command so "is it satisfied?" is answered by running
|
|
5
|
+
* something, not by a model's opinion (see docs/design/plan-debug-build.md).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type CommandResult, passed, type RunOptions, runCommand, summarize } from "./execution.js";
|
|
9
|
+
|
|
10
|
+
/** How to build/run/test the project. Emitted by /plan, consumed by /build. */
|
|
11
|
+
export interface RunRecipe {
|
|
12
|
+
build?: string;
|
|
13
|
+
run?: string;
|
|
14
|
+
test?: string;
|
|
15
|
+
/** A substring that, once seen in run output, means the app is ready. */
|
|
16
|
+
readySignal?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AcceptanceCriterion {
|
|
20
|
+
/** Human statement traced to the spec, e.g. "POST /login returns 200 + a JWT". */
|
|
21
|
+
description: string;
|
|
22
|
+
/**
|
|
23
|
+
* Optional command that exits 0 iff the criterion holds. When absent the
|
|
24
|
+
* criterion is "manual" — it can only be judged by a human/agent, never
|
|
25
|
+
* auto-marked satisfied (that is the anti-circularity rule).
|
|
26
|
+
*/
|
|
27
|
+
check?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CriterionResult {
|
|
31
|
+
criterion: AcceptanceCriterion;
|
|
32
|
+
/** true = ran and passed; false = ran and failed; null = manual (no check). */
|
|
33
|
+
satisfied: boolean | null;
|
|
34
|
+
result?: CommandResult;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AcceptanceReport {
|
|
38
|
+
results: CriterionResult[];
|
|
39
|
+
/** Criteria with a check that failed — the concrete work for /debug. */
|
|
40
|
+
failed: CriterionResult[];
|
|
41
|
+
/** Criteria with no check — cannot be auto-verified, surfaced honestly. */
|
|
42
|
+
manual: CriterionResult[];
|
|
43
|
+
/** All checkable criteria passed (manual ones are NOT counted as passing). */
|
|
44
|
+
allCheckablePassed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run every criterion's check command and classify. A criterion with no check
|
|
49
|
+
* is reported as `manual` (satisfied: null) — never silently counted as passed.
|
|
50
|
+
*/
|
|
51
|
+
export function checkAcceptance(criteria: AcceptanceCriterion[], options: RunOptions = {}): AcceptanceReport {
|
|
52
|
+
const results: CriterionResult[] = criteria.map((criterion) => {
|
|
53
|
+
if (!criterion.check || !criterion.check.trim()) {
|
|
54
|
+
return { criterion, satisfied: null };
|
|
55
|
+
}
|
|
56
|
+
const result = runCommand(criterion.check, options);
|
|
57
|
+
return { criterion, satisfied: passed(result), result };
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const failed = results.filter((r) => r.satisfied === false);
|
|
61
|
+
const manual = results.filter((r) => r.satisfied === null);
|
|
62
|
+
const checkable = results.filter((r) => r.satisfied !== null);
|
|
63
|
+
return {
|
|
64
|
+
results,
|
|
65
|
+
failed,
|
|
66
|
+
manual,
|
|
67
|
+
allCheckablePassed: checkable.length > 0 && failed.length === 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Markdown summary of an acceptance run for a report / handoff. */
|
|
72
|
+
export function formatAcceptance(report: AcceptanceReport): string {
|
|
73
|
+
const line = (r: CriterionResult) => {
|
|
74
|
+
const mark = r.satisfied === true ? "✅" : r.satisfied === false ? "❌" : "❔";
|
|
75
|
+
const detail = r.result ? ` — ${summarize(r.result)}` : r.satisfied === null ? " — (no check, manual)" : "";
|
|
76
|
+
return `- ${mark} ${r.criterion.description}${detail}`;
|
|
77
|
+
};
|
|
78
|
+
return report.results.map(line).join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate a parsed RunRecipe/criteria object (e.g. from a phase_result), so a
|
|
83
|
+
* malformed emission is caught at the boundary.
|
|
84
|
+
*/
|
|
85
|
+
export function validateCriteria(raw: unknown): AcceptanceCriterion[] {
|
|
86
|
+
if (!Array.isArray(raw)) return [];
|
|
87
|
+
const out: AcceptanceCriterion[] = [];
|
|
88
|
+
for (const item of raw) {
|
|
89
|
+
if (typeof item === "string" && item.trim()) {
|
|
90
|
+
out.push({ description: item.trim() });
|
|
91
|
+
} else if (item && typeof item === "object") {
|
|
92
|
+
const o = item as Record<string, unknown>;
|
|
93
|
+
if (typeof o.description === "string" && o.description.trim()) {
|
|
94
|
+
out.push({
|
|
95
|
+
description: o.description.trim(),
|
|
96
|
+
check: typeof o.check === "string" && o.check.trim() ? o.check.trim() : undefined,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|