sec-default: the system prompt's sections continue past the user tier (#97241)

* sec-default: the system prompt's sections continue past the user tier; the declarations carry prompt.compose

* sec-default: prompt.compose has its own row, a second case where a person's plugin asks first, and the declarations as the event shipped

* sec-default: the two new cases set their several-line hooks apart
This commit is contained in:
Alice T'Poteat
2026-09-29 19:39:25 +00:00
committed by GitHub
parent ec44ca97dc
commit 684800b206
9 changed files with 259 additions and 1 deletions
+2 -1
View File
@@ -27,6 +27,7 @@ settings it decides by.
| --- | --- |
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
| `prompt.section`, `prompt.context`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep `prompt.submit` and its additive context. |
| `prompt.compose` | Continue past the user tier: the system prompt's list of sections is what the organization's tiers, the built-ins and the engine's own composition make it. A person's plugin neither drops, reorders nor rewrites a section, nor changes the facts the list is composed from, nor answers a list of its own in its place. The engine raises this event only when some loaded plugin hooks it, so where this plugin is seated every render of the system prompt runs the chain. |
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
| `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. |
| `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. |
@@ -91,7 +92,7 @@ as unset leaves deny rules holding. See [Deny rules hold](#deny-rules-hold).
## What it hooks
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
`classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
`plugin.register`.
+1
View File
@@ -25,6 +25,7 @@ export function register(on: On) {
on('prompt.section', ($, e, next) => next.to(e, 'append'))
on('prompt.context', ($, e, next) => next.to(e, 'append'))
on('prompt.compose', ($, e, next) => next.to(e, 'append'))
on('skill.prompt', ($, e, next) => next.to(e, 'append'))
on('attribution.text', ($, e, next) => next.to(e, 'append'))
+13
View File
@@ -0,0 +1,13 @@
import type { PromptComposeInput } from 'claude-code'
/**
* The facts of one render of the system prompt, as the engine raises them.
*/
export const COMPOSED: PromptComposeInput = {
model: 'example-model-1',
promptModel: 'example-model-1',
surfaces: ['terminal'],
tools: ['Bash'],
outputStyle: null,
traits: [],
}
+14
View File
@@ -0,0 +1,14 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that answers a system prompt of its own
* and asks nothing of what is beneath it.
*/
export const emptying: Plugin = {
name: 'emptying',
register(on) {
on('prompt.compose', () => ({
sections: [{ id: 'emptying:all', text: 'mine alone', scope: 'session' }],
}))
},
}
+22
View File
@@ -0,0 +1,22 @@
import type { Plugin } from 'claude-code/testing'
/**
* The organization's own plugin, in its last tier, which puts its section
* at the head of the list beneath it.
*/
export const heading: Plugin = {
name: 'heading',
tier: 'append',
register(on) {
on('prompt.compose', async ($, e, next) => {
const { sections } = await next(e)
return {
sections: [
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
...sections,
],
}
})
},
}
+4
View File
@@ -2,9 +2,12 @@ export * from './agent-offered.js'
export * from './agent-spawned.js'
export * from './allowlist.js'
export * from './command-described.js'
export * from './composed.js'
export * from './denying.js'
export * from './dropping.js'
export * from './emptying.js'
export * from './fullscreen.js'
export * from './heading.js'
export * from './listing.js'
export * from './logged.js'
export * from './managed-mods-only.js'
@@ -23,6 +26,7 @@ export * from './reading.js'
export * from './registered-tool-of.js'
export * from './registering.js'
export * from './relabeling.js'
export * from './rewording.js'
export * from './server-policy.js'
export * from './session.js'
export * from './signing.js'
+23
View File
@@ -0,0 +1,23 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that asks for what is beneath it, then
* drops the body and rewrites the text of every section it keeps.
*/
export const rewording: Plugin = {
name: 'rewording',
register(on) {
on('prompt.compose', async ($, e, next) => {
const { sections } = await next(e)
return {
sections: sections
.filter(section => section.id !== 'body')
.map(section => ({
...section,
text: `${section.text} (reworded)`,
})),
}
})
},
}
+38
View File
@@ -155,6 +155,44 @@ describe('register', () => {
},
)
test(
"the system prompt's sections pass over the plugins the person installed",
{ plugins: [Fixtures.emptying, Fixtures.heading] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
on('prompt.compose', () => ({
sections: [{ id: 'body', text: 'the body', scope: 'shared' }],
}))
expect(await $.prompt.compose(Fixtures.COMPOSED)).toEqual({
sections: [
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
{ id: 'body', text: 'the body', scope: 'shared' },
],
})
},
)
test(
"nor does a person's plugin drop or reword a section it asked for",
{ plugins: [Fixtures.rewording, Fixtures.heading] },
async ($, on) => {
on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
on('prompt.compose', () => ({
sections: [{ id: 'body', text: 'the body', scope: 'shared' }],
}))
expect(await $.prompt.compose(Fixtures.COMPOSED)).toEqual({
sections: [
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
{ id: 'body', text: 'the body', scope: 'shared' },
],
})
},
)
test(
"a user plugin's rewrite of policy is skipped for every other reader",
{
+142
View File
@@ -2542,6 +2542,18 @@ declare module 'claude-code' {
* void $.prompt.suggest({ text: "run the tests you just wrote" })
*/
suggest: EventCalls['prompt']['suggest'];
/**
* Returns the system prompt's sections for `facts`: the event
* `prompt.compose`, the call the engine makes for every prompt it sends.
*
* A fact left out is the session's own (its model, its tools). Through
* every other plugin's hook, over the engine's own composition; composed
* for nobody to send, so nothing the session holds is written.
*
* @example
* const ids = (await $.prompt.compose()).sections.map(s => s.id)
*/
compose: EventCalls['prompt']['compose'];
};
/**
* The tools the model has in this session, and running one.
@@ -3417,6 +3429,18 @@ declare module 'claude-code' {
* on("prompt.context", () => ({ blocks: [] }))
*/
'prompt.context': PromptContextInput;
/**
* Fires when the engine renders a system prompt; `next(e)` resolves to
* `{ sections }`, each `{ id, text, scope }`, in the order they are sent.
*
* The bottom is the engine's own composition (`intro`, `tools`, `memory`,
* ...). Append, replace by id, reorder or drop what `next(e)` answered;
* answer without `next` to replace it all. The engine places each cache mark.
*
* @example
* on("prompt.compose", async ($, e, next) => dropped(await next(e), "tone"))
*/
'prompt.compose': PromptComposeInput;
/**
* Fires once per message the engine injects for the model on its own (a
* reminder, a mode transition, a mentioned file), as a request carries it.
@@ -3741,6 +3765,11 @@ declare module 'claude-code' {
* `{ blocks }` (a block left out is not sent).
*/
'prompt.context': PromptContextResult;
/**
* `{ sections }`, every `shared` one ahead of every `session` one (a
* section left out is not sent).
*/
'prompt.compose': PromptComposeResult;
/**
* `{ text }` (null leaves the attachment out).
*/
@@ -3853,6 +3882,7 @@ declare module 'claude-code' {
section: (input: PromptSectionInput) => Promise<PromptSectionResult>;
context: (input: PromptContextInput) => Promise<PromptContextResult>;
attachment: (input: PromptAttachmentInput) => Promise<PromptAttachmentResult>;
compose: (input?: PromptComposeArgs) => Promise<PromptComposeResult>;
};
skill: {
prompt: (input: SkillPromptInput) => Promise<SkillPromptResult>;
@@ -6622,6 +6652,118 @@ declare module 'claude-code' {
cursor: number;
};
/**
* What a plugin passes `$.prompt.compose`: the facts it wants composed for,
* each one it leaves out read off the session (its model, its tools).
*/
export type PromptComposeArgs = Partial<PromptComposeInput>;
/**
* The input of `prompt.compose`: the facts a system prompt is composed from,
* each already resolved by the engine, at the moment it renders one.
*/
export type PromptComposeInput = {
/**
* The id of the model the request is for; pinned, the field a matcher
* narrows on.
*/
model: string;
/**
* The model whose prompt is rendered: `model`, unless the engine renders
* another model's prompt for it (a model it holds no prompt of its own for).
*/
promptModel: string;
/**
* Where the session draws at this render, as `$.session.surfaces()`
* answers: `terminal` first under the REPL; empty where nothing draws.
*/
surfaces: readonly RenderSurface[];
/**
* The names of the tools the request offers the model; the engine's own
* composition reads them against the session's, an unknown name ignored.
*/
tools: readonly string[];
/**
* What the person chose in place of the default way of answering, and
* whether it keeps the coding instructions; null for the default style.
*/
outputStyle: {
name: string;
isKeepingCodingInstructions: boolean;
} | null;
traits: readonly PromptComposeTrait[];
};
/**
* What a `prompt.compose` hook returns: the sections of the system prompt,
* in order, every `shared` one ahead of every `session` one.
*
* A section left out is not sent; a hook that never calls `next` answers
* the whole list. The engine joins each side, places the cache boundary
* between them and every cache marker itself.
*/
export type PromptComposeResult = {
sections: readonly PromptComposeSection[];
};
/**
* Which side of the prompt cache's boundary a section of the system prompt
* sits on: `shared` before it, `session` after it.
*
* `shared` is text that reads the same for every person on this build and
* model: it is sent in the block the API may cache across organizations.
* `session` is text that varies with the person, the machine or the session.
*
* The engine places the one boundary and every cache marker itself,
* whatever a list says; `shared` text that varies hits that cache for nobody.
*/
export type PromptComposeScope = 'shared' | 'session';
/**
* One section of the system prompt as `prompt.compose` answers it: a stable
* id, the text the model reads, and the side of the cache boundary it is on.
*
* @example
* const POLICY = { id: "acme:policy", text: "# Policy\n...", scope: "session" }
*/
export type PromptComposeSection = {
/**
* What a hook above finds the section by, to replace, move or drop it;
* never empty, and unique in one list.
*
* A section a plugin adds is named `<plugin>:<name>`; the bare names are
* the engine's own composition's (`intro`, `tools`, `memory`, ...).
*/
id: string;
/**
* The section's text, sent as written; sections on one side of the
* boundary are joined by a blank line, in the list's order.
*/
text: string;
scope: PromptComposeScope;
};
/**
* One branch the engine's own composition of the system prompt takes on the
* request or the session before it computes any section: a closed set.
*
* `bare`: the session runs with the one-line prompt (`--bare`). `lean`: the
* prompt model takes the short body. `sdk-preset`: the SDK's `claude_code`
* preset, whose per-person sections ride the first user message instead.
*
* `teammate`: an in-process teammate's render of its lead's prompt.
* `analysis`: a render that measures the prompt (`/context`) and sends
* nothing. `print`: a session with no terminal behind it (`-p`, the SDK).
*
* `skills`: the Skill tool has commands to list. `send-user-message`: the
* session speaks to the person through a message tool.
*
* Rewritten going down, `sdk-preset`, `teammate` and `analysis` steer the
* engine's composition; the rest it derives itself, so they tell a hook what
* it will do. What one section's own text turns on (a flag) is not here.
*/
export type PromptComposeTrait = 'bare' | 'lean' | 'sdk-preset' | 'teammate' | 'analysis' | 'print' | 'skills' | 'send-user-message';
/**
* One block of the context the first user message carries: a name the
* engine keys it by and the text under it.