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.
@@ -262,3 +262,116 @@ describe("ask_choice panel fitting", () => {
262
262
  assert.ok(totalLines < rows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
263
263
  });
264
264
  });
265
+
266
+ describe("ask_choice checkpoint question lifecycle (I-003)", () => {
267
+ it("records pending before the panel opens and the answer before it returns", async () => {
268
+ const { spawnSync } = await import("node:child_process");
269
+ const { initState, startRun } = await import("../src/state.ts");
270
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
271
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-cp-"));
272
+ try {
273
+ spawnSync("git", ["init"], { cwd: workdir });
274
+ initState(workdir);
275
+ const { run } = startRun(workdir, { topic: "askcp", skill: "plan-small", requestText: "t" });
276
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
277
+
278
+ let pendingDuringPanel: unknown = null;
279
+ const tool = loadTool();
280
+ const ctx = makeCtx({
281
+ select: async (_question, labels) => {
282
+ pendingDuringPanel = loadCheckpoint(workdir, run.run_id);
283
+ return labels[0];
284
+ },
285
+ });
286
+ const result = await tool.execute("t1", {
287
+ question: "Scope ok?",
288
+ options: OPTIONS,
289
+ workdir,
290
+ questionId: "scope-confirm",
291
+ purpose: "scope",
292
+ }, undefined, undefined, ctx);
293
+ assert.match(result.content[0].text, /User selected: 1/);
294
+
295
+ // During the panel: the pending question is already durable.
296
+ const during = pendingDuringPanel as { status: string; checkpoint?: { pendingQuestion: unknown } };
297
+ assert.equal(during.status, "ok");
298
+ assert.ok(during.checkpoint?.pendingQuestion, "pending recorded before display");
299
+
300
+ // After the answer: cleared + answered with source user.
301
+ const after = loadCheckpoint(workdir, run.run_id);
302
+ assert.equal(after.status, "ok");
303
+ if (after.status === "ok") {
304
+ assert.equal(after.checkpoint.pendingQuestion, null);
305
+ assert.equal(after.checkpoint.answeredQuestions.length, 1);
306
+ assert.equal(after.checkpoint.answeredQuestions[0]?.questionId, "scope-confirm");
307
+ assert.equal(after.checkpoint.answeredQuestions[0]?.source, "user");
308
+ }
309
+ } finally {
310
+ fs.rmSync(workdir, { recursive: true, force: true });
311
+ }
312
+ });
313
+
314
+ it("keeps the pending question when the user cancels", async () => {
315
+ const { spawnSync } = await import("node:child_process");
316
+ const { initState, startRun } = await import("../src/state.ts");
317
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
318
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-cancel-"));
319
+ try {
320
+ spawnSync("git", ["init"], { cwd: workdir });
321
+ initState(workdir);
322
+ const { run } = startRun(workdir, { topic: "askcancel", skill: "plan-small", requestText: "t" });
323
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
324
+ const tool = loadTool();
325
+ const ctx = makeCtx({ select: async () => undefined });
326
+ const result = await tool.execute("t1", {
327
+ question: "Scope ok?",
328
+ options: OPTIONS,
329
+ workdir,
330
+ questionId: "scope-confirm",
331
+ }, undefined, undefined, ctx);
332
+ assert.match(result.content[0].text, /cancelled/i);
333
+ const loaded = loadCheckpoint(workdir, run.run_id);
334
+ if (loaded.status === "ok") {
335
+ assert.ok(loaded.checkpoint.pendingQuestion, "cancelled question stays pending for resume");
336
+ assert.equal(loaded.checkpoint.answeredQuestions.length, 0);
337
+ }
338
+ } finally {
339
+ fs.rmSync(workdir, { recursive: true, force: true });
340
+ }
341
+ });
342
+
343
+ it("skips checkpoint writes without questionId or without a checkpoint", async () => {
344
+ const { spawnSync } = await import("node:child_process");
345
+ const { initState, startRun } = await import("../src/state.ts");
346
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
347
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-skip-"));
348
+ try {
349
+ spawnSync("git", ["init"], { cwd: workdir });
350
+ initState(workdir);
351
+ const withCp = startRun(workdir, { topic: "withcp", skill: "plan-small", requestText: "t" }).run;
352
+ createCheckpoint(workdir, { runId: withCp.run_id, originWorkdir: workdir, workdir });
353
+ const noCp = startRun(workdir, { topic: "nocp", skill: "plan-small", requestText: "t" }).run;
354
+
355
+ const tool = loadTool();
356
+ const ctx = makeCtx({ select: async (_question, labels) => labels[0] });
357
+ // No questionId → no checkpoint mutation.
358
+ await tool.execute("t1", { question: "Q?", options: OPTIONS, workdir }, undefined, undefined, ctx);
359
+ let loaded = loadCheckpoint(workdir, withCp.run_id);
360
+ if (loaded.status === "ok") {
361
+ assert.equal(loaded.checkpoint.pendingQuestion, null);
362
+ assert.equal(loaded.checkpoint.answeredQuestions.length, 0);
363
+ }
364
+ // questionId but the active run has no checkpoint → silent skip.
365
+ const result = await tool.execute("t2", {
366
+ question: "Q?",
367
+ options: OPTIONS,
368
+ workdir,
369
+ questionId: "q-no-cp",
370
+ }, undefined, undefined, ctx);
371
+ assert.match(result.content[0].text, /User selected/);
372
+ assert.equal(loadCheckpoint(workdir, noCp.run_id).status, "missing");
373
+ } finally {
374
+ fs.rmSync(workdir, { recursive: true, force: true });
375
+ }
376
+ });
377
+ });
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_VCC_SETTINGS,
11
11
  loadVccSettings,
12
12
  parseCompactionInstructions,
13
+ PLANNING_PREPLAN_COMPACT_HINT,
13
14
  PI_VCC_COMPACT_INSTRUCTION,
14
15
  scaffoldVccSettings,
15
16
  shouldScheduleAutoContinue,
@@ -386,3 +387,41 @@ describe("pi-vcc compaction", () => {
386
387
  }
387
388
  });
388
389
  });
390
+ describe("pre-plan compaction settings and hint", () => {
391
+ let tmpRoot: string;
392
+
393
+ before(() => {
394
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-preplan-vcc-"));
395
+ });
396
+
397
+ after(() => {
398
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
399
+ });
400
+
401
+ it("parses the pre-plan compaction hint as an internal pi-plans instruction", () => {
402
+ assert.equal(DEFAULT_VCC_SETTINGS.prePlanCompact, true);
403
+ assert.deepEqual(parseCompactionInstructions(PLANNING_PREPLAN_COMPACT_HINT), {
404
+ isPiVcc: false,
405
+ isInternalPiPlans: true,
406
+ keepUserTurns: 1,
407
+ keepUserTurnsExplicit: false,
408
+ followUpPrompt: null,
409
+ });
410
+ });
411
+
412
+ it("fills a missing prePlanCompact key and honors an explicit false", () => {
413
+ const stateRoot = path.join(tmpRoot, "state");
414
+ fs.mkdirSync(stateRoot, { recursive: true });
415
+ fs.writeFileSync(vccSettingsPath(stateRoot), JSON.stringify({ debug: true }), "utf8");
416
+ scaffoldVccSettings(stateRoot);
417
+ assert.deepEqual(loadVccSettings(stateRoot), { ...DEFAULT_VCC_SETTINGS, debug: true });
418
+
419
+ fs.writeFileSync(
420
+ vccSettingsPath(stateRoot),
421
+ JSON.stringify({ ...DEFAULT_VCC_SETTINGS, prePlanCompact: false }),
422
+ "utf8",
423
+ );
424
+ scaffoldVccSettings(stateRoot);
425
+ assert.equal(loadVccSettings(stateRoot).prePlanCompact, false);
426
+ });
427
+ });
@@ -15,6 +15,7 @@ import {
15
15
  completeExecution,
16
16
  consumePendingExecutionFlush,
17
17
  consumePlanningCompactionResumeGuard,
18
+ consumePrePlanCompactPending,
18
19
  drainExecutionFlush,
19
20
  executionContextMessage,
20
21
  filterExecutionResumeMessages,
@@ -31,11 +32,15 @@ import {
31
32
  handlePlanningCompact,
32
33
  handlePlanningCompactFailed,
33
34
  isExecutionComplete,
35
+ markPrePlanCompactPending,
34
36
  PLANNING_PLAN_WRITTEN_CUSTOM_TYPE,
37
+ PLANNING_PREPLAN_COMPACT_HINT,
38
+ PLANNING_PREPLAN_RESUME_CUSTOM_TYPE,
35
39
  PLANNING_RUN_START_CUSTOM_TYPE,
36
40
  refreshPlanningCompactionCooldown,
37
41
  requestPlanningCompaction,
38
42
  restoreFromSession,
43
+ sendPrePlanCompactResume,
39
44
  shouldTriggerPlanningCompaction,
40
45
  startExecution,
41
46
  recordExecutionTurn,
@@ -43,6 +48,7 @@ import {
43
48
  stopExecution,
44
49
  updateStatusWidget,
45
50
  } from "../src/exec.ts";
51
+ import { buildPiPlansVccCompaction, loadVccSettings } from "../src/compaction.ts";
46
52
  import type { CheckItem } from "../src/plan.ts";
47
53
  import { initState, setGraphEnabled, setRunStatus, startRun } from "../src/state.ts";
48
54
 
@@ -1464,3 +1470,247 @@ describe("amelioration termination prompt", () => {
1464
1470
  assert.match(AMELIORATION_PROMPT_TEXT, /How should the implementation-review loop terminate\?/);
1465
1471
  });
1466
1472
  });
1473
+
1474
+ describe("checkpoint-backed execution restore (I-005)", () => {
1475
+ it("startExecution records approval (plan digest + HEAD) and progress in the checkpoint", async () => {
1476
+ const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
1477
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
1478
+ const { resetRunBindingForTests } = await import("../src/run-context.ts");
1479
+ resetRunBindingForTests();
1480
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-cp-"));
1481
+ try {
1482
+ const { spawnSync } = await import("node:child_process");
1483
+ spawnSync("git", ["init"], { cwd: workdir });
1484
+ spawnSync("git", ["config", "user.email", "t@e.com"], { cwd: workdir });
1485
+ spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
1486
+ initState(workdir);
1487
+ const { run } = startRun(workdir, { topic: "exec-cp", skill: "plan-normal", requestText: "t" });
1488
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
1489
+ const artifactDir = run.artifact_dir;
1490
+ fs.mkdirSync(artifactDir, { recursive: true });
1491
+ const planPath = path.join(artifactDir, "PLAN_v1.md");
1492
+ fs.writeFileSync(
1493
+ planPath,
1494
+ "# Plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n- [ ] `VC-002` covers `I-002`; pass condition: y.\n",
1495
+ "utf8",
1496
+ );
1497
+ spawnSync("git", ["add", "-A"], { cwd: workdir });
1498
+ spawnSync("git", ["commit", "-m", "init"], { cwd: workdir });
1499
+
1500
+ const harness = makeHarness(workdir);
1501
+ const items: CheckItem[] = [
1502
+ { id: "VC-001", text: "covers I-001", done: false },
1503
+ { id: "VC-002", text: "covers I-002", done: false },
1504
+ ];
1505
+ await startExecution(harness.pi, harness.ctx, planPath, items);
1506
+
1507
+ const loaded = loadCheckpoint(workdir, run.run_id);
1508
+ assert.equal(loaded.status, "ok");
1509
+ if (loaded.status === "ok") {
1510
+ assert.equal(loaded.checkpoint.phase, "executing");
1511
+ const approval = loaded.checkpoint.execution?.approval;
1512
+ assert.ok(approval, "approval recorded");
1513
+ assert.match(approval!.headAtApproval ?? "", /^[0-9a-f]{40}$/, "HEAD at approval");
1514
+ assert.equal(approval!.plan.path, planPath);
1515
+ }
1516
+
1517
+ // Progress lands in the checkpoint.
1518
+ applyDoneMarkers("[DONE:VC-001]");
1519
+ recordExecutionTurn(harness.pi, harness.ctx, ["VC-001"], { input: 5, output: 2 });
1520
+ const afterProgress = loadCheckpoint(workdir, run.run_id);
1521
+ if (afterProgress.status === "ok") {
1522
+ assert.deepEqual(afterProgress.checkpoint.execution?.doneVcIds, ["VC-001"]);
1523
+ assert.equal(afterProgress.checkpoint.execution?.usage.inToks, 5);
1524
+ }
1525
+
1526
+ // Cross-session restore: fresh harness (new session), load from checkpoint.
1527
+ resetRunBindingForTests();
1528
+ const harness2 = makeHarness(workdir);
1529
+ const restore = loadExecutionFromCheckpoint(harness2.pi, harness2.ctx, run.run_id);
1530
+ assert.equal(restore.status, "loaded");
1531
+ assert.deepEqual(restore.doneVcIds, ["VC-001"]);
1532
+ assert.equal(restore.reverifyAll, false, "same HEAD keeps verified VCs");
1533
+ assert.equal(getExecution()?.items.find((item) => item.id === "VC-001")?.done, true);
1534
+ // F-002: an immediate session snapshot was appended.
1535
+ const snapshots = harness2.recorded.entries.filter((entry) => entry.customType === "pi-plans-exec");
1536
+ assert.ok(snapshots.length >= 1, "session snapshot written on load");
1537
+
1538
+ // Stop keeps progress with pausedReason (D-008).
1539
+ await stopExecution(harness2.pi, harness2.ctx, "stopped by user");
1540
+ const stopped = loadCheckpoint(workdir, run.run_id);
1541
+ if (stopped.status === "ok") {
1542
+ assert.equal(stopped.checkpoint.execution?.pausedReason, "stopped by user");
1543
+ assert.deepEqual(stopped.checkpoint.execution?.doneVcIds, ["VC-001"]);
1544
+ }
1545
+ } finally {
1546
+ fs.rmSync(workdir, { recursive: true, force: true });
1547
+ }
1548
+ });
1549
+
1550
+ it("HEAD change after approval keeps authorization but re-verifies VCs (D-011)", async () => {
1551
+ const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
1552
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
1553
+ const { resetRunBindingForTests } = await import("../src/run-context.ts");
1554
+ resetRunBindingForTests();
1555
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-head-"));
1556
+ try {
1557
+ const { spawnSync } = await import("node:child_process");
1558
+ spawnSync("git", ["init"], { cwd: workdir });
1559
+ spawnSync("git", ["config", "user.email", "t@e.com"], { cwd: workdir });
1560
+ spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
1561
+ initState(workdir);
1562
+ const { run } = startRun(workdir, { topic: "exec-head", skill: "plan-normal", requestText: "t" });
1563
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
1564
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
1565
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
1566
+ fs.writeFileSync(
1567
+ planPath,
1568
+ "# Plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n",
1569
+ "utf8",
1570
+ );
1571
+ spawnSync("git", ["add", "-A"], { cwd: workdir });
1572
+ spawnSync("git", ["commit", "-m", "one"], { cwd: workdir });
1573
+
1574
+ const harness = makeHarness(workdir);
1575
+ await startExecution(harness.pi, harness.ctx, planPath, [
1576
+ { id: "VC-001", text: "covers I-001", done: false },
1577
+ ]);
1578
+ applyDoneMarkers("[DONE:VC-001]");
1579
+ recordExecutionTurn(harness.pi, harness.ctx, ["VC-001"]);
1580
+
1581
+ // Simulate a branch switch / reset under the unchanged plan.
1582
+ spawnSync("git", ["commit", "--allow-empty", "-m", "two"], { cwd: workdir });
1583
+
1584
+ resetRunBindingForTests();
1585
+ const harness2 = makeHarness(workdir);
1586
+ const restore = loadExecutionFromCheckpoint(harness2.pi, harness2.ctx, run.run_id);
1587
+ assert.equal(restore.status, "loaded");
1588
+ assert.deepEqual(restore.doneVcIds, ["VC-001"], "historical evidence kept");
1589
+ assert.equal(restore.reverifyAll, true, "HEAD change forces re-verification");
1590
+ assert.equal(getExecution()?.items[0]?.done, false, "old VC not auto-passed");
1591
+ // Authorization survives.
1592
+ const cp = loadCheckpoint(workdir, run.run_id);
1593
+ if (cp.status === "ok") {
1594
+ assert.ok(cp.checkpoint.execution?.approval, "authorization kept");
1595
+ assert.equal(cp.checkpoint.execution?.reverifyAll, true);
1596
+ }
1597
+ } finally {
1598
+ fs.rmSync(workdir, { recursive: true, force: true });
1599
+ }
1600
+ });
1601
+ });
1602
+
1603
+ describe("pre-plan compaction (start-run trigger)", () => {
1604
+ let tmpRoot: string;
1605
+ let counter = 0;
1606
+
1607
+ before(() => {
1608
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-preplan-"));
1609
+ });
1610
+
1611
+ after(() => {
1612
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
1613
+ });
1614
+
1615
+ async function preplanHarness() {
1616
+ const piPlansExtension = (await import("../index.ts")).default;
1617
+ const harness = makeHarness(path.join(tmpRoot, `ws-${++counter}`));
1618
+ fs.mkdirSync(harness.ctx.cwd, { recursive: true });
1619
+ piPlansExtension(harness.pi as never);
1620
+ return harness;
1621
+ }
1622
+
1623
+ it("routes the pre-plan hint through the planning VCC path for a fresh run", () => {
1624
+ // Pure-builder assertion (order-independent): the getExecution() gate on
1625
+ // buildPlanningCompactionResult is covered by the planning-hook tests.
1626
+ const workdir = path.join(tmpRoot, `hint-${++counter}`);
1627
+ fs.mkdirSync(workdir, { recursive: true });
1628
+ initState(workdir);
1629
+ const { run } = startRun(workdir, { topic: "preplan hint", skill: "plan-normal", requestText: "x" });
1630
+ const built = buildPiPlansVccCompaction({
1631
+ branchEntries: compactableBranchEntries(),
1632
+ preparation: makePreparation("manual", null),
1633
+ customInstructions: PLANNING_PREPLAN_COMPACT_HINT,
1634
+ reason: "manual",
1635
+ willRetry: false,
1636
+ settings: loadVccSettings(path.join(workdir, ".git", "pi_plans")),
1637
+ phaseContext: { phase: "planning", runId: run.run_id, artifactDir: run.artifact_dir },
1638
+ });
1639
+ assert.equal(built.kind, "compaction", "pre-plan hint must produce a VCC compaction, not a cancel/fallback");
1640
+ if (built.kind !== "compaction") return;
1641
+ assert.equal((built.compaction.details as { compactor: string }).compactor, "pi-vcc");
1642
+ assert.equal((built.compaction.details as { phase: string }).phase, "planning");
1643
+ assert.match(built.compaction.summary, /\[Session Goal\]/);
1644
+ });
1645
+
1646
+ it("marks and consumes the pending flag exactly once", () => {
1647
+ const harness = makeHarness(path.join(tmpRoot, `flag-${++counter}`));
1648
+ assert.equal(consumePrePlanCompactPending(harness.ctx), null);
1649
+ markPrePlanCompactPending(harness.ctx, "run-1");
1650
+ assert.deepEqual(consumePrePlanCompactPending(harness.ctx), { runId: "run-1" });
1651
+ assert.equal(consumePrePlanCompactPending(harness.ctx), null);
1652
+ });
1653
+
1654
+ it("compacts once from the plans tool_result and resumes exactly once on success", async () => {
1655
+ const harness = await preplanHarness();
1656
+ const { ctx, recorded, emit } = harness;
1657
+ markPrePlanCompactPending(ctx, "run-a");
1658
+
1659
+ // Non-plans tool results never consume the pending flag.
1660
+ await emit("tool_result", { type: "tool_result", toolName: "edit", input: { path: "x" }, isError: false });
1661
+ assert.equal(recorded.compacts?.length ?? 0, 0);
1662
+ assert.equal(recorded.messages.length, 0);
1663
+
1664
+ await emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1665
+ assert.equal(recorded.compacts?.length, 1);
1666
+ assert.equal(recorded.compacts?.[0]?.customInstructions, PLANNING_PREPLAN_COMPACT_HINT);
1667
+ assert.equal(recorded.messages.length, 0, "no resume before the compaction settles");
1668
+
1669
+ recorded.compacts?.[0]?.onComplete?.({} as never);
1670
+ assert.equal(recorded.messages.length, 1);
1671
+ assert.equal(recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1672
+ assert.equal(recorded.messages[0]?.content, "Continue planning.");
1673
+ assert.equal((recorded.messages[0] as { display?: boolean }).display, false);
1674
+ assert.equal((recorded.messages[0] as { options?: { triggerTurn?: boolean } }).options?.triggerTurn, true);
1675
+
1676
+ // Pending flag consumed: a second plans result neither compacts nor resumes.
1677
+ await emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "record-decision" }, isError: false });
1678
+ assert.equal(recorded.compacts?.length, 1);
1679
+ recorded.compacts?.[0]?.onComplete?.({} as never);
1680
+ assert.equal(recorded.messages.length, 1);
1681
+ });
1682
+
1683
+ it("resumes exactly once with an info notice when compaction fails", async () => {
1684
+ const harness = await preplanHarness();
1685
+ const { ctx, recorded } = harness;
1686
+ (ctx as { compact?: unknown }).compact = (options: { onError?: (error: Error) => void }) => {
1687
+ options.onError?.(new Error("Nothing to compact (session too small)"));
1688
+ };
1689
+ markPrePlanCompactPending(ctx, "run-b");
1690
+ await harness.emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1691
+ assert.equal(recorded.messages.length, 1, "resume exactly once despite the failure");
1692
+ assert.equal(recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1693
+ assert.deepEqual(recorded.notifies.at(-1), {
1694
+ message: "pi-plans: pre-plan compaction skipped; continuing planning.",
1695
+ severity: "info",
1696
+ });
1697
+ });
1698
+
1699
+ it("skips silently and still resumes when ctx.compact is unavailable (older Pi)", async () => {
1700
+ const harness = await preplanHarness();
1701
+ (harness.ctx as { compact?: unknown }).compact = undefined;
1702
+ markPrePlanCompactPending(harness.ctx, "run-c");
1703
+ await harness.emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1704
+ assert.equal(harness.recorded.messages.length, 1);
1705
+ assert.equal(harness.recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1706
+ });
1707
+
1708
+ it("filters the pre-plan resume message out of the model context payload", () => {
1709
+ const messages = [
1710
+ { customType: "user", content: "real prompt" },
1711
+ { customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE, content: "Continue planning." },
1712
+ { customType: "assistant", content: "ok" },
1713
+ ];
1714
+ assert.equal(filterPlanningResumeMessages(messages).length, 2);
1715
+ });
1716
+ });
@@ -94,3 +94,49 @@ describe("planning write guard", () => {
94
94
  assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
95
95
  });
96
96
  });
97
+
98
+ describe("session-bound guard run (I-002)", () => {
99
+ it("activeRunId overrides the shared pointer; null falls back", () => {
100
+ const workdir = path.join(tmpRoot, "repo-bound");
101
+ fs.mkdirSync(workdir);
102
+ initState(workdir);
103
+ const first = startRun(workdir, { topic: "first run", skill: "plan-small", requestText: "x" }).run;
104
+ const second = startRun(workdir, { topic: "second run", skill: "plan-small", requestText: "x" }).run;
105
+
106
+ // Shared pointer names `second`; this session works on `first`.
107
+ const bound = planningWriteBlockReason({
108
+ workdir,
109
+ toolName: "write",
110
+ rawPath: path.relative(workdir, path.join(first.artifact_dir, "PLAN_v1.md")),
111
+ activeRunId: first.run_id,
112
+ });
113
+ assert.equal(bound, null, "bound run artifacts stay writable");
114
+
115
+ // The other run's artifacts are NOT writable for the bound session.
116
+ const other = planningWriteBlockReason({
117
+ workdir,
118
+ toolName: "write",
119
+ rawPath: path.relative(workdir, path.join(second.artifact_dir, "PLAN_v1.md")),
120
+ activeRunId: first.run_id,
121
+ });
122
+ assert.ok(other);
123
+
124
+ // null = no session binding → legacy shared-pointer behavior.
125
+ const shared = planningWriteBlockReason({
126
+ workdir,
127
+ toolName: "write",
128
+ rawPath: path.relative(workdir, path.join(first.artifact_dir, "PLAN_v1.md")),
129
+ activeRunId: null,
130
+ });
131
+ assert.ok(shared, "shared pointer names second; first is not writable");
132
+
133
+ // A binding to a missing run falls back to the shared pointer.
134
+ const vanished = planningWriteBlockReason({
135
+ workdir,
136
+ toolName: "write",
137
+ rawPath: path.relative(workdir, path.join(second.artifact_dir, "PLAN_v1.md")),
138
+ activeRunId: "20990101T000000Z-gone",
139
+ });
140
+ assert.equal(vanished, null);
141
+ });
142
+ });
@@ -1,7 +1,9 @@
1
1
  /** Tests for the plans tool source wiring. */
2
2
 
3
3
  import * as assert from "node:assert/strict";
4
+ import { spawnSync } from "node:child_process";
4
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
5
7
  import * as path from "node:path";
6
8
  import * as url from "node:url";
7
9
  import { describe, it } from "node:test";
@@ -45,3 +47,72 @@ describe("plans tool source", () => {
45
47
  assert.match(source, /"reviewer", "criticizer", "ref-analyst"/);
46
48
  });
47
49
  });
50
+
51
+ describe("record-checkpoint transitions (I-003)", () => {
52
+ it("records plan identity and rejects forged terminal states", async () => {
53
+ const { recordCheckpointTransition } = await import("../tools/plans.ts");
54
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-rcp-"));
55
+ try {
56
+ spawnSync("git", ["init"], { cwd: workdir });
57
+ const { initState, startRun } = await import("../src/state.ts");
58
+ initState(workdir);
59
+ const { run } = startRun(workdir, { topic: "rcp", skill: "plan-normal", requestText: "t" });
60
+ const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
61
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
62
+ const ctx = { sessionManager: { id: "s" } };
63
+
64
+ // plan-written records the exact file identity.
65
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
66
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
67
+ fs.writeFileSync(planPath, "# plan body", "utf8");
68
+ const updated = recordCheckpointTransition(ctx, workdir, run.run_id, {
69
+ transition: "plan-written",
70
+ planPath,
71
+ });
72
+ assert.equal(updated.plan?.version, 1);
73
+ assert.equal(updated.plan?.sha256.length, 64);
74
+ const loaded = loadCheckpoint(workdir, run.run_id);
75
+ assert.equal(loaded.status, "ok");
76
+ assert.equal(loaded.checkpoint.plan?.path, planPath);
77
+
78
+ // completed without evidence is rejected (F-004).
79
+ assert.throws(
80
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed" }),
81
+ /evidence/,
82
+ );
83
+ // completed from the planning phase is rejected even with evidence.
84
+ assert.throws(
85
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "completed", evidence: "done" }),
86
+ /cannot complete from phase/,
87
+ );
88
+ // planPath is required.
89
+ assert.throws(
90
+ () => recordCheckpointTransition(ctx, workdir, run.run_id, { transition: "plan-written" }),
91
+ /planPath/,
92
+ );
93
+ } finally {
94
+ fs.rmSync(workdir, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
98
+
99
+ describe("pre-plan compaction wiring", () => {
100
+ it("start-run marks pre-plan compaction pending under settings and execution guards", () => {
101
+ const source = readPlansSource();
102
+ assert.match(source, /import \{ getExecution, markPrePlanCompactPending \} from "\.\.\/src\/exec\.ts";/);
103
+ assert.match(source, /import \{ loadVccSettings, scaffoldVccSettings \} from "\.\.\/src\/compaction\.ts";/);
104
+ assert.match(source, /const prePlanStateRoot = resolveStateRootOrNull\(workdir\);/);
105
+ assert.match(source, /if \(prePlanStateRoot && !getExecution\(\)\) \{/);
106
+ assert.match(source, /scaffoldVccSettings\(prePlanStateRoot\);/);
107
+ assert.match(source, /loadVccSettings\(prePlanStateRoot\)\.prePlanCompact/);
108
+ assert.match(source, /markPrePlanCompactPending\(ctx, result\.run\.run_id\);/);
109
+ });
110
+
111
+ it("index.ts consumes the pending flag from the plans tool_result hook", () => {
112
+ const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
113
+ assert.match(source, /consumePrePlanCompactPending/);
114
+ assert.match(source, /customInstructions: PLANNING_PREPLAN_COMPACT_HINT/);
115
+ assert.match(source, /sendPrePlanCompactResume\(pi\)/);
116
+ assert.match(source, /pre-plan compaction skipped; continuing planning\./);
117
+ });
118
+ });