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,326 @@
1
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_CONFIG, readActive, resolveStateRootOrNull, showConfig, type PlansConfig, utcNow, VALID_ROLE_MODES, updateConfig } from "./state.ts";
3
+
4
+ interface ModelLike {
5
+ provider?: unknown;
6
+ id?: unknown;
7
+ }
8
+
9
+ interface ConfigCommandContext {
10
+ cwd: string;
11
+ hasUI: boolean;
12
+ model?: unknown;
13
+ scopedModels?: Array<{ model?: unknown }>;
14
+ modelRegistry?: {
15
+ getAvailable?: () => unknown[];
16
+ };
17
+ ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input">;
18
+ }
19
+
20
+ type ChoiceResult<T> =
21
+ | { cancelled: false; value: T }
22
+ | { cancelled: true; reason: "user" | "invalid" };
23
+
24
+ interface MenuOption<T> {
25
+ label: string;
26
+ value?: T;
27
+ parse?: (input: string) => T | null;
28
+ prompt?: string;
29
+ errorMessage?: string;
30
+ }
31
+
32
+ function cancelled<T>(reason: "user" | "invalid" = "user"): ChoiceResult<T> {
33
+ return { cancelled: true, reason };
34
+ }
35
+
36
+ function modelSelectorOf(value: unknown): string | null {
37
+ if (!value || typeof value !== "object") return null;
38
+ const model = value as ModelLike;
39
+ if (typeof model.provider !== "string" || typeof model.id !== "string") return null;
40
+ if (!model.provider || !model.id) return null;
41
+ return `${model.provider}/${model.id}`;
42
+ }
43
+
44
+ function collectModelSelectors(ctx: ConfigCommandContext, currentSelector: string | null): string[] {
45
+ const selectors: string[] = [];
46
+ const push = (selector: string | null): void => {
47
+ if (!selector) return;
48
+ if (selectors.includes(selector)) return;
49
+ if (selector === currentSelector) return;
50
+ selectors.push(selector);
51
+ };
52
+
53
+ push(modelSelectorOf(ctx.model));
54
+ for (const entry of ctx.scopedModels ?? []) {
55
+ push(modelSelectorOf(entry?.model));
56
+ }
57
+ for (const entry of ctx.modelRegistry?.getAvailable?.() ?? []) {
58
+ push(modelSelectorOf(entry));
59
+ }
60
+ return selectors;
61
+ }
62
+
63
+ function parseLanguageTag(input: string): string | null {
64
+ const value = input.trim();
65
+ if (!value) return null;
66
+ return /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(value) ? value : null;
67
+ }
68
+
69
+ function parseArtifactRoot(input: string): string | null {
70
+ const value = input.trim();
71
+ return value ? value : null;
72
+ }
73
+
74
+ function parseModelSelector(input: string): string | null {
75
+ const value = input.trim();
76
+ if (!value) return null;
77
+ if (value.includes(" ")) return null;
78
+ const parts = value.split("/");
79
+ return parts.length === 2 && parts[0] && parts[1] ? value : null;
80
+ }
81
+
82
+ function selectIndex(labels: string[], selected: string): number {
83
+ let index = labels.indexOf(selected);
84
+ if (index >= 0) return index;
85
+ const normalized = selected.replace(/^\d+\.\s*/, "");
86
+ return labels.findIndex((label) => label.replace(/^\d+\.\s*/, "") === normalized);
87
+ }
88
+
89
+ async function promptMenu<T>(ctx: ConfigCommandContext, question: string, options: Array<MenuOption<T>>): Promise<ChoiceResult<T>> {
90
+ const labels = options.map((option, index) => `${index + 1}. ${option.label}`);
91
+ const selected = await ctx.ui.select(question, labels);
92
+ if (selected === undefined) return cancelled("user");
93
+ const index = selectIndex(labels, selected);
94
+ if (index < 0) return cancelled("invalid");
95
+ const option = options[index];
96
+ if (!option) return cancelled("invalid");
97
+ if (option.parse) {
98
+ const raw = await ctx.ui.input(option.prompt ?? `${question} ${option.label}`);
99
+ if (raw === undefined) return cancelled("user");
100
+ const parsed = option.parse(raw);
101
+ if (parsed === null) {
102
+ ctx.ui.notify(option.errorMessage ?? "Invalid input.", "error");
103
+ return cancelled("invalid");
104
+ }
105
+ return { cancelled: false, value: parsed };
106
+ }
107
+ if (!Object.prototype.hasOwnProperty.call(option, "value")) return cancelled("invalid");
108
+ return { cancelled: false, value: option.value as T };
109
+ }
110
+
111
+ async function promptLanguage(ctx: ConfigCommandContext, current: string | null): Promise<ChoiceResult<string>> {
112
+ const options: Array<MenuOption<string>> = [];
113
+ if (current) {
114
+ options.push({ label: `Keep current (${current})`, value: current });
115
+ } else {
116
+ options.push({ label: "zh-Hans", value: "zh-Hans" });
117
+ }
118
+ for (const tag of ["zh-Hans", "en", "zh-Hant"]) {
119
+ if (tag === current) continue;
120
+ if (options.some((option) => option.value === tag)) continue;
121
+ options.push({ label: tag, value: tag });
122
+ }
123
+ options.push({
124
+ label: "Other...",
125
+ parse: parseLanguageTag,
126
+ prompt: "Language tag:",
127
+ errorMessage: "Invalid language tag.",
128
+ });
129
+ return promptMenu(ctx, "Language?", options);
130
+ }
131
+
132
+ async function promptArtifactRoot(ctx: ConfigCommandContext, current: string): Promise<ChoiceResult<string>> {
133
+ const options: Array<MenuOption<string>> = [{ label: `Keep current (${current})`, value: current }];
134
+ for (const root of ["./docs/pi-plans", "./.git/pi_plans/plans"]) {
135
+ if (root === current) continue;
136
+ options.push({ label: root, value: root });
137
+ }
138
+ options.push({
139
+ label: "Other...",
140
+ parse: parseArtifactRoot,
141
+ prompt: "Artifact root:",
142
+ errorMessage: "Artifact root cannot be empty.",
143
+ });
144
+ return promptMenu(ctx, "Artifact root?", options);
145
+ }
146
+
147
+ async function promptGraphEnabled(ctx: ConfigCommandContext, current: boolean | null): Promise<ChoiceResult<boolean>> {
148
+ const options: Array<MenuOption<boolean>> = [];
149
+ if (current === true) {
150
+ options.push({ label: "Keep enabled", value: true });
151
+ options.push({ label: "Disable code graph", value: false });
152
+ } else if (current === false) {
153
+ options.push({ label: "Keep disabled", value: false });
154
+ options.push({ label: "Enable code graph", value: true });
155
+ } else {
156
+ options.push({ label: "Enable code graph", value: true });
157
+ options.push({ label: "Disable code graph", value: false });
158
+ }
159
+ return promptMenu(ctx, "Code graph?", options);
160
+ }
161
+
162
+ async function promptRoleMode(ctx: ConfigCommandContext, role: "reviewer" | "criticizer", current: string): Promise<ChoiceResult<string>> {
163
+ if (!VALID_ROLE_MODES.has(current)) {
164
+ current = "delegated-subagent";
165
+ }
166
+ const options: Array<MenuOption<string>> =
167
+ current === "delegated-subagent"
168
+ ? [
169
+ { label: "Keep delegated-subagent", value: "delegated-subagent" },
170
+ { label: "Switch to current-session", value: "current-session" },
171
+ ]
172
+ : [
173
+ { label: "Keep current-session", value: "current-session" },
174
+ { label: "Switch to delegated-subagent", value: "delegated-subagent" },
175
+ ];
176
+ return promptMenu(ctx, `${role[0].toUpperCase()}${role.slice(1)} mode?`, options);
177
+ }
178
+
179
+ async function promptRoleModel(
180
+ ctx: ConfigCommandContext,
181
+ role: "reviewer" | "criticizer",
182
+ currentSelector: string | null,
183
+ ): Promise<ChoiceResult<string | null>> {
184
+ const currentLiveSelector = modelSelectorOf(ctx.model);
185
+ const options: Array<MenuOption<string | null>> = [
186
+ {
187
+ label: currentSelector
188
+ ? `Keep current default (${currentSelector})`
189
+ : currentLiveSelector
190
+ ? `Keep current default (inherit live model: ${currentLiveSelector})`
191
+ : "Keep current default (inherit)",
192
+ value: currentSelector,
193
+ },
194
+ ];
195
+ if (currentLiveSelector && currentLiveSelector !== currentSelector) {
196
+ options.push({ label: `Use current session model (${currentLiveSelector})`, value: currentLiveSelector });
197
+ }
198
+ for (const selector of collectModelSelectors(ctx, currentSelector)) {
199
+ if (selector === currentLiveSelector) continue;
200
+ options.push({ label: `Use available model (${selector})`, value: selector });
201
+ }
202
+ options.push({
203
+ label: "Other...",
204
+ parse: parseModelSelector,
205
+ prompt: `${role[0].toUpperCase()}${role.slice(1)} model selector:`,
206
+ errorMessage: "Model selector must be an exact provider/model string.",
207
+ });
208
+ return promptMenu(ctx, `${role[0].toUpperCase()}${role.slice(1)} model?`, options);
209
+ }
210
+
211
+ function currentConfig(workdir: string): PlansConfig {
212
+ const stateRoot = resolveStateRootOrNull(workdir);
213
+ if (!stateRoot) return structuredClone(DEFAULT_CONFIG);
214
+ return showConfig(workdir);
215
+ }
216
+
217
+ function summarizeConfig(config: PlansConfig): string[] {
218
+ return [
219
+ "pi-plans config updated.",
220
+ `Language: ${config.language.tag ?? "(unset)"}`,
221
+ `Artifact root: ${config.artifact_root}`,
222
+ `Code graph: ${config.graph_enabled === true ? "enabled" : config.graph_enabled === false ? "disabled" : "unset"}`,
223
+ `Reviewer: ${config.reviewer.mode} / ${config.reviewer.model_selector ?? "inherit"}`,
224
+ `Criticizer: ${config.criticizer.mode} / ${config.criticizer.model_selector ?? "inherit"}`,
225
+ ];
226
+ }
227
+
228
+ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandContext): Promise<void> {
229
+ if (!ctx.hasUI) {
230
+ ctx.ui.notify("/config-pi-plans requires an interactive session.", "error");
231
+ return;
232
+ }
233
+
234
+ try {
235
+ const workdir = ctx.cwd;
236
+ const current = currentConfig(workdir);
237
+
238
+ const language = await promptLanguage(ctx, current.language.tag);
239
+ if (language.cancelled) {
240
+ if (language.reason === "user") {
241
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
242
+ }
243
+ return;
244
+ }
245
+
246
+ const artifactRoot = await promptArtifactRoot(ctx, current.artifact_root);
247
+ if (artifactRoot.cancelled) {
248
+ if (artifactRoot.reason === "user") {
249
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
250
+ }
251
+ return;
252
+ }
253
+
254
+ const graphEnabled = await promptGraphEnabled(ctx, current.graph_enabled);
255
+ if (graphEnabled.cancelled) {
256
+ if (graphEnabled.reason === "user") {
257
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
258
+ }
259
+ return;
260
+ }
261
+
262
+ const reviewerMode = await promptRoleMode(ctx, "reviewer", current.reviewer.mode);
263
+ if (reviewerMode.cancelled) {
264
+ if (reviewerMode.reason === "user") {
265
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
266
+ }
267
+ return;
268
+ }
269
+
270
+ const reviewerModel = await promptRoleModel(ctx, "reviewer", current.reviewer.model_selector);
271
+ if (reviewerModel.cancelled) {
272
+ if (reviewerModel.reason === "user") {
273
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
274
+ }
275
+ return;
276
+ }
277
+
278
+ const criticizerMode = await promptRoleMode(ctx, "criticizer", current.criticizer.mode);
279
+ if (criticizerMode.cancelled) {
280
+ if (criticizerMode.reason === "user") {
281
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
282
+ }
283
+ return;
284
+ }
285
+
286
+ const criticizerModel = await promptRoleModel(ctx, "criticizer", current.criticizer.model_selector);
287
+ if (criticizerModel.cancelled) {
288
+ if (criticizerModel.reason === "user") {
289
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
290
+ }
291
+ return;
292
+ }
293
+
294
+ const updated = updateConfig(workdir, (config) => {
295
+ const now = utcNow();
296
+ config.language = { tag: language.value, source: "user", updated_at: now };
297
+ config.artifact_root = artifactRoot.value;
298
+ config.artifact_root_source = "user";
299
+ config.artifact_root_updated_at = now;
300
+ config.graph_enabled = graphEnabled.value;
301
+ config.graph_enabled_updated_at = now;
302
+ config.reviewer = {
303
+ ...config.reviewer,
304
+ mode: reviewerMode.value,
305
+ model_selector: reviewerModel.value,
306
+ confirmed_at: now,
307
+ };
308
+ config.criticizer = {
309
+ ...config.criticizer,
310
+ mode: criticizerMode.value,
311
+ model_selector: criticizerModel.value,
312
+ confirmed_at: now,
313
+ };
314
+ return config;
315
+ });
316
+
317
+ const active = readActive(workdir);
318
+ const lines = summarizeConfig(updated.config);
319
+ if (active) {
320
+ lines.push(`Active run left unchanged: ${active.run_id}`);
321
+ }
322
+ ctx.ui.notify(lines.join("\n"), "info");
323
+ } catch (error) {
324
+ ctx.ui.notify(`Failed to update pi-plans config: ${(error as Error).message}`, "error");
325
+ }
326
+ }