@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
|
@@ -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
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executable red-team — the adversary's deliverable is a FAILING RUN, not an
|
|
3
|
+
* opinion (docs/design/plan-debug-build.md). This is the pure decision core: it
|
|
4
|
+
* turns the outcome of each adversarial attempt into a loop step, so a "breaking
|
|
5
|
+
* case" is only ever recorded when a real test went RED. Prose can't lie its way
|
|
6
|
+
* to a finding here — execution is the oracle.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: in the 3362 measurement an over-clever guard survived a
|
|
9
|
+
* different model's careful written review because the reviewer shared the
|
|
10
|
+
* misconception. It would NOT have survived the assertion being *run*. So the
|
|
11
|
+
* adversary must attack the specific input regime the diff touched, and express
|
|
12
|
+
* the attack as an executed test.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FailingState } from "./debug-contract.js";
|
|
16
|
+
import { type CommandResult, passed, tail } from "./execution.js";
|
|
17
|
+
|
|
18
|
+
/** One adversarial attempt: a runnable test aimed at a specific input regime. */
|
|
19
|
+
export interface RedTeamAttempt {
|
|
20
|
+
/** Which boundary/regime it targets (e.g. "buffered vs streaming"). */
|
|
21
|
+
regime: string;
|
|
22
|
+
/** The runnable breaking test (a command). */
|
|
23
|
+
test: string;
|
|
24
|
+
/** The execution of that test (null = it could not be run). */
|
|
25
|
+
result: CommandResult | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A confirmed break: an attempt whose test actually ran RED. */
|
|
29
|
+
export interface BreakingCase {
|
|
30
|
+
regime: string;
|
|
31
|
+
test: string;
|
|
32
|
+
symptom: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RedTeamState {
|
|
36
|
+
/** Consecutive rounds that failed to break the code. */
|
|
37
|
+
dry: number;
|
|
38
|
+
attemptsUsed: number;
|
|
39
|
+
breakingCases: BreakingCase[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RedTeamConfig {
|
|
43
|
+
/** Stop after this many consecutive rounds that could not break the code. */
|
|
44
|
+
dryRoundsToStop: number;
|
|
45
|
+
/** Hard budget on attempts. */
|
|
46
|
+
maxAttempts: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_REDTEAM_CONFIG: RedTeamConfig = { dryRoundsToStop: 2, maxAttempts: 8 };
|
|
50
|
+
|
|
51
|
+
export function initRedTeam(): RedTeamState {
|
|
52
|
+
return { dry: 0, attemptsUsed: 0, breakingCases: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Keep going only while under budget and not yet K dry rounds in a row. */
|
|
56
|
+
export function shouldContinueRedTeam(state: RedTeamState, config: RedTeamConfig): boolean {
|
|
57
|
+
return state.attemptsUsed < config.maxAttempts && state.dry < config.dryRoundsToStop;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold one attempt into the state. A RED run (test failed) is a breaking case
|
|
62
|
+
* and resets the dry counter; a GREEN run (or one that could not be executed at
|
|
63
|
+
* all) counts as a dry round — you cannot claim a break you did not run.
|
|
64
|
+
*/
|
|
65
|
+
export function recordAttempt(state: RedTeamState, attempt: RedTeamAttempt, config: RedTeamConfig): RedTeamState {
|
|
66
|
+
const attemptsUsed = state.attemptsUsed + 1;
|
|
67
|
+
const ran = attempt.result !== null;
|
|
68
|
+
const broke = ran && !passed(attempt.result as CommandResult);
|
|
69
|
+
|
|
70
|
+
if (broke) {
|
|
71
|
+
const result = attempt.result as CommandResult;
|
|
72
|
+
return {
|
|
73
|
+
dry: 0,
|
|
74
|
+
attemptsUsed,
|
|
75
|
+
breakingCases: [
|
|
76
|
+
...state.breakingCases,
|
|
77
|
+
{ regime: attempt.regime, test: attempt.test, symptom: tail(result, 20) || `exit ${result.exitCode}` },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Green, or unrunnable: this round produced no finding.
|
|
82
|
+
void config;
|
|
83
|
+
return { dry: state.dry + 1, attemptsUsed, breakingCases: state.breakingCases };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Turn confirmed breaks into failing states that /debug can consume directly. */
|
|
87
|
+
export function breakingCasesToFailingStates(cases: BreakingCase[], cwd?: string): FailingState[] {
|
|
88
|
+
return cases.map((c) => ({
|
|
89
|
+
reproCommand: c.test,
|
|
90
|
+
trace: c.symptom,
|
|
91
|
+
expected: `input regime "${c.regime}" must not break the change`,
|
|
92
|
+
cwd,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const STREAMING_HINT = /\b(stream|iter|chunk|read|io|http|response|decode|encode|buffer|socket|pipe)\b/i;
|
|
97
|
+
const PARSE_HINT = /\b(parse|json|yaml|xml|deserialize|decode|token|lexer)\b/i;
|
|
98
|
+
const NUMERIC_HINT = /\b(sum|count|index|offset|length|size|range|price|amount|math|float|int)\b/i;
|
|
99
|
+
const AUTH_HINT = /\b(auth|login|token|session|password|permission|role|jwt)\b/i;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cheap enumeration of the input regimes worth attacking, given hints from the
|
|
103
|
+
* change (file names + keywords). Always includes the universal boundaries;
|
|
104
|
+
* adds domain-specific ones when the hints match. The adversary attacks these,
|
|
105
|
+
* not the whole app.
|
|
106
|
+
*/
|
|
107
|
+
export function enumerateInputRegimes(hints: { changedFiles?: string[]; keywords?: string } = {}): string[] {
|
|
108
|
+
const blob = `${(hints.changedFiles ?? []).join(" ")} ${hints.keywords ?? ""}`;
|
|
109
|
+
const regimes = ["empty input", "null/undefined", "boundary value", "wrong type", "large input"];
|
|
110
|
+
if (STREAMING_HINT.test(blob)) regimes.push("buffered vs streaming");
|
|
111
|
+
if (PARSE_HINT.test(blob)) regimes.push("malformed/partial input", "non-ASCII / unicode");
|
|
112
|
+
if (NUMERIC_HINT.test(blob)) regimes.push("zero / negative", "overflow / very large number");
|
|
113
|
+
if (AUTH_HINT.test(blob)) regimes.push("missing credentials", "expired / tampered token");
|
|
114
|
+
return regimes;
|
|
115
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage / adaptive depth — pick the CHEAPEST mode consistent with the signals.
|
|
3
|
+
*
|
|
4
|
+
* The measured lesson (docs/design/plan-debug-build.md): the 6–14× overhead of
|
|
5
|
+
* the full pipeline is only worth paying when a single shot fails the real
|
|
6
|
+
* oracle. So this classifier defaults to the least machinery and escalates only
|
|
7
|
+
* on concrete signals — a supplied failing state routes to /debug, a large or
|
|
8
|
+
* under-specified build routes to /build, everything else is a single shot.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Route = "debug" | "single-shot" | "build" | "plan";
|
|
12
|
+
|
|
13
|
+
export interface TriageSignals {
|
|
14
|
+
/** A concrete failing test / trace / repro command was supplied. */
|
|
15
|
+
hasFailingState?: boolean;
|
|
16
|
+
/** Rough number of files the task will touch (undefined = estimate from text). */
|
|
17
|
+
estimatedFiles?: number;
|
|
18
|
+
/** The task text, for cheap keyword signals. */
|
|
19
|
+
text?: string;
|
|
20
|
+
/** Caller forces a specific mode (an explicit /debug, /build, /plan). */
|
|
21
|
+
forced?: Route;
|
|
22
|
+
/** Caller wants only a plan artifact, not the execution loop. */
|
|
23
|
+
planOnly?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TriageDecision {
|
|
27
|
+
route: Route;
|
|
28
|
+
/** "minimal" = do the least; "full" = the multi-phase pipeline is justified. */
|
|
29
|
+
depth: "minimal" | "full";
|
|
30
|
+
reason: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BUILD_KEYWORDS =
|
|
34
|
+
/\b(build|scaffold|application|app|feature|end[- ]?to[- ]?end|full[- ]?stack|from scratch|new (project|service|module|api|endpoint|component|page)|several files|multiple files)\b/i;
|
|
35
|
+
|
|
36
|
+
/** At/above this many touched files, a single shot is unlikely to stay coherent. */
|
|
37
|
+
export const MULTI_FILE_THRESHOLD = 3;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cheap file-count estimate from a request: count distinct path-like tokens.
|
|
41
|
+
* Deliberately conservative — defaults to 1 when nothing looks like a path.
|
|
42
|
+
*/
|
|
43
|
+
export function estimateFiles(text: string): number {
|
|
44
|
+
if (!text.trim()) return 1;
|
|
45
|
+
const paths = new Set<string>();
|
|
46
|
+
for (const m of text.matchAll(/\b[\w./-]+\.[a-z]{1,6}\b/gi)) {
|
|
47
|
+
paths.add(m[0].toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
return Math.max(1, paths.size);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Map signals to a route + depth. Pure and deterministic: same signals in, same
|
|
54
|
+
* decision out. Order matters — a forced mode wins, then a real failing state
|
|
55
|
+
* (cheapest useful oracle), then build-scale, else single shot.
|
|
56
|
+
*/
|
|
57
|
+
export function triage(signals: TriageSignals): TriageDecision {
|
|
58
|
+
if (signals.forced) {
|
|
59
|
+
return {
|
|
60
|
+
route: signals.forced,
|
|
61
|
+
depth: signals.forced === "single-shot" ? "minimal" : "full",
|
|
62
|
+
reason: `explicit /${signals.forced}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (signals.hasFailingState) {
|
|
67
|
+
return {
|
|
68
|
+
route: "debug",
|
|
69
|
+
depth: "minimal",
|
|
70
|
+
reason: "a reproducible failing state was supplied — fix it directly, skip planning",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = signals.text ?? "";
|
|
75
|
+
const files = signals.estimatedFiles ?? estimateFiles(text);
|
|
76
|
+
const buildy = BUILD_KEYWORDS.test(text);
|
|
77
|
+
const large = files >= MULTI_FILE_THRESHOLD;
|
|
78
|
+
|
|
79
|
+
if (buildy || large) {
|
|
80
|
+
const why = large ? `~${files} files touched (≥ ${MULTI_FILE_THRESHOLD})` : "build-scale request";
|
|
81
|
+
if (signals.planOnly) {
|
|
82
|
+
return {
|
|
83
|
+
route: "plan",
|
|
84
|
+
depth: "full",
|
|
85
|
+
reason: `${why}; plan-only requested — decompose without the run loop`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { route: "build", depth: "full", reason: `${why} — decompose and run the build→verify→debug loop` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
route: "single-shot",
|
|
93
|
+
depth: "minimal",
|
|
94
|
+
reason: "small, self-contained — try one shot and verify before escalating",
|
|
95
|
+
};
|
|
96
|
+
}
|