@phi-code-admin/phi-code 0.87.0 → 0.89.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 +111 -0
- package/docs/adr/0001-phase-contract.md +57 -0
- package/docs/adr/0002-independent-review.md +47 -0
- package/docs/design/plan-debug-build.md +266 -0
- package/extensions/phi/orchestrator.ts +482 -26
- 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 +129 -0
- package/extensions/phi/providers/debug-contract.ts +131 -0
- package/extensions/phi/providers/execution.ts +75 -0
- package/extensions/phi/providers/orchestrator-helpers.ts +14 -2
- package/extensions/phi/providers/phase-machine.ts +70 -7
- package/extensions/phi/providers/redteam.ts +115 -0
- package/extensions/phi/providers/triage.ts +96 -0
- package/package.json +1 -1
|
@@ -23,9 +23,17 @@ 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";
|
|
26
28
|
import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
+
import {
|
|
30
|
+
analyzePhaseMessages,
|
|
31
|
+
buildNextBrief,
|
|
32
|
+
decidePhaseTransition,
|
|
33
|
+
resolvePhaseOutcome,
|
|
34
|
+
type StructuredPhaseResult,
|
|
35
|
+
} from "./providers/phase-machine.js";
|
|
36
|
+
import { triage } from "./providers/triage.js";
|
|
29
37
|
|
|
30
38
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
31
39
|
|
|
@@ -284,6 +292,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
284
292
|
|
|
285
293
|
let phaseQueue: OrchestratorPhase[] = [];
|
|
286
294
|
let orchestrationActive = false;
|
|
295
|
+
// Which pipeline is running. "plan" keeps the existing 5-phase agent_end path
|
|
296
|
+
// untouched; "debug"/"build" are driven by the separate linear generic driver
|
|
297
|
+
// so /plan's fragile review-fix + "/5" logic is never engaged for them.
|
|
298
|
+
let orchestrationMode: "plan" | "debug" | "build" = "plan";
|
|
287
299
|
let activeAgentPrompt: string | null = null;
|
|
288
300
|
let _activeAgentTools: string[] | null = null;
|
|
289
301
|
let savedTools: string[] | null = null;
|
|
@@ -309,11 +321,85 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
309
321
|
// Set true right before an INTERNAL ctx.abort() (phase timeout) so the
|
|
310
322
|
// resulting agent_end is not mistaken for a user Ctrl+C cancellation.
|
|
311
323
|
let internalAbort = false;
|
|
324
|
+
// Structured result emitted by the current phase via the `phase_result` tool
|
|
325
|
+
// (robust primary path). Reset at each phase start; read in agent_end and
|
|
326
|
+
// merged with the parsed markdown report (fallback) by resolvePhaseOutcome.
|
|
327
|
+
// currentPhaseResultKey stamps which phase produced it, so a stale result
|
|
328
|
+
// from a previous run/phase (or a tool call that lands after the phase
|
|
329
|
+
// transition) is never mistaken for the current phase's output.
|
|
330
|
+
let currentPhaseResult: StructuredPhaseResult | null = null;
|
|
331
|
+
let currentPhaseResultKey: string | null = null;
|
|
332
|
+
|
|
333
|
+
// ─── phase_result Tool — structured phase outcome (robust primary path) ───
|
|
334
|
+
// A phase agent CALLS this to emit its verdict/handoff/blocking as data
|
|
335
|
+
// instead of relying on the orchestrator regex-scraping its markdown report.
|
|
336
|
+
// It is only meaningful during /plan; outside orchestration it is a no-op so
|
|
337
|
+
// a stray call never errors. The markdown report is still written (it is the
|
|
338
|
+
// human-readable artifact); this just makes the machine-read path exact.
|
|
339
|
+
pi.registerTool({
|
|
340
|
+
name: "phase_result",
|
|
341
|
+
label: "Phase Result",
|
|
342
|
+
description:
|
|
343
|
+
"Report THIS /plan phase's structured outcome so the orchestrator reads it exactly instead of parsing your markdown. Call it once, at the end, IN ADDITION to writing your report file. TEST and REVIEW phases MUST call it with a verdict.",
|
|
344
|
+
promptGuidelines: [
|
|
345
|
+
"During a /plan phase, after writing your report file, call phase_result once with your verdict (TEST/REVIEW) and a concise handoff for the next phase.",
|
|
346
|
+
"verdict: PASS only if you OBSERVED success; FAIL if any blocking issue; BLOCKED if you could not run; SKIP if not applicable.",
|
|
347
|
+
"blocking: the must-fix findings (REVIEW), one per line. handoff: what is done, open risks, and the single most important next action.",
|
|
348
|
+
],
|
|
349
|
+
parameters: Type.Object({
|
|
350
|
+
verdict: Type.Optional(
|
|
351
|
+
Type.Union([Type.Literal("PASS"), Type.Literal("FAIL"), Type.Literal("BLOCKED"), Type.Literal("SKIP")], {
|
|
352
|
+
description: "Phase verdict (required for TEST and REVIEW phases)",
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
blocking: Type.Optional(
|
|
356
|
+
Type.String({ description: "Must-fix findings, one per line (REVIEW). Empty when none." }),
|
|
357
|
+
),
|
|
358
|
+
handoff: Type.Optional(
|
|
359
|
+
Type.String({ description: "Concise handoff for the next phase: state, open risks, next action." }),
|
|
360
|
+
),
|
|
361
|
+
}),
|
|
362
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
363
|
+
if (!orchestrationActive) {
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: "text", text: "phase_result is only used during /plan orchestration; ignored here." }],
|
|
366
|
+
details: undefined,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const raw = params as { verdict?: StructuredPhaseResult["verdict"]; blocking?: string; handoff?: string };
|
|
370
|
+
// Merge only the fields this call actually set, so a phase that emits
|
|
371
|
+
// its result across several calls (verdict first, handoff later — a
|
|
372
|
+
// common LLM pattern) does not erase earlier fields. Start fresh when
|
|
373
|
+
// the previous result belonged to a different phase.
|
|
374
|
+
const defined: StructuredPhaseResult = {};
|
|
375
|
+
if (raw.verdict !== undefined) defined.verdict = raw.verdict;
|
|
376
|
+
if (typeof raw.blocking === "string") defined.blocking = raw.blocking;
|
|
377
|
+
if (typeof raw.handoff === "string") defined.handoff = raw.handoff;
|
|
378
|
+
const samePhase = currentPhaseResultKey === (currentPhase?.key ?? null);
|
|
379
|
+
currentPhaseResult = { ...(samePhase && currentPhaseResult ? currentPhaseResult : {}), ...defined };
|
|
380
|
+
currentPhaseResultKey = currentPhase?.key ?? null;
|
|
381
|
+
const parts = [
|
|
382
|
+
raw.verdict ? `verdict ${raw.verdict}` : null,
|
|
383
|
+
raw.blocking?.trim() ? "blocking findings" : null,
|
|
384
|
+
raw.handoff?.trim() ? "handoff" : null,
|
|
385
|
+
].filter(Boolean);
|
|
386
|
+
return {
|
|
387
|
+
content: [{ type: "text", text: `Phase result recorded${parts.length ? ` (${parts.join(", ")})` : ""}.` }],
|
|
388
|
+
details: undefined,
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// A phase's report file is not always named <key>-<ts>.md: PLAN writes its
|
|
394
|
+
// handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
|
|
395
|
+
// stem so the text fallback for HANDOFF/BLOCKING actually finds them.
|
|
396
|
+
const REPORT_FILE_STEM: Record<string, string> = { plan: "todo", code: "progress" };
|
|
312
397
|
|
|
313
|
-
/** Read a phase's report file (.phi/plans/<
|
|
398
|
+
/** Read a phase's report file (.phi/plans/<stem>-<ts>.md). Null-safe. */
|
|
314
399
|
function readPhaseReport(key: string, ts: string): string | null {
|
|
315
400
|
try {
|
|
316
|
-
const
|
|
401
|
+
const stem = REPORT_FILE_STEM[key] ?? key;
|
|
402
|
+
const f = join(process.cwd(), ".phi", "plans", `${stem}-${ts}.md`);
|
|
317
403
|
if (existsSync(f)) return readFileSync(f, "utf-8");
|
|
318
404
|
} catch {
|
|
319
405
|
/* ignore */
|
|
@@ -626,6 +712,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
|
|
|
626
712
|
**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.
|
|
627
713
|
|
|
628
714
|
**CRITICAL RULES:**
|
|
715
|
+
- **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.
|
|
629
716
|
- Write ONE file per tool call — NEVER combine multiple files in a single response
|
|
630
717
|
- Keep each file under 500 lines. If longer, split into modules
|
|
631
718
|
- After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` +
|
|
@@ -650,14 +737,13 @@ After implementation, use \`memory_write\` to save a summary of what was built,
|
|
|
650
737
|
|
|
651
738
|
**Step 1:** Read \`.phi/plans/todo-*.md\` to know what was planned
|
|
652
739
|
**Step 2:** Read \`.phi/plans/progress-*.md\` to see what was done
|
|
653
|
-
**Step 3:**
|
|
654
|
-
- **
|
|
655
|
-
- **
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
- Running \`npm test\` alone is NOT sufficient proof for a feature.
|
|
659
|
-
**Step 4:** Fix any real errors you find (root cause, not a workaround).
|
|
740
|
+
**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.
|
|
741
|
+
- **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.
|
|
742
|
+
- **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.
|
|
743
|
+
**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.
|
|
744
|
+
**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.
|
|
660
745
|
**Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
|
|
746
|
+
**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.
|
|
661
747
|
|
|
662
748
|
**Test report format (the FIRST line MUST be the verdict):**
|
|
663
749
|
\`\`\`markdown
|
|
@@ -725,12 +811,14 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
|
|
|
725
811
|
- **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.
|
|
726
812
|
- **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
|
|
727
813
|
- **Angle 3 - Security & language pitfalls:** input validation, injection, secrets, auth, error handling; plus language traps (async not awaited, shadowed scope, mutation of shared state).
|
|
814
|
+
- **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.
|
|
728
815
|
**Step 3 - VERIFY (3-state, cite the line):** for EACH candidate, quote the exact line and classify:
|
|
729
816
|
- **CONFIRMED** - you can name the input/state that triggers it and the wrong output. Quote the line.
|
|
730
817
|
- **PLAUSIBLE** - the mechanism is real but the trigger is uncertain. Say what would confirm it.
|
|
731
818
|
- **REFUTED** - drop it. Only refute if you can quote the line that proves it cannot happen (a type, guard, or constant). When unsure, keep it PLAUSIBLE.
|
|
732
819
|
Keep only CONFIRMED + PLAUSIBLE. A finding with no concrete failure scenario is noise: drop it.
|
|
733
820
|
**Step 4:** Write \`.phi/plans/review-${ts}.md\`, starting with the VERDICT line.
|
|
821
|
+
**Step 5 (MANDATORY):** Call the \`phase_result\` tool with your \`verdict\` (PASS/FAIL), the \`blocking\` findings (one per line, empty when none), and a short \`handoff\`. A FAIL here drives the one bounded fix→re-review cycle, so the orchestrator must read it exactly — do not rely on markdown parsing alone.
|
|
734
822
|
|
|
735
823
|
**Final report format (the FIRST line MUST be the verdict):**
|
|
736
824
|
\`\`\`markdown
|
|
@@ -844,9 +932,16 @@ Tag the note with relevant keywords for vector search.
|
|
|
844
932
|
activeAgentPrompt = agentDef.systemPrompt;
|
|
845
933
|
// Restrict tools to agent's allowed tools
|
|
846
934
|
if (agentDef.tools.length > 0) {
|
|
847
|
-
// Always include memory tools in orchestration phases
|
|
848
|
-
const
|
|
849
|
-
|
|
935
|
+
// Always include the memory tools and phase_result in orchestration phases.
|
|
936
|
+
const forcedTools = [
|
|
937
|
+
"memory_search",
|
|
938
|
+
"memory_write",
|
|
939
|
+
"memory_read",
|
|
940
|
+
"ontology_add",
|
|
941
|
+
"ontology_query",
|
|
942
|
+
"phase_result",
|
|
943
|
+
];
|
|
944
|
+
const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
|
|
850
945
|
_activeAgentTools = agentTools;
|
|
851
946
|
pi.setActiveTools(agentTools);
|
|
852
947
|
} else if (savedTools) {
|
|
@@ -959,6 +1054,10 @@ Tag the note with relevant keywords for vector search.
|
|
|
959
1054
|
const phase = phaseQueue.shift()!;
|
|
960
1055
|
phasePending = true;
|
|
961
1056
|
currentPhase = phase;
|
|
1057
|
+
// New phase starts with no structured result; it is set only if this
|
|
1058
|
+
// phase's agent calls phase_result.
|
|
1059
|
+
currentPhaseResult = null;
|
|
1060
|
+
currentPhaseResultKey = null;
|
|
962
1061
|
|
|
963
1062
|
switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
|
|
964
1063
|
activateAgent(phase, ctx);
|
|
@@ -1004,6 +1103,242 @@ Tag the note with relevant keywords for vector search.
|
|
|
1004
1103
|
});
|
|
1005
1104
|
}
|
|
1006
1105
|
|
|
1106
|
+
// ─── Generic linear driver (/debug, /build) ─────────────────────
|
|
1107
|
+
// A deliberately simpler engine than /plan's: phases run in order, a BLOCKED
|
|
1108
|
+
// verdict halts, and there is no review-fix cycle or "/5" bookkeeping. It
|
|
1109
|
+
// reuses the tested primitives (switchModelForPhase, activateAgent,
|
|
1110
|
+
// resolvePhaseOutcome) but never the /plan-specific agent_end path.
|
|
1111
|
+
|
|
1112
|
+
function routeFor(routeKey: string): { preferred: string; fallback: string } {
|
|
1113
|
+
const routingPath = join(homedir(), ".phi", "agent", "routing.json");
|
|
1114
|
+
try {
|
|
1115
|
+
const routing = JSON.parse(readFileSync(routingPath, "utf-8"));
|
|
1116
|
+
const route = routing.routes?.[routeKey];
|
|
1117
|
+
return {
|
|
1118
|
+
preferred: route?.preferredModel || routing.default?.model || "default",
|
|
1119
|
+
fallback: route?.fallback || routing.default?.model || "default",
|
|
1120
|
+
};
|
|
1121
|
+
} catch {
|
|
1122
|
+
return { preferred: "default", fallback: "default" };
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function genericPhase(
|
|
1127
|
+
key: string,
|
|
1128
|
+
label: string,
|
|
1129
|
+
routeKey: string,
|
|
1130
|
+
agentKey: string,
|
|
1131
|
+
instruction: string,
|
|
1132
|
+
): OrchestratorPhase {
|
|
1133
|
+
const route = routeFor(routeKey);
|
|
1134
|
+
return {
|
|
1135
|
+
key,
|
|
1136
|
+
label,
|
|
1137
|
+
model: route.preferred,
|
|
1138
|
+
fallback: route.fallback,
|
|
1139
|
+
agent: loadAgentDef(agentKey),
|
|
1140
|
+
instruction: instruction + COMMON_PHASE_RULES,
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase routes to a model
|
|
1145
|
+
// that does NOT share the coder's blind spot (verify on the test family).
|
|
1146
|
+
function buildDebugPhases(state: FailingState): OrchestratorPhase[] {
|
|
1147
|
+
const ins = debugPhaseInstructions(state);
|
|
1148
|
+
return [
|
|
1149
|
+
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1150
|
+
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1151
|
+
genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
|
|
1152
|
+
genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
|
|
1153
|
+
];
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// /build: reuse /plan's EXPLORE→PLAN→CODE, then an execution-grounded
|
|
1157
|
+
// BUILD-VERIFY that runs the recipe, checks acceptance, red-teams the
|
|
1158
|
+
// boundary, and routes real failures to the /debug protocol.
|
|
1159
|
+
function buildBuildPhases(spec: string, ts: string): OrchestratorPhase[] {
|
|
1160
|
+
const planLike = buildPhases(spec, ts).slice(0, 3); // explore, plan, code
|
|
1161
|
+
const verify = genericPhase(
|
|
1162
|
+
"verify",
|
|
1163
|
+
"🧪 Phase 4 — BUILD-VERIFY",
|
|
1164
|
+
"review",
|
|
1165
|
+
"review",
|
|
1166
|
+
buildVerifyInstruction(spec),
|
|
1167
|
+
);
|
|
1168
|
+
return [...planLike, verify];
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function finishGenericOrchestration(ctx: any, headline: string) {
|
|
1172
|
+
const elapsed = phaseStartTime ? Math.round((Date.now() - phaseStartTime) / 1000) : 0;
|
|
1173
|
+
const minutes = Math.floor(elapsed / 60);
|
|
1174
|
+
const seconds = elapsed % 60;
|
|
1175
|
+
const mode = orchestrationMode;
|
|
1176
|
+
setOrchestrationActive(false);
|
|
1177
|
+
phasePending = false;
|
|
1178
|
+
orchestrationMode = "plan";
|
|
1179
|
+
deactivateAgent();
|
|
1180
|
+
if (phaseTimeoutId) {
|
|
1181
|
+
clearTimeout(phaseTimeoutId);
|
|
1182
|
+
phaseTimeoutId = null;
|
|
1183
|
+
}
|
|
1184
|
+
if (originalModel) {
|
|
1185
|
+
const toRestore = originalModel;
|
|
1186
|
+
originalModel = null;
|
|
1187
|
+
Promise.resolve(pi.setModel(toRestore)).catch(() => {
|
|
1188
|
+
/* best effort */
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
ctx.ui.notify(
|
|
1192
|
+
`\n📊 **/${mode} summary** — ${completedPhases} phase(s) in ${minutes}m ${seconds}s${
|
|
1193
|
+
skippedPhases ? ` (${skippedPhases} skipped on timeout)` : ""
|
|
1194
|
+
}\n${headline}`,
|
|
1195
|
+
"info",
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function sendNextGenericPhase(ctx: any) {
|
|
1200
|
+
if (phaseQueue.length === 0) {
|
|
1201
|
+
finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const phase = phaseQueue.shift()!;
|
|
1205
|
+
phasePending = true;
|
|
1206
|
+
currentPhase = phase;
|
|
1207
|
+
currentPhaseResult = null;
|
|
1208
|
+
currentPhaseResultKey = null;
|
|
1209
|
+
|
|
1210
|
+
switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
|
|
1211
|
+
activateAgent(phase, ctx);
|
|
1212
|
+
const agentName = phase.agent?.name || phase.key;
|
|
1213
|
+
ctx.ui.notify(`\n${phase.label} → \`${modelId}\` (agent: ${agentName})`, "info");
|
|
1214
|
+
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1215
|
+
setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
|
|
1216
|
+
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1217
|
+
phaseTimeoutId = setTimeout(() => {
|
|
1218
|
+
if (orchestrationActive && phasePending) {
|
|
1219
|
+
phasePending = false;
|
|
1220
|
+
internalAbort = true;
|
|
1221
|
+
try {
|
|
1222
|
+
ctx.abort();
|
|
1223
|
+
} catch {
|
|
1224
|
+
/* best effort */
|
|
1225
|
+
}
|
|
1226
|
+
if (!phase.retried) {
|
|
1227
|
+
phase.retried = true;
|
|
1228
|
+
phase.useFallback = true;
|
|
1229
|
+
phaseQueue.unshift(phase);
|
|
1230
|
+
ctx.ui.notify(
|
|
1231
|
+
`\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`,
|
|
1232
|
+
"warning",
|
|
1233
|
+
);
|
|
1234
|
+
} else {
|
|
1235
|
+
skippedPhases++;
|
|
1236
|
+
ctx.ui.notify(`\n⏰ **Phase timed out again** — skipping to next phase.`, "warning");
|
|
1237
|
+
}
|
|
1238
|
+
sendNextGenericPhase(ctx);
|
|
1239
|
+
}
|
|
1240
|
+
}, MAX_PHASE_DURATION_MS);
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function handleGenericPhaseEnd(event: any, ctx: any) {
|
|
1245
|
+
const messages = event.messages || [];
|
|
1246
|
+
const analysis = analyzePhaseMessages(messages);
|
|
1247
|
+
|
|
1248
|
+
if (analysis.userAborted) {
|
|
1249
|
+
ctx.ui.notify(`\n🛑 **/${orchestrationMode} cancelled** by user. Remaining phases skipped.`, "warning");
|
|
1250
|
+
finishGenericOrchestration(ctx, "🛑 Cancelled.");
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!phasePending) {
|
|
1255
|
+
if (phaseQueue.length === 0) finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (phaseTimeoutId) {
|
|
1259
|
+
clearTimeout(phaseTimeoutId);
|
|
1260
|
+
phaseTimeoutId = null;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// A BLOCKED verdict (REPRODUCE cannot reproduce / VERIFY could not confirm)
|
|
1264
|
+
// halts the pipeline honestly — no fabricated success.
|
|
1265
|
+
const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
|
|
1266
|
+
const outcome = resolvePhaseOutcome(structuredForThisPhase, null);
|
|
1267
|
+
if (outcome.verdict === "BLOCKED" && currentPhase) {
|
|
1268
|
+
ctx.ui.notify(
|
|
1269
|
+
`\n⏸️ **${currentPhase.label} reported BLOCKED.** ${outcome.handoff || "No reproducible/verifiable result."}`,
|
|
1270
|
+
"warning",
|
|
1271
|
+
);
|
|
1272
|
+
finishGenericOrchestration(ctx, `⏸️ **/${orchestrationMode} stopped: BLOCKED at ${currentPhase.label}.**`);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Propagate a concise handoff to the next phase, like /plan does.
|
|
1277
|
+
const nextBrief = buildNextBrief(
|
|
1278
|
+
analysis,
|
|
1279
|
+
outcome.handoff,
|
|
1280
|
+
currentPhase?.label || "previous phase",
|
|
1281
|
+
MAX_TOOL_CALLS_PER_PHASE,
|
|
1282
|
+
);
|
|
1283
|
+
if (nextBrief && phaseQueue.length > 0) {
|
|
1284
|
+
phaseQueue[0].instruction += `\n\n**Previous phase summary:**\n${nextBrief}`;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
completedPhases++;
|
|
1288
|
+
phasePending = false;
|
|
1289
|
+
sendNextGenericPhase(ctx);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function startGenericOrchestration(
|
|
1293
|
+
mode: "debug" | "build",
|
|
1294
|
+
phases: OrchestratorPhase[],
|
|
1295
|
+
ctx: any,
|
|
1296
|
+
headline: string,
|
|
1297
|
+
) {
|
|
1298
|
+
phaseQueue = phases.slice(1);
|
|
1299
|
+
orchestrationMode = mode;
|
|
1300
|
+
setOrchestrationActive(true);
|
|
1301
|
+
phasePending = true;
|
|
1302
|
+
originalModel = ctx.model || null;
|
|
1303
|
+
savedTools = pi.getActiveTools();
|
|
1304
|
+
completedPhases = 0;
|
|
1305
|
+
skippedPhases = 0;
|
|
1306
|
+
internalAbort = false;
|
|
1307
|
+
currentPhaseResult = null;
|
|
1308
|
+
currentPhaseResultKey = null;
|
|
1309
|
+
phaseStartTime = Date.now();
|
|
1310
|
+
const first = phases[0];
|
|
1311
|
+
currentPhase = first;
|
|
1312
|
+
|
|
1313
|
+
ctx.ui.notify(headline, "info");
|
|
1314
|
+
for (const p of phases) {
|
|
1315
|
+
const agentName = p.agent?.name || p.key;
|
|
1316
|
+
ctx.ui.notify(` ${p.label} → \`${p.model}\` (agent: ${agentName})`, "info");
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
switchModelForPhase(first, ctx).then(({ modelId, warning }) => {
|
|
1320
|
+
activateAgent(first, ctx);
|
|
1321
|
+
ctx.ui.notify(`\n${first.label} → \`${modelId}\``, "info");
|
|
1322
|
+
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1323
|
+
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1324
|
+
phaseTimeoutId = setTimeout(() => {
|
|
1325
|
+
if (orchestrationActive && phasePending) {
|
|
1326
|
+
phasePending = false;
|
|
1327
|
+
internalAbort = true;
|
|
1328
|
+
try {
|
|
1329
|
+
ctx.abort();
|
|
1330
|
+
} catch {
|
|
1331
|
+
/* best effort */
|
|
1332
|
+
}
|
|
1333
|
+
skippedPhases++;
|
|
1334
|
+
ctx.ui.notify(`\n⏰ **First phase timed out** — skipping.`, "warning");
|
|
1335
|
+
sendNextGenericPhase(ctx);
|
|
1336
|
+
}
|
|
1337
|
+
}, MAX_PHASE_DURATION_MS);
|
|
1338
|
+
setTimeout(() => pi.sendUserMessage(first.instruction, { deliverAs: "followUp" }), 200);
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1007
1342
|
// ─── System Prompt Injection — Agent personas ────────────────────
|
|
1008
1343
|
|
|
1009
1344
|
pi.on("before_agent_start", async (_event, _ctx) => {
|
|
@@ -1033,6 +1368,13 @@ Tag the note with relevant keywords for vector search.
|
|
|
1033
1368
|
return;
|
|
1034
1369
|
}
|
|
1035
1370
|
|
|
1371
|
+
// /debug and /build run on the separate linear driver — never through the
|
|
1372
|
+
// /plan-specific review-fix + "/5" transition logic below.
|
|
1373
|
+
if (orchestrationMode !== "plan") {
|
|
1374
|
+
await handleGenericPhaseEnd(event, ctx);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1036
1378
|
const messages = event.messages || [];
|
|
1037
1379
|
// Analyze what happened, read the phase's report, and let the pure state
|
|
1038
1380
|
// machine (providers/phase-machine.ts, unit tested) decide the transition.
|
|
@@ -1063,13 +1405,18 @@ Tag the note with relevant keywords for vector search.
|
|
|
1063
1405
|
phaseTimeoutId = null;
|
|
1064
1406
|
}
|
|
1065
1407
|
|
|
1408
|
+
// Resolve the phase outcome: prefer the structured phase_result emission
|
|
1409
|
+
// (exact) and fall back per-field to the regex-scraped markdown report.
|
|
1410
|
+
// Only trust a structured result stamped with THIS phase's key — a stale
|
|
1411
|
+
// result from a previous run or a late tool call is ignored.
|
|
1066
1412
|
const reportContent = currentPhase ? readPhaseReport(currentPhase.key, currentRunTs) : null;
|
|
1067
|
-
const
|
|
1413
|
+
const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
|
|
1414
|
+
const outcome = resolvePhaseOutcome(structuredForThisPhase, reportContent);
|
|
1415
|
+
const verdict = outcome.verdict;
|
|
1068
1416
|
const decision = decidePhaseTransition({
|
|
1069
1417
|
analysis,
|
|
1070
1418
|
phase: currentPhase ? { key: currentPhase.key, retried: currentPhase.retried } : null,
|
|
1071
1419
|
verdict,
|
|
1072
|
-
hasReport: reportContent !== null,
|
|
1073
1420
|
reviewFixRounds,
|
|
1074
1421
|
maxToolCallsPerPhase: MAX_TOOL_CALLS_PER_PHASE,
|
|
1075
1422
|
});
|
|
@@ -1123,22 +1470,21 @@ Tag the note with relevant keywords for vector search.
|
|
|
1123
1470
|
}
|
|
1124
1471
|
|
|
1125
1472
|
if (decision.action === "continue" && decision.missingVerdict && currentPhase) {
|
|
1126
|
-
// The model deviated from
|
|
1127
|
-
//
|
|
1128
|
-
// instead of silently treating the phase as passed.
|
|
1473
|
+
// The model deviated from BOTH contract paths: it neither called
|
|
1474
|
+
// phase_result with a verdict nor wrote a parseable "VERDICT:" line.
|
|
1475
|
+
// Surface it instead of silently treating the phase as passed.
|
|
1129
1476
|
ctx.ui.notify(
|
|
1130
|
-
`\n⚠️ **${currentPhase.label}
|
|
1477
|
+
`\n⚠️ **${currentPhase.label} reported no verdict** (no phase_result call and no VERDICT line in \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\`) — treating it as passed. Check the report manually.`,
|
|
1131
1478
|
"warning",
|
|
1132
1479
|
);
|
|
1133
1480
|
}
|
|
1134
1481
|
|
|
1135
|
-
// Prefer the
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
const handoff = reportContent ? extractHandoff(reportContent) : "";
|
|
1482
|
+
// Prefer the resolved HANDOFF (structured phase_result, else the report's
|
|
1483
|
+
// "## HANDOFF" block) over the heuristic summary; fall back to the
|
|
1484
|
+
// heuristic when neither was provided.
|
|
1139
1485
|
const nextBrief = buildNextBrief(
|
|
1140
1486
|
analysis,
|
|
1141
|
-
handoff,
|
|
1487
|
+
outcome.handoff,
|
|
1142
1488
|
currentPhase?.label || "previous phase",
|
|
1143
1489
|
MAX_TOOL_CALLS_PER_PHASE,
|
|
1144
1490
|
);
|
|
@@ -1149,7 +1495,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1149
1495
|
if (decision.action === "review-fix-cycle" && currentPhase) {
|
|
1150
1496
|
// REVIEW FAIL: open ONE bounded fix -> re-review cycle.
|
|
1151
1497
|
reviewFixRounds++;
|
|
1152
|
-
const blocking =
|
|
1498
|
+
const blocking = outcome.blocking.slice(0, 4000);
|
|
1153
1499
|
const fixPhase: OrchestratorPhase = {
|
|
1154
1500
|
key: "code",
|
|
1155
1501
|
label: "🔧 Fix — CODE (review remediation)",
|
|
@@ -1217,6 +1563,10 @@ Tag the note with relevant keywords for vector search.
|
|
|
1217
1563
|
skippedPhases = 0;
|
|
1218
1564
|
reviewFixRounds = 0;
|
|
1219
1565
|
internalAbort = false;
|
|
1566
|
+
// Clear any structured result left over from a prior run so this
|
|
1567
|
+
// resumed phase's outcome is read fresh (see phase-result leak guard).
|
|
1568
|
+
currentPhaseResult = null;
|
|
1569
|
+
currentPhaseResultKey = null;
|
|
1220
1570
|
phaseStartTime = Date.now();
|
|
1221
1571
|
const firstResume = remaining[0];
|
|
1222
1572
|
currentPhase = firstResume;
|
|
@@ -1279,6 +1629,11 @@ Tag the note with relevant keywords for vector search.
|
|
|
1279
1629
|
skippedPhases = 0;
|
|
1280
1630
|
reviewFixRounds = 0;
|
|
1281
1631
|
internalAbort = false;
|
|
1632
|
+
// Clear any structured result left over from a previous /plan run in
|
|
1633
|
+
// this session; the first phase is launched directly (not via
|
|
1634
|
+
// sendNextPhase), so without this a stale verdict/handoff would leak.
|
|
1635
|
+
currentPhaseResult = null;
|
|
1636
|
+
currentPhaseResultKey = null;
|
|
1282
1637
|
const firstPhase = phases[0];
|
|
1283
1638
|
currentPhase = firstPhase;
|
|
1284
1639
|
|
|
@@ -1411,4 +1766,105 @@ Tag the note with relevant keywords for vector search.
|
|
|
1411
1766
|
ctx.ui.notify(output, "info");
|
|
1412
1767
|
},
|
|
1413
1768
|
});
|
|
1769
|
+
|
|
1770
|
+
// ─── /debug Command — turn a real failure green (execution-grounded) ──
|
|
1771
|
+
|
|
1772
|
+
pi.registerCommand("debug", {
|
|
1773
|
+
description: "Fix a REAL failing state — REPRODUCE → LOCALIZE → FIX → VERIFY, verdict backed by a real run",
|
|
1774
|
+
handler: async (args, ctx) => {
|
|
1775
|
+
if (orchestrationActive) {
|
|
1776
|
+
ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
const raw = args.trim();
|
|
1780
|
+
if (!raw) {
|
|
1781
|
+
ctx.ui.notify(
|
|
1782
|
+
`**Usage:** \`/debug <failing test | repro command | description>\`
|
|
1783
|
+
|
|
1784
|
+
/debug never guesses — it needs something reproducible.
|
|
1785
|
+
**Examples:**
|
|
1786
|
+
/debug pytest tests/test_auth.py::test_login_returns_jwt
|
|
1787
|
+
/debug node repro.js (segfaults on empty input, should return [])
|
|
1788
|
+
/debug the /login route 500s when the body is missing
|
|
1789
|
+
|
|
1790
|
+
It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a real before/after run — otherwise BLOCKED.`,
|
|
1791
|
+
"info",
|
|
1792
|
+
);
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const state: FailingState = parseFailingState(raw, { cwd: ctx.cwd || process.cwd() });
|
|
1797
|
+
// Honest gate at the boundary: /debug needs a reproducible failure. A bare
|
|
1798
|
+
// prose description with no test/command still runs (REPRODUCE will try to
|
|
1799
|
+
// derive one), but we tell the user the oracle depends on a real run.
|
|
1800
|
+
if (!state.failingTest && !state.reproCommand) {
|
|
1801
|
+
ctx.ui.notify(
|
|
1802
|
+
`⚠️ 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).`,
|
|
1803
|
+
"warning",
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
await ensurePlansDir();
|
|
1808
|
+
const phases = buildDebugPhases(state);
|
|
1809
|
+
startGenericOrchestration(
|
|
1810
|
+
"debug",
|
|
1811
|
+
phases,
|
|
1812
|
+
ctx,
|
|
1813
|
+
`🐛 **/debug started** — 4 execution-grounded phases (verdict requires a real run)\n`,
|
|
1814
|
+
);
|
|
1815
|
+
},
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
// ─── /build Command — build until it runs (plan + execution loop) ────
|
|
1819
|
+
|
|
1820
|
+
pi.registerCommand("build", {
|
|
1821
|
+
description:
|
|
1822
|
+
"Build from a spec AND prove it runs — EXPLORE → PLAN → CODE → BUILD-VERIFY (run + red-team + debug)",
|
|
1823
|
+
handler: async (args, ctx) => {
|
|
1824
|
+
if (orchestrationActive) {
|
|
1825
|
+
ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
const spec = args.trim();
|
|
1829
|
+
if (!spec) {
|
|
1830
|
+
ctx.ui.notify(
|
|
1831
|
+
`**Usage:** \`/build <what to build>\`
|
|
1832
|
+
|
|
1833
|
+
/build = /plan then an execution-grounded verify loop (run the recipe, check acceptance, executable red-team, fix real failures via /debug).
|
|
1834
|
+
**Examples:**
|
|
1835
|
+
/build a REST API for user auth with JWT and refresh tokens
|
|
1836
|
+
/build a CLI that converts CSV to JSON with a --pretty flag
|
|
1837
|
+
|
|
1838
|
+
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\`.`,
|
|
1839
|
+
"info",
|
|
1840
|
+
);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Cheap triage note (transparency): confirm /build is the right depth.
|
|
1845
|
+
const decision = triage({ text: spec });
|
|
1846
|
+
if (decision.route === "single-shot") {
|
|
1847
|
+
ctx.ui.notify(
|
|
1848
|
+
`ℹ️ This looks small and self-contained (${decision.reason}). /build will still run, but a single request may be cheaper.`,
|
|
1849
|
+
"info",
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
await ensurePlansDir();
|
|
1854
|
+
const ts = timestamp();
|
|
1855
|
+
await writeFile(
|
|
1856
|
+
join(plansDir, `spec-${ts}.md`),
|
|
1857
|
+
`# ${spec}\n\n**Created:** ${new Date().toLocaleString()}\n`,
|
|
1858
|
+
"utf-8",
|
|
1859
|
+
);
|
|
1860
|
+
currentDescription = spec;
|
|
1861
|
+
const phases = buildBuildPhases(spec, ts);
|
|
1862
|
+
startGenericOrchestration(
|
|
1863
|
+
"build",
|
|
1864
|
+
phases,
|
|
1865
|
+
ctx,
|
|
1866
|
+
`🏗️ **/build started** — plan + execution loop (SUCCESS requires a real run, else honest PARTIAL)\n`,
|
|
1867
|
+
);
|
|
1868
|
+
},
|
|
1869
|
+
});
|
|
1414
1870
|
}
|