pi-plans 0.5.7 → 0.7.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 (112) hide show
  1. package/CONTRIBUTING.md +126 -0
  2. package/README.md +49 -39
  3. package/agents/ref-analyst.md +7 -4
  4. package/agents/reviewer.md +12 -3
  5. package/index.ts +74 -40
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +45 -58
  8. package/references/plan-artifact-template.md +71 -60
  9. package/references/state-and-config.md +63 -47
  10. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  11. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  12. package/scripts/run-tests.ts +12 -1
  13. package/scripts/validate.ts +22 -10
  14. package/skills/debug-and-plan/SKILL.md +4 -4
  15. package/skills/plan-big/SKILL.md +5 -5
  16. package/skills/plan-normal/SKILL.md +5 -5
  17. package/skills/plan-small/SKILL.md +5 -5
  18. package/skills/plan-with-refs/SKILL.md +8 -8
  19. package/skills/planning/SKILL.md +1 -1
  20. package/src/ask-form.ts +4 -4
  21. package/src/auditor.ts +126 -0
  22. package/src/auto-approve.ts +1 -1
  23. package/src/autocomplete.ts +19 -17
  24. package/src/code-graph/commands.ts +2 -2
  25. package/src/code-graph/community.ts +1 -1
  26. package/src/code-graph/paths.ts +1 -1
  27. package/src/code-graph/watch.ts +2 -2
  28. package/src/compaction.ts +3 -3
  29. package/src/config-command.ts +154 -76
  30. package/src/dashboard.ts +257 -0
  31. package/src/exec.ts +709 -705
  32. package/src/global-state.ts +304 -0
  33. package/src/guard.ts +16 -3
  34. package/src/messaging.ts +44 -0
  35. package/src/plan.ts +421 -112
  36. package/src/query-hook.ts +4 -4
  37. package/src/refine-prompts.ts +14 -72
  38. package/src/refine-ui-helpers.ts +24 -5
  39. package/src/refine-ui-state.ts +1 -1
  40. package/src/refine-ui.ts +1 -1
  41. package/src/resume-command.ts +40 -130
  42. package/src/resume.ts +15 -17
  43. package/src/role-panels.ts +542 -0
  44. package/src/run-context.ts +5 -4
  45. package/src/run-picker.ts +98 -0
  46. package/src/state.ts +380 -77
  47. package/src/subagent.ts +32 -1
  48. package/src/task-tool.ts +100 -0
  49. package/src/tasks.ts +189 -0
  50. package/src/thinking-levels.ts +67 -0
  51. package/src/ui-language.ts +3 -54
  52. package/src/workflow-state.ts +78 -57
  53. package/tests/analyze-refs.test.ts +35 -18
  54. package/tests/ask-choice-pros-cons.test.ts +147 -0
  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 +111 -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 +268 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec.test.ts +617 -1706
  75. package/tests/execute-plan.test.ts +44 -19
  76. package/tests/extension-load.test.ts +48 -0
  77. package/tests/global-state.test.ts +371 -0
  78. package/tests/graph-aware-file-tools.test.ts +5 -5
  79. package/tests/guard.test.ts +1 -1
  80. package/tests/multi-run.test.ts +184 -0
  81. package/tests/plan.test.ts +139 -62
  82. package/tests/plans.test.ts +7 -79
  83. package/tests/refine-prompts.test.ts +20 -71
  84. package/tests/refine-resume.test.ts +27 -22
  85. package/tests/refine-ui.test.ts +6 -15
  86. package/tests/resume-lifecycle.test.ts +37 -22
  87. package/tests/resume.test.ts +43 -88
  88. package/tests/role-panels.test.ts +391 -0
  89. package/tests/run-context.test.ts +1 -1
  90. package/tests/run-ownership.test.ts +1 -1
  91. package/tests/stale-ctx.test.ts +218 -0
  92. package/tests/state.test.ts +151 -32
  93. package/tests/subagent-thinking.test.ts +65 -0
  94. package/tests/subagent-usage.test.ts +1 -1
  95. package/tests/task-tool.test.ts +61 -0
  96. package/tests/thinking-levels.test.ts +77 -0
  97. package/tests/ui-language.test.ts +2 -17
  98. package/tests/workflow-state.test.ts +17 -99
  99. package/tools/analyze-refs.ts +67 -32
  100. package/tools/ask-choice.ts +19 -49
  101. package/tools/code-graph.ts +2 -2
  102. package/tools/execute-plan.ts +63 -33
  103. package/tools/graph-aware-file-tools.ts +6 -4
  104. package/tools/plans.ts +40 -66
  105. package/tools/refine.ts +101 -164
  106. package/agents/criticizer.md +0 -18
  107. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  108. package/src/panel.ts +0 -473
  109. package/src/termination-prompt.ts +0 -73
  110. package/tests/goal-wait.test.ts +0 -269
  111. package/tests/panel-i-zero.test.ts +0 -420
  112. 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,15 @@ 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
+ assert.equal(final.checkpoint.execution?.tasks?.["Task-2"]?.status, undefined);
249
263
  }
250
- assert.equal(getRun(cwd, runId)?.status, "done");
251
264
  } finally {
252
265
  session2.session.dispose();
253
266
  }
@@ -313,7 +326,7 @@ describe("/resume-plans on the real Pi host", () => {
313
326
  const child = spawn("sleep", ["30"], { stdio: "ignore" });
314
327
  try {
315
328
  fs.writeFileSync(
316
- path.join(cwd, ".git", "pi_plans", "runs", runId, "owner.json"),
329
+ path.join(cwd, ".git", "pi-plans", "runs", runId, "owner.json"),
317
330
  JSON.stringify({
318
331
  schema: 1,
319
332
  host: os.hostname(),
@@ -369,7 +382,9 @@ describe("/resume-plans on the real Pi host", () => {
369
382
  });
370
383
 
371
384
  function artifactDirRelative(cwd: string): string {
372
- const root = path.join(cwd, "docs", "pi-plans");
385
+ // Resolve through the real default so this fixture follows the configured
386
+ // artifact root (inside the state dir by default) instead of hardcoding it.
387
+ const root = resolveArtifactRoot(cwd, DEFAULT_CONFIG.artifact_root);
373
388
  return path.join(root, fs.readdirSync(root)[0]!);
374
389
  }
375
390
 
@@ -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, "..");
@@ -113,7 +109,7 @@ after(() => {
113
109
  });
114
110
 
115
111
  describe("candidate discovery", () => {
116
- it("lists resumable runs, excludes terminal ones, prioritizes active", () => {
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;
119
115
  const stopped = startRun(workdir, { topic: "beta", skill: "plan-normal", requestText: "b" }).run;
@@ -136,21 +132,24 @@ describe("candidate discovery", () => {
136
132
  assert.equal(ids.includes(abandoned.run_id), false);
137
133
  assert.equal(ids.includes(done.run_id), false, "plain done excluded");
138
134
 
139
- // Active priority: shared pointer names `doneWithReview` (last started).
140
- const picked = pickDefaultCandidate(workdir, candidates);
141
- assert.equal(picked?.runId, doneWithReview.run_id);
135
+ // v0.6.0: the active-pointer auto-win is gone. The registry hint (newest
136
+ // non-terminal run) still sorts first; picking defaults to null when
137
+ // several candidates exist — the command opens the form (binding-first).
138
+ assert.equal(candidates[0]!.runId, stopped.run_id, "registry hint sorts first");
139
+ assert.equal(pickDefaultCandidate(workdir, candidates), null, "multi-candidate requires choosing");
142
140
  });
143
141
 
144
- it("unique candidate auto-picks; unfinished active wins; ambiguity requires choosing", () => {
142
+ it("unique candidate auto-picks; ambiguity requires choosing (v0.6.0)", () => {
145
143
  const workdir = setupRepo("picking");
146
144
  const only = startRun(workdir, { topic: "only", skill: "plan-normal", requestText: "a" }).run;
147
145
  const candidates = listResumeCandidates(workdir);
148
146
  assert.equal(pickDefaultCandidate(workdir, candidates)?.runId, only.run_id);
149
147
 
150
- // D-001: an unfinished active run wins even when others exist.
148
+ // v0.6.0 (D-1): no auto-win — two resumable runs are ambiguous here; the
149
+ // command's binding-first path handles the session-bound case.
151
150
  const second = startRun(workdir, { topic: "second", skill: "plan-normal", requestText: "b" }).run;
152
151
  const withActive = listResumeCandidates(workdir);
153
- assert.equal(pickDefaultCandidate(workdir, withActive)?.runId, second.run_id);
152
+ assert.equal(pickDefaultCandidate(workdir, withActive), null, "two candidates require choosing");
154
153
 
155
154
  // Ambiguity: two resumable runs, active pointer names a non-resumable one.
156
155
  const third = startRun(workdir, { topic: "third", skill: "plan-normal", requestText: "c" }).run;
@@ -165,7 +164,7 @@ describe("candidate discovery", () => {
165
164
  const run = startRun(workdir, { topic: "corrupt", skill: "plan-normal", requestText: "a" }).run;
166
165
  createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
167
166
  fs.writeFileSync(
168
- path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "checkpoint.json"),
167
+ path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json"),
169
168
  "{ broken",
170
169
  "utf8",
171
170
  );
@@ -179,27 +178,27 @@ describe("/resume-plans command", () => {
179
178
  it("refuses non-interactive and busy sessions, notifies when nothing is resumable", async () => {
180
179
  const workdir = setupRepo("guards");
181
180
  const noUI = makeCtx(workdir, { hasUI: false });
182
- await resumePlansCommand(makePi(noUI) as never, ctxAdapter(noUI) as never, BASE_DIR);
181
+ await resumePlansCommand(ctxAdapter(noUI) as never, BASE_DIR);
183
182
  assert.ok(noUI.notifies.some((n) => /interactive/.test(n.message)));
184
183
 
185
184
  const busy = makeCtx(workdir, { isIdleResult: false });
186
- await resumePlansCommand(makePi(busy) as never, ctxAdapter(busy) as never, BASE_DIR);
185
+ await resumePlansCommand(ctxAdapter(busy) as never, BASE_DIR);
187
186
  assert.ok(busy.notifies.some((n) => /busy/.test(n.message)));
188
187
  assert.equal(busy.userMessages.length, 0);
189
188
 
190
189
  const empty = setupRepo("empty-repo");
191
190
  const emptyCtx = makeCtx(empty);
192
- await resumePlansCommand(makePi(emptyCtx) as never, ctxAdapter(emptyCtx) as never, BASE_DIR);
191
+ await resumePlansCommand(ctxAdapter(emptyCtx) as never, BASE_DIR);
193
192
  assert.ok(emptyCtx.notifies.some((n) => /No resumable/.test(n.message)));
194
193
  });
195
194
 
196
195
  it("corrupt checkpoints report and change nothing", async () => {
197
196
  const workdir = setupRepo("corrupt-cmd");
198
197
  const run = startRun(workdir, { topic: "corruptcmd", skill: "plan-normal", requestText: "a" }).run;
199
- const cpPath = path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "checkpoint.json");
198
+ const cpPath = path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "checkpoint.json");
200
199
  fs.writeFileSync(cpPath, "{ broken", "utf8");
201
200
  const mock = makeCtx(workdir);
202
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
201
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
203
202
  assert.ok(mock.notifies.some((n) => /corrupt/.test(n.message)));
204
203
  assert.equal(mock.userMessages.length, 0);
205
204
  assert.equal(fs.readFileSync(cpPath, "utf8"), "{ broken");
@@ -222,7 +221,7 @@ describe("/resume-plans command", () => {
222
221
  void cp;
223
222
 
224
223
  const mock = makeCtx(workdir);
225
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
224
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
226
225
  assert.equal(mock.userMessages.length, 1, "exactly one kickoff");
227
226
  const brief = mock.userMessages[0]!;
228
227
  assert.match(brief, /PI-PLANS RESUME/);
@@ -241,7 +240,7 @@ describe("/resume-plans command", () => {
241
240
  const latest = startRun(workdir, { topic: "three", skill: "plan-normal", requestText: "c" }).run;
242
241
  setRunStatus(workdir, latest.run_id, "abandoned");
243
242
  const mock = makeCtx(workdir, { selectAnswer: null });
244
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
243
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
245
244
  assert.equal(mock.userMessages.length, 0);
246
245
  assert.ok(mock.notifies.some((n) => /Cancelled/.test(n.message)));
247
246
  assert.equal(mock.selects.length, 1);
@@ -254,7 +253,7 @@ describe("/resume-plans command", () => {
254
253
  const child = spawn("sleep", ["30"], { stdio: "ignore" });
255
254
  try {
256
255
  fs.writeFileSync(
257
- path.join(workdir, ".git", "pi_plans", "runs", run.run_id, "owner.json"),
256
+ path.join(workdir, ".git", "pi-plans", "runs", run.run_id, "owner.json"),
258
257
  JSON.stringify({
259
258
  schema: 1,
260
259
  host: os.hostname(),
@@ -268,7 +267,7 @@ describe("/resume-plans command", () => {
268
267
  "utf8",
269
268
  );
270
269
  const mock = makeCtx(workdir);
271
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
270
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
272
271
  assert.ok(mock.notifies.some((n) => /actively owned/.test(n.message)));
273
272
  assert.equal(mock.userMessages.length, 0);
274
273
  } finally {
@@ -277,8 +276,11 @@ describe("/resume-plans command", () => {
277
276
  });
278
277
 
279
278
  it("cross-worktree: cancel changes nothing; confirm migrates artifacts and resets approval", async () => {
280
- // Source worktree holds the artifacts inside its own tree.
279
+ // Source worktree holds the artifacts inside its own tree. D-007 only
280
+ // applies to a per-worktree root, so pin one; the default root lives in
281
+ // the shared git common dir and is never migrated.
281
282
  const source = setupRepo("xwt-source", { commit: true });
283
+ setArtifactRoot(source, "./docs/pi-plans", "user");
282
284
  const run = startRun(source, { topic: "xwt", skill: "plan-normal", requestText: "a" }).run;
283
285
  const cp = createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
284
286
  void cp;
@@ -291,12 +293,12 @@ describe("/resume-plans command", () => {
291
293
  spawnSync("git", ["worktree", "add", target], { cwd: source });
292
294
 
293
295
  const cancelMock = makeCtx(target, { confirmAnswer: false });
294
- await resumePlansCommand(makePi(cancelMock) as never, ctxAdapter(cancelMock) as never, BASE_DIR);
296
+ await resumePlansCommand(ctxAdapter(cancelMock) as never, BASE_DIR);
295
297
  assert.equal(cancelMock.userMessages.length, 0);
296
298
  assert.ok(cancelMock.confirmShown.length >= 1);
297
299
 
298
300
  const goMock = makeCtx(target, { confirmAnswer: true });
299
- await resumePlansCommand(makePi(goMock) as never, ctxAdapter(goMock) as never, BASE_DIR);
301
+ await resumePlansCommand(ctxAdapter(goMock) as never, BASE_DIR);
300
302
  assert.equal(goMock.userMessages.length, 1, "one kickoff after migration");
301
303
  // Artifacts copied into the target worktree's artifact root.
302
304
  const copied = path.join(target, "docs", "pi-plans", path.basename(run.artifact_dir));
@@ -320,7 +322,7 @@ describe("/resume-plans command", () => {
320
322
  fs.mkdirSync(run.artifact_dir, { recursive: true });
321
323
  fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan", "utf8");
322
324
  const mock = makeCtx(workdir, { confirmAnswer: true });
323
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
325
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
324
326
  assert.equal(mock.userMessages.length, 1);
325
327
  assert.match(mock.userMessages[0]!, /re-run the execution handoff/);
326
328
  assert.ok(mock.confirmShown.some((c) => /Legacy execution run/.test(c.title)));
@@ -328,6 +330,7 @@ describe("/resume-plans command", () => {
328
330
 
329
331
  it("migrateRunIntoCurrentWorktree aborts on differing conflicts (F-003)", () => {
330
332
  const source = setupRepo("mig-overwrite", { commit: true });
333
+ setArtifactRoot(source, "./docs/pi-plans", "user");
331
334
  const run = startRun(source, { topic: "mig", skill: "plan-normal", requestText: "a" }).run;
332
335
  createCheckpoint(source, { runId: run.run_id, originWorkdir: source, workdir: source });
333
336
  fs.mkdirSync(run.artifact_dir, { recursive: true });
@@ -373,7 +376,7 @@ describe("F-004 ledger reconcile (crash window)", () => {
373
376
  questionId: "q-x",
374
377
  });
375
378
  const mock = makeCtx(workdir);
376
- await resumePlansCommand(makePi(mock) as never, ctxAdapter(mock) as never, BASE_DIR);
379
+ await resumePlansCommand(ctxAdapter(mock) as never, BASE_DIR);
377
380
  assert.equal(mock.userMessages.length, 1);
378
381
  const brief = mock.userMessages[0]!;
379
382
  assert.doesNotMatch(brief, /PENDING question/, "answered ledger entry wins");
@@ -383,58 +386,10 @@ describe("F-004 ledger reconcile (crash window)", () => {
383
386
  });
384
387
  });
385
388
 
386
- describe("impl-review config crash-window recovery (0.5.4, D-5)", () => {
387
- it("both answers in the ledger, none persisted: everything recovers, nothing missing", async () => {
388
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
389
- const ledger = [
390
- { questionId: "termination-condition", answer: "1 round" },
391
- { questionId: "impl-review-reviewer-count", answer: "3" },
392
- ];
393
- const resolved = resolveImplReviewConfig({ completedRounds: 0 }, ledger);
394
- assert.equal(resolved.condition, "1 round");
395
- assert.ok(resolved.conditionFromLedger);
396
- assert.equal(resolved.reviewerCount, 3);
397
- assert.ok(resolved.reviewerCountFromLedger);
398
- assert.deepEqual(resolved.missing, []);
399
- });
400
-
401
- it("only termination answered: reviewer-count is the single missing question", async () => {
402
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
403
- const resolved = resolveImplReviewConfig({ completedRounds: 0 }, [
404
- { questionId: "termination-condition", answer: "goal wait" },
405
- ]);
406
- assert.equal(resolved.condition, "goal wait");
407
- assert.equal(resolved.reviewerCount, undefined);
408
- assert.deepEqual(resolved.missing, ["impl-review-reviewer-count"]);
409
- });
410
-
411
- it("persisted checkpoint wins over the ledger; latest ledger entry wins", async () => {
412
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
413
- const resolved = resolveImplReviewConfig(
414
- { terminationCondition: "1 round", reviewerCount: 2, completedRounds: 0 },
415
- [
416
- { questionId: "termination-condition", answer: "goal wait" },
417
- { questionId: "impl-review-reviewer-count", answer: "1" },
418
- ],
419
- );
420
- assert.equal(resolved.condition, "1 round");
421
- assert.ok(!resolved.conditionFromLedger);
422
- assert.equal(resolved.reviewerCount, 2);
423
- assert.deepEqual(resolved.missing, []);
424
- const staleThenFresh = resolveImplReviewConfig({ completedRounds: 0 }, [
425
- { questionId: "termination-condition", answer: "old" },
426
- { questionId: "termination-condition", answer: "new" },
427
- ]);
428
- assert.equal(staleThenFresh.condition, "new", "latest ledger entry wins");
429
- });
430
-
431
- it("non-integer or out-of-range ledger counts are ignored, not adopted", async () => {
432
- const { resolveImplReviewConfig } = await import("../src/resume-command.ts");
433
- for (const bad of ["0", "4", "three"]) {
434
- const resolved = resolveImplReviewConfig({ terminationCondition: "1 round", completedRounds: 0 }, [
435
- { questionId: "impl-review-reviewer-count", answer: bad },
436
- ]);
437
- assert.equal(resolved.reviewerCount, undefined, `ledger answer ${bad} ignored`);
438
- }
389
+ describe("impl-review crash-window recovery (removed in v0.6.1)", () => {
390
+ it("the resolver is gone with the loop: the helper no longer exists", async () => {
391
+ const mod = await import("../src/resume-command.ts");
392
+ assert.equal("resolveImplReviewConfig" in mod, false);
439
393
  });
440
394
  });
395
+