@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 +61 -0
- package/docs/adr/0001-phase-contract.md +57 -0
- package/docs/adr/0002-independent-review.md +47 -0
- package/extensions/phi/orchestrator.ts +124 -19
- package/extensions/phi/providers/orchestrator-helpers.ts +14 -2
- package/extensions/phi/providers/phase-machine.ts +70 -7
- package/package.json +1 -1
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 {
|
|
28
|
-
|
|
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/<
|
|
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
|
|
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
|
|
849
|
-
|
|
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
|
|
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
|
|
1127
|
-
//
|
|
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}
|
|
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
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
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 =
|
|
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
|
|
37
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
281
|
+
missingVerdict,
|
|
219
282
|
};
|
|
220
283
|
}
|