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,255 @@
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 { after, before, describe, it } from "node:test";
7
+
8
+ import { configPiPlansCommand } from "../src/config-command.ts";
9
+ import { startRun } from "../src/state.ts";
10
+
11
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
12
+
13
+ interface Recorded {
14
+ selects: Array<{ question: string; labels: string[] }>;
15
+ inputs: Array<{ prompt: string }>;
16
+ notifies: Array<{ message: string; severity: string }>;
17
+ }
18
+
19
+ interface HarnessOptions {
20
+ select: (question: string, labels: string[]) => string | undefined | Promise<string | undefined>;
21
+ input?: (prompt: string) => string | undefined | Promise<string | undefined>;
22
+ model?: { provider: string; id: string } | null;
23
+ scopedModels?: Array<{ model: { provider: string; id: string } }>;
24
+ availableModels?: Array<{ provider: string; id: string }>;
25
+ }
26
+
27
+ let root: string;
28
+
29
+ before(() => {
30
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-config-command-"));
31
+ });
32
+
33
+ after(() => {
34
+ fs.rmSync(root, { recursive: true, force: true });
35
+ });
36
+
37
+ function workdir(name: string): string {
38
+ const dir = path.join(root, name);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ return dir;
41
+ }
42
+
43
+ function configPath(workdirPath: string): string {
44
+ return path.join(workdirPath, ".git", "pi_plans", "config.json");
45
+ }
46
+
47
+ function runPath(workdirPath: string, runId: string): string {
48
+ return path.join(workdirPath, ".git", "pi_plans", "runs", runId, "run.json");
49
+ }
50
+
51
+ function readJson(filePath: string): any {
52
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
53
+ }
54
+
55
+ function makeContext(workdirPath: string, options: HarnessOptions) {
56
+ const recorded: Recorded = {
57
+ selects: [],
58
+ inputs: [],
59
+ notifies: [],
60
+ };
61
+ const ctx = {
62
+ cwd: workdirPath,
63
+ hasUI: true,
64
+ model: options.model ?? null,
65
+ scopedModels: options.scopedModels ?? [],
66
+ modelRegistry: {
67
+ getAvailable: () => options.availableModels ?? [],
68
+ },
69
+ ui: {
70
+ notify: (message: string, severity: string) => {
71
+ recorded.notifies.push({ message, severity });
72
+ },
73
+ select: async (question: string, labels: string[]) => {
74
+ recorded.selects.push({ question, labels });
75
+ return options.select(question, labels);
76
+ },
77
+ input: async (prompt: string) => {
78
+ recorded.inputs.push({ prompt });
79
+ return options.input?.(prompt);
80
+ },
81
+ },
82
+ } as unknown as Parameters<typeof configPiPlansCommand>[1];
83
+ return { ctx, recorded };
84
+ }
85
+
86
+ describe("config-pi-plans command", () => {
87
+ it("registers the slash command in index.ts and delegates to the helper", () => {
88
+ const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
89
+ assert.match(source, /import \{ configPiPlansCommand \} from "\.\/src\/config-command\.ts";/);
90
+ assert.match(source, /registerCommand\("config-pi-plans"/);
91
+ assert.match(source, /await configPiPlansCommand\(args, ctx\);/);
92
+ });
93
+
94
+ it("writes the full wizard into config.json", async () => {
95
+ const dir = workdir("full-wizard");
96
+ const { ctx } = makeContext(dir, {
97
+ model: { provider: "live", id: "pro" },
98
+ availableModels: [{ provider: "registry", id: "critic" }],
99
+ select: (question, labels) => {
100
+ if (question === "Language?") return labels.find((label) => label.includes("en"));
101
+ if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
102
+ if (question === "Code graph?") return labels.find((label) => label.includes("Disable code graph"));
103
+ if (question === "Reviewer mode?") return labels.find((label) => label.includes("Switch to current-session"));
104
+ if (question === "Reviewer model?") return labels.find((label) => label.includes("Use current session model (live/pro)"));
105
+ if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
106
+ if (question === "Criticizer model?") return labels.find((label) => label.includes("Other..."));
107
+ return undefined;
108
+ },
109
+ input: (prompt) => {
110
+ if (prompt === "Criticizer model selector:") return "custom/critic-model";
111
+ return undefined;
112
+ },
113
+ });
114
+
115
+ await configPiPlansCommand("", ctx);
116
+
117
+ assert.ok(fs.existsSync(configPath(dir)));
118
+ const config = readJson(configPath(dir));
119
+ assert.equal(config.language.tag, "en");
120
+ assert.equal(config.language.source, "user");
121
+ assert.equal(config.artifact_root, "./.git/pi_plans/plans");
122
+ assert.equal(config.artifact_root_source, "user");
123
+ assert.equal(config.graph_enabled, false);
124
+ assert.equal(config.reviewer.mode, "current-session");
125
+ assert.equal(config.reviewer.model_selector, "live/pro");
126
+ assert.equal(config.criticizer.mode, "delegated-subagent");
127
+ assert.equal(config.criticizer.model_selector, "custom/critic-model");
128
+ assert.ok(config.reviewer.confirmed_at);
129
+ assert.ok(config.criticizer.confirmed_at);
130
+ });
131
+
132
+ it("dedupes model choices and falls back to the live model when no default is stored", async () => {
133
+ const dir = workdir("model-menu");
134
+ const { ctx, recorded } = makeContext(dir, {
135
+ model: { provider: "live", id: "pro" },
136
+ scopedModels: [{ model: { provider: "scoped", id: "model-a" } }],
137
+ availableModels: [
138
+ { provider: "scoped", id: "model-a" },
139
+ { provider: "registry", id: "model-b" },
140
+ ],
141
+ select: (question, labels) => {
142
+ if (question === "Language?") return labels[0];
143
+ if (question === "Artifact root?") return labels[0];
144
+ if (question === "Code graph?") return labels[0];
145
+ if (question === "Reviewer mode?") return labels[0];
146
+ if (question === "Reviewer model?") return labels[0];
147
+ if (question === "Criticizer mode?") return undefined;
148
+ return undefined;
149
+ },
150
+ });
151
+
152
+ await configPiPlansCommand("", ctx);
153
+
154
+ const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
155
+ assert.ok(reviewerModelPrompt);
156
+ assert.deepEqual(reviewerModelPrompt?.labels, [
157
+ "1. Keep current default (inherit live model: live/pro)",
158
+ "2. Use current session model (live/pro)",
159
+ "3. Use available model (scoped/model-a)",
160
+ "4. Use available model (registry/model-b)",
161
+ "5. Other...",
162
+ ]);
163
+ assert.equal(fs.existsSync(configPath(dir)), false);
164
+ assert.equal(recorded.notifies.at(-1)?.severity, "warning");
165
+ });
166
+
167
+ it("rejects an invalid model selector entered through Other and leaves config untouched", async () => {
168
+ const dir = workdir("invalid-model");
169
+ const { ctx, recorded } = makeContext(dir, {
170
+ select: (question, labels) => {
171
+ if (question === "Language?") return labels[0];
172
+ if (question === "Artifact root?") return labels[0];
173
+ if (question === "Code graph?") return labels[0];
174
+ if (question === "Reviewer mode?") return labels[0];
175
+ if (question === "Reviewer model?") return labels.find((label) => label.includes("Other..."));
176
+ return undefined;
177
+ },
178
+ input: () => "bad selector",
179
+ });
180
+
181
+ await configPiPlansCommand("", ctx);
182
+
183
+ assert.equal(fs.existsSync(configPath(dir)), false);
184
+ assert.equal(recorded.notifies.some((entry) => entry.message.includes("Model selector must be an exact provider/model string.")), true);
185
+ });
186
+
187
+ it("does not write when the wizard is cancelled", async () => {
188
+ const dir = workdir("cancelled");
189
+ const { ctx } = makeContext(dir, {
190
+ select: (question, labels) => {
191
+ if (question === "Language?") return undefined;
192
+ return labels[0];
193
+ },
194
+ });
195
+
196
+ await configPiPlansCommand("", ctx);
197
+
198
+ assert.equal(fs.existsSync(configPath(dir)), false);
199
+ });
200
+
201
+ it("renders a two-option inherit menu when no model sources exist", async () => {
202
+ const dir = workdir("empty-model-menu");
203
+ const { ctx, recorded } = makeContext(dir, {
204
+ model: null,
205
+ scopedModels: [],
206
+ availableModels: [],
207
+ select: (question, labels) => {
208
+ if (question === "Language?") return labels[0];
209
+ if (question === "Artifact root?") return labels[0];
210
+ if (question === "Code graph?") return labels[0];
211
+ if (question === "Reviewer mode?") return labels[0];
212
+ if (question === "Reviewer model?") return labels[0];
213
+ return undefined;
214
+ },
215
+ });
216
+
217
+ await configPiPlansCommand("", ctx);
218
+
219
+ const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
220
+ assert.ok(reviewerModelPrompt);
221
+ assert.deepEqual(reviewerModelPrompt?.labels, [
222
+ "1. Keep current default (inherit)",
223
+ "2. Other...",
224
+ ]);
225
+ assert.equal(fs.existsSync(configPath(dir)), false);
226
+ });
227
+
228
+ it("keeps the active run snapshot unchanged", async () => {
229
+ const dir = workdir("active-run");
230
+ const { run } = startRun(dir, { topic: "config", skill: "plan-small", requestText: "x" });
231
+ const before = readJson(runPath(dir, run.run_id));
232
+ const { ctx } = makeContext(dir, {
233
+ select: (question, labels) => {
234
+ if (question === "Language?") return labels.find((label) => label.includes("en"));
235
+ if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
236
+ if (question === "Code graph?") return labels.find((label) => label.includes("Enable code graph"));
237
+ if (question === "Reviewer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
238
+ if (question === "Reviewer model?") return labels[0];
239
+ if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
240
+ if (question === "Criticizer model?") return labels[0];
241
+ return undefined;
242
+ },
243
+ });
244
+
245
+ await configPiPlansCommand("", ctx);
246
+
247
+ const after = readJson(runPath(dir, run.run_id));
248
+ assert.deepEqual(after, before);
249
+ const config = readJson(configPath(dir));
250
+ assert.equal(config.language.tag, "en");
251
+ assert.equal(config.artifact_root, "./.git/pi_plans/plans");
252
+ assert.equal(config.reviewer.model_selector, null);
253
+ assert.ok(config.reviewer.confirmed_at);
254
+ });
255
+ });