Project instruction modules
Delegated prompt and project context
Fresh delegated workers use the shared engineering foundation and worker contract before the existing
model/tool/plugin suffix, role and account instructions. Root instructions and unconditional modules
still enter through resolveSessionContextFiles in project_context, followed by cwd. Conditional
instruction activation uses the same resolver and retained journal records as conversational sessions.
The lean persona changes neither project eligibility nor host, managed or local transport boundaries.
A fork retains its parent's prefix; it does not receive this fresh-worker composition. See
turn composition and delegation runtime.
Local CLI instruction sources
SessionContextFilesInput.local carries the selected local target, its platform, acting account and session id. The platform picks the source's path semantics (localTargetPath): a Windows computer's files are joined and listed with path.win32, and ContextFileSource.within(root, path), which both module discovery and on-demand activation use, answers the /-form project-relative path, so .elowen\rules\api.md is the module .elowen/rules/api.md and its paths globs match touched files in / form. A local root is not realpath-resolved on the daemon. contextFileSource obtains its file operations from SandboxControl.localFiles and reuses the shared paged file client for the root instruction file and rule modules. Selection order, limits and the spawner's account eligibility stay the same. The root is the conversation's resolved working directory on the computer (effectiveTurnWorkDir: a child's placed directory, else the recorded work_dir, which a paired CLI sets to the directory it was started in, else the computer's default), not a confinement boundary. elowen started in ~/repo therefore loads ~/repo's AGENTS.md, CLAUDE.md and .elowen/rules, also after a cold respawn.
New CLI and browser conversations carry the chosen target through /brain/start before the first
spawn. Startup instructions are therefore fixed from that target, rather than from the server default
before a later Project selection. Existing resumes retain their stored target and startup prefix.
Core binds the original connection around startup reads. A missing Sandbox owner or LOCAL_EXECUTION_LOST fails startup; an absent client file never falls through to a same-named daemon file. No ancestor walk or daemon checkout instructions are involved. Local module reads use this same source later in the bound turn, with bounded transport bytes and no extra model request.
A Project can keep its root AGENTS.md short and split the rest of its instructions into modules that
travel with the work: .elowen/rules/**/*.md modules selected by the path the agent touches, and nested
AGENTS.md files in the folders of the files it touches. Both reach the model through the existing
project-context path. The root file, and every rule module that declares no condition, ride the system
prompt at spawn; a conditional module or a nested file is appended to the conversation as a turn-context
record just before the next model request, and stays in the journal from then on.
The mechanism deliberately stops there: it is not a pre-write gate, not a permission layer and not a second instruction runtime. What the model does with a module is still decided by the ordinary tool, Project and account authority.
Owning code
src/brain/session/instructionModules.ts:createInstructionResolver, itsforPathsmethod, andreportInstructionDiagnostics. Glob matching and activation ordering live here; unconditional rules are never returned byforPaths.src/brain/session/contextFiles.ts: discovery, frontmatter parsing, diagnostics and host/guest transport.resolveSessionContextFileskeeps the root file's behaviour and name priority unchanged and returns the unconditional rules after it asContextFileentries. It is the only startup entry point.CONTEXT_FILE_MAX_BYTES, 256 KiB, is the per-file cap on both filesystems.src/brain/session/instructionActivation.ts:installInstructionActivationregisters the file-access policy withinstallContextRecordPolicyincontextRecordDriver.tsand returnsobserve, fed from the tool-result seam. The driver freezes and appends the instruction envelope;contextDelivery.tsselects modules against the latest retained relative-path/hash identity. An empty or diagnostic resolution appends nothing. A rejected resolver still escapes the hook after draining its batch; only a failed append requeues it.src/brain/session/turnContextRecords.ts: theinstructionsturn-context kind, itsdetails.instructionsidentity list, and the protected-kind rule that keeps loaded instructions through cold clearing.src/brain/session/resourceLoader.tsinstalls the handler with the session extension, after live recall and step context, and handsobserveback throughonObserve;src/brain/session/factory.tspasses the spawner'sinstructionsthere beside live recall.src/brain/service/spawner.tsbuilds the resolver from the same project root and managed reference asresolveSessionContextFiles, and composesobserveinto the plugin tool-result observer.SessionSpec.instructionstakes{ resolver };BrainSessionFactory.createreturnsinstructionObserver, which the spawner invokes before the plugin hook bus.plugins/files/index.mjsis the only producer of the trigger fielddetails.fileAccess. The field's catalog row is seam 71.- The user-facing description is Context files.
Format and discovery
A module is one Markdown file found recursively under .elowen/rules, plus any AGENTS.md in a folder
under the root. Frontmatter decides which of the two kinds a rule module is:
---
paths:
- "src/api/**/*.ts"
- "tests/api/**/*.ts"
---
# API rules
- Validate endpoint inputs.
- Update the contract tests.
- Without frontmatter, or with an empty one, the module is unconditional: it is loaded at spawn, after the root file, and never returned by a trigger pass.
- With
paths, the module is conditional.pathsmust be a non-empty YAML list of non-empty strings, each a Project-relative POSIX glob.*,**and?are supported, the patterns compose as OR, and a leading/, a backslash or a..segment is refused. pathsis the only key the frontmatter may carry. Any other key, invalid YAML, an unterminated frontmatter block or a malformedpathsvalue makes the whole module invalid: it is refused with a diagnostic and is never promoted to an unconditional module, so a misspelledpath:cannot quietly turn a scoped rule set into a global one.- Frontmatter is never part of what the model reads. The sent content is the body with the frontmatter and a BOM stripped, and its SHA-256 is the identity half of the module's dedupe key.
- Nested
AGENTS.mdfiles are found for the folders of the touched file, from the first folder under the root down to the file's own folder, and use the same name priority as the root loader (AGENTS.override.md,AGENTS.md,AGENTS.MD,CLAUDE.md,CLAUDE.MD). The root file is never re-attached, because it is already in the system prompt. Nested discovery does not enter the.elowentree, whose rule modules use the rule loader instead. - Touching a file outside the Project root contributes nothing and is not even diagnosed.
Triggers and timing
A module activates when the agent performs a successful, authorized Read, Write or Edit on a matching
file, whether the call comes from the model directly or from a script inside code mode. The files plugin
reports that with details.fileAccess = { path } on its result; core triggers on that field and never on a
tool name, so any future tool that declares the same field participates without a second code path. A
refusal, an error and a read past the end of a file carry no field and therefore activate nothing.
An unchanged repeated Read still carries the field: its content reminder saves tokens, but the authorized
file access must still discover a changed instruction module.
Mentioning a path in prose, listing a directory, or running a command in Bash is not a trigger. Bash parsing is explicitly out of scope for this mechanism.
Activation is applied to the NEXT model request, never to the call that triggered it. The observer only
queues the touched path; the queue is drained inside context_with_system, which runs after the tool
result has settled and before the next provider request is composed. The call and result chain therefore
stays whole, and a module is never a condition on the write that touched it.
Ordering and composition
Within one activation, the modules are sent in resolver order: nested AGENTS.md files shallow to deep,
then every conditional rule whose globs match at least one touched path, ordered by relative path. Each
module is sent whole, with its source path, as:
<instruction_module path="/project/src/api/x.md">
CONTENT
</instruction_module>
Modules compose. They are appended in order and none of them replaces or rewrites another; a general rule and a narrow one both reach the model, in that order. One activation appends exactly one turn-context record, and a path touched twice in the same batch is one module.
Lifetime
An activation is a turn-context record of kind instructions, appended through the journal with the
placement after-message like live recall, and carrying details.instructions as a list of
{ relPath, sha256, supersedes }. Nothing counts as loaded unless that append succeeded: when the append
fails, brain.instruction_activation_failed is logged, the touched paths go back into the queue, and the
activation can retry at the next context boundary. A durable journal failure also trips the existing
persistence latch, which blocks further provider requests until the session is restored.
Identity is the Project-relative path plus the SHA-256 of the sent content, and presence is computed from the ACTIVE context, not from a process-wide set. Dedupe compares the current content with the latest active-context hash for that path; the last journal record wins. Three consequences follow:
- A module matching its latest active version is not sent twice, including when two triggers arrive in parallel or when a restored session continues the same conversation.
- A restart, a respawn or a fork restores the loaded modules from the journal, because they are journal records like any other turn context. Nothing is appended again for content that is still in the active context.
- After compaction has dropped the record together with the summarized history, the module is no longer present, so the next trigger that matches it loads it again. The full content is what matters, so a compacted summary of a module does not count as the module.
A file whose content changed is a different version: the next trigger computes a new hash, finds the path
present with another hash, and appends the new content marked supersedes="earlier version". There is no
watcher, so nothing loads by itself; the change is picked up by the next access to a matching file.
Reverting to earlier content is also a change when the latest active version differs, so it is appended
with the same supersedes marker.
Cold clearing does not strip instruction records. Instructions are protected the way skills and permissions are, because they are project facts rather than rebuilt per-turn context.
Boundaries and authority
An administrator's own chat and its delegated sub-agents receive the same Project instruction scope:
the root file and unconditional rules at spawn, and the same resolver for conditional rules and nested
instruction files on demand. This includes further delegation levels. Core uses the durable
parent_session_id chain and the existing isOwnedUserSession predicate to prove that a non-fork
sub-agent is rooted in that administrator's own conversation. The ancestry walk is shared with owner
drill-in through delegationRoot; it checks the account on every edge and rejects missing rows,
orphaned intermediate children and cycles. Captured admin or owner bits alone are insufficient:
an administrator in a shared room can hold them too.
The bound Project wins. Without one, the existing projectForPath lookup selects the accessible
registered host Project containing the session's own cwd. projectContextRoot extends that placement
for linked Git worktrees on the host: Git's .git file declares gitdir:, and the pointed-to
administrative directory's commondir identifies the registered Project's own .git directory.
Both relative pointers are resolved as documented by Git, and repository identity is compared using
real paths, with no directory-name convention. A matching worktree supplies its own top-level root,
so startup files and on-demand modules both come from the child's working branch. A linked worktree of
another repository contributes nothing, even if a host Project is bound. Project lookup uses the child's
policy, including canAccessProject for a scoped lookup. An unregistered directory contributes nothing.
Managed Projects keep their existing guest root and transport, without host Git inspection or environment
startup. Forks retain owner-chat composition and the same root selection as their parent. Other accounts,
shared platform conversations, and children whose chain ends in a shared channel or a cron execution
receive nothing new. A scheduled turn delivered into an administrator's own conversation retains that
owner-chat scope, including its delegated descendants; a brain-ch-cron-* root does not.
A chatbot account keeps its existing exception: it receives only its bound Project's root file and modules when its live policy admits that Project. The exception still applies to a delegated session owned by that chatbot. An unbound chatbot child gains no cwd lookup or inherited instruction scope.
This changes only spawn eligibility and host root placement. The root file and unconditional modules are
still read once at spawn and fixed in the system prompt for caching. Both consumers still share one
contextFileInput, with the same transport, 256 KiB per-file cap and diagnostics. Read/Write/Edit,
details.fileAccess, activation timing and journal lifetime are unchanged; delegation adds no tool or
Project authority. The spawn regressions live in tests/brain/subagentInstructionFiles.test.ts.
Every read is bounded twice:
- On the host,
realPathWithinfromsrc/plugins/pathUtils.tsrequires each module file and each nestedAGENTS.mdto resolve insiderealpathof the Project root. A symlink out of the Project is refused withbrain.instruction_module_escapeand is not loaded. This is the one place where modules are stricter than the root file, which follows a symlink as PI's own loader does. The resolver normalizes its host root once with the same realpath helper used by the tool boundary; guest paths remain lexical. Host directory symlinks inside.elowen/rulesstay unwalked, but links outside the root now report the escape diagnostic. The host source carries per-entry refusals inContextFileEntry.refusal, which discovery reports before filtering Markdown names. In-project rule-file symlinks are still followed. - Inside a managed Project, entries that are symlinks are refused for the same reason, and content is read
through the bounded guest read with
startIfNeeded: false. Opening a conversation never starts a stopped environment, so a module of a stopped environment is simply absent until the environment runs again. A listing that fails for any other reason than a stopped or pending environment still yields no files, but is logged at info level with the failure text. - The root's instruction-file names are found with one guest listing of at most 1,000 entries, starting just
below
AGENTS. A directory with more names before the candidates, such as a CLI started inC:\Windows\System32, is treated as having no instruction file and logged at info level, because it is a fact about that directory rather than a daemon fault.
A file above CONTEXT_FILE_MAX_BYTES is refused whole, on both filesystems, with the existing
brain.context_file_oversized error. Instructions are never truncated: half a rule set reads as a whole
one to the model.
Diagnostics
Module diagnostics use logger('brain'); activation failures use logger('brain-instruction-modules').
Both log with a declared code. The daemon log keeps the full message, and problem
reporting, when it is enabled, carries its redacted sample:
| Code | Level | Meaning |
|---|---|---|
brain.instruction_module_invalid |
warn | Invalid YAML, an unknown frontmatter key, or malformed paths. The module is not loaded at all. |
brain.context_file_oversized |
error | A file over 256 KiB. The module is refused whole, never truncated. The code is shared with the root file. |
brain.instruction_module_unreadable |
warn | The file could not be read. No silent catch, and the spawn still proceeds. |
brain.instruction_module_escape |
warn | A path that resolves outside the Project root, on the host through a symlink, or a symlink entry in the guest. |
brain.instruction_activation_failed |
warn | The journal append failed. Nothing is considered loaded and the paths are retried on the next request. |
A module that fails to load never falls back to a global one and never ends a turn.
Cost and limits
What a session pays is what it actually touches. An unconditional module sits in the cached system prompt for the session's whole life and is paid on a cold prefix, exactly as the root file is; a conditional module costs its tokens from the moment it activates, once, and then rides the journal. No saving is claimed here, because none has been measured; the property this mechanism establishes is that a conversation carries the rules of the files it works on and not the rest.
Known limits:
- A trigger caught immediately before a daemon pause, meaning a tool result with no further model request, is lost from the in-memory queue. The module loads on the next access to a matching file. Modules that were already loaded are restored from the journal, so a pause never loses them.
- Discovery is not cached. Every trigger re-lists and re-reads, which is what makes a changed file visible
on the next access, and is also why a very large
.elowen/rulestree costs a directory walk per trigger. - Bash commands are not parsed, so a module is not selected by a path that only appears in a shell command.
- There is no UI editor, no manual load tool, no topic-based selection and no automatic splitting of
AGENTS.md.