@phi-code-admin/phi-code 0.87.0 → 0.88.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 CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.88.0] - 2026-07-10
4
+
5
+ ### Changed
6
+
7
+ - **The /plan phase contract is now structured-primary with a text fallback**
8
+ (see `docs/adr/0001-phase-contract.md`). A phase agent CALLS a new
9
+ `phase_result` tool to emit its verdict / blocking / handoff as data;
10
+ `resolvePhaseOutcome` merges that structured emission field-by-field with the
11
+ regex-scraped markdown report, preferring the structured value. TEST and
12
+ REVIEW are instructed to call it. When a model does not call the tool,
13
+ behavior is identical to the previous text-only path — the change cannot make
14
+ any run worse. This replaces "regex luck" with an exact machine-read path for
15
+ the control-flow-critical signals (verdict, BLOCKED, the review-fix cycle).
16
+
17
+ ### Added
18
+
19
+ - **End-to-end integration test of the orchestrator.** The real orchestrator
20
+ extension is now driven through a full multi-phase run against a simulated Pi
21
+ runtime (`test/orchestrator-integration.test.ts`): phase progression, handoff
22
+ propagation, the review-FAIL → fix → re-review cycle, and the BLOCKED pause
23
+ are all exercised together, not just the pure decision function. (Scripted
24
+ phase outcomes — deterministic, no live model.)
25
+ - **An eval harness** (`evals/`) — the measurement infrastructure that did not
26
+ exist. Tasks with deterministic pass/fail verifiers, a runnable baseline
27
+ strategy (`npx tsx evals/run.ts`), unit-tested scoring/aggregation
28
+ (`test/evals-lib.test.ts`), and a demonstrated real run (2/2 baseline tasks on
29
+ a live model). `evals/README.md` documents the methodology and is honest that
30
+ the /plan-vs-baseline head-to-head is not yet a single number.
31
+ - **Two ADRs** documenting the phase-contract decision and the independent-review
32
+ release gate (`docs/adr/`).
33
+
34
+ ### Fixed
35
+
36
+ - Bugs found by an **independent adversarial review** of the phase-contract
37
+ change (see `docs/adr/0002-independent-review.md`), each now pinned by a
38
+ regression test:
39
+ - **State leak between runs**: `currentPhaseResult` was reset only in
40
+ `sendNextPhase`, but the first phase of each `/plan` run launches directly —
41
+ a stale structured result (e.g. a BLOCKED verdict) from a previous run could
42
+ abort a fresh run at phase 1. Reset on every run start + a phase-identity
43
+ stamp so a stale/late result is never mistaken for the current phase.
44
+ - **Field erasure on multi-call**: a second `phase_result` call replaced the
45
+ whole object, wiping a verdict set by the first; it now merges only the
46
+ fields each call sets.
47
+ - **Dead text fallback for PLAN/CODE**: `readPhaseReport` looked for
48
+ `<key>-<ts>.md` but PLAN writes `todo-<ts>.md` and CODE `progress-<ts>.md`,
49
+ so their handoff blocks were never read. Mapped key → report file.
50
+ - **`extractSection` over-matching**: a prose line starting with a section
51
+ word (e.g. "Blocking issues remain: 2") could be read as that section's
52
+ header. The plain-label form now requires `:` or end-of-line.
53
+ - A TEST/REVIEW phase that finished with tool work but emitted no verdict at all
54
+ (neither structured nor a VERDICT line) is now surfaced instead of silently
55
+ passing.
56
+ - **The eval runner** had two Windows bugs its first real run caught: temp-dir
57
+ cleanup raced `EPERM` (now retries), and `shell:true` with an args array
58
+ mangled the prompt (now a single quoted command string).
59
+
60
+ ### Tooling
61
+
62
+ - Biome now also lints `evals/`.
63
+
3
64
  ## [0.87.0] - 2026-07-10
4
65
 
5
66
  ### Added
@@ -0,0 +1,57 @@
1
+ # ADR 0001 — Structured-primary phase contract for /plan
2
+
3
+ Status: accepted (2026-07-10)
4
+
5
+ ## Context
6
+
7
+ The /plan orchestrator chains five agent phases (explore → plan → code → test
8
+ → review). Each phase must report two things the orchestrator acts on:
9
+
10
+ - a **verdict** (TEST/REVIEW): PASS / FAIL / BLOCKED / SKIP, and
11
+ - a **handoff** (+ BLOCKING findings for REVIEW) carried to the next phase.
12
+
13
+ Originally this was communicated **only** as markdown the model wrote to
14
+ `.phi/plans/<phase>-<ts>.md`, which the orchestrator scraped with regexes
15
+ (`## VERDICT:`, `## HANDOFF`, `## BLOCKING`). That is fragile: models phrase
16
+ headers inconsistently (`**HANDOFF**`, `HANDOFF:`, mid-sentence "verdict"), and
17
+ a mis-scrape silently degrades control flow — a missed REVIEW FAIL skips the fix
18
+ cycle; a missed BLOCKED keeps a doomed run going. The original justification for
19
+ text-only was that the upstream proxy did not guarantee valid structured tool
20
+ output.
21
+
22
+ ## Decision
23
+
24
+ Keep the markdown report (it is the human-readable artifact) but make the
25
+ **machine-read path structured and primary**:
26
+
27
+ - The orchestrator registers a `phase_result` tool. TEST and REVIEW phases are
28
+ instructed to call it with `{verdict, blocking, handoff}`; any phase may call
29
+ it to hand off.
30
+ - `resolvePhaseOutcome(structured, reportText)` (pure, unit-tested) merges the
31
+ two sources **field by field**, preferring the structured value and falling
32
+ back to the regex-scraped report per field. When the model calls the tool the
33
+ outcome is exact; when it does not, behavior is byte-for-byte the pre-existing
34
+ text path.
35
+ - The text parser was hardened anyway (`extractSection` accepts heading, bold,
36
+ and plain-label forms) so the fallback is as robust as possible.
37
+
38
+ ## Why not go structured-only
39
+
40
+ Two reasons the text path stays as a fallback rather than being removed:
41
+
42
+ 1. **Provider variance.** Not every provider/proxy reliably emits tool calls on
43
+ every turn; a model that writes a good report but forgets the tool call must
44
+ still drive the pipeline correctly.
45
+ 2. **Zero-regression migration.** Making structured additive means the change
46
+ cannot make any existing run worse — the worst case equals the old behavior.
47
+
48
+ ## Consequences
49
+
50
+ - Robustness of control flow (verdict/BLOCKED/fix-cycle) now depends on a
51
+ structured emission when available, not on regex luck.
52
+ - Two code paths must stay in sync; `resolvePhaseOutcome` centralizes the merge
53
+ and is covered by unit tests, and `orchestrator-integration.test.ts` drives
54
+ the whole chain via the structured path.
55
+ - Follow-up (not done here): once telemetry shows the structured path is taken
56
+ reliably across the providers phi ships, the text fallback can be demoted to a
57
+ warning-only safety net.
@@ -0,0 +1,47 @@
1
+ # ADR 0002 — Independent adversarial review as a release gate
2
+
3
+ Status: accepted (2026-07-10)
4
+
5
+ ## Context
6
+
7
+ phi-code is largely built by a single author, who is also the only reviewer.
8
+ Self-review misses the bugs the author's mental model is blind to — the code
9
+ does what the author *thinks* it does, and they test that. There is no external
10
+ human reviewer on hand for every change.
11
+
12
+ ## Decision
13
+
14
+ Before shipping a non-trivial change to a load-bearing subsystem (the /plan
15
+ orchestrator, the model/provider layer, compaction, the extension runtime), run
16
+ an **independent adversarial review**: a reviewer with fresh context that did
17
+ not write the code, prompted to *refute* — to find state leaks, races, contract
18
+ mismatches, and edge cases — not to approve.
19
+
20
+ Findings are triaged (verify each against the code, discard false positives),
21
+ the real ones are fixed, and each fix is pinned with a regression test before
22
+ the change ships.
23
+
24
+ ## Evidence this works
25
+
26
+ The structured-phase-contract change (ADR 0001) passed the author's own unit
27
+ and integration tests. An independent review of that change then found four
28
+ real defects the author's tests missed:
29
+
30
+ - a structured result leaking from one `/plan` run into the next (a stale
31
+ BLOCKED verdict could abort a fresh run at phase 1);
32
+ - a second `phase_result` call erasing fields set by the first;
33
+ - no phase-identity guard against a late tool call landing after a transition;
34
+ - the text HANDOFF fallback being dead for two phases due to a report-file name
35
+ mismatch.
36
+
37
+ All four are now fixed and covered by regression tests
38
+ (`orchestrator-integration.test.ts`, `phase-machine.test.ts`).
39
+
40
+ ## Consequences
41
+
42
+ - "Green tests" is necessary but not sufficient for a load-bearing change; an
43
+ independent refutation pass is part of the definition of done.
44
+ - The reviewer need not be human to be useful — it needs to be *independent of
45
+ the authoring context* and prompted adversarially. This is not a substitute
46
+ for real external users, whose absence remains an honest limitation of the
47
+ project's validation.
@@ -24,8 +24,13 @@ 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
26
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
27
- import { extractBlockingFindings, extractHandoff, parsePhaseVerdict } from "./providers/orchestrator-helpers.js";
28
- import { analyzePhaseMessages, buildNextBrief, decidePhaseTransition } from "./providers/phase-machine.js";
27
+ import {
28
+ analyzePhaseMessages,
29
+ buildNextBrief,
30
+ decidePhaseTransition,
31
+ resolvePhaseOutcome,
32
+ type StructuredPhaseResult,
33
+ } from "./providers/phase-machine.js";
29
34
 
30
35
  // ─── Types ───────────────────────────────────────────────────────────────
31
36
 
@@ -309,11 +314,85 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
309
314
  // Set true right before an INTERNAL ctx.abort() (phase timeout) so the
310
315
  // resulting agent_end is not mistaken for a user Ctrl+C cancellation.
311
316
  let internalAbort = false;
317
+ // Structured result emitted by the current phase via the `phase_result` tool
318
+ // (robust primary path). Reset at each phase start; read in agent_end and
319
+ // merged with the parsed markdown report (fallback) by resolvePhaseOutcome.
320
+ // currentPhaseResultKey stamps which phase produced it, so a stale result
321
+ // from a previous run/phase (or a tool call that lands after the phase
322
+ // transition) is never mistaken for the current phase's output.
323
+ let currentPhaseResult: StructuredPhaseResult | null = null;
324
+ let currentPhaseResultKey: string | null = null;
325
+
326
+ // ─── phase_result Tool — structured phase outcome (robust primary path) ───
327
+ // A phase agent CALLS this to emit its verdict/handoff/blocking as data
328
+ // instead of relying on the orchestrator regex-scraping its markdown report.
329
+ // It is only meaningful during /plan; outside orchestration it is a no-op so
330
+ // a stray call never errors. The markdown report is still written (it is the
331
+ // human-readable artifact); this just makes the machine-read path exact.
332
+ pi.registerTool({
333
+ name: "phase_result",
334
+ label: "Phase Result",
335
+ description:
336
+ "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.",
337
+ promptGuidelines: [
338
+ "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.",
339
+ "verdict: PASS only if you OBSERVED success; FAIL if any blocking issue; BLOCKED if you could not run; SKIP if not applicable.",
340
+ "blocking: the must-fix findings (REVIEW), one per line. handoff: what is done, open risks, and the single most important next action.",
341
+ ],
342
+ parameters: Type.Object({
343
+ verdict: Type.Optional(
344
+ Type.Union([Type.Literal("PASS"), Type.Literal("FAIL"), Type.Literal("BLOCKED"), Type.Literal("SKIP")], {
345
+ description: "Phase verdict (required for TEST and REVIEW phases)",
346
+ }),
347
+ ),
348
+ blocking: Type.Optional(
349
+ Type.String({ description: "Must-fix findings, one per line (REVIEW). Empty when none." }),
350
+ ),
351
+ handoff: Type.Optional(
352
+ Type.String({ description: "Concise handoff for the next phase: state, open risks, next action." }),
353
+ ),
354
+ }),
355
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
356
+ if (!orchestrationActive) {
357
+ return {
358
+ content: [{ type: "text", text: "phase_result is only used during /plan orchestration; ignored here." }],
359
+ details: undefined,
360
+ };
361
+ }
362
+ const raw = params as { verdict?: StructuredPhaseResult["verdict"]; blocking?: string; handoff?: string };
363
+ // Merge only the fields this call actually set, so a phase that emits
364
+ // its result across several calls (verdict first, handoff later — a
365
+ // common LLM pattern) does not erase earlier fields. Start fresh when
366
+ // the previous result belonged to a different phase.
367
+ const defined: StructuredPhaseResult = {};
368
+ if (raw.verdict !== undefined) defined.verdict = raw.verdict;
369
+ if (typeof raw.blocking === "string") defined.blocking = raw.blocking;
370
+ if (typeof raw.handoff === "string") defined.handoff = raw.handoff;
371
+ const samePhase = currentPhaseResultKey === (currentPhase?.key ?? null);
372
+ currentPhaseResult = { ...(samePhase && currentPhaseResult ? currentPhaseResult : {}), ...defined };
373
+ currentPhaseResultKey = currentPhase?.key ?? null;
374
+ const parts = [
375
+ raw.verdict ? `verdict ${raw.verdict}` : null,
376
+ raw.blocking?.trim() ? "blocking findings" : null,
377
+ raw.handoff?.trim() ? "handoff" : null,
378
+ ].filter(Boolean);
379
+ return {
380
+ content: [{ type: "text", text: `Phase result recorded${parts.length ? ` (${parts.join(", ")})` : ""}.` }],
381
+ details: undefined,
382
+ };
383
+ },
384
+ });
385
+
386
+ // A phase's report file is not always named <key>-<ts>.md: PLAN writes its
387
+ // handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
388
+ // stem so the text fallback for HANDOFF/BLOCKING actually finds them.
389
+ const REPORT_FILE_STEM: Record<string, string> = { plan: "todo", code: "progress" };
312
390
 
313
- /** Read a phase's report file (.phi/plans/<key>-<ts>.md). Null-safe. */
391
+ /** Read a phase's report file (.phi/plans/<stem>-<ts>.md). Null-safe. */
314
392
  function readPhaseReport(key: string, ts: string): string | null {
315
393
  try {
316
- const f = join(process.cwd(), ".phi", "plans", `${key}-${ts}.md`);
394
+ const stem = REPORT_FILE_STEM[key] ?? key;
395
+ const f = join(process.cwd(), ".phi", "plans", `${stem}-${ts}.md`);
317
396
  if (existsSync(f)) return readFileSync(f, "utf-8");
318
397
  } catch {
319
398
  /* ignore */
@@ -658,6 +737,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
658
737
  - Running \`npm test\` alone is NOT sufficient proof for a feature.
659
738
  **Step 4:** Fix any real errors you find (root cause, not a workaround).
660
739
  **Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
740
+ **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
741
 
662
742
  **Test report format (the FIRST line MUST be the verdict):**
663
743
  \`\`\`markdown
@@ -731,6 +811,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
731
811
  - **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
812
  Keep only CONFIRMED + PLAUSIBLE. A finding with no concrete failure scenario is noise: drop it.
733
813
  **Step 4:** Write \`.phi/plans/review-${ts}.md\`, starting with the VERDICT line.
814
+ **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
815
 
735
816
  **Final report format (the FIRST line MUST be the verdict):**
736
817
  \`\`\`markdown
@@ -844,9 +925,16 @@ Tag the note with relevant keywords for vector search.
844
925
  activeAgentPrompt = agentDef.systemPrompt;
845
926
  // Restrict tools to agent's allowed tools
846
927
  if (agentDef.tools.length > 0) {
847
- // Always include memory tools in orchestration phases
848
- const memoryTools = ["memory_search", "memory_write", "memory_read", "ontology_add", "ontology_query"];
849
- const agentTools = [...agentDef.tools, ...memoryTools.filter((t) => !agentDef.tools.includes(t))];
928
+ // Always include the memory tools and phase_result in orchestration phases.
929
+ const forcedTools = [
930
+ "memory_search",
931
+ "memory_write",
932
+ "memory_read",
933
+ "ontology_add",
934
+ "ontology_query",
935
+ "phase_result",
936
+ ];
937
+ const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
850
938
  _activeAgentTools = agentTools;
851
939
  pi.setActiveTools(agentTools);
852
940
  } else if (savedTools) {
@@ -959,6 +1047,10 @@ Tag the note with relevant keywords for vector search.
959
1047
  const phase = phaseQueue.shift()!;
960
1048
  phasePending = true;
961
1049
  currentPhase = phase;
1050
+ // New phase starts with no structured result; it is set only if this
1051
+ // phase's agent calls phase_result.
1052
+ currentPhaseResult = null;
1053
+ currentPhaseResultKey = null;
962
1054
 
963
1055
  switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
964
1056
  activateAgent(phase, ctx);
@@ -1063,13 +1155,18 @@ Tag the note with relevant keywords for vector search.
1063
1155
  phaseTimeoutId = null;
1064
1156
  }
1065
1157
 
1158
+ // Resolve the phase outcome: prefer the structured phase_result emission
1159
+ // (exact) and fall back per-field to the regex-scraped markdown report.
1160
+ // Only trust a structured result stamped with THIS phase's key — a stale
1161
+ // result from a previous run or a late tool call is ignored.
1066
1162
  const reportContent = currentPhase ? readPhaseReport(currentPhase.key, currentRunTs) : null;
1067
- const verdict = reportContent ? parsePhaseVerdict(reportContent) : null;
1163
+ const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
1164
+ const outcome = resolvePhaseOutcome(structuredForThisPhase, reportContent);
1165
+ const verdict = outcome.verdict;
1068
1166
  const decision = decidePhaseTransition({
1069
1167
  analysis,
1070
1168
  phase: currentPhase ? { key: currentPhase.key, retried: currentPhase.retried } : null,
1071
1169
  verdict,
1072
- hasReport: reportContent !== null,
1073
1170
  reviewFixRounds,
1074
1171
  maxToolCallsPerPhase: MAX_TOOL_CALLS_PER_PHASE,
1075
1172
  });
@@ -1123,22 +1220,21 @@ Tag the note with relevant keywords for vector search.
1123
1220
  }
1124
1221
 
1125
1222
  if (decision.action === "continue" && decision.missingVerdict && currentPhase) {
1126
- // The model deviated from the text contract: this phase must start its
1127
- // report with "VERDICT: ..." and nothing parseable was found. Surface it
1128
- // instead of silently treating the phase as passed.
1223
+ // The model deviated from BOTH contract paths: it neither called
1224
+ // phase_result with a verdict nor wrote a parseable "VERDICT:" line.
1225
+ // Surface it instead of silently treating the phase as passed.
1129
1226
  ctx.ui.notify(
1130
- `\n⚠️ **${currentPhase.label} wrote no parseable VERDICT line** in \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\` — treating it as passed. Check the report manually.`,
1227
+ `\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
1228
  "warning",
1132
1229
  );
1133
1230
  }
1134
1231
 
1135
- // Prefer the phase's canonical "## HANDOFF" block (a deterministic text
1136
- // contract written to its report file) over the heuristic summary; fall back
1137
- // to the heuristic when the model did not write one.
1138
- const handoff = reportContent ? extractHandoff(reportContent) : "";
1232
+ // Prefer the resolved HANDOFF (structured phase_result, else the report's
1233
+ // "## HANDOFF" block) over the heuristic summary; fall back to the
1234
+ // heuristic when neither was provided.
1139
1235
  const nextBrief = buildNextBrief(
1140
1236
  analysis,
1141
- handoff,
1237
+ outcome.handoff,
1142
1238
  currentPhase?.label || "previous phase",
1143
1239
  MAX_TOOL_CALLS_PER_PHASE,
1144
1240
  );
@@ -1149,7 +1245,7 @@ Tag the note with relevant keywords for vector search.
1149
1245
  if (decision.action === "review-fix-cycle" && currentPhase) {
1150
1246
  // REVIEW FAIL: open ONE bounded fix -> re-review cycle.
1151
1247
  reviewFixRounds++;
1152
- const blocking = (reportContent ? extractBlockingFindings(reportContent) : "").slice(0, 4000);
1248
+ const blocking = outcome.blocking.slice(0, 4000);
1153
1249
  const fixPhase: OrchestratorPhase = {
1154
1250
  key: "code",
1155
1251
  label: "🔧 Fix — CODE (review remediation)",
@@ -1217,6 +1313,10 @@ Tag the note with relevant keywords for vector search.
1217
1313
  skippedPhases = 0;
1218
1314
  reviewFixRounds = 0;
1219
1315
  internalAbort = false;
1316
+ // Clear any structured result left over from a prior run so this
1317
+ // resumed phase's outcome is read fresh (see phase-result leak guard).
1318
+ currentPhaseResult = null;
1319
+ currentPhaseResultKey = null;
1220
1320
  phaseStartTime = Date.now();
1221
1321
  const firstResume = remaining[0];
1222
1322
  currentPhase = firstResume;
@@ -1279,6 +1379,11 @@ Tag the note with relevant keywords for vector search.
1279
1379
  skippedPhases = 0;
1280
1380
  reviewFixRounds = 0;
1281
1381
  internalAbort = false;
1382
+ // Clear any structured result left over from a previous /plan run in
1383
+ // this session; the first phase is launched directly (not via
1384
+ // sendNextPhase), so without this a stale verdict/handoff would leak.
1385
+ currentPhaseResult = null;
1386
+ currentPhaseResultKey = null;
1282
1387
  const firstPhase = phases[0];
1283
1388
  currentPhase = firstPhase;
1284
1389
 
@@ -33,8 +33,20 @@ export function parsePhaseVerdict(content: string): PhaseVerdict | null {
33
33
  */
34
34
  export function extractSection(content: string, heading: string): string {
35
35
  if (!content) return "";
36
- // Header line: optional leading heading hashes and/or bold stars, then the name.
37
- const start = new RegExp(`(?:^|\\n)[ \\t]{0,3}(?:#{1,6}[ \\t]*)?\\*{0,2}[ \\t]*${heading}\\b[^\\n]*\\n`, "i");
36
+ // Header line, in one of three shapes, anchored to line start:
37
+ // `## HANDOFF ...` (markdown heading trailing text allowed)
38
+ // `**HANDOFF**` (bold label — closing stars, then only trailing space)
39
+ // `HANDOFF:` / `HANDOFF` (plain label — must be the whole line or end in ":")
40
+ // The plain form requires ":" or end-of-line after the name so a prose line
41
+ // like "Blocking issues remain: 2" is not mistaken for a "BLOCKING" header.
42
+ const start = new RegExp(
43
+ `(?:^|\\n)[ \\t]{0,3}(?:` +
44
+ `#{1,6}[ \\t]*\\*{0,2}[ \\t]*${heading}\\b[^\\n]*` + // heading
45
+ `|\\*{2}[ \\t]*${heading}[ \\t]*\\*{2}[ \\t]*` + // bold label
46
+ `|${heading}[ \\t]*:?[ \\t]*` + // plain label (bare or "name:")
47
+ `)\\n`,
48
+ "i",
49
+ );
38
50
  const m = start.exec(content);
39
51
  if (!m) return "";
40
52
  const rest = content.slice(m.index + m[0].length);
@@ -13,7 +13,13 @@
13
13
  * > continue (with diagnostics).
14
14
  */
15
15
 
16
- import { isTransientError, type PhaseVerdict } from "./orchestrator-helpers.js";
16
+ import {
17
+ extractBlockingFindings,
18
+ extractHandoff,
19
+ isTransientError,
20
+ type PhaseVerdict,
21
+ parsePhaseVerdict,
22
+ } from "./orchestrator-helpers.js";
17
23
 
18
24
  export interface PhaseEndAnalysis {
19
25
  userAborted: boolean;
@@ -177,16 +183,65 @@ export interface PhaseDecisionInput {
177
183
  analysis: PhaseEndAnalysis;
178
184
  /** Currently executing phase, or null when the queue raced. */
179
185
  phase: { key: string; retried?: boolean } | null;
180
- /** Verdict parsed from the phase's report file (null = absent/unparseable). */
186
+ /** Resolved verdict (structured phase_result or parsed report; null = none). */
181
187
  verdict: PhaseVerdict | null;
182
- /** Whether a report file existed at all for this phase. */
183
- hasReport: boolean;
184
188
  /** Completed review-fix cycles so far (bounded to one). */
185
189
  reviewFixRounds: number;
186
190
  maxToolCallsPerPhase: number;
187
191
  }
188
192
 
189
- /** Phases whose contract REQUIRES a leading "VERDICT:" line in their report. */
193
+ /**
194
+ * Structured phase result: what a phase agent emits by CALLING the
195
+ * `phase_result` tool (robust primary path), instead of only writing markdown
196
+ * that has to be regex-scraped (fragile fallback). All fields optional — a
197
+ * partial structured emission is merged field-by-field with the parsed report.
198
+ */
199
+ export interface StructuredPhaseResult {
200
+ verdict?: PhaseVerdict;
201
+ blocking?: string;
202
+ handoff?: string;
203
+ }
204
+
205
+ export interface EffectivePhaseOutcome {
206
+ verdict: PhaseVerdict | null;
207
+ blocking: string;
208
+ handoff: string;
209
+ /** Where each resolved field came from — for diagnostics and tests. */
210
+ source: "structured" | "text" | "mixed" | "none";
211
+ }
212
+
213
+ /**
214
+ * Resolve a phase's effective outcome, preferring the structured tool emission
215
+ * and falling back to the regex-scraped markdown report per field. This is the
216
+ * heart of the "structured primary, text fallback" contract: when the model
217
+ * calls phase_result the outcome is exact; when it doesn't, behavior is
218
+ * identical to the pure-text path that shipped before.
219
+ */
220
+ export function resolvePhaseOutcome(
221
+ structured: StructuredPhaseResult | null,
222
+ reportText: string | null,
223
+ ): EffectivePhaseOutcome {
224
+ const textVerdict = reportText ? parsePhaseVerdict(reportText) : null;
225
+ const textBlocking = reportText ? extractBlockingFindings(reportText) : "";
226
+ const textHandoff = reportText ? extractHandoff(reportText) : "";
227
+
228
+ const verdict = structured?.verdict ?? textVerdict;
229
+ const blocking = structured?.blocking?.trim() || textBlocking;
230
+ const handoff = structured?.handoff?.trim() || textHandoff;
231
+
232
+ const usedStructured = Boolean(
233
+ structured && (structured.verdict || structured.blocking?.trim() || structured.handoff?.trim()),
234
+ );
235
+ const usedText = Boolean(textVerdict || textBlocking || textHandoff);
236
+ let source: EffectivePhaseOutcome["source"] = "none";
237
+ if (usedStructured && usedText) source = "mixed";
238
+ else if (usedStructured) source = "structured";
239
+ else if (usedText) source = "text";
240
+
241
+ return { verdict, blocking, handoff, source };
242
+ }
243
+
244
+ /** Phases whose contract REQUIRES a verdict (structured or a leading VERDICT: line). */
190
245
  const VERDICT_PHASES = new Set(["test", "review"]);
191
246
 
192
247
  /**
@@ -194,7 +249,7 @@ const VERDICT_PHASES = new Set(["test", "review"]);
194
249
  * caller interprets the decision (notifications, queue edits, checkpoints).
195
250
  */
196
251
  export function decidePhaseTransition(input: PhaseDecisionInput): PhaseDecision {
197
- const { analysis, phase, verdict, hasReport, reviewFixRounds, maxToolCallsPerPhase } = input;
252
+ const { analysis, phase, verdict, reviewFixRounds, maxToolCallsPerPhase } = input;
198
253
 
199
254
  if (analysis.userAborted) return { action: "stop", reason: "user-abort" };
200
255
  if (analysis.hasAuthError) return { action: "stop", reason: "auth-error" };
@@ -212,9 +267,17 @@ export function decidePhaseTransition(input: PhaseDecisionInput): PhaseDecision
212
267
  return { action: "review-fix-cycle" };
213
268
  }
214
269
 
270
+ // A TEST/REVIEW phase must produce a verdict (structured phase_result is
271
+ // mandatory for them, with the report's VERDICT line as fallback). If it
272
+ // completed with tool work but no verdict either way, that is a contract
273
+ // deviation worth surfacing — not silently passing. The zero-tool-call case
274
+ // has its own warning, so exclude it here to avoid a double warning.
275
+ const missingVerdict =
276
+ phase !== null && VERDICT_PHASES.has(phase.key) && verdict === null && analysis.toolCallCount > 0;
277
+
215
278
  return {
216
279
  action: "continue",
217
280
  zeroToolCalls: analysis.toolCallCount === 0,
218
- missingVerdict: phase !== null && VERDICT_PHASES.has(phase.key) && hasReport && verdict === null,
281
+ missingVerdict,
219
282
  };
220
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.87.0",
3
+ "version": "0.88.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {