pi-plans 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Validates summary record validation and cache behavior. Does not require
3
+ * a real model — uses a fake completion handle.
4
+ */
5
+
6
+ import { test } from "node:test";
7
+ import * as assert from "node:assert/strict";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import { Store } from "../src/code-graph/store.ts";
12
+ import {
13
+ generateSummaries,
14
+ type CompletionHandle,
15
+ clearSummaryCache,
16
+ summaryCacheStats,
17
+ } from "../src/code-graph/summary.ts";
18
+
19
+ async function openTemp(): Promise<{ store: Store; cleanup: () => void } | null> {
20
+ try {
21
+ const sqlite = await import("node:sqlite");
22
+ const worktreeRoot = fs.realpathSync(os.tmpdir());
23
+ const dbPath = path.join(os.tmpdir(), `code-graph-summary-${process.pid}-${Date.now()}.db`);
24
+ const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
25
+ return { store, cleanup: () => {
26
+ store.close();
27
+ try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
28
+ } };
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function seedFunction(store: Store): void {
35
+ store.tx(() => {
36
+ store.db
37
+ .prepare(
38
+ `INSERT INTO functions (file_dir, file_name, function_name, language, kind,
39
+ full_code, full_code_hash, render_code, render_code_hash,
40
+ move_supported, is_primary,
41
+ provenance_start_byte, provenance_end_byte, provenance_start_line,
42
+ provenance_start_col, provenance_end_line, provenance_end_col, version)
43
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
44
+ )
45
+ .run(
46
+ "src",
47
+ "a.js",
48
+ "alpha",
49
+ "javascript",
50
+ "declaration",
51
+ "function alpha() { return 1; }",
52
+ "deadbeef",
53
+ "function alpha() { return 1; }",
54
+ "deadbeef",
55
+ 1,
56
+ 1,
57
+ 0,
58
+ 25,
59
+ 1,
60
+ 0,
61
+ 1,
62
+ 0,
63
+ 1,
64
+ );
65
+ });
66
+ }
67
+
68
+ test("generateSummaries writes ok status and validates schema", async (t) => {
69
+ const opened = await openTemp();
70
+ if (!opened) return;
71
+ t.after(() => opened.cleanup());
72
+ const { store } = opened;
73
+ clearSummaryCache();
74
+ seedFunction(store);
75
+ const handle: CompletionHandle = {
76
+ complete: async () => ({
77
+ content: [{ type: "text", text: '{"description":"adds two numbers","inputs":["a: number","b: number"],"outputs":["number"]}' }],
78
+ stopReason: "stop",
79
+ }),
80
+ model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: true }),
81
+ thinkingLevel: () => "low",
82
+ hasUI: true,
83
+ confirm: async () => true,
84
+ notify: () => {},
85
+ };
86
+ const report = await generateSummaries({ store, ctx: handle });
87
+ assert.equal(report.ok, 1);
88
+ assert.equal(report.failed, 0);
89
+ const row = store.read(() => store.db.prepare("SELECT summary_status, summary_inputs FROM functions WHERE function_name='alpha'").get()) as { summary_status: string; summary_inputs: string };
90
+ assert.equal(row.summary_status, "ok");
91
+ assert.equal(JSON.parse(row.summary_inputs)[0], "a: number");
92
+ });
93
+
94
+ test("declined consent marks summaries as declined without sending source", async (t) => {
95
+ const opened = await openTemp();
96
+ if (!opened) return;
97
+ t.after(() => opened.cleanup());
98
+ const { store } = opened;
99
+ clearSummaryCache();
100
+ seedFunction(store);
101
+ let sendCalled = false;
102
+ const handle: CompletionHandle = {
103
+ complete: async () => {
104
+ sendCalled = true;
105
+ return { content: [{ type: "text", text: "" }] };
106
+ },
107
+ model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
108
+ thinkingLevel: () => "low",
109
+ hasUI: true,
110
+ confirm: async () => false,
111
+ notify: () => {},
112
+ };
113
+ const report = await generateSummaries({ store, ctx: handle });
114
+ assert.equal(report.declined, 1);
115
+ assert.equal(sendCalled, false);
116
+ const row = store.read(() => store.db.prepare("SELECT summary_status FROM functions WHERE function_name='alpha'").get()) as { summary_status: string };
117
+ assert.equal(row.summary_status, "declined");
118
+ });
119
+
120
+ test("headless consent auto-declines", async (t) => {
121
+ const opened = await openTemp();
122
+ if (!opened) return;
123
+ t.after(() => opened.cleanup());
124
+ const { store } = opened;
125
+ clearSummaryCache();
126
+ seedFunction(store);
127
+ let sendCalled = false;
128
+ const handle: CompletionHandle = {
129
+ complete: async () => {
130
+ sendCalled = true;
131
+ return { content: [{ type: "text", text: "" }] };
132
+ },
133
+ model: () => ({ provider: "test", id: "test-model", api: "openai-completions", reasoning: false }),
134
+ thinkingLevel: () => "low",
135
+ hasUI: false,
136
+ notify: () => {},
137
+ };
138
+ const report = await generateSummaries({ store, ctx: handle });
139
+ assert.equal(report.declined, 1);
140
+ assert.equal(sendCalled, false);
141
+ const row = store.read(() => store.db.prepare("SELECT summary_status FROM functions WHERE function_name='alpha'").get()) as { summary_status: string };
142
+ assert.equal(row.summary_status, "declined");
143
+ });
144
+
145
+ test("summary cache stats are non-negative", () => {
146
+ clearSummaryCache();
147
+ assert.equal(summaryCacheStats().size, 0);
148
+ });
@@ -1,74 +1,388 @@
1
1
  import * as assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, before, describe, it } from "node:test";
3
6
  import {
4
- extractReadRecords,
5
- formatReadRecord,
6
- legalFirstKeptEntryIndex,
7
- mergeCompactionDetails,
8
- planIAwareCompaction,
9
- currentIExceedsTrigger,
7
+ buildOwnCut,
8
+ buildPiPlansVccCompaction,
9
+ compactionCurrentI,
10
+ DEFAULT_VCC_SETTINGS,
11
+ loadVccSettings,
12
+ parseCompactionInstructions,
13
+ PI_VCC_COMPACT_INSTRUCTION,
14
+ scaffoldVccSettings,
15
+ shouldScheduleAutoContinue,
16
+ vccSettingsPath,
17
+ type CompactionEntryLike,
10
18
  } from "../src/compaction.ts";
11
19
 
12
- function textEntry(id: string, text: string, tokens = 100) {
13
- return { id, type: "message", tokens, message: { role: "assistant", content: [{ type: "text", text }] } };
20
+ function textEntry(id: string, role: string, text: string): CompactionEntryLike {
21
+ return { id, type: "message", message: { role, content: [{ type: "text", text }] } };
14
22
  }
15
23
 
16
- describe("I-aware compaction policy", () => {
17
- it("keeps a legal current-I suffix and never starts at a tool result", () => {
24
+ function toolCallEntry(id: string, name: string, args: Record<string, unknown>): CompactionEntryLike {
25
+ return { id, type: "message", message: { role: "assistant", content: [{ type: "toolCall", name, arguments: args }] } };
26
+ }
27
+
28
+ function toolResultEntry(id: string, name: string, text: string): CompactionEntryLike {
29
+ return { id, type: "message", message: { role: "toolResult", toolName: name, content: [{ type: "text", text }] } };
30
+ }
31
+
32
+ describe("pi-vcc compaction", () => {
33
+ let tmpRoot: string;
34
+
35
+ before(() => {
36
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-vcc-"));
37
+ });
38
+
39
+ after(() => {
40
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
41
+ });
42
+
43
+ it("builds an own cut that keeps the requested recent user turns", () => {
18
44
  const entries = [
19
- textEntry("i1", "[I-001:current] completed"),
20
- { id: "call", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/repo/a.ts", offset: 4, limit: 2 } }] } },
21
- { id: "result", type: "message", tokens: 100, message: { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "private source output that must be bounded" }] } },
22
- textEntry("i2", "[I-002:current] active"),
23
- { id: "u", type: "message", tokens: 100, message: { role: "user", content: [{ type: "text", text: "latest question" }] } },
24
- textEntry("a", "latest answer"),
45
+ textEntry("u-1", "user", "implement compact support"),
46
+ textEntry("a-1", "assistant", "started the helper"),
47
+ textEntry("u-2", "user", "continue from the current task"),
48
+ textEntry("a-2", "assistant", "working on tests"),
25
49
  ];
26
- const plan = planIAwareCompaction({ entries, currentI: "I-002", knownIIds: ["I-001", "I-002"], contextWindow: 1000, tokensBefore: 600 });
27
- assert.equal(plan.currentI, "I-002");
28
- assert.equal(plan.slices.filter((slice) => slice.id !== null).length, 2);
29
- assert.ok(plan.firstKeptEntryId);
30
- assert.notEqual(plan.firstKeptEntryId, "result");
31
- assert.ok(plan.summaryEntries.every((entry) => !plan.keptEntries.includes(entry)));
32
- assert.equal(legalFirstKeptEntryIndex(entries, 2), 1);
50
+ const cut = buildOwnCut(entries, 1);
51
+ assert.equal(cut.ok, true);
52
+ assert.equal(cut.ok ? cut.firstKeptEntryId : undefined, "u-2");
53
+ assert.deepEqual(cut.ok ? cut.messages.map((message) => message.role) : [], ["user", "assistant"]);
54
+
55
+ const compactAll = buildOwnCut(entries, 0);
56
+ assert.equal(compactAll.ok, true);
57
+ assert.equal(compactAll.ok ? compactAll.compactAll : false, true);
58
+ assert.equal(compactAll.ok ? compactAll.firstKeptEntryId : "not-ok", "");
59
+ assert.deepEqual(buildOwnCut([textEntry("u", "user", "only one turn")], 1), { ok: false, reason: "too_few_live_messages" });
33
60
  });
34
61
 
35
- it("starts a new current-I slice after a prior compaction snapshot", () => {
62
+ it("reads legacy compaction details without writing the old schema", () => {
63
+ const legacyCompaction: CompactionEntryLike = {
64
+ id: "old-compact",
65
+ type: "compaction",
66
+ details: {
67
+ kind: "pi-plans-execution-compaction",
68
+ readRecords: [
69
+ { path: "src/legacy.ts", lineStart: 5, lineEnd: 9, range: "5-9", summary: "legacy facts", key: "src/legacy.ts|5-9" },
70
+ ],
71
+ metrics: {
72
+ currentI: "I-007",
73
+ firstKeptEntryId: "u-2",
74
+ targetMet: false,
75
+ hardFloorReason: "single oversized tool result",
76
+ },
77
+ },
78
+ };
79
+ assert.equal(compactionCurrentI(legacyCompaction), "I-007");
80
+
36
81
  const entries = [
37
- { id: "old-summary", type: "compaction", details: { currentI: "I-002" } },
38
- textEntry("u", "new question", 100),
39
- textEntry("a", "new answer", 100),
82
+ textEntry("u-1", "user", "old prefix"),
83
+ textEntry("a-1", "assistant", "old answer"),
84
+ legacyCompaction,
85
+ textEntry("u-2", "user", "retained user from old boundary"),
86
+ textEntry("a-2", "assistant", "live answer"),
87
+ textEntry("u-3", "user", "latest user"),
88
+ textEntry("a-3", "assistant", "latest answer"),
40
89
  ];
41
- const plan = planIAwareCompaction({ entries, currentI: "I-002", knownIIds: ["I-001", "I-002"], contextWindow: 1000, tokensBefore: 200 });
42
- assert.equal(plan.currentStartIndex, 1);
43
- assert.equal(plan.slices.at(-1)?.id, "I-002");
44
- assert.equal(plan.slices.at(-1)?.current, true);
45
- assert.equal(plan.slices.at(-1)?.entries[0]?.id, "u");
46
- }); it("extracts bounded paired Read records and merges by path/range", () => {
47
- const raw = "x".repeat(500);
48
- const entries = [
49
- { id: "call", type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/repo/a.ts", offset: 7, limit: 3 } }] } },
50
- { id: "result", type: "message", message: { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: raw }] } },
90
+ const cut = buildOwnCut(entries, 1);
91
+ assert.equal(cut.ok, true);
92
+ assert.deepEqual(cut.ok ? cut.messages.map((message) => message.content) : [], [
93
+ [{ type: "text", text: "retained user from old boundary" }],
94
+ [{ type: "text", text: "live answer" }],
95
+ ]);
96
+
97
+ const result = buildPiPlansVccCompaction({
98
+ branchEntries: entries,
99
+ preparation: { tokensBefore: 20_000 },
100
+ reason: "threshold",
101
+ willRetry: false,
102
+ settings: DEFAULT_VCC_SETTINGS,
103
+ phaseContext: { phase: "planning" },
104
+ });
105
+ assert.equal(result.kind, "compaction");
106
+ if (result.kind !== "compaction") return;
107
+ assert.match(result.compaction.summary, /Read: src\/legacy\.ts line 5-9 Extracted information summary: legacy facts/);
108
+ assert.match(result.compaction.summary, /Previous compaction hard floor: single oversized tool result/);
109
+ assert.equal((result.compaction.details as any).kind, undefined);
110
+ assert.equal((result.compaction.details as any).readRecords, undefined);
111
+ assert.equal((result.compaction.details as any).metrics?.currentI, undefined);
112
+ assert.equal(result.compaction.details.compactor, "pi-vcc");
113
+ });
114
+
115
+ it("applies smart keep, explicit keep, budget cuts, and tool-result-safe boundaries", () => {
116
+ const smallTurns = [
117
+ textEntry("u-1", "user", "first task"),
118
+ textEntry("a-1", "assistant", "first answer"),
119
+ textEntry("u-2", "user", "second task"),
120
+ textEntry("a-2", "assistant", "second answer"),
121
+ textEntry("u-3", "user", "third task"),
122
+ textEntry("a-3", "assistant", "third answer"),
51
123
  ];
52
- const records = extractReadRecords(entries);
53
- assert.equal(records.length, 1);
54
- assert.equal(records[0]?.range, "7-9");
55
- assert.equal(records[0]?.formatted, formatReadRecord(records[0]!));
56
- assert.match(records[0]?.formatted ?? "", /^Read: \/repo\/a\.ts line 7-9 Extracted information summary: /);
57
- assert.ok((records[0]?.formatted.length ?? 0) < raw.length);
58
- const merged = mergeCompactionDetails(
59
- { readRecords: records },
60
- { readRecords: [{ ...records[0]!, summary: "new extraction", formatted: "" }] },
61
- );
62
- assert.equal(merged.readRecords?.length, 1);
63
- assert.equal(merged.readRecords?.[0]?.summary, "new extraction");
124
+ const smart = buildPiPlansVccCompaction({
125
+ branchEntries: smallTurns,
126
+ preparation: { tokensBefore: 10_000 },
127
+ reason: "threshold",
128
+ willRetry: false,
129
+ settings: DEFAULT_VCC_SETTINGS,
130
+ phaseContext: { phase: "planning" },
131
+ });
132
+ assert.equal(smart.kind, "compaction");
133
+ if (smart.kind !== "compaction") return;
134
+ assert.equal(smart.stats.smartKeepAdjusted, true);
135
+ assert.equal(smart.stats.smartFromKeep, 1);
136
+ assert.equal(smart.stats.requestedKeepUserTurns, 2);
137
+ assert.equal(smart.compaction.firstKeptEntryId, "u-2");
138
+
139
+ const explicit = buildPiPlansVccCompaction({
140
+ branchEntries: smallTurns,
141
+ preparation: { tokensBefore: 10_000 },
142
+ customInstructions: `${PI_VCC_COMPACT_INSTRUCTION} keep:1`,
143
+ reason: "threshold",
144
+ willRetry: false,
145
+ settings: DEFAULT_VCC_SETTINGS,
146
+ phaseContext: { phase: "planning" },
147
+ });
148
+ assert.equal(explicit.kind, "compaction");
149
+ if (explicit.kind !== "compaction") return;
150
+ assert.equal(explicit.stats.smartKeepAdjusted, false);
151
+ assert.equal(explicit.stats.requestedKeepUserTurns, 1);
152
+ assert.equal(explicit.compaction.firstKeptEntryId, "u-3");
153
+
154
+ const huge = "x".repeat(300_000);
155
+ const noAnchor = buildPiPlansVccCompaction({
156
+ branchEntries: [
157
+ textEntry("u-1", "user", "single user turn"),
158
+ textEntry("a-1", "assistant", huge),
159
+ textEntry("a-2", "assistant", "safe assistant boundary"),
160
+ ],
161
+ preparation: { tokensBefore: 200_000 },
162
+ reason: "threshold",
163
+ willRetry: false,
164
+ settings: DEFAULT_VCC_SETTINGS,
165
+ phaseContext: { phase: "planning" },
166
+ });
167
+ assert.equal(noAnchor.kind, "compaction");
168
+ if (noAnchor.kind !== "compaction") return;
169
+ assert.equal(noAnchor.stats.budgetCut, "no_anchor");
170
+ assert.equal(noAnchor.compaction.firstKeptEntryId, "a-1");
171
+
172
+ const oversizedTail = buildPiPlansVccCompaction({
173
+ branchEntries: [
174
+ textEntry("u-1", "user", "one"),
175
+ textEntry("a-1", "assistant", "done"),
176
+ textEntry("u-2", "user", "two"),
177
+ textEntry("a-2", "assistant", "done"),
178
+ textEntry("u-3", "user", "three"),
179
+ textEntry("a-3", "assistant", huge),
180
+ ],
181
+ preparation: { tokensBefore: 250_000 },
182
+ reason: "threshold",
183
+ willRetry: false,
184
+ settings: { ...DEFAULT_VCC_SETTINGS, smartKeepTail: false },
185
+ phaseContext: { phase: "planning" },
186
+ });
187
+ assert.equal(oversizedTail.kind, "compaction");
188
+ if (oversizedTail.kind !== "compaction") return;
189
+ assert.equal(oversizedTail.stats.budgetCut, "oversized_tail");
190
+ assert.equal(oversizedTail.compaction.firstKeptEntryId, "a-3");
191
+
192
+ const toolBoundary = buildPiPlansVccCompaction({
193
+ branchEntries: [
194
+ textEntry("u-1", "user", "single user turn"),
195
+ toolCallEntry("tc-1", "read", { path: "src/exec.ts" }),
196
+ toolResultEntry("tr-1", "read", huge),
197
+ textEntry("a-after", "assistant", "safe boundary after the tool result"),
198
+ ],
199
+ preparation: { tokensBefore: 250_000 },
200
+ reason: "threshold",
201
+ willRetry: false,
202
+ settings: DEFAULT_VCC_SETTINGS,
203
+ phaseContext: { phase: "planning" },
204
+ });
205
+ assert.equal(toolBoundary.kind, "compaction");
206
+ if (toolBoundary.kind !== "compaction") return;
207
+ assert.equal(toolBoundary.stats.budgetCut, "no_anchor");
208
+ assert.equal(toolBoundary.compaction.firstKeptEntryId, "a-after");
209
+ });
210
+
211
+ it("compiles a deterministic five-section summary with phase context and file activity", () => {
212
+ const result = buildPiPlansVccCompaction({
213
+ branchEntries: [
214
+ textEntry("u-1", "user", "Please implement repo-private VCC compaction. Always keep ASCII output."),
215
+ toolCallEntry("tc-1", "edit", { path: "src/compaction.ts" }),
216
+ textEntry("a-1", "assistant", "Updated src/compaction.ts and ran npm test."),
217
+ textEntry("u-2", "user", "Continue execution."),
218
+ textEntry("a-2", "assistant", "Current work is in the retained tail."),
219
+ ],
220
+ preparation: {
221
+ firstKeptEntryId: "fallback",
222
+ tokensBefore: 40_000,
223
+ previousSummary: "## Legacy Summary\nEarlier compact facts.",
224
+ fileOps: { read: ["src/exec.ts"], written: ["src/compaction.ts"], edited: [] },
225
+ },
226
+ customInstructions: `${PI_VCC_COMPACT_INSTRUCTION} keep:1`,
227
+ reason: "threshold",
228
+ willRetry: false,
229
+ settings: DEFAULT_VCC_SETTINGS,
230
+ phaseContext: {
231
+ phase: "execution",
232
+ planPath: "/repo/PLAN_v3.md",
233
+ currentI: "I-002",
234
+ remainingVerifierIds: ["VC-002"],
235
+ implementationIds: ["I-001", "I-002"],
236
+ },
237
+ });
238
+ assert.equal(result.kind, "compaction");
239
+ if (result.kind !== "compaction") return;
240
+ assert.equal(result.compaction.firstKeptEntryId, "u-2");
241
+ assert.match(result.compaction.summary, /\[Session Goal\]/);
242
+ assert.match(result.compaction.summary, /Execute accepted plan \/repo\/PLAN_v3\.md/);
243
+ assert.match(result.compaction.summary, /\[Files And Changes\]/);
244
+ assert.match(result.compaction.summary, /Modified: src\/compaction\.ts/);
245
+ assert.match(result.compaction.summary, /Read: src\/exec\.ts/);
246
+ assert.match(result.compaction.summary, /\[Outstanding Context\]/);
247
+ assert.match(result.compaction.summary, /Current implementation item: I-002/);
248
+ assert.match(result.compaction.summary, /Previous compact summary: Legacy Summary Earlier compact facts\./);
249
+ assert.match(result.compaction.summary, /\[User Preferences\]/);
250
+ assert.match(result.compaction.summary, /Always keep ASCII output/);
251
+ assert.equal(result.compaction.details.compactor, "pi-vcc");
252
+ assert.equal(result.compaction.details.phase, "execution");
253
+ assert.equal(result.stats.keptUserTurns, 1);
254
+ assert.equal(result.followUpPrompt, null);
255
+ });
256
+
257
+ it("cancels unsafe manual cuts but falls back to Pi core for overflow retry", () => {
258
+ const manual = buildPiPlansVccCompaction({
259
+ branchEntries: [],
260
+ preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
261
+ reason: "manual",
262
+ willRetry: false,
263
+ settings: DEFAULT_VCC_SETTINGS,
264
+ phaseContext: { phase: "planning" },
265
+ });
266
+ assert.equal(manual.kind, "cancel");
267
+
268
+ const overflowRetry = buildPiPlansVccCompaction({
269
+ branchEntries: [],
270
+ preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
271
+ reason: "overflow",
272
+ willRetry: true,
273
+ settings: DEFAULT_VCC_SETTINGS,
274
+ phaseContext: { phase: "planning" },
275
+ });
276
+ assert.deepEqual(overflowRetry, { kind: "fallback", reason: "no_live_messages" });
277
+
278
+ const overrideDisabled = buildPiPlansVccCompaction({
279
+ branchEntries: [
280
+ textEntry("u-1", "user", "old"),
281
+ textEntry("a-1", "assistant", "old answer"),
282
+ textEntry("u-2", "user", "new"),
283
+ ],
284
+ preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
285
+ settings: { ...DEFAULT_VCC_SETTINGS, overrideDefaultCompaction: false },
286
+ phaseContext: { phase: "planning" },
287
+ });
288
+ assert.deepEqual(overrideDisabled, { kind: "fallback", reason: "override-disabled" });
64
289
  });
65
290
 
66
- it("uses strict trigger and records a hard floor when the retained suffix cannot fit", () => {
67
- assert.equal(currentIExceedsTrigger(200, 1000), false);
68
- assert.equal(currentIExceedsTrigger(201, 1000), true);
69
- const entries = [textEntry("i1", "[I-001:current] " + "work ".repeat(40), 900), textEntry("u", "latest", 200), textEntry("a", "answer", 200)];
70
- const plan = planIAwareCompaction({ entries, currentI: "I-001", contextWindow: 1000, tokensBefore: 1300 });
71
- assert.equal(plan.metrics.targetMet, false);
72
- assert.ok(plan.metrics.hardFloorReason);
291
+ it("parses manual keep/follow-up instructions and gates auto-continue by version", () => {
292
+ assert.deepEqual(parseCompactionInstructions("pi-plans execution auto compact"), {
293
+ isPiVcc: false,
294
+ isInternalPiPlans: true,
295
+ keepUserTurns: 1,
296
+ keepUserTurnsExplicit: false,
297
+ followUpPrompt: null,
298
+ });
299
+ assert.deepEqual(parseCompactionInstructions("keep:2 Continue with tests"), {
300
+ isPiVcc: false,
301
+ isInternalPiPlans: false,
302
+ keepUserTurns: 2,
303
+ keepUserTurnsExplicit: true,
304
+ followUpPrompt: "Continue with tests",
305
+ });
306
+ assert.deepEqual(parseCompactionInstructions(`${PI_VCC_COMPACT_INSTRUCTION} keep:3`), {
307
+ isPiVcc: true,
308
+ isInternalPiPlans: false,
309
+ keepUserTurns: 3,
310
+ keepUserTurnsExplicit: true,
311
+ followUpPrompt: null,
312
+ });
313
+ assert.deepEqual(parseCompactionInstructions("Continue with tests keep:2"), {
314
+ isPiVcc: false,
315
+ isInternalPiPlans: false,
316
+ keepUserTurns: 2,
317
+ keepUserTurnsExplicit: true,
318
+ followUpPrompt: "Continue with tests",
319
+ });
320
+ assert.equal(shouldScheduleAutoContinue(true, "0.84.3"), true);
321
+ assert.equal(shouldScheduleAutoContinue(true, "0.84.4"), false);
322
+ assert.equal(shouldScheduleAutoContinue(false, "0.84.3"), false);
323
+ });
324
+
325
+ it("scaffolds and loads repo-private VCC settings", () => {
326
+ const stateRoot = path.join(tmpRoot, "state");
327
+ scaffoldVccSettings(stateRoot);
328
+ assert.equal(fs.existsSync(vccSettingsPath(stateRoot)), true);
329
+ assert.deepEqual(loadVccSettings(stateRoot), DEFAULT_VCC_SETTINGS);
330
+
331
+ fs.writeFileSync(vccSettingsPath(stateRoot), JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
332
+ scaffoldVccSettings(stateRoot);
333
+ assert.deepEqual(loadVccSettings(stateRoot), {
334
+ ...DEFAULT_VCC_SETTINGS,
335
+ overrideDefaultCompaction: false,
336
+ });
337
+
338
+ const invalidRoot = path.join(tmpRoot, "invalid-state");
339
+ fs.mkdirSync(invalidRoot, { recursive: true });
340
+ const invalidPath = vccSettingsPath(invalidRoot);
341
+ fs.writeFileSync(invalidPath, "{ invalid json", "utf8");
342
+ scaffoldVccSettings(invalidRoot);
343
+ assert.equal(fs.readFileSync(invalidPath, "utf8"), "{ invalid json");
344
+ assert.deepEqual(loadVccSettings(invalidRoot), DEFAULT_VCC_SETTINGS);
345
+
346
+ const envRoot = path.join(tmpRoot, "env-state");
347
+ const envConfig = path.join(tmpRoot, "external-pi-vcc-config.json");
348
+ const previousEnv = process.env.PI_VCC_CONFIG_PATH;
349
+ try {
350
+ process.env.PI_VCC_CONFIG_PATH = envConfig;
351
+ fs.writeFileSync(envConfig, JSON.stringify({ smartKeepTail: false, continueAfterThresholdCompact: false }), "utf8");
352
+ assert.deepEqual(loadVccSettings(envRoot), DEFAULT_VCC_SETTINGS);
353
+ } finally {
354
+ if (previousEnv === undefined) delete process.env.PI_VCC_CONFIG_PATH;
355
+ else process.env.PI_VCC_CONFIG_PATH = previousEnv;
356
+ }
357
+ });
358
+
359
+ it("writes debug snapshots only when enabled", () => {
360
+ const debugPath = "/tmp/pi-vcc-debug.json";
361
+ const previous = fs.existsSync(debugPath) ? fs.readFileSync(debugPath, "utf8") : null;
362
+ try {
363
+ fs.rmSync(debugPath, { force: true });
364
+ const event = {
365
+ branchEntries: [
366
+ textEntry("u-1", "user", "implement debug test"),
367
+ textEntry("a-1", "assistant", "working"),
368
+ textEntry("u-2", "user", "continue"),
369
+ textEntry("a-2", "assistant", "tail"),
370
+ ],
371
+ preparation: { tokensBefore: 10_000 },
372
+ reason: "threshold" as const,
373
+ willRetry: false,
374
+ phaseContext: { phase: "planning" as const },
375
+ };
376
+ buildPiPlansVccCompaction({ ...event, settings: DEFAULT_VCC_SETTINGS });
377
+ assert.equal(fs.existsSync(debugPath), false);
378
+
379
+ buildPiPlansVccCompaction({ ...event, settings: { ...DEFAULT_VCC_SETTINGS, debug: true } });
380
+ const debug = JSON.parse(fs.readFileSync(debugPath, "utf8"));
381
+ assert.equal(debug.usedOwnCut, true);
382
+ assert.equal(debug.phase, "planning");
383
+ } finally {
384
+ if (previous === null) fs.rmSync(debugPath, { force: true });
385
+ else fs.writeFileSync(debugPath, previous, "utf8");
386
+ }
73
387
  });
74
388
  });