pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -6,6 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import {
9
+ AMELIORATION_PROMPT_TEXT,
9
10
  applyDoneMarkers,
10
11
  applyImplMarkers,
11
12
  applyCurrentIMarker,
@@ -19,10 +20,14 @@ import {
19
20
  filterExecutionResumeMessages,
20
21
  filterPlanningResumeMessages,
21
22
  getExecution,
23
+ resumeGoalWaitIfPaused,
22
24
  handleExecutionBeforeCompact,
23
25
  handleExecutionCompact,
24
26
  handleExecutionTurnCompaction,
25
27
  handleExecutionCompactFailed,
28
+ compactionInFlight,
29
+ noteCompactionStarted,
30
+ noteCompactionEnded,
26
31
  handlePlanningBeforeCompact,
27
32
  handlePlanningCompact,
28
33
  handlePlanningCompactFailed,
@@ -32,6 +37,7 @@ import {
32
37
  refreshPlanningCompactionCooldown,
33
38
  requestPlanningCompaction,
34
39
  restoreFromSession,
40
+ resetGoalWaitTurnFlags,
35
41
  shouldTriggerPlanningCompaction,
36
42
  startExecution,
37
43
  recordExecutionTurn,
@@ -40,7 +46,7 @@ import {
40
46
  updateStatusWidget,
41
47
  } from "../src/exec.ts";
42
48
  import type { CheckItem } from "../src/plan.ts";
43
- import { initState, setRunStatus, startRun } from "../src/state.ts";
49
+ import { initState, setGraphEnabled, setRunStatus, startRun } from "../src/state.ts";
44
50
 
45
51
  interface Recorded {
46
52
  entries: { type: string; customType?: string; data?: unknown }[];
@@ -55,6 +61,8 @@ interface Recorded {
55
61
  selectAnswer?: string;
56
62
  current: { provider: string; id: string } | null;
57
63
  thinking: string | null;
64
+ userMessages: string[];
65
+ userMessageOptions: Array<Record<string, unknown> | null>;
58
66
  compacts?: { customInstructions?: string }[];
59
67
  }
60
68
 
@@ -78,6 +86,8 @@ function makeHarness(workdir: string): Harness {
78
86
  selects: [],
79
87
  current: { provider: "p", id: "m" },
80
88
  thinking: "high",
89
+ userMessages: [],
90
+ userMessageOptions: [],
81
91
  };
82
92
  let contextPercent: number | null = 0;
83
93
  const registryModels = [
@@ -108,7 +118,10 @@ function makeHarness(workdir: string): Harness {
108
118
  sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
109
119
  recorded.messages.push({ ...message, options });
110
120
  },
111
- sendUserMessage: async () => {},
121
+ sendUserMessage: async (content: string, options?: Record<string, unknown>) => {
122
+ recorded.userMessages.push(content);
123
+ recorded.userMessageOptions.push(options ?? null);
124
+ },
112
125
  setModel: async (model: { provider: string; id: string }) => {
113
126
  recorded.models.push({ provider: model.provider, id: model.id });
114
127
  recorded.current = { provider: model.provider, id: model.id };
@@ -177,6 +190,22 @@ function items(...ids: string[]): CheckItem[] {
177
190
  return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
178
191
  }
179
192
 
193
+ function compactableBranchEntries(): any[] {
194
+ return [
195
+ { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "start the work" }] } },
196
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "made progress" }] } },
197
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "continue from here" }] } },
198
+ { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "tail work" }] } },
199
+ ];
200
+ }
201
+
202
+ function startActiveRun(workdir: string, status: "planning" | "executing" = "planning") {
203
+ initState(workdir);
204
+ const { run } = startRun(workdir, { topic: "compact", skill: "plan-normal", requestText: "x" });
205
+ if (status !== "planning") setRunStatus(workdir, run.run_id, status);
206
+ return run;
207
+ }
208
+
180
209
  describe("execution loop", () => {
181
210
  let tmpRoot: string;
182
211
  let counter = 0;
@@ -206,7 +235,7 @@ describe("execution loop", () => {
206
235
  assert.match(recorded.status ?? "", /out-toks/);
207
236
 
208
237
  assert.ok(getExecution());
209
- const rules = executionContextMessage()!;
238
+ const rules = executionContextMessage(ctx)!;
210
239
  assert.match(rules, /PI-PLANS EXECUTION/);
211
240
  assert.match(rules, /VC-001/);
212
241
  assert.match(rules, /subprocess-backed verification/);
@@ -234,6 +263,87 @@ describe("execution loop", () => {
234
263
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
235
264
  });
236
265
 
266
+ it("completion enters goal-running continuation and triggers a turn in interactive sessions", async () => {
267
+ const workdir = freshWorkdir();
268
+ const { pi, ctx, recorded } = makeHarness(workdir);
269
+ const planPath = path.join(workdir, "PLAN_v1.md");
270
+ await startExecution(pi, ctx, planPath, items("VC-001", "VC-002"));
271
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001] [DONE:VC-002]"), ["VC-001", "VC-002"]);
272
+
273
+ await completeExecution(pi, ctx);
274
+
275
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
276
+ assert.ok(completeMessage);
277
+ assert.match(completeMessage.content, /Goal-running continuation/);
278
+ assert.match(completeMessage.content, /How should the implementation-review loop terminate\?/);
279
+ assert.match(completeMessage.content, /goal wait: continue until no unpassed VCs remain/);
280
+ assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
281
+ assert.equal(completeMessage.options?.triggerTurn, true);
282
+ const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
283
+ assert.ok(ameliorateEntry);
284
+ const data = ameliorateEntry.data as Record<string, unknown>;
285
+ assert.equal(data.phase, "goal-started");
286
+ assert.equal(data.rounds, null);
287
+ assert.equal(data.currentRound, 0);
288
+ assert.equal(data.planPath, planPath);
289
+ });
290
+
291
+ it("completion stays silent in headless sessions", async () => {
292
+ const workdir = freshWorkdir();
293
+ const { pi, ctx, recorded } = makeHarness(workdir);
294
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
295
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
296
+ (ctx as any).hasUI = false;
297
+
298
+ await completeExecution(pi, ctx);
299
+
300
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
301
+ assert.ok(completeMessage);
302
+ assert.doesNotMatch(completeMessage.content, /Goal-running continuation/);
303
+ assert.equal(completeMessage.options?.triggerTurn, false);
304
+ assert.equal(
305
+ recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"),
306
+ false,
307
+ "headless completion must not append the ameliorate entry",
308
+ );
309
+ });
310
+
311
+ it("restoreFromSession completion triggers the same goal-running continuation in interactive sessions", async () => {
312
+ const workdir = freshWorkdir();
313
+ const { pi, ctx, recorded } = makeHarness(workdir);
314
+ const planPath = path.join(workdir, "PLAN_v1.md");
315
+ fs.writeFileSync(planPath, "# plan");
316
+ const snapshot = {
317
+ planPath,
318
+ items: items("VC-001", "VC-002"),
319
+ startedAt: "2026-08-25T00:00:00Z",
320
+ usage: { inToks: 0, outToks: 0 },
321
+ implItems: [],
322
+ implStatus: {},
323
+ compaction: {
324
+ inFlight: true,
325
+ resumeGuard: true,
326
+ cooldownActive: true,
327
+ lastAttemptReason: "threshold",
328
+ lastSuccessfulUsagePercent: 100,
329
+ lastSuccessfulAt: "2026-08-25T00:01:00Z",
330
+ },
331
+ };
332
+ const entries = [
333
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
334
+ {
335
+ type: "message",
336
+ message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001] [DONE:VC-002]" }] },
337
+ },
338
+ ];
339
+ await restoreFromSession(pi, ctx, entries as any);
340
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
341
+ assert.ok(completeMessage, "restore path must fire completeExecution");
342
+ assert.match(completeMessage.content, /Goal-running continuation/);
343
+ assert.equal(completeMessage.options?.triggerTurn, true);
344
+ assert.ok(recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"));
345
+ });
346
+
237
347
  it("restores progress from session entries and rescans messages", async () => {
238
348
  const workdir = freshWorkdir();
239
349
  const { pi, ctx, recorded } = makeHarness(workdir);
@@ -392,36 +502,51 @@ describe("execution loop", () => {
392
502
  assert.equal(getExecution(), null);
393
503
  });
394
504
 
395
- it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
505
+ it("returns control to Pi core without active execution or planning state", async () => {
396
506
  const workdir = freshWorkdir();
397
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
398
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
507
+ const { pi, ctx } = makeHarness(workdir);
399
508
 
400
- setUsagePercent(50);
401
- const below = handleExecutionBeforeCompact(pi, ctx, {
509
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v0.md"), items("VC-001"));
510
+ const executionResult = handleExecutionBeforeCompact(pi, ctx, {
402
511
  type: "session_before_compact",
403
512
  preparation: makePreparation("threshold", null),
404
- branchEntries: [],
513
+ branchEntries: compactableBranchEntries(),
405
514
  reason: "threshold",
406
515
  willRetry: false,
407
516
  signal: new AbortController().signal,
408
517
  });
409
- assert.equal(below?.cancel, undefined);
410
- assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
518
+ assert.equal(executionResult, undefined);
519
+ await stopExecution(pi, ctx, "no-active-run");
411
520
 
412
- setUsagePercent(105);
413
- const above = handleExecutionBeforeCompact(pi, ctx, {
521
+ const planningResult = handlePlanningBeforeCompact(pi as any, ctx as any, {
414
522
  type: "session_before_compact",
415
523
  preparation: makePreparation("threshold", null),
416
- branchEntries: [],
524
+ branchEntries: compactableBranchEntries(),
525
+ reason: "threshold",
526
+ willRetry: false,
527
+ signal: new AbortController().signal,
528
+ });
529
+ assert.equal(planningResult, undefined);
530
+ });
531
+
532
+ it("lets Pi core own execution compaction scheduling and customizes safe reasons", async () => {
533
+ const workdir = freshWorkdir();
534
+ startActiveRun(workdir);
535
+ const { pi, ctx, recorded } = makeHarness(workdir);
536
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
537
+
538
+ const threshold = handleExecutionBeforeCompact(pi, ctx, {
539
+ type: "session_before_compact",
540
+ preparation: makePreparation("threshold", null),
541
+ branchEntries: compactableBranchEntries(),
417
542
  reason: "threshold",
418
543
  willRetry: false,
419
544
  signal: new AbortController().signal,
420
545
  });
421
- assert.equal(above?.cancel, undefined);
422
- assert.ok(above?.compaction);
546
+ assert.equal(threshold?.cancel, undefined);
547
+ assert.ok(threshold?.compaction, "threshold compaction should use the VCC summary when a legal cut exists");
423
548
 
424
- const overflow = handleExecutionBeforeCompact(pi, ctx, {
549
+ const overflowRetry = handleExecutionBeforeCompact(pi, ctx, {
425
550
  type: "session_before_compact",
426
551
  preparation: makePreparation("overflow", null),
427
552
  branchEntries: [],
@@ -429,8 +554,7 @@ describe("execution loop", () => {
429
554
  willRetry: true,
430
555
  signal: new AbortController().signal,
431
556
  });
432
- assert.equal(overflow?.cancel, undefined);
433
- assert.ok(overflow?.compaction);
557
+ assert.equal(overflowRetry, undefined, "overflow retry falls back to Pi core when VCC has no safe cut");
434
558
 
435
559
  const manual = handleExecutionBeforeCompact(pi, ctx, {
436
560
  type: "session_before_compact",
@@ -440,54 +564,29 @@ describe("execution loop", () => {
440
564
  willRetry: false,
441
565
  signal: new AbortController().signal,
442
566
  });
443
- assert.equal(manual?.cancel, undefined);
444
- assert.ok(manual?.compaction);
567
+ assert.equal(manual?.cancel, true, "manual compaction cancels instead of discarding unsafe context");
568
+ assert.ok(recorded.notifies.some((note) => note.message.includes("Nothing to compact")));
445
569
 
446
570
  await stopExecution(pi, ctx, "test-done");
447
571
  });
448
572
 
449
- it("triggers execution compaction at the high watermark, suppresses repeats, and re-arms below the low watermark", async () => {
573
+ it("does not proactively request execution compaction", async () => {
450
574
  const workdir = freshWorkdir();
575
+ startActiveRun(workdir);
451
576
  const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
452
577
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
453
578
 
454
- setUsagePercent(96);
579
+ setUsagePercent(99);
455
580
  handleExecutionTurnCompaction(ctx);
456
- assert.equal(recorded.compacts?.length ?? 0, 1, "first high-watermark turn should request one compact");
457
-
458
581
  handleExecutionTurnCompaction(ctx);
459
- assert.equal(recorded.compacts?.length ?? 0, 1, "in-flight compact must not be requested twice");
460
-
461
- handleExecutionCompact(pi, ctx, {
462
- type: "session_compact",
463
- compactionEntry: { type: "compaction" } as never,
464
- fromExtension: true,
465
- reason: "threshold",
466
- willRetry: false,
467
- });
468
- assert.equal(
469
- recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
470
- 1,
471
- "non-retry execution compaction should queue one hidden resume",
472
- );
473
-
474
- setUsagePercent(96);
475
- handleExecutionTurnCompaction(ctx);
476
- assert.equal(recorded.compacts?.length ?? 0, 1, "resume guard should suppress the immediate follow-up turn");
477
-
478
- setUsagePercent(79);
479
- handleExecutionTurnCompaction(ctx);
480
- assert.equal(recorded.compacts?.length ?? 0, 1, "low-watermark turns stay quiet while below re-arm");
481
-
482
- setUsagePercent(96);
483
- handleExecutionTurnCompaction(ctx);
484
- assert.equal(recorded.compacts?.length ?? 0, 2, "high watermark should re-arm after the low-watermark drop");
582
+ assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
485
583
 
486
584
  await stopExecution(pi, ctx, "test-done");
487
585
  });
488
586
 
489
- it("skips proactive current-I compaction when no eligible prefix exists", async () => {
587
+ it("ignores current-I growth for proactive execution compaction", async () => {
490
588
  const workdir = freshWorkdir();
589
+ startActiveRun(workdir);
491
590
  const { pi, ctx, recorded } = makeHarness(workdir);
492
591
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
493
592
  { id: "I-001", text: "First item." },
@@ -499,52 +598,82 @@ describe("execution loop", () => {
499
598
  assert.equal(recorded.compacts?.length ?? 0, 0);
500
599
  await stopExecution(pi, ctx, "no-prefix");
501
600
  });
502
- it("wires execution compaction without restoring execution model helpers", () => {
601
+ it("wires execution compaction without restoring proactive requests or model helpers", () => {
503
602
  const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
504
603
  const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
505
604
  assert.match(indexSource, /handleExecutionTurnCompaction/);
506
605
  assert.match(execSource, /shouldTriggerExecutionCompaction/);
507
- assert.match(execSource, /requestExecutionCompaction/);
606
+ assert.doesNotMatch(execSource, /ctx\.compact\(/);
508
607
  assert.doesNotMatch(
509
608
  indexSource,
510
609
  /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
511
610
  );
512
611
  assert.doesNotMatch(
513
612
  execSource,
514
- /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
613
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel|buildModelExecutionCompactionResult|modelRegistry\.complete/,
515
614
  );
516
615
  });
517
616
 
518
- it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
617
+ it("auto-continues execution only for threshold/overflow and honors manual follow-up prompts", async () => {
519
618
  const workdir = freshWorkdir();
619
+ startActiveRun(workdir);
520
620
  const { pi, ctx, recorded } = makeHarness(workdir);
621
+ (ctx as any).piVersion = "0.84.3";
521
622
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
522
623
 
523
- const beforeRequests = recorded.compacts?.length ?? 0;
524
- handleExecutionCompact(pi, ctx, {
624
+ const threshold = handleExecutionBeforeCompact(pi, ctx, {
625
+ type: "session_before_compact",
626
+ preparation: makePreparation("threshold", null),
627
+ branchEntries: compactableBranchEntries(),
628
+ reason: "threshold",
629
+ willRetry: false,
630
+ signal: new AbortController().signal,
631
+ });
632
+ assert.ok(threshold?.compaction);
633
+ await handleExecutionCompact(pi, ctx, {
525
634
  type: "session_compact",
526
635
  compactionEntry: { type: "compaction" } as never,
527
- fromExtension: true,
636
+ fromExtension: false,
528
637
  reason: "threshold",
529
638
  willRetry: false,
530
639
  });
531
- const resumes = recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume");
532
- assert.equal(resumes.length, 1, "non-retry compaction should queue one hidden resume");
533
- assert.equal(resumes[0]?.options?.triggerTurn, true);
534
- assert.equal(recorded.compacts?.length ?? 0, beforeRequests, "compaction hook must not invoke ctx.compact");
640
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
641
+ assert.ok(recorded.notifies.some((note) => note.message.startsWith("pi-vcc: kept")));
535
642
 
536
- handleExecutionCompact(pi, ctx, {
643
+ handleExecutionBeforeCompact(pi, ctx, {
644
+ type: "session_before_compact",
645
+ preparation: makePreparation("manual", null),
646
+ branchEntries: compactableBranchEntries(),
647
+ reason: "manual",
648
+ willRetry: false,
649
+ signal: new AbortController().signal,
650
+ });
651
+ await handleExecutionCompact(pi, ctx, {
537
652
  type: "session_compact",
538
653
  compactionEntry: { type: "compaction" } as never,
539
- fromExtension: true,
540
- reason: "overflow",
541
- willRetry: true,
654
+ fromExtension: false,
655
+ reason: "manual",
656
+ willRetry: false,
542
657
  });
543
- assert.equal(
544
- recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
545
- 1,
546
- "overflow retry is owned by Pi core",
547
- );
658
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
659
+
660
+ handleExecutionBeforeCompact(pi, ctx, {
661
+ type: "session_before_compact",
662
+ preparation: makePreparation("manual", null),
663
+ branchEntries: compactableBranchEntries(),
664
+ customInstructions: "Run focused tests keep:1",
665
+ reason: "manual",
666
+ willRetry: false,
667
+ signal: new AbortController().signal,
668
+ });
669
+ await handleExecutionCompact(pi, ctx, {
670
+ type: "session_compact",
671
+ compactionEntry: { type: "compaction" } as never,
672
+ fromExtension: false,
673
+ reason: "manual",
674
+ willRetry: false,
675
+ });
676
+ assert.deepEqual(recorded.userMessages, ["Run focused tests"]);
548
677
 
549
678
  handleExecutionCompactFailed(pi, ctx, {
550
679
  type: "session_compact_failed",
@@ -560,29 +689,30 @@ describe("execution loop", () => {
560
689
  await stopExecution(pi, ctx, "test-done");
561
690
  });
562
691
 
563
- it("builds a plan-aware summary with per-item sections and chains the previous summary", async () => {
692
+ it("builds a VCC execution summary with phase context and previous summary", async () => {
564
693
  const workdir = freshWorkdir();
565
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
566
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"));
567
-
568
- applyDoneMarkers("[DONE:VC-001]");
694
+ startActiveRun(workdir);
695
+ const { pi, ctx } = makeHarness(workdir);
696
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"), [
697
+ { id: "I-001", text: "First item." },
698
+ { id: "I-002", text: "Second item." },
699
+ ]);
569
700
 
570
- const previousSummary = "## Goal\nDeliver auto-compact in execution phase.\n\n## Finished Items\n- legacy VC-000 summary";
701
+ const previousSummary = "## Legacy Summary\nDeliver auto-compact in execution phase.";
702
+ applyCurrentIMarker("[I-002:current]");
571
703
  const preparation = makePreparation("threshold", previousSummary);
572
704
  const branchEntries: any[] = [
573
- { id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
574
705
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
575
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [DONE:VC-001]" }] } },
706
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [I-002:current]" }] } },
576
707
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
577
708
  { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
578
- { id: "exec-ctx", type: "custom", customType: "pi-plans-exec-context" },
579
709
  ];
580
710
  const result = buildExecutionCompactionResult(
581
711
  {
582
712
  type: "session_before_compact",
583
713
  preparation,
584
714
  branchEntries,
585
- customInstructions: "keep current task visible",
715
+ customInstructions: "keep:1",
586
716
  reason: "threshold",
587
717
  willRetry: false,
588
718
  signal: new AbortController().signal,
@@ -590,71 +720,51 @@ describe("execution loop", () => {
590
720
  ctx,
591
721
  );
592
722
  assert.ok(result);
593
- assert.match(result!.summary, /## Compact Instructions/);
594
- assert.match(result!.summary, /keep current task visible/);
595
- assert.match(result!.summary, /## Plan Before This Run/);
596
- assert.match(result!.summary, /## Previous Compact Summary/);
597
- assert.match(result!.summary, /## Finished VC Items/);
598
- assert.match(result!.summary, /### `VC-001`/);
599
- assert.match(result!.summary, /## Current Work/);
600
- assert.match(result!.summary, /Raw tail preserved from `u-2`/);
601
- assert.deepEqual(result!.firstKeptEntryId, "u-2");
602
-
603
- setUsagePercent(110);
604
- handleExecutionBeforeCompact(pi, ctx, {
605
- type: "session_before_compact",
606
- preparation,
607
- branchEntries,
608
- reason: "threshold",
609
- willRetry: false,
610
- signal: new AbortController().signal,
611
- });
723
+ assert.equal(result!.firstKeptEntryId, "u-2");
724
+ assert.match(result!.summary, /\[Session Goal\]/);
725
+ assert.match(result!.summary, /Execute accepted plan/);
726
+ assert.match(result!.summary, /\[Outstanding Context\]/);
727
+ assert.match(result!.summary, /Current implementation item: I-002/);
728
+ assert.match(result!.summary, /Remaining verifier items: VC-001, VC-002/);
729
+ assert.match(result!.summary, /Previous compact summary: Legacy Summary Deliver auto-compact/);
730
+ assert.equal((result!.details as any).compactor, "pi-vcc");
731
+ assert.equal((result!.details as any).phase, "execution");
612
732
 
613
733
  await stopExecution(pi, ctx, "test-done");
614
734
  });
615
735
 
616
- it("uses the current model for bounded valid summaries and falls back on invalid output", async () => {
736
+ it("does not call model helpers and respects overrideDefaultCompaction=false", async () => {
617
737
  const workdir = freshWorkdir();
618
- const { pi, ctx, recorded } = makeHarness(workdir);
738
+ startActiveRun(workdir);
739
+ const { pi, ctx } = makeHarness(workdir);
619
740
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
620
741
  { id: "I-001", text: "First item." },
621
742
  ]);
622
- const signal = new AbortController().signal;
623
- const calls: unknown[][] = [];
624
- let response: any = {
625
- stopReason: "stop",
626
- content: [{ type: "text", text: "## Implementation Items\n- I-001\n\n## Current I\n- I-001\n\n## Read Records\n- none\n\n## Compaction Boundary\n- a-2\n\n## Decisions\n- preserved\n\n## Open Questions\n- none\n\n## Next Steps\n- continue" }],
627
- usage: { input: 12, output: 34 },
628
- };
629
- (ctx.modelRegistry as any).complete = async (...args: unknown[]) => {
630
- calls.push(args);
631
- return response;
743
+ let completeCalled = false;
744
+ (ctx.modelRegistry as any).complete = async () => {
745
+ completeCalled = true;
746
+ return {};
632
747
  };
633
748
  const event: any = {
634
749
  type: "session_before_compact",
635
750
  preparation: makePreparation("threshold", null),
636
- branchEntries: [
637
- { id: "i-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] work" }] } },
638
- { id: "a-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "details" }] } },
639
- ],
751
+ branchEntries: compactableBranchEntries(),
640
752
  reason: "threshold",
641
753
  willRetry: false,
642
- signal,
754
+ signal: new AbortController().signal,
643
755
  };
644
- const valid = await handleExecutionBeforeCompact(pi, ctx, event);
756
+ const valid = handleExecutionBeforeCompact(pi, ctx, event);
645
757
  assert.ok(valid?.compaction);
646
- assert.equal(calls[0]?.[0], recorded.current);
647
- assert.equal((calls[0]?.[2] as any).signal, signal);
648
- assert.equal((calls[0]?.[2] as any).cacheRetention, "none");
649
- assert.ok((calls[0]?.[2] as any).maxTokens <= 2048);
650
- assert.deepEqual(valid?.compaction?.usage, response.usage);
651
- assert.equal((valid?.compaction?.details as any)?.metrics?.summaryTokens, Math.ceil(response.content[0].text.length / 4));
652
-
653
- response = { stopReason: "length", content: [{ type: "text", text: "## Implementation Items\npartial" }] };
654
- const invalid = await handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
655
- assert.equal(invalid, undefined, "incomplete model output must return control to Pi default compaction");
758
+ assert.equal(completeCalled, false);
759
+
760
+ const configPath = path.join(workdir, ".git", "pi_plans", "pi-vcc-config.json");
761
+ fs.writeFileSync(configPath, JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
762
+ const fallback = handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
763
+ assert.equal(fallback, undefined, "override-disabled should return control to Pi default compaction");
764
+
656
765
  await stopExecution(pi, ctx, "model-test");
657
- }); it("filters the hidden resume message out of the LLM context payload", () => {
766
+ });
767
+ it("filters the hidden resume message out of the model context payload", () => {
658
768
  const messages = [
659
769
  { customType: "user", content: "real prompt" },
660
770
  { customType: "pi-plans-exec-resume", content: "Continue execution." },
@@ -665,37 +775,27 @@ describe("execution loop", () => {
665
775
  assert.equal(filterPlanningResumeMessages(messages).length, 3);
666
776
  });
667
777
 
668
- it("planning compaction cuts at plan-written when present and falls back to run-start", () => {
778
+ it("builds a VCC planning summary from active-run session context", () => {
669
779
  const workdir = freshWorkdir();
670
780
  const { ctx } = makeHarness(workdir);
671
781
  initState(workdir);
672
782
  const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
673
- fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
783
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
784
+ fs.writeFileSync(planPath, "# plan");
674
785
 
675
- const makePlanningPreparation = (previousSummary: string | null) => ({
676
- firstKeptEntryId: "fallback",
677
- messagesToSummarize: [],
678
- turnPrefixMessages: [],
679
- isSplitTurn: false,
680
- tokensBefore: 50000,
681
- previousSummary,
682
- fileOps: { read: [], written: [], edited: [] },
683
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
684
- });
685
-
686
- // Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
687
- const branchEntriesWithPlan = [
786
+ const branchEntries = [
688
787
  { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
689
788
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
690
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context" }] } },
691
- { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath: path.join(run.artifact_dir, "PLAN_v1.md") } },
789
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context [I-004:current]" }] } },
790
+ { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath } },
692
791
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
693
792
  ] as any;
694
793
  const withPlan = buildPlanningCompactionResult(
695
794
  {
696
795
  type: "session_before_compact",
697
- preparation: makePlanningPreparation(null),
698
- branchEntries: branchEntriesWithPlan,
796
+ preparation: makePreparation("threshold", "## Previous\nEarlier summary."),
797
+ branchEntries,
798
+ customInstructions: "keep:1",
699
799
  reason: "threshold",
700
800
  willRetry: false,
701
801
  signal: new AbortController().signal,
@@ -704,95 +804,57 @@ describe("execution loop", () => {
704
804
  );
705
805
  assert.ok(withPlan);
706
806
  assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
707
- assert.match(withPlan!.summary, /## Q&A During Planning/);
708
- assert.match(withPlan!.summary, /background question?/);
709
-
710
- // Case B: only run-start → cut at first non-internal entry after marker; no QA section.
711
- const branchEntriesWithoutPlan = [
712
- { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
713
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "open question" }] } },
714
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "thinking out loud" }] } },
715
- ] as any;
716
- const withoutPlan = buildPlanningCompactionResult(
717
- {
718
- type: "session_before_compact",
719
- preparation: makePlanningPreparation(null),
720
- branchEntries: branchEntriesWithoutPlan,
721
- reason: "manual",
722
- willRetry: false,
723
- signal: new AbortController().signal,
724
- },
725
- ctx,
726
- );
727
- assert.ok(withoutPlan);
728
- assert.deepEqual(withoutPlan!.firstKeptEntryId, "u-1");
729
- assert.doesNotMatch(withoutPlan!.summary, /## Q&A During Planning/);
730
-
731
- // Case C: no markers → fallback to preparation.firstKeptEntryId and no QA section.
732
- const fallback = buildPlanningCompactionResult(
733
- {
734
- type: "session_before_compact",
735
- preparation: makePlanningPreparation("## Previous\nEarlier summary."),
736
- branchEntries: [],
737
- reason: "threshold",
738
- willRetry: false,
739
- signal: new AbortController().signal,
740
- },
741
- ctx,
742
- );
743
- assert.ok(fallback);
744
- assert.deepEqual(fallback!.firstKeptEntryId, "fallback");
745
- assert.doesNotMatch(fallback!.summary, /## Q&A During Planning/);
746
- assert.match(fallback!.summary, /## Previous Compact Summary/);
807
+ assert.match(withPlan!.summary, /\[Session Goal\]/);
808
+ assert.match(withPlan!.summary, new RegExp(run.run_id));
809
+ assert.match(withPlan!.summary, /\[Outstanding Context\]/);
810
+ assert.match(withPlan!.summary, /Latest plan path from session/);
811
+ assert.match(withPlan!.summary, /Planning artifact directory from session/);
812
+ assert.match(withPlan!.summary, /Current implementation marker observed during planning: I-004/);
813
+ assert.match(withPlan!.summary, /Previous compact summary: Previous Earlier summary\./);
814
+ assert.equal((withPlan!.details as any).compactor, "pi-vcc");
815
+ assert.equal((withPlan!.details as any).phase, "planning");
747
816
  });
748
817
 
749
- it("planning hook is gated by run.status=planning and defers to execution hook when execution is running", async () => {
818
+ it("planning hook is gated by run.status=planning and defers while execution is running", async () => {
750
819
  const workdir = freshWorkdir();
751
- const { ctx, setUsagePercent } = makeHarness(workdir);
820
+ const { pi, ctx } = makeHarness(workdir);
752
821
  initState(workdir);
753
822
  const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
754
823
 
755
- setUsagePercent(110);
756
- const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
824
+ const resultPlanning = handlePlanningBeforeCompact(pi as any, ctx as any, {
757
825
  type: "session_before_compact",
758
- preparation: {
759
- firstKeptEntryId: "fb",
760
- messagesToSummarize: [],
761
- turnPrefixMessages: [],
762
- isSplitTurn: false,
763
- tokensBefore: 1,
764
- previousSummary: null,
765
- fileOps: { read: [], written: [], edited: [] },
766
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
767
- },
768
- branchEntries: [],
826
+ preparation: makePreparation("threshold", null),
827
+ branchEntries: compactableBranchEntries(),
828
+ customInstructions: "keep:1",
769
829
  reason: "threshold",
770
830
  willRetry: false,
771
831
  signal: new AbortController().signal,
772
832
  });
773
- assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
833
+ assert.ok(resultPlanning?.compaction);
774
834
 
775
- // Flip status to done; planning hook should refuse.
776
835
  setRunStatus(workdir, run.run_id, "done");
777
- setUsagePercent(110);
778
- const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
836
+ const resultDone = handlePlanningBeforeCompact(pi as any, ctx as any, {
779
837
  type: "session_before_compact",
780
- preparation: {
781
- firstKeptEntryId: "fb",
782
- messagesToSummarize: [],
783
- turnPrefixMessages: [],
784
- isSplitTurn: false,
785
- tokensBefore: 1,
786
- previousSummary: null,
787
- fileOps: { read: [], written: [], edited: [] },
788
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
789
- },
790
- branchEntries: [],
838
+ preparation: makePreparation("threshold", null),
839
+ branchEntries: compactableBranchEntries(),
791
840
  reason: "threshold",
792
841
  willRetry: false,
793
842
  signal: new AbortController().signal,
794
843
  });
795
844
  assert.equal(resultDone, undefined);
845
+
846
+ setRunStatus(workdir, run.run_id, "planning");
847
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), items("VC-001"));
848
+ const duringExecution = handlePlanningBeforeCompact(pi as any, ctx as any, {
849
+ type: "session_before_compact",
850
+ preparation: makePreparation("threshold", null),
851
+ branchEntries: compactableBranchEntries(),
852
+ reason: "threshold",
853
+ willRetry: false,
854
+ signal: new AbortController().signal,
855
+ });
856
+ assert.equal(duringExecution, undefined);
857
+ await stopExecution(pi, ctx, "planning-gate");
796
858
  });
797
859
 
798
860
  it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
@@ -970,7 +1032,7 @@ describe("execution loop", () => {
970
1032
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
971
1033
  { id: "I-001", text: "First item." },
972
1034
  ]);
973
- const rules = executionContextMessage()!;
1035
+ const rules = executionContextMessage(ctx)!;
974
1036
  assert.match(rules, /\[I-001:implemented\]/);
975
1037
  assert.match(rules, /\[I-001:validating\]/);
976
1038
  assert.match(rules, /subprocess-backed verification/);
@@ -981,42 +1043,411 @@ describe("execution loop", () => {
981
1043
  await stopExecution(pi, ctx, "done");
982
1044
  });
983
1045
 
984
- it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
1046
+ it("does not proactively request planning compaction", () => {
985
1047
  const workdir = freshWorkdir();
986
- const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
1048
+ const { ctx, setUsagePercent, recorded } = makeHarness(workdir);
987
1049
  initState(workdir);
988
- startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
1050
+ startRun(workdir, { topic: "planning no-op", skill: "plan-normal", requestText: "x" });
989
1051
 
990
1052
  setUsagePercent(120);
991
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
992
- requestPlanningCompaction(ctx as any);
993
- // In flight, second trigger ignored.
994
- requestPlanningCompaction(ctx as any);
995
1053
  assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1054
+ requestPlanningCompaction(ctx as any);
1055
+ refreshPlanningCompactionCooldown(ctx as any);
1056
+ assert.equal(recorded.compacts?.length ?? 0, 0, "planning scheduling is owned by Pi core");
1057
+ });
996
1058
 
997
- setUsagePercent(50);
998
- handlePlanningCompact(pi as any, ctx as any, {
1059
+ it("auto-continues planning only for threshold/overflow and honors manual follow-up prompts", async () => {
1060
+ const workdir = freshWorkdir();
1061
+ const { pi, ctx, recorded } = makeHarness(workdir);
1062
+ (ctx as any).piVersion = "0.84.3";
1063
+ initState(workdir);
1064
+ startRun(workdir, { topic: "planning success", skill: "plan-normal", requestText: "x" });
1065
+
1066
+ const threshold = handlePlanningBeforeCompact(pi as any, ctx as any, {
1067
+ type: "session_before_compact",
1068
+ preparation: makePreparation("threshold", null),
1069
+ branchEntries: compactableBranchEntries(),
1070
+ customInstructions: "keep:1",
1071
+ reason: "threshold",
1072
+ willRetry: false,
1073
+ signal: new AbortController().signal,
1074
+ });
1075
+ assert.ok(threshold?.compaction);
1076
+ await handlePlanningCompact(pi as any, ctx as any, {
999
1077
  type: "session_compact",
1000
1078
  compactionEntry: { type: "compaction" } as never,
1001
- fromExtension: true,
1002
- reason: "manual",
1079
+ fromExtension: false,
1080
+ reason: "threshold",
1003
1081
  willRetry: false,
1004
1082
  });
1005
- const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
1006
- assert.ok(resume);
1083
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1007
1084
  assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
1008
- // Cooldown blocks retrigger while usage is still mid-band.
1009
- setUsagePercent(95);
1010
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1011
- setUsagePercent(50);
1012
- refreshPlanningCompactionCooldown(ctx as any);
1085
+
1086
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1087
+ type: "session_before_compact",
1088
+ preparation: makePreparation("manual", null),
1089
+ branchEntries: compactableBranchEntries(),
1090
+ customInstructions: "Ask the next question keep:1",
1091
+ reason: "manual",
1092
+ willRetry: false,
1093
+ signal: new AbortController().signal,
1094
+ });
1095
+ await handlePlanningCompact(pi as any, ctx as any, {
1096
+ type: "session_compact",
1097
+ compactionEntry: { type: "compaction" } as never,
1098
+ fromExtension: false,
1099
+ reason: "manual",
1100
+ willRetry: false,
1101
+ });
1102
+ assert.deepEqual(recorded.userMessages, ["Ask the next question"]);
1103
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1104
+ });
1105
+
1106
+ it("planning request helper remains a no-op regardless of idleness", () => {
1107
+ const workdir = freshWorkdir();
1108
+ const { ctx, recorded, setUsagePercent } = makeHarness(workdir);
1109
+ initState(workdir);
1110
+ startRun(workdir, { topic: "idle no-op", skill: "plan-normal", requestText: "x" });
1013
1111
  setUsagePercent(120);
1014
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
1112
+
1113
+ (ctx as any).isIdle = () => false;
1114
+ requestPlanningCompaction(ctx as any);
1115
+ (ctx as any).isIdle = () => true;
1116
+ (ctx as any).hasPendingMessages = () => false;
1117
+ requestPlanningCompaction(ctx as any);
1118
+ assert.equal(recorded.compacts?.length ?? 0, 0);
1119
+ assert.equal((ctx as any).sessionManager.__planningCompaction, undefined);
1120
+ });
1121
+
1122
+ it("planning compact failures notify but do not re-request proactively", () => {
1123
+ const workdir = freshWorkdir();
1124
+ const { pi, ctx, recorded } = makeHarness(workdir);
1125
+ initState(workdir);
1126
+ startRun(workdir, { topic: "failure notify", skill: "plan-normal", requestText: "x" });
1127
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1128
+ type: "session_before_compact",
1129
+ preparation: makePreparation("threshold", null),
1130
+ branchEntries: compactableBranchEntries(),
1131
+ customInstructions: "keep:1",
1132
+ reason: "threshold",
1133
+ willRetry: false,
1134
+ signal: new AbortController().signal,
1135
+ });
1136
+
1137
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1138
+ type: "session_compact_failed",
1139
+ reason: "manual",
1140
+ errorMessage: "Compaction failed: Nothing to compact (session too small)",
1141
+ aborted: false,
1142
+ willRetry: false,
1143
+ fromExtension: false,
1144
+ });
1145
+ assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1146
+ requestPlanningCompaction(ctx as any);
1147
+ assert.equal(recorded.compacts?.length ?? 0, 0);
1148
+
1149
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1150
+ type: "session_before_compact",
1151
+ preparation: makePreparation("threshold", null),
1152
+ branchEntries: compactableBranchEntries(),
1153
+ customInstructions: "keep:1",
1154
+ reason: "threshold",
1155
+ willRetry: false,
1156
+ signal: new AbortController().signal,
1157
+ });
1158
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1159
+ type: "session_compact_failed",
1160
+ reason: "manual",
1161
+ errorMessage: "network down",
1162
+ aborted: false,
1163
+ willRetry: false,
1164
+ fromExtension: false,
1165
+ });
1166
+ assert.ok(recorded.notifies.some((note) => note.message.includes("will try again")));
1167
+ });
1168
+
1169
+ it("execution turn compaction helper remains a no-op and failures keep execution active", async () => {
1170
+ const workdir = freshWorkdir();
1171
+ startActiveRun(workdir);
1172
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
1173
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v23.md"), items("VC-001"), [
1174
+ { id: "I-001", text: "First item." },
1175
+ ]);
1176
+
1177
+ setUsagePercent(96);
1178
+ handleExecutionTurnCompaction(ctx);
1179
+ assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
1180
+
1181
+ handleExecutionBeforeCompact(pi, ctx, {
1182
+ type: "session_before_compact",
1183
+ preparation: makePreparation("threshold", null),
1184
+ branchEntries: compactableBranchEntries(),
1185
+ customInstructions: "keep:1",
1186
+ reason: "threshold",
1187
+ willRetry: false,
1188
+ signal: new AbortController().signal,
1189
+ });
1190
+ handleExecutionCompactFailed(pi, ctx, {
1191
+ type: "session_compact_failed",
1192
+ reason: "manual",
1193
+ errorMessage: "Compaction failed: Already compacted",
1194
+ aborted: false,
1195
+ willRetry: false,
1196
+ fromExtension: false,
1197
+ });
1198
+ assert.ok(getExecution());
1199
+ assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1200
+
1201
+ await stopExecution(pi, ctx, "test-done");
1202
+ });
1203
+
1204
+ it("treats planning abort/stream compact failures as terminal and notifies explicitly", () => {
1205
+ const workdir = freshWorkdir();
1206
+ const { pi, ctx, recorded } = makeHarness(workdir);
1207
+ initState(workdir);
1208
+ startRun(workdir, { topic: "abort-stream classification", skill: "plan-normal", requestText: "x" });
1209
+
1210
+ const abortMessages = [
1211
+ "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1212
+ "Error: OpenAI Responses stream ended before a terminal response event",
1213
+ "Auto-compaction failed: context overflow recovery failed",
1214
+ "this operation was aborted",
1215
+ "aborted",
1216
+ ];
1217
+ for (const errorMessage of abortMessages) {
1218
+ recorded.notifies.length = 0;
1219
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1220
+ type: "session_before_compact",
1221
+ preparation: makePreparation("threshold", null),
1222
+ branchEntries: compactableBranchEntries(),
1223
+ customInstructions: "keep:1",
1224
+ reason: "threshold",
1225
+ willRetry: false,
1226
+ signal: new AbortController().signal,
1227
+ });
1228
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1229
+ type: "session_compact_failed",
1230
+ reason: "threshold",
1231
+ errorMessage,
1232
+ aborted: errorMessage.includes("aborted"),
1233
+ willRetry: false,
1234
+ fromExtension: false,
1235
+ });
1236
+ const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1237
+ assert.ok(backoffNote, `expected abort-class notify for: ${errorMessage}`);
1238
+ assert.match(backoffNote!.message, /provider interruption|competing manual/);
1239
+ const before = recorded.compacts?.length ?? 0;
1240
+ requestPlanningCompaction(ctx as any);
1241
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1242
+ }
1243
+ });
1244
+
1245
+ it("event.aborted=true with empty errorMessage still enters abort-class handling", () => {
1246
+ const workdir = freshWorkdir();
1247
+ const { pi, ctx, recorded } = makeHarness(workdir);
1248
+ initState(workdir);
1249
+ startRun(workdir, { topic: "aborted-no-message", skill: "plan-normal", requestText: "x" });
1250
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1251
+ type: "session_before_compact",
1252
+ preparation: makePreparation("threshold", null),
1253
+ branchEntries: compactableBranchEntries(),
1254
+ customInstructions: "keep:1",
1255
+ reason: "threshold",
1256
+ willRetry: false,
1257
+ signal: new AbortController().signal,
1258
+ });
1259
+
1260
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1261
+ type: "session_compact_failed",
1262
+ reason: "threshold",
1263
+ errorMessage: undefined,
1264
+ aborted: true,
1265
+ willRetry: false,
1266
+ fromExtension: false,
1267
+ });
1268
+ const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1269
+ assert.ok(backoffNote);
1270
+ const before = recorded.compacts?.length ?? 0;
1271
+ requestPlanningCompaction(ctx as any);
1272
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1273
+ });
1274
+
1275
+ it("network-style planning failures stay retryable without proactive requests", () => {
1276
+ const workdir = freshWorkdir();
1277
+ const { pi, ctx, recorded } = makeHarness(workdir);
1278
+ initState(workdir);
1279
+ startRun(workdir, { topic: "network-retryable", skill: "plan-normal", requestText: "x" });
1280
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1281
+ type: "session_before_compact",
1282
+ preparation: makePreparation("threshold", null),
1283
+ branchEntries: compactableBranchEntries(),
1284
+ customInstructions: "keep:1",
1285
+ reason: "threshold",
1286
+ willRetry: false,
1287
+ signal: new AbortController().signal,
1288
+ });
1289
+
1290
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1291
+ type: "session_compact_failed",
1292
+ reason: "threshold",
1293
+ errorMessage: "network down",
1294
+ aborted: false,
1295
+ willRetry: false,
1296
+ fromExtension: false,
1297
+ });
1298
+ const abortNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1299
+ assert.equal(abortNote, undefined, "network down must not be classified as abort-class");
1300
+ const retryNote = recorded.notifies.find((n) => n.message.includes("will try again"));
1301
+ assert.ok(retryNote, "network down must keep the retryable path");
1302
+ const before = recorded.compacts?.length ?? 0;
1303
+ requestPlanningCompaction(ctx as any);
1304
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1305
+ });
1306
+
1307
+ it("lifecycle flags distinguish unhinted and phase-attributed compactions", () => {
1308
+ const workdir = freshWorkdir();
1309
+ const { ctx } = makeHarness(workdir);
1310
+
1311
+ noteCompactionStarted(ctx as any, undefined);
1312
+ assert.equal(compactionInFlight(ctx as any, "planning"), true, "unhinted compaction marks planning inFlight");
1313
+ assert.equal(compactionInFlight(ctx as any, "execution"), true, "unhinted compaction marks execution inFlight");
1314
+ noteCompactionEnded(ctx as any, undefined);
1315
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1316
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1317
+
1318
+ noteCompactionStarted(ctx as any, "pi-plans planning auto compact");
1319
+ assert.equal(compactionInFlight(ctx as any, "planning"), true);
1320
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1321
+ noteCompactionEnded(ctx as any, "pi-plans planning auto compact");
1322
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1323
+
1324
+ noteCompactionStarted(ctx as any, "pi-plans execution auto compact");
1325
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1326
+ assert.equal(compactionInFlight(ctx as any, "execution"), true);
1327
+ noteCompactionEnded(ctx as any, "pi-plans execution auto compact");
1328
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1329
+ });
1330
+
1331
+ it("execution handler classifies abort/stream failures as terminal", async () => {
1332
+ const workdir = freshWorkdir();
1333
+ startActiveRun(workdir);
1334
+ const { pi, ctx, recorded } = makeHarness(workdir);
1335
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v4.md"), items("VC-001"));
1336
+
1337
+ const abortMessages = [
1338
+ "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1339
+ "Error: OpenAI Responses stream ended before a terminal response event",
1340
+ "Auto-compaction failed: context overflow recovery failed",
1341
+ "aborted",
1342
+ ];
1343
+ for (const errorMessage of abortMessages) {
1344
+ recorded.notifies.length = 0;
1345
+ handleExecutionBeforeCompact(pi, ctx, {
1346
+ type: "session_before_compact",
1347
+ preparation: makePreparation("threshold", null),
1348
+ branchEntries: compactableBranchEntries(),
1349
+ customInstructions: "keep:1",
1350
+ reason: "threshold",
1351
+ willRetry: false,
1352
+ signal: new AbortController().signal,
1353
+ });
1354
+ handleExecutionCompactFailed(pi, ctx, {
1355
+ type: "session_compact_failed",
1356
+ reason: "threshold",
1357
+ errorMessage,
1358
+ aborted: errorMessage.includes("aborted"),
1359
+ willRetry: false,
1360
+ fromExtension: false,
1361
+ });
1362
+ const note = recorded.notifies.find((n) => n.message.includes("was aborted"));
1363
+ assert.ok(note, `expected abort-class notify for: ${errorMessage}`);
1364
+ assert.match(note!.message, /provider interruption|competing manual/);
1365
+ }
1366
+ handleExecutionBeforeCompact(pi, ctx, {
1367
+ type: "session_before_compact",
1368
+ preparation: makePreparation("threshold", null),
1369
+ branchEntries: compactableBranchEntries(),
1370
+ customInstructions: "keep:1",
1371
+ reason: "threshold",
1372
+ willRetry: false,
1373
+ signal: new AbortController().signal,
1374
+ });
1375
+ handleExecutionCompactFailed(pi, ctx, {
1376
+ type: "session_compact_failed",
1377
+ reason: "threshold",
1378
+ errorMessage: undefined,
1379
+ aborted: true,
1380
+ willRetry: false,
1381
+ fromExtension: false,
1382
+ });
1383
+ const cleanNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1384
+ assert.ok(cleanNote, "aborted-without-message must enter abort-class handling on execution side too");
1385
+ await stopExecution(pi, ctx, "test-done");
1386
+ });
1387
+
1388
+ it("index.ts wires session_compact/session_compact_failed to clear inFlight flags (F-003/F-004 coverage)", () => {
1389
+ const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
1390
+ assert.match(indexSource, /await handleExecutionCompact\(pi, ctx, event\)/);
1391
+ assert.match(indexSource, /await handlePlanningCompact\(pi, ctx, event\)/);
1392
+
1393
+ const workdir = freshWorkdir();
1394
+ // Use a minimal harness that exposes sessionManager so we can inspect the
1395
+ // in-flight store after dispatching the registered event handlers.
1396
+ const ctx: any = {
1397
+ cwd: workdir,
1398
+ hasUI: false,
1399
+ sessionManager: {},
1400
+ };
1401
+ // Simulate the lifecycle that index.ts wires in production:
1402
+ noteCompactionStarted(ctx, undefined);
1403
+ assert.equal(compactionInFlight(ctx, "planning"), true);
1404
+ assert.equal(compactionInFlight(ctx, "execution"), true);
1405
+ noteCompactionEnded(ctx, undefined);
1406
+ assert.equal(compactionInFlight(ctx, "planning"), false);
1407
+ assert.equal(compactionInFlight(ctx, "execution"), false);
1408
+ // And a planning-attributed start + end still clears both (end has no hint).
1409
+ noteCompactionStarted(ctx, "pi-plans planning auto compact");
1410
+ assert.equal(compactionInFlight(ctx, "planning"), true);
1411
+ noteCompactionEnded(ctx, "pi-plans planning auto compact");
1412
+ assert.equal(compactionInFlight(ctx, "planning"), false);
1015
1413
  });
1016
1414
  });
1017
1415
 
1018
- function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
1019
- return {
1416
+ describe("execution injection reads graph config live", () => {
1417
+ it("reflects graph_enabled flips on the next assembly and surfaces config failures", async (t) => {
1418
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-graph-"));
1419
+ t.after(() => {
1420
+ try {
1421
+ fs.rmSync(workdir, { recursive: true, force: true });
1422
+ } catch {
1423
+ /* ignore */
1424
+ }
1425
+ });
1426
+ const { pi, ctx } = makeHarness(workdir);
1427
+ setGraphEnabled(workdir, true);
1428
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1429
+
1430
+ const enabledRules = executionContextMessage(ctx)!;
1431
+ assert.match(enabledRules, /Code graph loop: indexed code files read as a function digest/);
1432
+ assert.doesNotMatch(enabledRules, /Code graph disabled/);
1433
+
1434
+ setGraphEnabled(workdir, false);
1435
+ assert.match(executionContextMessage(ctx)!, /Code graph disabled/);
1436
+
1437
+ fs.writeFileSync(path.join(workdir, ".git", "pi_plans", "config.json"), "{broken");
1438
+ const brokenRules = executionContextMessage(ctx)!;
1439
+ assert.match(brokenRules, /Code graph disabled/);
1440
+ assert.match(brokenRules, /config unreadable this turn/);
1441
+ });
1442
+
1443
+ it("keeps no graphEnabled snapshot in the execution state source", () => {
1444
+ const source = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
1445
+ assert.doesNotMatch(source, /graphEnabled/);
1446
+ assert.match(source, /resolveGraphMode/);
1447
+ });
1448
+ });
1449
+
1450
+ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any { return {
1020
1451
  firstKeptEntryId: "a-2",
1021
1452
  messagesToSummarize: [],
1022
1453
  turnPrefixMessages: [],
@@ -1027,3 +1458,139 @@ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSu
1027
1458
  settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
1028
1459
  };
1029
1460
  }
1461
+
1462
+ describe("execution goal-wait continuation", () => {
1463
+ // Fresh per-turn continuation flags, mirroring the before_agent_start reset.
1464
+ const setup = (workdir: string) => {
1465
+ resetGoalWaitTurnFlags();
1466
+ const harness = makeHarness(workdir);
1467
+ registerExecutionTurnHandlers(harness.pi);
1468
+ return harness;
1469
+ };
1470
+
1471
+ it("sends a goal-wait followUp when a turn ends with unpassed VCs", async () => {
1472
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1473
+ const { pi, ctx, recorded, emit } = setup(workdir);
1474
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1475
+ recorded.userMessages.length = 0;
1476
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1477
+ assert.equal(recorded.userMessages.length, 1);
1478
+ assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1479
+ assert.match(recorded.userMessages[0], /\`VC-001\`/);
1480
+ assert.equal(recorded.userMessageOptions.at(-1)?.deliverAs, "followUp");
1481
+ assert.match(recorded.status ?? "", /goal-wait · 无进展 1\/3 · 等待 0\/6/);
1482
+ });
1483
+
1484
+ it("sends the goal-wait followUp in headless sessions too", async () => {
1485
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1486
+ const { pi, ctx, recorded, emit } = setup(workdir);
1487
+ (ctx as any).hasUI = false;
1488
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1489
+ recorded.userMessages.length = 0;
1490
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1491
+ assert.equal(recorded.userMessages.length, 1);
1492
+ assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1493
+ });
1494
+
1495
+ it("does not goal-wait when every VC is done (completion path)", async () => {
1496
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1497
+ const { pi, ctx, recorded, emit } = setup(workdir);
1498
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1499
+ recorded.userMessages.length = 0;
1500
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "done [DONE:VC-001]" }] } });
1501
+ assert.equal(recorded.userMessages.length, 0);
1502
+ assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
1503
+ });
1504
+
1505
+ it("skips goal-wait while any compaction continuation flag is active", async () => {
1506
+ const variants = [
1507
+ { inFlight: true, resumeGuard: false, pendingFollowUpPrompt: null },
1508
+ { inFlight: false, resumeGuard: true, pendingFollowUpPrompt: null },
1509
+ { inFlight: false, resumeGuard: false, pendingFollowUpPrompt: "compaction follow-up" },
1510
+ ];
1511
+ for (const flags of variants) {
1512
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1513
+ const { pi, ctx, recorded, emit } = setup(workdir);
1514
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1515
+ (ctx.sessionManager as any).__executionCompaction = { ...flags, cooldownActive: false };
1516
+ recorded.userMessages.length = 0;
1517
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1518
+ assert.equal(recorded.userMessages.length, 0, `flags ${JSON.stringify(flags)} must skip goal-wait`);
1519
+ }
1520
+ });
1521
+
1522
+ it("pauses after 3 no-progress rounds and resumes on kick", async () => {
1523
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1524
+ const { pi, ctx, recorded, emit } = setup(workdir);
1525
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1526
+ recorded.userMessages.length = 0;
1527
+ for (let round = 0; round < 3; round++) {
1528
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1529
+ }
1530
+ assert.equal(getExecution()?.goalWait?.paused, true);
1531
+ assert.equal(recorded.userMessages.length, 2, "third quiet round must not queue another followUp");
1532
+ assert.ok(recorded.notifies.some((entry) => /goal-wait paused/.test(entry.message)));
1533
+ assert.match(recorded.status ?? "", /⏸ goal-wait paused/);
1534
+
1535
+ resumeGoalWaitIfPaused(pi, ctx);
1536
+ assert.equal(getExecution()?.goalWait?.paused, false);
1537
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1538
+ assert.match(recorded.userMessages.at(-1) ?? "", /Goal wait/);
1539
+ });
1540
+
1541
+ it("waiting rounds are exempt until the sixth quiet waiting round", async () => {
1542
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1543
+ const { pi, ctx, recorded, emit } = setup(workdir);
1544
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1545
+ for (let round = 1; round <= 5; round++) {
1546
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: `waiting for CI (${round})` }] } });
1547
+ assert.equal(getExecution()?.goalWait?.paused, false, `round ${round} must not pause`);
1548
+ }
1549
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "waiting for CI (6)" }] } });
1550
+ assert.equal(getExecution()?.goalWait?.paused, true);
1551
+ assert.ok(recorded.notifies.some((entry) => /waiting without progress for 6 rounds/.test(entry.message)));
1552
+ });
1553
+
1554
+ it("progress resets both guard counters", async () => {
1555
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1556
+ const { pi, ctx, emit } = makeHarness(workdir);
1557
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
1558
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1559
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1560
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1561
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1562
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1563
+ });
1564
+
1565
+ it("keeps goal-wait counters across restore", async () => {
1566
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1567
+ const { pi, ctx } = setup(workdir);
1568
+ const planPath = path.join(workdir, "PLAN_v1.md");
1569
+ fs.writeFileSync(planPath, "# plan");
1570
+ const snapshot = {
1571
+ planPath,
1572
+ items: items("VC-001"),
1573
+ startedAt: "2026-08-25T00:00:00Z",
1574
+ usage: { inToks: 0, outToks: 0 },
1575
+ implItems: [],
1576
+ implStatus: {},
1577
+ goalWait: { noProgressRounds: 2, waitRounds: 1, lastMarkers: null, paused: false },
1578
+ };
1579
+ const entries = [
1580
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
1581
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "no new progress this turn" }] } },
1582
+ ];
1583
+ await restoreFromSession(pi, ctx, entries as any);
1584
+ // Replay advanced the marker snapshot past the persisted baseline → counters reset (D-010).
1585
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1586
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1587
+ });
1588
+ });
1589
+
1590
+ describe("amelioration termination prompt", () => {
1591
+ it("recommends goal-wait first and keeps the round options", () => {
1592
+ assert.match(AMELIORATION_PROMPT_TEXT, /goal wait: continue until no unpassed VCs remain/);
1593
+ assert.match(AMELIORATION_PROMPT_TEXT, /until no high-severity finding \(hard cap 5 rounds\)/);
1594
+ assert.match(AMELIORATION_PROMPT_TEXT, /How should the implementation-review loop terminate\?/);
1595
+ });
1596
+ });