pi-plans 0.6.0 → 0.8.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 (116) hide show
  1. package/AGENTS.md +58 -0
  2. package/CONTRIBUTING.md +8 -15
  3. package/README.md +39 -37
  4. package/agents/execution-reviewer.md +40 -0
  5. package/agents/reviewer.md +12 -3
  6. package/index.ts +55 -58
  7. package/package.json +2 -1
  8. package/references/pi-planning-workflow.md +50 -60
  9. package/references/plan-artifact-template.md +81 -60
  10. package/references/state-and-config.md +60 -44
  11. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  12. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  13. package/scripts/run-tests.ts +12 -1
  14. package/scripts/validate.ts +39 -11
  15. package/skills/debug-and-plan/SKILL.md +3 -3
  16. package/skills/plan-big/SKILL.md +3 -3
  17. package/skills/plan-normal/SKILL.md +3 -3
  18. package/skills/plan-small/SKILL.md +4 -4
  19. package/skills/plan-with-refs/SKILL.md +6 -6
  20. package/skills/planning/SKILL.md +1 -1
  21. package/src/ask-form.ts +4 -4
  22. package/src/auditor.ts +227 -0
  23. package/src/auto-approve.ts +1 -1
  24. package/src/autocomplete.ts +19 -17
  25. package/src/code-graph/commands.ts +8 -3
  26. package/src/code-graph/community.ts +1 -1
  27. package/src/code-graph/paths.ts +1 -1
  28. package/src/code-graph/watch.ts +2 -2
  29. package/src/compaction.ts +3 -3
  30. package/src/config-command.ts +146 -73
  31. package/src/dashboard.ts +303 -0
  32. package/src/exec.ts +1185 -924
  33. package/src/global-state.ts +304 -0
  34. package/src/guard.ts +18 -19
  35. package/src/messaging.ts +44 -0
  36. package/src/plan.ts +421 -112
  37. package/src/query-hook.ts +4 -4
  38. package/src/refine-prompts.ts +12 -70
  39. package/src/refine-ui-helpers.ts +24 -5
  40. package/src/refine-ui-state.ts +1 -1
  41. package/src/refine-ui.ts +19 -3
  42. package/src/resume-command.ts +45 -129
  43. package/src/resume.ts +5 -1
  44. package/src/role-panels.ts +542 -0
  45. package/src/run-context.ts +3 -10
  46. package/src/staleness.ts +53 -0
  47. package/src/state.ts +273 -72
  48. package/src/subagent.ts +19 -29
  49. package/src/task-tool.ts +100 -0
  50. package/src/tasks.ts +223 -0
  51. package/src/thinking-levels.ts +67 -0
  52. package/src/ui-language.ts +7 -54
  53. package/src/workflow-state.ts +76 -58
  54. package/tests/analyze-refs.test.ts +35 -18
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +210 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +402 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec-review-loop.test.ts +331 -0
  75. package/tests/exec.test.ts +771 -1706
  76. package/tests/execute-plan.test.ts +44 -19
  77. package/tests/extension-load.test.ts +48 -0
  78. package/tests/global-state.test.ts +371 -0
  79. package/tests/graph-aware-file-tools.test.ts +5 -5
  80. package/tests/guard.test.ts +1 -1
  81. package/tests/multi-run.test.ts +3 -103
  82. package/tests/plan.test.ts +139 -62
  83. package/tests/plans.test.ts +7 -79
  84. package/tests/refine-prompts.test.ts +20 -71
  85. package/tests/refine-resume.test.ts +27 -22
  86. package/tests/refine-ui.test.ts +6 -15
  87. package/tests/resume-lifecycle.test.ts +41 -22
  88. package/tests/resume.test.ts +39 -81
  89. package/tests/role-panels.test.ts +391 -0
  90. package/tests/run-context.test.ts +1 -1
  91. package/tests/run-ownership.test.ts +1 -1
  92. package/tests/stale-ctx.test.ts +218 -0
  93. package/tests/staleness.test.ts +76 -0
  94. package/tests/state.test.ts +155 -32
  95. package/tests/subagent-thinking.test.ts +65 -0
  96. package/tests/subagent-usage.test.ts +1 -1
  97. package/tests/task-tool.test.ts +61 -0
  98. package/tests/tasks.test.ts +142 -0
  99. package/tests/thinking-levels.test.ts +77 -0
  100. package/tests/ui-language.test.ts +2 -17
  101. package/tests/workflow-state.test.ts +73 -90
  102. package/tools/analyze-refs.ts +67 -32
  103. package/tools/ask-choice.ts +7 -53
  104. package/tools/code-graph.ts +2 -2
  105. package/tools/execute-plan.ts +55 -99
  106. package/tools/graph-aware-file-tools.ts +4 -10
  107. package/tools/plans.ts +41 -67
  108. package/tools/refine.ts +101 -164
  109. package/agents/criticizer.md +0 -18
  110. package/agents/executor.md +0 -26
  111. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  112. package/src/panel.ts +0 -473
  113. package/src/termination-prompt.ts +0 -73
  114. package/tests/goal-wait.test.ts +0 -269
  115. package/tests/panel-i-zero.test.ts +0 -420
  116. package/tests/panel.test.ts +0 -355
@@ -7,7 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { after, before, describe, it } from "node:test";
9
9
  import {
10
- applyImplementationReviewConfigured,
10
+
11
11
  applyLaneResult,
12
12
  applyReviewConsolidated,
13
13
  createCheckpoint,
@@ -19,8 +19,11 @@ import {
19
19
  startReviewRound,
20
20
  } from "../src/workflow-state.ts";
21
21
  import { initState, startRun } from "../src/state.ts";
22
+ import { setMessagingApi } from "../src/messaging.ts";
22
23
 
23
24
  let tmpRoot: string;
25
+ let globalDir: string;
26
+ let previousGlobalDir: string | undefined;
24
27
 
25
28
  function setupRun(name: string): { workdir: string; runId: string; artifactDir: string } {
26
29
  const workdir = path.join(tmpRoot, name);
@@ -35,9 +38,17 @@ function setupRun(name: string): { workdir: string; runId: string; artifactDir:
35
38
 
36
39
  before(() => {
37
40
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rr-"));
41
+ // Isolate the global reviewer config (F-002): setRole writes the global
42
+ // file; tests must never touch the developer's ~/.pi/pi-plans.
43
+ previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
44
+ globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-rr-"));
45
+ process.env.PI_PLANS_GLOBAL_DIR = globalDir;
38
46
  });
39
47
 
40
48
  after(() => {
49
+ if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
50
+ else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
51
+ fs.rmSync(globalDir, { recursive: true, force: true });
41
52
  fs.rmSync(tmpRoot, { recursive: true, force: true });
42
53
  });
43
54
 
@@ -194,6 +205,7 @@ describe("implementation-review round 1 fixes", () => {
194
205
  hasUI: false,
195
206
  ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
196
207
  };
208
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
197
209
  const result = await tool!.execute("t1", { role: "reviewer", planPath, reviewers: 2 }, undefined, undefined, ctx);
198
210
  assert.match(result.content[0]?.text ?? "", /current-session/);
199
211
  // The durable round exists WITH lanes — the pre-fix crash site.
@@ -236,9 +248,9 @@ describe("implementation-review round 1 fixes", () => {
236
248
  // Edit the plan in place — same path, different bytes.
237
249
  fs.writeFileSync(planPath, "# plan (edited)\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n", "utf8");
238
250
  const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
239
- const pi = { appendEntry: () => {}, sendMessage: () => {} } as never;
240
251
  const ctx = { cwd: workdir, sessionManager: {}, ui: { setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } } };
241
- const load = loadExecutionFromCheckpoint(pi, ctx, run.run_id);
252
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
253
+ const load = loadExecutionFromCheckpoint(ctx as never, run.run_id);
242
254
  assert.equal(load.status, "plan-mismatch");
243
255
  assert.match((load as { error?: string }).error ?? "", /changed since the approval/);
244
256
  });
@@ -266,9 +278,9 @@ describe("implementation-review round 1 fixes", () => {
266
278
  });
267
279
  mutateCheckpoint(workdir, run.run_id, (cp) => applyExecutionProgress(cp, { doneVcIds: ["VC-001"] }));
268
280
  const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
269
- const pi = { appendEntry: () => {}, sendMessage: () => {} } as never;
270
281
  const ctx = { cwd: workdir, sessionManager: {}, ui: { setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } } };
271
- const load = loadExecutionFromCheckpoint(pi, ctx, run.run_id);
282
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
283
+ const load = loadExecutionFromCheckpoint(ctx as never, run.run_id);
272
284
  assert.equal(load.status, "loaded");
273
285
  assert.equal((load as { reverifyAll?: boolean }).reverifyAll, true, "unverifiable HEAD re-verifies");
274
286
  });
@@ -285,7 +297,7 @@ describe("implementation-review round 1 fixes", () => {
285
297
  const owner = acquireOwnership(workdir, run.run_id);
286
298
  // Another process takes over (fabricated record with a reused/dead pid).
287
299
  fs.writeFileSync(
288
- path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "owner.json"),
300
+ path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "owner.json"),
289
301
  JSON.stringify({ ...owner, pid: process.pid, pidStart: "bogus-start", processToken: "someone-else", generation: owner.generation + 1 }),
290
302
  "utf8",
291
303
  );
@@ -323,7 +335,7 @@ describe("implementation-review round 1 fixes", () => {
323
335
  });
324
336
  });
325
337
 
326
- describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
338
+ describe("plan-review reviewer-count defaults (v0.6.1 single-reviewer)", () => {
327
339
  async function setupLoopRun(name: string, skill: string, reviewerCount?: number) {
328
340
  const workdir = path.join(tmpRoot, name);
329
341
  fs.mkdirSync(workdir, { recursive: true });
@@ -336,16 +348,6 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
336
348
  fs.mkdirSync(run.artifact_dir, { recursive: true });
337
349
  const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
338
350
  fs.writeFileSync(planPath, "# plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n", "utf8");
339
- // refine's round-start guard requires a recorded termination condition
340
- // before any implementation round, so the loop is always configured;
341
- // reviewerCount may legitimately be absent (skill default applies).
342
- mutateCheckpoint(workdir, run.run_id, (cp) =>
343
- applyImplementationReviewConfigured(
344
- { ...cp, phase: "implementation-review", nextAction: "ask-question" },
345
- "1 round",
346
- reviewerCount,
347
- ),
348
- );
349
351
  return { workdir, runId: run.run_id, planPath };
350
352
  }
351
353
 
@@ -373,13 +375,14 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
373
375
  hasUI: false,
374
376
  ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
375
377
  };
376
- const result = await tool.execute("t1", { role: "reviewer", target: "implementation", planPath }, undefined, undefined, ctx);
378
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
379
+ const result = await tool.execute("t1", { planPath, reviewers: 3 }, undefined, undefined, ctx);
377
380
  assert.match(result.content[0]?.text ?? "", /current-session/);
378
381
  const loaded = loadCheckpoint(workdir, runId);
379
382
  assert.ok(loaded.status === "ok");
380
383
  const round = loaded.checkpoint.reviewRounds.at(-1);
381
384
  assert.ok(round, "round recorded");
382
- assert.equal(round!.reviewers, 3, "configured reviewerCount used as fallback");
385
+ assert.equal(round!.reviewers, 3, "explicit reviewers=3 honored");
383
386
  assert.equal(round!.lanes.length, 3, "three lanes spawned");
384
387
  });
385
388
 
@@ -394,7 +397,8 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
394
397
  hasUI: false,
395
398
  ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
396
399
  };
397
- await tool.execute("t1", { role: "reviewer", target: "implementation", planPath, reviewers: 1 }, undefined, undefined, ctx);
400
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
401
+ await tool.execute("t1", { planPath, reviewers: 1 }, undefined, undefined, ctx);
398
402
  const loaded = loadCheckpoint(workdir, runId);
399
403
  assert.ok(loaded.status === "ok");
400
404
  const round = loaded.checkpoint.reviewRounds.at(-1);
@@ -413,11 +417,12 @@ describe("implementation-review reviewer-count fallback (0.5.4, D-4)", () => {
413
417
  hasUI: false,
414
418
  ui: { notify: () => {}, setStatus: () => {}, theme: { fg: (_c: string, t: string) => t } },
415
419
  };
416
- await tool.execute("t1", { role: "reviewer", target: "implementation", planPath }, undefined, undefined, ctx);
420
+ setMessagingApi({ appendEntry: () => {}, sendMessage: () => {}, sendUserMessage: async () => {} });
421
+ await tool.execute("t1", { planPath }, undefined, undefined, ctx);
417
422
  const loaded = loadCheckpoint(workdir, runId);
418
423
  assert.ok(loaded.status === "ok");
419
424
  const round = loaded.checkpoint.reviewRounds.at(-1);
420
- assert.equal(round!.lanes.length, 1, "no configuration and no param → 1 lane");
425
+ assert.equal(round!.lanes.length, 1, "no param → 1 lane default");
421
426
  });
422
427
 
423
428
  it("count=2 consolidation text carries the source-reviewer contract (D-7)", async () => {
@@ -151,15 +151,6 @@ describe("refine overlay viewport", () => {
151
151
  );
152
152
  assert.ok(reviewer.render(120).some((line) => line.includes("Reviewer (m1)")));
153
153
 
154
- const criticizer = new RefineOverlayComponent(
155
- fakeTheme,
156
- "criticizer",
157
- [readyLane("lane-1", "criticizer", "ok")],
158
- () => {},
159
- undefined,
160
- "m2",
161
- );
162
- assert.ok(criticizer.render(120).some((line) => line.includes("Criticizer (m2)")));
163
154
  });
164
155
 
165
156
  it("renders model-aware titles and footer hints", () => {
@@ -230,14 +221,14 @@ describe("refine overlay viewport", () => {
230
221
  const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
231
222
  const component = new RefineOverlayComponent(
232
223
  fakeTheme,
233
- "criticizer",
234
- [streamingToolLane("lane-1", "criticizer", linesText)],
224
+ "reviewer",
225
+ [streamingToolLane("lane-1", "reviewer", linesText)],
235
226
  () => {},
236
227
  undefined,
237
228
  "singularity-gpt/gpt-5.5:xhigh",
238
229
  );
239
230
  const lines = component.render(90);
240
- assert.ok(lines.some((line) => line.includes("Criticizer (singularity-gpt/gpt-5.5:xhigh)")));
231
+ assert.ok(lines.some((line) => line.includes("Reviewer (singularity-gpt/gpt-5.5:xhigh)")));
241
232
  assert.ok(lines.some((line) => line.includes("…")));
242
233
  assert.ok(lines.some((line) => line.includes("line 3")));
243
234
  assert.ok(lines.some((line) => line.includes("line 4")));
@@ -269,8 +260,8 @@ describe("refine overlay viewport", () => {
269
260
  it("renders a single pane with pi-btw-style transcript badges", () => {
270
261
  const component = new RefineOverlayComponent(
271
262
  fakeTheme,
272
- "criticizer",
273
- [readyLane("lane-1", "criticizer", "assistant output")],
263
+ "refs",
264
+ [readyLane("lane-1", "refs", "assistant output")],
274
265
  () => {},
275
266
  );
276
267
  const lines = component.render(90);
@@ -340,7 +331,7 @@ describe("refine overlay wiring", () => {
340
331
  it("threads the chrome language through both overlay construction sites (issue #3, VC-007)", () => {
341
332
  const refineSource = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
342
333
  assert.match(refineSource, /const overlayLang = uiLanguageFromTag\(config\.language\.tag\)/);
343
- assert.match(refineSource, /new RefineOverlayController\(role, lanes, relayAbort, lang\)/);
334
+ assert.match(refineSource, /new RefineOverlayController\("reviewer", lanes, relayAbort, lang\)/);
344
335
  assert.match(refineSource, /modelLabel,\s*\n\s*overlayLang,/);
345
336
  const refsSource = fs.readFileSync(path.join(process.cwd(), "tools", "analyze-refs.ts"), "utf8");
346
337
  assert.match(
@@ -25,7 +25,7 @@ import { startExecution } from "../src/exec.ts";
25
25
  import { resetRunBindingForTests } from "../src/run-context.ts";
26
26
  import { processStartOf } from "../src/run-ownership.ts";
27
27
  import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked } from "../src/workflow-state.ts";
28
- import { getRun, initState, startRun } from "../src/state.ts";
28
+ import { DEFAULT_CONFIG, getRun, initState, resolveArtifactRoot, startRun } from "../src/state.ts";
29
29
 
30
30
  initTheme("dark", false);
31
31
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-resume-life-"));
@@ -34,10 +34,15 @@ let serial = 0;
34
34
 
35
35
  const PLAN_TEXT = `# Plan
36
36
 
37
- ## Verifier Checklist
37
+ ## Tasks
38
38
 
39
- - [ ] \`VC-001\` covers \`I-001\`; pass condition: first.
40
- - [ ] \`VC-002\` covers \`I-002\`; pass condition: second.
39
+ - Task-1: first — files: src/a.ts; wave: 1
40
+ - Task-2: second — files: src/b.ts; wave: 1
41
+
42
+ ## Verification Checks
43
+
44
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: first.
45
+ - [ ] \`VC-002\` covers \`Task-2\`; pass condition: second.
41
46
  `;
42
47
 
43
48
  interface Fixture {
@@ -173,6 +178,9 @@ function setupRun(name: string): { cwd: string; runId: string; artifactDir: stri
173
178
  createCheckpoint(cwd, { runId: run.run_id, originWorkdir: cwd, workdir: cwd });
174
179
  fs.mkdirSync(run.artifact_dir, { recursive: true });
175
180
  fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), PLAN_TEXT, "utf8");
181
+ // The artifact dir lives under .git/ and is never tracked; seed a root file so
182
+ // the "seed" commit exists and resolveHeadAt() yields the approval evidence HEAD.
183
+ fs.writeFileSync(path.join(cwd, "WORKTREE.md"), "seed worktree marker\n", "utf8");
176
184
  spawnSync("git", ["add", "-A"], { cwd });
177
185
  spawnSync("git", ["commit", "-m", "seed"], { cwd });
178
186
  return { cwd, runId: run.run_id, artifactDir: run.artifact_dir };
@@ -185,12 +193,10 @@ describe("/resume-plans on the real Pi host", () => {
185
193
  async () => {
186
194
  resetRunBindingForTests();
187
195
  const { cwd, runId } = setupRun("cross-session");
188
- // Session 1: execution starts, VC-001 lands, then the session ends.
196
+ // Session 1: execution starts, Task-1 closes via the task tool,
197
+ // then the session ends.
189
198
  const fixture1: Fixture = {
190
- script: [
191
- { kind: "tool" },
192
- { kind: "text", text: "[DONE:VC-001] Partial progress; stopping here." },
193
- ],
199
+ script: [{ kind: "text", text: "Partial progress; stopping here." }],
194
200
  calls: [],
195
201
  };
196
202
  const session1 = await makeSession({
@@ -200,10 +206,18 @@ describe("/resume-plans on the real Pi host", () => {
200
206
  extraExtension: ((pi: never) => {
201
207
  const realPi = pi as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI;
202
208
  realPi.on("session_start", async (_event: unknown, ctx: never) => {
203
- await startExecution(realPi, ctx as never, path.join(artifactDirRelative(cwd), "PLAN_v1.md"), [
204
- { id: "VC-001", text: "first", done: false },
205
- { id: "VC-002", text: "second", done: false },
206
- ]);
209
+ const planPath = path.join(artifactDirRelative(cwd), "PLAN_v1.md");
210
+ const planText = fs.readFileSync(planPath, "utf8");
211
+ const { parsePlanTasks, parseChecklist } = await import("../src/plan.ts");
212
+ await startExecution(ctx as never, {
213
+ planPath,
214
+ planTasks: parsePlanTasks(planText),
215
+ items: parseChecklist(planText),
216
+ });
217
+ const { applyTaskUpdate } = await import("../src/task-tool.ts");
218
+ const { getExecution, persistTaskProgress } = await import("../src/exec.ts");
219
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "first tests green");
220
+ persistTaskProgress(ctx as never);
207
221
  });
208
222
  }) as never,
209
223
  });
@@ -214,7 +228,7 @@ describe("/resume-plans on the real Pi host", () => {
214
228
  const mid = loadCheckpoint(cwd, runId);
215
229
  assert.equal(mid.status, "ok");
216
230
  if (mid.status === "ok") {
217
- assert.deepEqual(mid.checkpoint.execution?.doneVcIds, ["VC-001"], "progress persisted by session 1");
231
+ assert.equal(mid.checkpoint.execution?.tasks?.["Task-1"]?.status, "complete", "task progress persisted by session 1");
218
232
  assert.ok(mid.checkpoint.execution?.approval?.headAtApproval, "approval evidence persisted");
219
233
  }
220
234
  } finally {
@@ -225,7 +239,7 @@ describe("/resume-plans on the real Pi host", () => {
225
239
 
226
240
  // Session 2: fresh session, resume via the command.
227
241
  const fixture2: Fixture = {
228
- script: [{ kind: "text", text: "[DONE:VC-002] All items verified." }],
242
+ script: [{ kind: "text", text: "Resumed; continuing the remaining task." }],
229
243
  calls: [],
230
244
  };
231
245
  const notifies: string[] = [];
@@ -238,16 +252,19 @@ describe("/resume-plans on the real Pi host", () => {
238
252
  assert.ok(fixture2.calls.length >= 1);
239
253
  const firstPayload = JSON.stringify(fixture2.calls[0]);
240
254
  assert.match(firstPayload, /PI-PLANS RESUME/);
241
- assert.match(firstPayload, /VC-001/);
255
+ assert.match(firstPayload, /plans_update_task/);
242
256
  assert.ok(notifies.some((message) => /Resumed/.test(message)), "resume notification shown");
243
- // Both VCs are done; the run advanced to the review phase.
257
+ // Task-1 stays complete across the session boundary.
244
258
  const final = loadCheckpoint(cwd, runId);
245
259
  assert.equal(final.status, "ok");
246
260
  if (final.status === "ok") {
247
- assert.deepEqual(final.checkpoint.execution?.doneVcIds.sort(), ["VC-001", "VC-002"]);
248
- assert.equal(final.checkpoint.phase, "implementation-review");
261
+ assert.equal(final.checkpoint.execution?.tasks?.["Task-1"]?.status, "complete");
262
+ // Every task now carries a record: an untouched task is
263
+ // persisted as pending rather than omitted, so the checkpoint
264
+ // distinguishes "never started" from "rolled back with
265
+ // evidence" instead of losing the record entirely.
266
+ assert.equal(final.checkpoint.execution?.tasks?.["Task-2"]?.status, "pending");
249
267
  }
250
- assert.equal(getRun(cwd, runId)?.status, "done");
251
268
  } finally {
252
269
  session2.session.dispose();
253
270
  }
@@ -313,7 +330,7 @@ describe("/resume-plans on the real Pi host", () => {
313
330
  const child = spawn("sleep", ["30"], { stdio: "ignore" });
314
331
  try {
315
332
  fs.writeFileSync(
316
- path.join(cwd, ".git", "pi_plans", "runs", runId, "owner.json"),
333
+ path.join(cwd, ".git", "pi-plans", "runs", runId, "owner.json"),
317
334
  JSON.stringify({
318
335
  schema: 1,
319
336
  host: os.hostname(),
@@ -369,7 +386,9 @@ describe("/resume-plans on the real Pi host", () => {
369
386
  });
370
387
 
371
388
  function artifactDirRelative(cwd: string): string {
372
- const root = path.join(cwd, "docs", "pi-plans");
389
+ // Resolve through the real default so this fixture follows the configured
390
+ // artifact root (inside the state dir by default) instead of hardcoding it.
391
+ const root = resolveArtifactRoot(cwd, DEFAULT_CONFIG.artifact_root);
373
392
  return path.join(root, fs.readdirSync(root)[0]!);
374
393
  }
375
394
 
@@ -11,7 +11,8 @@ import { migrateRunIntoCurrentWorktree, resumePlansCommand } from "../src/resume
11
11
  import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked, applyQuestionAnswered } from "../src/workflow-state.ts";
12
12
  import { acquireOwnership, processStartOf } from "../src/run-ownership.ts";
13
13
  import { resetRunBindingForTests } from "../src/run-context.ts";
14
- import { initState, setRunStatus, startRun, StateError } from "../src/state.ts";
14
+ import { initState, setArtifactRoot, setRunStatus, startRun, StateError } from "../src/state.ts";
15
+ import { setMessagingApi } from "../src/messaging.ts";
15
16
 
16
17
  let tmpRoot: string;
17
18
 
@@ -63,7 +64,7 @@ function makeCtx(cwd: string, overrides: Partial<CtxMock> = {}): CtxMock {
63
64
  }
64
65
 
65
66
  function ctxAdapter(mock: CtxMock): unknown {
66
- return {
67
+ const ctx = {
67
68
  cwd: mock.cwd,
68
69
  hasUI: mock.hasUI,
69
70
  isIdle: () => mock.isIdleResult,
@@ -84,21 +85,16 @@ function ctxAdapter(mock: CtxMock): unknown {
84
85
  setStatus: () => {},
85
86
  },
86
87
  };
87
- }
88
-
89
- function makePi(mock: CtxMock): unknown {
90
- return {
91
- sendUserMessage: async (content: string) => {
92
- mock.userMessages.push(content);
93
- },
88
+ setMessagingApi({
94
89
  appendEntry: (customType: string, data?: unknown) => {
95
90
  mock.entries.push({ customType, data });
96
91
  },
97
92
  sendMessage: () => {},
98
- on: () => {},
99
- setModel: async () => true,
100
- setThinkingLevel: () => {},
101
- };
93
+ sendUserMessage: async (content: string) => {
94
+ mock.userMessages.push(content);
95
+ },
96
+ });
97
+ return ctx;
102
98
  }
103
99
 
104
100
  const BASE_DIR = path.resolve(import.meta.dirname, "..");
@@ -116,6 +112,9 @@ describe("candidate discovery", () => {
116
112
  it("lists resumable runs, excludes terminal ones, registry hint first", () => {
117
113
  const workdir = setupRepo("discovery");
118
114
  const planning = startRun(workdir, { topic: "alpha", skill: "plan-normal", requestText: "a" }).run;
115
+ const verifying = startRun(workdir, { topic: "zeta", skill: "plan-normal", requestText: "f" }).run;
116
+ setRunStatus(workdir, verifying.run_id, "executing");
117
+ setRunStatus(workdir, verifying.run_id, "verifying");
119
118
  const stopped = startRun(workdir, { topic: "beta", skill: "plan-normal", requestText: "b" }).run;
120
119
  setRunStatus(workdir, stopped.run_id, "executing");
121
120
  setRunStatus(workdir, stopped.run_id, "stopped");
@@ -133,6 +132,9 @@ describe("candidate discovery", () => {
133
132
  assert.ok(ids.includes(planning.run_id));
134
133
  assert.ok(ids.includes(stopped.run_id));
135
134
  assert.ok(ids.includes(doneWithReview.run_id), "done with review artifacts resumable");
135
+ assert.ok(ids.includes(verifying.run_id), "verifying runs are resumable");
136
+ const verifyingCandidate = candidates.find((candidate) => candidate.runId === verifying.run_id);
137
+ assert.equal(verifyingCandidate?.phaseLabel, "verifying", "verifying runs keep a distinct resume label even though checkpoint.phase stays executing");
136
138
  assert.equal(ids.includes(abandoned.run_id), false);
137
139
  assert.equal(ids.includes(done.run_id), false, "plain done excluded");
138
140
 
@@ -168,7 +170,7 @@ describe("candidate discovery", () => {
168
170
  const run = startRun(workdir, { topic: "corrupt", skill: "plan-normal", requestText: "a" }).run;
169
171
  createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
170
172
  fs.writeFileSync(
171
- path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "checkpoint.json"),
173
+ path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json"),
172
174
  "{ broken",
173
175
  "utf8",
174
176
  );
@@ -182,27 +184,27 @@ describe("/resume-plans command", () => {
182
184
  it("refuses non-interactive and busy sessions, notifies when nothing is resumable", async () => {
183
185
  const workdir = setupRepo("guards");
184
186
  const noUI = makeCtx(workdir, { hasUI: false });
185
- await resumePlansCommand(makePi(noUI) as never, ctxAdapter(noUI) as never, BASE_DIR);
187
+ await resumePlansCommand(ctxAdapter(noUI) as never, BASE_DIR);
186
188
  assert.ok(noUI.notifies.some((n) => /interactive/.test(n.message)));
187
189
 
188
190
  const busy = makeCtx(workdir, { isIdleResult: false });
189
- await resumePlansCommand(makePi(busy) as never, ctxAdapter(busy) as never, BASE_DIR);
191
+ await resumePlansCommand(ctxAdapter(busy) as never, BASE_DIR);
190
192
  assert.ok(busy.notifies.some((n) => /busy/.test(n.message)));
191
193
  assert.equal(busy.userMessages.length, 0);
192
194
 
193
195
  const empty = setupRepo("empty-repo");
194
196
  const emptyCtx = makeCtx(empty);
195
- await resumePlansCommand(makePi(emptyCtx) as never, ctxAdapter(emptyCtx) as never, BASE_DIR);
197
+ await resumePlansCommand(ctxAdapter(emptyCtx) as never, BASE_DIR);
196
198
  assert.ok(emptyCtx.notifies.some((n) => /No resumable/.test(n.message)));
197
199
  });
198
200
 
199
201
  it("corrupt checkpoints report and change nothing", async () => {
200
202
  const workdir = setupRepo("corrupt-cmd");
201
203
  const run = startRun(workdir, { topic: "corruptcmd", skill: "plan-normal", requestText: "a" }).run;
202
- const cpPath = path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "checkpoint.json");
204
+ const cpPath = path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json");
203
205
  fs.writeFileSync(cpPath, "{ broken", "utf8");
204
206
  const mock = makeCtx(workdir);
205
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
207
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
206
208
  assert.ok(mock.notifies.some((n) => /corrupt/.test(n.message)));
207
209
  assert.equal(mock.userMessages.length, 0);
208
210
  assert.equal(fs.readFileSync(cpPath, "utf8"), "{ broken");
@@ -225,7 +227,7 @@ describe("/resume-plans command", () => {
225
227
  void cp;
226
228
 
227
229
  const mock = makeCtx(workdir);
228
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
230
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
229
231
  assert.equal(mock.userMessages.length, 1, "exactly one kickoff");
230
232
  const brief = mock.userMessages[0]!;
231
233
  assert.match(brief, /PI-PLANS RESUME/);
@@ -244,7 +246,7 @@ describe("/resume-plans command", () => {
244
246
  const latest = startRun(workdir, { topic: "three", skill: "plan-normal", requestText: "c" }).run;
245
247
  setRunStatus(workdir, latest.run_id, "abandoned");
246
248
  const mock = makeCtx(workdir, { selectAnswer: null });
247
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
249
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
248
250
  assert.equal(mock.userMessages.length, 0);
249
251
  assert.ok(mock.notifies.some((n) => /Cancelled/.test(n.message)));
250
252
  assert.equal(mock.selects.length, 1);
@@ -257,7 +259,7 @@ describe("/resume-plans command", () => {
257
259
  const child = spawn("sleep", ["30"], { stdio: "ignore" });
258
260
  try {
259
261
  fs.writeFileSync(
260
- path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "owner.json"),
262
+ path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "owner.json"),
261
263
  JSON.stringify({
262
264
  schema: 1,
263
265
  host: os.hostname(),
@@ -271,7 +273,7 @@ describe("/resume-plans command", () => {
271
273
  "utf8",
272
274
  );
273
275
  const mock = makeCtx(workdir);
274
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
276
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
275
277
  assert.ok(mock.notifies.some((n) => /actively owned/.test(n.message)));
276
278
  assert.equal(mock.userMessages.length, 0);
277
279
  } finally {
@@ -280,8 +282,11 @@ describe("/resume-plans command", () => {
280
282
  });
281
283
 
282
284
  it("cross-worktree: cancel changes nothing; confirm migrates artifacts and resets approval", async () => {
283
- // Source worktree holds the artifacts inside its own tree.
285
+ // Source worktree holds the artifacts inside its own tree. D-007 only
286
+ // applies to a per-worktree root, so pin one; the default root lives in
287
+ // the shared git common dir and is never migrated.
284
288
  const source = setupRepo("xwt-source", { commit: true });
289
+ setArtifactRoot(source, "./docs/pi-plans", "user");
285
290
  const run = startRun(source, { topic: "xwt", skill: "plan-normal", requestText: "a" }).run;
286
291
  const cp = createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
287
292
  void cp;
@@ -294,12 +299,12 @@ describe("/resume-plans command", () => {
294
299
  spawnSync("git", ["worktree", "add", target], { cwd: source });
295
300
 
296
301
  const cancelMock = makeCtx(target, { confirmAnswer: false });
297
- await resumePlansCommand(makePi(cancelMock) as never, ctxAdapter(cancelMock) as never, BASE_DIR);
302
+ await resumePlansCommand(ctxAdapter(cancelMock) as never, BASE_DIR);
298
303
  assert.equal(cancelMock.userMessages.length, 0);
299
304
  assert.ok(cancelMock.confirmShown.length >= 1);
300
305
 
301
306
  const goMock = makeCtx(target, { confirmAnswer: true });
302
- await resumePlansCommand(makePi(goMock) as never, ctxAdapter(goMock) as never, BASE_DIR);
307
+ await resumePlansCommand(ctxAdapter(goMock) as never, BASE_DIR);
303
308
  assert.equal(goMock.userMessages.length, 1, "one kickoff after migration");
304
309
  // Artifacts copied into the target worktree's artifact root.
305
310
  const copied = path.join(target, "docs", "pi-plans", path.basename(run.artifact_dir));
@@ -323,7 +328,7 @@ describe("/resume-plans command", () => {
323
328
  fs.mkdirSync(run.artifact_dir, { recursive: true });
324
329
  fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan", "utf8");
325
330
  const mock = makeCtx(workdir, { confirmAnswer: true });
326
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
331
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
327
332
  assert.equal(mock.userMessages.length, 1);
328
333
  assert.match(mock.userMessages[0]!, /re-run the execution handoff/);
329
334
  assert.ok(mock.confirmShown.some((c) => /Legacy execution run/.test(c.title)));
@@ -331,6 +336,7 @@ describe("/resume-plans command", () => {
331
336
 
332
337
  it("migrateRunIntoCurrentWorktree aborts on differing conflicts (F-003)", () => {
333
338
  const source = setupRepo("mig-overwrite", { commit: true });
339
+ setArtifactRoot(source, "./docs/pi-plans", "user");
334
340
  const run = startRun(source, { topic: "mig", skill: "plan-normal", requestText: "a" }).run;
335
341
  createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
336
342
  fs.mkdirSync(run.artifact_dir, { recursive: true });
@@ -376,7 +382,7 @@ describe("F-004 ledger reconcile (crash window)", () => {
376
382
  questionId: "q-x",
377
383
  });
378
384
  const mock = makeCtx(workdir);
379
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
385
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
380
386
  assert.equal(mock.userMessages.length, 1);
381
387
  const brief = mock.userMessages[0]!;
382
388
  assert.doesNotMatch(brief, /PENDING question/, "answered ledger entry wins");
@@ -386,58 +392,10 @@ describe("F-004 ledger reconcile (crash window)", () => {
386
392
  });
387
393
  });
388
394
 
389
- describe("impl-review config crash-window recovery (0.5.4, D-5)", () => {
390
- it("both answers in the ledger, none persisted: everything recovers, nothing missing", async () => {
391
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
392
- const ledger = [
393
- { questionId: "termination-condition", answer: "1 round" },
394
- { questionId: "impl-review-reviewer-count", answer: "3" },
395
- ];
396
- const resolved = resolveImplReviewConfig({ completedRounds: 0 }, ledger);
397
- assert.equal(resolved.condition, "1 round");
398
- assert.ok(resolved.conditionFromLedger);
399
- assert.equal(resolved.reviewerCount, 3);
400
- assert.ok(resolved.reviewerCountFromLedger);
401
- assert.deepEqual(resolved.missing, []);
402
- });
403
-
404
- it("only termination answered: reviewer-count is the single missing question", async () => {
405
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
406
- const resolved = resolveImplReviewConfig({ completedRounds: 0 }, [
407
- { questionId: "termination-condition", answer: "goal wait" },
408
- ]);
409
- assert.equal(resolved.condition, "goal wait");
410
- assert.equal(resolved.reviewerCount, undefined);
411
- assert.deepEqual(resolved.missing, ["impl-review-reviewer-count"]);
412
- });
413
-
414
- it("persisted checkpoint wins over the ledger; latest ledger entry wins", async () => {
415
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
416
- const resolved = resolveImplReviewConfig(
417
- { terminationCondition: "1 round", reviewerCount: 2, completedRounds: 0 },
418
- [
419
- { questionId: "termination-condition", answer: "goal wait" },
420
- { questionId: "impl-review-reviewer-count", answer: "1" },
421
- ],
422
- );
423
- assert.equal(resolved.condition, "1 round");
424
- assert.ok(!resolved.conditionFromLedger);
425
- assert.equal(resolved.reviewerCount, 2);
426
- assert.deepEqual(resolved.missing, []);
427
- const staleThenFresh = resolveImplReviewConfig({ completedRounds: 0 }, [
428
- { questionId: "termination-condition", answer: "old" },
429
- { questionId: "termination-condition", answer: "new" },
430
- ]);
431
- assert.equal(staleThenFresh.condition, "new", "latest ledger entry wins");
432
- });
433
-
434
- it("non-integer or out-of-range ledger counts are ignored, not adopted", async () => {
435
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
436
- for (const bad of ["0", "4", "three"]) {
437
- const resolved = resolveImplReviewConfig({ terminationCondition: "1 round", completedRounds: 0 }, [
438
- { questionId: "impl-review-reviewer-count", answer: bad },
439
- ]);
440
- assert.equal(resolved.reviewerCount, undefined, `ledger answer ${bad} ignored`);
441
- }
395
+ describe("impl-review crash-window recovery (removed in v0.6.1)", () => {
396
+ it("the resolver is gone with the loop: the helper no longer exists", async () => {
397
+ const mod = await import("../src/resume-command.ts");
398
+ assert.equal("resolveImplReviewConfig" in mod, false);
442
399
  });
443
400
  });
401
+