ultimate-pi 0.9.0 → 0.10.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.
Files changed (27) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +17 -13
  2. package/.agents/skills/harness-plan/SKILL.md +3 -3
  3. package/.pi/agents/harness/planner.md +8 -4
  4. package/.pi/extensions/harness-plan-approval.ts +140 -0
  5. package/.pi/extensions/harness-run-context.ts +29 -8
  6. package/.pi/extensions/lib/harness-subagents/harness-subagent-policy.ts +11 -1
  7. package/.pi/extensions/lib/harness-subagents/parent-ask-user-bridge.ts +8 -87
  8. package/.pi/extensions/lib/harness-subagents/parent-harness-ui-bridge.ts +306 -0
  9. package/.pi/extensions/lib/harness-subagents/parent-harness-ui-hooks.ts +59 -0
  10. package/.pi/extensions/lib/harness-subagents/spawn-policy.ts +9 -0
  11. package/.pi/extensions/lib/harness-subagents/vendored/agent-manager.ts +4 -0
  12. package/.pi/extensions/lib/harness-subagents/vendored/agent-runner.ts +39 -12
  13. package/.pi/extensions/lib/harness-subagents/vendored/index.ts +36 -12
  14. package/.pi/extensions/lib/plan-approval/create-plan.ts +131 -0
  15. package/.pi/extensions/lib/plan-approval/dialog.ts +207 -0
  16. package/.pi/extensions/lib/plan-approval/fallback.ts +50 -0
  17. package/.pi/extensions/lib/plan-approval/format-plan.ts +94 -0
  18. package/.pi/extensions/lib/plan-approval/render.ts +83 -0
  19. package/.pi/extensions/lib/plan-approval/schema.ts +39 -0
  20. package/.pi/extensions/lib/plan-approval/types.ts +32 -0
  21. package/.pi/extensions/lib/plan-approval/validate.ts +61 -0
  22. package/.pi/harness/agents.manifest.json +2 -2
  23. package/.pi/lib/harness-run-context.ts +117 -28
  24. package/.pi/prompts/harness-plan.md +6 -6
  25. package/.pi/scripts/harness-verify.mjs +28 -1
  26. package/CHANGELOG.md +12 -0
  27. package/package.json +3 -3
@@ -0,0 +1,131 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import {
4
+ canonicalPlanPath,
5
+ type HarnessRunContext,
6
+ hasPlanUserApproval,
7
+ type PlanPacketLike,
8
+ saveProjectActiveRun,
9
+ saveRunContextToDisk,
10
+ validatePlanPacket,
11
+ } from "../../../lib/harness-run-context.js";
12
+
13
+ export const CREATE_PLAN_SNIPPET =
14
+ "create_plan({ plan_packet: { ...approved PlanPacket } })";
15
+
16
+ export const CREATE_PLAN_GUIDELINES = [
17
+ "Call create_plan only after the user approves via approve_plan (Approve selection).",
18
+ "Pass the same plan_packet you showed in approve_plan — path is resolved automatically.",
19
+ "Never use write or edit for plan-packet.json; create_plan is the only allowed plan write.",
20
+ ];
21
+
22
+ export interface CreatePlanDeps {
23
+ projectRoot: string;
24
+ getParentEntries: () => unknown[];
25
+ getSubagentEntries: () => unknown[];
26
+ getParentRunContext: () => HarnessRunContext | null;
27
+ /** Persist parent harness-run-context + plan-packet summary entries. */
28
+ onCommitted: (
29
+ runCtx: HarnessRunContext,
30
+ packet: PlanPacketLike,
31
+ planPath: string,
32
+ ) => void;
33
+ }
34
+
35
+ export type CreatePlanResult =
36
+ | { ok: true; planPath: string; planId: string }
37
+ | { ok: false; error: string };
38
+
39
+ export async function executeCreatePlan(
40
+ planPacket: PlanPacketLike,
41
+ deps: CreatePlanDeps,
42
+ ): Promise<CreatePlanResult> {
43
+ const validation = validatePlanPacket(planPacket);
44
+ if (!validation.valid) {
45
+ return {
46
+ ok: false,
47
+ error: `create_plan: invalid plan_packet — ${validation.errors.join("; ")}`,
48
+ };
49
+ }
50
+
51
+ const runCtx = deps.getParentRunContext();
52
+ if (!runCtx?.run_id || !runCtx.plan_packet_path) {
53
+ return {
54
+ ok: false,
55
+ error:
56
+ "create_plan: no active harness run on parent session (missing plan_packet_path).",
57
+ };
58
+ }
59
+
60
+ const planPath = resolve(deps.projectRoot, runCtx.plan_packet_path);
61
+ const canonical = canonicalPlanPath(runCtx.run_id, deps.projectRoot);
62
+ if (resolve(planPath) !== resolve(canonical)) {
63
+ return {
64
+ ok: false,
65
+ error: `create_plan: plan_packet_path must be ${canonical}`,
66
+ };
67
+ }
68
+
69
+ const planId = String(planPacket.plan_id ?? "");
70
+ const parentEntries = deps.getParentEntries();
71
+ const subEntries = deps.getSubagentEntries();
72
+ const approved =
73
+ hasPlanUserApproval(parentEntries, {
74
+ sincePlanCommand: true,
75
+ planId: planId || runCtx.plan_id,
76
+ }) ||
77
+ hasPlanUserApproval(subEntries, {
78
+ sincePlanCommand: false,
79
+ planId: planId || runCtx.plan_id,
80
+ });
81
+ if (!approved) {
82
+ return {
83
+ ok: false,
84
+ error:
85
+ "create_plan: blocked until user approves via approve_plan (Approve) in this session.",
86
+ };
87
+ }
88
+
89
+ try {
90
+ await mkdir(dirname(planPath), { recursive: true });
91
+ await writeFile(
92
+ planPath,
93
+ `${JSON.stringify(planPacket, null, 2)}\n`,
94
+ "utf-8",
95
+ );
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return { ok: false, error: `create_plan: write failed — ${msg}` };
99
+ }
100
+
101
+ const updated: HarnessRunContext = {
102
+ ...runCtx,
103
+ plan_id: planId || runCtx.plan_id,
104
+ plan_ready: true,
105
+ phase: "plan",
106
+ last_completed_step: "plan",
107
+ last_outcome: "ready",
108
+ next_recommended_command: "/harness-run",
109
+ updated_at: new Date().toISOString(),
110
+ };
111
+
112
+ try {
113
+ await saveRunContextToDisk(updated);
114
+ await saveProjectActiveRun(updated);
115
+ } catch {
116
+ /* disk mirror best-effort */
117
+ }
118
+
119
+ deps.onCommitted(updated, planPacket, planPath);
120
+
121
+ return {
122
+ ok: true,
123
+ planPath,
124
+ planId: planId || updated.plan_id || "unknown",
125
+ };
126
+ }
127
+
128
+ export function formatCreatePlanResultText(result: CreatePlanResult): string {
129
+ if (!result.ok) return result.error;
130
+ return `Plan written to ${result.planPath} (plan_id=${result.planId}).`;
131
+ }
@@ -0,0 +1,207 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { formatPlanPacketLines } from "./format-plan.js";
4
+ import type {
5
+ PlanApprovalDialogResult,
6
+ ValidatedApprovePlanParams,
7
+ } from "./types.js";
8
+
9
+ type FocusRegion = "plan" | "options";
10
+
11
+ interface CustomAnswer {
12
+ response: { kind: "selection"; selections: string[] };
13
+ }
14
+
15
+ function withTimeout<T>(
16
+ promise: Promise<T | null>,
17
+ ms: number | undefined,
18
+ ): Promise<T | null> {
19
+ if (!ms) return promise;
20
+ return Promise.race([
21
+ promise,
22
+ new Promise<null>((resolve) => {
23
+ setTimeout(() => resolve(null), ms);
24
+ }),
25
+ ]);
26
+ }
27
+
28
+ export async function runPlanApprovalDialog(
29
+ ui: ExtensionUIContext,
30
+ validated: ValidatedApprovePlanParams,
31
+ ): Promise<PlanApprovalDialogResult> {
32
+ const planLines = formatPlanPacketLines(validated.plan_packet, 100);
33
+ const displayOptions = validated.options;
34
+
35
+ const result = await withTimeout(
36
+ ui.custom<CustomAnswer | null>((tui, theme, _kb, done) => {
37
+ let scrollOffset = 0;
38
+ let optionIndex = 0;
39
+ let focus: FocusRegion = "plan";
40
+ let cachedLines: string[] | undefined;
41
+
42
+ function refresh() {
43
+ cachedLines = undefined;
44
+ tui.requestRender();
45
+ }
46
+
47
+ function submitSelection() {
48
+ const opt = displayOptions[optionIndex];
49
+ done({
50
+ response: { kind: "selection", selections: [opt.title] },
51
+ });
52
+ }
53
+
54
+ function handleInput(data: string) {
55
+ if (focus === "plan") {
56
+ if (matchesKey(data, Key.up) || data === "k") {
57
+ scrollOffset = Math.max(0, scrollOffset - 1);
58
+ refresh();
59
+ return;
60
+ }
61
+ if (matchesKey(data, Key.down) || data === "j") {
62
+ scrollOffset += 1;
63
+ refresh();
64
+ return;
65
+ }
66
+ if (matchesKey(data, Key.pageUp)) {
67
+ scrollOffset = Math.max(0, scrollOffset - 8);
68
+ refresh();
69
+ return;
70
+ }
71
+ if (matchesKey(data, Key.pageDown)) {
72
+ scrollOffset += 8;
73
+ refresh();
74
+ return;
75
+ }
76
+ if (matchesKey(data, Key.tab)) {
77
+ focus = "options";
78
+ refresh();
79
+ return;
80
+ }
81
+ }
82
+
83
+ if (focus === "options") {
84
+ if (matchesKey(data, Key.up)) {
85
+ optionIndex = Math.max(0, optionIndex - 1);
86
+ refresh();
87
+ return;
88
+ }
89
+ if (matchesKey(data, Key.down)) {
90
+ optionIndex = Math.min(displayOptions.length - 1, optionIndex + 1);
91
+ refresh();
92
+ return;
93
+ }
94
+ if (matchesKey(data, Key.tab)) {
95
+ focus = "plan";
96
+ refresh();
97
+ return;
98
+ }
99
+ if (matchesKey(data, Key.enter)) {
100
+ submitSelection();
101
+ return;
102
+ }
103
+ }
104
+
105
+ if (matchesKey(data, Key.escape)) {
106
+ done(null);
107
+ }
108
+ }
109
+
110
+ function render(width: number): string[] {
111
+ if (cachedLines) return cachedLines;
112
+
113
+ const lines: string[] = [];
114
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
115
+ const useOverlay = validated.displayMode !== "inline";
116
+ const dims = (tui as { height?: number }).height;
117
+ const termHeight = typeof dims === "number" && dims > 10 ? dims : 24;
118
+ const footerLines = displayOptions.length * 2 + 8;
119
+ const planViewport = Math.max(
120
+ 6,
121
+ Math.floor(termHeight * 0.55) - footerLines,
122
+ );
123
+
124
+ if (useOverlay) {
125
+ add(theme.fg("accent", "─".repeat(width)));
126
+ }
127
+
128
+ add(theme.fg("accent", " Plan approval"));
129
+ if (validated.human_summary) {
130
+ for (const line of validated.human_summary.split("\n")) {
131
+ add(theme.fg("muted", ` ${line}`));
132
+ }
133
+ }
134
+ lines.push("");
135
+
136
+ const maxScroll = Math.max(0, planLines.length - planViewport);
137
+ scrollOffset = Math.min(scrollOffset, maxScroll);
138
+ const visible = planLines.slice(
139
+ scrollOffset,
140
+ scrollOffset + planViewport,
141
+ );
142
+ const planLabel =
143
+ focus === "plan"
144
+ ? theme.fg("accent", " [plan — ↑↓/Pg scroll, Tab → options]")
145
+ : theme.fg("dim", " [plan]");
146
+ add(planLabel);
147
+ for (const line of visible) {
148
+ add(theme.fg("text", ` ${line}`));
149
+ }
150
+ if (planLines.length > planViewport) {
151
+ add(
152
+ theme.fg(
153
+ "dim",
154
+ ` … ${scrollOffset + 1}-${scrollOffset + visible.length} of ${planLines.length}`,
155
+ ),
156
+ );
157
+ }
158
+ lines.push("");
159
+
160
+ const optLabel =
161
+ focus === "options"
162
+ ? theme.fg("accent", " Options (↑↓, Enter, Tab → plan):")
163
+ : theme.fg("dim", " Options (Tab to focus):");
164
+ add(optLabel);
165
+ for (let i = 0; i < displayOptions.length; i++) {
166
+ const opt = displayOptions[i];
167
+ const focused = focus === "options" && i === optionIndex;
168
+ const prefix = focused ? theme.fg("accent", "> ") : " ";
169
+ const num = `${i + 1}. `;
170
+ if (focused) {
171
+ add(prefix + theme.fg("accent", `${num}${opt.title}`));
172
+ } else {
173
+ add(`${prefix}${theme.fg("text", `${num}${opt.title}`)}`);
174
+ }
175
+ if (opt.description) {
176
+ add(` ${theme.fg("muted", opt.description)}`);
177
+ }
178
+ }
179
+
180
+ lines.push("");
181
+ add(theme.fg("dim", " Tab: plan ↔ options • Esc: cancel"));
182
+
183
+ if (useOverlay) {
184
+ add(theme.fg("accent", "─".repeat(width)));
185
+ }
186
+
187
+ cachedLines = lines;
188
+ return lines;
189
+ }
190
+
191
+ return {
192
+ render,
193
+ invalidate: () => {
194
+ cachedLines = undefined;
195
+ },
196
+ handleInput,
197
+ };
198
+ }),
199
+ undefined,
200
+ );
201
+
202
+ if (!result) {
203
+ return { response: null, cancelled: true };
204
+ }
205
+
206
+ return { response: result.response, cancelled: false };
207
+ }
@@ -0,0 +1,50 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { formatPlanPacketLines } from "./format-plan.js";
3
+ import type {
4
+ PlanApprovalDialogResult,
5
+ ValidatedApprovePlanParams,
6
+ } from "./types.js";
7
+
8
+ export async function runPlanApprovalFallback(
9
+ ui: ExtensionUIContext,
10
+ validated: ValidatedApprovePlanParams,
11
+ ): Promise<PlanApprovalDialogResult> {
12
+ const lines = formatPlanPacketLines(validated.plan_packet, 80);
13
+ const body = lines.join("\n");
14
+ const summary = validated.human_summary
15
+ ? `${validated.human_summary}\n\n`
16
+ : "";
17
+ const prompt = `${summary}${body}\n\nSelect: ${validated.options.map((o, i) => `${i + 1}. ${o.title}`).join(" | ")}`;
18
+ const raw = await ui.input("Plan approval", prompt);
19
+ if (!raw?.trim()) {
20
+ return { response: null, cancelled: true };
21
+ }
22
+ const pick = raw.trim();
23
+ const byIndex = Number.parseInt(pick, 10);
24
+ if (
25
+ Number.isFinite(byIndex) &&
26
+ byIndex >= 1 &&
27
+ byIndex <= validated.options.length
28
+ ) {
29
+ return {
30
+ response: {
31
+ kind: "selection",
32
+ selections: [validated.options[byIndex - 1].title],
33
+ },
34
+ cancelled: false,
35
+ };
36
+ }
37
+ const match = validated.options.find(
38
+ (o) => o.title.toLowerCase() === pick.toLowerCase(),
39
+ );
40
+ if (match) {
41
+ return {
42
+ response: { kind: "selection", selections: [match.title] },
43
+ cancelled: false,
44
+ };
45
+ }
46
+ return {
47
+ response: { kind: "freeform", text: pick },
48
+ cancelled: false,
49
+ };
50
+ }
@@ -0,0 +1,94 @@
1
+ import type { PlanPacketLike } from "../../../lib/harness-run-context.js";
2
+
3
+ function wrapLine(text: string, width: number): string[] {
4
+ if (width < 20) return [text];
5
+ const words = text.split(/\s+/);
6
+ const lines: string[] = [];
7
+ let line = "";
8
+ for (const word of words) {
9
+ const next = line ? `${line} ${word}` : word;
10
+ if (next.length > width && line) {
11
+ lines.push(line);
12
+ line = word;
13
+ } else {
14
+ line = next;
15
+ }
16
+ }
17
+ if (line) lines.push(line);
18
+ return lines.length > 0 ? lines : [""];
19
+ }
20
+
21
+ function riskBadge(risk: string | undefined): string {
22
+ const r = (risk ?? "med").toLowerCase();
23
+ return `[risk: ${r}]`;
24
+ }
25
+
26
+ export function formatPlanPacketLines(
27
+ packet: PlanPacketLike,
28
+ width: number,
29
+ ): string[] {
30
+ const lines: string[] = [];
31
+ const w = Math.max(40, width - 2);
32
+ const add = (s: string) => {
33
+ for (const part of wrapLine(s, w)) lines.push(part);
34
+ };
35
+
36
+ lines.push(`plan_id: ${packet.plan_id ?? "?"}`);
37
+ lines.push(`task_id: ${packet.task_id ?? "?"}`);
38
+ lines.push(
39
+ riskBadge(
40
+ typeof packet.risk_level === "string" ? packet.risk_level : undefined,
41
+ ),
42
+ );
43
+ lines.push("");
44
+ lines.push("scope:");
45
+ add(String(packet.scope ?? ""));
46
+ lines.push("");
47
+
48
+ const assumptions = Array.isArray(packet.assumptions)
49
+ ? (packet.assumptions as string[])
50
+ : [];
51
+ if (assumptions.length > 0) {
52
+ lines.push("assumptions:");
53
+ for (const a of assumptions) {
54
+ add(` • ${a}`);
55
+ }
56
+ lines.push("");
57
+ }
58
+
59
+ const checks = Array.isArray(packet.acceptance_checks)
60
+ ? (packet.acceptance_checks as string[])
61
+ : [];
62
+ if (checks.length > 0) {
63
+ lines.push("acceptance_checks:");
64
+ for (let i = 0; i < checks.length; i++) {
65
+ add(` ${i + 1}. ${checks[i]}`);
66
+ }
67
+ lines.push("");
68
+ }
69
+
70
+ const rollback = packet.rollback_plan as
71
+ | {
72
+ rollback_artifacts?: {
73
+ revert_command?: string;
74
+ revert_branch?: string;
75
+ patch_bundle?: string;
76
+ };
77
+ }
78
+ | undefined;
79
+ const artifacts = rollback?.rollback_artifacts;
80
+ if (artifacts) {
81
+ lines.push("rollback:");
82
+ if (artifacts.revert_command) {
83
+ add(` revert_command: ${artifacts.revert_command}`);
84
+ }
85
+ if (artifacts.revert_branch) {
86
+ add(` revert_branch: ${artifacts.revert_branch}`);
87
+ }
88
+ if (artifacts.patch_bundle) {
89
+ add(` patch_bundle: ${artifacts.patch_bundle}`);
90
+ }
91
+ }
92
+
93
+ return lines;
94
+ }
@@ -0,0 +1,83 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type {
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
7
+ import type { PlanPacketLike } from "../../../lib/harness-run-context.js";
8
+ import { formatPlanPacketLines } from "./format-plan.js";
9
+ import type { ApprovePlanToolDetails } from "./types.js";
10
+
11
+ export function renderApprovePlanCall(
12
+ args: { plan_packet?: PlanPacketLike; human_summary?: string },
13
+ theme: Theme,
14
+ ): Text {
15
+ const packet = args.plan_packet;
16
+ const planId = packet?.plan_id ?? "?";
17
+ const risk = packet?.risk_level ?? "?";
18
+ const scope = typeof packet?.scope === "string" ? packet.scope : "";
19
+ const scopeShort = scope.length > 60 ? `${scope.slice(0, 57)}...` : scope;
20
+ const summary = args.human_summary?.trim();
21
+ const head = summary
22
+ ? `${planId} ${risk} — ${summary}`
23
+ : `${planId} ${risk} — ${scopeShort || "(no scope)"}`;
24
+ return new Text(theme.fg("accent", `approve_plan: ${head}`), 0, 0);
25
+ }
26
+
27
+ export function renderApprovePlanResult(
28
+ result: AgentToolResult<unknown>,
29
+ _options: ToolRenderResultOptions,
30
+ theme: Theme,
31
+ ): Text {
32
+ const details = result.details as ApprovePlanToolDetails | undefined;
33
+ if (!details) {
34
+ const block = result.content[0];
35
+ return new Text(block?.type === "text" ? block.text : "", 0, 0);
36
+ }
37
+ if (details.cancelled) {
38
+ return new Text(theme.fg("warning", "Plan approval cancelled"), 0, 0);
39
+ }
40
+ const sel =
41
+ details.response?.kind === "selection"
42
+ ? details.response.selections[0]
43
+ : details.response?.kind === "freeform"
44
+ ? details.response.text
45
+ : "";
46
+ if (/^approve/i.test(sel ?? "")) {
47
+ return new Text(
48
+ theme.fg(
49
+ "success",
50
+ `Approved plan ${details.plan_packet.plan_id ?? ""}`.trim(),
51
+ ),
52
+ 0,
53
+ 0,
54
+ );
55
+ }
56
+ if (sel) return new Text(theme.fg("muted", sel), 0, 0);
57
+ return new Text(theme.fg("muted", "No response"), 0, 0);
58
+ }
59
+
60
+ export function renderHarnessPlanDraft(
61
+ details: {
62
+ plan_packet?: PlanPacketLike;
63
+ human_summary?: string | null;
64
+ },
65
+ width: number,
66
+ theme: Theme,
67
+ ): string[] {
68
+ const lines: string[] = [];
69
+ lines.push(theme.fg("accent", "Harness plan (pending approval)"));
70
+ if (details.human_summary) {
71
+ lines.push(theme.fg("muted", details.human_summary));
72
+ lines.push("");
73
+ }
74
+ const packet = details.plan_packet;
75
+ if (!packet) {
76
+ lines.push(theme.fg("warning", "(no plan_packet)"));
77
+ return lines;
78
+ }
79
+ for (const line of formatPlanPacketLines(packet, width)) {
80
+ lines.push(truncateToWidth(line, width));
81
+ }
82
+ return lines;
83
+ }
@@ -0,0 +1,39 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const ApprovePlanParamsSchema = Type.Object({
4
+ plan_packet: Type.Object(
5
+ {},
6
+ {
7
+ description:
8
+ "Full PlanPacket object (schema_version, plan_id, task_id, scope, assumptions, risk_level, acceptance_checks, rollback_plan).",
9
+ },
10
+ ),
11
+ human_summary: Type.Optional(
12
+ Type.String({
13
+ description: "Short summary shown above the plan body.",
14
+ }),
15
+ ),
16
+ options: Type.Optional(
17
+ Type.Array(
18
+ Type.Union([
19
+ Type.String(),
20
+ Type.Object({
21
+ title: Type.String(),
22
+ description: Type.Optional(Type.String()),
23
+ }),
24
+ ]),
25
+ ),
26
+ ),
27
+ displayMode: Type.Optional(
28
+ Type.Union([Type.Literal("overlay"), Type.Literal("inline")]),
29
+ ),
30
+ });
31
+
32
+ export const PROMPT_SNIPPET =
33
+ "approve_plan({ plan_packet: { ...PlanPacket fields... }, human_summary?: string })";
34
+
35
+ export const PROMPT_GUIDELINES = [
36
+ "Call approve_plan once with the complete plan_packet when ready for user approval.",
37
+ "Use ask_user only for clarification — not for final plan approval.",
38
+ "On Request changes, revise the plan and call approve_plan again.",
39
+ ];
@@ -0,0 +1,32 @@
1
+ import type { PlanPacketLike } from "../../../lib/harness-run-context.js";
2
+ import type { AskResponse, DialogResult } from "../ask-user/types.js";
3
+
4
+ export const DEFAULT_PLAN_APPROVAL_OPTIONS = [
5
+ "Approve",
6
+ "Request changes",
7
+ "Cancel",
8
+ ] as const;
9
+
10
+ export interface ApprovePlanParams {
11
+ plan_packet: PlanPacketLike;
12
+ human_summary?: string;
13
+ options?: Array<string | { title: string; description?: string }>;
14
+ displayMode?: "overlay" | "inline";
15
+ }
16
+
17
+ export interface ValidatedApprovePlanParams {
18
+ plan_packet: PlanPacketLike;
19
+ human_summary?: string;
20
+ options: { title: string; description?: string }[];
21
+ displayMode: "overlay" | "inline";
22
+ }
23
+
24
+ export interface ApprovePlanToolDetails {
25
+ plan_packet: PlanPacketLike;
26
+ human_summary?: string;
27
+ options: string[];
28
+ response: AskResponse | null;
29
+ cancelled: boolean;
30
+ }
31
+
32
+ export type PlanApprovalDialogResult = DialogResult;
@@ -0,0 +1,61 @@
1
+ import {
2
+ type PlanPacketLike,
3
+ validatePlanPacket,
4
+ } from "../../../lib/harness-run-context.js";
5
+ import type { AskResponse } from "../ask-user/types.js";
6
+ import { formatResultText } from "../ask-user/validate.js";
7
+ import type {
8
+ ApprovePlanParams,
9
+ ApprovePlanToolDetails,
10
+ ValidatedApprovePlanParams,
11
+ } from "./types.js";
12
+ import { DEFAULT_PLAN_APPROVAL_OPTIONS } from "./types.js";
13
+
14
+ export function validateApprovePlanParams(
15
+ params: ApprovePlanParams,
16
+ ): ValidatedApprovePlanParams | string {
17
+ const packet = params.plan_packet;
18
+ if (!packet || typeof packet !== "object") {
19
+ return "approve_plan: plan_packet object is required.";
20
+ }
21
+ const validation = validatePlanPacket(packet as PlanPacketLike);
22
+ if (!validation.valid) {
23
+ return `approve_plan: invalid plan_packet — ${validation.errors.join("; ")}`;
24
+ }
25
+ const rawOptions = params.options;
26
+ const options =
27
+ rawOptions && rawOptions.length > 0
28
+ ? rawOptions.map((o) =>
29
+ typeof o === "string"
30
+ ? { title: o }
31
+ : { title: o.title, description: o.description },
32
+ )
33
+ : DEFAULT_PLAN_APPROVAL_OPTIONS.map((title) => ({ title }));
34
+ return {
35
+ plan_packet: packet as PlanPacketLike,
36
+ human_summary: params.human_summary?.trim() || undefined,
37
+ options,
38
+ displayMode: params.displayMode ?? "overlay",
39
+ };
40
+ }
41
+
42
+ export function toApprovePlanToolDetails(
43
+ validated: ValidatedApprovePlanParams,
44
+ response: AskResponse | null,
45
+ cancelled: boolean,
46
+ ): ApprovePlanToolDetails {
47
+ return {
48
+ plan_packet: validated.plan_packet,
49
+ human_summary: validated.human_summary,
50
+ options: validated.options.map((o) => o.title),
51
+ response,
52
+ cancelled,
53
+ };
54
+ }
55
+
56
+ export function formatApprovePlanResultText(
57
+ response: AskResponse | null,
58
+ cancelled: boolean,
59
+ ): string {
60
+ return formatResultText(response, cancelled);
61
+ }