@phi-code-admin/phi-code 0.88.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.
@@ -23,6 +23,8 @@ 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
29
  import {
28
30
  analyzePhaseMessages,
@@ -31,6 +33,7 @@ import {
31
33
  resolvePhaseOutcome,
32
34
  type StructuredPhaseResult,
33
35
  } from "./providers/phase-machine.js";
36
+ import { triage } from "./providers/triage.js";
34
37
 
35
38
  // ─── Types ───────────────────────────────────────────────────────────────
36
39
 
@@ -289,6 +292,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
289
292
 
290
293
  let phaseQueue: OrchestratorPhase[] = [];
291
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";
292
299
  let activeAgentPrompt: string | null = null;
293
300
  let _activeAgentTools: string[] | null = null;
294
301
  let savedTools: string[] | null = null;
@@ -705,6 +712,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
705
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.
706
713
 
707
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.
708
716
  - Write ONE file per tool call — NEVER combine multiple files in a single response
709
717
  - Keep each file under 500 lines. If longer, split into modules
710
718
  - After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` +
@@ -729,13 +737,11 @@ After implementation, use \`memory_write\` to save a summary of what was built,
729
737
 
730
738
  **Step 1:** Read \`.phi/plans/todo-*.md\` to know what was planned
731
739
  **Step 2:** Read \`.phi/plans/progress-*.md\` to see what was done
732
- **Step 3:** ACTUALLY RUN the code and observe it (proof, not a declarative checkbox). Verification = runtime evidence, by surface type:
733
- - **CLI:** run the real command, capture stdout AND the exit code, paste them.
734
- - **HTTP/API:** start the server in the background with a readiness wait, then \`curl\` the route that changed; paste the response + status.
735
- - **Library/package:** import the public entry and call it; paste the output.
736
- - At least one adversarial probe per feature (empty input, wrong type, missing arg).
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).
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.
739
745
  **Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
740
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.
741
747
 
@@ -805,6 +811,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
805
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.
806
812
  - **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
807
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.
808
815
  **Step 3 - VERIFY (3-state, cite the line):** for EACH candidate, quote the exact line and classify:
809
816
  - **CONFIRMED** - you can name the input/state that triggers it and the wrong output. Quote the line.
810
817
  - **PLAUSIBLE** - the mechanism is real but the trigger is uncertain. Say what would confirm it.
@@ -1096,6 +1103,242 @@ Tag the note with relevant keywords for vector search.
1096
1103
  });
1097
1104
  }
1098
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
+
1099
1342
  // ─── System Prompt Injection — Agent personas ────────────────────
1100
1343
 
1101
1344
  pi.on("before_agent_start", async (_event, _ctx) => {
@@ -1125,6 +1368,13 @@ Tag the note with relevant keywords for vector search.
1125
1368
  return;
1126
1369
  }
1127
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
+
1128
1378
  const messages = event.messages || [];
1129
1379
  // Analyze what happened, read the phase's report, and let the pure state
1130
1380
  // machine (providers/phase-machine.ts, unit tested) decide the transition.
@@ -1516,4 +1766,105 @@ Tag the note with relevant keywords for vector search.
1516
1766
  ctx.ui.notify(output, "info");
1517
1767
  },
1518
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
+ });
1519
1870
  }
@@ -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
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * /build outer loop — the pure decision core that composes the two oracles
3
+ * (docs/design/plan-debug-build.md): an acceptance RUN and an executable
4
+ * red-team. It never runs anything itself; given this round's reports it decides
5
+ * SUCCESS, CONTINUE (hand these real failures to /debug), or PARTIAL (budget
6
+ * spent — report what still fails honestly, never a confident-wrong PASS).
7
+ */
8
+
9
+ import type { AcceptanceReport, CriterionResult } from "./acceptance.js";
10
+ import type { FailingState } from "./debug-contract.js";
11
+ import { type BreakingCase, breakingCasesToFailingStates } from "./redteam.js";
12
+
13
+ export type BuildStatus = "success" | "continue" | "partial";
14
+
15
+ export interface BuildDecision {
16
+ status: BuildStatus;
17
+ /** Real failures to route to /debug (continue) or to report (partial). */
18
+ failures: FailingState[];
19
+ /** Manual criteria that could not be executed — surfaced, never counted green. */
20
+ unverified: string[];
21
+ reason: string;
22
+ }
23
+
24
+ export interface BuildRoundInput {
25
+ round: number;
26
+ maxRounds: number;
27
+ acceptance: AcceptanceReport;
28
+ breakingCases: BreakingCase[];
29
+ cwd?: string;
30
+ }
31
+
32
+ /** A failed acceptance criterion becomes a failing state /debug can reproduce. */
33
+ export function criterionToFailingState(r: CriterionResult, cwd?: string): FailingState {
34
+ return {
35
+ reproCommand: r.criterion.check,
36
+ expected: r.criterion.description,
37
+ trace: r.result ? `${r.result.command} exited ${r.result.exitCode}` : undefined,
38
+ cwd,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Decide one round of the build loop. SUCCESS requires: at least one criterion
44
+ * was actually checked, none failed, and the red-team found no break. Manual
45
+ * criteria never count toward success — they are reported as `unverified`.
46
+ */
47
+ export function decideBuildRound(input: BuildRoundInput): BuildDecision {
48
+ const failedStates = input.acceptance.failed.map((r) => criterionToFailingState(r, input.cwd));
49
+ const breakStates = breakingCasesToFailingStates(input.breakingCases, input.cwd);
50
+ const failures = [...failedStates, ...breakStates];
51
+ const unverified = input.acceptance.manual.map((r) => r.criterion.description);
52
+
53
+ if (failures.length === 0) {
54
+ if (!input.acceptance.allCheckablePassed) {
55
+ // Nothing failed, but nothing was executably verified either.
56
+ return {
57
+ status: "partial",
58
+ failures: [],
59
+ unverified,
60
+ reason: "no criterion could be executed — nothing verified; add checkable acceptance criteria",
61
+ };
62
+ }
63
+ const note = unverified.length ? ` (${unverified.length} manual criteria still unverified)` : "";
64
+ return {
65
+ status: "success",
66
+ failures: [],
67
+ unverified,
68
+ reason: `all checkable acceptance criteria passed and the red-team found no break${note}`,
69
+ };
70
+ }
71
+
72
+ if (input.round >= input.maxRounds) {
73
+ return {
74
+ status: "partial",
75
+ failures,
76
+ unverified,
77
+ reason: `budget exhausted after ${input.maxRounds} round(s); ${failures.length} failure(s) still open`,
78
+ };
79
+ }
80
+
81
+ return {
82
+ status: "continue",
83
+ failures,
84
+ unverified,
85
+ reason: `${failures.length} real failure(s) this round → route to /debug (round ${input.round}/${input.maxRounds})`,
86
+ };
87
+ }
88
+
89
+ export const DEFAULT_MAX_ROUNDS = 4;