@tryinget/pi-modes 0.1.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/LICENSE +78 -0
- package/README.md +176 -0
- package/docs/project/2026-07-11-implementation-plan.md +61 -0
- package/docs/project/2026-07-11-prompt-mode-architecture.md +101 -0
- package/examples/modes/exact-minimal.json +8 -0
- package/examples/modes/focused-builder.json +8 -0
- package/examples/modes/hard-nosed-review.json +8 -0
- package/extensions/mode.ts +409 -0
- package/package.json +88 -0
- package/policy/engineering-lane.json +34 -0
- package/policy/security-policy.json +10 -0
- package/prompts/mode-authoring.md +23 -0
- package/src/modes.ts +371 -0
- package/tests/modes.test.ts +333 -0
package/src/modes.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
12
|
+
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
export const MODE_SCHEMA_VERSION = 1 as const;
|
|
15
|
+
export const MODE_STATE_TYPE = "pi-mode-state.v1";
|
|
16
|
+
export type PromptStrategy = "append" | "replace_base" | "replace_final";
|
|
17
|
+
export type ModeScope = "builtin" | "global" | "project";
|
|
18
|
+
|
|
19
|
+
export interface ModeDefinition {
|
|
20
|
+
schemaVersion: typeof MODE_SCHEMA_VERSION;
|
|
21
|
+
key: string;
|
|
22
|
+
label: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
promptStrategy: PromptStrategy;
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolvedMode extends ModeDefinition {
|
|
29
|
+
scope: ModeScope;
|
|
30
|
+
path?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ModeDiagnostic {
|
|
34
|
+
path: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LoadedModes {
|
|
39
|
+
modes: ResolvedMode[];
|
|
40
|
+
diagnostics: ModeDiagnostic[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModeState {
|
|
44
|
+
key: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StartupModeSelection {
|
|
48
|
+
configured: boolean;
|
|
49
|
+
key: string | null;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InitialModeResolution {
|
|
54
|
+
source: "environment" | "session";
|
|
55
|
+
key: string | null;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const BUILTIN_MODES: ModeDefinition[] = [
|
|
60
|
+
{
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
key: "plan",
|
|
63
|
+
label: "Plan",
|
|
64
|
+
description: "Plan carefully without changing files until asked.",
|
|
65
|
+
promptStrategy: "append",
|
|
66
|
+
systemPrompt:
|
|
67
|
+
"Make a concise implementation plan before changing files. Do not edit files unless the user asks you to proceed.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
key: "review",
|
|
72
|
+
label: "Review",
|
|
73
|
+
description: "Prioritize correctness, risks, and missing verification.",
|
|
74
|
+
promptStrategy: "append",
|
|
75
|
+
systemPrompt:
|
|
76
|
+
"Review the current work for correctness, risks, regressions, and missing tests before proposing changes.",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
key: "explain",
|
|
81
|
+
label: "Explain",
|
|
82
|
+
description: "Explain code and decisions before proposing changes.",
|
|
83
|
+
promptStrategy: "append",
|
|
84
|
+
systemPrompt: "Explain the relevant code and decisions clearly before proposing changes.",
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export function isValidModeKey(value: string): boolean {
|
|
89
|
+
return /^[a-z][a-z0-9_-]{0,63}$/.test(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Mirror Pi's ancestor discovery order: filesystem root to the active cwd. */
|
|
93
|
+
export function ancestorModeDirectories(cwd: string, configDirName = ".pi"): string[] {
|
|
94
|
+
const directories: string[] = [];
|
|
95
|
+
let current = resolve(cwd);
|
|
96
|
+
while (true) {
|
|
97
|
+
directories.push(join(current, configDirName, "modes"));
|
|
98
|
+
const parent = dirname(current);
|
|
99
|
+
if (parent === current) break;
|
|
100
|
+
current = parent;
|
|
101
|
+
}
|
|
102
|
+
return directories.reverse();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve the explicit launch-time mode selector without reading arbitrary environment values. */
|
|
106
|
+
export function startupModeFromEnvironment(value: string | undefined): StartupModeSelection {
|
|
107
|
+
if (value === undefined || value.trim() === "") return { configured: false, key: null };
|
|
108
|
+
const key = value.trim().toLowerCase();
|
|
109
|
+
if (key === "off" || key === "default" || key === "none") {
|
|
110
|
+
return { configured: true, key: null };
|
|
111
|
+
}
|
|
112
|
+
if (!isValidModeKey(key)) {
|
|
113
|
+
return {
|
|
114
|
+
configured: true,
|
|
115
|
+
key: null,
|
|
116
|
+
error: "PI_MODE must name a valid mode key or use off",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { configured: true, key };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveInitialModeSelection(options: {
|
|
123
|
+
applyEnvironment: boolean;
|
|
124
|
+
environmentValue: string | undefined;
|
|
125
|
+
sessionKey: string | null;
|
|
126
|
+
availableKeys: readonly string[];
|
|
127
|
+
}): InitialModeResolution {
|
|
128
|
+
if (!options.applyEnvironment) return { source: "session", key: options.sessionKey };
|
|
129
|
+
const startup = startupModeFromEnvironment(options.environmentValue);
|
|
130
|
+
if (!startup.configured) return { source: "session", key: options.sessionKey };
|
|
131
|
+
if (startup.error) return { source: "environment", key: null, error: startup.error };
|
|
132
|
+
if (startup.key && !options.availableKeys.includes(startup.key)) {
|
|
133
|
+
return {
|
|
134
|
+
source: "environment",
|
|
135
|
+
key: null,
|
|
136
|
+
error: `PI_MODE names an unavailable mode: ${startup.key}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { source: "environment", key: startup.key };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseModeDefinition(raw: unknown): ModeDefinition {
|
|
143
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
144
|
+
throw new Error("mode must be a JSON object");
|
|
145
|
+
}
|
|
146
|
+
const value = raw as Record<string, unknown>;
|
|
147
|
+
const schemaVersion = value.schemaVersion ?? MODE_SCHEMA_VERSION;
|
|
148
|
+
if (schemaVersion !== MODE_SCHEMA_VERSION) {
|
|
149
|
+
throw new Error(`unsupported schemaVersion: ${String(schemaVersion)}`);
|
|
150
|
+
}
|
|
151
|
+
const key = typeof value.key === "string" ? value.key.trim().toLowerCase() : "";
|
|
152
|
+
if (!isValidModeKey(key)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"key must start with a letter and contain only lowercase letters, digits, _ or - (max 64 characters)",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const label = typeof value.label === "string" ? value.label.trim() : "";
|
|
158
|
+
if (!label) throw new Error("label is required");
|
|
159
|
+
const description = typeof value.description === "string" ? value.description.trim() : "";
|
|
160
|
+
const systemPrompt = typeof value.systemPrompt === "string" ? value.systemPrompt.trim() : "";
|
|
161
|
+
if (!systemPrompt) throw new Error("systemPrompt is required");
|
|
162
|
+
const promptStrategy = value.promptStrategy ?? "replace_base";
|
|
163
|
+
if (!isPromptStrategy(promptStrategy)) {
|
|
164
|
+
throw new Error("promptStrategy must be append, replace_base, or replace_final");
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
schemaVersion: MODE_SCHEMA_VERSION,
|
|
168
|
+
key,
|
|
169
|
+
label,
|
|
170
|
+
...(description ? { description } : {}),
|
|
171
|
+
promptStrategy,
|
|
172
|
+
systemPrompt,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isPromptStrategy(value: unknown): value is PromptStrategy {
|
|
177
|
+
return value === "append" || value === "replace_base" || value === "replace_final";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function findSymbolicLinkBoundary(path: string): string | undefined {
|
|
181
|
+
let current = resolve(path);
|
|
182
|
+
while (true) {
|
|
183
|
+
try {
|
|
184
|
+
if (lstatSync(current).isSymbolicLink()) return current;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
187
|
+
if (code !== "ENOENT") throw error;
|
|
188
|
+
}
|
|
189
|
+
const parent = dirname(current);
|
|
190
|
+
if (parent === current) return undefined;
|
|
191
|
+
current = parent;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadModeDirectory(dir: string, scope: Exclude<ModeScope, "builtin">): LoadedModes {
|
|
196
|
+
const modes: ResolvedMode[] = [];
|
|
197
|
+
const diagnostics: ModeDiagnostic[] = [];
|
|
198
|
+
const symbolicLinkBoundary = findSymbolicLinkBoundary(dir);
|
|
199
|
+
if (symbolicLinkBoundary) {
|
|
200
|
+
return {
|
|
201
|
+
modes,
|
|
202
|
+
diagnostics: [
|
|
203
|
+
{
|
|
204
|
+
path: dir,
|
|
205
|
+
message: `mode path crosses symbolic-link boundary: ${symbolicLinkBoundary}`,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!existsSync(dir)) return { modes, diagnostics };
|
|
211
|
+
for (const file of readdirSync(dir)
|
|
212
|
+
.filter((name) => name.endsWith(".json"))
|
|
213
|
+
.sort()) {
|
|
214
|
+
const path = join(dir, file);
|
|
215
|
+
try {
|
|
216
|
+
if (lstatSync(path).isSymbolicLink())
|
|
217
|
+
throw new Error("mode file must not be a symbolic link");
|
|
218
|
+
const mode = parseModeDefinition(JSON.parse(readFileSync(path, "utf8")));
|
|
219
|
+
modes.push({ ...mode, scope, path });
|
|
220
|
+
} catch (error) {
|
|
221
|
+
diagnostics.push({ path, message: error instanceof Error ? error.message : String(error) });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { modes, diagnostics };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function loadModes(options: {
|
|
228
|
+
globalDir: string;
|
|
229
|
+
projectDir?: string;
|
|
230
|
+
projectDirs?: readonly string[];
|
|
231
|
+
projectTrusted: boolean;
|
|
232
|
+
}): LoadedModes {
|
|
233
|
+
const byKey = new Map<string, ResolvedMode>(
|
|
234
|
+
BUILTIN_MODES.map((mode) => [mode.key, { ...mode, scope: "builtin" }]),
|
|
235
|
+
);
|
|
236
|
+
const diagnostics: ModeDiagnostic[] = [];
|
|
237
|
+
const global = loadModeDirectory(options.globalDir, "global");
|
|
238
|
+
diagnostics.push(...global.diagnostics);
|
|
239
|
+
for (const mode of global.modes) byKey.set(mode.key, mode);
|
|
240
|
+
if (options.projectTrusted) {
|
|
241
|
+
const projectDirs = options.projectDirs ?? (options.projectDir ? [options.projectDir] : []);
|
|
242
|
+
for (const projectDir of projectDirs) {
|
|
243
|
+
const project = loadModeDirectory(projectDir, "project");
|
|
244
|
+
diagnostics.push(...project.diagnostics);
|
|
245
|
+
for (const mode of project.modes) byKey.set(mode.key, mode);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
modes: [...byKey.values()].sort((left, right) => left.key.localeCompare(right.key)),
|
|
250
|
+
diagnostics,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function modePath(dir: string, key: string): string {
|
|
255
|
+
if (!isValidModeKey(key)) throw new Error("invalid mode key");
|
|
256
|
+
const base = resolve(dir);
|
|
257
|
+
const target = resolve(base, `${key}.json`);
|
|
258
|
+
if (target !== base && !target.startsWith(`${base}${sep}`))
|
|
259
|
+
throw new Error("mode path escapes mode directory");
|
|
260
|
+
return target;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function saveMode(dir: string, mode: ModeDefinition): string {
|
|
264
|
+
const normalized = parseModeDefinition(mode);
|
|
265
|
+
const target = modePath(dir, normalized.key);
|
|
266
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
267
|
+
const symbolicLinkBoundary = findSymbolicLinkBoundary(dirname(target));
|
|
268
|
+
if (symbolicLinkBoundary) {
|
|
269
|
+
throw new Error(`mode path crosses symbolic-link boundary: ${symbolicLinkBoundary}`);
|
|
270
|
+
}
|
|
271
|
+
if (existsSync(target) && lstatSync(target).isSymbolicLink())
|
|
272
|
+
throw new Error("mode file must not be a symbolic link");
|
|
273
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
274
|
+
writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
mode: 0o600,
|
|
277
|
+
});
|
|
278
|
+
renameSync(temporary, target);
|
|
279
|
+
return target;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function deleteMode(path: string, expectedDir: string): void {
|
|
283
|
+
const base = resolve(expectedDir);
|
|
284
|
+
const target = resolve(path);
|
|
285
|
+
if (!target.startsWith(`${base}${sep}`) || !target.endsWith(".json")) {
|
|
286
|
+
throw new Error("refusing to delete outside the selected mode directory");
|
|
287
|
+
}
|
|
288
|
+
const symbolicLinkBoundary = findSymbolicLinkBoundary(target);
|
|
289
|
+
if (symbolicLinkBoundary) {
|
|
290
|
+
throw new Error(`mode path crosses symbolic-link boundary: ${symbolicLinkBoundary}`);
|
|
291
|
+
}
|
|
292
|
+
rmSync(target);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function composeModePrompt(
|
|
296
|
+
mode: ModeDefinition,
|
|
297
|
+
options: BuildSystemPromptOptions,
|
|
298
|
+
assembledPrompt: string,
|
|
299
|
+
): string {
|
|
300
|
+
if (mode.promptStrategy === "append") {
|
|
301
|
+
return `${assembledPrompt}\n\n# Active prompt mode: ${mode.label}\n${mode.systemPrompt}`;
|
|
302
|
+
}
|
|
303
|
+
if (mode.promptStrategy === "replace_final") return mode.systemPrompt;
|
|
304
|
+
return buildCustomBasePrompt(mode.systemPrompt, options);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Mirrors Pi's documented custom-base branch: custom base + append + context + skills + date/cwd. */
|
|
308
|
+
export function buildCustomBasePrompt(
|
|
309
|
+
customPrompt: string,
|
|
310
|
+
options: BuildSystemPromptOptions,
|
|
311
|
+
now = new Date(),
|
|
312
|
+
): string {
|
|
313
|
+
let prompt = customPrompt;
|
|
314
|
+
if (options.appendSystemPrompt) prompt += `\n\n${options.appendSystemPrompt}`;
|
|
315
|
+
if (options.contextFiles && options.contextFiles.length > 0) {
|
|
316
|
+
prompt += "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n";
|
|
317
|
+
for (const file of options.contextFiles) {
|
|
318
|
+
prompt += `<project_instructions path="${file.path}">\n${file.content}\n</project_instructions>\n\n`;
|
|
319
|
+
}
|
|
320
|
+
prompt += "</project_context>\n";
|
|
321
|
+
}
|
|
322
|
+
const readAvailable = !options.selectedTools || options.selectedTools.includes("read");
|
|
323
|
+
const visibleSkills = readAvailable
|
|
324
|
+
? (options.skills ?? []).filter((skill) => !skill.disableModelInvocation)
|
|
325
|
+
: [];
|
|
326
|
+
if (visibleSkills.length > 0) prompt += formatSkills(visibleSkills);
|
|
327
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
328
|
+
prompt += `\nCurrent date: ${date}`;
|
|
329
|
+
prompt += `\nCurrent working directory: ${options.cwd.replace(/\\/g, "/")}`;
|
|
330
|
+
return prompt;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatSkills(skills: NonNullable<BuildSystemPromptOptions["skills"]>): string {
|
|
334
|
+
const lines = [
|
|
335
|
+
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
|
336
|
+
"Use the read tool to load a skill's file when the task matches its description.",
|
|
337
|
+
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
|
338
|
+
"",
|
|
339
|
+
"<available_skills>",
|
|
340
|
+
];
|
|
341
|
+
for (const skill of skills) {
|
|
342
|
+
lines.push(" <skill>");
|
|
343
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
344
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
345
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
|
346
|
+
lines.push(" </skill>");
|
|
347
|
+
}
|
|
348
|
+
lines.push("</available_skills>");
|
|
349
|
+
return lines.join("\n");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escapeXml(value: string): string {
|
|
353
|
+
return value
|
|
354
|
+
.replace(/&/g, "&")
|
|
355
|
+
.replace(/</g, "<")
|
|
356
|
+
.replace(/>/g, ">")
|
|
357
|
+
.replace(/"/g, """)
|
|
358
|
+
.replace(/'/g, "'");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function selectedModeFromEntries(entries: readonly unknown[]): ModeState {
|
|
362
|
+
let key: string | null = null;
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (!entry || typeof entry !== "object") continue;
|
|
365
|
+
const candidate = entry as { type?: string; customType?: string; data?: { key?: unknown } };
|
|
366
|
+
if (candidate.type !== "custom" || candidate.customType !== MODE_STATE_TYPE) continue;
|
|
367
|
+
if (candidate.data?.key === null || typeof candidate.data?.key === "string")
|
|
368
|
+
key = candidate.data.key;
|
|
369
|
+
}
|
|
370
|
+
return { key };
|
|
371
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { buildSystemPrompt as buildHostSystemPrompt } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/system-prompt.js";
|
|
8
|
+
import {
|
|
9
|
+
ancestorModeDirectories,
|
|
10
|
+
buildCustomBasePrompt,
|
|
11
|
+
composeModePrompt,
|
|
12
|
+
deleteMode,
|
|
13
|
+
loadModes,
|
|
14
|
+
MODE_STATE_TYPE,
|
|
15
|
+
modePath,
|
|
16
|
+
parseModeDefinition,
|
|
17
|
+
resolveInitialModeSelection,
|
|
18
|
+
saveMode,
|
|
19
|
+
selectedModeFromEntries,
|
|
20
|
+
startupModeFromEnvironment,
|
|
21
|
+
} from "../src/modes.ts";
|
|
22
|
+
|
|
23
|
+
const appendMode = parseModeDefinition({
|
|
24
|
+
key: "review",
|
|
25
|
+
label: "Review",
|
|
26
|
+
promptStrategy: "append",
|
|
27
|
+
systemPrompt: "Review carefully.",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const promptOptions: BuildSystemPromptOptions = {
|
|
31
|
+
cwd: "/workspace/demo",
|
|
32
|
+
selectedTools: ["read", "bash"],
|
|
33
|
+
appendSystemPrompt: "Operator appendix",
|
|
34
|
+
contextFiles: [{ path: "/workspace/AGENTS.md", content: "Project policy" }],
|
|
35
|
+
skills: [
|
|
36
|
+
{
|
|
37
|
+
name: "example-skill",
|
|
38
|
+
description: "Use for examples",
|
|
39
|
+
filePath: "/skills/example/SKILL.md",
|
|
40
|
+
baseDir: "/skills/example",
|
|
41
|
+
sourceInfo: {
|
|
42
|
+
path: "/skills/example/SKILL.md",
|
|
43
|
+
source: "test",
|
|
44
|
+
scope: "temporary",
|
|
45
|
+
origin: "top-level",
|
|
46
|
+
baseDir: "/skills/example",
|
|
47
|
+
},
|
|
48
|
+
disableModelInvocation: false,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test("append preserves the assembled host prompt", () => {
|
|
54
|
+
const result = composeModePrompt(appendMode, promptOptions, "HOST PROMPT");
|
|
55
|
+
assert.match(result, /^HOST PROMPT/);
|
|
56
|
+
assert.match(result, /Active prompt mode: Review/);
|
|
57
|
+
assert.match(result, /Review carefully/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("replace_base mirrors Pi custom-base composition", () => {
|
|
61
|
+
const result = buildCustomBasePrompt("CUSTOM BASE", promptOptions, new Date(2026, 6, 11));
|
|
62
|
+
assert.match(result, /^CUSTOM BASE/);
|
|
63
|
+
assert.match(result, /Operator appendix/);
|
|
64
|
+
assert.match(result, /<project_context>/);
|
|
65
|
+
assert.match(result, /Project policy/);
|
|
66
|
+
assert.match(result, /<name>example-skill<\/name>/);
|
|
67
|
+
assert.match(result, /Current date: 2026-07-11/);
|
|
68
|
+
assert.match(result, /Current working directory: \/workspace\/demo$/);
|
|
69
|
+
assert.doesNotMatch(result, /HOST PROMPT/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("replace_base has complete-output parity with the pinned Pi host builder", () => {
|
|
73
|
+
const customPrompt = "CUSTOM BASE\nwith deliberate spacing";
|
|
74
|
+
const expected = buildHostSystemPrompt({ ...promptOptions, customPrompt });
|
|
75
|
+
const actual = buildCustomBasePrompt(customPrompt, promptOptions);
|
|
76
|
+
assert.equal(actual, expected);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("replace_final returns the exact configured prompt", () => {
|
|
80
|
+
const mode = parseModeDefinition({
|
|
81
|
+
key: "raw",
|
|
82
|
+
label: "Raw",
|
|
83
|
+
promptStrategy: "replace_final",
|
|
84
|
+
systemPrompt: "EXACT FINAL PROMPT",
|
|
85
|
+
});
|
|
86
|
+
assert.equal(composeModePrompt(mode, promptOptions, "HOST PROMPT"), "EXACT FINAL PROMPT");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("replace_base omits skills when read is inactive", () => {
|
|
90
|
+
const result = buildCustomBasePrompt(
|
|
91
|
+
"BASE",
|
|
92
|
+
{ ...promptOptions, selectedTools: ["bash"] },
|
|
93
|
+
new Date(2026, 6, 11),
|
|
94
|
+
);
|
|
95
|
+
assert.doesNotMatch(result, /example-skill/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("mode loading is per-file fault tolerant and project overrides global", () => {
|
|
99
|
+
const root = mkdtempSync(join(tmpdir(), "pi-modes-test-"));
|
|
100
|
+
const globalDir = join(root, "global");
|
|
101
|
+
const projectDir = join(root, "project");
|
|
102
|
+
mkdirSync(globalDir);
|
|
103
|
+
mkdirSync(projectDir);
|
|
104
|
+
writeFileSync(
|
|
105
|
+
join(globalDir, "shared.json"),
|
|
106
|
+
JSON.stringify({ key: "shared", label: "Global", systemPrompt: "global" }),
|
|
107
|
+
);
|
|
108
|
+
writeFileSync(join(globalDir, "broken.json"), "{");
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(projectDir, "shared.json"),
|
|
111
|
+
JSON.stringify({ key: "shared", label: "Project", systemPrompt: "project" }),
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
const loaded = loadModes({ globalDir, projectDir, projectTrusted: true });
|
|
115
|
+
assert.equal(loaded.modes.find((mode) => mode.key === "shared")?.label, "Project");
|
|
116
|
+
assert.equal(loaded.diagnostics.length, 1);
|
|
117
|
+
assert.match(loaded.diagnostics[0]?.path ?? "", /broken\.json$/);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(root, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("ancestor mode discovery mirrors root-to-cwd AGENTS layering", () => {
|
|
124
|
+
const root = mkdtempSync(join(tmpdir(), "pi-modes-test-"));
|
|
125
|
+
const company = join(root, "softwareco");
|
|
126
|
+
const repo = join(company, "owned", "demo");
|
|
127
|
+
const companyModes = join(company, ".pi", "modes");
|
|
128
|
+
const repoModes = join(repo, ".pi", "modes");
|
|
129
|
+
const globalDir = join(root, "global");
|
|
130
|
+
mkdirSync(companyModes, { recursive: true });
|
|
131
|
+
mkdirSync(repoModes, { recursive: true });
|
|
132
|
+
mkdirSync(globalDir);
|
|
133
|
+
writeFileSync(
|
|
134
|
+
join(companyModes, "shared.json"),
|
|
135
|
+
JSON.stringify({ key: "shared", label: "Company", systemPrompt: "company" }),
|
|
136
|
+
);
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(companyModes, "company-only.json"),
|
|
139
|
+
JSON.stringify({ key: "company-only", label: "Company Only", systemPrompt: "company" }),
|
|
140
|
+
);
|
|
141
|
+
writeFileSync(
|
|
142
|
+
join(repoModes, "shared.json"),
|
|
143
|
+
JSON.stringify({ key: "shared", label: "Repo", systemPrompt: "repo" }),
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
const projectDirs = ancestorModeDirectories(repo);
|
|
147
|
+
assert.deepEqual(projectDirs.slice(-4), [
|
|
148
|
+
join(root, ".pi", "modes"),
|
|
149
|
+
join(company, ".pi", "modes"),
|
|
150
|
+
join(company, "owned", ".pi", "modes"),
|
|
151
|
+
repoModes,
|
|
152
|
+
]);
|
|
153
|
+
const loaded = loadModes({ globalDir, projectDirs, projectTrusted: true });
|
|
154
|
+
assert.equal(loaded.modes.find((mode) => mode.key === "shared")?.label, "Repo");
|
|
155
|
+
assert.equal(loaded.modes.find((mode) => mode.key === "company-only")?.label, "Company Only");
|
|
156
|
+
|
|
157
|
+
const untrusted = loadModes({ globalDir, projectDirs, projectTrusted: false });
|
|
158
|
+
assert.equal(
|
|
159
|
+
untrusted.modes.some((mode) => mode.key === "company-only"),
|
|
160
|
+
false,
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(root, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("untrusted projects cannot contribute modes", () => {
|
|
168
|
+
const root = mkdtempSync(join(tmpdir(), "pi-modes-test-"));
|
|
169
|
+
const projectDir = join(root, "project");
|
|
170
|
+
mkdirSync(projectDir);
|
|
171
|
+
writeFileSync(
|
|
172
|
+
join(projectDir, "secret.json"),
|
|
173
|
+
JSON.stringify({ key: "secret", label: "Secret", systemPrompt: "secret" }),
|
|
174
|
+
);
|
|
175
|
+
try {
|
|
176
|
+
const loaded = loadModes({
|
|
177
|
+
globalDir: join(root, "missing"),
|
|
178
|
+
projectDir,
|
|
179
|
+
projectTrusted: false,
|
|
180
|
+
});
|
|
181
|
+
assert.equal(
|
|
182
|
+
loaded.modes.some((mode) => mode.key === "secret"),
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(root, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("safe persistence rejects traversal and deletes only selected directory files", () => {
|
|
191
|
+
const root = mkdtempSync(join(tmpdir(), "pi-modes-test-"));
|
|
192
|
+
try {
|
|
193
|
+
assert.throws(() => modePath(root, "../../outside"), /invalid mode key/);
|
|
194
|
+
const mode = parseModeDefinition({ key: "safe", label: "Safe", systemPrompt: "safe" });
|
|
195
|
+
const path = saveMode(root, mode);
|
|
196
|
+
assert.equal(JSON.parse(readFileSync(path, "utf8")).key, "safe");
|
|
197
|
+
assert.throws(() => deleteMode(join(root, "..", "outside.json"), root), /refusing to delete/);
|
|
198
|
+
deleteMode(path, root);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("mode discovery and persistence reject symbolic-link boundaries", () => {
|
|
205
|
+
const root = mkdtempSync(join(tmpdir(), "pi-modes-test-"));
|
|
206
|
+
const actualDir = join(root, "actual");
|
|
207
|
+
const linkedDir = join(root, "linked");
|
|
208
|
+
const actualConfig = join(root, "actual-config");
|
|
209
|
+
const project = join(root, "project");
|
|
210
|
+
const linkedParentModes = join(project, ".pi", "modes");
|
|
211
|
+
mkdirSync(actualDir);
|
|
212
|
+
mkdirSync(join(actualConfig, "modes"), { recursive: true });
|
|
213
|
+
mkdirSync(project);
|
|
214
|
+
symlinkSync(actualDir, linkedDir, "dir");
|
|
215
|
+
symlinkSync(actualConfig, join(project, ".pi"), "dir");
|
|
216
|
+
try {
|
|
217
|
+
const direct = loadModes({ globalDir: linkedDir, projectTrusted: false });
|
|
218
|
+
assert.match(direct.diagnostics[0]?.message ?? "", /symbolic-link boundary/);
|
|
219
|
+
const parent = loadModes({ globalDir: linkedParentModes, projectTrusted: false });
|
|
220
|
+
assert.match(parent.diagnostics[0]?.message ?? "", /symbolic-link boundary/);
|
|
221
|
+
const mode = parseModeDefinition({ key: "safe", label: "Safe", systemPrompt: "safe" });
|
|
222
|
+
assert.throws(() => saveMode(linkedDir, mode), /symbolic-link boundary/);
|
|
223
|
+
assert.throws(() => saveMode(linkedParentModes, mode), /symbolic-link boundary/);
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(root, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("session state follows the latest mode entry including off", () => {
|
|
230
|
+
const entries = [
|
|
231
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: "review" } },
|
|
232
|
+
{ type: "custom", customType: "other", data: { key: "ignored" } },
|
|
233
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: null } },
|
|
234
|
+
];
|
|
235
|
+
assert.deepEqual(selectedModeFromEntries(entries), { key: null });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("session state uses only the caller-provided active branch", () => {
|
|
239
|
+
const abandonedBranch = [
|
|
240
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: "review" } },
|
|
241
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: "explain" } },
|
|
242
|
+
];
|
|
243
|
+
const activeBranch = [
|
|
244
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: "review" } },
|
|
245
|
+
{ type: "custom", customType: MODE_STATE_TYPE, data: { key: "plan" } },
|
|
246
|
+
];
|
|
247
|
+
assert.equal(selectedModeFromEntries(abandonedBranch).key, "explain");
|
|
248
|
+
assert.equal(selectedModeFromEntries(activeBranch).key, "plan");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("PI_MODE selects a normalized launch-time mode key", () => {
|
|
252
|
+
assert.deepEqual(startupModeFromEnvironment(" Focused_Builder "), {
|
|
253
|
+
configured: true,
|
|
254
|
+
key: "focused_builder",
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("PI_MODE off aliases explicitly select the native SYSTEM.md host base", () => {
|
|
259
|
+
for (const value of ["off", "DEFAULT", " none "]) {
|
|
260
|
+
assert.deepEqual(startupModeFromEnvironment(value), { configured: true, key: null });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("missing or blank PI_MODE does not override restored session state", () => {
|
|
265
|
+
assert.deepEqual(startupModeFromEnvironment(undefined), { configured: false, key: null });
|
|
266
|
+
assert.deepEqual(startupModeFromEnvironment(" "), { configured: false, key: null });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("invalid PI_MODE fails closed instead of becoming a path or prompt selector", () => {
|
|
270
|
+
const result = startupModeFromEnvironment("../../SYSTEM.md");
|
|
271
|
+
assert.equal(result.configured, true);
|
|
272
|
+
assert.equal(result.key, null);
|
|
273
|
+
assert.match(result.error ?? "", /valid mode key/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("explicit PI_MODE overrides a resumed session selection", () => {
|
|
277
|
+
assert.deepEqual(
|
|
278
|
+
resolveInitialModeSelection({
|
|
279
|
+
applyEnvironment: true,
|
|
280
|
+
environmentValue: "review",
|
|
281
|
+
sessionKey: "plan",
|
|
282
|
+
availableKeys: ["plan", "review"],
|
|
283
|
+
}),
|
|
284
|
+
{ source: "environment", key: "review" },
|
|
285
|
+
);
|
|
286
|
+
assert.deepEqual(
|
|
287
|
+
resolveInitialModeSelection({
|
|
288
|
+
applyEnvironment: true,
|
|
289
|
+
environmentValue: "off",
|
|
290
|
+
sessionKey: "review",
|
|
291
|
+
availableKeys: ["review"],
|
|
292
|
+
}),
|
|
293
|
+
{ source: "environment", key: null },
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("session selection is restored when PI_MODE is absent", () => {
|
|
298
|
+
assert.deepEqual(
|
|
299
|
+
resolveInitialModeSelection({
|
|
300
|
+
applyEnvironment: true,
|
|
301
|
+
environmentValue: undefined,
|
|
302
|
+
sessionKey: "review",
|
|
303
|
+
availableKeys: ["review"],
|
|
304
|
+
}),
|
|
305
|
+
{ source: "session", key: "review" },
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("PI_MODE is consumed only on process startup, not reload or session replacement", () => {
|
|
310
|
+
for (const sessionKey of [null, "plan"] as const) {
|
|
311
|
+
assert.deepEqual(
|
|
312
|
+
resolveInitialModeSelection({
|
|
313
|
+
applyEnvironment: false,
|
|
314
|
+
environmentValue: "review",
|
|
315
|
+
sessionKey,
|
|
316
|
+
availableKeys: ["plan", "review"],
|
|
317
|
+
}),
|
|
318
|
+
{ source: "session", key: sessionKey },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("unavailable PI_MODE fails closed to the native SYSTEM.md host base", () => {
|
|
324
|
+
const result = resolveInitialModeSelection({
|
|
325
|
+
applyEnvironment: true,
|
|
326
|
+
environmentValue: "missing",
|
|
327
|
+
sessionKey: "review",
|
|
328
|
+
availableKeys: ["review"],
|
|
329
|
+
});
|
|
330
|
+
assert.equal(result.source, "environment");
|
|
331
|
+
assert.equal(result.key, null);
|
|
332
|
+
assert.match(result.error ?? "", /unavailable mode/);
|
|
333
|
+
});
|