pi-plans 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/agents/criticizer.md +18 -0
- package/agents/reviewer.md +20 -0
- package/docs/assets/pi-plans-logo.svg +66 -0
- package/index.ts +375 -0
- package/package.json +58 -0
- package/references/pi-planning-workflow.md +154 -0
- package/references/plan-artifact-template.md +77 -0
- package/references/state-and-config.md +137 -0
- package/scripts/run-tests.ts +31 -0
- package/scripts/validate.ts +185 -0
- package/skills/debug-and-plan/SKILL.md +35 -0
- package/skills/plan-big/SKILL.md +24 -0
- package/skills/plan-normal/SKILL.md +24 -0
- package/skills/plan-small/SKILL.md +23 -0
- package/skills/plan-with-refs/SKILL.md +30 -0
- package/skills/planning/SKILL.md +22 -0
- package/src/exec.ts +317 -0
- package/src/execution-panel.ts +497 -0
- package/src/guard.ts +38 -0
- package/src/plan.ts +63 -0
- package/src/refine-prompts.ts +70 -0
- package/src/state.ts +490 -0
- package/src/subagent.ts +197 -0
- package/tests/exec.test.ts +249 -0
- package/tests/execution-panel.test.ts +198 -0
- package/tests/guard.test.ts +70 -0
- package/tests/plan.test.ts +105 -0
- package/tests/refine-prompts.test.ts +49 -0
- package/tests/state.test.ts +240 -0
- package/tools/ask-choice.ts +199 -0
- package/tools/execute-plan.ts +137 -0
- package/tools/plans.ts +195 -0
- package/tools/refine.ts +237 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/** State test suite (node:test, stdlib only). */
|
|
2
|
+
|
|
3
|
+
import * as assert from "node:assert/strict";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { after, before, describe, it } from "node:test";
|
|
9
|
+
import {
|
|
10
|
+
initState,
|
|
11
|
+
getRun,
|
|
12
|
+
readActive,
|
|
13
|
+
recordDecision,
|
|
14
|
+
setLanguage,
|
|
15
|
+
setArtifactRoot,
|
|
16
|
+
setRole,
|
|
17
|
+
setRunStatus,
|
|
18
|
+
showConfig,
|
|
19
|
+
startRun,
|
|
20
|
+
StateError,
|
|
21
|
+
testHooks,
|
|
22
|
+
utcNow,
|
|
23
|
+
} from "../src/state.ts";
|
|
24
|
+
|
|
25
|
+
let tmpRoot: string;
|
|
26
|
+
|
|
27
|
+
function mkWorkdir(name: string): string {
|
|
28
|
+
const dir = path.join(tmpRoot, name);
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function git(workdir: string, ...args: string[]): void {
|
|
34
|
+
const result = spawnSync("git", args, { cwd: workdir, encoding: "utf8" });
|
|
35
|
+
assert.equal(result.status, 0, result.stderr ?? "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function commonDir(workdir: string): string {
|
|
39
|
+
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd: workdir, encoding: "utf8" });
|
|
40
|
+
assert.equal(result.status, 0, result.stderr ?? "");
|
|
41
|
+
const raw = result.stdout.trim();
|
|
42
|
+
return path.resolve(workdir, raw);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readConfig(workdir: string): Record<string, any> {
|
|
46
|
+
return JSON.parse(fs.readFileSync(path.join(commonDir(workdir), "pi_plans", "config.json"), "utf8"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
before(() => {
|
|
50
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-test-"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
after(() => {
|
|
54
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
55
|
+
testHooks.now = () => new Date();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("init", () => {
|
|
59
|
+
it("auto-inits and resolves state under the git common dir", () => {
|
|
60
|
+
const workdir = mkWorkdir("fresh");
|
|
61
|
+
const result = initState(workdir);
|
|
62
|
+
assert.ok(result.notices.some((notice) => notice.includes("git init")));
|
|
63
|
+
const state = path.join(workdir, ".git", "pi_plans");
|
|
64
|
+
assert.equal(path.join(commonDir(workdir), "pi_plans"), state);
|
|
65
|
+
const config = JSON.parse(fs.readFileSync(path.join(state, "config.json"), "utf8"));
|
|
66
|
+
assert.equal(config.schema, 1);
|
|
67
|
+
assert.equal(config.language.tag, null);
|
|
68
|
+
assert.equal(config.reviewer.mode, "delegated-subagent");
|
|
69
|
+
assert.equal(config.reviewer.confirmed_at, null);
|
|
70
|
+
assert.equal(config.criticizer.confirmed_at, null);
|
|
71
|
+
assert.equal(config.artifact_root, "./docs/pi-plans");
|
|
72
|
+
assert.equal(config.artifact_root_source, "unset");
|
|
73
|
+
assert.equal(config.artifact_root_updated_at, null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("migrates legacy artifact roots to ./docs/pi-plans", () => {
|
|
77
|
+
const workdir = mkWorkdir("artifact-root-migration");
|
|
78
|
+
initState(workdir);
|
|
79
|
+
const configPath = path.join(commonDir(workdir), "pi_plans", "config.json");
|
|
80
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
81
|
+
config.artifact_root = "docs/plans";
|
|
82
|
+
delete config.artifact_root_source;
|
|
83
|
+
delete config.artifact_root_updated_at;
|
|
84
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
85
|
+
const updated = initState(workdir);
|
|
86
|
+
assert.equal(updated.config.artifact_root, "./docs/pi-plans");
|
|
87
|
+
assert.equal(updated.config.artifact_root_source, "unset");
|
|
88
|
+
assert.equal(readConfig(workdir).artifact_root, "./docs/pi-plans");
|
|
89
|
+
assert.equal(readConfig(workdir).artifact_root_source, "unset");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("roundtrips language, artifact root, and start-run", () => {
|
|
93
|
+
const workdir = mkWorkdir("roundtrip");
|
|
94
|
+
setLanguage(workdir, "zh-Hans", "user");
|
|
95
|
+
setArtifactRoot(workdir, "./docs/pi-plans", "user");
|
|
96
|
+
assert.equal(readConfig(workdir).language.tag, "zh-Hans");
|
|
97
|
+
assert.equal(readConfig(workdir).artifact_root, "./docs/pi-plans");
|
|
98
|
+
assert.equal(readConfig(workdir).artifact_root_source, "user");
|
|
99
|
+
const { run } = startRun(workdir, {
|
|
100
|
+
topic: "Example Plan",
|
|
101
|
+
skill: "plan-small",
|
|
102
|
+
requestText: "Plan the example",
|
|
103
|
+
});
|
|
104
|
+
assert.ok(fs.statSync(run.artifact_dir).isDirectory());
|
|
105
|
+
const active = readActive(workdir);
|
|
106
|
+
assert.equal(active?.run_id, run.run_id);
|
|
107
|
+
assert.ok(fs.existsSync(path.join(active!.run_dir, "run.json")));
|
|
108
|
+
const loaded = getRun(workdir, run.run_id);
|
|
109
|
+
assert.equal(loaded?.status, "planning");
|
|
110
|
+
recordDecision(workdir, run.run_id, {
|
|
111
|
+
question: "Q?",
|
|
112
|
+
options: ["a", "b"],
|
|
113
|
+
answer: "a",
|
|
114
|
+
answer_source: "user",
|
|
115
|
+
});
|
|
116
|
+
const decisions = fs.readFileSync(path.join(active!.run_dir, "decisions.jsonl"), "utf8").trim().split("\n");
|
|
117
|
+
assert.equal(decisions.length, 1);
|
|
118
|
+
assert.equal(JSON.parse(decisions[0]!).answer, "a");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("subdir uses the enclosing repo", () => {
|
|
122
|
+
const repo = mkWorkdir("enclosing");
|
|
123
|
+
git(repo, "init", "-q");
|
|
124
|
+
const sub = path.join(repo, "pkg", "sub");
|
|
125
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
126
|
+
const result = initState(sub);
|
|
127
|
+
assert.ok(result.notices.some((notice) => notice.includes("enclosing repository")));
|
|
128
|
+
assert.ok(fs.existsSync(path.join(commonDir(repo), "pi_plans", "config.json")));
|
|
129
|
+
assert.ok(!fs.existsSync(path.join(sub, ".git")));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("supports private planning docs under .git/pi_plans/plans", () => {
|
|
133
|
+
const workdir = mkWorkdir("private-artifact-root");
|
|
134
|
+
setArtifactRoot(workdir, "./.git/pi_plans/plans", "user");
|
|
135
|
+
const { run } = startRun(workdir, {
|
|
136
|
+
topic: "Private Docs",
|
|
137
|
+
skill: "plan-small",
|
|
138
|
+
requestText: "Plan the example",
|
|
139
|
+
});
|
|
140
|
+
assert.ok(fs.statSync(run.artifact_dir).isDirectory());
|
|
141
|
+
assert.equal(readConfig(workdir).artifact_root, "./.git/pi_plans/plans");
|
|
142
|
+
assert.equal(run.artifact_dir, path.join(workdir, ".git", "pi_plans", "plans", `${run.created_at.slice(0, 10)}-private-docs`));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("scrubs leaked GIT_DIR env", () => {
|
|
146
|
+
const repoA = mkWorkdir("repo-a");
|
|
147
|
+
git(repoA, "init", "-q");
|
|
148
|
+
const workdir = mkWorkdir("repo-b");
|
|
149
|
+
const stateUrl = new URL("../src/state.ts", import.meta.url).href;
|
|
150
|
+
const driver = path.join(tmpRoot, "env-driver.mjs");
|
|
151
|
+
fs.writeFileSync(
|
|
152
|
+
driver,
|
|
153
|
+
`import { initState } from ${JSON.stringify(stateUrl)};\ninitState(process.argv[2]);\n`,
|
|
154
|
+
);
|
|
155
|
+
const result = spawnSync(process.execPath, ["--experimental-strip-types", driver, workdir], {
|
|
156
|
+
env: { ...process.env, GIT_DIR: path.join(repoA, ".git") },
|
|
157
|
+
encoding: "utf8",
|
|
158
|
+
});
|
|
159
|
+
assert.equal(result.status, 0, result.stderr ?? "");
|
|
160
|
+
assert.ok(fs.existsSync(path.join(workdir, ".git", "pi_plans", "config.json")));
|
|
161
|
+
assert.ok(!fs.existsSync(path.join(repoA, ".git", "pi_plans")));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses broken .git entries and bare repos", () => {
|
|
165
|
+
const broken = mkWorkdir("broken");
|
|
166
|
+
fs.writeFileSync(path.join(broken, ".git"), "gitdir: /nonexistent/xyz\n");
|
|
167
|
+
assert.throws(() => initState(broken), StateError);
|
|
168
|
+
assert.ok(fs.statSync(path.join(broken, ".git")).isFile());
|
|
169
|
+
|
|
170
|
+
const bare = mkWorkdir("bare");
|
|
171
|
+
git(bare, "init", "-q", "--bare");
|
|
172
|
+
assert.throws(() => initState(bare), StateError);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("runs", () => {
|
|
177
|
+
it("dedups run ids within the same second", () => {
|
|
178
|
+
const workdir = mkWorkdir("dedup");
|
|
179
|
+
git(workdir, "init", "-q");
|
|
180
|
+
testHooks.now = () => new Date("2026-08-25T13:00:00Z");
|
|
181
|
+
assert.equal(utcNow(), "2026-08-25T13:00:00Z");
|
|
182
|
+
const first = startRun(workdir, { topic: "Same Topic", skill: "plan-normal", requestText: "x" });
|
|
183
|
+
const second = startRun(workdir, { topic: "Same Topic", skill: "plan-normal", requestText: "x" });
|
|
184
|
+
assert.equal(first.run.run_id, "20260825T130000Z-same-topic");
|
|
185
|
+
assert.equal(second.run.run_id, "20260825T130000Z-same-topic-2");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("tracks run status transitions", () => {
|
|
189
|
+
const workdir = mkWorkdir("status");
|
|
190
|
+
git(workdir, "init", "-q");
|
|
191
|
+
const { run } = startRun(workdir, { topic: "status flow", skill: "plan-normal", requestText: "x" });
|
|
192
|
+
const updated = setRunStatus(workdir, run.run_id, "accepted");
|
|
193
|
+
assert.equal(updated.status, "accepted");
|
|
194
|
+
assert.equal(getRun(workdir, run.run_id)?.status, "accepted");
|
|
195
|
+
assert.throws(() => setRunStatus(workdir, run.run_id, "bogus"), StateError);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("set-role invariants", () => {
|
|
200
|
+
it("mode-only edits never forge or discard confirmations", () => {
|
|
201
|
+
const workdir = mkWorkdir("roles");
|
|
202
|
+
git(workdir, "init", "-q");
|
|
203
|
+
initState(workdir);
|
|
204
|
+
|
|
205
|
+
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
206
|
+
let role = readConfig(workdir).reviewer;
|
|
207
|
+
assert.equal(role.mode, "current-session");
|
|
208
|
+
assert.equal(role.confirmed_at, null);
|
|
209
|
+
|
|
210
|
+
setRole(workdir, {
|
|
211
|
+
role: "reviewer",
|
|
212
|
+
mode: "delegated-subagent",
|
|
213
|
+
modelSelector: "deepseek/deepseek-v4-flash",
|
|
214
|
+
confirmed: true,
|
|
215
|
+
});
|
|
216
|
+
role = readConfig(workdir).reviewer;
|
|
217
|
+
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
218
|
+
const stamped = role.confirmed_at;
|
|
219
|
+
assert.ok(stamped);
|
|
220
|
+
|
|
221
|
+
setRole(workdir, { role: "reviewer", mode: "current-session" });
|
|
222
|
+
role = readConfig(workdir).reviewer;
|
|
223
|
+
assert.equal(role.mode, "current-session");
|
|
224
|
+
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
225
|
+
assert.equal(role.confirmed_at, stamped);
|
|
226
|
+
|
|
227
|
+
setRole(workdir, { role: "reviewer", resetConfirmation: true });
|
|
228
|
+
role = readConfig(workdir).reviewer;
|
|
229
|
+
assert.equal(role.confirmed_at, null);
|
|
230
|
+
assert.equal(role.model_selector, "deepseek/deepseek-v4-flash");
|
|
231
|
+
|
|
232
|
+
// Confirmed-inherit is distinguishable from never-confirmed.
|
|
233
|
+
setRole(workdir, { role: "criticizer", confirmed: true });
|
|
234
|
+
role = readConfig(workdir).criticizer;
|
|
235
|
+
assert.equal(role.model_selector, null);
|
|
236
|
+
assert.ok(role.confirmed_at);
|
|
237
|
+
|
|
238
|
+
assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_choice` tool — the choice-prompt contract as a Pi tool.
|
|
3
|
+
*
|
|
4
|
+
* Every user-facing planning/refinement question goes through this tool:
|
|
5
|
+
* recommended option first, real alternatives next, `Other` second-last,
|
|
6
|
+
* `Auto-complete` last. Auto-complete may answer planning and refinement
|
|
7
|
+
* questions only; it is forbidden for execution handoff and any
|
|
8
|
+
* external-state change (pass autoComplete: false there).
|
|
9
|
+
*
|
|
10
|
+
* Answers are recorded automatically in the active run's decisions.jsonl.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
|
|
18
|
+
|
|
19
|
+
const Option = Type.Object({
|
|
20
|
+
label: Type.String({ description: "Option label" }),
|
|
21
|
+
description: Type.Optional(Type.String({ description: "Short tradeoff that matters, shown to the user" })),
|
|
22
|
+
recommended: Type.Optional(Type.Boolean({ description: "Mark exactly one recommended option; put it first" })),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const AskChoiceParams = Type.Object({
|
|
26
|
+
question: Type.String({ description: "The question to ask, in the configured language" }),
|
|
27
|
+
options: Type.Array(Option, { description: "Ordered options: recommended first, alternatives next. Do not include Other or Auto-complete yourself." }),
|
|
28
|
+
allowOther: Type.Optional(Type.Boolean({ description: "Offer free-form input (default true)" })),
|
|
29
|
+
autoComplete: Type.Optional(
|
|
30
|
+
Type.Boolean({
|
|
31
|
+
description:
|
|
32
|
+
"Offer the Auto-complete option (default true). MUST be false for the execution handoff, install waivers, publishing, deployment, merge, push, credential use, or any external-state change.",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface AskChoiceDetails {
|
|
39
|
+
question: string;
|
|
40
|
+
options: string[];
|
|
41
|
+
answer: string | null;
|
|
42
|
+
source: "user" | "auto-complete" | "other" | "cancelled";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
46
|
+
pi.registerTool({
|
|
47
|
+
name: "ask_choice",
|
|
48
|
+
label: "Ask Choice",
|
|
49
|
+
description:
|
|
50
|
+
"Ask the user one planning or refinement question as a numbered choice prompt: recommended option first, alternatives next, then Other and Auto-complete. One question per call. Use for every user-facing planning question, the final scope confirmation, refinement-mode questions, language/role/model settings, and the execution handoff (with autoComplete: false).",
|
|
51
|
+
promptSnippet: "Ask structured planning questions with recommended/Other/Auto-complete ordering",
|
|
52
|
+
promptGuidelines: [
|
|
53
|
+
"Use ask_choice for every pi-plans question to the user instead of plain-text questions; it enforces option ordering and records decisions.",
|
|
54
|
+
],
|
|
55
|
+
parameters: AskChoiceParams,
|
|
56
|
+
executionMode: "sequential",
|
|
57
|
+
|
|
58
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
59
|
+
const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
|
|
60
|
+
const allowOther = params.allowOther ?? true;
|
|
61
|
+
const autoComplete = params.autoComplete ?? true;
|
|
62
|
+
const options = params.options;
|
|
63
|
+
if (options.length === 0) throw new Error("ask_choice requires at least one option");
|
|
64
|
+
const recommended = options.find((option) => option.recommended) ?? options[0];
|
|
65
|
+
|
|
66
|
+
const record = (answer: string, source: AskChoiceDetails["source"]) => {
|
|
67
|
+
const active = readActive(workdir);
|
|
68
|
+
if (!active) return;
|
|
69
|
+
try {
|
|
70
|
+
recordDecision(workdir, active.run_id, {
|
|
71
|
+
question: params.question,
|
|
72
|
+
options: options.map((option) => option.label),
|
|
73
|
+
answer,
|
|
74
|
+
answer_source: source === "auto-complete" ? "auto-complete" : "user",
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
/* recording is best-effort; the question still gets answered */
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const details = (answer: string | null, source: AskChoiceDetails["source"]): AskChoiceDetails => ({
|
|
82
|
+
question: params.question,
|
|
83
|
+
options: options.map((option) => option.label),
|
|
84
|
+
answer,
|
|
85
|
+
source,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// No UI (print/json mode): planning questions may auto-complete;
|
|
89
|
+
// questions without Auto-complete must stop and wait for the user.
|
|
90
|
+
if (!ctx.hasUI) {
|
|
91
|
+
if (!autoComplete) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"No UI available and this question must not be auto-completed (execution handoff or external-state change). Stop and wait for the user.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
record(recommended.label, "auto-complete");
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: `No UI available. Auto-complete selected the recommended option: ${recommended.label}`,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
details: details(recommended.label, "auto-complete"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const displayLabels: string[] = options.map((option, index) => {
|
|
109
|
+
let label = `${index + 1}. ${option.label}`;
|
|
110
|
+
if (option === recommended) label += " (recommended)";
|
|
111
|
+
if (option.description) label += ` — ${option.description}`;
|
|
112
|
+
return label;
|
|
113
|
+
});
|
|
114
|
+
if (allowOther) displayLabels.push("Other… (type your own answer)");
|
|
115
|
+
if (autoComplete) displayLabels.push("Auto-complete (take the recommended option)");
|
|
116
|
+
|
|
117
|
+
const selected = await ctx.ui.select(params.question, displayLabels);
|
|
118
|
+
if (selected === undefined) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: "User cancelled the question. Do not treat this as approval for anything; ask again later or stop.",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
details: details(null, "cancelled"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (autoComplete && selected.startsWith("Auto-complete")) {
|
|
131
|
+
record(recommended.label, "auto-complete");
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: `User selected Auto-complete; take the recommended option: ${options.findIndex((o) => o === recommended) + 1}. ${recommended.label}`,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
details: details(recommended.label, "auto-complete"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (allowOther && selected.startsWith("Other…")) {
|
|
144
|
+
const typed = await ctx.ui.input(`${params.question} — your answer:`);
|
|
145
|
+
if (typed === undefined || !typed.trim()) {
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: "User cancelled the free-form answer. Ask again or stop." }],
|
|
148
|
+
details: details(null, "cancelled"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const answer = typed.trim();
|
|
152
|
+
record(answer, "user");
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: `User wrote: ${answer}` }],
|
|
155
|
+
details: details(answer, "other"),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const index = displayLabels.indexOf(selected);
|
|
160
|
+
const option = index >= 0 && index < options.length ? options[index] : undefined;
|
|
161
|
+
if (!option) {
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: "text", text: `User selected: ${selected}` }],
|
|
164
|
+
details: details(selected, "user"),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
record(option.label, "user");
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: `User selected: ${index + 1}. ${option.label}` }],
|
|
170
|
+
details: details(option.label, "user"),
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
renderCall(args, theme) {
|
|
175
|
+
let text = theme.fg("toolTitle", theme.bold("ask_choice ")) + theme.fg("muted", args.question);
|
|
176
|
+
const labels = (args.options ?? []).map((option: { label: string }, i: number) => `${i + 1}. ${option.label}`);
|
|
177
|
+
if (labels.length) text += `\n${theme.fg("dim", ` Options: ${labels.join(", ")}`)}`;
|
|
178
|
+
return new Text(text, 0, 0);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
renderResult(result, _options, theme) {
|
|
182
|
+
const details = result.details as AskChoiceDetails | undefined;
|
|
183
|
+
if (!details) {
|
|
184
|
+
const text = result.content[0];
|
|
185
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
186
|
+
}
|
|
187
|
+
if (details.answer === null || details.source === "cancelled") {
|
|
188
|
+
return new Text(theme.fg("warning", "✗ cancelled"), 0, 0);
|
|
189
|
+
}
|
|
190
|
+
const prefix =
|
|
191
|
+
details.source === "auto-complete"
|
|
192
|
+
? theme.fg("muted", "✓ (auto-complete) ")
|
|
193
|
+
: details.source === "other"
|
|
194
|
+
? theme.fg("muted", "✓ (wrote) ")
|
|
195
|
+
: theme.fg("success", "✓ ");
|
|
196
|
+
return new Text(prefix + theme.fg("accent", details.answer), 0, 0);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `execute_plan` tool — the execution handoff. On explicit user approval (no
|
|
3
|
+
* Auto-complete) the extension enters execution mode with checklist tracking.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { startExecution } from "../src/exec.ts";
|
|
12
|
+
import { latestPlanVersion, parseChecklist } from "../src/plan.ts";
|
|
13
|
+
import { normalizeWorkdir, readActive } from "../src/state.ts";
|
|
14
|
+
|
|
15
|
+
const ExecutePlanParams = Type.Object({
|
|
16
|
+
planPath: Type.Optional(
|
|
17
|
+
Type.String({ description: "Path to the accepted PLAN_vN.md. Default: highest version in the active run's artifact directory." }),
|
|
18
|
+
),
|
|
19
|
+
workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export interface HandoffOutcome {
|
|
23
|
+
status: "executing" | "declined" | "error";
|
|
24
|
+
planPath?: string;
|
|
25
|
+
itemCount?: number;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shared handoff logic for the execute_plan tool and /plans-execute command. */
|
|
30
|
+
export async function executeHandoff(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
planPathArg?: string,
|
|
33
|
+
workdirArg?: string,
|
|
34
|
+
): Promise<HandoffOutcome> {
|
|
35
|
+
const workdir = normalizeWorkdir(workdirArg ?? ctx.cwd);
|
|
36
|
+
|
|
37
|
+
let planPath: string | null = null;
|
|
38
|
+
if (planPathArg) {
|
|
39
|
+
planPath = path.resolve(workdir, planPathArg.replace(/^@/, ""));
|
|
40
|
+
} else {
|
|
41
|
+
const active = readActive(workdir);
|
|
42
|
+
if (!active) {
|
|
43
|
+
return {
|
|
44
|
+
status: "error",
|
|
45
|
+
message: "No plan path given and no active planning run found. Pass planPath or start a run first.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const latest = latestPlanVersion(active.artifact_dir);
|
|
49
|
+
if (!latest) {
|
|
50
|
+
return { status: "error", message: `No PLAN_vN.md found in ${active.artifact_dir}` };
|
|
51
|
+
}
|
|
52
|
+
planPath = latest.path;
|
|
53
|
+
}
|
|
54
|
+
if (!fs.existsSync(planPath)) {
|
|
55
|
+
return { status: "error", message: `Plan file not found: ${planPath}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const items = parseChecklist(fs.readFileSync(planPath, "utf8"));
|
|
59
|
+
if (items.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
status: "error",
|
|
62
|
+
message: `${planPath} has no parsable \`## Verifier Checklist\` with \`- [ ] \`VC-###\` ...\` items. Fix the plan before execution.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!ctx.hasUI) {
|
|
67
|
+
return {
|
|
68
|
+
status: "error",
|
|
69
|
+
message:
|
|
70
|
+
"The execution handoff requires explicit user approval and must never be auto-completed. Run interactively.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const preview = items.map((item) => `- ${item.done ? "☑" : "☐"} ${item.id}`).join("\n");
|
|
75
|
+
const approved = await ctx.ui.confirm(
|
|
76
|
+
"Execute this plan now?",
|
|
77
|
+
`${planPath}\n${items.length} verifier item(s):\n${preview}\n\nExecution mode enables write access and tracks [DONE:VC-xxx] progress.`,
|
|
78
|
+
);
|
|
79
|
+
if (!approved) {
|
|
80
|
+
return { status: "declined", message: "User declined execution. Stay in planning; ask how to proceed.", planPath };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
startExecution(getCurrentApi(), ctx, planPath, items);
|
|
84
|
+
return {
|
|
85
|
+
status: "executing",
|
|
86
|
+
planPath,
|
|
87
|
+
itemCount: items.length,
|
|
88
|
+
message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The tool registers with the ExtensionAPI in scope; keep a module-level
|
|
93
|
+
// reference so the shared handoff helper can reach appendEntry/sendMessage.
|
|
94
|
+
let currentApi: ExtensionAPI | null = null;
|
|
95
|
+
export function setCurrentApi(api: ExtensionAPI): void {
|
|
96
|
+
currentApi = api;
|
|
97
|
+
}
|
|
98
|
+
function getCurrentApi(): ExtensionAPI {
|
|
99
|
+
if (!currentApi) throw new Error("execute_plan used before extension initialization");
|
|
100
|
+
return currentApi;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerExecutePlanTool(pi: ExtensionAPI): void {
|
|
104
|
+
setCurrentApi(pi);
|
|
105
|
+
pi.registerTool({
|
|
106
|
+
name: "execute_plan",
|
|
107
|
+
label: "Execute Plan",
|
|
108
|
+
description:
|
|
109
|
+
"Execution handoff for an accepted plan. Asks the user for explicit approval (never auto-completed), then enters plan-execution mode: the extension injects the remaining Verifier Checklist every turn, tracks [DONE:VC-xxx] markers, and completes when every item passes. Only call after the user chose 'Execute this plan now' at the handoff question.",
|
|
110
|
+
promptSnippet: "Hand an accepted plan off to the tracked execution loop",
|
|
111
|
+
parameters: ExecutePlanParams,
|
|
112
|
+
|
|
113
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
114
|
+
const outcome = await executeHandoff(ctx, params.planPath, params.workdir);
|
|
115
|
+
if (outcome.status === "error") throw new Error(outcome.message);
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text: outcome.message }],
|
|
118
|
+
details: outcome,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
renderCall(args, theme) {
|
|
123
|
+
const short = args.planPath ? args.planPath.split("/").pop() : "latest accepted plan";
|
|
124
|
+
return new Text(
|
|
125
|
+
theme.fg("toolTitle", theme.bold("execute_plan ")) + theme.fg("accent", short ?? ""),
|
|
126
|
+
0,
|
|
127
|
+
0,
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
renderResult(result, _options, theme) {
|
|
132
|
+
const text = result.content[0];
|
|
133
|
+
const raw = text?.type === "text" ? text.text : "";
|
|
134
|
+
return new Text(theme.fg("success", "🚀 ") + theme.fg("muted", raw.slice(0, 160)), 0, 0);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|