@gotgenes/pi-permission-system 33.0.8 → 33.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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [33.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.8...pi-permission-system-v33.1.0) (2026-09-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** state the relocated tool surface in pi ≥0.86's section shape ([e9980a4](https://github.com/gotgenes/pi-packages/commit/e9980a46af93b2cf668977fc103c803c911d9b12)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **pi-permission-system:** keep a project's own Guidelines section on a pi ≥0.86 prompt ([4f88680](https://github.com/gotgenes/pi-packages/commit/4f88680b309219d6f21d84968101975012f01364)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
18
+ * **pi-permission-system:** remove pi's own tool list and rules on a pi ≥0.86 prompt ([596dca1](https://github.com/gotgenes/pi-packages/commit/596dca1a64838162f0662ff06b6a0cc29c538bdc)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
19
+ * **pi-permission-system:** keep extension-contributed rules when relocating the tool surface ([00eb11b](https://github.com/gotgenes/pi-packages/commit/00eb11b3ac5bc4c80205fc1c5ffe62d45d8ddc72)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
20
+
21
+ ### Documentation
22
+
23
+ * **pi-permission-system:** describe both prompt shapes in the tool-surface module comments ([5b5ca51](https://github.com/gotgenes/pi-packages/commit/5b5ca512586e2d47b887406e210f847811293192)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
24
+ * **pi-permission-system:** describe the tool-surface relocation on pi ≥0.86 section prompts ([5168e40](https://github.com/gotgenes/pi-packages/commit/5168e408c1a0f07c0ae5c9668ec49914b4a2a41b)), closes [#962](https://github.com/gotgenes/pi-packages/issues/962)
25
+
8
26
  ## [33.0.8](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.7...pi-permission-system-v33.0.8) (2026-09-23)
9
27
 
10
28
 
@@ -1243,11 +1243,11 @@ permission:
1243
1243
 
1244
1244
  The extension integrates via Pi's lifecycle hooks:
1245
1245
 
1246
- | Hook | Behavior |
1247
- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1248
- | `before_agent_start` | Filters the active tool set (restrict-only), restates the `Available tools:` and `Guidelines:` sections at the end of the system prompt to match, and hides denied skills |
1249
- | `tool_call` | Enforces permissions for every tool invocation |
1250
- | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1246
+ | Hook | Behavior |
1247
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
1248
+ | `before_agent_start` | Filters the active tool set (restrict-only), restates the tool list and guidelines at the end of the system prompt to match, and hides denied skills |
1249
+ | `tool_call` | Enforces permissions for every tool invocation |
1250
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1251
1251
 
1252
1252
  Additional behaviors:
1253
1253
 
@@ -1257,10 +1257,11 @@ Additional behaviors:
1257
1257
  A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1258
1258
  - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
1259
1259
  - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1260
- - The `Available tools:` and `Guidelines:` sections are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after pi's `Current working directory:` footer.
1260
+ - The tool list and guidelines are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after the working directory pi states last.
1261
+ They take the shape pi writes them in: `Available tools:` and `Guidelines:` sections after a `Current working directory:` footer through pi 0.85, and `<tools>` and `<rules>` sections after a `<cwd>` section from pi 0.86.
1261
1262
  Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied.
1262
1263
  Only the copies pi wrote are removed: a custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) keeps its own text untouched, sections and all, because pi writes no tool surface of its own under one — so a prompt that lists tools itself is shown alongside this session's block rather than replaced by it.
1263
- - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions around pi's built-in ones
1264
+ - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones
1264
1265
  - The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1265
1266
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1266
1267
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "33.0.8",
3
+ "version": "33.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,17 +15,23 @@
15
15
  * and no session edits another's bytes.
16
16
  *
17
17
  * Removal is bounded to the text this package or Pi wrote. The prompt is split
18
- * at Pi's `Current working directory:` footer, which it writes last and
19
- * unconditionally: everything after it was appended by an extension, and
20
- * everything before it is Pi's own preamble only when Pi did not build the
21
- * prompt from a `customPrompt`. Under a custom prompt Pi writes no tool
22
- * surface at all, so a section matched above the footer is a user's or another
23
- * extension's — removing it destroyed their text (#919, #932).
18
+ * at the cwd layer Pi writes last and unconditionally: a
19
+ * `Current working directory:` footer through Pi 0.85, a `<cwd>` section from
20
+ * 0.86. Everything after it was appended by an extension, and everything
21
+ * before it is Pi's own only when Pi did not build the prompt from a
22
+ * `customPrompt`. Under a custom prompt Pi writes no tool surface at all, so a
23
+ * section matched above the cwd layer is a user's or another extension's, and
24
+ * removing it destroyed their text (#919, #932). On the 0.86 shape even a
25
+ * Pi-authored head is searched only for Pi's own `<tools>` and `<rules>`,
26
+ * bounded to where Pi writes them, since the plain headers it no longer
27
+ * writes would match only a context file's text.
24
28
  *
25
- * Rendering follows `buildSystemPrompt`'s own rules — a tool is listed only
26
- * when it has a snippet, and the guideline bullets are the allowed tools' own
27
- * `promptGuidelines` around Pi's built-in ones — so the block reads as the one
28
- * Pi would have written for this session's real surface.
29
+ * Rendering follows `buildSystemPrompt`'s own rules (a tool is listed only
30
+ * when it has a snippet; the guideline bullets are the allowed tools' own
31
+ * `promptGuidelines`, then other extensions' rules, around Pi's built-in
32
+ * ones) and its shape (plain headers or tagged sections, matching the
33
+ * prompt), so the block reads as the one Pi would have written for this
34
+ * session's real surface.
29
35
  */
30
36
 
31
37
  /** What a session's tool surface renders from. */
@@ -36,6 +42,16 @@ export interface ToolSurfaceInputs {
36
42
  readonly toolSnippets: Readonly<Record<string, string>>;
37
43
  /** Guideline bullets each tool contributes, keyed by tool name. */
38
44
  readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
45
+ /**
46
+ * `systemPromptOptions.promptGuidelines`: bullets Pi writes into its rules
47
+ * after the tools' own.
48
+ *
49
+ * Only a bullet no registered tool contributes is carried. From Pi 0.86 the
50
+ * field holds what other extensions added; through 0.85 it *is* the tools'
51
+ * guidelines, flattened, and a denied tool's bullet must not return by this
52
+ * route.
53
+ */
54
+ readonly promptGuidelines: readonly string[];
39
55
  /**
40
56
  * Whether Pi wrote the prompt's preamble itself.
41
57
  *
@@ -75,6 +91,31 @@ const EMPTY_LIST_PLACEHOLDER = "(none)";
75
91
  */
76
92
  const PROMPT_FOOTER_PREFIX = "Current working directory: ";
77
93
 
94
+ /**
95
+ * The tags of the section Pi renders the working directory into from 0.86,
96
+ * last among its own sections and in both of its branches.
97
+ */
98
+ const CWD_SECTION_OPEN = "<cwd>";
99
+ const CWD_SECTION_CLOSE = "</cwd>";
100
+
101
+ /** The sections Pi 0.86+ writes its tool surface into, in the order it writes them. */
102
+ const TOOL_SURFACE_SECTION_NAMES = ["tools", "rules"] as const;
103
+
104
+ /**
105
+ * Opening tags of the sections Pi writes after its tool surface.
106
+ *
107
+ * Pi writes `<tools>` and `<rules>` directly under its preamble and ahead of
108
+ * every one of these, so a match that opens or closes past the first of them
109
+ * is text somebody quoted, not Pi's.
110
+ */
111
+ const LATER_PI_SECTION_OPENS: ReadonlySet<string> = new Set([
112
+ "<docs>",
113
+ "<addendum>",
114
+ "<project_context>",
115
+ "<skills>",
116
+ CWD_SECTION_OPEN,
117
+ ]);
118
+
78
119
  /** Pi's two unconditional guideline bullets, in the order it writes them. */
79
120
  const UNIVERSAL_GUIDELINES: readonly string[] = [
80
121
  "Be concise in your responses",
@@ -93,43 +134,116 @@ export function renderToolSurface(
93
134
  inputs: ToolSurfaceInputs,
94
135
  ): string {
95
136
  const lines = normalizePrompt(systemPrompt).split("\n");
96
- const tailStart = extensionTailStart(lines);
137
+ const { layout, tailStart } = detectPromptLayout(lines);
97
138
  const body = [
98
- settleRegion(lines.slice(0, tailStart), inputs.piAuthoredPreamble),
99
- settleRegion(lines.slice(tailStart), true),
139
+ settleRegion(
140
+ lines.slice(0, tailStart),
141
+ inputs.piAuthoredPreamble ? layout.removePiSurface : null,
142
+ ),
143
+ settleRegion(lines.slice(tailStart), layout.removeRelocatedSurface),
100
144
  ]
101
145
  .filter((region) => region.length > 0)
102
146
  .join("\n")
103
147
  .trimEnd();
104
- const block = renderToolSurfaceBlock(inputs);
148
+ const block = layout.renderBlock(toolSurfaceBullets(inputs));
105
149
 
106
150
  return body.length > 0 ? `${body}\n\n${block}` : block;
107
151
  }
108
152
 
153
+ /** Lines removed from one region of the prompt; the rest are returned in order. */
154
+ type RegionRemoval = (lines: readonly string[]) => string[];
155
+
156
+ /**
157
+ * How one prompt shape bounds and writes the tool surface.
158
+ *
159
+ * Pi has written its prompt in more than one shape, and each shape answers the
160
+ * same three questions differently, so the shape is decided once per prompt
161
+ * and every answer is read off the layout it selected.
162
+ */
163
+ interface PromptLayout {
164
+ /** Pi's own tool surface, removed from the head when Pi wrote the preamble. */
165
+ readonly removePiSurface: RegionRemoval;
166
+ /** A relocated block (this package's or a peer's), removed from the tail. */
167
+ readonly removeRelocatedSurface: RegionRemoval;
168
+ /** This session's block, in this layout's shape. */
169
+ readonly renderBlock: (bullets: ToolSurfaceBullets) => string;
170
+ }
171
+
172
+ /** A prompt's layout, and the line at which the text extensions appended begins. */
173
+ interface DetectedLayout {
174
+ readonly layout: PromptLayout;
175
+ readonly tailStart: number;
176
+ }
177
+
178
+ /**
179
+ * The shape Pi wrote through 0.85: `Available tools:` and `Guidelines:`
180
+ * sections in its preamble, and a `Current working directory:` footer last.
181
+ */
182
+ const HEADER_LAYOUT: PromptLayout = {
183
+ removePiSurface: removeToolSurfaceSections,
184
+ removeRelocatedSurface: removeToolSurfaceSections,
185
+ renderBlock: renderHeaderBlock,
186
+ };
187
+
188
+ /**
189
+ * The shape Pi writes from 0.86: an untagged preamble, then `<name>` sections
190
+ * joined by a blank line (`<tools>` and `<rules>` among them), and a `<cwd>`
191
+ * section last among its own.
192
+ */
193
+ const SECTION_LAYOUT: PromptLayout = {
194
+ removePiSurface: removePiToolSurfaceSections,
195
+ removeRelocatedSurface: removeRelocatedToolSurface,
196
+ renderBlock: renderSectionBlock,
197
+ };
198
+
109
199
  /**
110
- * Where the text extensions appended begins: the line after Pi's footer, or
111
- * the end of the prompt when nothing downstream left one.
200
+ * The prompt's layout, read off the cwd layer Pi wrote, and where the text
201
+ * extensions appended after that layer begins.
112
202
  *
113
- * The last footer is Pi's own — it appends one after everything it assembled,
114
- * so a line of the same shape in a custom prompt is always above it.
203
+ * Pi writes its cwd layer last among its own, in both of its branches: a
204
+ * `Current working directory:` footer through 0.85, a `<cwd>` section from
205
+ * 0.86. So whichever of the two sits later is Pi's, and a line of either shape
206
+ * quoted in a context file or a custom prompt is always above it. The shape is
207
+ * told apart by which layer is present, never by version sniffing.
115
208
  *
116
- * Accepted edge: a prompt carrying no footer at all is treated as all head, so
117
- * a block appended to *that* prompt cannot be found and replaced, and a custom
118
- * preamble would collect a second one. Pi writes the footer last and in both
119
- * branches, so reaching this needs a downstream rewrite of Pi's whole output —
120
- * which has already broken `@gotgenes/pi-subagents`' identity anchor, since it
121
- * reads the same line.
209
+ * A prompt carrying neither is read as the header layout and treated as all
210
+ * head, so a block appended to *that* prompt cannot be found and replaced, and
211
+ * a custom preamble would collect a second one. Pi writes a cwd layer in both
212
+ * branches of both renderers, so reaching this needs a downstream rewrite of
213
+ * Pi's whole output.
122
214
  */
123
- function extensionTailStart(lines: readonly string[]): number {
215
+ function detectPromptLayout(lines: readonly string[]): DetectedLayout {
124
216
  const footerAt = lines.findLastIndex((line) =>
125
217
  line.startsWith(PROMPT_FOOTER_PREFIX),
126
218
  );
127
- return footerAt === -1 ? lines.length : footerAt + 1;
219
+ const cwdCloseAt = lastCwdSectionClose(lines);
220
+ if (cwdCloseAt > footerAt) {
221
+ return { layout: SECTION_LAYOUT, tailStart: cwdCloseAt + 1 };
222
+ }
223
+ return {
224
+ layout: HEADER_LAYOUT,
225
+ tailStart: footerAt === -1 ? lines.length : footerAt + 1,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Line index of the closing tag of the last `<cwd>` section, or -1 when there
231
+ * is none.
232
+ *
233
+ * Pi renders the section as exactly three lines (the opening tag, the
234
+ * directory, the closing tag), so that is the shape matched, the way the
235
+ * footer is matched by its prefix.
236
+ */
237
+ function lastCwdSectionClose(lines: readonly string[]): number {
238
+ return lines.findLastIndex(
239
+ (line, index) =>
240
+ line === CWD_SECTION_CLOSE && lines[index - 2] === CWD_SECTION_OPEN,
241
+ );
128
242
  }
129
243
 
130
244
  /**
131
245
  * One region's surviving text: its sections removed, when they are ours to
132
- * remove.
246
+ * remove, or returned as it arrived when `removal` is `null`.
133
247
  *
134
248
  * Blank runs are collapsed only where a removal opened one, so a region this
135
249
  * pass took nothing out of is returned exactly as it arrived rather than
@@ -137,12 +251,12 @@ function extensionTailStart(lines: readonly string[]): number {
137
251
  */
138
252
  function settleRegion(
139
253
  lines: readonly string[],
140
- removalAllowed: boolean,
254
+ removal: RegionRemoval | null,
141
255
  ): string {
142
- if (!removalAllowed) {
256
+ if (!removal) {
143
257
  return lines.join("\n");
144
258
  }
145
- const kept = removeToolSurfaceSections(lines);
259
+ const kept = removal(lines);
146
260
  const text = kept.join("\n");
147
261
  return kept.length === lines.length ? text : collapseExtraBlankLines(text);
148
262
  }
@@ -176,45 +290,155 @@ function removeToolSurfaceSections(lines: readonly string[]): string[] {
176
290
  );
177
291
  }
178
292
 
179
- /** This session's tool surface, as Pi would have rendered it. */
180
- function renderToolSurfaceBlock(inputs: ToolSurfaceInputs): string {
293
+ /**
294
+ * Remove Pi 0.86+'s own `<tools>` and `<rules>` sections, tags included.
295
+ *
296
+ * A section is Pi's only when it opens and closes before the first section Pi
297
+ * writes after it; the plain headers `removeToolSurfaceSections` matches are
298
+ * never searched here, because Pi writes none on this shape and every match
299
+ * would be a user's or another extension's text.
300
+ */
301
+ function removePiToolSurfaceSections(head: readonly string[]): string[] {
302
+ return removeTaggedToolSurface(head, laterPiSectionStart);
303
+ }
304
+
305
+ /**
306
+ * Remove a relocated block from the extension tail, in either shape.
307
+ *
308
+ * This package writes the tagged shape here, and a peer writer may write
309
+ * either, so both are removed: the pass stays safe to apply to its own output
310
+ * and order-independent with a second writer.
311
+ */
312
+ function removeRelocatedToolSurface(tail: readonly string[]): string[] {
313
+ return removeToolSurfaceSections(
314
+ removeTaggedToolSurface(tail, (lines) => lines.length),
315
+ );
316
+ }
317
+
318
+ /**
319
+ * Remove the first `<tools>` and the first `<rules>` section, each only when
320
+ * it closes before the line `limitOf` names.
321
+ */
322
+ function removeTaggedToolSurface(
323
+ lines: readonly string[],
324
+ limitOf: (lines: readonly string[]) => number,
325
+ ): string[] {
326
+ let remaining = [...lines];
327
+ for (const name of TOOL_SURFACE_SECTION_NAMES) {
328
+ const section = findTaggedSection(remaining, name, limitOf(remaining));
329
+ if (section) {
330
+ remaining = [
331
+ ...remaining.slice(0, section.start),
332
+ ...remaining.slice(section.end),
333
+ ];
334
+ }
335
+ }
336
+ return remaining;
337
+ }
338
+
339
+ /** Line index of the first section Pi writes after its tool surface. */
340
+ function laterPiSectionStart(lines: readonly string[]): number {
341
+ const at = lines.findIndex((line) => LATER_PI_SECTION_OPENS.has(line));
342
+ return at === -1 ? lines.length : at;
343
+ }
344
+
345
+ /**
346
+ * The first `<name>` section, opening tag through closing tag, that ends
347
+ * before `limit`; `null` when there is none.
348
+ */
349
+ function findTaggedSection(
350
+ lines: readonly string[],
351
+ name: string,
352
+ limit: number,
353
+ ): LineSection | null {
354
+ const start = lines.indexOf(`<${name}>`);
355
+ if (start === -1 || start >= limit) {
356
+ return null;
357
+ }
358
+ const closeAt = lines.indexOf(`</${name}>`, start + 1);
359
+ if (closeAt === -1 || closeAt >= limit) {
360
+ return null;
361
+ }
362
+ return { start, end: closeAt + 1 };
363
+ }
364
+
365
+ /**
366
+ * This session's tool surface, as Pi 0.86+ would have rendered it: a `<tools>`
367
+ * section, when any allowed tool has a snippet, then a `<rules>` section.
368
+ */
369
+ function renderSectionBlock(bullets: ToolSurfaceBullets): string {
370
+ const sections: string[] = [];
371
+ if (bullets.tools.length > 0) {
372
+ sections.push(taggedSection("tools", bullets.tools));
373
+ }
374
+ sections.push(taggedSection("rules", bullets.rules));
375
+
376
+ return sections.join("\n\n");
377
+ }
378
+
379
+ /** A section in the form Pi 0.86+ renders: its tags on their own lines. */
380
+ function taggedSection(name: string, lines: readonly string[]): string {
381
+ return [`<${name}>`, ...lines, `</${name}>`].join("\n");
382
+ }
383
+
384
+ /** This session's tool surface, as Pi through 0.85 would have rendered it. */
385
+ function renderHeaderBlock(bullets: ToolSurfaceBullets): string {
181
386
  const sections: string[] = [];
182
387
 
183
- const toolList = renderAvailableTools(inputs);
388
+ const toolList = renderAvailableTools(bullets.tools);
184
389
  if (toolList) {
185
390
  sections.push(toolList);
186
391
  }
187
- sections.push(renderGuidelines(inputs));
392
+ sections.push(renderGuidelines(bullets.rules));
188
393
 
189
394
  return sections.join("\n\n");
190
395
  }
191
396
 
397
+ /** The `Available tools:` section, or `null` when it lists no tool. */
398
+ function renderAvailableTools(bullets: readonly string[]): string | null {
399
+ return bullets.length > 0
400
+ ? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
401
+ : null;
402
+ }
403
+
404
+ /** The `Guidelines:` section. */
405
+ function renderGuidelines(bullets: readonly string[]): string {
406
+ return [GUIDELINES_SECTION_HEADER, ...bullets].join("\n");
407
+ }
408
+
409
+ /** The bullets a tool-surface block wraps, whatever shape wraps them. */
410
+ interface ToolSurfaceBullets {
411
+ /** `- name: snippet` lines; empty when no allowed tool has a snippet. */
412
+ readonly tools: readonly string[];
413
+ /** `- rule` lines, in `buildSystemPrompt`'s order. */
414
+ readonly rules: readonly string[];
415
+ }
416
+
417
+ function toolSurfaceBullets(inputs: ToolSurfaceInputs): ToolSurfaceBullets {
418
+ return { tools: toolBullets(inputs), rules: ruleBullets(inputs) };
419
+ }
420
+
192
421
  /**
193
- * The `Available tools:` section for the allowed set, or `null` when none of
194
- * those tools has a snippet.
422
+ * One bullet per allowed tool that has a snippet.
195
423
  *
196
424
  * Pi lists a tool only when the caller supplied a one-line snippet for it, so
197
425
  * a tool without one is left unlisted here too rather than rendered bare.
198
426
  */
199
- function renderAvailableTools(inputs: ToolSurfaceInputs): string | null {
200
- const bullets = inputs.allowedTools
427
+ function toolBullets(inputs: ToolSurfaceInputs): string[] {
428
+ return inputs.allowedTools
201
429
  .map((toolName) => ({ toolName, snippet: inputs.toolSnippets[toolName] }))
202
430
  .filter((tool) => Boolean(tool.snippet))
203
431
  .map((tool) => `- ${tool.toolName}: ${tool.snippet}`);
204
-
205
- return bullets.length > 0
206
- ? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
207
- : null;
208
432
  }
209
433
 
210
434
  /**
211
- * The `Guidelines:` section for the allowed set.
435
+ * The guideline bullets for the allowed set.
212
436
  *
213
437
  * Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
214
- * bullet first, then each allowed tool's own contributions, then its two
215
- * unconditional bullets — de-duplicated in first-seen order, as Pi does.
438
+ * bullet first, then each allowed tool's own contributions, then the bullets
439
+ * other extensions added, then its two unconditional bullets — de-duplicated in first-seen order, as Pi does.
216
440
  */
217
- function renderGuidelines(inputs: ToolSurfaceInputs): string {
441
+ function ruleBullets(inputs: ToolSurfaceInputs): string[] {
218
442
  const bullets: string[] = [];
219
443
  const seen = new Set<string>();
220
444
  const addGuideline = (guideline: string): void => {
@@ -237,14 +461,27 @@ function renderGuidelines(inputs: ToolSurfaceInputs): string {
237
461
  }
238
462
  }
239
463
 
464
+ for (const guideline of extensionGuidelines(inputs)) {
465
+ addGuideline(guideline);
466
+ }
467
+
240
468
  for (const guideline of UNIVERSAL_GUIDELINES) {
241
469
  addGuideline(guideline);
242
470
  }
243
471
 
244
- return [
245
- GUIDELINES_SECTION_HEADER,
246
- ...bullets.map((bullet) => `- ${bullet}`),
247
- ].join("\n");
472
+ return bullets.map((bullet) => `- ${bullet}`);
473
+ }
474
+
475
+ /** The `promptGuidelines` bullets no registered tool contributes. */
476
+ function extensionGuidelines(inputs: ToolSurfaceInputs): string[] {
477
+ const toolGuidelines = new Set(
478
+ [...inputs.guidelinesByTool.values()]
479
+ .flat()
480
+ .map((guideline) => guideline.trim()),
481
+ );
482
+ return inputs.promptGuidelines.filter(
483
+ (guideline) => !toolGuidelines.has(guideline.trim()),
484
+ );
248
485
  }
249
486
 
250
487
  /**
@@ -24,10 +24,13 @@ interface BeforeAgentStartPayload {
24
24
  * wrote — including in a child, whose inherited identity carries none.
25
25
  * `customPrompt` says whether Pi wrote a preamble at all: under one, it
26
26
  * writes no tool surface, so there is nothing of Pi's to remove.
27
+ * `promptGuidelines` carries rules other extensions added, which removing
28
+ * Pi's own rules section would otherwise drop.
27
29
  */
28
30
  systemPromptOptions?: {
29
31
  customPrompt?: string;
30
32
  toolSnippets?: Record<string, string>;
33
+ promptGuidelines?: readonly string[];
31
34
  };
32
35
  }
33
36
 
@@ -109,6 +112,7 @@ export class AgentPrepHandler {
109
112
  allowedTools,
110
113
  toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
111
114
  guidelinesByTool: registered.guidelinesByTool,
115
+ promptGuidelines: event.systemPromptOptions?.promptGuidelines ?? [],
112
116
  // Pi's own `if (customPrompt)` test, so an empty string reads here the
113
117
  // way it reads there: as no custom prompt at all.
114
118
  piAuthoredPreamble: !event.systemPromptOptions?.customPrompt,