@dieulc/workflow 0.2.0 → 0.3.1
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/README.md +188 -6
- package/index.ts +1021 -65
- package/package.json +9 -4
- package/permissions.ts +1042 -0
- package/review-runtime.ts +931 -0
- package/review-tools.ts +749 -0
- package/review-ui.ts +421 -0
- package/review.ts +1704 -0
- package/roles.ts +164 -57
- package/utils.ts +474 -92
package/review.ts
ADDED
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Review Mode
|
|
3
|
+
*
|
|
4
|
+
* A second, independent reviewer agent that supervises Plan Mode:
|
|
5
|
+
*
|
|
6
|
+
* Plan Mode (main session) ──plan file──▶ Review Mode (child session)
|
|
7
|
+
* ▲ │
|
|
8
|
+
* │ feedback │ rewrites the plan
|
|
9
|
+
* │ ▼
|
|
10
|
+
* Plannotator webview ◀──────────── reviewed plan + change log
|
|
11
|
+
*
|
|
12
|
+
* Design rules (see docs/plans/2026-09-18-review-mode-dual-plan.md):
|
|
13
|
+
*
|
|
14
|
+
* - Review Mode is TOGGLEABLE. When disabled, the plan/build flow is
|
|
15
|
+
* byte-for-byte the behaviour that existed before this module.
|
|
16
|
+
* - Review Mode runs in its OWN in-process session (own context, own model,
|
|
17
|
+
* own system prompt). It reads Plan Mode's context but never the reverse.
|
|
18
|
+
* - Review Mode is read-only except for one write path: `review_submit_plan`
|
|
19
|
+
* (implemented in the parent, not in the child).
|
|
20
|
+
* - This module holds the pure, I/O-light parts (config, prompt, state,
|
|
21
|
+
* validation, rendering) so they can be unit-tested without a Pi runtime.
|
|
22
|
+
* Nothing here imports the Pi SDK at module scope.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import {
|
|
29
|
+
getUtcDatePrefix,
|
|
30
|
+
isPlanWritePath,
|
|
31
|
+
normalizePlanPath,
|
|
32
|
+
planHash,
|
|
33
|
+
reviewNeedsVerification,
|
|
34
|
+
validateReviewedPlan,
|
|
35
|
+
type ReviewDisposition,
|
|
36
|
+
type ReviewFinding,
|
|
37
|
+
type ReviewTier,
|
|
38
|
+
} from "./utils.ts";
|
|
39
|
+
|
|
40
|
+
// Re-exported so `review.ts` is the single import surface for Review Mode.
|
|
41
|
+
export type { ReviewDisposition, ReviewFinding, ReviewTier };
|
|
42
|
+
export { planHash, reviewNeedsVerification, validateReviewedPlan };
|
|
43
|
+
|
|
44
|
+
/** Pi's project config directory. Mirrors CONFIG_DIR_NAME without importing Pi at module scope. */
|
|
45
|
+
export const WORKFLOW_CONFIG_DIR = ".pi";
|
|
46
|
+
|
|
47
|
+
/** Top-level key in settings.json that namespaces all workflow settings. */
|
|
48
|
+
export const WORKFLOW_SETTINGS_KEY = "workflow";
|
|
49
|
+
|
|
50
|
+
/** Nested key under WORKFLOW_SETTINGS_KEY. */
|
|
51
|
+
export const REVIEW_MODE_KEY = "reviewMode";
|
|
52
|
+
|
|
53
|
+
/** Custom entry type used to persist review state across reload/resume. */
|
|
54
|
+
export const REVIEW_ENTRY_TYPE = "workflow-review";
|
|
55
|
+
export const REVIEW_SUBMITTED_ENTRY_TYPE = "workflow-review-submitted";
|
|
56
|
+
|
|
57
|
+
// ── Step 2: Review Mode configuration ────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export type ReviewFallbackOnError = "skip" | "block";
|
|
60
|
+
|
|
61
|
+
export interface ReviewModeConfig {
|
|
62
|
+
/** Master toggle. When false every Review Mode code path is a no-op. */
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
/** Max Review↔Plan rounds per user-feedback cycle. */
|
|
65
|
+
rounds: number;
|
|
66
|
+
/** 1 = review + rewrite only. 2 = review + rewrite, then self-verify. */
|
|
67
|
+
passes: number;
|
|
68
|
+
/** Run the self-verification pass (only when pass 1 found severity >= 5). */
|
|
69
|
+
verify: boolean;
|
|
70
|
+
/** What to do if the reviewer model is unavailable or the review fails. */
|
|
71
|
+
fallbackOnError: ReviewFallbackOnError;
|
|
72
|
+
/** Max `review_explore` subagents per review round (cost ceiling). */
|
|
73
|
+
exploreBudget: number;
|
|
74
|
+
/**
|
|
75
|
+
* Optional wall-clock cap for a single review round, in milliseconds.
|
|
76
|
+
* 0 = unlimited (default). When unlimited, a pass ends on
|
|
77
|
+
* `review_pass_done`, the run settling, an abort, or a prompt error.
|
|
78
|
+
*/
|
|
79
|
+
timeoutMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const DEFAULT_REVIEW_MODE_CONFIG: Readonly<ReviewModeConfig> = {
|
|
83
|
+
enabled: false,
|
|
84
|
+
rounds: 1,
|
|
85
|
+
passes: 2,
|
|
86
|
+
verify: true,
|
|
87
|
+
fallbackOnError: "skip",
|
|
88
|
+
exploreBudget: 3,
|
|
89
|
+
timeoutMs: 0,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const ROUNDS_MIN = 1;
|
|
93
|
+
const ROUNDS_MAX = 5;
|
|
94
|
+
const PASSES_MIN = 1;
|
|
95
|
+
const PASSES_MAX = 2;
|
|
96
|
+
const EXPLORE_MIN = 0;
|
|
97
|
+
const EXPLORE_MAX = 8;
|
|
98
|
+
const TIMEOUT_MIN = 0;
|
|
99
|
+
const TIMEOUT_MAX = 3_600_000;
|
|
100
|
+
|
|
101
|
+
/** A JSON object parsed from an untrusted source (settings file, fragment). */
|
|
102
|
+
export type JsonObject = Record<string, unknown>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A raw `workflow.reviewMode` fragment. `true` is accepted as shorthand for
|
|
106
|
+
* `{ enabled: true }` so a minimal settings file stays readable.
|
|
107
|
+
*/
|
|
108
|
+
export type RawReviewModeSetting = JsonObject | boolean;
|
|
109
|
+
|
|
110
|
+
function clampInt(
|
|
111
|
+
value: unknown,
|
|
112
|
+
min: number,
|
|
113
|
+
max: number,
|
|
114
|
+
fallback: number,
|
|
115
|
+
): number {
|
|
116
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
117
|
+
if (!Number.isFinite(n)) return fallback;
|
|
118
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
122
|
+
return typeof value === "boolean" ? value : fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Coerce an untrusted settings fragment into a complete ReviewModeConfig.
|
|
127
|
+
* Accepts an object, or the boolean shorthand `true`/`false` for
|
|
128
|
+
* `{ enabled: true }` / `{ enabled: false }`. Invalid fields fall back to
|
|
129
|
+
* `base` (defaults). Never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function coerceReviewModeConfig(
|
|
132
|
+
raw: unknown,
|
|
133
|
+
base: ReviewModeConfig = DEFAULT_REVIEW_MODE_CONFIG as ReviewModeConfig,
|
|
134
|
+
): ReviewModeConfig {
|
|
135
|
+
if (typeof raw === "boolean") return { ...base, enabled: raw };
|
|
136
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
137
|
+
return { ...base };
|
|
138
|
+
}
|
|
139
|
+
const r = raw as Record<string, unknown>;
|
|
140
|
+
const fallbackOnError: ReviewFallbackOnError =
|
|
141
|
+
r.fallbackOnError === "block" || r.fallbackOnError === "skip"
|
|
142
|
+
? r.fallbackOnError
|
|
143
|
+
: base.fallbackOnError;
|
|
144
|
+
return {
|
|
145
|
+
enabled: asBool(r.enabled, base.enabled),
|
|
146
|
+
rounds: clampInt(r.rounds, ROUNDS_MIN, ROUNDS_MAX, base.rounds),
|
|
147
|
+
passes: clampInt(r.passes, PASSES_MIN, PASSES_MAX, base.passes),
|
|
148
|
+
verify: asBool(r.verify, base.verify),
|
|
149
|
+
fallbackOnError,
|
|
150
|
+
exploreBudget: clampInt(
|
|
151
|
+
r.exploreBudget,
|
|
152
|
+
EXPLORE_MIN,
|
|
153
|
+
EXPLORE_MAX,
|
|
154
|
+
base.exploreBudget,
|
|
155
|
+
),
|
|
156
|
+
timeoutMs: clampInt(r.timeoutMs, TIMEOUT_MIN, TIMEOUT_MAX, base.timeoutMs),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Extract the `workflow.reviewMode` fragment from a raw settings object. */
|
|
161
|
+
export function extractReviewModeRaw(
|
|
162
|
+
settings: unknown,
|
|
163
|
+
): RawReviewModeSetting | undefined {
|
|
164
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
165
|
+
return undefined;
|
|
166
|
+
const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
|
|
167
|
+
if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
|
|
168
|
+
const raw = (wf as JsonObject)[REVIEW_MODE_KEY];
|
|
169
|
+
if (typeof raw === "boolean") return raw;
|
|
170
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
171
|
+
return raw as JsonObject;
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Merge global then project settings fragments, then coerce onto defaults.
|
|
177
|
+
* Project wins field-by-field over global; global wins over defaults.
|
|
178
|
+
*/
|
|
179
|
+
export function mergeReviewModeConfig(
|
|
180
|
+
globalRaw: unknown,
|
|
181
|
+
projectRaw: unknown,
|
|
182
|
+
): ReviewModeConfig {
|
|
183
|
+
const fromGlobal = coerceReviewModeConfig(globalRaw);
|
|
184
|
+
return coerceReviewModeConfig(projectRaw, fromGlobal);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ReviewConfigPaths {
|
|
188
|
+
global: string;
|
|
189
|
+
project: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Settings file locations. `agentDir` is passed in rather than read from the
|
|
194
|
+
* Pi SDK so this stays pure and testable.
|
|
195
|
+
*/
|
|
196
|
+
export function reviewConfigPaths(
|
|
197
|
+
cwd: string,
|
|
198
|
+
agentDir: string,
|
|
199
|
+
): ReviewConfigPaths {
|
|
200
|
+
return {
|
|
201
|
+
global: path.join(agentDir, "settings.json"),
|
|
202
|
+
project: path.join(cwd, WORKFLOW_CONFIG_DIR, "settings.json"),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readJsonSafe(fp: string): JsonObject | undefined {
|
|
207
|
+
try {
|
|
208
|
+
if (!fs.existsSync(fp)) return undefined;
|
|
209
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
210
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
211
|
+
return undefined;
|
|
212
|
+
return parsed as JsonObject;
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ResolvedReviewConfig {
|
|
219
|
+
config: ReviewModeConfig;
|
|
220
|
+
/** Which files contributed (for /review-mode status and debugging). */
|
|
221
|
+
sources: {
|
|
222
|
+
global: boolean;
|
|
223
|
+
project: boolean;
|
|
224
|
+
/** Which scope supplied the effective `timeoutMs` value. */
|
|
225
|
+
timeout: ReviewConfigScopeSource;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Which settings scope supplied a field's effective value. */
|
|
230
|
+
export type ReviewConfigScopeSource = "default" | "global" | "project";
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Which raw fragment supplied the effective `timeoutMs`: the project
|
|
234
|
+
* fragment when it carries the key, else the global fragment, else the
|
|
235
|
+
* built-in default. Pure and total.
|
|
236
|
+
*/
|
|
237
|
+
export function timeoutSourceOf(
|
|
238
|
+
globalRaw: RawReviewModeSetting | undefined,
|
|
239
|
+
projectRaw: RawReviewModeSetting | undefined,
|
|
240
|
+
): ReviewConfigScopeSource {
|
|
241
|
+
const hasTimeoutKey = (raw: unknown): boolean =>
|
|
242
|
+
Boolean(raw) &&
|
|
243
|
+
typeof raw === "object" &&
|
|
244
|
+
!Array.isArray(raw) &&
|
|
245
|
+
Object.prototype.hasOwnProperty.call(raw, "timeoutMs");
|
|
246
|
+
if (hasTimeoutKey(projectRaw)) return "project";
|
|
247
|
+
if (hasTimeoutKey(globalRaw)) return "global";
|
|
248
|
+
return "default";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the effective Review Mode config from disk.
|
|
253
|
+
* Never throws — a corrupt or missing file falls back to defaults.
|
|
254
|
+
*/
|
|
255
|
+
export function readReviewModeConfig(
|
|
256
|
+
cwd: string,
|
|
257
|
+
agentDir: string,
|
|
258
|
+
): ResolvedReviewConfig {
|
|
259
|
+
const paths = reviewConfigPaths(cwd, agentDir);
|
|
260
|
+
const globalSettings = readJsonSafe(paths.global);
|
|
261
|
+
const projectSettings = readJsonSafe(paths.project);
|
|
262
|
+
const globalRaw = extractReviewModeRaw(globalSettings);
|
|
263
|
+
const projectRaw = extractReviewModeRaw(projectSettings);
|
|
264
|
+
const config = mergeReviewModeConfig(globalRaw, projectRaw);
|
|
265
|
+
return {
|
|
266
|
+
config,
|
|
267
|
+
sources: {
|
|
268
|
+
global: globalRaw !== undefined,
|
|
269
|
+
project: projectRaw !== undefined,
|
|
270
|
+
timeout: timeoutSourceOf(globalRaw, projectRaw),
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The single gate used by every Review Mode hook.
|
|
277
|
+
* Never throws.
|
|
278
|
+
*/
|
|
279
|
+
export function isReviewEnabled(cwd: string, agentDir: string): boolean {
|
|
280
|
+
try {
|
|
281
|
+
return readReviewModeConfig(cwd, agentDir).config.enabled;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read-modify-write `workflow.reviewMode` into a settings file, preserving
|
|
289
|
+
* every other key in that file (Pi owns settings.json — we must not clobber it).
|
|
290
|
+
*/
|
|
291
|
+
export function writeReviewModeConfig(
|
|
292
|
+
scope: "global" | "project",
|
|
293
|
+
cwd: string,
|
|
294
|
+
agentDir: string,
|
|
295
|
+
patch: Partial<ReviewModeConfig>,
|
|
296
|
+
): { path: string; ok: boolean; error?: string } {
|
|
297
|
+
const paths = reviewConfigPaths(cwd, agentDir);
|
|
298
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
299
|
+
try {
|
|
300
|
+
const existing = readJsonSafe(fp);
|
|
301
|
+
const root: JsonObject = existing ? { ...existing } : {};
|
|
302
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
303
|
+
const wf: JsonObject =
|
|
304
|
+
wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
|
|
305
|
+
? { ...(wfExisting as JsonObject) }
|
|
306
|
+
: {};
|
|
307
|
+
const current = coerceReviewModeConfig(wf[REVIEW_MODE_KEY]);
|
|
308
|
+
const next = coerceReviewModeConfig({ ...current, ...patch }, current);
|
|
309
|
+
wf[REVIEW_MODE_KEY] = next;
|
|
310
|
+
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
311
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
312
|
+
fs.writeFileSync(fp, JSON.stringify(root, null, 2) + "\n", "utf8");
|
|
313
|
+
return { path: fp, ok: true };
|
|
314
|
+
} catch (e: unknown) {
|
|
315
|
+
return {
|
|
316
|
+
path: fp,
|
|
317
|
+
ok: false,
|
|
318
|
+
error: e instanceof Error ? e.message : String(e),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Status helpers (pure) ────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** Parsed `/review-mode timeout` value. */
|
|
326
|
+
export type ParsedReviewTimeout =
|
|
327
|
+
| { ok: true; timeoutMs: number }
|
|
328
|
+
| { ok: false; error: string };
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Parse a `/review-mode timeout` argument: `unlimited`/`off`/`none`/`0` mean
|
|
332
|
+
* no cap; otherwise whole seconds in 1..3600. Never throws.
|
|
333
|
+
*/
|
|
334
|
+
export function parseReviewTimeoutArg(raw: unknown): ParsedReviewTimeout {
|
|
335
|
+
const s = String(raw ?? "").trim().toLowerCase();
|
|
336
|
+
if (!s)
|
|
337
|
+
return { ok: false, error: "missing value — use <seconds|unlimited>" };
|
|
338
|
+
if (s === "unlimited" || s === "off" || s === "none" || s === "0") {
|
|
339
|
+
return { ok: true, timeoutMs: 0 };
|
|
340
|
+
}
|
|
341
|
+
if (!/^\d+$/.test(s)) {
|
|
342
|
+
return {
|
|
343
|
+
ok: false,
|
|
344
|
+
error: `invalid timeout "${String(raw).trim()}" — use <seconds|unlimited>`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const secs = Number(s);
|
|
348
|
+
if (secs < 1 || secs * 1000 > TIMEOUT_MAX) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
error: `timeout must be 0 (unlimited) or 1-${TIMEOUT_MAX / 1000} seconds`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, timeoutMs: secs * 1000 };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The historical default that older settings files pinned explicitly. */
|
|
358
|
+
const LEGACY_REVIEW_TIMEOUT_MS = 10 * 60 * 1000;
|
|
359
|
+
|
|
360
|
+
/** `unlimited (default)` / `600s (global settings)` / `… (project settings)`. */
|
|
361
|
+
export function formatReviewTimeoutLabel(
|
|
362
|
+
timeoutMs: number,
|
|
363
|
+
source: ReviewConfigScopeSource,
|
|
364
|
+
): string {
|
|
365
|
+
const value =
|
|
366
|
+
timeoutMs === 0 ? "unlimited" : `${Math.round(timeoutMs / 1000)}s`;
|
|
367
|
+
const from = source === "default" ? "default" : `${source} settings`;
|
|
368
|
+
return `${value} (${from})`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Everything the `/review-mode status` block renders. */
|
|
372
|
+
export interface ReviewStatusInput {
|
|
373
|
+
enabled: boolean;
|
|
374
|
+
config: ReviewModeConfig;
|
|
375
|
+
/** Which scope supplied `config.timeoutMs`. */
|
|
376
|
+
timeoutSource: ReviewConfigScopeSource;
|
|
377
|
+
/** Effective reviewer model, when the reviewer role is configured. */
|
|
378
|
+
reviewer?: { provider: string; id: string; thinking: string };
|
|
379
|
+
/** Resolved reviewer prompt source (path or built-in label). */
|
|
380
|
+
promptSource: string;
|
|
381
|
+
/** Whether each settings file carries a `workflow.reviewMode` fragment. */
|
|
382
|
+
sources: { global: boolean; project: boolean };
|
|
383
|
+
/** `reviewing` | `session active` | `not started`. */
|
|
384
|
+
runtime: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the `/review-mode status` block. Pure so the exact rows are testable;
|
|
389
|
+
* notably there is no `flow` row — Review Mode is always sequential.
|
|
390
|
+
*/
|
|
391
|
+
export function formatReviewStatusLines(input: ReviewStatusInput): string[] {
|
|
392
|
+
const c = input.config;
|
|
393
|
+
const model = input.reviewer;
|
|
394
|
+
const lines = [
|
|
395
|
+
`Review Mode: ${input.enabled ? "ENABLED" : "disabled"}`,
|
|
396
|
+
` reviewer model : ${
|
|
397
|
+
model
|
|
398
|
+
? `${model.provider}/${model.id} (${model.thinking})`
|
|
399
|
+
: "unconfigured — /role set reviewer <provider/model>"
|
|
400
|
+
}`,
|
|
401
|
+
` prompt : ${input.promptSource}`,
|
|
402
|
+
` rounds : ${c.rounds} passes: ${c.passes} verify: ${c.verify}`,
|
|
403
|
+
` explore budget : ${c.exploreBudget} timeout: ${formatReviewTimeoutLabel(
|
|
404
|
+
c.timeoutMs,
|
|
405
|
+
input.timeoutSource,
|
|
406
|
+
)}`,
|
|
407
|
+
];
|
|
408
|
+
if (
|
|
409
|
+
input.timeoutSource !== "default" &&
|
|
410
|
+
c.timeoutMs === LEGACY_REVIEW_TIMEOUT_MS
|
|
411
|
+
) {
|
|
412
|
+
lines.push(
|
|
413
|
+
` ⚠ 600s cap set in ${input.timeoutSource} settings — /review-mode timeout unlimited removes it`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
lines.push(
|
|
417
|
+
` sources : global=${input.sources.global} project=${input.sources.project}`,
|
|
418
|
+
` runtime : ${input.runtime}`,
|
|
419
|
+
` transcript : streamed into the main Pi transcript`,
|
|
420
|
+
"",
|
|
421
|
+
"Usage: /review-mode on|off [global|project] | /review-mode status | /review-mode timeout <seconds|unlimited> [global|project] | /review-prompt | /review-status | /review",
|
|
422
|
+
);
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Step 3: Review prompt resolution ─────────────────────────────────
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Built-in reviewer guidance. The resolved prompt is appended to the
|
|
430
|
+
* reviewer's SYSTEM prompt inside the child session only — it is never
|
|
431
|
+
* injected into the parent (Plan Mode) context.
|
|
432
|
+
*/
|
|
433
|
+
export const DEFAULT_REVIEW_PROMPT = [
|
|
434
|
+
"Always align with the existing project framework and industry best practice.",
|
|
435
|
+
"",
|
|
436
|
+
"Your job is to independently audit a plan that another agent produced, then",
|
|
437
|
+
"rewrite it so it is correct, complete, and idiomatic for THIS repository.",
|
|
438
|
+
"",
|
|
439
|
+
"Review criteria, in priority order:",
|
|
440
|
+
"",
|
|
441
|
+
"1. Framework alignment — does the plan follow the conventions, module layout,",
|
|
442
|
+
" naming, error-handling style, and dependency choices already established in",
|
|
443
|
+
" this codebase? Prefer the repo's own patterns over novel ones. Call out every",
|
|
444
|
+
" divergence explicitly.",
|
|
445
|
+
"2. Industry best practice — correctness, security, error handling, testability,",
|
|
446
|
+
" observability, performance, and migration/rollback safety. Cite the specific",
|
|
447
|
+
" practice when you flag a gap.",
|
|
448
|
+
"3. Requirement coverage — every requirement in the shared context must map to at",
|
|
449
|
+
" least one plan step. A requirement with no step is a Critical finding.",
|
|
450
|
+
"4. Executability — each step must be concrete enough to implement without",
|
|
451
|
+
" guessing: file paths, symbols, and a verifiable outcome.",
|
|
452
|
+
"5. Simplicity — remove unnecessary steps, speculative abstractions, and work that",
|
|
453
|
+
" is out of scope for the stated goal.",
|
|
454
|
+
"",
|
|
455
|
+
"Rules:",
|
|
456
|
+
"",
|
|
457
|
+
"- You may READ anything: files, git history, docs, tests, prior plans.",
|
|
458
|
+
"- You may NOT write code or modify any file. The ONLY write you may perform is",
|
|
459
|
+
" submitting the revised plan through `review_submit_plan`.",
|
|
460
|
+
"- Never invent files, APIs, or conventions. Verify with read/grep/find/ls first.",
|
|
461
|
+
"- If the plan is already sound, say so and submit it unchanged rather than",
|
|
462
|
+
" inventing findings to look useful.",
|
|
463
|
+
"- Preserve the original plan's structure and the author's intent. Adjust; do not",
|
|
464
|
+
" replace it with your own different plan.",
|
|
465
|
+
"- Keep the plan's existing heading style so its steps remain machine-extractable.",
|
|
466
|
+
].join("\n");
|
|
467
|
+
|
|
468
|
+
export interface ReviewPromptPaths {
|
|
469
|
+
global: string;
|
|
470
|
+
project: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function reviewPromptPaths(
|
|
474
|
+
cwd: string,
|
|
475
|
+
agentDir: string,
|
|
476
|
+
): ReviewPromptPaths {
|
|
477
|
+
return {
|
|
478
|
+
global: path.join(agentDir, "review-prompt.md"),
|
|
479
|
+
project: path.join(cwd, WORKFLOW_CONFIG_DIR, "review-prompt.md"),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function readTextSafe(fp: string): string | undefined {
|
|
484
|
+
try {
|
|
485
|
+
if (!fs.existsSync(fp)) return undefined;
|
|
486
|
+
const text = fs.readFileSync(fp, "utf8");
|
|
487
|
+
return text.trim() ? text : undefined;
|
|
488
|
+
} catch {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export type ReviewPromptSource = "project" | "global" | "default";
|
|
494
|
+
|
|
495
|
+
export interface ResolvedReviewPrompt {
|
|
496
|
+
text: string;
|
|
497
|
+
source: ReviewPromptSource;
|
|
498
|
+
path?: string;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Resolve the user's reviewer guidance: project file wins over global file
|
|
503
|
+
* wins over the built-in default. Never throws.
|
|
504
|
+
*/
|
|
505
|
+
export function resolveReviewPrompt(
|
|
506
|
+
cwd: string,
|
|
507
|
+
agentDir: string,
|
|
508
|
+
): ResolvedReviewPrompt {
|
|
509
|
+
const paths = reviewPromptPaths(cwd, agentDir);
|
|
510
|
+
const project = readTextSafe(paths.project);
|
|
511
|
+
if (project) {
|
|
512
|
+
return { text: project, source: "project", path: paths.project };
|
|
513
|
+
}
|
|
514
|
+
const global = readTextSafe(paths.global);
|
|
515
|
+
if (global) {
|
|
516
|
+
return { text: global, source: "global", path: paths.global };
|
|
517
|
+
}
|
|
518
|
+
return { text: DEFAULT_REVIEW_PROMPT, source: "default" };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Write user reviewer guidance to the project or global prompt file.
|
|
523
|
+
* Returns the path written, or an error — never throws.
|
|
524
|
+
*/
|
|
525
|
+
export function writeReviewPrompt(
|
|
526
|
+
scope: "global" | "project",
|
|
527
|
+
text: string,
|
|
528
|
+
cwd: string,
|
|
529
|
+
agentDir: string,
|
|
530
|
+
): { path: string; ok: boolean; error?: string } {
|
|
531
|
+
const paths = reviewPromptPaths(cwd, agentDir);
|
|
532
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
533
|
+
try {
|
|
534
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
535
|
+
fs.writeFileSync(fp, text.endsWith("\n") ? text : text + "\n", "utf8");
|
|
536
|
+
return { path: fp, ok: true };
|
|
537
|
+
} catch (e: unknown) {
|
|
538
|
+
return {
|
|
539
|
+
path: fp,
|
|
540
|
+
ok: false,
|
|
541
|
+
error: e instanceof Error ? e.message : String(e),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Step 5: Review Mode state ────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
/** Lifecycle phase of the reviewer session. */
|
|
549
|
+
export type ReviewPhase =
|
|
550
|
+
| "idle"
|
|
551
|
+
| "starting"
|
|
552
|
+
| "exploring"
|
|
553
|
+
| "reviewing"
|
|
554
|
+
| "verifying"
|
|
555
|
+
| "rewriting"
|
|
556
|
+
| "done"
|
|
557
|
+
| "failed"
|
|
558
|
+
| "aborted";
|
|
559
|
+
|
|
560
|
+
/** The reviewer's overall judgement of the plan. */
|
|
561
|
+
export type ReviewVerdict = "approve" | "revise" | "block";
|
|
562
|
+
|
|
563
|
+
/** Where a review transcript line came from. */
|
|
564
|
+
export type ReviewLineKind =
|
|
565
|
+
| "text"
|
|
566
|
+
| "thinking"
|
|
567
|
+
| "tool"
|
|
568
|
+
| "finding"
|
|
569
|
+
| "notice"
|
|
570
|
+
| "status";
|
|
571
|
+
|
|
572
|
+
/** One line in the reviewer pane. Kept small: the pane renders these verbatim. */
|
|
573
|
+
export interface ReviewTranscriptLine {
|
|
574
|
+
kind: ReviewLineKind;
|
|
575
|
+
text: string;
|
|
576
|
+
/** Unix ms. */
|
|
577
|
+
at: number;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The reviewer's structured output for one pass. */
|
|
581
|
+
export interface ReviewPassResult {
|
|
582
|
+
pass: 1 | 2;
|
|
583
|
+
verdict: ReviewVerdict;
|
|
584
|
+
findings: ReviewFinding[];
|
|
585
|
+
/** Free-text notes the reviewer added alongside the structured findings. */
|
|
586
|
+
notes?: string;
|
|
587
|
+
/** Unix ms. */
|
|
588
|
+
at: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Full Review Mode runtime state (persisted on every phase boundary). */
|
|
592
|
+
export interface ReviewState {
|
|
593
|
+
phase: ReviewPhase;
|
|
594
|
+
/** 1-based round within the current user-feedback cycle. */
|
|
595
|
+
round: number;
|
|
596
|
+
/** 1-based pass within the current round. */
|
|
597
|
+
pass: 1 | 2;
|
|
598
|
+
/** Hash of the plan revision under review (see planHash). */
|
|
599
|
+
planHash: string;
|
|
600
|
+
/** Plan path being reviewed/rewritten. */
|
|
601
|
+
planPath?: string;
|
|
602
|
+
/** `provider/id` of the reviewer model, for display + provenance. */
|
|
603
|
+
modelLabel: string;
|
|
604
|
+
findings: ReviewFinding[];
|
|
605
|
+
verdict?: ReviewVerdict;
|
|
606
|
+
/** The rewritten plan, once submitted. */
|
|
607
|
+
rewrittenPlan?: string;
|
|
608
|
+
/** Reviewer passes completed in this round. */
|
|
609
|
+
passes: ReviewPassResult[];
|
|
610
|
+
/** Number of `review_explore` subagents used this round. */
|
|
611
|
+
exploreUsed: number;
|
|
612
|
+
/** Input tokens reported by the child session, when available. */
|
|
613
|
+
tokenEstimate?: number;
|
|
614
|
+
startedAt: number;
|
|
615
|
+
finishedAt?: number;
|
|
616
|
+
error?: string;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A fresh state object for a new round. */
|
|
620
|
+
export function createReviewState(init: {
|
|
621
|
+
planHash: string;
|
|
622
|
+
planPath?: string;
|
|
623
|
+
modelLabel: string;
|
|
624
|
+
round?: number;
|
|
625
|
+
now?: number;
|
|
626
|
+
}): ReviewState {
|
|
627
|
+
const now = init.now ?? Date.now();
|
|
628
|
+
return {
|
|
629
|
+
phase: "idle",
|
|
630
|
+
round: Math.max(1, Math.trunc(init.round ?? 1)),
|
|
631
|
+
pass: 1,
|
|
632
|
+
planHash: init.planHash,
|
|
633
|
+
planPath: init.planPath,
|
|
634
|
+
modelLabel: init.modelLabel,
|
|
635
|
+
findings: [],
|
|
636
|
+
passes: [],
|
|
637
|
+
exploreUsed: 0,
|
|
638
|
+
startedAt: now,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Elapsed wall-clock for the current round, in ms. */
|
|
643
|
+
export function reviewElapsedMs(
|
|
644
|
+
state: ReviewState,
|
|
645
|
+
now: number = Date.now(),
|
|
646
|
+
): number {
|
|
647
|
+
const end = state.finishedAt ?? now;
|
|
648
|
+
return Math.max(0, end - state.startedAt);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Short human label for a phase, used by the status widget and `/review-status`. */
|
|
652
|
+
export function reviewPhaseLabel(phase: ReviewPhase): string {
|
|
653
|
+
switch (phase) {
|
|
654
|
+
case "idle":
|
|
655
|
+
return "idle";
|
|
656
|
+
case "starting":
|
|
657
|
+
return "starting";
|
|
658
|
+
case "exploring":
|
|
659
|
+
return "exploring";
|
|
660
|
+
case "reviewing":
|
|
661
|
+
return "reviewing";
|
|
662
|
+
case "verifying":
|
|
663
|
+
return "verifying";
|
|
664
|
+
case "rewriting":
|
|
665
|
+
return "rewriting";
|
|
666
|
+
case "done":
|
|
667
|
+
return "done";
|
|
668
|
+
case "failed":
|
|
669
|
+
return "failed";
|
|
670
|
+
case "aborted":
|
|
671
|
+
return "aborted";
|
|
672
|
+
default:
|
|
673
|
+
return "idle";
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** True while the reviewer is actively working (used to gate live review UI). */
|
|
678
|
+
export function reviewIsActive(phase: ReviewPhase): boolean {
|
|
679
|
+
return (
|
|
680
|
+
phase === "starting" ||
|
|
681
|
+
phase === "exploring" ||
|
|
682
|
+
phase === "reviewing" ||
|
|
683
|
+
phase === "verifying" ||
|
|
684
|
+
phase === "rewriting"
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Decide where a typed prompt goes while Review Mode may be running.
|
|
690
|
+
*
|
|
691
|
+
* Input follows the ACTIVE mode, not a focused pane: only a live review round
|
|
692
|
+
* pulls the prompt to the reviewer, so a plan sitting in Plannotator (or any
|
|
693
|
+
* non-review moment) still sends messages to the plan agent. A reviewer session
|
|
694
|
+
* must also exist — otherwise the message would vanish into a session that
|
|
695
|
+
* cannot answer it. The caller still falls back to the plan agent when delivery
|
|
696
|
+
* fails, so a message is never swallowed.
|
|
697
|
+
*/
|
|
698
|
+
export function shouldRouteInputToReviewer(input: {
|
|
699
|
+
reviewEnabled: boolean;
|
|
700
|
+
reviewActive: boolean;
|
|
701
|
+
hasReviewerSession: boolean;
|
|
702
|
+
}): boolean {
|
|
703
|
+
return (
|
|
704
|
+
input.reviewEnabled === true &&
|
|
705
|
+
input.reviewActive === true &&
|
|
706
|
+
input.hasReviewerSession === true
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* One-line summary for the status strip / notify calls.
|
|
712
|
+
* Example: `🔍 review · pass 2/2 · 3 findings · ● verifying`
|
|
713
|
+
*/
|
|
714
|
+
export function reviewStateSummary(state: ReviewState | undefined): string {
|
|
715
|
+
if (!state) return "";
|
|
716
|
+
const bits: string[] = [];
|
|
717
|
+
bits.push(`pass ${state.pass}/2`);
|
|
718
|
+
if (state.round > 1) bits.push(`round ${state.round}`);
|
|
719
|
+
const n = state.findings.length;
|
|
720
|
+
bits.push(`${n} finding${n === 1 ? "" : "s"}`);
|
|
721
|
+
if (reviewIsActive(state.phase))
|
|
722
|
+
bits.push(`● ${reviewPhaseLabel(state.phase)}`);
|
|
723
|
+
else if (state.phase === "done") bits.push("✓ done");
|
|
724
|
+
else if (state.phase !== "idle") bits.push(reviewPhaseLabel(state.phase));
|
|
725
|
+
return `🔍 review · ${bits.join(" · ")}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Outcome of one Plan↔Review round, handed back to the handoff orchestrator.
|
|
730
|
+
*
|
|
731
|
+
* `fallback: true` means "the reviewer did not produce a usable rewrite —
|
|
732
|
+
* continue with the author's plan" (see config.fallbackOnError).
|
|
733
|
+
*/
|
|
734
|
+
export interface ReviewRoundResult {
|
|
735
|
+
ok: boolean;
|
|
736
|
+
fallback: boolean;
|
|
737
|
+
/** The plan to hand to the user (reviewed when ok, original otherwise). */
|
|
738
|
+
planText: string;
|
|
739
|
+
planHash: string;
|
|
740
|
+
state: ReviewState;
|
|
741
|
+
reason?: string;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const DISPOSITIONS: readonly ReviewDisposition[] = [
|
|
745
|
+
"accepted",
|
|
746
|
+
"rejected",
|
|
747
|
+
"deferred",
|
|
748
|
+
];
|
|
749
|
+
|
|
750
|
+
function clampNumber(
|
|
751
|
+
value: unknown,
|
|
752
|
+
min: number,
|
|
753
|
+
max: number,
|
|
754
|
+
fallback: number,
|
|
755
|
+
): number {
|
|
756
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
757
|
+
if (!Number.isFinite(n)) return fallback;
|
|
758
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function str(value: unknown, fallback = ""): string {
|
|
762
|
+
return typeof value === "string" ? value : fallback;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export interface SanitizeFindingsOptions {
|
|
766
|
+
/** Cap on findings kept, to bound the changelog and prompt size. */
|
|
767
|
+
max?: number;
|
|
768
|
+
/** How to treat a finding with a missing/unknown disposition. */
|
|
769
|
+
defaultDisposition?: ReviewDisposition;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Coerce the model's submitted findings into well-formed ReviewFinding values.
|
|
774
|
+
*
|
|
775
|
+
* Findings arrive from an LLM, so nothing here is trusted: severities are
|
|
776
|
+
* clamped to 1-10, confidences to 0-100, dispositions restricted to the three
|
|
777
|
+
* known values, and text fields trimmed. Entries that cannot yield a summary
|
|
778
|
+
* are dropped rather than rendered as empty bullets. Never throws.
|
|
779
|
+
*/
|
|
780
|
+
export function sanitizeFindings(
|
|
781
|
+
raw: unknown,
|
|
782
|
+
opts: SanitizeFindingsOptions = {},
|
|
783
|
+
): ReviewFinding[] {
|
|
784
|
+
const max = Number.isFinite(opts.max) ? Math.max(0, Number(opts.max)) : 50;
|
|
785
|
+
const defaultDisposition = opts.defaultDisposition ?? "accepted";
|
|
786
|
+
if (!Array.isArray(raw)) return [];
|
|
787
|
+
const out: ReviewFinding[] = [];
|
|
788
|
+
for (const item of raw) {
|
|
789
|
+
if (!item || typeof item !== "object") continue;
|
|
790
|
+
const f = item as Record<string, unknown>;
|
|
791
|
+
const summary = str(f.summary).replace(/\s+/g, " ").trim();
|
|
792
|
+
if (!summary) continue;
|
|
793
|
+
const disposition = DISPOSITIONS.includes(
|
|
794
|
+
f.disposition as ReviewDisposition,
|
|
795
|
+
)
|
|
796
|
+
? (f.disposition as ReviewDisposition)
|
|
797
|
+
: defaultDisposition;
|
|
798
|
+
const file = str(f.file).trim();
|
|
799
|
+
const lineRange = str(f.lineRange).trim();
|
|
800
|
+
const finding: ReviewFinding = {
|
|
801
|
+
id: str(f.id).trim() || `F${out.length + 1}`,
|
|
802
|
+
severity: clampNumber(f.severity, 1, 10, 5),
|
|
803
|
+
confidence: clampNumber(f.confidence, 0, 100, 60),
|
|
804
|
+
category: str(f.category).trim() || "general",
|
|
805
|
+
summary,
|
|
806
|
+
rationale: str(f.rationale).replace(/\s+/g, " ").trim(),
|
|
807
|
+
disposition,
|
|
808
|
+
};
|
|
809
|
+
if (file) finding.file = file;
|
|
810
|
+
if (lineRange) finding.lineRange = lineRange;
|
|
811
|
+
out.push(finding);
|
|
812
|
+
if (out.length >= max) break;
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── Steps 12-13: apply a submitted plan ──────────────────────────────
|
|
818
|
+
|
|
819
|
+
/** Minimal Pi surface used when publishing a reviewed plan (keeps this testable). */
|
|
820
|
+
export interface ReviewEntrySink {
|
|
821
|
+
appendEntry: (customType: string, data?: unknown) => void;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export interface ApplySubmittedPlanOptions {
|
|
825
|
+
cwd: string;
|
|
826
|
+
/** The plan path Plan Mode chose, absolute or cwd-relative. */
|
|
827
|
+
planPath: string;
|
|
828
|
+
/** planBody + `## Review changes` appendix (what gets written). */
|
|
829
|
+
planText: string;
|
|
830
|
+
planBody: string;
|
|
831
|
+
verdict: ReviewVerdict;
|
|
832
|
+
summary: string;
|
|
833
|
+
findings: ReviewFinding[];
|
|
834
|
+
round: number;
|
|
835
|
+
modelLabel: string;
|
|
836
|
+
pi: ReviewEntrySink;
|
|
837
|
+
notify?: (message: string, level?: "info" | "warning" | "error") => void;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export interface ApplySubmittedPlanResult {
|
|
841
|
+
ok: boolean;
|
|
842
|
+
path: string;
|
|
843
|
+
/** True when the date prefix was corrected to today (UTC). */
|
|
844
|
+
corrected: boolean;
|
|
845
|
+
error?: string;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Steps 12-13: write the reviewed plan over Plan Mode's plan file and record it.
|
|
850
|
+
*
|
|
851
|
+
* Step 12 — the reviewer adjusts the SAME file Plan Mode wrote. It never picks
|
|
852
|
+
* a new slug: `normalizePlanPath` owns the `.pi/plans/<UTC-date>-<slug>.md`
|
|
853
|
+
* convention, so the plan the user reviews in Plannotator is the plan history
|
|
854
|
+
* they already have. A defense-in-depth guard refuses any path outside
|
|
855
|
+
* `.pi/plans/`, so even a compromised reviewer tool call cannot write elsewhere.
|
|
856
|
+
*
|
|
857
|
+
* Step 13 — appends a `workflow-review-submitted` entry (not part of the LLM
|
|
858
|
+
* context) so `/review-status`, resume, and post-hoc inspection can see what was
|
|
859
|
+
* submitted, and notifies the user once.
|
|
860
|
+
*
|
|
861
|
+
* Never throws; failures are returned so the caller can fall back.
|
|
862
|
+
*/
|
|
863
|
+
export function applySubmittedPlan(
|
|
864
|
+
options: ApplySubmittedPlanOptions,
|
|
865
|
+
): ApplySubmittedPlanResult {
|
|
866
|
+
const today = getUtcDatePrefix();
|
|
867
|
+
const normalized = normalizePlanPath(options.planPath, options.cwd, today);
|
|
868
|
+
const relOrAbs = normalized.path;
|
|
869
|
+
|
|
870
|
+
// Defense in depth: Review Mode may only ever write inside .pi/plans/.
|
|
871
|
+
if (!isPlanWritePath(relOrAbs, options.cwd)) {
|
|
872
|
+
return {
|
|
873
|
+
ok: false,
|
|
874
|
+
path: relOrAbs,
|
|
875
|
+
corrected: normalized.corrected,
|
|
876
|
+
error: `refusing to write outside .pi/plans/ (${relOrAbs})`,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const abs = path.isAbsolute(relOrAbs)
|
|
881
|
+
? relOrAbs
|
|
882
|
+
: path.join(options.cwd, relOrAbs);
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
886
|
+
fs.writeFileSync(abs, options.planText, "utf8");
|
|
887
|
+
} catch (e: unknown) {
|
|
888
|
+
return {
|
|
889
|
+
ok: false,
|
|
890
|
+
path: abs,
|
|
891
|
+
corrected: normalized.corrected,
|
|
892
|
+
error: e instanceof Error ? e.message : String(e),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
options.pi.appendEntry(REVIEW_SUBMITTED_ENTRY_TYPE, {
|
|
898
|
+
at: Date.now(),
|
|
899
|
+
planPath: abs,
|
|
900
|
+
round: options.round,
|
|
901
|
+
verdict: options.verdict,
|
|
902
|
+
summary: options.summary,
|
|
903
|
+
modelLabel: options.modelLabel,
|
|
904
|
+
planHash: planHash(options.planBody),
|
|
905
|
+
planChars: options.planText.length,
|
|
906
|
+
findings: options.findings.map((f) => ({
|
|
907
|
+
id: f.id,
|
|
908
|
+
severity: f.severity,
|
|
909
|
+
confidence: f.confidence,
|
|
910
|
+
category: f.category,
|
|
911
|
+
disposition: f.disposition,
|
|
912
|
+
})),
|
|
913
|
+
correctedDatePrefix: normalized.corrected,
|
|
914
|
+
});
|
|
915
|
+
} catch {
|
|
916
|
+
/* the write succeeded; a failed audit entry must not invalidate it */
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
options.notify?.(
|
|
921
|
+
`Review Mode submitted the plan (${options.verdict}, ${options.findings.length} finding${
|
|
922
|
+
options.findings.length === 1 ? "" : "s"
|
|
923
|
+
})`,
|
|
924
|
+
"info",
|
|
925
|
+
);
|
|
926
|
+
} catch {
|
|
927
|
+
/* notifications are best-effort */
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return { ok: true, path: abs, corrected: normalized.corrected };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── Step 14: pass gate ───────────────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Step 14 — the handshake between the reviewer's tool calls and the
|
|
937
|
+
* orchestrator, so pass 1 → pass 2 advances deterministically instead of the
|
|
938
|
+
* orchestrator guessing from prose.
|
|
939
|
+
*
|
|
940
|
+
* Both waits are edge-tolerant: whichever happens first (the tool call or the
|
|
941
|
+
* await) wins, and a wait that starts after the event resolves immediately.
|
|
942
|
+
* That matters because a fast reviewer can call `review_submit_plan` and
|
|
943
|
+
* `review_pass_done` in the same turn, before the orchestrator awaits.
|
|
944
|
+
*
|
|
945
|
+
* Every wait is bounded by an AbortSignal and, when a positive timeout is
|
|
946
|
+
* configured, a wall-clock timer. With the default timeout of 0 (unlimited) a
|
|
947
|
+
* silent reviewer is still caught by the run-settle race in review-runtime.ts,
|
|
948
|
+
* so the handoff cannot hang on a reviewer that simply finished.
|
|
949
|
+
*/
|
|
950
|
+
export interface ReviewRoundGate {
|
|
951
|
+
/** Clear both slots for a new round/pass. */
|
|
952
|
+
reset(): void;
|
|
953
|
+
notifySubmitted(payload: ReviewSubmitPayloadLike): void;
|
|
954
|
+
notifyPassDone(result: ReviewPassResult): void;
|
|
955
|
+
lastSubmitted(): ReviewSubmitPayloadLike | undefined;
|
|
956
|
+
lastPassDone(): ReviewPassResult | undefined;
|
|
957
|
+
waitForSubmit(
|
|
958
|
+
timeoutMs?: number,
|
|
959
|
+
signal?: AbortSignal,
|
|
960
|
+
): Promise<ReviewSubmitPayloadLike | undefined>;
|
|
961
|
+
waitForPassDone(
|
|
962
|
+
timeoutMs?: number,
|
|
963
|
+
signal?: AbortSignal,
|
|
964
|
+
): Promise<ReviewPassResult | undefined>;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The subset of a submission the gate needs. `review-tools.ts` builds the full
|
|
969
|
+
* payload; the gate stays independent of it to avoid a module cycle.
|
|
970
|
+
*/
|
|
971
|
+
export interface ReviewSubmitPayloadLike {
|
|
972
|
+
planBody: string;
|
|
973
|
+
planText: string;
|
|
974
|
+
findings: ReviewFinding[];
|
|
975
|
+
verdict: ReviewVerdict;
|
|
976
|
+
summary: string;
|
|
977
|
+
changelog: string;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function deferredWait<T>(
|
|
981
|
+
current: () => T | undefined,
|
|
982
|
+
register: (resolve: (value: T | undefined) => void) => void,
|
|
983
|
+
timeoutMs?: number,
|
|
984
|
+
signal?: AbortSignal,
|
|
985
|
+
): Promise<T | undefined> {
|
|
986
|
+
const existing = current();
|
|
987
|
+
if (existing !== undefined) return Promise.resolve(existing);
|
|
988
|
+
return new Promise<T | undefined>((resolve) => {
|
|
989
|
+
let settled = false;
|
|
990
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
991
|
+
const finish = (value: T | undefined) => {
|
|
992
|
+
if (settled) return;
|
|
993
|
+
settled = true;
|
|
994
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
995
|
+
signal?.removeEventListener("abort", onAbort);
|
|
996
|
+
resolve(value);
|
|
997
|
+
};
|
|
998
|
+
// No timer when the cap is disabled (0 / undefined) — the caller's own
|
|
999
|
+
// settle race and AbortSignal are then the only ways out.
|
|
1000
|
+
if (
|
|
1001
|
+
typeof timeoutMs === "number" &&
|
|
1002
|
+
Number.isFinite(timeoutMs) &&
|
|
1003
|
+
timeoutMs > 0
|
|
1004
|
+
) {
|
|
1005
|
+
timer = setTimeout(() => finish(undefined), Math.trunc(timeoutMs));
|
|
1006
|
+
}
|
|
1007
|
+
const onAbort = () => finish(undefined);
|
|
1008
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1009
|
+
register(finish);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export function createReviewRoundGate(): ReviewRoundGate {
|
|
1014
|
+
let submitted: ReviewSubmitPayloadLike | undefined;
|
|
1015
|
+
let passDone: ReviewPassResult | undefined;
|
|
1016
|
+
let submitWaiters: Array<
|
|
1017
|
+
(value: ReviewSubmitPayloadLike | undefined) => void
|
|
1018
|
+
> = [];
|
|
1019
|
+
let passWaiters: Array<(value: ReviewPassResult | undefined) => void> = [];
|
|
1020
|
+
|
|
1021
|
+
const settleWaiters = <T>(
|
|
1022
|
+
waiters: Array<(value: T | undefined) => void>,
|
|
1023
|
+
value: T,
|
|
1024
|
+
): void => {
|
|
1025
|
+
for (const resolve of waiters.splice(0)) {
|
|
1026
|
+
try {
|
|
1027
|
+
resolve(value);
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* ignore */
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
reset() {
|
|
1036
|
+
submitted = undefined;
|
|
1037
|
+
passDone = undefined;
|
|
1038
|
+
submitWaiters = [];
|
|
1039
|
+
passWaiters = [];
|
|
1040
|
+
},
|
|
1041
|
+
notifySubmitted(payload) {
|
|
1042
|
+
submitted = payload;
|
|
1043
|
+
settleWaiters(submitWaiters, payload);
|
|
1044
|
+
},
|
|
1045
|
+
notifyPassDone(result) {
|
|
1046
|
+
passDone = result;
|
|
1047
|
+
settleWaiters(passWaiters, result);
|
|
1048
|
+
},
|
|
1049
|
+
lastSubmitted: () => submitted,
|
|
1050
|
+
lastPassDone: () => passDone,
|
|
1051
|
+
waitForSubmit: (timeoutMs, signal) =>
|
|
1052
|
+
deferredWait<ReviewSubmitPayloadLike>(
|
|
1053
|
+
() => submitted,
|
|
1054
|
+
(resolve) => submitWaiters.push(resolve),
|
|
1055
|
+
timeoutMs,
|
|
1056
|
+
signal,
|
|
1057
|
+
),
|
|
1058
|
+
waitForPassDone: (timeoutMs, signal) =>
|
|
1059
|
+
deferredWait<ReviewPassResult>(
|
|
1060
|
+
() => passDone,
|
|
1061
|
+
(resolve) => passWaiters.push(resolve),
|
|
1062
|
+
timeoutMs,
|
|
1063
|
+
signal,
|
|
1064
|
+
),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ── Step 16: reviewer transcript stream ──────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
/** Default cap on retained pane lines (the panes are a rolling window). */
|
|
1071
|
+
export const MAX_REVIEW_LINES = 2000;
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* A transcript mutation derived from a session event.
|
|
1075
|
+
*
|
|
1076
|
+
* Deltas are modelled as `append` rather than one line per token: the reviewer
|
|
1077
|
+
* streams hundreds of text deltas per message, and pushing each as its own line
|
|
1078
|
+
* would make the pane unreadable and the buffer useless. Coalescing is decided
|
|
1079
|
+
* here (pure) so the pane and the buffer cannot disagree.
|
|
1080
|
+
*/
|
|
1081
|
+
export type ReviewTranscriptOp =
|
|
1082
|
+
| { op: "append"; kind: ReviewLineKind; text: string }
|
|
1083
|
+
| { op: "new"; kind: ReviewLineKind; text: string };
|
|
1084
|
+
|
|
1085
|
+
function eventRecord(event: unknown): Record<string, unknown> | undefined {
|
|
1086
|
+
if (!event || typeof event !== "object") return undefined;
|
|
1087
|
+
return event as Record<string, unknown>;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Longest rendered action label before it is elided. */
|
|
1091
|
+
export const MAX_ACTION_LABEL = 100;
|
|
1092
|
+
|
|
1093
|
+
function firstLine(s: string): string {
|
|
1094
|
+
const i = s.indexOf("\n");
|
|
1095
|
+
return i >= 0 ? s.slice(0, i) : s;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function truncateLabel(s: string, max: number): string {
|
|
1099
|
+
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Human-readable header for a tool call: the tool name plus a short, safe
|
|
1104
|
+
* excerpt of its key argument (`▸ read src/app.ts`, `▸ review_bash: git log -5`).
|
|
1105
|
+
* Never includes large payloads (e.g. the submitted plan), and never throws.
|
|
1106
|
+
*/
|
|
1107
|
+
export function formatActionLabel(toolName: unknown, args: unknown): string {
|
|
1108
|
+
const name =
|
|
1109
|
+
typeof toolName === "string" && toolName.trim()
|
|
1110
|
+
? toolName.trim()
|
|
1111
|
+
: "tool";
|
|
1112
|
+
const a: Record<string, unknown> =
|
|
1113
|
+
args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
1114
|
+
const str = (v: unknown): string | undefined =>
|
|
1115
|
+
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
1116
|
+
|
|
1117
|
+
let detail: string | undefined;
|
|
1118
|
+
if (name === "review_bash" || name === "bash") {
|
|
1119
|
+
const cmd = str(a.command);
|
|
1120
|
+
if (cmd) detail = firstLine(cmd);
|
|
1121
|
+
} else if (name === "read" || name === "write" || name === "edit") {
|
|
1122
|
+
detail = str(a.path) ?? str(a.file_path);
|
|
1123
|
+
} else if (name === "grep" || name === "find") {
|
|
1124
|
+
const pattern = str(a.pattern) ?? str(a.query);
|
|
1125
|
+
const where = str(a.path) ?? str(a.glob);
|
|
1126
|
+
detail = pattern ? (where ? `${pattern} (${where})` : pattern) : where;
|
|
1127
|
+
} else if (name === "ls") {
|
|
1128
|
+
detail = str(a.path);
|
|
1129
|
+
} else if (name === "review_explore") {
|
|
1130
|
+
const tasks = Array.isArray(a.tasks) ? a.tasks : [];
|
|
1131
|
+
if (tasks.length > 0) {
|
|
1132
|
+
const first = str((tasks[0] as Record<string, unknown>)?.task);
|
|
1133
|
+
detail = `${tasks.length} task${tasks.length === 1 ? "" : "s"}${
|
|
1134
|
+
first ? ` — ${first}` : ""
|
|
1135
|
+
}`;
|
|
1136
|
+
}
|
|
1137
|
+
} else if (name === "review_submit_plan") {
|
|
1138
|
+
const verdict = str(a.verdict) ?? "revise";
|
|
1139
|
+
const findings = Array.isArray(a.findings) ? a.findings.length : 0;
|
|
1140
|
+
detail = `verdict ${verdict} · ${findings} finding${
|
|
1141
|
+
findings === 1 ? "" : "s"
|
|
1142
|
+
}`;
|
|
1143
|
+
} else if (name === "review_pass_done") {
|
|
1144
|
+
const pass = a.pass === 2 ? 2 : 1;
|
|
1145
|
+
detail = `pass ${pass} · verdict ${str(a.verdict) ?? "revise"}`;
|
|
1146
|
+
} else {
|
|
1147
|
+
// Unknown tool: fall back to the first short string argument, if any.
|
|
1148
|
+
for (const v of Object.values(a)) {
|
|
1149
|
+
const s = str(v);
|
|
1150
|
+
if (s) {
|
|
1151
|
+
detail = firstLine(s);
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const label = detail ? `${name}: ${detail}` : name;
|
|
1158
|
+
return truncateLabel(label, MAX_ACTION_LABEL);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Translate one child-session event into transcript operations.
|
|
1163
|
+
*
|
|
1164
|
+
* Only the event kinds that make a readable review transcript are surfaced.
|
|
1165
|
+
* Assistant prose (`text_delta`) and reasoning (`thinking_delta`) are
|
|
1166
|
+
* deliberately dropped: the main transcript shows the header of each action
|
|
1167
|
+
* plus the REVIEW SUMMARY card, not a token stream (see review-ui.ts). Tool
|
|
1168
|
+
* accounting, queue churn and compaction internals are dropped too.
|
|
1169
|
+
*
|
|
1170
|
+
* `toolLabels` is owned by the stateful caller (`ReviewTranscript`): the end
|
|
1171
|
+
* event carries no args, so the start event registers `toolCallId → label` and
|
|
1172
|
+
* the end event consumes it. Pure otherwise; junk input never throws.
|
|
1173
|
+
*/
|
|
1174
|
+
export function translateReviewEvent(
|
|
1175
|
+
event: unknown,
|
|
1176
|
+
toolLabels?: Map<string, string>,
|
|
1177
|
+
): ReviewTranscriptOp[] {
|
|
1178
|
+
const e = eventRecord(event);
|
|
1179
|
+
if (!e) return [];
|
|
1180
|
+
const type = typeof e.type === "string" ? e.type : "";
|
|
1181
|
+
|
|
1182
|
+
if (type === "message_update") return [];
|
|
1183
|
+
|
|
1184
|
+
if (type === "tool_execution_start") {
|
|
1185
|
+
const name = typeof e.toolName === "string" ? e.toolName : "tool";
|
|
1186
|
+
const label = formatActionLabel(name, e.args);
|
|
1187
|
+
const id = typeof e.toolCallId === "string" ? e.toolCallId : "";
|
|
1188
|
+
if (id && toolLabels) toolLabels.set(id, label);
|
|
1189
|
+
return [{ op: "new", kind: "tool", text: `▸ ${label}` }];
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (type === "tool_execution_end") {
|
|
1193
|
+
const name = typeof e.toolName === "string" ? e.toolName : "tool";
|
|
1194
|
+
const id = typeof e.toolCallId === "string" ? e.toolCallId : "";
|
|
1195
|
+
const known = id && toolLabels ? toolLabels.get(id) : undefined;
|
|
1196
|
+
if (id && toolLabels) toolLabels.delete(id);
|
|
1197
|
+
const failed = e.isError === true;
|
|
1198
|
+
return [
|
|
1199
|
+
{
|
|
1200
|
+
op: "new",
|
|
1201
|
+
kind: "tool",
|
|
1202
|
+
text: `▸ ${known ?? name} ${failed ? "failed" : "ok"}`,
|
|
1203
|
+
},
|
|
1204
|
+
];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (type === "agent_start") {
|
|
1208
|
+
return [{ op: "new", kind: "status", text: "reviewer started" }];
|
|
1209
|
+
}
|
|
1210
|
+
if (type === "agent_end") {
|
|
1211
|
+
return [{ op: "new", kind: "status", text: "reviewer finished this run" }];
|
|
1212
|
+
}
|
|
1213
|
+
if (type === "auto_retry_start") {
|
|
1214
|
+
return [
|
|
1215
|
+
{ op: "new", kind: "notice", text: "retrying after a provider error" },
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return [];
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Bounded rolling buffer of pane lines.
|
|
1224
|
+
*
|
|
1225
|
+
* One buffer serves both the child transcript (right pane) and, via the same
|
|
1226
|
+
* API, the mirrored Plan Mode transcript (left pane). Bounding matters: a long
|
|
1227
|
+
* review can emit tens of thousands of deltas, and the pane must not grow
|
|
1228
|
+
* without limit for the life of the session.
|
|
1229
|
+
*/
|
|
1230
|
+
export class ReviewTranscript {
|
|
1231
|
+
private lines: ReviewTranscriptLine[] = [];
|
|
1232
|
+
/** `toolCallId → label` so the end event can repeat the start's header. */
|
|
1233
|
+
private readonly toolLabels = new Map<string, string>();
|
|
1234
|
+
/**
|
|
1235
|
+
* Assigned in the body rather than declared as a constructor parameter
|
|
1236
|
+
* property: `npm test` runs `node --test` on this file directly, and Node's
|
|
1237
|
+
* type-stripping mode rejects parameter properties (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
|
|
1238
|
+
*/
|
|
1239
|
+
private readonly max: number;
|
|
1240
|
+
|
|
1241
|
+
constructor(max: number = MAX_REVIEW_LINES) {
|
|
1242
|
+
this.max = max;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Apply one op, coalescing with the tail when the kind matches. */
|
|
1246
|
+
apply(op: ReviewTranscriptOp, now: number = Date.now()): void {
|
|
1247
|
+
if (op.op === "append") {
|
|
1248
|
+
const last = this.lines[this.lines.length - 1];
|
|
1249
|
+
if (last && last.kind === op.kind) {
|
|
1250
|
+
last.text += op.text;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
// Nothing to append to: start a line instead of losing the text.
|
|
1254
|
+
this.lines.push({ kind: op.kind, text: op.text, at: now });
|
|
1255
|
+
this.trim();
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
this.lines.push({ kind: op.kind, text: op.text, at: now });
|
|
1259
|
+
this.trim();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Translate and apply a raw session event. Returns the applied ops. */
|
|
1263
|
+
ingest(event: unknown, now: number = Date.now()): ReviewTranscriptOp[] {
|
|
1264
|
+
const ops = translateReviewEvent(event, this.toolLabels);
|
|
1265
|
+
for (const op of ops) this.apply(op, now);
|
|
1266
|
+
return ops;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
private trim(): void {
|
|
1270
|
+
const max = Math.max(1, Math.trunc(this.max));
|
|
1271
|
+
if (this.lines.length > max) {
|
|
1272
|
+
this.lines.splice(0, this.lines.length - max);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Snapshot for rendering. */
|
|
1277
|
+
toLines(): ReviewTranscriptLine[] {
|
|
1278
|
+
return this.lines.slice();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Flatten to plain strings for the pane. */
|
|
1282
|
+
toDisplayLines(): string[] {
|
|
1283
|
+
const out: string[] = [];
|
|
1284
|
+
for (const l of this.lines) {
|
|
1285
|
+
if (l.kind === "text" || l.kind === "thinking") {
|
|
1286
|
+
for (const part of l.text.split("\n")) out.push(part);
|
|
1287
|
+
} else {
|
|
1288
|
+
out.push(l.text);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return out;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
get size(): number {
|
|
1295
|
+
return this.lines.length;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
clear(): void {
|
|
1299
|
+
this.lines = [];
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* The reviewer's system prompt. The user's guidance (review-prompt.md) is
|
|
1305
|
+
* APPENDED to this, and the whole thing lives only in the child session —
|
|
1306
|
+
* Plan Mode never sees it (requirement: Plan cannot see Review's context).
|
|
1307
|
+
*/
|
|
1308
|
+
export const REVIEW_SYSTEM_PROMPT = [
|
|
1309
|
+
"You are REVIEW MODE: an independent auditor that supervises another agent's plan.",
|
|
1310
|
+
"",
|
|
1311
|
+
"You run in your own session with your own context and your own model. The plan",
|
|
1312
|
+
"you are auditing was produced by a different agent (Plan Mode). Your value comes",
|
|
1313
|
+
"from a genuinely independent perspective, not from agreeing with it.",
|
|
1314
|
+
"",
|
|
1315
|
+
"You are READ-ONLY. You cannot edit files, run mutating shell commands, install",
|
|
1316
|
+
"anything, or build. You may run test/lint/typecheck commands. Your single write",
|
|
1317
|
+
"action is submitting the revised plan through the `review_submit_plan` tool.",
|
|
1318
|
+
"",
|
|
1319
|
+
"## Tools",
|
|
1320
|
+
"",
|
|
1321
|
+
"- `read`, `grep`, `find`, `ls` — inspect the repository freely. Always verify a",
|
|
1322
|
+
" claim about the codebase with these before you rely on it.",
|
|
1323
|
+
"- `review_bash` — a READ-ONLY/VERIFY shell on the same role allowlist Plan Mode",
|
|
1324
|
+
" uses (ls, cat, rg, fd, jq, diff, stat, git status/log/diff/show/branch, npm",
|
|
1325
|
+
" test, npm run lint/typecheck, eslint, tsc --noEmit, node --test, pytest, cargo",
|
|
1326
|
+
" clippy, ...). Mutating commands, redirects, installs and builds are refused.",
|
|
1327
|
+
" Verify a plan's claims by reading the code and, where useful, by running its",
|
|
1328
|
+
" tests or linters — but never assert that something passes unless you ran it.",
|
|
1329
|
+
"- `review_explore` — delegate focused read-only reconnaissance to subagents.",
|
|
1330
|
+
"- `review_submit_plan` — submit the final rewritten plan. This is the ONLY way",
|
|
1331
|
+
" your work reaches the user. A review that never submits is a failed review.",
|
|
1332
|
+
"- `review_pass_done` — end the current pass with a verdict and your findings.",
|
|
1333
|
+
"",
|
|
1334
|
+
"## Protocol",
|
|
1335
|
+
"",
|
|
1336
|
+
"1. Read the shared context first. It contains Plan Mode's exploration, the user's",
|
|
1337
|
+
" requirements, and the plan itself.",
|
|
1338
|
+
"2. Verify the plan against the real repository. Do not trust the plan's own claims",
|
|
1339
|
+
" about what exists.",
|
|
1340
|
+
"3. Record every issue as a finding: severity 1-10, confidence 0-100, a category, a",
|
|
1341
|
+
" file/line anchor when you have one, a one-line summary, and a rationale.",
|
|
1342
|
+
"4. Rewrite the plan so it is correct and framework-aligned, then submit it with",
|
|
1343
|
+
" `review_submit_plan`.",
|
|
1344
|
+
"5. End the pass with `review_pass_done`.",
|
|
1345
|
+
"",
|
|
1346
|
+
"You may run test/lint/typecheck commands, but not builds. Never assert that",
|
|
1347
|
+
"something 'passes' unless you actually ran it; otherwise state what the plan",
|
|
1348
|
+
"should verify and how, and flag unverifiable claims.",
|
|
1349
|
+
"",
|
|
1350
|
+
"## Restraint",
|
|
1351
|
+
"",
|
|
1352
|
+
"- If the plan is sound, submit it unchanged and say so. Never invent findings to",
|
|
1353
|
+
" appear useful — a false finding costs the user more than a missed nit.",
|
|
1354
|
+
"- Adjust the plan; do not replace it with a different plan of your own. Preserve",
|
|
1355
|
+
" the author's structure, step numbering, and heading style so the plan stays",
|
|
1356
|
+
" machine-extractable.",
|
|
1357
|
+
"- Never invent files, symbols, APIs, or conventions. If you cannot verify",
|
|
1358
|
+
" something, say so in the finding rather than asserting it.",
|
|
1359
|
+
].join("\n");
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Type-only handle on the Pi SDK. This is erased at compile time, so importing
|
|
1363
|
+
* this module in a plain Node test does not load Pi.
|
|
1364
|
+
*/
|
|
1365
|
+
export type PiCodingAgentSdk = typeof import("@earendil-works/pi-coding-agent");
|
|
1366
|
+
|
|
1367
|
+
export type ReviewSdkLoader = () => Promise<PiCodingAgentSdk>;
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Built-in tool names the reviewer may call. Read-only by construction.
|
|
1371
|
+
*
|
|
1372
|
+
* This is a BASE, not the whole tool set: Pi's `createAgentSession({ tools })`
|
|
1373
|
+
* option is a GLOBAL allowlist applied to built-in AND custom tools alike
|
|
1374
|
+
* (`dist/core/sdk.js`: `allowedToolNames = options.tools ?? ...`, then
|
|
1375
|
+
* `dist/core/agent-session.js`: `allCustomTools = [...].filter(tool =>
|
|
1376
|
+
* isAllowedTool(tool.definition.name))`). Passing only these names silently
|
|
1377
|
+
* drops `review_submit_plan`, i.e. the reviewer loses its only write path.
|
|
1378
|
+
* Always build the session's allowlist with `reviewToolAllowlist()`.
|
|
1379
|
+
*/
|
|
1380
|
+
export const REVIEW_BUILTIN_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* The exact `tools` allowlist the reviewer child session must be created with:
|
|
1384
|
+
* the read-only built-ins PLUS every custom tool the caller registered.
|
|
1385
|
+
*
|
|
1386
|
+
* Load-bearing, not cosmetic — see `REVIEW_BUILTIN_TOOLS`. A custom tool whose
|
|
1387
|
+
* name is missing from this list is filtered out of the child's tool registry
|
|
1388
|
+
* before the model can ever see it, which is how the reviewer ends up unable to
|
|
1389
|
+
* submit a plan while still looking "started".
|
|
1390
|
+
*
|
|
1391
|
+
* Pure and total: tolerant of junk entries and duplicate names.
|
|
1392
|
+
*/
|
|
1393
|
+
export function reviewToolAllowlist(customTools: unknown): string[] {
|
|
1394
|
+
const names = new Set<string>(REVIEW_BUILTIN_TOOLS);
|
|
1395
|
+
if (Array.isArray(customTools)) {
|
|
1396
|
+
for (const tool of customTools) {
|
|
1397
|
+
const name = (tool as { name?: unknown } | undefined)?.name;
|
|
1398
|
+
if (typeof name === "string" && name.trim()) names.add(name.trim());
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return [...names];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* A live reviewer session, narrowed to what Review Mode actually uses.
|
|
1406
|
+
* Keeps the orchestrator free of SDK types.
|
|
1407
|
+
*/
|
|
1408
|
+
export interface ReviewChildSession {
|
|
1409
|
+
readonly sessionId: string;
|
|
1410
|
+
readonly modelLabel: string;
|
|
1411
|
+
/** Subscribe to the session's event stream. Returns an unsubscribe function. */
|
|
1412
|
+
subscribe(listener: (event: unknown) => void): () => void;
|
|
1413
|
+
/** Send a prompt. Queues as a steer when the session is already streaming. */
|
|
1414
|
+
prompt(text: string): Promise<void>;
|
|
1415
|
+
/** Send a prompt that is delivered only after the current run stops. */
|
|
1416
|
+
followUp(text: string): Promise<void>;
|
|
1417
|
+
/** Cancel the in-flight run. Never throws. */
|
|
1418
|
+
abort(): Promise<void>;
|
|
1419
|
+
isStreaming(): boolean;
|
|
1420
|
+
/** Unsubscribe and release. Never throws, safe to call twice. */
|
|
1421
|
+
dispose(): void;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
export interface CreateReviewSessionOptions {
|
|
1425
|
+
cwd: string;
|
|
1426
|
+
/** Global Pi config dir (usually ~/.pi/agent). */
|
|
1427
|
+
agentDir: string;
|
|
1428
|
+
/** Reviewer role model. */
|
|
1429
|
+
modelRef: { provider: string; id: string; thinking: string };
|
|
1430
|
+
/** Resolve a model from the parent's registry by provider/id. */
|
|
1431
|
+
findModel: (provider: string, id: string) => unknown;
|
|
1432
|
+
/**
|
|
1433
|
+
* Resolved user reviewer guidance, appended to REVIEW_SYSTEM_PROMPT.
|
|
1434
|
+
*
|
|
1435
|
+
* Note: the pruned Plan Mode history is NOT passed to the session manager.
|
|
1436
|
+
* The 3-argument `SessionManager.inMemory(cwd, opts, entries)` form only
|
|
1437
|
+
* exists from Pi 0.85.1, and this package declares
|
|
1438
|
+
* `peerDependencies: { "@earendil-works/pi-coding-agent": "*" }`. A 0.84.x
|
|
1439
|
+
* runtime would silently ignore those entries and hand the reviewer an empty
|
|
1440
|
+
* context, so the caller renders the history into the first prompt with
|
|
1441
|
+
* `renderReviewContextBlock` instead.
|
|
1442
|
+
*/
|
|
1443
|
+
reviewPrompt: string;
|
|
1444
|
+
/** Reviewer-only tools, built by the caller. */
|
|
1445
|
+
customTools: unknown[];
|
|
1446
|
+
/** Injectable for tests; defaults to a real dynamic import. */
|
|
1447
|
+
loadSdk?: ReviewSdkLoader;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
export interface CreateReviewSessionResult {
|
|
1451
|
+
ok: boolean;
|
|
1452
|
+
session?: ReviewChildSession;
|
|
1453
|
+
modelLabel: string;
|
|
1454
|
+
/** Tool names the child actually has enabled, when the SDK exposes them. */
|
|
1455
|
+
activeTools?: string[];
|
|
1456
|
+
error?: string;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Environment marker set only for the duration of child-session creation.
|
|
1461
|
+
*
|
|
1462
|
+
* Belt-and-braces anti-recursion: the child is created with
|
|
1463
|
+
* `noExtensions: true`, so it cannot load this extension again. The marker is
|
|
1464
|
+
* set and immediately restored anyway, because the child runs in the SAME
|
|
1465
|
+
* process as the parent — leaving it set would leak into a later `/reload`.
|
|
1466
|
+
*/
|
|
1467
|
+
export const REVIEW_CHILD_MARKER_ENV = "PI_WORKFLOW_REVIEW_CHILD";
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Read the session's enabled tool names, when the SDK exposes them.
|
|
1471
|
+
* Returns `undefined` (not `[]`) when the accessor is missing OR throws, so a
|
|
1472
|
+
* caller can tell "cannot verify" apart from a verifiably empty tool set.
|
|
1473
|
+
*/
|
|
1474
|
+
function readActiveToolNames(session: unknown): string[] | undefined {
|
|
1475
|
+
try {
|
|
1476
|
+
const get = (session as { getActiveToolNames?: unknown } | undefined)
|
|
1477
|
+
?.getActiveToolNames;
|
|
1478
|
+
if (typeof get !== "function") return undefined;
|
|
1479
|
+
const names = (get as () => unknown).call(session);
|
|
1480
|
+
if (!Array.isArray(names)) return undefined;
|
|
1481
|
+
return names.filter((n): n is string => typeof n === "string");
|
|
1482
|
+
} catch {
|
|
1483
|
+
return undefined;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function wrapAgentSession(
|
|
1488
|
+
raw: unknown,
|
|
1489
|
+
modelLabel: string,
|
|
1490
|
+
sessionId: string,
|
|
1491
|
+
): ReviewChildSession {
|
|
1492
|
+
const s = raw as {
|
|
1493
|
+
subscribe: (l: (e: unknown) => void) => () => void;
|
|
1494
|
+
prompt: (
|
|
1495
|
+
t: string,
|
|
1496
|
+
o?: { streamingBehavior?: "steer" | "followUp" },
|
|
1497
|
+
) => Promise<void>;
|
|
1498
|
+
followUp: (t: string) => Promise<void>;
|
|
1499
|
+
abort: () => Promise<void>;
|
|
1500
|
+
isStreaming: boolean;
|
|
1501
|
+
};
|
|
1502
|
+
let disposed = false;
|
|
1503
|
+
const unsubscribers: Array<() => void> = [];
|
|
1504
|
+
return {
|
|
1505
|
+
sessionId,
|
|
1506
|
+
modelLabel,
|
|
1507
|
+
subscribe(listener) {
|
|
1508
|
+
const un = s.subscribe(listener);
|
|
1509
|
+
if (disposed) {
|
|
1510
|
+
try {
|
|
1511
|
+
un();
|
|
1512
|
+
} catch {
|
|
1513
|
+
/* ignore */
|
|
1514
|
+
}
|
|
1515
|
+
return () => {};
|
|
1516
|
+
}
|
|
1517
|
+
unsubscribers.push(un);
|
|
1518
|
+
return un;
|
|
1519
|
+
},
|
|
1520
|
+
async prompt(text) {
|
|
1521
|
+
if (disposed) return;
|
|
1522
|
+
if (s.isStreaming) await s.prompt(text, { streamingBehavior: "steer" });
|
|
1523
|
+
else await s.prompt(text);
|
|
1524
|
+
},
|
|
1525
|
+
async followUp(text) {
|
|
1526
|
+
if (disposed) return;
|
|
1527
|
+
await s.followUp(text);
|
|
1528
|
+
},
|
|
1529
|
+
async abort() {
|
|
1530
|
+
try {
|
|
1531
|
+
await s.abort();
|
|
1532
|
+
} catch {
|
|
1533
|
+
/* aborted sessions can throw; cancellation is best-effort */
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
isStreaming() {
|
|
1537
|
+
return !disposed && Boolean(s.isStreaming);
|
|
1538
|
+
},
|
|
1539
|
+
dispose() {
|
|
1540
|
+
if (disposed) return;
|
|
1541
|
+
disposed = true;
|
|
1542
|
+
for (const un of unsubscribers.splice(0)) {
|
|
1543
|
+
try {
|
|
1544
|
+
un();
|
|
1545
|
+
} catch {
|
|
1546
|
+
/* ignore */
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
},
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Create the reviewer's in-process child session.
|
|
1555
|
+
*
|
|
1556
|
+
* Isolation properties that matter:
|
|
1557
|
+
* - `noExtensions: true` → the child never loads this or any other extension,
|
|
1558
|
+
* so Review Mode cannot recurse or observe the parent's hooks.
|
|
1559
|
+
* - `SessionManager.inMemory(...)` → the child never writes a session file and
|
|
1560
|
+
* never touches the parent's session history.
|
|
1561
|
+
* - its own `ModelRuntime` → no contention with the parent's model state.
|
|
1562
|
+
* - read-only built-in tools + reviewer-only custom tools.
|
|
1563
|
+
*
|
|
1564
|
+
* Never throws: a failure is returned as `{ ok: false, error }` so the caller
|
|
1565
|
+
* can fall back to the author's plan (config.fallbackOnError).
|
|
1566
|
+
*/
|
|
1567
|
+
export async function createReviewSession(
|
|
1568
|
+
options: CreateReviewSessionOptions,
|
|
1569
|
+
): Promise<CreateReviewSessionResult> {
|
|
1570
|
+
const modelLabel = `${options.modelRef.provider}/${options.modelRef.id}`;
|
|
1571
|
+
const loadSdk =
|
|
1572
|
+
options.loadSdk ?? (() => import("@earendil-works/pi-coding-agent"));
|
|
1573
|
+
const sessionId = `review-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
1574
|
+
|
|
1575
|
+
const previousMarker = process.env[REVIEW_CHILD_MARKER_ENV];
|
|
1576
|
+
try {
|
|
1577
|
+
const sdk = await loadSdk();
|
|
1578
|
+
|
|
1579
|
+
const model = options.findModel(
|
|
1580
|
+
options.modelRef.provider,
|
|
1581
|
+
options.modelRef.id,
|
|
1582
|
+
);
|
|
1583
|
+
if (!model) {
|
|
1584
|
+
return {
|
|
1585
|
+
ok: false,
|
|
1586
|
+
modelLabel,
|
|
1587
|
+
error: `reviewer model ${modelLabel} is not in the model registry — run /role set reviewer <provider/model>`,
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
let modelRuntime: unknown;
|
|
1592
|
+
try {
|
|
1593
|
+
modelRuntime = await sdk.ModelRuntime.create();
|
|
1594
|
+
} catch (e: unknown) {
|
|
1595
|
+
return {
|
|
1596
|
+
ok: false,
|
|
1597
|
+
modelLabel,
|
|
1598
|
+
error: `could not create a model runtime for the reviewer (auth/model catalog problem): ${
|
|
1599
|
+
e instanceof Error ? e.message : String(e)
|
|
1600
|
+
}`,
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// No ambient extensions, no skills/prompts/themes: the reviewer gets exactly
|
|
1605
|
+
// the system prompt below and nothing from the user's other packages.
|
|
1606
|
+
// Project context files (AGENTS.md etc.) DO load — they are the framework
|
|
1607
|
+
// signal the reviewer is meant to align with.
|
|
1608
|
+
const loader = new sdk.DefaultResourceLoader({
|
|
1609
|
+
cwd: options.cwd,
|
|
1610
|
+
agentDir: options.agentDir,
|
|
1611
|
+
noExtensions: true,
|
|
1612
|
+
noSkills: true,
|
|
1613
|
+
noPromptTemplates: true,
|
|
1614
|
+
noThemes: true,
|
|
1615
|
+
systemPrompt: REVIEW_SYSTEM_PROMPT,
|
|
1616
|
+
appendSystemPrompt: [options.reviewPrompt],
|
|
1617
|
+
});
|
|
1618
|
+
try {
|
|
1619
|
+
await loader.reload?.();
|
|
1620
|
+
} catch {
|
|
1621
|
+
/* a reload failure must not abort the review; defaults still apply */
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const sessionManager = sdk.SessionManager.inMemory(options.cwd, {
|
|
1625
|
+
id: sessionId,
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
// MUST include the custom tool names: `tools` is an allowlist that filters
|
|
1629
|
+
// custom tools too, so a built-ins-only list would silently remove
|
|
1630
|
+
// `review_submit_plan` and make every review fail (see REVIEW_BUILTIN_TOOLS).
|
|
1631
|
+
const expectedTools = reviewToolAllowlist(options.customTools);
|
|
1632
|
+
|
|
1633
|
+
process.env[REVIEW_CHILD_MARKER_ENV] = "1";
|
|
1634
|
+
let created: { session?: unknown; modelFallbackMessage?: string };
|
|
1635
|
+
try {
|
|
1636
|
+
created = await sdk.createAgentSession({
|
|
1637
|
+
cwd: options.cwd,
|
|
1638
|
+
agentDir: options.agentDir,
|
|
1639
|
+
modelRuntime,
|
|
1640
|
+
model,
|
|
1641
|
+
thinkingLevel: options.modelRef.thinking as never,
|
|
1642
|
+
tools: expectedTools,
|
|
1643
|
+
customTools: options.customTools as never,
|
|
1644
|
+
resourceLoader: loader,
|
|
1645
|
+
sessionManager,
|
|
1646
|
+
sessionStartEvent: { type: "session_start", reason: "startup" },
|
|
1647
|
+
} as never);
|
|
1648
|
+
} finally {
|
|
1649
|
+
if (previousMarker === undefined)
|
|
1650
|
+
delete process.env[REVIEW_CHILD_MARKER_ENV];
|
|
1651
|
+
else process.env[REVIEW_CHILD_MARKER_ENV] = previousMarker;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
if (!created?.session) {
|
|
1655
|
+
return {
|
|
1656
|
+
ok: false,
|
|
1657
|
+
modelLabel,
|
|
1658
|
+
error: "createAgentSession returned no session",
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Defence in depth: assert the SDK really enabled the reviewer's tools.
|
|
1663
|
+
// Without `review_submit_plan` a review can only ever end in a fallback, so
|
|
1664
|
+
// failing here (instantly, with the missing names) beats a 10-minute pass
|
|
1665
|
+
// that silently cannot succeed. Feature-detected: an SDK that does not
|
|
1666
|
+
// expose the accessor is trusted rather than blocked.
|
|
1667
|
+
const activeTools = readActiveToolNames(created.session);
|
|
1668
|
+
if (activeTools) {
|
|
1669
|
+
const missing = expectedTools.filter((n) => !activeTools.includes(n));
|
|
1670
|
+
if (missing.length > 0) {
|
|
1671
|
+
return {
|
|
1672
|
+
ok: false,
|
|
1673
|
+
modelLabel,
|
|
1674
|
+
activeTools,
|
|
1675
|
+
error: `reviewer tools are not active: ${missing.join(", ")} — the session's tools allowlist dropped them; Review Mode cannot submit a plan without review_submit_plan`,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Bind in print mode so the child's UI context is explicitly non-interactive.
|
|
1681
|
+
// Best-effort: a binding failure is not fatal because the child has no extensions.
|
|
1682
|
+
try {
|
|
1683
|
+
const bindable = created.session as {
|
|
1684
|
+
bindExtensions?: (b: { mode: string }) => Promise<void>;
|
|
1685
|
+
};
|
|
1686
|
+
await bindable.bindExtensions?.({ mode: "print" });
|
|
1687
|
+
} catch {
|
|
1688
|
+
/* ignore */
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
return {
|
|
1692
|
+
ok: true,
|
|
1693
|
+
modelLabel,
|
|
1694
|
+
...(activeTools ? { activeTools } : {}),
|
|
1695
|
+
session: wrapAgentSession(created.session, modelLabel, sessionId),
|
|
1696
|
+
};
|
|
1697
|
+
} catch (e: unknown) {
|
|
1698
|
+
return {
|
|
1699
|
+
ok: false,
|
|
1700
|
+
modelLabel,
|
|
1701
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
}
|