Skip to content
Merged
9 changes: 9 additions & 0 deletions docs/plans/nested-instruction-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Referenced-path instruction discovery

PR #1976 originally performed a serial full-tree scan at the start of every turn, even when a prompt referenced one file. On a macOS Node 24.20 fixture with 100 groups of 100 directories, five fresh-turn activations took 650, 614, 773, 596 and 633 ms; same-turn cache hits took less than 1 ms.

Turn-time discovery now reads only each referenced path's ancestor scopes, with at most 64 distinct context paths per call and 16 ancestor levels. Concurrent tool calls share in-flight reads of each scope. Missing instruction files are cached too. Every turn owns a fresh cache; a file-tool write, rename or deletion of AGENTS.md invalidates that execution root. A previously unseen scope is read on first entry. Already-read scopes changed through an external editor or shell are refreshed next turn. Already-injected rules remain fixed for the current turn; invalidating discovery permits new activations, not replacement of existing prompt instructions. No filesystem watcher or cross-turn cache is trusted for prompt correctness.

Settings retains the bounded full inventory and its existing 30-second cache. Instruction precedence, workspace trust, directory-symlink exclusion, nested checkout boundaries, generated-directory exclusion, file-size limits and prompt budgets remain enforced. A symlinked instruction file still passes through the existing canonical-path read boundary. The existing handwritten filter predicate is replaced with TypeScript's inferred predicate.

The same fixture after the change measured fresh-turn activations of 14, 13, 5, 8 and 6 ms while other validation ran. These are local measurements, not a cross-platform latency guarantee. Run `pnpm test -- nested-instruction-latency` to repeat the real public activation-path benchmark; it prints each fresh-turn and cached duration. `pnpm test -- project-instructions` covers scope freshness, explicit invalidation, trust and repository/symlink boundaries.
9 changes: 9 additions & 0 deletions docs/ui-taste.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,15 @@ left-elision (`direction: rtl` + `text-overflow: ellipsis`, same trick as `.git-
the leaf stays visible; mirror the full path on the row's `title` for the native tooltip. Spec:
[`tests/e2e/settings-sources-skills.e2e.ts`](../tests/e2e/settings-sources-skills.e2e.ts).

Nested instruction rows use the badge as activation state, not origin: **active** uses the existing
accent outline when the latest turn selected that scope; **scoped** uses the quiet default outline when
the file is available but unrelated to that turn; **duplicate** (quiet outline) marks a nested file
whose text repeats one already listed, so it is loaded once through that one. Keep the governed
directory and the explanatory state in the detail line so sibling scopes are understandable without
adding another row of chips. When discovery stopped at its cap, a single `.sources-empty` line under
the list says the list may be incomplete — a note, not a row.
Spec: [`tests/e2e/settings-sources-nested-instructions.e2e.ts`](../tests/e2e/settings-sources-nested-instructions.e2e.ts).

## Prove visual changes with a focused e2e eval

Per `AGENTS.md`, any user-visible change needs a focused WebdriverIO Electron spec that seeds the
Expand Down
1 change: 1 addition & 0 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ contributor and design archive — start here instead.
- [Install](install.md) — macOS 26+ from a signed DMG, or run from source
- [Quickstart](quickstart.md) — open a project, pick a model, send the first prompt
- [Connect a model](connect-a-model.md) — API key, environment scan, or a local server
- [Project instructions](project-instructions.md) — root and directory-scoped AGENTS.md files

## Staying in control

Expand Down
57 changes: 57 additions & 0 deletions docs/user/project-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: Project instructions
description: Give Copse global, project-wide, or directory-scoped guidance with AGENTS.md files.
---

# Project instructions

Instruction files let a repository carry its own build commands, conventions, and safety notes.
Copse lists every discovered source under **Settings → Customise → Instruction files**. Project
instructions stay inert until you trust the project; click a file name there to read it first.

## Project-wide instructions

At the project root, Copse reads these files in order:

1. `AGENT.md`
2. `AGENTS.md`
3. `CLAUDE.md`

Identical contents are injected once. Global `~/AGENTS.md` and `~/.claude/CLAUDE.md` load beneath
the project layer as user-owned guidance.

## Directory-scoped instructions

A nested `AGENTS.md` applies only when a path under its directory enters the turn's context. A
path enters context when the prompt or an attachment names it, or when one of Copse's built-in
file tools touches it: `read_file`, `list_dir`, `search_code`, `search_codebase`,
`read_staged_diff`, `write_file`, `str_replace`, `delete_file`, `rename_file`, and
`make_directory`. Nothing else activates a nested file: a `run_shell` command that reads or writes
under the directory, an ACP agent's own file access, and a subagent's reads do not count.
Instructions are applied from the project root toward the target directory, so the nearest file
appears last and can refine broader conventions. Sibling scopes stay inactive unless that sibling
has a relevant path.

When an edit tool is the first action to enter a new scope, Copse loads the applicable instruction
chain and defers that edit once. The agent sees the new rules and retries instead of changing the
file before its local guidance is available. Each activation adds a one-line note to the
transcript naming the file that was loaded.

During a turn, Copse reads only the ancestor directories of referenced paths, rather than scanning
unrelated parts of the repository. Each scope, including a missing `AGENTS.md`, is read once when
first referenced and shared by later tool calls. Writing, moving, or removing an `AGENTS.md` with a
file tool invalidates those reads, so a later tool can activate newly available instructions. Rules
already injected remain in the current turn; changes to those rules apply next turn. External
edits to an already-read scope are also seen next turn. A newly referenced scope is read when the
agent first enters it. Settings still discovers the full bounded inventory.

Nested `AGENT.md` and `CLAUDE.md` remain root-only compatibility formats. Only `AGENTS.md` follows
the cross-client directory-scoping convention, which avoids silently changing the meaning of
vendor-specific files.

Sources marks a nested file **active** when the latest turn used it, **scoped** when it is
available but did not apply, and **duplicate** when its text repeats a file already listed (the
text is loaded once, through that file). Discovery skips dependency, generated, vendored, cache,
nested-repo, and VCS trees; it does not follow a symlink outside the trusted workspace. Very deep
or unusually large instruction trees are bounded so they cannot consume the whole prompt; when
discovery stops at that bound, Sources says the list may be incomplete.
2 changes: 1 addition & 1 deletion schemas/api-protocol.manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Generated by scripts/gen-api-protocol.mts from ApiClient (src/preload/api.d.ts) and the preload bindings; do not edit by hand. The full JSON Schema is a build output (dist/schemas/api-protocol.schema.json). See docs/api-protocol.md.",
"version": 4,
"version": 5,
"channels": {
"invoke": {
"acp:auto-setup": {
Expand Down
35 changes: 28 additions & 7 deletions src/main/ipc/register-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2142,13 +2142,34 @@ export function registerAllHandlers(win: BrowserWindow, registry: ToolRegistry):
return listUnsandboxedProjectHooks(root)
})
ipcMain.handle('instructions:list', async () =>
(await loadProjectInstructionSources()).map(({ path, name, scope, content, active }) => ({
path,
name,
scope,
bytes: Buffer.byteLength(content, 'utf-8'),
active,
})),
(
await loadProjectInstructionSources({
useLatestNestedActivation: true,
refreshNestedDiscovery: true,
})
).map(
({
path,
name,
scope,
content,
active,
trusted,
scopePath,
duplicateOf,
discoveryTruncated,
}) => ({
path,
name,
scope,
bytes: Buffer.byteLength(content, 'utf-8'),
active,
trusted,
...(scopePath !== undefined ? { scopePath } : {}),
...(duplicateOf !== undefined ? { duplicateOf } : {}),
...(discoveryTruncated ? { discoveryTruncated } : {}),
}),
),
)
/**
* Read one instruction file for display (Settings → Sources opens it in the
Expand Down
Loading
Loading