@dieulc/workflow 0.2.0 → 0.3.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.
@@ -0,0 +1,749 @@
1
+ /**
2
+ * Workflow Extension — Review Mode tool definitions
3
+ *
4
+ * These are the ONLY tools the reviewer child session may call. They are built
5
+ * here (rather than in review.ts) because they need TypeBox schemas, and
6
+ * review.ts is deliberately free of runtime imports so it stays unit-testable.
7
+ *
8
+ * Permission model — Review Mode is read-only except for one write path:
9
+ *
10
+ * review_bash read-only + verify shell, gated by the same role policy
11
+ * Plan Mode uses via `explainCommandRefusal("reviewer", …)`
12
+ * (no file redirects — `2>/dev/null` and `2>&1` are fine —
13
+ * no rm/mv/cp, no sudo, no installs, no sed/awk, no git
14
+ * mutations; test/lint/typecheck runners are allowed)
15
+ * review_explore read-only subagents, capped by exploreBudget
16
+ * review_submit_plan the single write path: writes the reviewed plan
17
+ * review_pass_done ends a pass with a verdict (no side effects)
18
+ *
19
+ * Note: the reviewer is NOT given the built-in `bash`, `edit`, or `write`
20
+ * tools at all. `review_bash` is a distinct tool name so it can never be
21
+ * confused with, or override, the built-in one.
22
+ */
23
+
24
+ import { Type, type TSchema } from "typebox";
25
+ import {
26
+ appendReviewChangelog,
27
+ renderReviewChangelog,
28
+ validateReviewedPlan,
29
+ type ReviewFinding,
30
+ } from "./utils.ts";
31
+ import { explainCommandRefusal } from "./permissions.ts";
32
+ import type {
33
+ PiCodingAgentSdk,
34
+ ReviewPassResult,
35
+ ReviewVerdict,
36
+ } from "./review.ts";
37
+ import { sanitizeFindings } from "./review.ts";
38
+
39
+ /** Result shape shared by every review tool. */
40
+ export interface ReviewToolTextResult {
41
+ content: Array<{ type: "text"; text: string }>;
42
+ details?: Record<string, unknown>;
43
+ isError?: boolean;
44
+ }
45
+
46
+ export interface ReviewBashDeps {
47
+ /** Working directory for shell execution. */
48
+ cwd: string;
49
+ /** Surface refusals and anomalies to the parent session. */
50
+ log: (message: string, level?: "info" | "warning" | "error") => void;
51
+ }
52
+
53
+ /** Injectable shell-tool builder so this module is testable without the SDK. */
54
+ export type BashToolDefinitionBuilder = (cwd: string) => {
55
+ parameters: TSchema;
56
+ execute: (...args: unknown[]) => Promise<unknown>;
57
+ };
58
+
59
+ /**
60
+ * `review_bash` — the reviewer's only shell access.
61
+ *
62
+ * The gate is `explainCommandRefusal("reviewer", …)` from permissions.ts —
63
+ * the same role policy Plan Mode's `tool_call` hook applies to the planner, so
64
+ * Review Mode inherits Plan Mode's permissions rather than defining its own. A
65
+ * refused command is never executed: the wrapper returns before delegating to
66
+ * Pi's shell backend, and the refusal names the first rule that failed.
67
+ *
68
+ * Execution itself is delegated to Pi's `createBashToolDefinition`, so shell
69
+ * selection (bash on POSIX, the platform shell on Windows), output truncation,
70
+ * timeouts and session env vars all behave exactly as they do elsewhere in Pi.
71
+ */
72
+ export function createReviewBashTool(
73
+ buildBashToolDefinition: BashToolDefinitionBuilder,
74
+ deps: ReviewBashDeps,
75
+ ): {
76
+ name: string;
77
+ label: string;
78
+ description: string;
79
+ parameters: TSchema;
80
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
81
+ } {
82
+ const inner = buildBashToolDefinition(deps.cwd);
83
+
84
+ return {
85
+ name: "review_bash",
86
+ label: "Review Bash (read-only)",
87
+ description:
88
+ "Run a READ-ONLY or VERIFY shell command to inspect and check the repository (git log/diff/status/show-ref, ls, cat, rg, npm test, npm run lint/typecheck, eslint, tsc --noEmit, node --test, pytest, cargo clippy). Mutating commands — file redirects (> and >>), sed/awk, rm, mv, cp, chmod, sudo, package installs/builds, git add/commit/push — are refused by the harness; `2>/dev/null` and `2>&1` are allowed. You cannot write files with this tool; use review_submit_plan to submit the revised plan.",
89
+ parameters: inner.parameters,
90
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
91
+ // SAFETY: Pi invokes ToolDefinition.execute as
92
+ // (toolCallId, params, signal, onUpdate, ctx) — see ToolDefinition in
93
+ // @earendil-works/pi-coding-agent/dist/core/extensions/types.d.ts. The
94
+ // variadic `unknown[]` signature exists only so this wrapper stays
95
+ // assignable to ToolDefinition without pinning its generics; the runtime
96
+ // argument order is guaranteed by that contract.
97
+ const [toolCallId, rawParams, signal, onUpdate, ctx] =
98
+ args as unknown as [
99
+ string,
100
+ { command?: unknown; timeout?: unknown },
101
+ AbortSignal | undefined,
102
+ unknown,
103
+ unknown,
104
+ ];
105
+ const command =
106
+ typeof rawParams?.command === "string" ? rawParams.command : "";
107
+
108
+ if (!command.trim()) {
109
+ return {
110
+ content: [
111
+ { type: "text", text: "review_bash: command is empty" },
112
+ ],
113
+ isError: true,
114
+ };
115
+ }
116
+
117
+ const refusal = explainCommandRefusal("reviewer", command);
118
+ if (refusal) {
119
+ deps.log(
120
+ `Review Mode refused a read-only violation: ${command.slice(0, 120)}`,
121
+ "warning",
122
+ );
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: [
128
+ `review_bash: REFUSED — ${refusal.detail}.`,
129
+ refusal.suggestion ?? "",
130
+ "",
131
+ "Review Mode has Plan Mode's permissions: read-only inspection plus",
132
+ "test/lint/typecheck commands (npm test, npm run lint/typecheck, eslint,",
133
+ "tsc --noEmit, node --test, pytest, cargo clippy). File redirects (> and >>)",
134
+ "are refused while 2>/dev/null and 2>&1 are allowed; sed/awk are not on",
135
+ "the allowlist; no rm/mv/cp/mkdir/touch/chmod, no sudo, no package installs",
136
+ "or builds, no git add/commit/push/checkout. Do not work around this.",
137
+ "",
138
+ "If you need to change the plan, call review_submit_plan.",
139
+ ]
140
+ .filter(Boolean)
141
+ .join("\n"),
142
+ },
143
+ ],
144
+ isError: true,
145
+ };
146
+ }
147
+
148
+ try {
149
+ const result = (await inner.execute(
150
+ toolCallId,
151
+ rawParams,
152
+ signal,
153
+ onUpdate,
154
+ ctx,
155
+ )) as ReviewToolTextResult;
156
+ return result;
157
+ } catch (e: unknown) {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: `review_bash: execution failed — ${
163
+ e instanceof Error ? e.message : String(e)
164
+ }`,
165
+ },
166
+ ],
167
+ isError: true,
168
+ };
169
+ }
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Type guard for the parts of the SDK this module needs, so a partial or
176
+ * mismatched SDK produces a clear tool-level error instead of a crash.
177
+ */
178
+ export function resolveBashToolDefinitionBuilder(
179
+ sdk: PiCodingAgentSdk | undefined,
180
+ ): BashToolDefinitionBuilder | undefined {
181
+ const candidate = (
182
+ sdk as { createBashToolDefinition?: unknown } | undefined
183
+ )?.createBashToolDefinition;
184
+ if (typeof candidate !== "function") return undefined;
185
+ return candidate as BashToolDefinitionBuilder;
186
+ }
187
+
188
+ // ── Step 8: review_explore ───────────────────────────────────────────
189
+
190
+ /**
191
+ * One read-only reconnaissance task. Resolves to a concise findings string.
192
+ *
193
+ * Injected rather than imported: the existing `explore` implementation lives
194
+ * inside index.ts's extension closure and is not importable. Supplying it as a
195
+ * dependency also means the reviewer reuses the SAME subagent spawn path as
196
+ * Plan Mode — identical tool allowlist, identical explorer role model — instead
197
+ * of a parallel implementation that could drift.
198
+ */
199
+ export type ReviewExploreRunner = (
200
+ task: string,
201
+ signal?: AbortSignal,
202
+ ) => Promise<string>;
203
+
204
+ export interface ReviewExploreDeps {
205
+ /** Hard ceiling on subagents for the current review round. */
206
+ budget: () => number;
207
+ /** Read-only reconnaissance runner (explorer role, no write tools). */
208
+ run: ReviewExploreRunner;
209
+ /** Report the running total so the parent can persist/display it. */
210
+ onUsed: (used: number) => void;
211
+ /** Surface budget exhaustion and per-task failures. */
212
+ log: (message: string, level?: "info" | "warning" | "error") => void;
213
+ }
214
+
215
+ /**
216
+ * `review_explore` — delegate focused read-only reconnaissance to subagents.
217
+ *
218
+ * Budget accounting lives here (not in the prompt) so an over-eager reviewer
219
+ * cannot run away with the user's tokens: every task is counted, and tasks
220
+ * beyond `budget()` are refused with a message that tells the reviewer to work
221
+ * with what it already has.
222
+ */
223
+ export function createReviewExploreTool(deps: ReviewExploreDeps): {
224
+ name: string;
225
+ label: string;
226
+ description: string;
227
+ parameters: TSchema;
228
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
229
+ } {
230
+ let used = 0;
231
+
232
+ return {
233
+ name: "review_explore",
234
+ label: "Review Explore (read-only subagents)",
235
+ description:
236
+ "Delegate focused READ-ONLY reconnaissance to subagents that run with the explorer role's model. Use this to check several independent facts at once (e.g. 'do other modules use a Result type?', 'what is the established migration pattern?', 'how are these tests structured?'). Subagents cannot write files. The number of subagents is capped by the review budget; tasks beyond the cap are refused.",
237
+ parameters: Type.Object({
238
+ tasks: Type.Array(
239
+ Type.Object({
240
+ task: Type.String({
241
+ description:
242
+ "A specific, self-contained question about THIS repository. Include the files or symbols to look at.",
243
+ }),
244
+ }),
245
+ {
246
+ minItems: 1,
247
+ maxItems: 4,
248
+ description:
249
+ "Independent reconnaissance tasks, run in parallel.",
250
+ },
251
+ ),
252
+ }),
253
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
254
+ // SAFETY: same ToolDefinition.execute contract as review_bash above —
255
+ // Pi calls (toolCallId, params, signal, onUpdate, ctx).
256
+ const [, rawParams, signal] = args as unknown as [
257
+ string,
258
+ { tasks?: Array<{ task?: unknown }> },
259
+ AbortSignal | undefined,
260
+ ];
261
+
262
+ const requested = Array.isArray(rawParams?.tasks)
263
+ ? rawParams.tasks
264
+ .map((t) =>
265
+ typeof t?.task === "string" ? t.task.trim() : "",
266
+ )
267
+ .filter(Boolean)
268
+ : [];
269
+
270
+ if (requested.length === 0) {
271
+ return {
272
+ content: [
273
+ {
274
+ type: "text",
275
+ text: "review_explore: no tasks given",
276
+ },
277
+ ],
278
+ isError: true,
279
+ };
280
+ }
281
+
282
+ const budget = Math.max(0, Math.trunc(deps.budget()));
283
+ const remaining = Math.max(0, budget - used);
284
+ if (remaining === 0) {
285
+ return {
286
+ content: [
287
+ {
288
+ type: "text",
289
+ text: `review_explore: budget exhausted (${used}/${budget} subagents used this round). Work with what you have already gathered, or finish the review and report what you could not verify in the findings.`,
290
+ },
291
+ ],
292
+ isError: true,
293
+ };
294
+ }
295
+
296
+ const slice = requested.slice(0, Math.min(remaining, 4));
297
+ used += slice.length;
298
+ deps.onUsed(used);
299
+
300
+ const results = await Promise.all(
301
+ slice.map(async (task, i) => {
302
+ try {
303
+ const text = await deps.run(task, signal);
304
+ return `### Recon ${i + 1}: ${task}\n\n${text?.trim() || "(no findings)"}`;
305
+ } catch (e: unknown) {
306
+ const msg = e instanceof Error ? e.message : String(e);
307
+ deps.log(
308
+ `review_explore task failed: ${msg}`,
309
+ "warning",
310
+ );
311
+ return `### Recon ${i + 1}: ${task}\n\nFAILED: ${msg}`;
312
+ }
313
+ }),
314
+ );
315
+
316
+ const omitted = requested.length - slice.length;
317
+ const tail =
318
+ omitted > 0
319
+ ? `\n\n(${omitted} further task(s) not run — explore budget is ${budget} per round.)`
320
+ : "";
321
+
322
+ return {
323
+ content: [{ type: "text", text: results.join("\n\n") + tail }],
324
+ details: { used, budget, tasks: slice.length },
325
+ };
326
+ },
327
+ };
328
+ }
329
+
330
+ /** Convenience toolbox used by review.ts when assembling `customTools`. */
331
+ export interface ReviewToolbox {
332
+ tools: unknown[];
333
+ /** Names actually available; missing SDK pieces are reported, not thrown. */
334
+ unavailable: string[];
335
+ }
336
+
337
+ /** Everything the review tool set needs from the parent extension. */
338
+ export interface ReviewToolDeps {
339
+ cwd: string;
340
+ log: (message: string, level?: "info" | "warning" | "error") => void;
341
+ explore: ReviewExploreDeps;
342
+ submit: ReviewSubmitDeps;
343
+ passDone: ReviewPassDoneDeps;
344
+ }
345
+
346
+ // ── Steps 9-11: review_submit_plan ───────────────────────────────────
347
+
348
+ /** What the reviewer handed in, after validation. */
349
+ export interface ReviewSubmitPayload {
350
+ /** The reviewer's plan body, without the changelog appendix. */
351
+ planBody: string;
352
+ /** planBody + the generated `## Review changes` appendix (what gets written). */
353
+ planText: string;
354
+ findings: ReviewFinding[];
355
+ verdict: ReviewVerdict;
356
+ /** The reviewer's one-line summary of the change. */
357
+ summary: string;
358
+ /** The rendered changelog section. */
359
+ changelog: string;
360
+ }
361
+
362
+ export interface ReviewSubmitDeps {
363
+ /**
364
+ * The plan path Plan Mode computed. Review Mode adjusts that file; it never
365
+ * invents a new slug (utils.normalizePlanPath owns the naming convention).
366
+ */
367
+ planPath: () => string | undefined;
368
+ /** The author's current plan — the validation baseline and changelog base. */
369
+ originalPlan: () => string | undefined;
370
+ /** Reviewer model label, recorded in the changelog header. */
371
+ modelLabel: () => string;
372
+ /** Persist the reviewed plan and announce it. Owned by the parent. */
373
+ submit: (
374
+ payload: ReviewSubmitPayload,
375
+ ) => Promise<{ ok: boolean; path?: string; error?: string }>;
376
+ log: (message: string, level?: "info" | "warning" | "error") => void;
377
+ }
378
+
379
+ const VERDICT_SCHEMA = Type.Union([
380
+ Type.Literal("approve"),
381
+ Type.Literal("revise"),
382
+ Type.Literal("block"),
383
+ ]);
384
+
385
+ const FINDING_SCHEMA = Type.Object({
386
+ id: Type.Optional(
387
+ Type.String({ description: "Short stable id, e.g. F1. Optional." }),
388
+ ),
389
+ severity: Type.Number({
390
+ description: "1-10, higher is worse. Use 8+ only for real breakage.",
391
+ }),
392
+ confidence: Type.Number({ description: "0-100. Be honest." }),
393
+ category: Type.String({
394
+ description:
395
+ "e.g. framework-alignment, correctness, requirement-coverage, security, simplicity, best-practice",
396
+ }),
397
+ file: Type.Optional(
398
+ Type.String({
399
+ description: "Repo-relative path, when you can anchor it.",
400
+ }),
401
+ ),
402
+ lineRange: Type.Optional(
403
+ Type.String({ description: "e.g. '42' or '42-58'." }),
404
+ ),
405
+ summary: Type.String({ description: "One line: what is wrong." }),
406
+ rationale: Type.String({
407
+ description: "Why it matters, citing the repo convention or practice.",
408
+ }),
409
+ disposition: Type.Union([
410
+ Type.Literal("accepted"),
411
+ Type.Literal("rejected"),
412
+ Type.Literal("deferred"),
413
+ ]),
414
+ });
415
+
416
+ /**
417
+ * `review_submit_plan` — the ONLY write path for Review Mode.
418
+ *
419
+ * Three things happen here, in order, and nothing is written unless all three
420
+ * pass:
421
+ *
422
+ * 1. (step 10) Validate the submission against the author's plan. A stub or a
423
+ * heading-less blob is rejected and returned to the reviewer to fix, so a
424
+ * degenerate rewrite can never silently replace a real plan.
425
+ * 2. (step 11) Compose the changelog and append it, so the user can see
426
+ * exactly what the reviewer changed and why.
427
+ * 3. (steps 12-13) Hand the composed plan to the parent, which writes it to the
428
+ * SAME plan path Plan Mode chose and records an entry.
429
+ */
430
+ export function createReviewSubmitPlanTool(deps: ReviewSubmitDeps): {
431
+ name: string;
432
+ label: string;
433
+ description: string;
434
+ parameters: TSchema;
435
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
436
+ } {
437
+ return {
438
+ name: "review_submit_plan",
439
+ label: "Submit Reviewed Plan",
440
+ description:
441
+ "Submit the FINAL revised plan. This is the only way your work reaches the user, and the only file you can write. Pass the complete plan markdown (not a diff or a summary) plus your findings and verdict. The plan body must keep a markdown heading and stay close to the original length; the harness rejects stubs. A `## Review changes` section is appended automatically from your findings, so do not write it yourself. You may call this more than once — the last successful submission wins.",
442
+ parameters: Type.Object({
443
+ planMarkdown: Type.String({
444
+ description:
445
+ "The complete revised plan in markdown, preserving the original structure and heading style. Do NOT include a 'Review changes' section.",
446
+ }),
447
+ findings: Type.Array(FINDING_SCHEMA, {
448
+ description:
449
+ "Every issue you found. Use an empty array if the plan is sound and you are submitting it unchanged.",
450
+ }),
451
+ verdict: VERDICT_SCHEMA,
452
+ summary: Type.String({
453
+ description: "One sentence describing what you changed.",
454
+ }),
455
+ }),
456
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
457
+ // SAFETY: same ToolDefinition.execute contract as the tools above —
458
+ // Pi calls (toolCallId, params, signal, onUpdate, ctx).
459
+ const [, rawParams] = args as unknown as [
460
+ string,
461
+ {
462
+ planMarkdown?: unknown;
463
+ findings?: unknown;
464
+ verdict?: unknown;
465
+ summary?: unknown;
466
+ },
467
+ ];
468
+
469
+ const planBody =
470
+ typeof rawParams?.planMarkdown === "string"
471
+ ? rawParams.planMarkdown
472
+ : "";
473
+ const verdict: ReviewVerdict =
474
+ rawParams?.verdict === "approve" ||
475
+ rawParams?.verdict === "revise" ||
476
+ rawParams?.verdict === "block"
477
+ ? rawParams.verdict
478
+ : "revise";
479
+ const summary =
480
+ typeof rawParams?.summary === "string"
481
+ ? rawParams.summary.trim()
482
+ : "";
483
+ const findings = sanitizeFindings(rawParams?.findings);
484
+
485
+ const original = deps.originalPlan() ?? "";
486
+ const targetPath = deps.planPath();
487
+ if (!targetPath) {
488
+ return {
489
+ content: [
490
+ {
491
+ type: "text",
492
+ text: "review_submit_plan: no plan path is known for this round — the reviewer cannot write. Report this as an error to the user.",
493
+ },
494
+ ],
495
+ isError: true,
496
+ };
497
+ }
498
+
499
+ // Step 10 — reject stubs and heading-less blobs before anything is written.
500
+ const problems = validateReviewedPlan(planBody, original);
501
+ if (problems.length > 0) {
502
+ deps.log(
503
+ `Review submission rejected: ${problems.join("; ")}`,
504
+ "warning",
505
+ );
506
+ return {
507
+ content: [
508
+ {
509
+ type: "text",
510
+ text: [
511
+ "review_submit_plan: REJECTED — nothing was written.",
512
+ "",
513
+ ...problems.map((p) => `- ${p}`),
514
+ "",
515
+ "Resubmit the COMPLETE plan (all sections, same heading style), not a",
516
+ "summary or a diff. Read the original plan again if needed.",
517
+ ].join("\n"),
518
+ },
519
+ ],
520
+ isError: true,
521
+ };
522
+ }
523
+
524
+ // Step 11 — changelog, appended so the user sees every change.
525
+ const changelog = renderReviewChangelog(findings, {
526
+ modelLabel: deps.modelLabel(),
527
+ verdict,
528
+ });
529
+ const planText = appendReviewChangelog(planBody, changelog);
530
+
531
+ const payload: ReviewSubmitPayload = {
532
+ planBody,
533
+ planText,
534
+ findings,
535
+ verdict,
536
+ summary,
537
+ changelog,
538
+ };
539
+
540
+ let result: { ok: boolean; path?: string; error?: string };
541
+ try {
542
+ result = await deps.submit(payload);
543
+ } catch (e: unknown) {
544
+ result = {
545
+ ok: false,
546
+ error: e instanceof Error ? e.message : String(e),
547
+ };
548
+ }
549
+
550
+ if (!result.ok) {
551
+ deps.log(
552
+ `Review submission failed to write: ${result.error ?? "unknown error"}`,
553
+ "error",
554
+ );
555
+ return {
556
+ content: [
557
+ {
558
+ type: "text",
559
+ text: `review_submit_plan: could not write the plan — ${result.error ?? "unknown error"}`,
560
+ },
561
+ ],
562
+ isError: true,
563
+ };
564
+ }
565
+
566
+ return {
567
+ content: [
568
+ {
569
+ type: "text",
570
+ text: [
571
+ `review_submit_plan: submitted to ${result.path ?? targetPath}`,
572
+ `- verdict: ${verdict}`,
573
+ `- findings: ${findings.length}`,
574
+ summary ? `- summary: ${summary}` : "",
575
+ "",
576
+ "The plan now carries a `## Review changes` appendix. Now call",
577
+ "review_pass_done to end this pass.",
578
+ ]
579
+ .filter(Boolean)
580
+ .join("\n"),
581
+ },
582
+ ],
583
+ details: {
584
+ path: result.path ?? targetPath,
585
+ verdict,
586
+ findings: findings.length,
587
+ },
588
+ };
589
+ },
590
+ };
591
+ }
592
+
593
+ // ── Step 15: review_pass_done ────────────────────────────────────────
594
+
595
+ /**
596
+ * Deterministic orchestration hook for ending a pass.
597
+ *
598
+ * The tool records the verdict and findings; the ORCHESTRATOR decides what
599
+ * happens next (pass 2, another round, or hand off to Plannotator). Keeping that
600
+ * decision in TypeScript rather than in the prompt is what makes the protocol
601
+ * reliable — the same principle the persona-audit and supi-review extensions
602
+ * use for their reviewer loops.
603
+ */
604
+ export interface ReviewPassDoneDeps {
605
+ /** Whether the reviewer has submitted a plan in the current pass. */
606
+ hasSubmitted: () => boolean;
607
+ /** Called when the reviewer ends a pass. */
608
+ onPassDone: (result: ReviewPassResult) => void;
609
+ /** True when the harness intends to run the self-verification pass. */
610
+ willVerify: () => boolean;
611
+ log: (message: string, level?: "info" | "warning" | "error") => void;
612
+ }
613
+
614
+ /**
615
+ * `review_pass_done` — end the current pass with a verdict.
616
+ *
617
+ * Refuses to end a pass in which nothing was submitted: a review that never
618
+ * reaches the plan file is a failed review, and silently "completing" it would
619
+ * hand the user the author's unreviewed plan while looking reviewed.
620
+ */
621
+ export function createReviewPassDoneTool(deps: ReviewPassDoneDeps): {
622
+ name: string;
623
+ label: string;
624
+ description: string;
625
+ parameters: TSchema;
626
+ execute: (...args: unknown[]) => Promise<ReviewToolTextResult>;
627
+ } {
628
+ return {
629
+ name: "review_pass_done",
630
+ label: "Finish Review Pass",
631
+ description:
632
+ "End the current review pass with your verdict and findings. Call this AFTER review_submit_plan — a pass that submits nothing is rejected. Pass 1 completes the review; if the harness schedules a verification pass, it will prompt you for pass 2 next.",
633
+ parameters: Type.Object({
634
+ pass: Type.Optional(
635
+ Type.Union([Type.Literal(1), Type.Literal(2)], {
636
+ description:
637
+ "Which pass you are ending. Defaults to the current pass.",
638
+ }),
639
+ ),
640
+ verdict: VERDICT_SCHEMA,
641
+ findings: Type.Array(FINDING_SCHEMA, {
642
+ description:
643
+ "The same findings you submitted, so the run is readable without re-parsing the plan file.",
644
+ }),
645
+ notes: Type.Optional(
646
+ Type.String({
647
+ description:
648
+ "Anything the user should know that is not a finding: what you could not verify, and why.",
649
+ }),
650
+ ),
651
+ }),
652
+ async execute(...args: unknown[]): Promise<ReviewToolTextResult> {
653
+ // SAFETY: same ToolDefinition.execute contract as the tools above —
654
+ // Pi calls (toolCallId, params, signal, onUpdate, ctx).
655
+ const [, rawParams] = args as unknown as [
656
+ string,
657
+ {
658
+ pass?: unknown;
659
+ verdict?: unknown;
660
+ findings?: unknown;
661
+ notes?: unknown;
662
+ },
663
+ ];
664
+
665
+ if (!deps.hasSubmitted()) {
666
+ deps.log(
667
+ "Review pass_done refused: no plan was submitted this pass",
668
+ "warning",
669
+ );
670
+ return {
671
+ content: [
672
+ {
673
+ type: "text",
674
+ text: [
675
+ "review_pass_done: REFUSED — you have not submitted a plan this pass.",
676
+ "",
677
+ "Your review only reaches the user through `review_submit_plan`.",
678
+ "Submit the complete revised plan (unchanged is fine if it is sound),",
679
+ "then call review_pass_done again.",
680
+ ].join("\n"),
681
+ },
682
+ ],
683
+ isError: true,
684
+ };
685
+ }
686
+
687
+ const pass: 1 | 2 = rawParams?.pass === 2 ? 2 : 1;
688
+ const verdict: ReviewVerdict =
689
+ rawParams?.verdict === "approve" ||
690
+ rawParams?.verdict === "revise" ||
691
+ rawParams?.verdict === "block"
692
+ ? rawParams.verdict
693
+ : "revise";
694
+ const findings = sanitizeFindings(rawParams?.findings);
695
+ const notes =
696
+ typeof rawParams?.notes === "string"
697
+ ? rawParams.notes.trim()
698
+ : undefined;
699
+
700
+ const result: ReviewPassResult = {
701
+ pass,
702
+ verdict,
703
+ findings,
704
+ at: Date.now(),
705
+ };
706
+ if (notes) result.notes = notes;
707
+
708
+ deps.onPassDone(result);
709
+ return {
710
+ content: [
711
+ {
712
+ type: "text",
713
+ text: deps.willVerify()
714
+ ? "review_pass_done: pass 1 recorded. A verification pass is scheduled — wait for the harness to prompt you, then confirm the rewrite addressed each finding and dropped no original content."
715
+ : `review_pass_done: pass ${pass} recorded (${verdict}, ${findings.length} finding${findings.length === 1 ? "" : "s"}). Your review is complete.`,
716
+ },
717
+ ],
718
+ details: { pass, verdict, findings: findings.length },
719
+ };
720
+ },
721
+ };
722
+ }
723
+
724
+ /**
725
+ * Build every tool the reviewer may use.
726
+ */
727
+ export function createReviewTools(
728
+ sdk: PiCodingAgentSdk | undefined,
729
+ deps: ReviewToolDeps,
730
+ ): ReviewToolbox {
731
+ const tools: unknown[] = [];
732
+ const unavailable: string[] = [];
733
+
734
+ const bashBuilder = resolveBashToolDefinitionBuilder(sdk);
735
+ if (bashBuilder) {
736
+ tools.push(createReviewBashTool(bashBuilder, deps));
737
+ } else {
738
+ unavailable.push("review_bash (createBashToolDefinition unavailable)");
739
+ }
740
+
741
+ tools.push(createReviewExploreTool(deps.explore));
742
+ tools.push(createReviewSubmitPlanTool(deps.submit));
743
+ tools.push(createReviewPassDoneTool(deps.passDone));
744
+
745
+ return { tools, unavailable };
746
+ }
747
+
748
+ /** Re-exported so consumers can build ad-hoc schemas of the same flavour. */
749
+ export const ReviewSchema = Type;