Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 20. októbra 2026

Prechádzať dokumentáciu · Project instruction modules

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Developer reference

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, its forPaths method, and reportInstructionDiagnostics. Glob matching and activation ordering live here; unconditional rules are never returned by forPaths.
  • src/brain/session/contextFiles.ts: discovery, frontmatter parsing, diagnostics and host/guest transport. resolveSessionContextFiles keeps the root file's behaviour and name priority unchanged and returns the unconditional rules after it as ContextFile entries. 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: installInstructionActivation registers the file-access policy with installContextRecordPolicy in contextRecordDriver.ts and returns observe, fed from the tool-result seam. The driver freezes and appends the instruction envelope; contextDelivery.ts selects 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: the instructions turn-context kind, its details.instructions identity list, and the protected-kind rule that keeps loaded instructions through cold clearing.
  • src/brain/session/resourceLoader.ts installs the handler with the session extension, after live recall and step context, and hands observe back through onObserve; src/brain/session/factory.ts passes the spawner's instructions there beside live recall. src/brain/service/spawner.ts builds the resolver from the same project root and managed reference as resolveSessionContextFiles, and composes observe into the plugin tool-result observer. SessionSpec.instructions takes { resolver }; BrainSessionFactory.create returns instructionObserver, which the spawner invokes before the plugin hook bus.
  • plugins/files/index.mjs is the only producer of the trigger field details.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. paths must 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.
  • paths is the only key the frontmatter may carry. Any other key, invalid YAML, an unterminated frontmatter block or a malformed paths value makes the whole module invalid: it is refused with a diagnostic and is never promoted to an unconditional module, so a misspelled path: 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.md files 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 .elowen tree, 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, realPathWithin from src/plugins/pathUtils.ts requires each module file and each nested AGENTS.md to resolve inside realpath of the Project root. A symlink out of the Project is refused with brain.instruction_module_escape and 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/rules stay unwalked, but links outside the root now report the escape diagnostic. The host source carries per-entry refusals in ContextFileEntry.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 in C:\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/rules tree 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.