pi-plans 0.5.7 → 0.7.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.
- package/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +184 -0
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +43 -88
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global (user-level) pi-plans configuration.
|
|
3
|
+
*
|
|
4
|
+
* The reviewer role lives here as the single source of truth (v0.7.0): one
|
|
5
|
+
* file, shared across every workspace, confirmed once. The file is
|
|
6
|
+
* `~/.pi/pi-plans/config.json` — a standalone pi-plans file, NOT Pi's own
|
|
7
|
+
* `~/.pi/agent/settings.json` — and its directory can be overridden with
|
|
8
|
+
* `PI_PLANS_GLOBAL_DIR` (tests, CI, and bench containers rely on this).
|
|
9
|
+
*
|
|
10
|
+
* Workspace `.git/pi-plans/config.json` keeps a read-tolerated legacy
|
|
11
|
+
* `reviewer` block (pre-0.7.0). It is honored read-only until the first
|
|
12
|
+
* mutating state call migrates it (see `seedGlobalRoleFromLegacy`) and is
|
|
13
|
+
* stripped from the workspace config on the next write.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
export class GlobalStateError extends Error {}
|
|
21
|
+
|
|
22
|
+
export const GLOBAL_DIR_ENV = "PI_PLANS_GLOBAL_DIR";
|
|
23
|
+
|
|
24
|
+
/** Test hook so tests can pin the clock (run-id dedup etc.). Shared with
|
|
25
|
+
* state.ts via re-export so both modules stamp identical timestamps. */
|
|
26
|
+
export const testHooks: { now: () => Date } = { now: () => new Date() };
|
|
27
|
+
|
|
28
|
+
export function utcNow(): string {
|
|
29
|
+
return testHooks.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ThinkingLevelValue = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
33
|
+
|
|
34
|
+
/** Valid stored thinking levels. `null` means "default": the spawned child
|
|
35
|
+
* gets no `--thinking` flag and resolves its own default chain (per-model
|
|
36
|
+
* settings → defaultThinkingLevel → medium). */
|
|
37
|
+
export const VALID_THINKING_LEVELS = new Set<string>([
|
|
38
|
+
"off",
|
|
39
|
+
"minimal",
|
|
40
|
+
"low",
|
|
41
|
+
"medium",
|
|
42
|
+
"high",
|
|
43
|
+
"xhigh",
|
|
44
|
+
"max",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
|
|
48
|
+
|
|
49
|
+
export interface GlobalRoleConfig {
|
|
50
|
+
mode: string;
|
|
51
|
+
/** Exact `provider/model` selector. `null` = unconfirmed (delegated mode
|
|
52
|
+
* refuses to spawn until a concrete selector is confirmed; the inherit
|
|
53
|
+
* entry point was removed in v0.7.0). */
|
|
54
|
+
model_selector: string | null;
|
|
55
|
+
/** `null` = default (omit `--thinking`); "off" is an explicit level and
|
|
56
|
+
* deliberately distinct from null. */
|
|
57
|
+
thinking_level: ThinkingLevelValue | null;
|
|
58
|
+
name_prefix: string;
|
|
59
|
+
confirmed_at: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GlobalConfig {
|
|
63
|
+
schema: 1;
|
|
64
|
+
reviewer: GlobalRoleConfig;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_GLOBAL_ROLE: GlobalRoleConfig = {
|
|
68
|
+
mode: "delegated-subagent",
|
|
69
|
+
model_selector: null,
|
|
70
|
+
thinking_level: null,
|
|
71
|
+
name_prefix: "pi-plans-reviewer",
|
|
72
|
+
confirmed_at: null,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
|
|
76
|
+
schema: 1,
|
|
77
|
+
reviewer: structuredClone(DEFAULT_GLOBAL_ROLE),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Resolve the global config directory: `$PI_PLANS_GLOBAL_DIR` when set,
|
|
81
|
+
* otherwise `~/.pi/pi-plans`. */
|
|
82
|
+
export function resolveGlobalDir(): string {
|
|
83
|
+
const override = process.env[GLOBAL_DIR_ENV];
|
|
84
|
+
if (override && override.trim() !== "") return path.resolve(override);
|
|
85
|
+
return path.join(os.homedir(), ".pi", "pi-plans");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveGlobalConfigPath(): string {
|
|
89
|
+
return path.join(resolveGlobalDir(), "config.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface GlobalLoadResult {
|
|
93
|
+
config: GlobalConfig;
|
|
94
|
+
/** Load-time diagnostics (corrupt file, invalid fields). */
|
|
95
|
+
notices: string[];
|
|
96
|
+
/** True when no global file exists yet (defaults returned). */
|
|
97
|
+
fresh: boolean;
|
|
98
|
+
/** True when the file exists but was unusable (never clobbered). */
|
|
99
|
+
corrupt: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Normalize an unknown value into a GlobalRoleConfig, collecting
|
|
103
|
+
* diagnostics. Invalid fields fall back to defaults; the file is never
|
|
104
|
+
* rewritten by a read. */
|
|
105
|
+
export function normalizeGlobalRole(
|
|
106
|
+
raw: unknown,
|
|
107
|
+
notices: string[],
|
|
108
|
+
label: string,
|
|
109
|
+
): GlobalRoleConfig {
|
|
110
|
+
const role = structuredClone(DEFAULT_GLOBAL_ROLE);
|
|
111
|
+
if (raw === null || raw === undefined || typeof raw !== "object") {
|
|
112
|
+
notices.push(`${label}: missing reviewer block; using defaults`);
|
|
113
|
+
return role;
|
|
114
|
+
}
|
|
115
|
+
const record = raw as Record<string, unknown>;
|
|
116
|
+
if (typeof record.mode === "string") {
|
|
117
|
+
if (VALID_ROLE_MODES.has(record.mode)) {
|
|
118
|
+
role.mode = record.mode;
|
|
119
|
+
} else {
|
|
120
|
+
notices.push(`${label}: invalid reviewer mode ${JSON.stringify(record.mode)}; using default`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (record.model_selector === null) {
|
|
124
|
+
role.model_selector = null;
|
|
125
|
+
} else if (typeof record.model_selector === "string" && record.model_selector.trim() !== "") {
|
|
126
|
+
role.model_selector = record.model_selector;
|
|
127
|
+
} else if (record.model_selector !== undefined) {
|
|
128
|
+
notices.push(`${label}: invalid model_selector; treating as unconfirmed`);
|
|
129
|
+
}
|
|
130
|
+
if (record.thinking_level === null || record.thinking_level === undefined) {
|
|
131
|
+
role.thinking_level = null;
|
|
132
|
+
} else if (typeof record.thinking_level === "string" && VALID_THINKING_LEVELS.has(record.thinking_level)) {
|
|
133
|
+
role.thinking_level = record.thinking_level as ThinkingLevelValue;
|
|
134
|
+
} else {
|
|
135
|
+
notices.push(`${label}: invalid thinking_level ${JSON.stringify(record.thinking_level)}; using default`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof record.name_prefix === "string" && record.name_prefix.trim() !== "") {
|
|
138
|
+
role.name_prefix = record.name_prefix;
|
|
139
|
+
}
|
|
140
|
+
if (typeof record.confirmed_at === "string" && record.confirmed_at !== "") {
|
|
141
|
+
role.confirmed_at = record.confirmed_at;
|
|
142
|
+
}
|
|
143
|
+
return role;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read the global config. Never writes; a corrupt or wrong-schema file is
|
|
147
|
+
* reported and left untouched (defaults are used for this call only). */
|
|
148
|
+
export function loadGlobalConfig(): GlobalLoadResult {
|
|
149
|
+
const configPath = resolveGlobalConfigPath();
|
|
150
|
+
if (!existsSync(configPath)) {
|
|
151
|
+
return { config: structuredClone(DEFAULT_GLOBAL_CONFIG), notices: [], fresh: true, corrupt: false };
|
|
152
|
+
}
|
|
153
|
+
const notices: string[] = [];
|
|
154
|
+
let data: unknown;
|
|
155
|
+
try {
|
|
156
|
+
data = JSON.parse(readFileSync(configPath, "utf8"));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
notices.push(
|
|
159
|
+
`global config is invalid JSON (${(error as Error).message}); using defaults and leaving ${configPath} untouched`,
|
|
160
|
+
);
|
|
161
|
+
return { config: structuredClone(DEFAULT_GLOBAL_CONFIG), notices, fresh: false, corrupt: true };
|
|
162
|
+
}
|
|
163
|
+
if (data === null || typeof data !== "object" || (data as Record<string, unknown>).schema !== 1) {
|
|
164
|
+
notices.push(
|
|
165
|
+
`global config schema is unsupported; using defaults and leaving ${configPath} untouched`,
|
|
166
|
+
);
|
|
167
|
+
return { config: structuredClone(DEFAULT_GLOBAL_CONFIG), notices, fresh: false, corrupt: true };
|
|
168
|
+
}
|
|
169
|
+
const reviewer = normalizeGlobalRole((data as Record<string, unknown>).reviewer, notices, "global config");
|
|
170
|
+
return { config: { schema: 1, reviewer }, notices, fresh: false, corrupt: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Atomic write with a unique temp name (concurrent writers never collide
|
|
174
|
+
* on a shared `.tmp` path). */
|
|
175
|
+
export function writeGlobalConfig(config: GlobalConfig): void {
|
|
176
|
+
const configPath = resolveGlobalConfigPath();
|
|
177
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
178
|
+
const tmp = `${configPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
179
|
+
writeFileSync(tmp, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
180
|
+
renameSync(tmp, configPath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Gate predicate shared by every spawn site: current-session runs in the
|
|
185
|
+
* main session (no model needed); delegated-subagent requires a confirmed,
|
|
186
|
+
* CONCRETE `provider/model` selector (the inherit entry point was removed —
|
|
187
|
+
* a null selector can never pass confirmation).
|
|
188
|
+
*/
|
|
189
|
+
export function reviewerReady(role: GlobalRoleConfig): boolean {
|
|
190
|
+
if (role.mode === "current-session") return true;
|
|
191
|
+
return role.confirmed_at !== null && role.model_selector !== null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface SetGlobalRoleOptions {
|
|
195
|
+
mode?: string;
|
|
196
|
+
/** Exact "provider/model" selector; "inherit" resets BOTH the selector
|
|
197
|
+
* and the confirmation (a pure reset, never a confirmable state). */
|
|
198
|
+
modelSelector?: string;
|
|
199
|
+
/** Level from VALID_THINKING_LEVELS, or "default" for null (omit
|
|
200
|
+
* --thinking). Omitted = keep, except when modelSelector changes
|
|
201
|
+
* concurrently, which resets the level. */
|
|
202
|
+
thinkingLevel?: string;
|
|
203
|
+
confirmed?: boolean;
|
|
204
|
+
resetConfirmation?: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Apply set-role options to a role snapshot. Throws GlobalStateError on
|
|
208
|
+
* invalid combinations BEFORE any mutation lands on disk. */
|
|
209
|
+
export function applyGlobalRoleOptions(base: GlobalRoleConfig, options: SetGlobalRoleOptions): GlobalRoleConfig {
|
|
210
|
+
const role = { ...base };
|
|
211
|
+
if (options.mode !== undefined && !VALID_ROLE_MODES.has(options.mode)) {
|
|
212
|
+
throw new GlobalStateError(`mode must be one of ${[...VALID_ROLE_MODES].sort().join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
if (options.confirmed && options.resetConfirmation) {
|
|
215
|
+
throw new GlobalStateError("confirmed and resetConfirmation are mutually exclusive");
|
|
216
|
+
}
|
|
217
|
+
if (options.thinkingLevel !== undefined) {
|
|
218
|
+
if (options.thinkingLevel === "default") {
|
|
219
|
+
role.thinking_level = null;
|
|
220
|
+
} else if (!VALID_THINKING_LEVELS.has(options.thinkingLevel)) {
|
|
221
|
+
throw new GlobalStateError(
|
|
222
|
+
`thinkingLevel must be one of ${[...VALID_THINKING_LEVELS].join(", ")}, or "default"`,
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
role.thinking_level = options.thinkingLevel as ThinkingLevelValue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (options.modelSelector !== undefined) {
|
|
229
|
+
if (options.modelSelector === "inherit") {
|
|
230
|
+
if (options.confirmed) {
|
|
231
|
+
throw new GlobalStateError(
|
|
232
|
+
"'inherit' is a reset and cannot be combined with confirmed; confirm an exact provider/model selector instead",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
role.model_selector = null;
|
|
236
|
+
role.confirmed_at = null;
|
|
237
|
+
} else {
|
|
238
|
+
role.model_selector = options.modelSelector;
|
|
239
|
+
// Switching models invalidates a stored level (the level domain is
|
|
240
|
+
// per-model); callers that keep the level pass it explicitly.
|
|
241
|
+
if (options.thinkingLevel === undefined) role.thinking_level = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (options.mode !== undefined) role.mode = options.mode;
|
|
245
|
+
if (options.confirmed) {
|
|
246
|
+
if (role.mode === "delegated-subagent" && role.model_selector === null) {
|
|
247
|
+
throw new GlobalStateError(
|
|
248
|
+
"confirmed requires an exact provider/model selector for delegated-subagent; use the first-use model panel or pass modelSelector",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
role.confirmed_at = utcNow();
|
|
252
|
+
}
|
|
253
|
+
if (options.resetConfirmation) role.confirmed_at = null;
|
|
254
|
+
return role;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface SetGlobalRoleResult {
|
|
258
|
+
global: GlobalConfig;
|
|
259
|
+
/** Load-time diagnostics merged from the initial load. */
|
|
260
|
+
notices: string[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Load → apply → write the global reviewer role. Global writes never touch
|
|
264
|
+
* workspace state (no git-init side effects, F-013). */
|
|
265
|
+
export function setGlobalRole(options: SetGlobalRoleOptions): SetGlobalRoleResult {
|
|
266
|
+
const loaded = loadGlobalConfig();
|
|
267
|
+
const reviewer = applyGlobalRoleOptions(loaded.config.reviewer, options);
|
|
268
|
+
const config: GlobalConfig = { schema: 1, reviewer };
|
|
269
|
+
writeGlobalConfig(config);
|
|
270
|
+
return { global: config, notices: loaded.notices };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** True when a legacy workspace reviewer block carries real user intent
|
|
274
|
+
* (anything but the scaffold defaults). Scaffolded blocks are dropped
|
|
275
|
+
* silently; intent blocks seed the global file exactly once (the first
|
|
276
|
+
* touched workspace wins, Q-1=A). */
|
|
277
|
+
export function legacyReviewerHasUserIntent(raw: unknown): boolean {
|
|
278
|
+
if (raw === null || raw === undefined || typeof raw !== "object") return false;
|
|
279
|
+
const record = raw as Record<string, unknown>;
|
|
280
|
+
if (record.confirmed_at !== null && record.confirmed_at !== undefined) return true;
|
|
281
|
+
if (typeof record.model_selector === "string" && record.model_selector.trim() !== "") return true;
|
|
282
|
+
if (typeof record.mode === "string" && record.mode !== "delegated-subagent") return true;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Seed a global role from a legacy workspace reviewer block (v0.6.x). A
|
|
287
|
+
* confirmed-inherit block (model_selector null) loses its confirmation —
|
|
288
|
+
* under v0.7.0 semantics a delegated reviewer needs a concrete model, so
|
|
289
|
+
* the first refine re-asks once with the native panel (decision 8). */
|
|
290
|
+
export function seedGlobalRoleFromLegacy(raw: unknown): { role: GlobalRoleConfig; notices: string[] } {
|
|
291
|
+
const notices: string[] = [];
|
|
292
|
+
const role = normalizeGlobalRole(raw, notices, "legacy workspace reviewer");
|
|
293
|
+
role.thinking_level = null; // pre-0.7.0 blocks never carried a level
|
|
294
|
+
if (role.model_selector === null) role.confirmed_at = null;
|
|
295
|
+
return { role, notices };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function globalMigratedNotice(globalPath: string): string {
|
|
299
|
+
return `migrated the reviewer role from workspace config.json to the global config (${globalPath}); the workspace reviewer key is removed on the next config write`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function globalIgnoredNotice(globalPath: string): string {
|
|
303
|
+
return `workspace config.json still carries a reviewer block; the global config (${globalPath}) is the source of truth — the workspace block is ignored and will be removed on the next config write`;
|
|
304
|
+
}
|
package/src/guard.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Planning write guard: while a planning run is active (status planning or
|
|
3
3
|
* accepted) and execution has not been approved, edit/write may only target
|
|
4
4
|
* planning artifacts — the pi-plans state dir, the active run's artifact
|
|
5
|
-
* directory, and the pi-plans reference cache.
|
|
5
|
+
* directory, and the pi-plans reference cache. Other runs' artifact
|
|
6
|
+
* directories are denied outright, including when they sit inside the state
|
|
7
|
+
* root (the default artifact root does).
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import * as os from "node:os";
|
|
9
11
|
import * as path from "node:path";
|
|
10
|
-
import { getRun, loadConfig, readActive, resolveStateRootOrNull } from "./state.ts";
|
|
11
12
|
import { activeInfoById } from "./run-context.ts";
|
|
13
|
+
import { getRun, listRuns, loadConfig, readActive, resolveStateRootOrNull } from "./state.ts";
|
|
12
14
|
|
|
13
15
|
const GUARDED_TOOLS = new Set(["write", "edit"]);
|
|
14
16
|
const GUARDED_STATUSES = new Set(["planning", "accepted"]);
|
|
@@ -51,7 +53,18 @@ export function planningWriteBlockReason(input: GuardInput): string | null {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
const allowed = allowedRoots.some((root) => target === root || target.startsWith(`${root}${path.sep}`));
|
|
56
|
+
|
|
57
|
+
// The artifact root defaults inside the state root, so the blanket stateRoot
|
|
58
|
+
// allowance above also covers every other run's plans. Deny those explicitly
|
|
59
|
+
// before the allowed check: a run's artifacts stay read-only for the session
|
|
60
|
+
// that is not bound to that run, wherever the artifact root lives.
|
|
61
|
+
const otherRunArtifactDirs = listRuns(input.workdir)
|
|
62
|
+
.filter((run) => run.run_id !== active.run_id)
|
|
63
|
+
.map((run) => path.resolve(input.workdir, run.artifact_dir));
|
|
64
|
+
if (otherRunArtifactDirs.some((dir) => target === dir || target.startsWith(`${dir}${path.sep}`))) {
|
|
65
|
+
return `pi-plans: planning artifacts of other runs are read-only while "${active.run_id}" is active. Bind the run you mean via /resume-plans, or pick the run explicitly.`;
|
|
66
|
+
}
|
|
54
67
|
if (allowed) return null;
|
|
55
68
|
|
|
56
|
-
return `pi-plans: active planning run "${active.run_id}" is read-only outside planning artifacts. Allowed write roots: ${allowedRoots.join(", ")}. Finish planning and get execution approval (execute_plan tool or /plans-execute), or abandon the run (/plans-abandon).`;
|
|
69
|
+
return `pi-plans: active planning run "${active.run_id}" is read-only outside planning artifacts (this session is not bound to it; if you are operating on a different run, bind it via /resume-plans or pick the run explicitly). Allowed write roots: ${allowedRoots.join(", ")}. Finish planning and get execution approval (execute_plan tool or /plans-execute), or abandon the run (/plans-abandon).`;
|
|
57
70
|
}
|
package/src/messaging.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Messaging surface for pi-plans: the subset of ExtensionAPI that tools,
|
|
5
|
+
* commands, and event handlers need for session writes (appendEntry /
|
|
6
|
+
* sendMessage / sendUserMessage).
|
|
7
|
+
*
|
|
8
|
+
* SDK facts (agent-session.js / extensions/types.d.ts, pi 0.99.0):
|
|
9
|
+
* - These methods exist ONLY on the ExtensionAPI ("pi") handed to the
|
|
10
|
+
* extension factory. The per-call ExtensionContext does NOT carry them.
|
|
11
|
+
* - Session replacement (newSession/fork/switchSession) disposes the old
|
|
12
|
+
* session and constructs a new one whose extension runner RE-RUNS every
|
|
13
|
+
* extension factory; reload() does the same. Therefore the correct
|
|
14
|
+
* staleness-safe pattern is to refresh this module-level reference at the
|
|
15
|
+
* TOP of the factory on every run — never hold a ctx/pi captured anywhere
|
|
16
|
+
* else across turns.
|
|
17
|
+
*
|
|
18
|
+
* Invariant (verified for pi 0.99.x hosts): ONE live session per process.
|
|
19
|
+
* Teardown strictly precedes rebuild (agent-session-runtime teardownCurrent
|
|
20
|
+
* → createRuntime), and pi-plans subagents run as separate processes, so the
|
|
21
|
+
* last factory to run always belongs to the only live session. A future
|
|
22
|
+
* same-process multi-session host sharing the extension cache would break
|
|
23
|
+
* this assumption (last factory wins); that would need per-session keying.
|
|
24
|
+
*/
|
|
25
|
+
export type Messaging = Pick<ExtensionAPI, "appendEntry" | "sendMessage" | "sendUserMessage">;
|
|
26
|
+
|
|
27
|
+
let messagingApi: Messaging | null = null;
|
|
28
|
+
|
|
29
|
+
/** Called at the top of the extension factory on every (re)load and session construction. */
|
|
30
|
+
export function setMessagingApi(api: Messaging | null): void {
|
|
31
|
+
messagingApi = api;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Current-session messaging surface. Throws if used before the extension factory ran. */
|
|
35
|
+
export function messaging(): Messaging {
|
|
36
|
+
if (!messagingApi) throw new Error("pi-plans messaging used before extension initialization");
|
|
37
|
+
return messagingApi;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Best-effort variant for optional writes (e.g. auto-complete state entries):
|
|
41
|
+
* returns null when the factory has not run; callers swallow transient errors. */
|
|
42
|
+
export function tryMessaging(): Messaging | null {
|
|
43
|
+
return messagingApi;
|
|
44
|
+
}
|