pi-plans 0.3.2 → 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.
- package/README.md +5 -3
- package/index.ts +92 -6
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +7 -1
- package/references/plan-artifact-template.md +4 -0
- package/references/state-and-config.md +19 -0
- package/src/autocomplete.ts +2 -1
- package/src/code-graph/commands.ts +21 -6
- package/src/compaction.ts +10 -0
- package/src/config-command.ts +2 -0
- package/src/exec.ts +256 -10
- package/src/guard.ts +7 -1
- package/src/resume-command.ts +450 -0
- package/src/resume.ts +205 -0
- package/src/run-context.ts +97 -0
- package/src/run-ownership.ts +310 -0
- package/src/state.ts +24 -1
- package/src/termination-prompt.ts +8 -0
- package/src/workflow-state.ts +1159 -0
- package/tests/ask-choice.test.ts +113 -0
- package/tests/compaction.test.ts +39 -0
- package/tests/exec.test.ts +250 -0
- package/tests/guard.test.ts +46 -0
- package/tests/plans.test.ts +71 -0
- package/tests/refine-resume.test.ts +324 -0
- package/tests/resume-lifecycle.test.ts +385 -0
- package/tests/resume.test.ts +384 -0
- package/tests/run-context.test.ts +119 -0
- package/tests/run-ownership.test.ts +170 -0
- package/tests/state.test.ts +16 -0
- package/tests/workflow-state.test.ts +432 -0
- package/tools/analyze-refs.ts +2 -1
- package/tools/ask-choice.ts +61 -3
- package/tools/code-graph.ts +5 -1
- package/tools/execute-plan.ts +2 -1
- package/tools/plans.ts +119 -0
- package/tools/refine.ts +91 -7
package/tests/state.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
setRole,
|
|
18
18
|
setRunStatus,
|
|
19
19
|
showConfig,
|
|
20
|
+
runDirPath,
|
|
20
21
|
startRun,
|
|
21
22
|
StateError,
|
|
22
23
|
testHooks,
|
|
@@ -327,3 +328,18 @@ describe("set-role invariants", () => {
|
|
|
327
328
|
assert.throws(() => setRole(workdir, { role: "reviewer", confirmed: true, resetConfirmation: true }), StateError);
|
|
328
329
|
});
|
|
329
330
|
});
|
|
331
|
+
|
|
332
|
+
describe("runDirPath", () => {
|
|
333
|
+
it("resolves run directories read-only and returns null for unknown runs", () => {
|
|
334
|
+
const workdir = mkWorkdir("run-dir-path");
|
|
335
|
+
initState(workdir);
|
|
336
|
+
const { run } = startRun(workdir, { topic: "rdp", skill: "plan-small", requestText: "t" });
|
|
337
|
+
const dir = runDirPath(workdir, run.run_id);
|
|
338
|
+
assert.ok(dir);
|
|
339
|
+
assert.ok(fs.existsSync(path.join(dir, "run.json")));
|
|
340
|
+
assert.equal(runDirPath(workdir, "20990101T000000Z-nope"), null);
|
|
341
|
+
const notARepo = path.join(tmpRoot, "not-a-repo");
|
|
342
|
+
fs.mkdirSync(notARepo, { recursive: true });
|
|
343
|
+
assert.equal(runDirPath(notARepo, run.run_id), null);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/** Workflow checkpoint test suite (node:test, stdlib only) — I-001. */
|
|
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
|
+
applyCompleted,
|
|
11
|
+
applyExecutionApproved,
|
|
12
|
+
applyExecutionCompleted,
|
|
13
|
+
applyExecutionProgress,
|
|
14
|
+
applyExecutionHeadChanged,
|
|
15
|
+
applyExecutionStopped,
|
|
16
|
+
applyImplementationReviewConfigured,
|
|
17
|
+
applyImplementationRoundFinished,
|
|
18
|
+
applyLaneResult,
|
|
19
|
+
applyMigration,
|
|
20
|
+
applyPlanWritten,
|
|
21
|
+
applyQuestionAnswered,
|
|
22
|
+
applyQuestionAsked,
|
|
23
|
+
applyReviewConsolidated,
|
|
24
|
+
applyReviewRoundStarted,
|
|
25
|
+
checkpointFilePath,
|
|
26
|
+
createCheckpoint,
|
|
27
|
+
loadCheckpoint,
|
|
28
|
+
mutateCheckpoint,
|
|
29
|
+
planIdentityOf,
|
|
30
|
+
readReviewOutput,
|
|
31
|
+
reconcilePendingWithAnswered,
|
|
32
|
+
resolveHeadAt,
|
|
33
|
+
safeRelativePath,
|
|
34
|
+
safeResolveInside,
|
|
35
|
+
sha256File,
|
|
36
|
+
StaleCheckpointError,
|
|
37
|
+
validateCheckpoint,
|
|
38
|
+
writeReviewOutput,
|
|
39
|
+
type ExecutionApproval,
|
|
40
|
+
type WorkflowCheckpoint,
|
|
41
|
+
} from "../src/workflow-state.ts";
|
|
42
|
+
import { acquireOwnership } from "../src/run-ownership.ts";
|
|
43
|
+
import { initState, startRun, StateError } from "../src/state.ts";
|
|
44
|
+
|
|
45
|
+
let tmpRoot: string;
|
|
46
|
+
|
|
47
|
+
function mkWorkdir(name: string): string {
|
|
48
|
+
const dir = path.join(tmpRoot, name);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function git(workdir: string, ...args: string[]): void {
|
|
54
|
+
const result = spawnSync("git", args, { cwd: workdir, encoding: "utf8" });
|
|
55
|
+
assert.equal(result.status, 0, result.stderr ?? "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setupRun(name: string): { workdir: string; runId: string } {
|
|
59
|
+
const workdir = mkWorkdir(name);
|
|
60
|
+
git(workdir, "init");
|
|
61
|
+
git(workdir, "config", "user.email", "t@example.com");
|
|
62
|
+
git(workdir, "config", "user.name", "T");
|
|
63
|
+
initState(workdir);
|
|
64
|
+
const { run } = startRun(workdir, { topic: "checkpoint-tests", skill: "plan-normal", requestText: "test" });
|
|
65
|
+
return { workdir, runId: run.run_id };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function baseCheckpoint(workdir: string, runId: string): WorkflowCheckpoint {
|
|
69
|
+
const loaded = loadCheckpoint(workdir, runId);
|
|
70
|
+
assert.equal(loaded.status, "ok");
|
|
71
|
+
return loaded.checkpoint;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
before(() => {
|
|
75
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-wf-"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
after(() => {
|
|
79
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("create / load / mutate storage", () => {
|
|
83
|
+
it("creates a valid initial checkpoint and reloads it", () => {
|
|
84
|
+
const { workdir, runId } = setupRun("storage-create");
|
|
85
|
+
const created = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
86
|
+
assert.equal(created.phase, "planning");
|
|
87
|
+
assert.equal(created.nextAction, "continue-planning");
|
|
88
|
+
assert.equal(created.revision, 1);
|
|
89
|
+
const loaded = loadCheckpoint(workdir, runId);
|
|
90
|
+
assert.equal(loaded.status, "ok");
|
|
91
|
+
assert.equal(loaded.checkpoint.runId, runId);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("refuses to overwrite an existing checkpoint via createCheckpoint", () => {
|
|
95
|
+
const { workdir, runId } = setupRun("storage-no-overwrite");
|
|
96
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
97
|
+
assert.throws(() => createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir }), StateError);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("mutateCheckpoint bumps revisions atomically", () => {
|
|
101
|
+
const { workdir, runId } = setupRun("storage-revision");
|
|
102
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
103
|
+
const next = mutateCheckpoint(workdir, runId, (cp) => ({ ...cp, autoComplete: true }));
|
|
104
|
+
assert.equal(next.revision, 2);
|
|
105
|
+
const loaded = loadCheckpoint(workdir, runId);
|
|
106
|
+
assert.equal(loaded.status, "ok");
|
|
107
|
+
assert.equal(loaded.checkpoint.revision, 2);
|
|
108
|
+
assert.equal(loaded.checkpoint.autoComplete, true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("distinguishes missing from corrupt and never overwrites corrupt files", () => {
|
|
112
|
+
const { workdir, runId } = setupRun("storage-corrupt");
|
|
113
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
114
|
+
const filePath = checkpointFilePath(workdir, runId);
|
|
115
|
+
assert.ok(filePath);
|
|
116
|
+
const bytes = fs.readFileSync(filePath, "utf8");
|
|
117
|
+
fs.writeFileSync(filePath, "{ not json", "utf8");
|
|
118
|
+
const loaded = loadCheckpoint(workdir, runId);
|
|
119
|
+
assert.equal(loaded.status, "corrupt");
|
|
120
|
+
assert.ok(loaded.status === "corrupt" && loaded.error.includes("invalid JSON"));
|
|
121
|
+
assert.throws(
|
|
122
|
+
() => mutateCheckpoint(workdir, runId, (cp) => cp),
|
|
123
|
+
(error: unknown) => error instanceof StateError && /corrupt/.test(error.message),
|
|
124
|
+
);
|
|
125
|
+
assert.equal(fs.readFileSync(filePath, "utf8"), "{ not json");
|
|
126
|
+
fs.writeFileSync(filePath, bytes, "utf8");
|
|
127
|
+
assert.equal(loadCheckpoint(workdir, runId).status, "ok");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects unknown schema versions as corrupt without touching bytes", () => {
|
|
131
|
+
const { workdir, runId } = setupRun("storage-schema");
|
|
132
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
133
|
+
const filePath = checkpointFilePath(workdir, runId)!;
|
|
134
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
135
|
+
data.schema = 99;
|
|
136
|
+
fs.writeFileSync(filePath, JSON.stringify(data), "utf8");
|
|
137
|
+
const loaded = loadCheckpoint(workdir, runId);
|
|
138
|
+
assert.equal(loaded.status, "corrupt");
|
|
139
|
+
assert.ok(loaded.status === "corrupt" && loaded.error.includes("unsupported version"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("mutate fails cleanly when the checkpoint is missing", () => {
|
|
143
|
+
const { workdir, runId } = setupRun("storage-missing");
|
|
144
|
+
assert.throws(() => mutateCheckpoint(workdir, runId, (cp) => cp), StateError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("owner-guarded mutation refuses writes after a takeover", () => {
|
|
148
|
+
const { workdir, runId } = setupRun("storage-owner");
|
|
149
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
150
|
+
const owner = acquireOwnership(workdir, runId);
|
|
151
|
+
const next = mutateCheckpoint(workdir, runId, (cp) => ({ ...cp, autoComplete: true }), {
|
|
152
|
+
owner: { processToken: owner.processToken, generation: owner.generation },
|
|
153
|
+
});
|
|
154
|
+
assert.equal(next.revision, 2);
|
|
155
|
+
// Another session takes over: our owner check must fail.
|
|
156
|
+
const foreign = {
|
|
157
|
+
schema: 1,
|
|
158
|
+
host: owner.host,
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
pidStart: "bogus-start",
|
|
161
|
+
sessionId: null,
|
|
162
|
+
processToken: "foreign-token",
|
|
163
|
+
generation: owner.generation + 1,
|
|
164
|
+
acquiredAt: "2026-09-07T00:00:00Z",
|
|
165
|
+
};
|
|
166
|
+
fs.writeFileSync(path.join(path.dirname(checkpointFilePath(workdir, runId)!), "owner.json"), JSON.stringify(foreign), "utf8");
|
|
167
|
+
assert.throws(
|
|
168
|
+
() => mutateCheckpoint(workdir, runId, (cp) => cp, { owner: { processToken: owner.processToken, generation: owner.generation } }),
|
|
169
|
+
StateError,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("optimistic revision checks reject concurrent writes", () => {
|
|
174
|
+
const { workdir, runId } = setupRun("storage-cas");
|
|
175
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
176
|
+
mutateCheckpoint(workdir, runId, (cp) => ({ ...cp }), { expectRevision: 1 });
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => mutateCheckpoint(workdir, runId, (cp) => cp, { expectRevision: 1 }),
|
|
179
|
+
StaleCheckpointError,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("validation", () => {
|
|
185
|
+
it("rejects malformed shapes and unexpected keys", () => {
|
|
186
|
+
const { workdir, runId } = setupRun("validate-shapes");
|
|
187
|
+
const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
188
|
+
assert.throws(() => validateCheckpoint({ ...cp, phase: "bogus" }), StateError);
|
|
189
|
+
assert.throws(() => validateCheckpoint({ ...cp, nextAction: "bogus" }), StateError);
|
|
190
|
+
assert.throws(() => validateCheckpoint({ ...cp, extraKey: true }), StateError);
|
|
191
|
+
assert.throws(() => validateCheckpoint({ ...cp, revision: 0 }), StateError);
|
|
192
|
+
assert.throws(() => validateCheckpoint({ ...cp, updatedAt: "yesterday" }), StateError);
|
|
193
|
+
assert.throws(() => validateCheckpoint(null), StateError);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("rejects run ids that could traverse paths", () => {
|
|
197
|
+
const { workdir, runId } = setupRun("validate-runid");
|
|
198
|
+
const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
199
|
+
for (const bad of ["../escape", "foo/bar", ".hidden", "a b"]) {
|
|
200
|
+
assert.throws(() => validateCheckpoint({ ...cp, runId: bad }), StateError, bad);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rejects forged approval shapes", () => {
|
|
205
|
+
const { workdir, runId } = setupRun("validate-approval");
|
|
206
|
+
const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
207
|
+
const forged = {
|
|
208
|
+
...cp,
|
|
209
|
+
execution: {
|
|
210
|
+
approval: { plan: { path: "/tmp/x", version: "one", sha256: "zz" }, worktree: "/tmp", approvedAt: "nope" },
|
|
211
|
+
doneVcIds: ["VC-001"],
|
|
212
|
+
implStatus: {},
|
|
213
|
+
usage: { inToks: 0, outToks: 0 },
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
assert.throws(() => validateCheckpoint(forged), StateError);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("completed phase must use nextAction none", () => {
|
|
220
|
+
const { workdir, runId } = setupRun("validate-terminal");
|
|
221
|
+
const cp = createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
222
|
+
assert.throws(() => validateCheckpoint({ ...cp, phase: "completed", nextAction: "execute-items" }), StateError);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("safe paths and digests", () => {
|
|
227
|
+
it("safeRelativePath rejects traversal and absolute inputs", () => {
|
|
228
|
+
assert.throws(() => safeRelativePath("../x", "p"), StateError);
|
|
229
|
+
assert.throws(() => safeRelativePath("a/../../b", "p"), StateError);
|
|
230
|
+
assert.throws(() => safeRelativePath(path.resolve("x"), "p"), StateError);
|
|
231
|
+
assert.throws(() => safeRelativePath("", "p"), StateError);
|
|
232
|
+
assert.equal(safeRelativePath("reviews/a__b.md", "p"), "reviews/a__b.md");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("safeResolveInside rejects symlink escapes", () => {
|
|
236
|
+
const root = mkWorkdir("symlink-root");
|
|
237
|
+
const outside = mkWorkdir("symlink-outside");
|
|
238
|
+
fs.writeFileSync(path.join(outside, "payload.md"), "escaped", "utf8");
|
|
239
|
+
fs.symlinkSync(path.join(outside, "payload.md"), path.join(root, "link.md"));
|
|
240
|
+
assert.throws(() => safeResolveInside(root, "link.md", "p"), StateError);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("sha256File hashes full bytes and fails on missing files", () => {
|
|
244
|
+
const file = path.join(tmpRoot, "hash-target.txt");
|
|
245
|
+
fs.writeFileSync(file, "hello", "utf8");
|
|
246
|
+
assert.equal(sha256File(file), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
|
|
247
|
+
assert.throws(() => sha256File(path.join(tmpRoot, "missing")), StateError);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("resolveHeadAt returns null without commits and a sha with commits", () => {
|
|
251
|
+
const empty = mkWorkdir("head-empty");
|
|
252
|
+
git(empty, "init");
|
|
253
|
+
assert.equal(resolveHeadAt(empty), null);
|
|
254
|
+
const committed = mkWorkdir("head-set");
|
|
255
|
+
git(committed, "init");
|
|
256
|
+
git(committed, "config", "user.email", "t@example.com");
|
|
257
|
+
git(committed, "config", "user.name", "T");
|
|
258
|
+
fs.writeFileSync(path.join(committed, "a.txt"), "a", "utf8");
|
|
259
|
+
git(committed, "add", "a.txt");
|
|
260
|
+
git(committed, "commit", "-m", "a");
|
|
261
|
+
assert.match(resolveHeadAt(committed) ?? "", /^[0-9a-f]{40}$/);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("review outputs", () => {
|
|
266
|
+
it("writes and reads lane outputs inside the run directory", () => {
|
|
267
|
+
const { workdir, runId } = setupRun("reviews-io");
|
|
268
|
+
const rel = writeReviewOutput(workdir, runId, "r1", "lane-a", "# findings\nbody");
|
|
269
|
+
assert.equal(rel, "reviews/r1__lane-a.md");
|
|
270
|
+
assert.equal(readReviewOutput(workdir, runId, rel), "# findings\nbody");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("sanitizes ids and rejects traversal attempts", () => {
|
|
274
|
+
const { workdir, runId } = setupRun("reviews-sanitize");
|
|
275
|
+
assert.throws(() => writeReviewOutput(workdir, runId, "../r", "lane", "x"), StateError);
|
|
276
|
+
assert.throws(() => writeReviewOutput(workdir, runId, "r", "../lane", "x"), StateError);
|
|
277
|
+
assert.throws(() => writeReviewOutput(workdir, runId, "a/b", "lane", "x"), StateError);
|
|
278
|
+
assert.throws(() => readReviewOutput(workdir, runId, "../decisions.jsonl"), StateError);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("state machine reducers", () => {
|
|
283
|
+
it("question lifecycle: ask, answer, no re-ask (F-005 answered wins)", () => {
|
|
284
|
+
const { workdir, runId } = setupRun("sm-question");
|
|
285
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
286
|
+
let cp = baseCheckpoint(workdir, runId);
|
|
287
|
+
cp = applyQuestionAsked(cp, { questionId: "q1", question: "Q?", options: ["a", "b"] });
|
|
288
|
+
cp = applyQuestionAnswered(cp, "q1", "a", "user");
|
|
289
|
+
assert.equal(cp.pendingQuestion, null);
|
|
290
|
+
assert.equal(cp.answeredQuestions.length, 1);
|
|
291
|
+
// Same id pending while answered → reconcile drops the pending entry.
|
|
292
|
+
const withPending = { ...cp, pendingQuestion: { questionId: "q1", question: "Q?", options: ["a"], askedAt: cp.updatedAt } };
|
|
293
|
+
assert.equal(reconcilePendingWithAnswered(withPending).pendingQuestion, null);
|
|
294
|
+
assert.throws(() => applyQuestionAsked(cp, { questionId: "q1", question: "Q?", options: ["a"] }), StateError);
|
|
295
|
+
assert.throws(() => applyQuestionAnswered(cp, "q1", "b", "user"), StateError);
|
|
296
|
+
// Another question cannot be pending at the same time.
|
|
297
|
+
const two = applyQuestionAsked(cp, { questionId: "q2", question: "Q2?", options: ["a"] });
|
|
298
|
+
assert.throws(
|
|
299
|
+
() => applyQuestionAsked(two, { questionId: "q3", question: "Q3?", options: ["a"] }),
|
|
300
|
+
StateError,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("review rounds: start requires plan; lanes complete then consolidate", () => {
|
|
305
|
+
const { workdir, runId } = setupRun("sm-review");
|
|
306
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
307
|
+
let cp = baseCheckpoint(workdir, runId);
|
|
308
|
+
assert.throws(
|
|
309
|
+
() => applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] }),
|
|
310
|
+
StateError,
|
|
311
|
+
);
|
|
312
|
+
const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
|
|
313
|
+
fs.writeFileSync(planPath, "# plan", "utf8");
|
|
314
|
+
const plan = planIdentityOf(planPath, 1);
|
|
315
|
+
cp = applyPlanWritten(cp, plan);
|
|
316
|
+
cp = { ...cp, phase: "reviewing", nextAction: "run-review" };
|
|
317
|
+
cp = applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 2, lanes: [{ laneId: "l1" }, { laneId: "l2" }] });
|
|
318
|
+
assert.throws(() => applyReviewRoundStarted(cp, { roundId: "r1", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] }), StateError);
|
|
319
|
+
const file1 = writeReviewOutput(workdir, runId, "r1", "l1", "out1");
|
|
320
|
+
cp = applyLaneResult(cp, "r1", "l1", { ok: true, resultFile: file1 });
|
|
321
|
+
// Idempotent replay with the same file is a no-op.
|
|
322
|
+
assert.equal(applyLaneResult(cp, "r1", "l1", { ok: true, resultFile: file1 }), cp);
|
|
323
|
+
assert.throws(() => applyReviewConsolidated(cp, "r1"), StateError);
|
|
324
|
+
cp = applyLaneResult(cp, "r1", "l2", { ok: false, error: "boom" });
|
|
325
|
+
cp = applyReviewConsolidated(cp, "r1");
|
|
326
|
+
assert.equal(cp.reviewRounds[0]!.consolidated, true);
|
|
327
|
+
// Successful lanes must carry a result file.
|
|
328
|
+
cp = applyReviewRoundStarted(cp, { roundId: "r2", role: "reviewer", target: "plan", reviewers: 1, lanes: [{ laneId: "l1" }] });
|
|
329
|
+
const lane = { ...cp.reviewRounds[1]!, lanes: cp.reviewRounds[1]!.lanes.map((l) => ({ ...l, status: "running" as const })) };
|
|
330
|
+
const staged = { ...cp, reviewRounds: [cp.reviewRounds[0]!, lane] };
|
|
331
|
+
assert.throws(() => applyLaneResult(staged, "r2", "l1", { ok: true }), StateError);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("implementation review: condition first, rounds counted, completed needs evidence (F-004)", () => {
|
|
335
|
+
const { workdir, runId } = setupRun("sm-impl");
|
|
336
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
337
|
+
let cp = baseCheckpoint(workdir, runId);
|
|
338
|
+
const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
|
|
339
|
+
fs.writeFileSync(planPath, "# plan", "utf8");
|
|
340
|
+
const plan = planIdentityOf(planPath, 1);
|
|
341
|
+
cp = { ...cp, plan, phase: "implementation-review", nextAction: "ask-question" };
|
|
342
|
+
assert.throws(
|
|
343
|
+
() => applyReviewRoundStarted(cp, { roundId: "i1", role: "reviewer", target: "implementation", reviewers: 1, lanes: [{ laneId: "l1" }] }),
|
|
344
|
+
StateError,
|
|
345
|
+
);
|
|
346
|
+
cp = applyImplementationReviewConfigured(cp, "until-no-high");
|
|
347
|
+
assert.throws(() => applyImplementationReviewConfigured(cp, "again"), StateError);
|
|
348
|
+
cp = applyReviewRoundStarted(cp, { roundId: "i1", role: "reviewer", target: "implementation", reviewers: 1, lanes: [{ laneId: "l1" }] });
|
|
349
|
+
const file = writeReviewOutput(workdir, runId, "i1", "l1", "out");
|
|
350
|
+
cp = applyLaneResult(cp, "i1", "l1", { ok: true, resultFile: file });
|
|
351
|
+
assert.throws(() => applyImplementationRoundFinished(cp), StateError);
|
|
352
|
+
cp = applyReviewConsolidated(cp, "i1");
|
|
353
|
+
cp = applyImplementationRoundFinished(cp);
|
|
354
|
+
assert.equal(cp.implementationReview?.completedRounds, 1);
|
|
355
|
+
// Completion requires evidence AND a termination condition.
|
|
356
|
+
assert.throws(() => applyCompleted({ ...cp, implementationReview: undefined }, "evidence"), StateError);
|
|
357
|
+
assert.throws(() => applyCompleted(cp, " "), StateError);
|
|
358
|
+
const done = applyCompleted(cp, "no findings in final round");
|
|
359
|
+
assert.equal(done.phase, "completed");
|
|
360
|
+
assert.equal(done.nextAction, "none");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("execution approval and progress (D-003/D-011)", () => {
|
|
364
|
+
const { workdir, runId } = setupRun("sm-exec");
|
|
365
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
366
|
+
let cp = baseCheckpoint(workdir, runId);
|
|
367
|
+
const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
|
|
368
|
+
fs.writeFileSync(planPath, "# plan", "utf8");
|
|
369
|
+
const plan = planIdentityOf(planPath, 1);
|
|
370
|
+
cp = applyPlanWritten(cp, plan);
|
|
371
|
+
const approval: ExecutionApproval = {
|
|
372
|
+
plan,
|
|
373
|
+
worktree: cp.worktreeRoot,
|
|
374
|
+
headAtApproval: null,
|
|
375
|
+
approvedAt: cp.updatedAt,
|
|
376
|
+
};
|
|
377
|
+
assert.throws(() => applyExecutionApproved(cp, approval), StateError);
|
|
378
|
+
cp = { ...cp, nextAction: "accept-execute" };
|
|
379
|
+
cp = applyExecutionApproved(cp, approval);
|
|
380
|
+
assert.equal(cp.phase, "executing");
|
|
381
|
+
assert.deepEqual(cp.execution?.doneVcIds, []);
|
|
382
|
+
cp = applyExecutionProgress(cp, { doneVcIds: ["VC-001"], usage: { inToks: 10, outToks: 5 } });
|
|
383
|
+
assert.deepEqual(cp.execution?.doneVcIds, ["VC-001"]);
|
|
384
|
+
assert.equal(cp.execution?.usage.inToks, 10);
|
|
385
|
+
// Mismatched plan hash is rejected.
|
|
386
|
+
const other = { ...approval, plan: { ...plan, sha256: planIdentityOf(planPath, 1).sha256.replace(/^./, "f") } };
|
|
387
|
+
const fresh = { ...baseCheckpoint(workdir, runId), plan, nextAction: "accept-execute" };
|
|
388
|
+
assert.throws(() => applyExecutionApproved(fresh, other), StateError);
|
|
389
|
+
// Head change keeps authorization but forces re-verification (F-001).
|
|
390
|
+
cp = applyExecutionHeadChanged(cp);
|
|
391
|
+
assert.equal(cp.execution?.approval, approval);
|
|
392
|
+
assert.equal(cp.execution?.reverifyAll, true);
|
|
393
|
+
cp = applyExecutionStopped(cp, "stopped by user");
|
|
394
|
+
assert.equal(cp.execution?.pausedReason, "stopped by user");
|
|
395
|
+
const resumed = applyExecutionProgress(cp, { pausedReason: null });
|
|
396
|
+
assert.equal(resumed.execution?.pausedReason, undefined);
|
|
397
|
+
// Completion hands over to the implementation-review phase (R-005).
|
|
398
|
+
const finished = applyExecutionCompleted(cp);
|
|
399
|
+
assert.equal(finished.phase, "implementation-review");
|
|
400
|
+
assert.equal(finished.nextAction, "ask-question");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("migration resets rounds and approval, keeps termination (F-003)", () => {
|
|
404
|
+
const { workdir, runId } = setupRun("sm-migrate");
|
|
405
|
+
createCheckpoint(workdir, { runId, originWorkdir: workdir, workdir });
|
|
406
|
+
const planPath = path.join(path.dirname(checkpointFilePath(workdir, runId)!), "PLAN_v1.md");
|
|
407
|
+
fs.writeFileSync(planPath, "# plan", "utf8");
|
|
408
|
+
const plan = planIdentityOf(planPath, 1);
|
|
409
|
+
let cp: WorkflowCheckpoint = {
|
|
410
|
+
...baseCheckpoint(workdir, runId),
|
|
411
|
+
plan,
|
|
412
|
+
phase: "implementation-review",
|
|
413
|
+
implementationReview: { terminationCondition: "until-no-high", completedRounds: 3, currentRoundId: undefined },
|
|
414
|
+
execution: {
|
|
415
|
+
approval: { plan, worktree: "/origin/wt", headAtApproval: "a".repeat(40), approvedAt: "2026-09-07T00:00:00Z" },
|
|
416
|
+
doneVcIds: ["VC-001", "VC-002"],
|
|
417
|
+
implStatus: { "I-001": "implemented" },
|
|
418
|
+
usage: { inToks: 1, outToks: 2 },
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const migrated = applyMigration(cp, { workdir: "/target/wt", worktreeRoot: "/target/wt", commonDir: "/target/.git" });
|
|
422
|
+
assert.equal(migrated.implementationReview?.terminationCondition, "until-no-high");
|
|
423
|
+
assert.equal(migrated.implementationReview?.completedRounds, 0);
|
|
424
|
+
assert.equal(migrated.execution?.approval, null);
|
|
425
|
+
assert.deepEqual(migrated.execution?.doneVcIds, []);
|
|
426
|
+
assert.equal(migrated.execution?.usage.inToks, 1);
|
|
427
|
+
// F-007: an implementation-review checkpoint keeps its review ordering
|
|
428
|
+
// after migration instead of becoming approvable.
|
|
429
|
+
assert.equal(migrated.nextAction, "run-review");
|
|
430
|
+
assert.equal(migrated.migration?.fromWorktree, cp.worktreeRoot);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/tools/analyze-refs.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { Type } from "typebox";
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { loadConfig, normalizeWorkdir, readActive, recordSubagent, resolveStateRootOrNull, StateError } from "../src/state.ts";
|
|
21
|
+
import { resolveActiveRun } from "../src/run-context.ts";
|
|
21
22
|
import { buildRefAnalystTask, type RefAnalystTaskInput } from "../src/refine-prompts.ts";
|
|
22
23
|
import { runPiSubagent, stripFrontmatter } from "../src/subagent.ts";
|
|
23
24
|
import { RefineOverlayController, refineOverlayContext } from "../src/refine-ui.ts";
|
|
@@ -102,7 +103,7 @@ export function registerAnalyzeRefsTool(pi: ExtensionAPI, baseDir: string): void
|
|
|
102
103
|
|
|
103
104
|
// Resolve refs and validate directories up front; missing ones become
|
|
104
105
|
// FAILED sections instead of aborting the whole batch.
|
|
105
|
-
const active =
|
|
106
|
+
const active = resolveActiveRun(ctx.sessionManager, workdir);
|
|
106
107
|
const jobs: AnalysisJob[] = params.refs.map((ref, index) => {
|
|
107
108
|
const dir = path.resolve(workdir, ref.localPath.replace(/^@/, ""));
|
|
108
109
|
const missing = fs.existsSync(dir) && fs.statSync(dir).isDirectory() ? null : `reference directory not found: ${dir}`;
|
package/tools/ask-choice.ts
CHANGED
|
@@ -15,9 +15,11 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
15
15
|
import { Text } from "@earendil-works/pi-tui";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { disableAutoComplete, enableAutoComplete, isAutoCompleteEnabled, recordAskChoice } from "../src/autocomplete.ts";
|
|
18
|
-
import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "../src/termination-prompt.ts";
|
|
18
|
+
import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, renderTerminationOptions } from "../src/termination-prompt.ts";
|
|
19
19
|
import { truncateToWidth, visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
20
20
|
import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
|
|
21
|
+
import { resolveActiveRun } from "../src/run-context.ts";
|
|
22
|
+
import { applyQuestionAnswered, applyQuestionAsked, loadCheckpoint, mutateCheckpoint } from "../src/workflow-state.ts";
|
|
21
23
|
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
25
|
// Panel fitting: pi's ExtensionSelectorComponent renders each option as an
|
|
@@ -144,6 +146,15 @@ const AskChoiceParams = Type.Object({
|
|
|
144
146
|
"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.",
|
|
145
147
|
}),
|
|
146
148
|
),
|
|
149
|
+
questionId: Type.Optional(
|
|
150
|
+
Type.String({
|
|
151
|
+
description:
|
|
152
|
+
"Stable id for this question so cross-session resume can deduplicate (e.g. 'termination-condition'). Provide one for planning/refinement questions that gate progress.",
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
purpose: Type.Optional(
|
|
156
|
+
Type.String({ description: "Short machine-readable purpose (e.g. 'scope', 'termination-condition')." }),
|
|
157
|
+
),
|
|
147
158
|
trailing: Type.Optional(
|
|
148
159
|
StringEnum(["auto-refine-loop"] as const, {
|
|
149
160
|
description:
|
|
@@ -176,6 +187,40 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
176
187
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
177
188
|
const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
|
|
178
189
|
const allowOther = params.allowOther ?? true;
|
|
190
|
+
// I-003: cross-session question durability. Pending is recorded
|
|
191
|
+
// before the panel opens; the answer is recorded before it returns.
|
|
192
|
+
// Runs without a checkpoint (adhoc, pre-start-run setup questions)
|
|
193
|
+
// skip silently — legacy behavior is unchanged.
|
|
194
|
+
const activeRun = resolveActiveRun(ctx.sessionManager, workdir);
|
|
195
|
+
const hasCheckpoint =
|
|
196
|
+
activeRun !== null && loadCheckpoint(workdir, activeRun.run_id).status === "ok";
|
|
197
|
+
const recordQuestionAsked = (): void => {
|
|
198
|
+
if (!hasCheckpoint || !activeRun || !params.questionId) return;
|
|
199
|
+
try {
|
|
200
|
+
mutateCheckpoint(workdir, activeRun.run_id, (cp) =>
|
|
201
|
+
applyQuestionAsked(cp, {
|
|
202
|
+
questionId: params.questionId!,
|
|
203
|
+
question: params.question,
|
|
204
|
+
options: options.map((option) => option.label),
|
|
205
|
+
purpose: params.purpose,
|
|
206
|
+
allowOther,
|
|
207
|
+
autoComplete,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
/* checkpoint bookkeeping must not block the question itself */
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const recordQuestionAnswered = (answer: string, source: "user" | "auto-complete" | "other"): void => {
|
|
215
|
+
if (!hasCheckpoint || !activeRun || !params.questionId) return;
|
|
216
|
+
try {
|
|
217
|
+
mutateCheckpoint(workdir, activeRun.run_id, (cp) =>
|
|
218
|
+
applyQuestionAnswered(cp, params.questionId!, answer, source),
|
|
219
|
+
);
|
|
220
|
+
} catch {
|
|
221
|
+
/* the decisions ledger already holds the answer; F-005 reconcile covers the gap */
|
|
222
|
+
}
|
|
223
|
+
};
|
|
179
224
|
// Param normalization: a trailing option replaces Auto-complete entirely,
|
|
180
225
|
// so an erroneously passed autoComplete flag is suppressed here.
|
|
181
226
|
const trailing = params.trailing;
|
|
@@ -185,7 +230,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
185
230
|
const recommended = options.find((option) => option.recommended) ?? options[0];
|
|
186
231
|
|
|
187
232
|
const record = (answer: string, source: AskChoiceDetails["source"]) => {
|
|
188
|
-
const active =
|
|
233
|
+
const active = resolveActiveRun(ctx.sessionManager, workdir);
|
|
189
234
|
if (!active) return;
|
|
190
235
|
try {
|
|
191
236
|
recordDecision(workdir, active.run_id, {
|
|
@@ -193,6 +238,10 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
193
238
|
options: options.map((option) => option.label),
|
|
194
239
|
answer,
|
|
195
240
|
answer_source: source === "auto-complete" ? "auto-complete" : "user",
|
|
241
|
+
// F-005 reconcile (implementation review): the ledger
|
|
242
|
+
// carries the stable id so resume can drop stale pending
|
|
243
|
+
// questions after a crash between the two writes.
|
|
244
|
+
...(params.questionId ? { questionId: params.questionId } : {}),
|
|
196
245
|
});
|
|
197
246
|
} catch {
|
|
198
247
|
/* recording is best-effort; the question still gets answered */
|
|
@@ -211,6 +260,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
211
260
|
if (autoComplete && isAutoCompleteEnabled(ctx)) {
|
|
212
261
|
recordAskChoice(ctx, true);
|
|
213
262
|
record(recommended.label, "auto-complete");
|
|
263
|
+
recordQuestionAsked();
|
|
264
|
+
recordQuestionAnswered(recommended.label, "auto-complete");
|
|
214
265
|
return {
|
|
215
266
|
content: [{ type: "text", text: `Auto-complete selected the recommended option: ${recommended.label}` }],
|
|
216
267
|
details: details(recommended.label, "auto-complete"),
|
|
@@ -229,6 +280,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
229
280
|
enableAutoComplete(ctx);
|
|
230
281
|
recordAskChoice(ctx, true);
|
|
231
282
|
record(recommended.label, "auto-complete");
|
|
283
|
+
recordQuestionAsked();
|
|
284
|
+
recordQuestionAnswered(recommended.label, "auto-complete");
|
|
232
285
|
return {
|
|
233
286
|
content: [
|
|
234
287
|
{
|
|
@@ -263,6 +316,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
263
316
|
ctx.ui.notify?.("Terminal too small: the ask_choice panel may overflow even in its minimal form.", "warning");
|
|
264
317
|
}
|
|
265
318
|
|
|
319
|
+
recordQuestionAsked();
|
|
266
320
|
const selected = await ctx.ui.select(panel.question, panel.labels);
|
|
267
321
|
if (selected === undefined) {
|
|
268
322
|
disableAutoComplete(ctx, "question cancelled");
|
|
@@ -281,6 +335,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
281
335
|
enableAutoComplete(ctx);
|
|
282
336
|
recordAskChoice(ctx, true);
|
|
283
337
|
record(recommended.label, "auto-complete");
|
|
338
|
+
recordQuestionAnswered(recommended.label, "auto-complete");
|
|
284
339
|
return {
|
|
285
340
|
content: [
|
|
286
341
|
{
|
|
@@ -299,7 +354,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
299
354
|
content: [
|
|
300
355
|
{
|
|
301
356
|
type: "text",
|
|
302
|
-
text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the chosen termination condition — the goal-wait option keeps the loop running until no unpassed VCs remain.`,
|
|
357
|
+
text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the chosen termination condition — the goal-wait option keeps the loop running until no unpassed VCs remain.`,
|
|
303
358
|
},
|
|
304
359
|
],
|
|
305
360
|
details: details("Auto-refine loop", "user"),
|
|
@@ -318,6 +373,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
318
373
|
recordAskChoice(ctx, false);
|
|
319
374
|
const answer = typed.trim();
|
|
320
375
|
record(answer, "user");
|
|
376
|
+
recordQuestionAnswered(answer, "other");
|
|
321
377
|
return {
|
|
322
378
|
content: [{ type: "text", text: `User wrote: ${answer}` }],
|
|
323
379
|
details: details(answer, "other"),
|
|
@@ -328,6 +384,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
328
384
|
const option = index >= 0 && index < options.length ? options[index] : undefined;
|
|
329
385
|
if (!option) {
|
|
330
386
|
recordAskChoice(ctx, false);
|
|
387
|
+
recordQuestionAnswered(selected, "user");
|
|
331
388
|
return {
|
|
332
389
|
content: [{ type: "text", text: `User selected: ${selected}` }],
|
|
333
390
|
details: details(selected, "user"),
|
|
@@ -335,6 +392,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
335
392
|
}
|
|
336
393
|
recordAskChoice(ctx, false);
|
|
337
394
|
record(option.label, "user");
|
|
395
|
+
recordQuestionAnswered(option.label, "user");
|
|
338
396
|
return {
|
|
339
397
|
content: [{ type: "text", text: `User selected: ${index + 1}. ${option.label}` }],
|
|
340
398
|
details: details(option.label, "user"),
|
package/tools/code-graph.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { Language } from "../src/code-graph/types.ts";
|
|
|
22
22
|
import { screeningQuery } from "../src/code-graph/screening.ts";
|
|
23
23
|
import { deleteFile, listPending, updateFile, updateFunction } from "../src/code-graph/mutations.ts";
|
|
24
24
|
import { applyGraphCore } from "../src/code-graph/commands.ts";
|
|
25
|
+
import { boundRunId } from "../src/run-context.ts";
|
|
25
26
|
import { normalizeWorkdir } from "../src/state.ts";
|
|
26
27
|
|
|
27
28
|
const CodeGraphParams = Type.Object({
|
|
@@ -110,7 +111,10 @@ export function registerCodeGraphTool(pi: ExtensionAPI): void {
|
|
|
110
111
|
details: {},
|
|
111
112
|
};
|
|
112
113
|
}
|
|
113
|
-
const
|
|
114
|
+
const resolvedWorkdir = normalizeWorkdir(workdir);
|
|
115
|
+
const core = await applyGraphCore(resolvedWorkdir, {
|
|
116
|
+
sessionRunId: boundRunId(ctx.sessionManager, resolvedWorkdir),
|
|
117
|
+
});
|
|
114
118
|
if (core.refused || core.failed || !core.report) {
|
|
115
119
|
return {
|
|
116
120
|
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: core.refused ?? core.failed ?? "unknown error" }) }],
|
package/tools/execute-plan.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { disableAutoComplete } from "../src/autocomplete.ts";
|
|
17
17
|
import { latestPlanVersion, parseChecklist, parseImplItems } from "../src/plan.ts";
|
|
18
18
|
import { normalizeWorkdir, readActive } from "../src/state.ts";
|
|
19
|
+
import { resolveActiveRun } from "../src/run-context.ts";
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
const ExecutePlanParams = Type.Object({
|
|
@@ -44,7 +45,7 @@ export async function executeHandoff(
|
|
|
44
45
|
if (planPathArg) {
|
|
45
46
|
planPath = path.resolve(workdir, planPathArg.replace(/^@/, ""));
|
|
46
47
|
} else {
|
|
47
|
-
const active =
|
|
48
|
+
const active = resolveActiveRun(ctx.sessionManager, workdir);
|
|
48
49
|
if (!active) {
|
|
49
50
|
return {
|
|
50
51
|
status: "error",
|