@plannotator/pi-extension 0.16.2 → 0.16.3

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.
package/README.md CHANGED
@@ -63,6 +63,87 @@ When the agent calls `plannotator_submit_plan`, the Plannotator UI opens in your
63
63
 
64
64
  The agent iterates on the plan until you approve, then executes with full tool access. On resubmission, Plan Diff highlights what changed since the previous version.
65
65
 
66
+ ### Configuring per-phase behavior
67
+
68
+ Plannotator loads configuration in three layers:
69
+
70
+ 1. Built-in base config shipped with the package: `plannotator.json`
71
+ 2. Global user config: `~/.pi/agent/plannotator.json`
72
+ 3. Project-local config: `<cwd>/.pi/plannotator.json`
73
+
74
+ Later layers overwrite earlier ones. If a field is omitted, it inherits the value from lower-precedence layers. If a value is set to `null`, an empty string, or an empty array, it clears the inherited value instead of merging it. You can also set `defaults` or an entire phase object to `null` to clear all inherited settings from lower-precedence layers.
75
+
76
+ #### Top-level shape
77
+
78
+ ```json
79
+ {
80
+ "defaults": {
81
+ "model": { "provider": "anthropic", "id": "claude-sonnet-4-5" },
82
+ "thinking": "medium",
83
+ "activeTools": ["read", "bash"],
84
+ "statusLabel": "Ready",
85
+ "systemPrompt": "Optional prompt template"
86
+ },
87
+ "phases": {
88
+ "planning": {
89
+ "model": null,
90
+ "thinking": null,
91
+ "activeTools": ["grep", "find", "ls", "plannotator_submit_plan"],
92
+ "statusLabel": "⏸ plan",
93
+ "systemPrompt": "[PLANNING]\nPlan file: ${planFilePath}"
94
+ },
95
+ "executing": {
96
+ "model": { "provider": "anthropic", "id": "claude-sonnet-4-5" },
97
+ "thinking": "high",
98
+ "activeTools": [],
99
+ "statusLabel": "",
100
+ "systemPrompt": "[EXECUTING]\nRemaining steps:\n${todoList}"
101
+ },
102
+ "reviewing": {
103
+ "systemPrompt": "..."
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ #### Option reference
110
+
111
+ | Option | Type | Meaning |
112
+ |--------|------|---------|
113
+ | `defaults` | object | Base values applied to every phase before phase-specific overrides |
114
+ | `phases` | object | Phase-specific overrides |
115
+ | `phases.planning` | object | Settings for planning mode |
116
+ | `phases.executing` | object | Settings for execution mode |
117
+ | `phases.reviewing` | object | Reserved for future review-mode customization |
118
+ | `model` | `{ provider, id }` \| `null` | Sets the model for the phase; `null` leaves the current model unchanged |
119
+ | `thinking` | `minimal` \| `low` \| `medium` \| `high` \| `xhigh` \| `null` | Sets the thinking level; `null` leaves the current level unchanged |
120
+ | `activeTools` | string[] \| `null` | Extra tools to enable for the phase; `[]` or `null` means no extra phase tools |
121
+ | `statusLabel` | string \| `null` | Optional UI label for the phase; empty/null clears it |
122
+ | `systemPrompt` | string \| `null` | Phase system prompt template; empty/null disables prompt injection |
123
+
124
+ #### Prompt variables
125
+
126
+ Use these inside `systemPrompt` strings:
127
+
128
+ - `${planFilePath}` — current plan file path
129
+ - `${todoList}` — remaining checklist items as markdown checkboxes
130
+ - `${completedCount}` — completed checklist count
131
+ - `${totalCount}` — total checklist count
132
+ - `${remainingCount}` — remaining checklist count
133
+ - `${phase}` — current runtime phase (`planning`, `executing`, `reviewing`, or `idle`)
134
+
135
+ #### Behavior notes
136
+
137
+ - Unknown template variables trigger a warning in the UI and are rendered as empty strings.
138
+ - `activeTools` are additive with the tools currently active in the session, so Plannotator still preserves tools provided by other extensions.
139
+ - Execution progress remains dynamic (`[DONE:n]` + checklist tracking), even if `statusLabel` is set.
140
+
141
+ #### Example files
142
+
143
+ - Built-in base config shipped with the package: `apps/pi-extension/plannotator.json`
144
+ - Global user override: `~/.pi/agent/plannotator.json`
145
+ - Project-local override: `<cwd>/.pi/plannotator.json`
146
+
66
147
  ### Code review
67
148
 
68
149
  Run `/plannotator-review` to open your current git changes in the code review UI. Annotate specific lines, switch between diff views (uncommitted, staged, last commit, branch), and submit feedback that gets sent to the agent.
package/config.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { homedir } from "node:os";
5
+ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
6
+
7
+ export type PhaseName = "planning" | "executing" | "reviewing";
8
+ export type RuntimePhase = PhaseName | "idle";
9
+
10
+ export interface PhaseModelRef {
11
+ provider: string;
12
+ id: string;
13
+ }
14
+
15
+ /**
16
+ * Config values loaded from JSON can intentionally clear inherited values.
17
+ *
18
+ * - `null` clears a value from a parent config.
19
+ * - `[]` clears active tools.
20
+ * - `""` clears string values.
21
+ */
22
+ export interface PhaseProfile {
23
+ model?: PhaseModelRef | null;
24
+ thinking?: ThinkingLevel | null;
25
+ activeTools?: string[] | null;
26
+ statusLabel?: string | null;
27
+ systemPrompt?: string | null;
28
+ }
29
+
30
+ export interface PlannotatorConfig {
31
+ defaults?: PhaseProfile | null;
32
+ phases?: Partial<Record<PhaseName, PhaseProfile | null>>;
33
+ }
34
+
35
+ export interface LoadedPlannotatorConfig {
36
+ config: PlannotatorConfig;
37
+ warnings: string[];
38
+ }
39
+
40
+ export interface ResolvedPhaseProfile {
41
+ model?: PhaseModelRef;
42
+ thinking?: ThinkingLevel;
43
+ activeTools?: string[];
44
+ statusLabel?: string;
45
+ systemPrompt?: string;
46
+ }
47
+
48
+ export interface PromptVariables {
49
+ planFilePath: string;
50
+ todoList: string;
51
+ completedCount: number;
52
+ totalCount: number;
53
+ remainingCount: number;
54
+ phase: RuntimePhase;
55
+ }
56
+
57
+ export interface PromptRenderResult {
58
+ text: string;
59
+ unknownVariables: string[];
60
+ }
61
+
62
+ const INTERNAL_CONFIG_PATH = join(dirname(fileURLToPath(import.meta.url)), "plannotator.json");
63
+ const PHASES: PhaseName[] = ["planning", "executing", "reviewing"];
64
+ const THINKING_LEVELS = new Set<string>(["minimal", "low", "medium", "high", "xhigh"]);
65
+
66
+ function getAgentConfigDir(): string {
67
+ const envDir = process.env.PI_CODING_AGENT_DIR;
68
+ if (envDir) return envDir;
69
+ return join(process.env.HOME || process.env.USERPROFILE || homedir(), ".pi", "agent");
70
+ }
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ function readJsonFile(path: string): { data?: unknown; error?: string } {
77
+ if (!existsSync(path)) return {};
78
+
79
+ try {
80
+ return { data: JSON.parse(readFileSync(path, "utf-8")) };
81
+ } catch (error) {
82
+ return { error: `Failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}` };
83
+ }
84
+ }
85
+
86
+ function normalizeModel(value: unknown): PhaseModelRef | null | undefined {
87
+ if (value === null) return null;
88
+ if (!isRecord(value)) return undefined;
89
+
90
+ const provider = typeof value.provider === "string" ? value.provider.trim() : "";
91
+ const id = typeof value.id === "string" ? value.id.trim() : "";
92
+ if (!provider || !id) return undefined;
93
+ return { provider, id };
94
+ }
95
+
96
+ function normalizeThinking(value: unknown): ThinkingLevel | null | undefined {
97
+ if (value === null) return null;
98
+ if (typeof value !== "string") return undefined;
99
+ const trimmed = value.trim();
100
+ if (!trimmed) return null;
101
+
102
+ return THINKING_LEVELS.has(trimmed as ThinkingLevel) ? (trimmed as ThinkingLevel) : undefined;
103
+ }
104
+
105
+ function normalizeTools(value: unknown): string[] | null | undefined {
106
+ if (value === null) return null;
107
+ if (!Array.isArray(value)) return undefined;
108
+ if (value.length === 0) return [];
109
+
110
+ const tools = value.filter((tool): tool is string => typeof tool === "string" && tool.trim().length > 0);
111
+ return tools.length > 0 ? tools : undefined;
112
+ }
113
+
114
+ function normalizeLabel(value: unknown): string | null | undefined {
115
+ if (value === null) return null;
116
+ if (typeof value !== "string") return undefined;
117
+ const trimmed = value.trim();
118
+ return trimmed.length > 0 ? trimmed : null;
119
+ }
120
+
121
+ function normalizePrompt(value: unknown): string | null | undefined {
122
+ if (value === null) return null;
123
+ if (typeof value !== "string") return undefined;
124
+ return value.length > 0 ? value : null;
125
+ }
126
+
127
+ function normalizeProfile(raw: unknown): PhaseProfile | null | undefined {
128
+ if (raw === null) return null;
129
+ if (!isRecord(raw)) return undefined;
130
+
131
+ const profile: PhaseProfile = {};
132
+
133
+ if ("model" in raw) profile.model = normalizeModel(raw.model);
134
+ if ("thinking" in raw) profile.thinking = normalizeThinking(raw.thinking);
135
+ if ("thinkingLevel" in raw && profile.thinking === undefined) profile.thinking = normalizeThinking(raw.thinkingLevel);
136
+ if ("activeTools" in raw) profile.activeTools = normalizeTools(raw.activeTools);
137
+ if ("statusLabel" in raw) profile.statusLabel = normalizeLabel(raw.statusLabel);
138
+ if ("systemPrompt" in raw) profile.systemPrompt = normalizePrompt(raw.systemPrompt);
139
+
140
+ return profile;
141
+ }
142
+
143
+ function cloneProfile(profile: PhaseProfile | null | undefined): PhaseProfile | null | undefined {
144
+ if (profile === null || profile === undefined) return profile;
145
+ return { ...profile, activeTools: profile.activeTools ? [...profile.activeTools] : profile.activeTools };
146
+ }
147
+
148
+ function mergeProfile(base: PhaseProfile | null | undefined, override: PhaseProfile | null | undefined): PhaseProfile | null | undefined {
149
+ if (override === null) return null;
150
+ if (override === undefined) return cloneProfile(base);
151
+ if (base === null || base === undefined) return cloneProfile(override);
152
+
153
+ const merged: PhaseProfile = {
154
+ model: override.model !== undefined ? override.model : base.model,
155
+ thinking: override.thinking !== undefined ? override.thinking : base.thinking,
156
+ activeTools: override.activeTools !== undefined ? override.activeTools : base.activeTools,
157
+ statusLabel: override.statusLabel !== undefined ? override.statusLabel : base.statusLabel,
158
+ systemPrompt: override.systemPrompt !== undefined ? override.systemPrompt : base.systemPrompt,
159
+ };
160
+
161
+ return merged;
162
+ }
163
+
164
+ function mergeConfig(base: PlannotatorConfig, override: PlannotatorConfig): PlannotatorConfig {
165
+ const phases: Partial<Record<PhaseName, PhaseProfile | null>> = {};
166
+ for (const phase of PHASES) {
167
+ const merged = mergeProfile(base.phases?.[phase], override.phases?.[phase]);
168
+ if (merged !== undefined) phases[phase] = merged;
169
+ }
170
+
171
+ return {
172
+ defaults: mergeProfile(base.defaults, override.defaults),
173
+ phases: Object.keys(phases).length > 0 ? phases : undefined,
174
+ };
175
+ }
176
+
177
+ function loadConfigSource(path: string): { config: PlannotatorConfig; warning?: string } {
178
+ const parsed = readJsonFile(path);
179
+ if (parsed.error) {
180
+ return { config: {}, warning: parsed.error };
181
+ }
182
+
183
+ const raw = parsed.data;
184
+ if (!isRecord(raw)) return { config: {} };
185
+
186
+ const config: PlannotatorConfig = {};
187
+ if ("defaults" in raw) config.defaults = normalizeProfile(raw.defaults);
188
+
189
+ if ("phases" in raw && isRecord(raw.phases)) {
190
+ const phases: Partial<Record<PhaseName, PhaseProfile | null>> = {};
191
+ for (const phase of PHASES) {
192
+ const normalized = normalizeProfile(raw.phases[phase]);
193
+ if (normalized !== undefined) phases[phase] = normalized;
194
+ }
195
+ if (Object.keys(phases).length > 0) config.phases = phases;
196
+ }
197
+
198
+ return { config };
199
+ }
200
+
201
+ export function loadPlannotatorConfig(cwd: string): LoadedPlannotatorConfig {
202
+ const warnings: string[] = [];
203
+
204
+ const internal = loadConfigSource(INTERNAL_CONFIG_PATH);
205
+ if (internal.warning) warnings.push(internal.warning);
206
+
207
+ const globalPath = join(getAgentConfigDir(), "plannotator.json");
208
+ const globalConfig = loadConfigSource(globalPath);
209
+ if (globalConfig.warning) warnings.push(globalConfig.warning);
210
+
211
+ const projectPath = join(cwd, ".pi", "plannotator.json");
212
+ const projectConfig = loadConfigSource(projectPath);
213
+ if (projectConfig.warning) warnings.push(projectConfig.warning);
214
+
215
+ const merged = mergeConfig(mergeConfig(internal.config, globalConfig.config), projectConfig.config);
216
+ return { config: merged, warnings };
217
+ }
218
+
219
+ export function resolvePhaseProfile(config: PlannotatorConfig, phase: PhaseName): ResolvedPhaseProfile {
220
+ const defaults = config.defaults ?? {};
221
+ const phaseConfig = config.phases?.[phase] ?? {};
222
+
223
+ return {
224
+ model: resolveModel(defaults.model, phaseConfig.model),
225
+ thinking: resolveThinking(defaults.thinking, phaseConfig.thinking),
226
+ activeTools: resolveTools(defaults.activeTools, phaseConfig.activeTools),
227
+ statusLabel: resolveString(defaults.statusLabel, phaseConfig.statusLabel),
228
+ systemPrompt: resolveString(defaults.systemPrompt, phaseConfig.systemPrompt),
229
+ };
230
+ }
231
+
232
+ function resolveModel(base: PhaseModelRef | null | undefined, override: PhaseModelRef | null | undefined): PhaseModelRef | undefined {
233
+ if (override !== undefined) {
234
+ return override ?? undefined;
235
+ }
236
+ return base ?? undefined;
237
+ }
238
+
239
+ function resolveThinking(base: ThinkingLevel | null | undefined, override: ThinkingLevel | null | undefined): ThinkingLevel | undefined {
240
+ if (override !== undefined) {
241
+ return override ?? undefined;
242
+ }
243
+ return base ?? undefined;
244
+ }
245
+
246
+ function resolveTools(base: string[] | null | undefined, override: string[] | null | undefined): string[] | undefined {
247
+ if (override !== undefined) {
248
+ if (override === null) return [];
249
+ return [...override];
250
+ }
251
+ if (base === null) return [];
252
+ return base ? [...base] : undefined;
253
+ }
254
+
255
+ function resolveString(base: string | null | undefined, override: string | null | undefined): string | undefined {
256
+ if (override !== undefined) {
257
+ if (override === null || override === "") return undefined;
258
+ return override;
259
+ }
260
+ return base ?? undefined;
261
+ }
262
+
263
+ export function buildPromptVariables(options: {
264
+ planFilePath: string;
265
+ phase: RuntimePhase;
266
+ totalCount: number;
267
+ completedCount: number;
268
+ remainingCount?: number;
269
+ todoList?: string;
270
+ }): PromptVariables {
271
+ const totalCount = options.totalCount;
272
+ const completedCount = options.completedCount;
273
+ const remainingCount = options.remainingCount ?? Math.max(totalCount - completedCount, 0);
274
+
275
+ return {
276
+ planFilePath: options.planFilePath,
277
+ todoList: options.todoList ?? "",
278
+ completedCount,
279
+ totalCount,
280
+ remainingCount,
281
+ phase: options.phase,
282
+ };
283
+ }
284
+
285
+ export function renderTemplate(template: string, vars: PromptVariables): PromptRenderResult {
286
+ const unknownVariables = new Set<string>();
287
+ const text = template.replace(/\$\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => {
288
+ if (key in vars) {
289
+ const value = vars[key as keyof PromptVariables];
290
+ return value === undefined || value === null ? "" : String(value);
291
+ }
292
+ unknownVariables.add(key);
293
+ return "";
294
+ });
295
+
296
+ return { text, unknownVariables: [...unknownVariables] };
297
+ }
298
+
299
+ export function formatTodoList(items: Array<{ step: number; text: string; completed: boolean }>): {
300
+ todoList: string;
301
+ completedCount: number;
302
+ totalCount: number;
303
+ remainingCount: number;
304
+ } {
305
+ const totalCount = items.length;
306
+ const completedCount = items.filter((item) => item.completed).length;
307
+ const remainingItems = items.filter((item) => !item.completed);
308
+ const todoList = remainingItems.length
309
+ ? remainingItems.map((item) => `- [ ] ${item.step}. ${item.text}`).join("\n")
310
+ : "";
311
+
312
+ return {
313
+ todoList,
314
+ completedCount,
315
+ totalCount,
316
+ remainingCount: remainingItems.length,
317
+ };
318
+ }
@@ -175,9 +175,20 @@ async function getUntrackedFileDiffs(
175
175
  dstPrefix = "b/",
176
176
  cwd?: string,
177
177
  ): Promise<string> {
178
+ // git ls-files scopes to the CWD subtree and returns CWD-relative paths,
179
+ // unlike git diff HEAD which always covers the full repo with root-relative
180
+ // paths. Resolve the repo root so untracked files from the entire repo are
181
+ // included and their paths match the tracked-diff output.
182
+ const toplevelResult = await runtime.runGit(
183
+ ["rev-parse", "--show-toplevel"],
184
+ { cwd },
185
+ );
186
+ const rootCwd =
187
+ toplevelResult.exitCode === 0 ? toplevelResult.stdout.trim() : cwd;
188
+
178
189
  const lsResult = await runtime.runGit(
179
190
  ["ls-files", "--others", "--exclude-standard"],
180
- { cwd },
191
+ { cwd: rootCwd },
181
192
  );
182
193
  if (lsResult.exitCode !== 0) return "";
183
194
 
@@ -200,7 +211,7 @@ async function getUntrackedFileDiffs(
200
211
  "/dev/null",
201
212
  file,
202
213
  ],
203
- { cwd },
214
+ { cwd: rootCwd },
204
215
  );
205
216
  return diffResult.stdout;
206
217
  }),