Skip to content

Commit 684800b

Browse files
authored
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
1 parent ec44ca9 commit 684800b

9 files changed

Lines changed: 259 additions & 1 deletion

File tree

‎mods/sec-default/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ settings it decides by.
2727
| --- | --- |
2828
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
2929
| `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. |
30+
| `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. |
3031
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
3132
| `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. |
3233
| `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).
9192

9293
## What it hooks
9394

94-
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
95+
`classic.*`, `prompt.section`, `prompt.context`, `prompt.compose`, `skill.prompt`,
9596
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
9697
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`, `tool.check`,
9798
`plugin.register`.

‎mods/sec-default/hooks/register.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function register(on: On) {
2525

2626
on('prompt.section', ($, e, next) => next.to(e, 'append'))
2727
on('prompt.context', ($, e, next) => next.to(e, 'append'))
28+
on('prompt.compose', ($, e, next) => next.to(e, 'append'))
2829
on('skill.prompt', ($, e, next) => next.to(e, 'append'))
2930
on('attribution.text', ($, e, next) => next.to(e, 'append'))
3031

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { PromptComposeInput } from 'claude-code'
2+
3+
/**
4+
* The facts of one render of the system prompt, as the engine raises them.
5+
*/
6+
export const COMPOSED: PromptComposeInput = {
7+
model: 'example-model-1',
8+
promptModel: 'example-model-1',
9+
surfaces: ['terminal'],
10+
tools: ['Bash'],
11+
outputStyle: null,
12+
traits: [],
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Plugin } from 'claude-code/testing'
2+
3+
/**
4+
* A plugin the person installed that answers a system prompt of its own
5+
* and asks nothing of what is beneath it.
6+
*/
7+
export const emptying: Plugin = {
8+
name: 'emptying',
9+
register(on) {
10+
on('prompt.compose', () => ({
11+
sections: [{ id: 'emptying:all', text: 'mine alone', scope: 'session' }],
12+
}))
13+
},
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Plugin } from 'claude-code/testing'
2+
3+
/**
4+
* The organization's own plugin, in its last tier, which puts its section
5+
* at the head of the list beneath it.
6+
*/
7+
export const heading: Plugin = {
8+
name: 'heading',
9+
tier: 'append',
10+
register(on) {
11+
on('prompt.compose', async ($, e, next) => {
12+
const { sections } = await next(e)
13+
14+
return {
15+
sections: [
16+
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
17+
...sections,
18+
],
19+
}
20+
})
21+
},
22+
}

‎mods/sec-default/tests/fixtures/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ export * from './agent-offered.js'
22
export * from './agent-spawned.js'
33
export * from './allowlist.js'
44
export * from './command-described.js'
5+
export * from './composed.js'
56
export * from './denying.js'
67
export * from './dropping.js'
8+
export * from './emptying.js'
79
export * from './fullscreen.js'
10+
export * from './heading.js'
811
export * from './listing.js'
912
export * from './logged.js'
1013
export * from './managed-mods-only.js'
@@ -23,6 +26,7 @@ export * from './reading.js'
2326
export * from './registered-tool-of.js'
2427
export * from './registering.js'
2528
export * from './relabeling.js'
29+
export * from './rewording.js'
2630
export * from './server-policy.js'
2731
export * from './session.js'
2832
export * from './signing.js'
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Plugin } from 'claude-code/testing'
2+
3+
/**
4+
* A plugin the person installed that asks for what is beneath it, then
5+
* drops the body and rewrites the text of every section it keeps.
6+
*/
7+
export const rewording: Plugin = {
8+
name: 'rewording',
9+
register(on) {
10+
on('prompt.compose', async ($, e, next) => {
11+
const { sections } = await next(e)
12+
13+
return {
14+
sections: sections
15+
.filter(section => section.id !== 'body')
16+
.map(section => ({
17+
...section,
18+
text: `${section.text} (reworded)`,
19+
})),
20+
}
21+
})
22+
},
23+
}

‎mods/sec-default/tests/register.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,44 @@ describe('register', () => {
155155
},
156156
)
157157

158+
test(
159+
"the system prompt's sections pass over the plugins the person installed",
160+
{ plugins: [Fixtures.emptying, Fixtures.heading] },
161+
async ($, on) => {
162+
on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
163+
164+
on('prompt.compose', () => ({
165+
sections: [{ id: 'body', text: 'the body', scope: 'shared' }],
166+
}))
167+
168+
expect(await $.prompt.compose(Fixtures.COMPOSED)).toEqual({
169+
sections: [
170+
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
171+
{ id: 'body', text: 'the body', scope: 'shared' },
172+
],
173+
})
174+
},
175+
)
176+
177+
test(
178+
"nor does a person's plugin drop or reword a section it asked for",
179+
{ plugins: [Fixtures.rewording, Fixtures.heading] },
180+
async ($, on) => {
181+
on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
182+
183+
on('prompt.compose', () => ({
184+
sections: [{ id: 'body', text: 'the body', scope: 'shared' }],
185+
}))
186+
187+
expect(await $.prompt.compose(Fixtures.COMPOSED)).toEqual({
188+
sections: [
189+
{ id: 'heading:org', text: 'the org says hi', scope: 'shared' },
190+
{ id: 'body', text: 'the body', scope: 'shared' },
191+
],
192+
})
193+
},
194+
)
195+
158196
test(
159197
"a user plugin's rewrite of policy is skipped for every other reader",
160198
{

‎mods/types/claude-code.d.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2542,6 +2542,18 @@ declare module 'claude-code' {
25422542
* void $.prompt.suggest({ text: "run the tests you just wrote" })
25432543
*/
25442544
suggest: EventCalls['prompt']['suggest'];
2545+
/**
2546+
* Returns the system prompt's sections for `facts`: the event
2547+
* `prompt.compose`, the call the engine makes for every prompt it sends.
2548+
*
2549+
* A fact left out is the session's own (its model, its tools). Through
2550+
* every other plugin's hook, over the engine's own composition; composed
2551+
* for nobody to send, so nothing the session holds is written.
2552+
*
2553+
* @example
2554+
* const ids = (await $.prompt.compose()).sections.map(s => s.id)
2555+
*/
2556+
compose: EventCalls['prompt']['compose'];
25452557
};
25462558
/**
25472559
* The tools the model has in this session, and running one.
@@ -3417,6 +3429,18 @@ declare module 'claude-code' {
34173429
* on("prompt.context", () => ({ blocks: [] }))
34183430
*/
34193431
'prompt.context': PromptContextInput;
3432+
/**
3433+
* Fires when the engine renders a system prompt; `next(e)` resolves to
3434+
* `{ sections }`, each `{ id, text, scope }`, in the order they are sent.
3435+
*
3436+
* The bottom is the engine's own composition (`intro`, `tools`, `memory`,
3437+
* ...). Append, replace by id, reorder or drop what `next(e)` answered;
3438+
* answer without `next` to replace it all. The engine places each cache mark.
3439+
*
3440+
* @example
3441+
* on("prompt.compose", async ($, e, next) => dropped(await next(e), "tone"))
3442+
*/
3443+
'prompt.compose': PromptComposeInput;
34203444
/**
34213445
* Fires once per message the engine injects for the model on its own (a
34223446
* reminder, a mode transition, a mentioned file), as a request carries it.
@@ -3741,6 +3765,11 @@ declare module 'claude-code' {
37413765
* `{ blocks }` (a block left out is not sent).
37423766
*/
37433767
'prompt.context': PromptContextResult;
3768+
/**
3769+
* `{ sections }`, every `shared` one ahead of every `session` one (a
3770+
* section left out is not sent).
3771+
*/
3772+
'prompt.compose': PromptComposeResult;
37443773
/**
37453774
* `{ text }` (null leaves the attachment out).
37463775
*/
@@ -3853,6 +3882,7 @@ declare module 'claude-code' {
38533882
section: (input: PromptSectionInput) => Promise<PromptSectionResult>;
38543883
context: (input: PromptContextInput) => Promise<PromptContextResult>;
38553884
attachment: (input: PromptAttachmentInput) => Promise<PromptAttachmentResult>;
3885+
compose: (input?: PromptComposeArgs) => Promise<PromptComposeResult>;
38563886
};
38573887
skill: {
38583888
prompt: (input: SkillPromptInput) => Promise<SkillPromptResult>;
@@ -6622,6 +6652,118 @@ declare module 'claude-code' {
66226652
cursor: number;
66236653
};
66246654

6655+
/**
6656+
* What a plugin passes `$.prompt.compose`: the facts it wants composed for,
6657+
* each one it leaves out read off the session (its model, its tools).
6658+
*/
6659+
export type PromptComposeArgs = Partial<PromptComposeInput>;
6660+
6661+
/**
6662+
* The input of `prompt.compose`: the facts a system prompt is composed from,
6663+
* each already resolved by the engine, at the moment it renders one.
6664+
*/
6665+
export type PromptComposeInput = {
6666+
/**
6667+
* The id of the model the request is for; pinned, the field a matcher
6668+
* narrows on.
6669+
*/
6670+
model: string;
6671+
/**
6672+
* The model whose prompt is rendered: `model`, unless the engine renders
6673+
* another model's prompt for it (a model it holds no prompt of its own for).
6674+
*/
6675+
promptModel: string;
6676+
/**
6677+
* Where the session draws at this render, as `$.session.surfaces()`
6678+
* answers: `terminal` first under the REPL; empty where nothing draws.
6679+
*/
6680+
surfaces: readonly RenderSurface[];
6681+
/**
6682+
* The names of the tools the request offers the model; the engine's own
6683+
* composition reads them against the session's, an unknown name ignored.
6684+
*/
6685+
tools: readonly string[];
6686+
/**
6687+
* What the person chose in place of the default way of answering, and
6688+
* whether it keeps the coding instructions; null for the default style.
6689+
*/
6690+
outputStyle: {
6691+
name: string;
6692+
isKeepingCodingInstructions: boolean;
6693+
} | null;
6694+
traits: readonly PromptComposeTrait[];
6695+
};
6696+
6697+
/**
6698+
* What a `prompt.compose` hook returns: the sections of the system prompt,
6699+
* in order, every `shared` one ahead of every `session` one.
6700+
*
6701+
* A section left out is not sent; a hook that never calls `next` answers
6702+
* the whole list. The engine joins each side, places the cache boundary
6703+
* between them and every cache marker itself.
6704+
*/
6705+
export type PromptComposeResult = {
6706+
sections: readonly PromptComposeSection[];
6707+
};
6708+
6709+
/**
6710+
* Which side of the prompt cache's boundary a section of the system prompt
6711+
* sits on: `shared` before it, `session` after it.
6712+
*
6713+
* `shared` is text that reads the same for every person on this build and
6714+
* model: it is sent in the block the API may cache across organizations.
6715+
* `session` is text that varies with the person, the machine or the session.
6716+
*
6717+
* The engine places the one boundary and every cache marker itself,
6718+
* whatever a list says; `shared` text that varies hits that cache for nobody.
6719+
*/
6720+
export type PromptComposeScope = 'shared' | 'session';
6721+
6722+
/**
6723+
* One section of the system prompt as `prompt.compose` answers it: a stable
6724+
* id, the text the model reads, and the side of the cache boundary it is on.
6725+
*
6726+
* @example
6727+
* const POLICY = { id: "acme:policy", text: "# Policy\n...", scope: "session" }
6728+
*/
6729+
export type PromptComposeSection = {
6730+
/**
6731+
* What a hook above finds the section by, to replace, move or drop it;
6732+
* never empty, and unique in one list.
6733+
*
6734+
* A section a plugin adds is named `<plugin>:<name>`; the bare names are
6735+
* the engine's own composition's (`intro`, `tools`, `memory`, ...).
6736+
*/
6737+
id: string;
6738+
/**
6739+
* The section's text, sent as written; sections on one side of the
6740+
* boundary are joined by a blank line, in the list's order.
6741+
*/
6742+
text: string;
6743+
scope: PromptComposeScope;
6744+
};
6745+
6746+
/**
6747+
* One branch the engine's own composition of the system prompt takes on the
6748+
* request or the session before it computes any section: a closed set.
6749+
*
6750+
* `bare`: the session runs with the one-line prompt (`--bare`). `lean`: the
6751+
* prompt model takes the short body. `sdk-preset`: the SDK's `claude_code`
6752+
* preset, whose per-person sections ride the first user message instead.
6753+
*
6754+
* `teammate`: an in-process teammate's render of its lead's prompt.
6755+
* `analysis`: a render that measures the prompt (`/context`) and sends
6756+
* nothing. `print`: a session with no terminal behind it (`-p`, the SDK).
6757+
*
6758+
* `skills`: the Skill tool has commands to list. `send-user-message`: the
6759+
* session speaks to the person through a message tool.
6760+
*
6761+
* Rewritten going down, `sdk-preset`, `teammate` and `analysis` steer the
6762+
* engine's composition; the rest it derives itself, so they tell a hook what
6763+
* it will do. What one section's own text turns on (a flag) is not here.
6764+
*/
6765+
export type PromptComposeTrait = 'bare' | 'lean' | 'sdk-preset' | 'teammate' | 'analysis' | 'print' | 'skills' | 'send-user-message';
6766+
66256767
/**
66266768
* One block of the context the first user message carries: a name the
66276769
* engine keys it by and the text under it.

0 commit comments

Comments
 (0)