pi-plans 0.1.2 → 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 (76) hide show
  1. package/README.md +98 -19
  2. package/index.ts +147 -66
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +21 -3
  5. package/references/state-and-config.md +35 -3
  6. package/scripts/validate.ts +4 -0
  7. package/src/autocomplete.ts +163 -0
  8. package/src/code-graph/commands.ts +437 -0
  9. package/src/code-graph/discovery.ts +118 -0
  10. package/src/code-graph/git.ts +108 -0
  11. package/src/code-graph/identity.ts +59 -0
  12. package/src/code-graph/indexer.ts +281 -0
  13. package/src/code-graph/materialize.ts +166 -0
  14. package/src/code-graph/mode.ts +28 -0
  15. package/src/code-graph/mutations.ts +160 -0
  16. package/src/code-graph/parser.ts +51 -0
  17. package/src/code-graph/parsers/javascript.ts +35 -0
  18. package/src/code-graph/parsers/python.ts +160 -0
  19. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  20. package/src/code-graph/paths.ts +85 -0
  21. package/src/code-graph/prompts.ts +18 -0
  22. package/src/code-graph/resolver.ts +69 -0
  23. package/src/code-graph/runtime.ts +158 -0
  24. package/src/code-graph/schema.ts +135 -0
  25. package/src/code-graph/screening.ts +82 -0
  26. package/src/code-graph/store.ts +278 -0
  27. package/src/code-graph/summary.ts +435 -0
  28. package/src/code-graph/types.ts +163 -0
  29. package/src/compaction.ts +1256 -0
  30. package/src/config-command.ts +326 -0
  31. package/src/exec.ts +519 -625
  32. package/src/plan.ts +37 -0
  33. package/src/query-hook.ts +82 -0
  34. package/src/refine-prompts.ts +50 -0
  35. package/src/refine-ui-helpers.ts +142 -0
  36. package/src/refine-ui-state.ts +144 -0
  37. package/src/refine-ui.ts +430 -0
  38. package/src/state.ts +24 -29
  39. package/src/subagent.ts +299 -70
  40. package/tests/ask-choice.test.ts +263 -0
  41. package/tests/autocomplete.test.ts +147 -0
  42. package/tests/code-graph-apply.test.ts +185 -0
  43. package/tests/code-graph-commands.test.ts +211 -0
  44. package/tests/code-graph-db.test.ts +166 -0
  45. package/tests/code-graph-discovery.test.ts +38 -0
  46. package/tests/code-graph-git.test.ts +94 -0
  47. package/tests/code-graph-index.test.ts +175 -0
  48. package/tests/code-graph-loop.e2e.test.ts +159 -0
  49. package/tests/code-graph-mutations.test.ts +117 -0
  50. package/tests/code-graph-parser.test.ts +85 -0
  51. package/tests/code-graph-rollback.test.ts +100 -0
  52. package/tests/code-graph-summary-batching.test.ts +518 -0
  53. package/tests/code-graph-summary.test.ts +148 -0
  54. package/tests/compaction.test.ts +388 -0
  55. package/tests/config-command.test.ts +255 -0
  56. package/tests/exec.test.ts +751 -422
  57. package/tests/execute-plan.test.ts +65 -0
  58. package/tests/fixtures/code-graph/sample.js +36 -0
  59. package/tests/fixtures/code-graph/sample.py +20 -0
  60. package/tests/fixtures/code-graph/sample.ts +15 -0
  61. package/tests/graph-aware-file-tools.test.ts +411 -0
  62. package/tests/plan.test.ts +11 -1
  63. package/tests/plans.test.ts +6 -5
  64. package/tests/query-hook.test.ts +82 -0
  65. package/tests/refine-prompts.test.ts +67 -2
  66. package/tests/refine-ui.test.ts +392 -0
  67. package/tests/state.test.ts +12 -15
  68. package/tests/subagent.test.ts +120 -0
  69. package/tools/ask-choice.ts +180 -11
  70. package/tools/code-graph.ts +254 -0
  71. package/tools/execute-plan.ts +7 -39
  72. package/tools/graph-aware-file-tools.ts +392 -0
  73. package/tools/plans.ts +84 -18
  74. package/tools/refine.ts +180 -80
  75. package/src/execution-panel.ts +0 -633
  76. package/tests/execution-panel.test.ts +0 -234
@@ -0,0 +1,65 @@
1
+ import * as assert from "node:assert/strict";
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";
6
+
7
+ import { getExecution, startExecution, stopExecution } from "../src/exec.ts";
8
+
9
+ let tmpRoot: string;
10
+
11
+ before(() => {
12
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-execute-test-"));
13
+ });
14
+
15
+ after(() => {
16
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
17
+ });
18
+
19
+ function mkWorkdir(name: string): string {
20
+ const dir = path.join(tmpRoot, name);
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ describe("execute handoff", () => {
26
+ it("starts execution without switching models", async () => {
27
+ const workdir = mkWorkdir("handoff");
28
+ const recorded = {
29
+ messages: [] as string[],
30
+ entries: [] as Array<{ customType: string; data: unknown }>,
31
+ setModelCalls: 0,
32
+ };
33
+ const pi = {
34
+ appendEntry: (customType: string, data: unknown) => {
35
+ recorded.entries.push({ customType, data });
36
+ },
37
+ sendMessage: (message: { customType: string; content: string }) => {
38
+ recorded.messages.push(message.content);
39
+ },
40
+ setModel: async () => {
41
+ recorded.setModelCalls += 1;
42
+ return true;
43
+ },
44
+ };
45
+ const ctx = {
46
+ cwd: workdir,
47
+ ui: {
48
+ setStatus: () => {},
49
+ theme: {
50
+ fg: (_kind: string, text: string) => text,
51
+ bold: (text: string) => text,
52
+ },
53
+ },
54
+ } as any;
55
+
56
+ await startExecution(pi as any, ctx, path.join(workdir, "PLAN_v1.md"), [
57
+ { id: "VC-001", text: "first item", done: false },
58
+ ] as any);
59
+ assert.equal(recorded.setModelCalls, 0);
60
+ assert.ok(getExecution());
61
+
62
+ await stopExecution(pi as any, ctx, "cleanup");
63
+ assert.equal(getExecution(), null);
64
+ });
65
+ });
@@ -0,0 +1,36 @@
1
+ // JavaScript fixture used by the code-graph parser tests. Includes nested
2
+ // arrow functions, classes with methods, getters/setters, and anonymous
3
+ // expressions assigned to variables.
4
+
5
+ function alpha(a, b) {
6
+ return a + b;
7
+ }
8
+
9
+ const beta = function (x) {
10
+ return x * 2;
11
+ };
12
+
13
+ const gamma = (y) => y + 1;
14
+
15
+ class Container {
16
+ delta() {
17
+ return 4;
18
+ }
19
+ get foo() {
20
+ return "getter";
21
+ }
22
+ set foo(value) {
23
+ this._foo = value;
24
+ }
25
+ }
26
+
27
+ function epsilon(value) {
28
+ const inner = (v) => v;
29
+ return inner(value);
30
+ }
31
+
32
+ const factory = {
33
+ make() {
34
+ return () => 1;
35
+ },
36
+ };
@@ -0,0 +1,20 @@
1
+ """Fixture for Python code graph tests."""
2
+
3
+ def alpha(a, b):
4
+ return a + b
5
+
6
+
7
+ async def beta(x):
8
+ return x * 2
9
+
10
+
11
+ class Container:
12
+ def delta(self):
13
+ return 4
14
+
15
+ @staticmethod
16
+ def helper(value):
17
+ return value
18
+
19
+
20
+ gamma = lambda y: y + 1
@@ -0,0 +1,15 @@
1
+ // TypeScript fixture for overload metadata and accessor parsing.
2
+ function overloaded(value: string): string;
3
+ function overloaded(value: number): number;
4
+ function overloaded(value: string | number): string | number {
5
+ return value;
6
+ }
7
+
8
+ class Accessors {
9
+ get value(): string {
10
+ return "value";
11
+ }
12
+ set value(next: string) {
13
+ void next;
14
+ }
15
+ }
@@ -0,0 +1,411 @@
1
+ import * as assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import * as url from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+ import { test } from "node:test";
8
+ import { Store } from "../src/code-graph/store.ts";
9
+ import { runIndex } from "../src/code-graph/indexer.ts";
10
+ import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
11
+ import { PythonBackend } from "../src/code-graph/parsers/python.ts";
12
+ import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
13
+ import { setGraphEnabled } from "../src/state.ts";
14
+ import type { ParserBackend } from "../src/code-graph/parser.ts";
15
+ import type { Language } from "../src/code-graph/types.ts";
16
+ import { createGraphAwareFileTools } from "../tools/graph-aware-file-tools.ts";
17
+
18
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
19
+
20
+ function git(cwd: string, args: string[]): void {
21
+ const env: Record<string, string | undefined> = { ...process.env };
22
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
23
+ const result = spawnSync("git", args, { cwd, env, encoding: "utf8" });
24
+ assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`);
25
+ }
26
+
27
+ function initRepo(): { root: string; cleanup: () => void } {
28
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-tools-"));
29
+ git(root, ["init", "--initial-branch=main"]);
30
+ git(root, ["config", "user.email", "test@example.com"]);
31
+ git(root, ["config", "user.name", "test"]);
32
+ fs.writeFileSync(path.join(root, "math.ts"), "export function add(a: number, b: number): number { return a + b; }\n");
33
+ git(root, ["add", "-A"]);
34
+ git(root, ["commit", "-m", "init"]);
35
+ const canonicalRoot = fs.realpathSync(root);
36
+ return {
37
+ root: canonicalRoot,
38
+ cleanup: () => {
39
+ try {
40
+ fs.rmSync(root, { recursive: true, force: true });
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ },
45
+ };
46
+ }
47
+
48
+ async function loadParsers() {
49
+ const runtime = await loadGraphRuntime();
50
+ if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return null;
51
+ const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
52
+ parse(input: string | Buffer): unknown;
53
+ setLanguage(language: unknown): void;
54
+ };
55
+ const parsers: Record<Language, ParserBackend> = {
56
+ javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
57
+ typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
58
+ tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
59
+ python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
60
+ };
61
+ return { runtime, parsers };
62
+ }
63
+
64
+ function openStore(root: string, sqlite: typeof import("node:sqlite")): Store {
65
+ const canonicalRoot = fs.realpathSync(root);
66
+ const dbPath = path.join(canonicalRoot, ".git", "pi_plans", "code_graph.db");
67
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
68
+ return new Store({ dbPath, worktreeRoot: canonicalRoot, gitCommonDir: path.join(canonicalRoot, ".git") }, sqlite);
69
+ }
70
+
71
+ function makeCtx(workdir: string) {
72
+ return {
73
+ cwd: workdir,
74
+ ui: {
75
+ notify: () => {},
76
+ },
77
+ } as any;
78
+ }
79
+
80
+ function firstText(result: { content: Array<{ type: string; text?: string }> }): string {
81
+ return result.content.find((part) => part.type === "text" && typeof part.text === "string")?.text ?? "";
82
+ }
83
+
84
+ test("index.ts wires graph-aware read/write/edit overrides", () => {
85
+ const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
86
+ assert.match(source, /import \{ registerGraphAwareFileTools \} from "\.\/tools\/graph-aware-file-tools\.ts";/);
87
+ assert.match(source, /registerGraphAwareFileTools\(pi\);/);
88
+ });
89
+
90
+ test("graph-aware read/write/edit stage indexed code in the DB instead of on disk", async (t) => {
91
+ const loaded = await loadParsers();
92
+ if (!loaded) return;
93
+ const { runtime, parsers } = loaded;
94
+ const { root, cleanup } = initRepo();
95
+ setGraphEnabled(root, true);
96
+ const store = openStore(root, runtime.runtime.sqlite);
97
+ t.after(() => {
98
+ try {
99
+ store.close();
100
+ } finally {
101
+ cleanup();
102
+ }
103
+ });
104
+ await runIndex({ store, worktreeRoot: root, parsers });
105
+
106
+ const tools = createGraphAwareFileTools(root);
107
+ const ctx = makeCtx(root);
108
+ const filePath = path.join(root, "math.ts");
109
+ const originalDisk = fs.readFileSync(filePath, "utf8");
110
+
111
+ const initialRead = await tools.read.execute("read-1", { path: "math.ts" }, undefined, undefined, ctx);
112
+ assert.match(firstText(initialRead), /return a \+ b;/);
113
+
114
+ const stagedWrite = "export function add(a: number, b: number): number { return a + b + 100; }\n";
115
+ await tools.write.execute("write-1", { path: "math.ts", content: stagedWrite }, undefined, undefined, ctx);
116
+ assert.equal(fs.readFileSync(filePath, "utf8"), originalDisk, "graph-aware write must not touch the worktree yet");
117
+
118
+ const stagedRead = await tools.read.execute("read-2", { path: "math.ts" }, undefined, undefined, ctx);
119
+ assert.match(firstText(stagedRead), /return a \+ b \+ 100;/);
120
+
121
+ const editResult = await tools.edit.execute(
122
+ "edit-1",
123
+ {
124
+ path: "math.ts",
125
+ edits: [{ oldText: "return a + b + 100;", newText: "return a + b + 200;" }],
126
+ },
127
+ undefined,
128
+ undefined,
129
+ ctx,
130
+ );
131
+ assert.match(firstText(editResult), /staged in code graph/);
132
+ assert.equal(fs.readFileSync(filePath, "utf8"), originalDisk, "graph-aware edit must stay DB-first until /apply-graph");
133
+
134
+ const editedRead = await tools.read.execute("read-3", { path: "math.ts" }, undefined, undefined, ctx);
135
+ assert.match(firstText(editedRead), /return a \+ b \+ 200;/);
136
+ });
137
+
138
+ function makeRepoWith(files: Record<string, string>): { root: string } {
139
+ const root0 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-read-"));
140
+ git(root0, ["init", "--initial-branch=main"]);
141
+ git(root0, ["config", "user.email", "test@example.com"]);
142
+ git(root0, ["config", "user.name", "test"]);
143
+ for (const [name, content] of Object.entries(files)) {
144
+ fs.writeFileSync(path.join(root0, name), content);
145
+ }
146
+ git(root0, ["add", "-A"]);
147
+ git(root0, ["commit", "-m", "init"]);
148
+ fs.mkdirSync(path.join(root0, ".git", "pi_plans"), { recursive: true });
149
+ return { root: fs.realpathSync(root0) };
150
+ }
151
+
152
+ function bigSource(functionCount: number): string {
153
+ const parts: string[] = [];
154
+ for (let i = 0; i < functionCount; i++) {
155
+ parts.push(`export function fn${i}(x: number): number {`);
156
+ parts.push(`\treturn ${i} + x;`);
157
+ parts.push(`}`);
158
+ parts.push("");
159
+ }
160
+ return parts.join("\n");
161
+ }
162
+
163
+ test("graph read defaults to a capped function digest; full/offset/beyond-EOF follow native semantics", async (t) => {
164
+ const loaded = await loadParsers();
165
+ if (!loaded) return;
166
+ const { runtime, parsers } = loaded;
167
+ const { root, cleanup } = initRepo();
168
+ setGraphEnabled(root, true);
169
+ const store = openStore(root, runtime.runtime.sqlite);
170
+ t.after(() => {
171
+ try {
172
+ store.close();
173
+ } finally {
174
+ cleanup();
175
+ }
176
+ });
177
+ fs.writeFileSync(path.join(root, "big.ts"), bigSource(120));
178
+ git(root, ["add", "-A"]);
179
+ git(root, ["commit", "-m", "big"]);
180
+ await runIndex({ store, worktreeRoot: root, parsers });
181
+
182
+ const tools = createGraphAwareFileTools(root);
183
+ const ctx = makeCtx(root);
184
+
185
+ const digest = firstText(await tools.read.execute("d1", { path: "big.ts" }, undefined, undefined, ctx));
186
+ const digestLines = digest.split("\n");
187
+ assert.ok(digestLines.length <= 50, `digest must be capped, got ${digestLines.length}`);
188
+ assert.match(digestLines[0] ?? "", /big\.ts · typescript · 120 functions/);
189
+ assert.match(digest, /fn0 \(1-3\) function fn0\(x: number\): number \{/);
190
+ assert.match(digest, /\u2026\+\d+ more \(code_graph screening \/ get-function\)/);
191
+ assert.match(digest, /Use full:true for the whole file/);
192
+ assert.doesNotMatch(digest, /return 0 \+ x;/);
193
+
194
+ const whole = firstText(await tools.read.execute("d2", { path: "big.ts", full: true }, undefined, undefined, ctx));
195
+ assert.match(whole, /return 119 \+ x;/);
196
+ assert.ok(whole.split("\n").length > 400);
197
+
198
+ const slice = firstText(await tools.read.execute("d3", { path: "big.ts", full: true, offset: 477, limit: 3 }, undefined, undefined, ctx));
199
+ assert.match(slice, /export function fn119\(x: number\): number \{/);
200
+
201
+ await assert.rejects(
202
+ tools.read.execute("d4", { path: "big.ts", offset: 481 }, undefined, undefined, ctx),
203
+ /beyond end of file/,
204
+ );
205
+
206
+ fs.writeFileSync(path.join(root, "consts.ts"), Array.from({ length: 250 }, (_, i) => `export const c${i} = ${i};`).join("\n") + "\n");
207
+ git(root, ["add", "-A"]);
208
+ git(root, ["commit", "-m", "consts"]);
209
+ await runIndex({ store, worktreeRoot: root, parsers });
210
+ const constsRead = firstText(await tools.read.execute("d5", { path: "consts.ts" }, undefined, undefined, ctx));
211
+ assert.match(constsRead, /c249 = 249/);
212
+ });
213
+
214
+ test("graph read folds synthetic anonymous functions and keeps named non-primary rows", async (t) => {
215
+ const loaded = await loadParsers();
216
+ if (!loaded) return;
217
+ const { runtime, parsers } = loaded;
218
+ const { root, cleanup } = initRepo();
219
+ setGraphEnabled(root, true);
220
+ const store = openStore(root, runtime.runtime.sqlite);
221
+ t.after(() => {
222
+ try {
223
+ store.close();
224
+ } finally {
225
+ cleanup();
226
+ }
227
+ });
228
+ fs.writeFileSync(path.join(root, "big.ts"), bigSource(120));
229
+ git(root, ["add", "-A"]);
230
+ git(root, ["commit", "-m", "big"]);
231
+ await runIndex({ store, worktreeRoot: root, parsers });
232
+ store.tx(() => {
233
+ for (let i = 0; i < 5; i++) {
234
+ store.db
235
+ .prepare(
236
+ `INSERT INTO functions (file_dir, file_name, function_name, language, kind, full_code, full_code_hash, render_code, render_code_hash,
237
+ move_supported, is_primary, provenance_start_byte, provenance_end_byte, provenance_start_line, provenance_start_col,
238
+ provenance_end_line, provenance_end_col, version)
239
+ VALUES ('.', 'big.ts', ?, 'typescript', 'arrow', '', 'h', '', 'h', 1, 0, 0, 4, 1, 1, 1, 1, 1)`,
240
+ )
241
+ .run(`outer.<anonymous:arrow_function#${i}>`);
242
+ }
243
+ });
244
+
245
+ const tools = createGraphAwareFileTools(root);
246
+ const ctx = makeCtx(root);
247
+ const digest = firstText(await tools.read.execute("a1", { path: "big.ts" }, undefined, undefined, ctx));
248
+ assert.match(digest.split("\n")[0] ?? "", /120 functions \(\+5 anonymous\)/);
249
+ assert.match(digest, /…\+\d+ more/);
250
+ });
251
+
252
+ test("graph read marks unexpected fallbacks and stays silent for flag-off", async (t) => {
253
+ const loaded = await loadParsers();
254
+ if (!loaded) return;
255
+ const { runtime, parsers } = loaded;
256
+ const { root, cleanup } = initRepo();
257
+ setGraphEnabled(root, true);
258
+ const store = openStore(root, runtime.runtime.sqlite);
259
+ t.after(() => {
260
+ try {
261
+ store.close();
262
+ } finally {
263
+ cleanup();
264
+ }
265
+ });
266
+ await runIndex({ store, worktreeRoot: root, parsers });
267
+ // Close before the foreign-DB swap below: an open connection keeps wal/shm
268
+ // siblings alive and SQLite recovery would resurrect the old rows.
269
+ store.close();
270
+ fs.writeFileSync(path.join(root, "late.ts"), "export function late(): number { return 42; }\n");
271
+
272
+ const tools = createGraphAwareFileTools(root);
273
+ const ctx = makeCtx(root);
274
+
275
+ const notIndexed = firstText(await tools.read.execute("f1", { path: "late.ts" }, undefined, undefined, ctx));
276
+ assert.match(notIndexed, /^\[graph-read fallback: not indexed → native\]/);
277
+ assert.match(notIndexed, /return 42;/);
278
+
279
+ const normal = firstText(await tools.read.execute("f2", { path: "math.ts" }, undefined, undefined, ctx));
280
+ assert.doesNotMatch(normal, /^\[graph-read fallback/);
281
+ assert.match(normal, /return a \+ b;/);
282
+
283
+ setGraphEnabled(root, false);
284
+ const off = firstText(await tools.read.execute("f3", { path: "math.ts" }, undefined, undefined, ctx));
285
+ assert.doesNotMatch(off, /^\[graph-read fallback/);
286
+ assert.match(off, /return a \+ b;/);
287
+
288
+ fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), "{broken");
289
+ const broken = firstText(await tools.read.execute("f4", { path: "math.ts" }, undefined, undefined, ctx));
290
+ assert.match(broken, /^\[graph-read fallback: config read failed → native\]/);
291
+
292
+ // runtime unavailable: DB whose stored worktree is another directory.
293
+ // ensureRuntime caches by db path within a process, so probe in a child
294
+ // process with fresh module state: checkWorktree must reject the foreign DB.
295
+ fs.writeFileSync(path.join(root, ".git", "pi_plans", "config.json"), JSON.stringify({ schema: 1, graph_enabled: true }));
296
+ const otherRoot0 = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-graph-other-"));
297
+ git(otherRoot0, ["init", "--initial-branch=main"]);
298
+ const otherRoot = fs.realpathSync(otherRoot0);
299
+ const foreignStore = new Store(
300
+ { dbPath: path.join(otherRoot, "foreign.db"), worktreeRoot: otherRoot, gitCommonDir: path.join(otherRoot, ".git") },
301
+ runtime.runtime.sqlite,
302
+ );
303
+ foreignStore.close();
304
+ fs.copyFileSync(path.join(otherRoot, "foreign.db"), path.join(root, ".git", "pi_plans", "code_graph.db"));
305
+ fs.writeFileSync(path.join(root, "math.ts"), "export function add(a: number, b: number): number { return a + b; }\n");
306
+ const probeScript = [
307
+ "const fs = await import('node:fs');",
308
+ "const { pathToFileURL } = await import('node:url');",
309
+ `const mod = await import(pathToFileURL(${JSON.stringify(path.join(ROOT, "tools", "graph-aware-file-tools.ts"))}));`,
310
+ `const tools = mod.createGraphAwareFileTools(${JSON.stringify(root)});`,
311
+ "const ctx = { cwd: process.cwd(), ui: { notify: () => {} } };",
312
+ "const r = await tools.read.execute('probe', { path: 'math.ts' }, undefined, undefined, ctx);",
313
+ "const text = r.content.find((c) => c.type === 'text').text;",
314
+ "console.log(JSON.stringify(text));",
315
+ ].join("\n");
316
+ const probe = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", probeScript], {
317
+ cwd: root,
318
+ encoding: "utf8",
319
+ timeout: 60000,
320
+ });
321
+ let probeText = "";
322
+ try {
323
+ probeText = JSON.parse(probe.stdout.trim().split("\n").filter((l) => l.startsWith("\"") || l.startsWith("["))[0] ?? "\"\"");
324
+ } catch {
325
+ probeText = probe.stderr;
326
+ }
327
+ assert.match(probeText, /^\[graph-read fallback: runtime unavailable → native\]/);
328
+
329
+ fs.rmSync(otherRoot0, { recursive: true, force: true });
330
+ });
331
+
332
+ test("full read triggers the native-equivalent safety valve and marks truncation in details", async (t) => {
333
+ const loaded = await loadParsers();
334
+ if (!loaded) return;
335
+ const { runtime, parsers } = loaded;
336
+ const { root, cleanup } = initRepo();
337
+ setGraphEnabled(root, true);
338
+ const store = openStore(root, runtime.runtime.sqlite);
339
+ t.after(() => {
340
+ try {
341
+ store.close();
342
+ } finally {
343
+ cleanup();
344
+ }
345
+ });
346
+ fs.writeFileSync(path.join(root, "huge.ts"), bigSource(700)); // 2800 lines > DEFAULT_MAX_LINES
347
+ git(root, ["add", "-A"]);
348
+ git(root, ["commit", "-m", "huge"]);
349
+ await runIndex({ store, worktreeRoot: root, parsers });
350
+
351
+ const tools = createGraphAwareFileTools(root);
352
+ const ctx = makeCtx(root);
353
+ const result = await tools.read.execute("t1", { path: "huge.ts", full: true }, undefined, undefined, ctx);
354
+ assert.equal(result.details.truncated, true);
355
+ assert.equal(result.details.truncatedBy, "lines");
356
+ assert.ok(result.details.outputLines <= 2000);
357
+ assert.ok(result.details.totalLines >= 2800);
358
+ });
359
+
360
+ test("digest descriptions decode multibyte slices and degrade across all three levels", async (t) => {
361
+ const loaded = await loadParsers();
362
+ if (!loaded) return;
363
+ const { runtime, parsers } = loaded;
364
+ const { root, cleanup } = initRepo();
365
+ setGraphEnabled(root, true);
366
+ const store = openStore(root, runtime.runtime.sqlite);
367
+ t.after(() => {
368
+ try {
369
+ store.close();
370
+ } finally {
371
+ cleanup();
372
+ }
373
+ });
374
+ fs.writeFileSync(
375
+ path.join(root, "mixed.ts"),
376
+ [
377
+ "// 中文注释:多字节字符用于锁定字节偏移切片的正确性。",
378
+ "export function 求和(a: number, b: number): number {",
379
+ "\treturn a + b;",
380
+ "}",
381
+ "",
382
+ "export function noProvenance() {",
383
+ "\treturn 1;",
384
+ "}",
385
+ "",
386
+ ...Array.from({ length: 200 }, (_, i) => `export const pad${i} = ${i};`), // push past the 200-line full-text threshold
387
+ ].join("\n") + "\n",
388
+ );
389
+ git(root, ["add", "-A"]);
390
+ git(root, ["commit", "-m", "mixed"]);
391
+ await runIndex({ store, worktreeRoot: root, parsers });
392
+ // Level 1: summary_description set → used verbatim.
393
+ store.tx(() => {
394
+ store.db
395
+ .prepare(`UPDATE functions SET summary_description = ? WHERE function_name = ?`)
396
+ .run("求和函数:返回两数之和", "求和");
397
+ store.db
398
+ .prepare(`UPDATE functions SET provenance_start_byte = provenance_end_byte WHERE function_name = ?`)
399
+ .run("noProvenance");
400
+ });
401
+
402
+ const tools = createGraphAwareFileTools(root);
403
+ const ctx = makeCtx(root);
404
+ const digest = firstText(await tools.read.execute("m1", { path: "mixed.ts" }, undefined, undefined, ctx));
405
+ // Level 1: summary_description wins over the byte slice.
406
+ assert.match(digest, /求和 .*求和函数:返回两数之和/);
407
+ // Level 3: NULL provenance bytes → name + line range, empty description.
408
+ assert.match(digest, /noProvenance \(6-8\)\n/);
409
+ // Level 2 sanity: no mojibake from byte-offset slicing on CJK sources.
410
+ assert.doesNotMatch(digest, /\uFFFD/);
411
+ });
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
5
5
  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
- import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage } from "../src/plan.ts";
8
+ import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanCurrentIMarkers, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage, resolveCurrentI, inferCurrentI } from "../src/plan.ts";
9
9
 
10
10
  const PLAN = `# PLAN_v1 - demo
11
11
 
@@ -148,6 +148,16 @@ describe("implementation items", () => {
148
148
  assert.deepEqual(extractCoverage("no coverage clause here"), []);
149
149
  });
150
150
 
151
+ it("parses and resolves current-I markers without changing progress marker states", () => {
152
+ const implItems = [{ id: "I-001", text: "first" }, { id: "I-002", text: "second" }];
153
+ assert.deepEqual(scanCurrentIMarkers("[I-001:current] [I-999:current] [I-002:current]"), [
154
+ { id: "I-001" },
155
+ { id: "I-999" },
156
+ { id: "I-002" },
157
+ ]);
158
+ assert.equal(resolveCurrentI(implItems, scanCurrentIMarkers("[I-999:current] [I-002:current]")), "I-002");
159
+ assert.equal(inferCurrentI(implItems, [], undefined), "I-001");
160
+ });
151
161
  it("scans impl markers", () => {
152
162
  assert.deepEqual(scanImplMarkers("[I-001:implemented] then [I-002:validating] and [I-003:done]"), [
153
163
  { id: "I-001", state: "implemented" },
@@ -22,15 +22,16 @@ describe("plans tool source", () => {
22
22
 
23
23
  const params = source.slice(start, end);
24
24
  assert.match(params, /artifactRootSource:\s*Type\.Optional/);
25
- assert.match(params, /executionModelSelector:\s*Type\.Optional/);
26
- assert.match(params, /executionModelSource:\s*Type\.Optional/);
25
+ assert.doesNotMatch(params, /executionModelSelector/);
26
+ assert.doesNotMatch(params, /executionModelSource/);
27
27
  });
28
28
 
29
- it("keeps the plans handler wired to artifact root and execution model actions", () => {
29
+ it("keeps the plans handler wired to artifact root and no separate model-selection action", () => {
30
30
  const source = readPlansSource();
31
- assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]*setExecutionModel,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
31
+ assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
32
32
  assert.match(source, /case "set-artifact-root"/);
33
- assert.match(source, /case "set-execution-model"/);
33
+ assert.doesNotMatch(source, /setExecutionModel/);
34
+ assert.doesNotMatch(source, /case "set-execution-model"/);
34
35
  assert.match(source, /params\.artifactRootSource/);
35
36
  });
36
37
  });
@@ -0,0 +1,82 @@
1
+ import * as assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ consumeOrdinaryQuery,
5
+ isOrdinaryExternalQuery,
6
+ QUERY_INTERVIEW_MESSAGE,
7
+ QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE,
8
+ recordOrdinaryQuery,
9
+ registerQueryInterviewHooks,
10
+ resetOrdinaryQueryState,
11
+ } from "../src/query-hook.ts";
12
+
13
+ function context(sessionManager: object): any {
14
+ return { cwd: process.cwd(), sessionManager };
15
+ }
16
+
17
+ function registeredHooks(): { pi: any; handlers: Map<string, Function[]> } {
18
+ const handlers = new Map<string, Function[]>();
19
+ const pi = {
20
+ on(name: string, handler: Function) {
21
+ handlers.set(name, [...(handlers.get(name) ?? []), handler]);
22
+ },
23
+ };
24
+ return { pi, handlers };
25
+ }
26
+
27
+ const ordinaryInput = { text: "implement the requested change", source: "interactive" as const };
28
+
29
+ describe("query interview hook", () => {
30
+ it("accepts only idle external non-slash input", () => {
31
+ assert.equal(isOrdinaryExternalQuery(ordinaryInput), true);
32
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, source: "rpc" }), true);
33
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, source: "extension" }), false);
34
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, streamingBehavior: "followUp" }), false);
35
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, streamingBehavior: "steer" }), false);
36
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: " " }), false);
37
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/planning clarify this" }), false);
38
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/skill:plan-small implement this" }), false);
39
+ assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/unknown-command" }), false);
40
+ });
41
+
42
+ it("queues multiple queries, consumes one at a time, and isolates sessions", () => {
43
+ const first = context({});
44
+ const second = context({});
45
+ assert.equal(recordOrdinaryQuery(first, ordinaryInput), true);
46
+ assert.equal(recordOrdinaryQuery(first, { ...ordinaryInput, text: "verify the change" }), true);
47
+ assert.equal(consumeOrdinaryQuery(second), false);
48
+ assert.equal(consumeOrdinaryQuery(first), true);
49
+ assert.equal(consumeOrdinaryQuery(first), true);
50
+ assert.equal(consumeOrdinaryQuery(first), false);
51
+ resetOrdinaryQueryState(first);
52
+ assert.equal(consumeOrdinaryQuery(first), false);
53
+ });
54
+
55
+ it("registers one hidden message per ordinary query and consumes suppressed queries", async () => {
56
+ const { pi, handlers } = registeredHooks();
57
+ let suppressed = false;
58
+ registerQueryInterviewHooks(pi, () => suppressed);
59
+ const session = {};
60
+ const ctx = context(session);
61
+ const input = handlers.get("input")![0]!;
62
+ const beforeAgentStart = handlers.get("before_agent_start")![0]!;
63
+ const sessionStart = handlers.get("session_start")![0]!;
64
+
65
+ await input({ ...ordinaryInput }, ctx);
66
+ const first = await beforeAgentStart({ type: "before_agent_start", prompt: ordinaryInput.text }, ctx);
67
+ assert.equal(first?.message.customType, QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE);
68
+ assert.equal(first?.message.display, false);
69
+ assert.equal(first?.message.content, QUERY_INTERVIEW_MESSAGE);
70
+ assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: ordinaryInput.text }, ctx), undefined);
71
+
72
+ await input({ ...ordinaryInput, text: "a query inside the planning workflow" }, ctx);
73
+ suppressed = true;
74
+ assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "expanded workflow prompt" }, ctx), undefined);
75
+ suppressed = false;
76
+ assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "later ordinary prompt" }, ctx), undefined);
77
+
78
+ await input({ ...ordinaryInput, text: "stale input" }, ctx);
79
+ await sessionStart({ type: "session_start" }, ctx);
80
+ assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "new session" }, ctx), undefined);
81
+ });
82
+ });