pi-plans 0.3.1 → 0.3.3

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,269 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, describe, it } from "node:test";
6
+ import {
7
+ GOAL_WAIT_CUSTOM_TYPE, filterGoalWaitMessages, getExecution, noteCompactionStarted,
8
+ registerExecutionTurnHandlers, restoreFromSession, startExecution, stopExecution,
9
+ } from "../src/exec.ts";
10
+ import { executeCommand, executeHandoff, setCurrentApi } from "../tools/execute-plan.ts";
11
+
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
13
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ let serial = 0;
15
+
16
+ async function setup(mode = "tui") {
17
+ const cwd = path.join(root, String(++serial));
18
+ fs.mkdirSync(cwd);
19
+ const planPath = path.join(cwd, "PLAN_v1.md");
20
+ fs.writeFileSync(planPath, "## Verifier Checklist\n- [ ] `VC-001` first\n- [ ] `VC-002` second\n");
21
+ const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
22
+ const messages: any[] = [];
23
+ const entries: any[] = [];
24
+ const notices: string[] = [];
25
+ const pending: unknown[] = [];
26
+ let idle = true;
27
+ let status = "";
28
+ const ctx: any = {
29
+ cwd, mode, hasUI: mode === "tui" || mode === "rpc", sessionManager: {},
30
+ isIdle: () => idle, hasPendingMessages: () => pending.length > 0,
31
+ ui: { setStatus: (_key: string, s: string) => { status = s; },
32
+ notify: (s: string) => notices.push(s), theme: { fg: (_c: string, s: string) => s },
33
+ confirm: async () => { throw new Error("same-plan resume must not re-enter handoff"); } },
34
+ };
35
+ const pi: any = {
36
+ on: (name: string, handler: any) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
37
+ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data: structuredClone(data) }),
38
+ sendUserMessage: () => { throw new Error("goal-wait must not impersonate a user"); },
39
+ sendMessage: (message: any, options: any) => {
40
+ messages.push({ ...message, options });
41
+ if (message.customType === GOAL_WAIT_CUSTOM_TYPE && options?.triggerTurn) idle = false;
42
+ },
43
+ };
44
+ registerExecutionTurnHandlers(pi);
45
+ setCurrentApi(pi);
46
+ await startExecution(pi, ctx, planPath, [
47
+ { id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
48
+ ]);
49
+ const emit = async (name: string, event = {}) => {
50
+ for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
51
+ };
52
+ const begin = async () => { idle = false; await emit("agent_start"); };
53
+ const turn = async (text = "working", stopReason = "stop") => emit("turn_end", {
54
+ message: { role: "assistant", stopReason, content: [{ type: "text", text },
55
+ ...(stopReason === "toolUse" ? [{ type: "toolCall", id: "call", name: "read", arguments: {} }] : [])],
56
+ usage: { input: 10, output: 5 } }, toolResults: [],
57
+ });
58
+ const settle = async () => { idle = true; await emit("agent_settled"); };
59
+ const run = async (text = "working", stopReason = "stop") => { await begin(); await turn(text, stopReason); await settle(); };
60
+ return { pi, ctx, planPath, messages, entries, notices, pending, emit, begin, turn, settle, run,
61
+ wakes: () => messages.filter(m => m.customType === GOAL_WAIT_CUSTOM_TYPE),
62
+ status: () => status, setIdle: (value: boolean) => { idle = value; } };
63
+ }
64
+
65
+ describe("goal-wait settled lifecycle", () => {
66
+ it("never queues on ten tool turns and never wakes after completion", async () => {
67
+ const h = await setup();
68
+ await h.begin();
69
+ for (let i = 0; i < 10; i++) await h.turn("working", "toolUse");
70
+ assert.equal(h.wakes().length, 0);
71
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
72
+ await h.turn("[DONE:VC-001] [DONE:VC-002]");
73
+ await h.settle();
74
+ await h.settle();
75
+ assert.equal(getExecution(), null);
76
+ assert.equal(h.wakes().length, 0);
77
+ assert.equal(h.messages.filter(m => m.customType === "pi-plans-complete").length, 1);
78
+ });
79
+
80
+ for (const mode of ["tui", "rpc"]) {
81
+ it(`${mode}: sends one hidden fresh wake and deduplicates settled`, async () => {
82
+ const h = await setup(mode);
83
+ await h.run("[DONE:VC-001]");
84
+ await h.settle();
85
+ const [wake] = h.wakes();
86
+ assert.equal(h.wakes().length, 1);
87
+ assert.equal(wake.display, false);
88
+ assert.equal(wake.options.triggerTurn, true);
89
+ assert.match(wake.content, /1\/2 verifier items done/);
90
+ assert.match(wake.content, /- `VC-002` second/);
91
+ assert.doesNotMatch(wake.content, /- `VC-001` first/);
92
+ assert.deepEqual(filterGoalWaitMessages([wake]), [wake]);
93
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
94
+ });
95
+ }
96
+
97
+ for (const mode of ["print", "json"]) {
98
+ it(`${mode}: tracks markers without any automatic wake`, async () => {
99
+ const h = await setup(mode);
100
+ await h.run("[DONE:VC-001]");
101
+ assert.equal(getExecution()?.items[0].done, true);
102
+ await h.run("failed", "error");
103
+ await h.run("[DONE:VC-002]");
104
+ assert.equal(h.wakes().length, 0);
105
+ assert.equal(getExecution(), null);
106
+ assert.equal(h.messages.find(m => m.customType === "pi-plans-complete").options.triggerTurn, false);
107
+ });
108
+ }
109
+
110
+ for (const gate of ["busy", "pending", "inFlight", "resumeGuard", "pendingFollowUpPrompt", "lifecycle"]) {
111
+ it(`does not send or count when ${gate} owns continuation`, async () => {
112
+ const h = await setup();
113
+ await h.begin();
114
+ await h.turn();
115
+ h.setIdle(gate !== "busy");
116
+ if (gate === "pending") h.pending.push("user message", { customType: "another-extension" });
117
+ else if (gate === "lifecycle") noteCompactionStarted(h.ctx, undefined);
118
+ else if (gate !== "busy") h.ctx.sessionManager.__executionCompaction = { [gate]: gate === "pendingFollowUpPrompt" ? "follow-up" : true };
119
+ const original = structuredClone(h.pending);
120
+ await h.emit("agent_settled");
121
+ assert.equal(h.wakes().length, 0);
122
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
123
+ assert.deepEqual(h.pending, original);
124
+ if (gate === "busy") {
125
+ await h.settle();
126
+ assert.equal(h.wakes().length, 1, "a busy notification must not consume the real settled cycle");
127
+ }
128
+ });
129
+ }
130
+
131
+ for (const stopReason of ["error", "aborted"]) {
132
+ it(`${stopReason}: pauses once and only genuine input resumes`, async () => {
133
+ const h = await setup();
134
+ await h.run("failed", stopReason);
135
+ assert.equal(getExecution()?.goalWait?.paused, true);
136
+ await h.settle();
137
+ assert.equal(h.notices.length, 1);
138
+ await h.emit("input", { source: "extension" });
139
+ await h.emit("before_agent_start");
140
+ assert.equal(getExecution()?.goalWait?.paused, true);
141
+ assert.equal(h.wakes().length, 0);
142
+ await h.emit("input", { source: "rpc" });
143
+ assert.equal(getExecution()?.goalWait?.paused, false);
144
+ assert.equal(h.wakes().length, 0, "input is already owned by Pi");
145
+ await h.run();
146
+ assert.equal(h.wakes().length, 1);
147
+ });
148
+ }
149
+
150
+ it("leaves retries to Pi and refuses unknown or intentional tool termination", async () => {
151
+ const h = await setup();
152
+ await h.begin();
153
+ await h.turn("retryable failure", "error");
154
+ await h.emit("agent_end");
155
+ assert.equal(h.wakes().length, 0);
156
+ assert.equal(getExecution()?.goalWait?.paused, false);
157
+ await h.turn("recovered");
158
+ await h.settle();
159
+ assert.equal(h.wakes().length, 1);
160
+ for (const reason of ["toolUse", "length", "unknown"]) await h.run("intentional stop", reason);
161
+ assert.equal(h.wakes().length, 1);
162
+ });
163
+
164
+ for (const [text, count, field] of [["working", 3, "noProgressRounds"], ["waiting for CI", 6, "waitRounds"]] as const) {
165
+ it(`pauses at ${count} ${field} settled cycles, not intermediate turns`, async () => {
166
+ const h = await setup();
167
+ for (let i = 1; i <= count; i++) {
168
+ await h.begin();
169
+ for (let j = 0; j < 4; j++) await h.turn("tool work", "toolUse");
170
+ assert.equal(getExecution()?.goalWait?.[field], i - 1);
171
+ await h.turn(text);
172
+ await h.settle();
173
+ await h.settle();
174
+ assert.equal(getExecution()?.goalWait?.[field], i);
175
+ }
176
+ assert.equal(h.wakes().length, count - 1);
177
+ assert.equal(getExecution()?.goalWait?.paused, true);
178
+ assert.match(h.status(), /goal-wait paused/);
179
+ });
180
+ }
181
+
182
+ it("real progress resets both nonzero counters through registered handlers", async () => {
183
+ const h = await setup();
184
+ await h.run();
185
+ await h.run("waiting for tests");
186
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 1);
187
+ assert.equal(getExecution()?.goalWait?.waitRounds, 1);
188
+ await h.run("[DONE:VC-001]");
189
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
190
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
191
+ const persisted = h.entries.filter(e => e.customType === "pi-plans-exec").at(-1).data;
192
+ assert.equal(persisted.items[0].done, true);
193
+ assert.equal(persisted.goalWait.noProgressRounds, 0);
194
+ });
195
+
196
+ it("same-plan explicit handoff resumes without losing verified progress", async () => {
197
+ const h = await setup();
198
+ await h.run("[DONE:VC-001]");
199
+ await h.run("cancelled", "aborted");
200
+ const ex = getExecution()!;
201
+ const before = structuredClone(ex);
202
+ const outcome = await executeCommand(h.ctx, h.planPath);
203
+ assert.equal(outcome.status, "executing");
204
+ assert.equal(getExecution(), ex);
205
+ assert.deepEqual(ex.items, before.items);
206
+ assert.deepEqual(ex.usage, before.usage);
207
+ assert.equal(ex.startedAt, before.startedAt);
208
+ assert.equal(ex.goalWait?.paused, false);
209
+ assert.equal(h.wakes().length, 2);
210
+ await executeCommand(h.ctx, h.planPath);
211
+ assert.equal(h.wakes().length, 2);
212
+ await assert.rejects(executeHandoff(h.ctx, h.planPath), /must not re-enter handoff/);
213
+ const other = path.join(h.ctx.cwd, "PLAN_v2.md");
214
+ fs.copyFileSync(h.planPath, other);
215
+ await assert.rejects(executeCommand(h.ctx, other), /must not re-enter handoff/);
216
+ });
217
+
218
+ it("a paused command during another run preserves its next settled opportunity", async () => {
219
+ const h = await setup();
220
+ await h.run("interrupted", "aborted");
221
+ await h.begin();
222
+ const outcome = await executeCommand(h.ctx, h.planPath);
223
+ assert.equal(outcome.status, "executing");
224
+ assert.equal(getExecution()?.goalWait?.paused, false);
225
+ assert.equal(h.wakes().length, 0, "busy command must not enqueue a kick");
226
+ await h.turn("still incomplete");
227
+ await h.settle();
228
+ assert.equal(h.wakes().length, 1);
229
+ });
230
+
231
+ it("restore preserves pause and counters but cannot replay a wake", async () => {
232
+ const h = await setup();
233
+ for (let i = 0; i < 3; i++) await h.run();
234
+ const oldWake = h.wakes().at(-1);
235
+ const before = structuredClone(getExecution()?.goalWait);
236
+ await restoreFromSession(h.pi, h.ctx, h.entries);
237
+ await h.settle();
238
+ assert.deepEqual(getExecution()?.goalWait, before);
239
+ assert.equal(h.wakes().length, 2);
240
+ assert.deepEqual(filterGoalWaitMessages([oldWake]), []);
241
+ });
242
+
243
+ for (const exit of ["stop", "complete", "replacement", "shutdown"]) {
244
+ it(`${exit} invalidates wake identity without filtering user messages`, async () => {
245
+ const h = await setup();
246
+ await h.run();
247
+ const wake = h.wakes()[0];
248
+ if (exit === "stop") await stopExecution(h.pi, h.ctx, "test");
249
+ if (exit === "complete") await h.turn("[DONE:VC-001] [DONE:VC-002]");
250
+ if (exit === "replacement") await startExecution(h.pi, h.ctx, h.planPath, [{ id: "VC-003", text: "new", done: false }]);
251
+ if (exit === "shutdown") await h.emit("session_shutdown");
252
+ await h.settle();
253
+ const user = { role: "user", content: "Goal wait: this is my text" };
254
+ const other = { customType: "another-extension", content: "continue" };
255
+ assert.deepEqual(filterGoalWaitMessages([wake, user, other]), [user, other]);
256
+ assert.equal(h.wakes().length, 1);
257
+ });
258
+ }
259
+
260
+ it("a synchronous dispatch failure pauses once instead of leaving a retry lock", async () => {
261
+ const h = await setup();
262
+ h.pi.sendMessage = () => { throw new Error("dispatch failed"); };
263
+ await h.run();
264
+ await h.settle();
265
+ assert.equal(getExecution()?.goalWait?.paused, true);
266
+ assert.match(h.notices[0], /dispatch failed/);
267
+ assert.equal(h.notices.length, 1);
268
+ });
269
+ });
@@ -94,3 +94,49 @@ describe("planning write guard", () => {
94
94
  assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
95
95
  });
96
96
  });
97
+
98
+ describe("session-bound guard run (I-002)", () => {
99
+ it("activeRunId overrides the shared pointer; null falls back", () => {
100
+ const workdir = path.join(tmpRoot, "repo-bound");
101
+ fs.mkdirSync(workdir);
102
+ initState(workdir);
103
+ const first = startRun(workdir, { topic: "first run", skill: "plan-small", requestText: "x" }).run;
104
+ const second = startRun(workdir, { topic: "second run", skill: "plan-small", requestText: "x" }).run;
105
+
106
+ // Shared pointer names `second`; this session works on `first`.
107
+ const bound = planningWriteBlockReason({
108
+ workdir,
109
+ toolName: "write",
110
+ rawPath: path.relative(workdir, path.join(first.artifact_dir, "PLAN_v1.md")),
111
+ activeRunId: first.run_id,
112
+ });
113
+ assert.equal(bound, null, "bound run artifacts stay writable");
114
+
115
+ // The other run's artifacts are NOT writable for the bound session.
116
+ const other = planningWriteBlockReason({
117
+ workdir,
118
+ toolName: "write",
119
+ rawPath: path.relative(workdir, path.join(second.artifact_dir, "PLAN_v1.md")),
120
+ activeRunId: first.run_id,
121
+ });
122
+ assert.ok(other);
123
+
124
+ // null = no session binding → legacy shared-pointer behavior.
125
+ const shared = planningWriteBlockReason({
126
+ workdir,
127
+ toolName: "write",
128
+ rawPath: path.relative(workdir, path.join(first.artifact_dir, "PLAN_v1.md")),
129
+ activeRunId: null,
130
+ });
131
+ assert.ok(shared, "shared pointer names second; first is not writable");
132
+
133
+ // A binding to a missing run falls back to the shared pointer.
134
+ const vanished = planningWriteBlockReason({
135
+ workdir,
136
+ toolName: "write",
137
+ rawPath: path.relative(workdir, path.join(second.artifact_dir, "PLAN_v1.md")),
138
+ activeRunId: "20990101T000000Z-gone",
139
+ });
140
+ assert.equal(vanished, null);
141
+ });
142
+ });
@@ -1,7 +1,9 @@
1
1
  /** Tests for the plans tool source wiring. */
2
2
 
3
3
  import * as assert from "node:assert/strict";
4
+ import { spawnSync } from "node:child_process";
4
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
5
7
  import * as path from "node:path";
6
8
  import * as url from "node:url";
7
9
  import { describe, it } from "node:test";
@@ -45,3 +47,72 @@ describe("plans tool source", () => {
45
47
  assert.match(source, /"reviewer", "criticizer", "ref-analyst"/);
46
48
  });
47
49
  });
50
+
51
+ describe("record-checkpoint transitions (I-003)", () => {
52
+ it("records plan identity and rejects forged terminal states", async () => {
53
+ const { recordCheckpointTransition } = await import("../tools/plans.ts");
54
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rcp-"));
55
+ try {
56
+ spawnSync("git", ["init"], { cwd: workdir });
57
+ const { initState, startRun } = await import("../src/state.ts");
58
+ initState(workdir);
59
+ const { run } = startRun(workdir, { topic: "rcp", skill: "plan-normal", requestText: "t" });
60
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
61
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
62
+ const ctx = { sessionManager: { id: "s" } };
63
+
64
+ // plan-written records the exact file identity.
65
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
66
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
67
+ fs.writeFileSync(planPath, "# plan body", "utf8");
68
+ const updated = recordCheckpointTransition(ctx, workdir, run.run_id, {
69
+ transition: "plan-written",
70
+ planPath,
71
+ });
72
+ assert.equal(updated.plan?.version, 1);
73
+ assert.equal(updated.plan?.sha256.length, 64);
74
+ const loaded = loadCheckpoint(workdir, run.run_id);
75
+ assert.equal(loaded.status, "ok");
76
+ assert.equal(loaded.checkpoint.plan?.path, planPath);
77
+
78
+ // completed without evidence is rejected (F-004).
79
+ assert.throws(
80
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed" }),
81
+ /evidence/,
82
+ );
83
+ // completed from the planning phase is rejected even with evidence.
84
+ assert.throws(
85
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed", evidence: "done" }),
86
+ /cannot complete from phase/,
87
+ );
88
+ // planPath is required.
89
+ assert.throws(
90
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "plan-written" }),
91
+ /planPath/,
92
+ );
93
+ } finally {
94
+ fs.rmSync(workdir, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("pre-plan compaction wiring", () => {
100
+ it("start-run marks pre-plan compaction pending under settings and execution guards", () => {
101
+ const source = readPlansSource();
102
+ assert.match(source, /import \{ getExecution, markPrePlanCompactPending \} from "\.\.\/src\/exec\.ts";/);
103
+ assert.match(source, /import \{ loadVccSettings, scaffoldVccSettings \} from "\.\.\/src\/compaction\.ts";/);
104
+ assert.match(source, /const prePlanStateRoot = resolveStateRootOrNull\(workdir\);/);
105
+ assert.match(source, /if \(prePlanStateRoot && !getExecution\(\)\) \{/);
106
+ assert.match(source, /scaffoldVccSettings\(prePlanStateRoot\);/);
107
+ assert.match(source, /loadVccSettings\(prePlanStateRoot\)\.prePlanCompact/);
108
+ assert.match(source, /markPrePlanCompactPending\(ctx, result\.run\.run_id\);/);
109
+ });
110
+
111
+ it("index.ts consumes the pending flag from the plans tool_result hook", () => {
112
+ const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
113
+ assert.match(source, /consumePrePlanCompactPending/);
114
+ assert.match(source, /customInstructions: PLANNING_PREPLAN_COMPACT_HINT/);
115
+ assert.match(source, /sendPrePlanCompactResume\(pi\)/);
116
+ assert.match(source, /pre-plan compaction skipped; continuing planning\./);
117
+ });
118
+ });