surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
* mods/types: refresh the engine typings; diff's old-files fixture names isLink * mods/agents-md: the AGENTS.md project-instructions mod * mods/agents-md: the instructionFiles option with its legacy key, as shipped; typings at 2.1.277
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | A mod is a Claude Code plugin whose behaviour lives in a hooks module: one |
| 4 | 4 | `register(on, options)` entry that hooks the engine's events as functions |
| 5 | -`($, e, next)`. These three ship inside Claude Code; this folder is their | |
| 5 | +`($, e, next)`. These four ship inside Claude Code; this folder is their | |
| 6 | 6 | source, published as it is built into the binary. |
| 7 | 7 | |
| 8 | 8 | | Mod | What it does | Seated | |
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | | [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise | |
| 11 | 11 | | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in | |
| 12 | 12 | | [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in | |
| 13 | +| [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in | | |
| 13 | 14 | |
| 14 | 15 | Each folder is a complete plugin: `.claude-plugin/plugin.json`, a |
| 15 | 16 | `hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed |
| @@ -1,0 +1,23 @@ | ||
| 1 | +{ | |
| 2 | + "name": "agents-md", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "description": "AGENTS.md as project instructions, by one option. Under claude-md-or-agents-md a project with no instruction files of its own gets its AGENTS.md files instead: a prompt.context hook asks $.fs.ancestors for every AGENTS.md and .claude/AGENTS.md from the filesystem root down to the working directory and hands them to the engine as project instruction files, which it renders, announces and withholds from agents exactly as it does CLAUDE.md, skipping any file the engine already loaded by import or link; a tool.call hook on Read attaches the ones under the project root as the engine attaches a nested CLAUDE.md. Under claude-md-and-agents-md every AGENTS.md is loaded beside CLAUDE.md. Under managed-only the project's and the person's instruction files are dropped and the organization's managed ones kept. Under claude-md the engine's walk stands alone. claude-md-or-agents-md is the default.", | |
| 5 | + "author": { | |
| 6 | + "name": "Anthropic" | |
| 7 | + }, | |
| 8 | + "userConfig": { | |
| 9 | + "instructionFiles": { | |
| 10 | + "type": "string", | |
| 11 | + "title": "Project instructions", | |
| 12 | + "description": "\"claude-md\": CLAUDE.md only, loaded by the engine as today. \"claude-md-or-agents-md\" (default): a project with no CLAUDE.md of its own gets its AGENTS.md files instead, loaded exactly where and how CLAUDE.md would be. \"claude-md-and-agents-md\": AGENTS.md files are loaded beside CLAUDE.md (a file CLAUDE.md already imports or links to is not loaded twice). \"managed-only\": the project's and your own instruction files are dropped; the organization's managed CLAUDE.md and memory stay.", | |
| 13 | + "required": false, | |
| 14 | + "default": "claude-md-or-agents-md", | |
| 15 | + "options": [ | |
| 16 | + "claude-md", | |
| 17 | + "claude-md-or-agents-md", | |
| 18 | + "claude-md-and-agents-md", | |
| 19 | + "managed-only" | |
| 20 | + ] | |
| 21 | + } | |
| 22 | + } | |
| 23 | +} |
| @@ -1,0 +1,163 @@ | ||
| 1 | +# agents-md | |
| 2 | + | |
| 3 | +`AGENTS.md` read the way Claude Code reads `CLAUDE.md`, as a plugin, under | |
| 4 | +one option, `instructionFiles`: | |
| 5 | + | |
| 6 | +- `claude-md`: only `CLAUDE.md` is loaded, by the engine, as today. The plugin | |
| 7 | + adds nothing. | |
| 8 | +- `claude-md-or-agents-md` (the default): a project with no instruction files | |
| 9 | + of its own gets its `AGENTS.md` files instead, loaded exactly where and how | |
| 10 | + `CLAUDE.md` would be. "Of its own" is read off what the engine loaded for | |
| 11 | + the context: a `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` in any | |
| 12 | + directory from the root down to the working directory leaves the whole | |
| 13 | + project to the engine, and the plugin stays out (the organization's managed | |
| 14 | + file, the person's `~/.claude/CLAUDE.md`, a `.claude/rules` file and an | |
| 15 | + added directory's `CLAUDE.md` do not count, as the nested walk does not see | |
| 16 | + them either). With none, every `AGENTS.md` and `.claude/AGENTS.md` on that | |
| 17 | + path joins the instruction files the engine renders, and a `Read` under a | |
| 18 | + subdirectory attaches that directory's `AGENTS.md` unless a `CLAUDE.md` | |
| 19 | + there claims it. | |
| 20 | +- `claude-md-and-agents-md`: every `AGENTS.md` is loaded beside `CLAUDE.md`, | |
| 21 | + up and down the tree; a file `CLAUDE.md` already `@`-imports, or is a link | |
| 22 | + to, is not loaded a second time (compared by path, then by content). | |
| 23 | +- `managed-only`: the project's checked-in and private instruction files and | |
| 24 | + the person's own are dropped from the context; the organization's managed | |
| 25 | + `CLAUDE.md` and the engine's memory stay. The engine's nested `CLAUDE.md` | |
| 26 | + attachments on `Read` are not an event yet and still arrive. (The engine's | |
| 27 | + `claudeMdExcludes` setting also exists, for user, project and local files, | |
| 28 | + and applies to the `AGENTS.md` files this plugin reads too.) | |
| 29 | + | |
| 30 | +How the files reach the model is the engine's doing, not the plugin's: | |
| 31 | +`prompt.context` hands a hook the instruction files behind `claudeMd` | |
| 32 | +(`{ path, kind, content, parent? }`, kinds `managed`, `user`, `project`, | |
| 33 | +`local`, `memory`, in load order) and a hook answers the list changed. The | |
| 34 | +engine then renders `claudeMd` from the answered files with its own preamble | |
| 35 | +and framing, announces them by name, and keeps only the `managed` ones for an | |
| 36 | +agent that omits project instructions (Explore, Plan, a custom agent with | |
| 37 | +`omitClaudeMd`). So an `AGENTS.md` this plugin adds as a `project` file is, to | |
| 38 | +everything downstream, a project instruction file: same place in the context, | |
| 39 | +same framing, same omission rules, same announcement. An organization's | |
| 40 | +prepended plugin on `prompt.context` sits above this one and has the last | |
| 41 | +word on the files. | |
| 42 | + | |
| 43 | +`hooks/register.ts` is the module; everything under `hooks/` is its parts, | |
| 44 | +importing `claude-code` and one another alone. `tests/` runs under | |
| 45 | +`claude plugin test <this folder>`. | |
| 46 | + | |
| 47 | +## Setting the option | |
| 48 | + | |
| 49 | +As a built-in its option is the `/config` row "Project instructions", a | |
| 50 | +picker over the four values, each described there. By hand it is | |
| 51 | + | |
| 52 | +```json | |
| 53 | +{ | |
| 54 | + "pluginConfigs": { | |
| 55 | + "agents-md@builtin": { | |
| 56 | + "options": { "instructionFiles": "claude-md-and-agents-md" } | |
| 57 | + } | |
| 58 | + } | |
| 59 | +} | |
| 60 | +``` | |
| 61 | + | |
| 62 | +in user settings (`~/.claude/settings.json`), `--settings`, or managed | |
| 63 | +settings; a project's `.claude/settings.json` is not read for plugin | |
| 64 | +options. Changing it reloads the module, and the next context the engine | |
| 65 | +builds (the next turn after the reload, a new conversation, `/clear`, a | |
| 66 | +compaction) carries the new mode's files. A hand-typed value outside the | |
| 67 | +four is told once in the transcript and reads as the default. `/plugin` | |
| 68 | +lists the plugin among the built-ins, where a person can turn it off; with | |
| 69 | +it off the engine reads `CLAUDE.md` alone. | |
| 70 | + | |
| 71 | +The option was first keyed `projectInstructions`, with the values `claude`, | |
| 72 | +`agents-fallback`, `both` and `none`. A value still stored under that key is | |
| 73 | +honoured for now while `instructionFiles` reads as its default: `none` as | |
| 74 | +`managed-only`, `claude` as `claude-md`, `agents-fallback` as | |
| 75 | +`claude-md-or-agents-md`, `both` as `claude-md-and-agents-md`, any other | |
| 76 | +value as `claude-md` (which adds nothing, never as the default, which loads | |
| 77 | +`AGENTS.md`); the first `session.start` of a load says in the transcript how | |
| 78 | +it was read. Once `instructionFiles` is set to anything but its default, the | |
| 79 | +old key is not read and the transcript says to remove it. | |
| 80 | + | |
| 81 | +Run from this folder instead (`claude --plugin-dir mods/agents-md`), the | |
| 82 | +same entry is keyed `"agents-md"`. | |
| 83 | + | |
| 84 | +## What it hooks | |
| 85 | + | |
| 86 | +| event | what the hook does | | |
| 87 | +| --- | --- | | |
| 88 | +| `session.start` | in every mode: passes the start straight through and floats the usage row for the configured mode, never awaited; the first start of a load logs how a stored `projectInstructions` value is read. The session's start never waits on this plugin | | |
| 89 | +| `prompt.context` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`: walks `$.fs.ancestors` for the `AGENTS.md` files above the working directory and answers them as `project` instruction files, each `@` import its own entry after its file, each placed where a project file of its directory stands (root first, before the first deeper project file, else after the last project file, before memory); files the engine already holds by path or by content are left out; under `claude-md-or-agents-md` it answers nothing when the project has a `CLAUDE.md` of its own (among the handed files, else found by a `$.fs.ancestors` walk, so a `CLAUDE.md` the engine loaded and then withheld still counts), and logs which files it loaded once, and again after a move to another project root; handed unknown files (a hook above rewrote the `claudeMd` text) it adds nothing; the first context of a load sends the load row (counts) and the feature mark; under `managed-only` (matcher: a `project`, `local` or `user` file present): answers the list without those kinds | | |
| 90 | +| `agent.spawn` on `fork: true` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`: a fork the Agent tool starts shares its parent's prompt prefix, so the parent loop's delivered nested files are copied to the fork's loop and not attached to it again (a `/fork` or `/subtask` fork does not raise `agent.spawn` yet and starts from an empty set, as every fork did before; a fork started in the same tool batch as a `Read` inherits that Read's file although its prefix holds a placeholder for it) | | |
| 91 | +| `tool.call` on `Read` | under `claude-md-or-agents-md` and `claude-md-and-agents-md`, for a file under the session's project root (`$.session.root()`, read live, so `/cd`, a host's directory change and worktree moves are followed and a moved root starts the delivered sets and the fallback decision over; a file elsewhere gets nothing, as the engine attaches no nested `CLAUDE.md` there): walks only the directories strictly between the root and the read file (`$.fs.ancestors` with `below: root`, as the engine walks only those for a nested `CLAUDE.md`, never up to the filesystem root again) and attaches their `AGENTS.md` files not yet given to that agent loop, not already among the context's instruction files (by path or, for a project file, by text) and not claimed by a `CLAUDE.md` of the same directory (or imported by one), as `context` after the tool result, framed `Contents of <path>:` byte for byte as the engine frames a nested `CLAUDE.md`, whatever its size; each file once per loop and conversation (the context's recomputation after a compaction or `/clear` starts the count over), the context's files never; a Read that attached files sends the nested row. A `~` or `~/` path is read under the home directory as the Read tool reads it | | |
| 92 | + | |
| 93 | +## What it calls on `$` | |
| 94 | + | |
| 95 | +`fs.ancestors` (with each found file's `parts`: the file and its imports | |
| 96 | +apart; with `below` on a Read; it finds nothing on a thin client, whose | |
| 97 | +workspace files are remote, as the engine's own walk does), `session.root`, | |
| 98 | +`session.cwd`, `env.get` (`HOME` and `USERPROFILE`, once per load, the | |
| 99 | +profile first on a Windows spelling of the working directory, so a `~/` path | |
| 100 | +the model hands a Read resolves where the Read tool reads it), `ui.log`, | |
| 101 | +`telemetry.log` and `telemetry.mark`. | |
| 102 | + | |
| 103 | +`$.telemetry` is the [telemetry](../telemetry) plugin's noun; where that | |
| 104 | +plugin is not seated the calls find no noun and are dropped without a trace, | |
| 105 | +and nothing else changes. | |
| 106 | + | |
| 107 | +## What it logs | |
| 108 | + | |
| 109 | +Counts and closed choices only; no path and no file text. Each row goes | |
| 110 | +through `$.telemetry.log`, so it exists only where the telemetry plugin | |
| 111 | +does: | |
| 112 | + | |
| 113 | +| event | when | properties | | |
| 114 | +| --- | --- | --- | | |
| 115 | +| `agents_md_mode` | once per fresh load, at `session.start` | `mode` (`claude-md` \| `claude-md-or-agents-md` \| `claude-md-and-agents-md` \| `managed-only`), `is_interactive` | | |
| 116 | +| `agents_md_load` | the first context of a load, under `claude-md-or-agents-md` and `claude-md-and-agents-md` | `mode`, `file_count` (`AGENTS.md` files handed to the engine), `import_count` (their `@` imports), `total_content_length`, `yielded` (`claude-md-or-agents-md` stood down for a `CLAUDE.md` of the project's own), `walk_failed`; with it one `$.telemetry.mark` for feature `agents_md`: `ok`, or `sad` with reason `walk_failed` | | |
| 117 | +| `agents_md_nested` | a Read that attached nested files | `mode`, `file_count` | | |
| 118 | + | |
| 119 | +## Where it still differs from CLAUDE.md | |
| 120 | + | |
| 121 | +All of these apply only to the modes that load `AGENTS.md`, | |
| 122 | +`claude-md-or-agents-md` (the default) and `claude-md-and-agents-md`. Each | |
| 123 | +names a loader fact a plugin cannot reach through the events it has today. | |
| 124 | + | |
| 125 | +1. Nested files attach on a text `Read` only. The engine also attaches a | |
| 126 | + directory's `CLAUDE.md` for a file `@`-mentioned in the prompt, for the | |
| 127 | + IDE's opened file or selection, and for the `Read` tool's notebook, image | |
| 128 | + and PDF results. | |
| 129 | +2. A nested file the plugin attaches is not registered in the loop's | |
| 130 | + read-file state, so after a compaction the engine does not restore it | |
| 131 | + among the recently read files (the plugin attaches it again at the next | |
| 132 | + `Read` under that directory instead), and a change to it mid-session is | |
| 133 | + not re-announced. | |
| 134 | +3. `/cd` carries the new tree's `CLAUDE.md` in its own notice; the plugin's | |
| 135 | + files for the new tree arrive in the same next request through the | |
| 136 | + engine's instructions announcement instead. | |
| 137 | +4. Paths compare by spelling; the engine resolves a symlinked alias of the | |
| 138 | + working directory before deciding a file is inside it. | |
| 139 | +5. `--add-dir` directories contribute no `AGENTS.md`, where the engine can | |
| 140 | + load their `CLAUDE.md`. | |
| 141 | +6. `/memory` and the `#` shortcut do not know `AGENTS.md` files, and the | |
| 142 | + engine's own initial-load row does not count them (this plugin's | |
| 143 | + `agents_md_load` row does). | |
| 144 | +7. An `@` import outside the working directory inside an `AGENTS.md` is | |
| 145 | + honoured only once the approval the engine asks for a `CLAUDE.md`'s | |
| 146 | + external imports has been given (without it the import is left out, as a | |
| 147 | + `CLAUDE.md`'s is); the approval dialog itself is raised for `CLAUDE.md` | |
| 148 | + imports alone. | |
| 149 | +8. A subagent that is not a fork gets a nested `AGENTS.md` at its own first | |
| 150 | + `Read` under that directory even when its parent's loop was already given | |
| 151 | + it; the engine does not hand such a subagent the nested `CLAUDE.md` again. | |
| 152 | + A fork matches the engine on both sides. | |
| 153 | + | |
| 154 | +## Testing | |
| 155 | + | |
| 156 | + claude plugin test mods/agents-md | |
| 157 | + | |
| 158 | +`tests/register.test.ts` covers the default mode: a project with `AGENTS.md` | |
| 159 | +alone gets it as a project instruction file and one transcript line naming | |
| 160 | +it, a project with a `CLAUDE.md` of its own is left to the engine without a | |
| 161 | +walk, a failed walk leaves the context as handed, and the start hands | |
| 162 | +`$.telemetry` the mode row alone where a test seats a provider for that | |
| 163 | +noun, and goes on untouched where none is seated. |
| @@ -1,0 +1,28 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The path of the file at the head of an instruction file's `@`-import | |
| 5 | + * chain; the file's own path when nothing imported it. | |
| 6 | + * | |
| 7 | + * Follows `parent` among the given files until a file nothing imported. | |
| 8 | + * | |
| 9 | + * @param file the instruction file | |
| 10 | + * @param byPath the files it may have come through, by path | |
| 11 | + * @returns the chain head's path | |
| 12 | + */ | |
| 13 | +export function chainRootOf( | |
| 14 | + file: InstructionFile, | |
| 15 | + byPath: ReadonlyMap<string, InstructionFile>, | |
| 16 | +): string { | |
| 17 | + const seen = new Set<string>([file.path]) | |
| 18 | + let path = file.path | |
| 19 | + let parent = file.parent | |
| 20 | + | |
| 21 | + while (parent !== undefined && !seen.has(parent)) { | |
| 22 | + seen.add(parent) | |
| 23 | + path = parent | |
| 24 | + parent = byPath.get(parent)?.parent | |
| 25 | + } | |
| 26 | + | |
| 27 | + return path | |
| 28 | +} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import type { InstructionFileKind } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The kinds `managed-only` drops: the project's checked-in and private | |
| 5 | + * instruction files and the person's own; what it keeps is the organization's | |
| 6 | + * and memory. | |
| 7 | + */ | |
| 8 | +export const DROPPED_KINDS: readonly InstructionFileKind[] = [ | |
| 9 | + 'project', | |
| 10 | + 'local', | |
| 11 | + 'user', | |
| 12 | +] |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { FsAncestor, InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The found AGENTS.md files as project instruction files, one per file and | |
| 5 | + * per file it `@`-imported, in walk and load order. | |
| 6 | + * | |
| 7 | + * An import names the file that brought it as its parent. | |
| 8 | + * | |
| 9 | + * @param found what `$.fs.ancestors` found, root first | |
| 10 | + * @returns the instruction files, kind `project` | |
| 11 | + */ | |
| 12 | +export const filesOf = (found: readonly FsAncestor[]): InstructionFile[] => | |
| 13 | + found.flatMap(entry => | |
| 14 | + entry.parts.map((part, index) => ({ | |
| 15 | + path: part.path, | |
| 16 | + kind: 'project' as const, | |
| 17 | + content: part.content, | |
| 18 | + ...(index > 0 && { parent: entry.parts[0]?.path ?? part.path }), | |
| 19 | + })), | |
| 20 | + ) |
| @@ -1,0 +1,12 @@ | ||
| 1 | +export * from './chain-root-of.js' | |
| 2 | +export * from './dropped-kinds.js' | |
| 3 | +export * from './files-of.js' | |
| 4 | +export * from './insertion-index.js' | |
| 5 | +export * from './is-claude-file-on-walk.js' | |
| 6 | +export * from './is-kept-without-instructions.js' | |
| 7 | +export * from './is-project-own.js' | |
| 8 | +export * from './project-dir-of.js' | |
| 9 | +export * from './unseen-files.js' | |
| 10 | +export * from './with-project-files.js' | |
| 11 | + | |
| 12 | +export * as default from '.' |
| @@ -1,0 +1,44 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import Frames from '../frames' | |
| 4 | +import { chainRootOf } from './chain-root-of.js' | |
| 5 | +import { isProjectOwn } from './is-project-own.js' | |
| 6 | +import { projectDirOf } from './project-dir-of.js' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Where a directory's AGENTS.md files go among the files the engine loaded, | |
| 10 | + * as the engine orders project files root first. | |
| 11 | + * | |
| 12 | + * Before the first project file of a deeper directory (an imported file | |
| 13 | + * counts with the file at the head of its chain), else after the last | |
| 14 | + * project file, else before the engine's memory, else at the end. | |
| 15 | + * | |
| 16 | + * @param files the list so far | |
| 17 | + * @param dir the directory the AGENTS.md files stand in | |
| 18 | + * @returns the index to insert at | |
| 19 | + */ | |
| 20 | +export function insertionIndex( | |
| 21 | + files: readonly InstructionFile[], | |
| 22 | + dir: string, | |
| 23 | +): number { | |
| 24 | + const byPath = new Map(files.map(file => [file.path, file])) | |
| 25 | + const deeper = files.findIndex( | |
| 26 | + file => | |
| 27 | + isProjectOwn(file) && | |
| 28 | + Frames.isBelow(projectDirOf(chainRootOf(file, byPath)), dir), | |
| 29 | + ) | |
| 30 | + | |
| 31 | + if (deeper !== -1) { | |
| 32 | + return deeper | |
| 33 | + } | |
| 34 | + | |
| 35 | + const lastOwn = files.findLastIndex(isProjectOwn) | |
| 36 | + | |
| 37 | + if (lastOwn !== -1) { | |
| 38 | + return lastOwn + 1 | |
| 39 | + } | |
| 40 | + | |
| 41 | + const memory = files.findIndex(file => file.kind === 'memory') | |
| 42 | + | |
| 43 | + return memory === -1 ? files.length : memory | |
| 44 | +} |
| @@ -1,0 +1,39 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import Frames from '../frames' | |
| 4 | +import Names from '../names' | |
| 5 | +import { isProjectOwn } from './is-project-own.js' | |
| 6 | +import { projectDirOf } from './project-dir-of.js' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Whether a handed instruction file is a CLAUDE.md, `.claude/CLAUDE.md` or | |
| 10 | + * CLAUDE.local.md of a directory on the walk down to the session's root. | |
| 11 | + * | |
| 12 | + * What makes a project "have a CLAUDE.md of its own" for | |
| 13 | + * `claude-md-or-agents-md`, the same files the Read walk asks for; a rules | |
| 14 | + * file, an imported file or an added directory's CLAUDE.md is not one. | |
| 15 | + * | |
| 16 | + * @param file the handed instruction file | |
| 17 | + * @param root the session's project root, absolute | |
| 18 | + * @returns true for the project's own CLAUDE.md files on the walk | |
| 19 | + */ | |
| 20 | +export function isClaudeFileOnWalk( | |
| 21 | + file: InstructionFile, | |
| 22 | + root: string, | |
| 23 | +): boolean { | |
| 24 | + const isOwnClaudeFile = | |
| 25 | + isProjectOwn(file) && | |
| 26 | + file.parent === undefined && | |
| 27 | + Names.CLAUDE_NAMES.some(name => | |
| 28 | + Frames.normalSpellingOf(file.path).endsWith(`/${name}`), | |
| 29 | + ) | |
| 30 | + | |
| 31 | + if (!isOwnClaudeFile) { | |
| 32 | + return false | |
| 33 | + } | |
| 34 | + | |
| 35 | + const dir = projectDirOf(file.path) | |
| 36 | + const spelledRoot = Frames.normalSpellingOf(root) | |
| 37 | + | |
| 38 | + return dir === spelledRoot || Frames.isBelow(spelledRoot, dir) | |
| 39 | +} |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import { DROPPED_KINDS } from './dropped-kinds.js' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Whether `managed-only` keeps an instruction file: the organization's managed | |
| 7 | + * files and the engine's memory stay, the project's and the person's go. | |
| 8 | + * | |
| 9 | + * @param file the instruction file | |
| 10 | + * @returns true for a file `managed-only` leaves in place | |
| 11 | + */ | |
| 12 | +export const isKeptWithoutInstructions = (file: InstructionFile): boolean => | |
| 13 | + !DROPPED_KINDS.includes(file.kind) |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether an instruction file is one of the project's own (checked in or | |
| 5 | + * private), as opposed to the organization's, the person's or memory. | |
| 6 | + * | |
| 7 | + * @param file the instruction file | |
| 8 | + * @returns true for a `project` or `local` file | |
| 9 | + */ | |
| 10 | +export const isProjectOwn = (file: InstructionFile): boolean => | |
| 11 | + file.kind === 'project' || file.kind === 'local' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import Frames from '../frames' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The project directory an instruction file speaks for: the directory | |
| 5 | + * holding it, or the one holding its `.claude` folder. | |
| 6 | + * | |
| 7 | + * @param path the file's path, absolute | |
| 8 | + * @returns the directory, in the walk's normal spelling | |
| 9 | + */ | |
| 10 | +export function projectDirOf(path: string): string { | |
| 11 | + const spelled = Frames.normalSpellingOf(path) | |
| 12 | + const claudeAt = spelled.lastIndexOf('/.claude/') | |
| 13 | + const cut = claudeAt === -1 ? spelled.lastIndexOf('/') : claudeAt | |
| 14 | + | |
| 15 | + return cut <= 0 ? '/' : spelled.slice(0, cut) | |
| 16 | +} |
| @@ -1,0 +1,40 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import Frames from '../frames' | |
| 4 | +import { isProjectOwn } from './is-project-own.js' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The candidate files the engine has not already loaded, and no candidate | |
| 8 | + * twice. | |
| 9 | + * | |
| 10 | + * Leaves out one whose path is among the handed files (a CLAUDE.md that | |
| 11 | + * `@`-imports AGENTS.md) or whose text equals a handed project file's (a | |
| 12 | + * CLAUDE.md symlinked to AGENTS.md); other kinds' text does not count. | |
| 13 | + * | |
| 14 | + * @param candidates the AGENTS.md instruction files of a walk | |
| 15 | + * @param handed the files the engine handed the hook | |
| 16 | + * @returns the candidates worth adding, in order | |
| 17 | + */ | |
| 18 | +export function unseenFiles( | |
| 19 | + candidates: readonly InstructionFile[], | |
| 20 | + handed: readonly InstructionFile[], | |
| 21 | +): InstructionFile[] { | |
| 22 | + const paths = new Set(handed.map(file => Frames.normalSpellingOf(file.path))) | |
| 23 | + const texts = new Set( | |
| 24 | + handed.filter(isProjectOwn).map(file => file.content.trim()), | |
| 25 | + ) | |
| 26 | + const kept: InstructionFile[] = [] | |
| 27 | + | |
| 28 | + for (const file of candidates) { | |
| 29 | + const path = Frames.normalSpellingOf(file.path) | |
| 30 | + const text = file.content.trim() | |
| 31 | + const isSeen = paths.has(path) || (text !== '' && texts.has(text)) | |
| 32 | + | |
| 33 | + if (!isSeen) { | |
| 34 | + paths.add(path) | |
| 35 | + kept.push(file) | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + return kept | |
| 40 | +} |
| @@ -1,0 +1,37 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import { chainRootOf } from './chain-root-of.js' | |
| 4 | +import { insertionIndex } from './insertion-index.js' | |
| 5 | +import { projectDirOf } from './project-dir-of.js' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The engine's instruction files with the AGENTS.md files placed where the | |
| 9 | + * engine would place a project file of their directory; as is when none. | |
| 10 | + * | |
| 11 | + * @param handed the files the engine handed the hook, in its order | |
| 12 | + * @param added the AGENTS.md instruction files to add, root first | |
| 13 | + * @returns the merged list | |
| 14 | + */ | |
| 15 | +export function withProjectFiles( | |
| 16 | + handed: readonly InstructionFile[], | |
| 17 | + added: readonly InstructionFile[], | |
| 18 | +): readonly InstructionFile[] { | |
| 19 | + if (added.length === 0) { | |
| 20 | + return handed | |
| 21 | + } | |
| 22 | + | |
| 23 | + const merged = [...handed] | |
| 24 | + const byPath = new Map(added.map(file => [file.path, file])) | |
| 25 | + const byDir = new Map<string, InstructionFile[]>() | |
| 26 | + | |
| 27 | + for (const file of added) { | |
| 28 | + const dir = projectDirOf(chainRootOf(file, byPath)) | |
| 29 | + byDir.set(dir, [...(byDir.get(dir) ?? []), file]) | |
| 30 | + } | |
| 31 | + | |
| 32 | + for (const [dir, files] of byDir) { | |
| 33 | + merged.splice(insertionIndex(merged, dir), 0, ...files) | |
| 34 | + } | |
| 35 | + | |
| 36 | + return merged | |
| 37 | +} |
| @@ -1,0 +1,33 @@ | ||
| 1 | +/** | |
| 2 | + * A path the model handed a Read, made absolute as the Read tool reads it. | |
| 3 | + * | |
| 4 | + * As given when it is absolute; under the home directory (less any trailing | |
| 5 | + * separator) for `~` or a `~/` spelling when a home is known; else under the | |
| 6 | + * working directory. | |
| 7 | + * | |
| 8 | + * @param path the Read's `file_path` as the model spelled it | |
| 9 | + * @param root the session's working directory, absolute | |
| 10 | + * @param home the home directory, absolute, when the environment names one | |
| 11 | + * @returns the absolute path | |
| 12 | + */ | |
| 13 | +export function absoluteOf(path: string, root: string, home?: string): string { | |
| 14 | + const isHomeSpelling = path === '~' || path.startsWith('~/') | |
| 15 | + | |
| 16 | + if (isHomeSpelling && home !== undefined) { | |
| 17 | + const bare = home.replace(/(?<=.)[\\/]+$/, '') | |
| 18 | + | |
| 19 | + return path === '~' ? bare : `${bare}${separatorOf(bare)}${path.slice(2)}` | |
| 20 | + } | |
| 21 | + | |
| 22 | + return /^(?:[A-Za-z]:)?[\\/]/.test(path) | |
| 23 | + ? path | |
| 24 | + : `${root}${separatorOf(root)}${path}` | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * The separator a directory's own spelling uses. | |
| 29 | + * | |
| 30 | + * @param dir an absolute directory | |
| 31 | + * @returns `\` for a backslash spelling, `/` otherwise | |
| 32 | + */ | |
| 33 | +const separatorOf = (dir: string): string => (dir.includes('\\') ? '\\' : '/') |
| @@ -1,0 +1,8 @@ | ||
| 1 | +export * from './absolute-of.js' | |
| 2 | +export * from './is-below.js' | |
| 3 | +export * from './is-file-at.js' | |
| 4 | +export * from './nested-frame.js' | |
| 5 | +export * from './normal-spelling-of.js' | |
| 6 | +export * from './outside-claude-dirs.js' | |
| 7 | + | |
| 8 | +export * as default from '.' |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import { normalSpellingOf } from './normal-spelling-of.js' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a path lies strictly inside a directory, by spelling (no links | |
| 5 | + * followed), as the engine bounds its nested CLAUDE.md walk. | |
| 6 | + * | |
| 7 | + * @param path an absolute path | |
| 8 | + * @param dir an absolute directory | |
| 9 | + * @returns true when `path` is under `dir` and not `dir` itself | |
| 10 | + */ | |
| 11 | +export const isBelow = (path: string, dir: string): boolean => | |
| 12 | + normalSpellingOf(path).startsWith(`${normalSpellingOf(dir)}/`) |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import { normalSpellingOf } from './normal-spelling-of.js' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Whether an instruction file is the file at a path, however either is | |
| 7 | + * spelled. | |
| 8 | + * | |
| 9 | + * @param file the instruction file | |
| 10 | + * @param path an absolute path | |
| 11 | + * @returns true when both name one path | |
| 12 | + */ | |
| 13 | +export const isFileAt = (file: InstructionFile, path: string): boolean => | |
| 14 | + normalSpellingOf(file.path) === normalSpellingOf(path) |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * An instruction file as a Read attaches it, framed as the engine frames a | |
| 5 | + * nested CLAUDE.md: the text as loaded, trailing newline and all. | |
| 6 | + * | |
| 7 | + * @param file the file | |
| 8 | + * @returns the `Contents of <path>` frame over its text | |
| 9 | + */ | |
| 10 | +export const nestedFrame = (file: InstructionFile): string => | |
| 11 | + `Contents of ${file.path}:\n\n${file.content}` |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * A path with one separator spelling and no trailing separator, so two | |
| 3 | + * spellings of one path compare equal (`C:/repo/x` and `C:\repo\x`). | |
| 4 | + * | |
| 5 | + * @param path an absolute path as a walk or the model spelled it | |
| 6 | + * @returns the path with `/` for every separator, no trailing one | |
| 7 | + */ | |
| 8 | +export const normalSpellingOf = (path: string): string => | |
| 9 | + path.replaceAll('\\', '/').replace(/(?<=.)\/+$/, '') |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import type { FsAncestor } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The AGENTS.md files standing in a directory with no CLAUDE.md of its own, | |
| 5 | + * in walk order. | |
| 6 | + * | |
| 7 | + * Where the engine attaches a directory's CLAUDE.md, that directory's | |
| 8 | + * AGENTS.md is left out rather than merged. | |
| 9 | + * | |
| 10 | + * @param agents the AGENTS.md files of a walk, root first | |
| 11 | + * @param claude the CLAUDE.md files of the same walk | |
| 12 | + * @returns the AGENTS.md files whose directory holds no CLAUDE.md | |
| 13 | + */ | |
| 14 | +export function outsideClaudeDirs( | |
| 15 | + agents: readonly FsAncestor[], | |
| 16 | + claude: readonly FsAncestor[], | |
| 17 | +): readonly FsAncestor[] { | |
| 18 | + const taken = new Set(claude.map(file => file.dir)) | |
| 19 | + | |
| 20 | + return agents.filter(file => !taken.has(file.dir)) | |
| 21 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "description": "AGENTS.md up and down the tree like CLAUDE.md, by the instructionFiles option: under claude-md-or-agents-md (the default; where the project has no CLAUDE.md of its own on the walk) or claude-md-and-agents-md, a prompt.context hook hands every ancestor AGENTS.md to the engine among its instruction files, skipping ones it already loaded, and a tool.call hook on Read attaches the AGENTS.md files of the directories between the project root and a read file, once a conversation and agent loop (an Agent-tool fork inherits its parent's); under managed-only, a prompt.context hook keeps only the organization's managed files and memory; under claude-md, nothing is added; in every mode a session.start hook sends the plugin's usage row through $.telemetry where that noun is seated", | |
| 3 | + "modules": ["./register.ts"] | |
| 4 | +} |
| @@ -1,0 +1,8 @@ | ||
| 1 | +export * from './files' | |
| 2 | +export * from './frames' | |
| 3 | +export * from './modes' | |
| 4 | +export * from './names' | |
| 5 | +export * from './register.js' | |
| 6 | +export * from './telemetry' | |
| 7 | + | |
| 8 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +import type { Mode } from './types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The mode an unset option reads as: AGENTS.md where the project has no | |
| 5 | + * CLAUDE.md of its own, the engine's CLAUDE.md walk standing alone where it | |
| 6 | + * has one. | |
| 7 | + */ | |
| 8 | +export const DEFAULT_MODE: Mode = 'claude-md-or-agents-md' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +export * from './default-mode.js' | |
| 2 | +export * from './legacy-mode-of.js' | |
| 3 | +export * from './mode-of.js' | |
| 4 | +export * from './modes.js' | |
| 5 | +export * from './types' | |
| 6 | + | |
| 7 | +export * as default from '.' |
| @@ -1,0 +1,33 @@ | ||
| 1 | +import type { Mode } from './types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Each value the option took under its old key, `projectInstructions`, and | |
| 5 | + * the mode that keeps its meaning. | |
| 6 | + */ | |
| 7 | +const LEGACY_MODES: Readonly<Record<string, Mode>> = { | |
| 8 | + none: 'managed-only', | |
| 9 | + claude: 'claude-md', | |
| 10 | + 'agents-fallback': 'claude-md-or-agents-md', | |
| 11 | + both: 'claude-md-and-agents-md', | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * The mode a value stored under the old `projectInstructions` key keeps; a | |
| 16 | + * value it never took reads as `claude-md`, which adds nothing, never as the | |
| 17 | + * default, which loads AGENTS.md. | |
| 18 | + * | |
| 19 | + * COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions | |
| 20 | + * mapping once stored settings have migrated. | |
| 21 | + * | |
| 22 | + * @param value the `projectInstructions` option as stored | |
| 23 | + * @returns the mode it keeps, undefined when nothing is stored there | |
| 24 | + */ | |
| 25 | +export function legacyModeOf(value: unknown): Mode | undefined { | |
| 26 | + if (value === undefined) { | |
| 27 | + return undefined | |
| 28 | + } | |
| 29 | + | |
| 30 | + return ( | |
| 31 | + (typeof value === 'string' ? LEGACY_MODES[value] : undefined) ?? 'claude-md' | |
| 32 | + ) | |
| 33 | +} |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import { DEFAULT_MODE } from './default-mode.js' | |
| 2 | +import { MODES } from './modes.js' | |
| 3 | +import type { Mode } from './types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The mode a stored option names; unset, a typo or a value of another type | |
| 7 | + * names the default. | |
| 8 | + * | |
| 9 | + * @param value the `instructionFiles` option as stored | |
| 10 | + * @returns the mode named, DEFAULT_MODE for anything else | |
| 11 | + */ | |
| 12 | +export const modeOf = (value: unknown): Mode => | |
| 13 | + MODES.find(mode => mode === value) ?? DEFAULT_MODE |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { Mode } from './types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Every mode, in the order the option's description names them. | |
| 5 | + */ | |
| 6 | +export const MODES: readonly Mode[] = [ | |
| 7 | + 'claude-md', | |
| 8 | + 'claude-md-or-agents-md', | |
| 9 | + 'claude-md-and-agents-md', | |
| 10 | + 'managed-only', | |
| 11 | +] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './mode' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './mode.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +/** | |
| 2 | + * Which project instructions the plugin loads: the `instructionFiles` | |
| 3 | + * option. | |
| 4 | + * | |
| 5 | + * `claude-md` loads none of its own, the engine's CLAUDE.md walk standing | |
| 6 | + * alone; `claude-md-or-agents-md` loads AGENTS.md where the project has no | |
| 7 | + * CLAUDE.md; `claude-md-and-agents-md` loads AGENTS.md beside CLAUDE.md; | |
| 8 | + * `managed-only` loads neither, taking the project's and the person's | |
| 9 | + * CLAUDE.md files out of the conversation's context. | |
| 10 | + */ | |
| 11 | +export type Mode = | |
| 12 | + | 'claude-md' | |
| 13 | + | 'claude-md-or-agents-md' | |
| 14 | + | 'claude-md-and-agents-md' | |
| 15 | + | 'managed-only' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The instruction files read in each directory, in the order the engine | |
| 3 | + * reads CLAUDE.md and .claude/CLAUDE.md. | |
| 4 | + */ | |
| 5 | +export const AGENTS_NAMES = ['AGENTS.md', '.claude/AGENTS.md'] as const |
| @@ -1,0 +1,11 @@ | ||
| 1 | +/** | |
| 2 | + * The engine's own instruction files, as it reads them in each directory of | |
| 3 | + * its walk and under a read file. | |
| 4 | + * | |
| 5 | + * A directory holding one has project instructions the engine loads itself. | |
| 6 | + */ | |
| 7 | +export const CLAUDE_NAMES = [ | |
| 8 | + 'CLAUDE.md', | |
| 9 | + '.claude/CLAUDE.md', | |
| 10 | + 'CLAUDE.local.md', | |
| 11 | +] as const |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './agents-names.js' | |
| 2 | +export * from './claude-names.js' | |
| 3 | +export * from './main-loop.js' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The key the main conversation's delivered files are kept under; a | |
| 3 | + * subagent's are kept under its `agentId`. | |
| 4 | + */ | |
| 5 | +export const MAIN_LOOP = 'main' |
| @@ -1,0 +1,272 @@ | ||
| 1 | +import type { | |
| 2 | + EngineInterface, | |
| 3 | + FsAncestor, | |
| 4 | + InstructionFile, | |
| 5 | + On, | |
| 6 | + PluginOptions, | |
| 7 | +} from 'claude-code' | |
| 8 | + | |
| 9 | +import Files from './files' | |
| 10 | +import Frames from './frames' | |
| 11 | +import Modes from './modes' | |
| 12 | +import Names from './names' | |
| 13 | +import Telemetry from './telemetry' | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * No files: what a walk not taken, or one that failed, found. | |
| 17 | + */ | |
| 18 | +const NONE: readonly FsAncestor[] = [] | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Registers the plugin's hooks for the mode `instructionFiles` names | |
| 22 | + * (`claude-md-or-agents-md` when unset; the manifest lists the four, nothing | |
| 23 | + * else arrives). | |
| 24 | + * | |
| 25 | + * `claude-md`: nothing beyond the usage row, the engine's CLAUDE.md walk | |
| 26 | + * standing alone. `managed-only`: the project's and the person's instruction | |
| 27 | + * files dropped, the organization's kept. `claude-md-or-agents-md` (a project | |
| 28 | + * with none of its own) and `claude-md-and-agents-md`: AGENTS.md files joined | |
| 29 | + * to the engine's instruction files, nested ones on a Read. Every mode sends | |
| 30 | + * its usage rows through `$.telemetry` where that noun is seated and drops | |
| 31 | + * them where it is not. | |
| 32 | + * | |
| 33 | + * @param on the engine's registrar | |
| 34 | + * @param options the plugin's options; `instructionFiles` is `claude-md`, | |
| 35 | + * `claude-md-or-agents-md`, `claude-md-and-agents-md` or `managed-only` | |
| 36 | + */ | |
| 37 | +export function register(on: On, options: PluginOptions): void { | |
| 38 | + const named = Modes.modeOf(options.instructionFiles) | |
| 39 | + // COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions | |
| 40 | + // mapping once stored settings have migrated. The host fills in the default, | |
| 41 | + // so an instructionFiles set to it cannot be told from one left unset. | |
| 42 | + const legacy = Modes.legacyModeOf(options.projectInstructions) | |
| 43 | + const isLegacyRead = legacy !== undefined && named === Modes.DEFAULT_MODE | |
| 44 | + const mode = isLegacyRead ? legacy : named | |
| 45 | + let isRenameTold = legacy === undefined | |
| 46 | + | |
| 47 | + on('session.start', ($, e, next) => { | |
| 48 | + Telemetry.quietly(() => | |
| 49 | + $.telemetry.log(Telemetry.modeRowOf(mode, e.isInteractive)), | |
| 50 | + ) | |
| 51 | + | |
| 52 | + if (!isRenameTold) { | |
| 53 | + isRenameTold = true | |
| 54 | + $.ui.log( | |
| 55 | + isLegacyRead | |
| 56 | + ? 'option projectInstructions in settings is honoured for now, ' + | |
| 57 | + `read as instructionFiles ${mode}; set instructionFiles to ` + | |
| 58 | + `${mode} and remove projectInstructions` | |
| 59 | + : `option projectInstructions in settings is not read: ` + | |
| 60 | + `instructionFiles ${mode} is set; remove projectInstructions`, | |
| 61 | + ) | |
| 62 | + } | |
| 63 | + | |
| 64 | + return next(e) | |
| 65 | + }) | |
| 66 | + | |
| 67 | + if (mode === 'claude-md') { | |
| 68 | + return | |
| 69 | + } | |
| 70 | + | |
| 71 | + if (mode === 'managed-only') { | |
| 72 | + on( | |
| 73 | + 'prompt.context', | |
| 74 | + { instructionFiles: { kind: Files.DROPPED_KINDS } }, | |
| 75 | + ($, e, next) => | |
| 76 | + next({ | |
| 77 | + ...e, | |
| 78 | + instructionFiles: e.instructionFiles?.filter( | |
| 79 | + Files.isKeptWithoutInstructions, | |
| 80 | + ), | |
| 81 | + }), | |
| 82 | + ) | |
| 83 | + | |
| 84 | + return | |
| 85 | + } | |
| 86 | + | |
| 87 | + const isFallback = mode === 'claude-md-or-agents-md' | |
| 88 | + const given = new Map<string, Set<string>>() | |
| 89 | + let inContext: readonly InstructionFile[] = [] | |
| 90 | + let home: string | undefined | |
| 91 | + let isClaudeProject: boolean | undefined | |
| 92 | + let rootSeen: string | undefined | |
| 93 | + let rootLogged: string | undefined | |
| 94 | + let isCounted = false | |
| 95 | + | |
| 96 | + on('prompt.context', async ($, e, next) => { | |
| 97 | + const handed = e.instructionFiles | |
| 98 | + | |
| 99 | + if (handed === undefined) { | |
| 100 | + return next(e).finally(() => { | |
| 101 | + given.clear() | |
| 102 | + }) | |
| 103 | + } | |
| 104 | + | |
| 105 | + const root = isFallback ? await $.session.root() : undefined | |
| 106 | + rootSeen = root ?? rootSeen | |
| 107 | + let isWalkFailed = false | |
| 108 | + // The walk, not only what was handed: the engine can withhold a project | |
| 109 | + // CLAUDE.md it loaded, and the project still has one. | |
| 110 | + isClaudeProject = | |
| 111 | + root !== undefined && | |
| 112 | + (handed.some(file => Files.isClaudeFileOnWalk(file, root)) || | |
| 113 | + (await $.fs.ancestors({ names: Names.CLAUDE_NAMES }).then( | |
| 114 | + files => files.length > 0, | |
| 115 | + () => { | |
| 116 | + isWalkFailed = true | |
| 117 | + | |
| 118 | + return true | |
| 119 | + }, | |
| 120 | + ))) | |
| 121 | + const found = isClaudeProject | |
| 122 | + ? NONE | |
| 123 | + : await $.fs.ancestors({ names: Names.AGENTS_NAMES }).catch(() => { | |
| 124 | + isWalkFailed = true | |
| 125 | + | |
| 126 | + return NONE | |
| 127 | + }) | |
| 128 | + const added = Files.unseenFiles(Files.filesOf(found), handed) | |
| 129 | + | |
| 130 | + if (!isCounted) { | |
| 131 | + isCounted = true | |
| 132 | + const counts = Telemetry.loadCountsOf( | |
| 133 | + added, | |
| 134 | + isClaudeProject && !isWalkFailed, | |
| 135 | + isWalkFailed, | |
| 136 | + ) | |
| 137 | + Telemetry.quietly(() => | |
| 138 | + $.telemetry.log(Telemetry.loadRowOf(mode, counts)), | |
| 139 | + ) | |
| 140 | + Telemetry.quietly(() => $.telemetry.mark(Telemetry.loadMarkOf(counts))) | |
| 141 | + } | |
| 142 | + | |
| 143 | + const isFirstLoad = | |
| 144 | + root !== undefined && root !== rootLogged && added.length > 0 | |
| 145 | + | |
| 146 | + if (isFirstLoad) { | |
| 147 | + rootLogged = root | |
| 148 | + $.ui.log( | |
| 149 | + 'no CLAUDE.md found; AGENTS.md loaded: ' + | |
| 150 | + added | |
| 151 | + .filter(file => file.parent === undefined) | |
| 152 | + .map(file => file.path) | |
| 153 | + .join(', '), | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + | |
| 157 | + const instructionFiles = Files.withProjectFiles(handed, added) | |
| 158 | + | |
| 159 | + return next({ ...e, instructionFiles }).finally(() => { | |
| 160 | + given.clear() | |
| 161 | + inContext = instructionFiles | |
| 162 | + }) | |
| 163 | + }) | |
| 164 | + | |
| 165 | + on('agent.spawn', { fork: true }, async ($, e, next) => { | |
| 166 | + const result = await next(e) | |
| 167 | + | |
| 168 | + if (result.agentId !== undefined) { | |
| 169 | + const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP) | |
| 170 | + given.set(result.agentId, new Set(parent)) | |
| 171 | + } | |
| 172 | + | |
| 173 | + return result | |
| 174 | + }) | |
| 175 | + | |
| 176 | + on('tool.call', { tool: 'Read' }, async ($, e, next) => { | |
| 177 | + const result = await next(e) | |
| 178 | + const isSettledElsewhere = | |
| 179 | + e.tool !== 'Read' || result.deny !== undefined || result.isError | |
| 180 | + | |
| 181 | + if (isSettledElsewhere) { | |
| 182 | + return result | |
| 183 | + } | |
| 184 | + | |
| 185 | + const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()]) | |
| 186 | + home ??= await homeOf($, cwd) | |
| 187 | + const read = Frames.absoluteOf(e.file_path, cwd, home) | |
| 188 | + | |
| 189 | + if (root !== rootSeen) { | |
| 190 | + rootSeen = root | |
| 191 | + isClaudeProject = undefined | |
| 192 | + given.clear() | |
| 193 | + } | |
| 194 | + | |
| 195 | + isClaudeProject ??= | |
| 196 | + isFallback && | |
| 197 | + (await $.fs.ancestors({ names: Names.CLAUDE_NAMES })).length > 0 | |
| 198 | + const isOutOfReach = isClaudeProject || !Frames.isBelow(read, root) | |
| 199 | + | |
| 200 | + if (isOutOfReach) { | |
| 201 | + return result | |
| 202 | + } | |
| 203 | + | |
| 204 | + const [stack, claude] = await Promise.all([ | |
| 205 | + $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }), | |
| 206 | + $.fs.ancestors({ names: Names.CLAUDE_NAMES, of: read, below: root }), | |
| 207 | + ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE]) | |
| 208 | + const loop = e.agentId ?? Names.MAIN_LOOP | |
| 209 | + const sent = given.get(loop) ?? new Set<string>() | |
| 210 | + given.set(loop, sent) | |
| 211 | + const nested = ( | |
| 212 | + isFallback ? Frames.outsideClaudeDirs(stack, claude) : stack | |
| 213 | + ).filter(file => Frames.isBelow(file.dir, root)) | |
| 214 | + const fresh = Files.unseenFiles(Files.filesOf(nested), [ | |
| 215 | + ...inContext, | |
| 216 | + ...Files.filesOf(claude), | |
| 217 | + ]).filter(file => !sent.has(file.path)) | |
| 218 | + const attached = fresh.filter(file => !Frames.isFileAt(file, read)) | |
| 219 | + const isWholeRead = e.offset === undefined && e.limit === undefined | |
| 220 | + | |
| 221 | + for (const file of fresh) { | |
| 222 | + const isSent = | |
| 223 | + attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead) | |
| 224 | + | |
| 225 | + if (isSent) { | |
| 226 | + sent.add(file.path) | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + const hasAttached = attached.length > 0 | |
| 231 | + | |
| 232 | + if (hasAttached) { | |
| 233 | + Telemetry.quietly(() => | |
| 234 | + $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)), | |
| 235 | + ) | |
| 236 | + } | |
| 237 | + | |
| 238 | + return hasAttached | |
| 239 | + ? { | |
| 240 | + ...result, | |
| 241 | + context: [ | |
| 242 | + ...(result.context ?? []), | |
| 243 | + ...attached.map(Frames.nestedFrame), | |
| 244 | + ], | |
| 245 | + } | |
| 246 | + : result | |
| 247 | + }) | |
| 248 | +} | |
| 249 | + | |
| 250 | +/** | |
| 251 | + * The home directory a `~` in a Read's path stands for, read the way the | |
| 252 | + * Read tool reads it. | |
| 253 | + * | |
| 254 | + * The profile directory on a Windows spelling of the working directory, | |
| 255 | + * else `HOME`, each falling back to the other. | |
| 256 | + * | |
| 257 | + * @param $ the engine, as the `tool.call` hook holds it | |
| 258 | + * @param cwd the session's working directory, whose spelling names the platform | |
| 259 | + * @returns the home directory, or undefined when the environment names none | |
| 260 | + */ | |
| 261 | +async function homeOf( | |
| 262 | + $: EngineInterface, | |
| 263 | + cwd: string, | |
| 264 | +): Promise<string | undefined> { | |
| 265 | + const [home, profile] = await Promise.all([ | |
| 266 | + $.env.get('HOME'), | |
| 267 | + $.env.get('USERPROFILE'), | |
| 268 | + ]) | |
| 269 | + const isWindowsSpelling = cwd.includes('\\') | |
| 270 | + | |
| 271 | + return isWindowsSpelling ? (profile ?? home) : (home ?? profile) | |
| 272 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The plugin's name on the product-wide feature surface: its marks are | |
| 3 | + * `$.telemetry.mark` rows carrying this `feature_name`. | |
| 4 | + */ | |
| 5 | +export const FEATURE_NAME = 'agents_md' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +export * from './feature-name.js' | |
| 2 | +export * from './load-counts-of.js' | |
| 3 | +export * from './load-event.js' | |
| 4 | +export * from './load-mark-of.js' | |
| 5 | +export * from './load-row-of.js' | |
| 6 | +export * from './mode-choice-of.js' | |
| 7 | +export * from './mode-event.js' | |
| 8 | +export * from './mode-row-of.js' | |
| 9 | +export * from './nested-event.js' | |
| 10 | +export * from './nested-row-of.js' | |
| 11 | +export * from './quietly.js' | |
| 12 | +export * from './types' | |
| 13 | +export * from './walk-failed-reason.js' | |
| 14 | + | |
| 15 | +export * as default from '.' |
| @@ -1,0 +1,35 @@ | ||
| 1 | +import type { InstructionFile } from 'claude-code' | |
| 2 | + | |
| 3 | +import type { LoadCounts } from './types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The first context's counts from the files it added: an entry with a parent | |
| 7 | + * is an `@` import, one without is an AGENTS.md file. | |
| 8 | + * | |
| 9 | + * @param added the AGENTS.md instruction files handed to the engine, imports | |
| 10 | + * among them | |
| 11 | + * @param isYielded whether `claude-md-or-agents-md` stood down for a CLAUDE.md | |
| 12 | + * @param isWalkFailed whether the walk threw | |
| 13 | + * @returns the counts the load row and mark are built from | |
| 14 | + */ | |
| 15 | +export function loadCountsOf( | |
| 16 | + added: readonly InstructionFile[], | |
| 17 | + isYielded: boolean, | |
| 18 | + isWalkFailed: boolean, | |
| 19 | +): LoadCounts { | |
| 20 | + const importCount = added.reduce( | |
| 21 | + (sum, file) => sum + (file.parent === undefined ? 0 : 1), | |
| 22 | + 0, | |
| 23 | + ) | |
| 24 | + | |
| 25 | + return { | |
| 26 | + fileCount: added.length - importCount, | |
| 27 | + importCount, | |
| 28 | + totalContentLength: added.reduce( | |
| 29 | + (sum, file) => sum + file.content.length, | |
| 30 | + 0, | |
| 31 | + ), | |
| 32 | + isYielded, | |
| 33 | + isWalkFailed, | |
| 34 | + } | |
| 35 | +} |
| @@ -1,0 +1,7 @@ | ||
| 1 | +/** | |
| 2 | + * The row the first context of a load sends under `claude-md-or-agents-md` and | |
| 3 | + * `claude-md-and-agents-md`: what was handed on, in counts. | |
| 4 | + * | |
| 5 | + * The engine's own initial-load row counts its CLAUDE.md files the same way. | |
| 6 | + */ | |
| 7 | +export const LOAD_EVENT = 'agents_md_load' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import { FEATURE_NAME } from './feature-name.js' | |
| 2 | +import type { LoadCounts, TelemetryMark } from './types' | |
| 3 | +import { WALK_FAILED_REASON } from './walk-failed-reason.js' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The first context's feature mark: `ok`, or `sad` when the walk failed and | |
| 7 | + * the context went on without AGENTS.md. | |
| 8 | + * | |
| 9 | + * @param counts what the first context handed on | |
| 10 | + * @returns the mark to send | |
| 11 | + */ | |
| 12 | +export const loadMarkOf = (counts: LoadCounts): TelemetryMark => | |
| 13 | + counts.isWalkFailed | |
| 14 | + ? { feature: FEATURE_NAME, kind: 'sad', reason: WALK_FAILED_REASON } | |
| 15 | + : { feature: FEATURE_NAME, kind: 'ok' } |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type Modes from '../modes' | |
| 2 | +import { LOAD_EVENT } from './load-event.js' | |
| 3 | +import { modeChoiceOf } from './mode-choice-of.js' | |
| 4 | +import type { LoadCounts, TelemetryRow } from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The first-context row: the mode and what was handed to the engine, in | |
| 8 | + * counts only; no path and no text. | |
| 9 | + * | |
| 10 | + * @param mode the configured mode | |
| 11 | + * @param counts what the first context handed on | |
| 12 | + * @returns the row to send | |
| 13 | + */ | |
| 14 | +export const loadRowOf = ( | |
| 15 | + mode: Modes.Mode, | |
| 16 | + counts: LoadCounts, | |
| 17 | +): TelemetryRow => ({ | |
| 18 | + event: LOAD_EVENT, | |
| 19 | + props: { | |
| 20 | + mode: modeChoiceOf(mode), | |
| 21 | + file_count: counts.fileCount, | |
| 22 | + import_count: counts.importCount, | |
| 23 | + total_content_length: counts.totalContentLength, | |
| 24 | + yielded: counts.isYielded, | |
| 25 | + walk_failed: counts.isWalkFailed, | |
| 26 | + }, | |
| 27 | +}) |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import Modes from '../modes' | |
| 2 | +import type { TelemetryProp } from './types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The mode as a row carries it: the value with the four it is chosen from. | |
| 6 | + * | |
| 7 | + * @param mode the configured mode | |
| 8 | + * @returns the `mode` property | |
| 9 | + */ | |
| 10 | +export const modeChoiceOf = (mode: Modes.Mode): TelemetryProp => ({ | |
| 11 | + value: mode, | |
| 12 | + of: Modes.MODES, | |
| 13 | +}) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The row a fresh load sends at `session.start`, whatever the mode: which | |
| 3 | + * mode is configured. | |
| 4 | + */ | |
| 5 | +export const MODE_EVENT = 'agents_md_mode' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type Modes from '../modes' | |
| 2 | +import { modeChoiceOf } from './mode-choice-of.js' | |
| 3 | +import { MODE_EVENT } from './mode-event.js' | |
| 4 | +import type { TelemetryRow } from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The `session.start` row: the configured mode and whether a person is at | |
| 8 | + * the terminal. | |
| 9 | + * | |
| 10 | + * @param mode the configured mode | |
| 11 | + * @param isInteractive whether a person is at the terminal | |
| 12 | + * @returns the row to send | |
| 13 | + */ | |
| 14 | +export const modeRowOf = ( | |
| 15 | + mode: Modes.Mode, | |
| 16 | + isInteractive: boolean, | |
| 17 | +): TelemetryRow => ({ | |
| 18 | + event: MODE_EVENT, | |
| 19 | + props: { mode: modeChoiceOf(mode), is_interactive: isInteractive }, | |
| 20 | +}) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The row a Read sends when it attached nested AGENTS.md files; a Read that | |
| 3 | + * attached none sends nothing. | |
| 4 | + */ | |
| 5 | +export const NESTED_EVENT = 'agents_md_nested' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type Modes from '../modes' | |
| 2 | +import { modeChoiceOf } from './mode-choice-of.js' | |
| 3 | +import { NESTED_EVENT } from './nested-event.js' | |
| 4 | +import type { TelemetryRow } from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The row of a Read that attached nested AGENTS.md files: the mode and how | |
| 8 | + * many it attached. | |
| 9 | + * | |
| 10 | + * @param mode the configured mode | |
| 11 | + * @param fileCount the files attached to this Read's result | |
| 12 | + * @returns the row to send | |
| 13 | + */ | |
| 14 | +export const nestedRowOf = ( | |
| 15 | + mode: Modes.Mode, | |
| 16 | + fileCount: number, | |
| 17 | +): TelemetryRow => ({ | |
| 18 | + event: NESTED_EVENT, | |
| 19 | + props: { mode: modeChoiceOf(mode), file_count: fileCount }, | |
| 20 | +}) |
| @@ -1,0 +1,13 @@ | ||
| 1 | +/** | |
| 2 | + * Runs one `$.telemetry` call and drops whatever it throws or rejects with. | |
| 3 | + * | |
| 4 | + * A build with no telemetry noun, a refused row or an ingest that will not | |
| 5 | + * take it never disturbs the instructions. | |
| 6 | + * | |
| 7 | + * @param send the call | |
| 8 | + */ | |
| 9 | +export const quietly = (send: () => Promise<void>): void => { | |
| 10 | + void Promise.resolve() | |
| 11 | + .then(send) | |
| 12 | + .catch(() => undefined) | |
| 13 | +} |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './load-counts' | |
| 2 | +export * from './telemetry-mark' | |
| 3 | +export * from './telemetry-prop' | |
| 4 | +export * from './telemetry-row' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './load-counts.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,14 @@ | ||
| 1 | +/** | |
| 2 | + * What the first context of a load handed the engine, as counts. | |
| 3 | + * | |
| 4 | + * The AGENTS.md files added, the files they `@`-imported, their characters | |
| 5 | + * in all, whether `claude-md-or-agents-md` stood down for a CLAUDE.md of the | |
| 6 | + * project's own, and whether the walk threw so that nothing was found. | |
| 7 | + */ | |
| 8 | +export type LoadCounts = { | |
| 9 | + fileCount: number | |
| 10 | + importCount: number | |
| 11 | + totalContentLength: number | |
| 12 | + isYielded: boolean | |
| 13 | + isWalkFailed: boolean | |
| 14 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './telemetry-mark.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +import type { EngineInterface } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What `$.telemetry.mark` takes, as the telemetry mod's contract declares | |
| 5 | + * it: the feature, how it went, and why when not ok. | |
| 6 | + */ | |
| 7 | +export type TelemetryMark = Parameters<EngineInterface['telemetry']['mark']>[0] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './telemetry-prop.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { TelemetryRow } from '../telemetry-row' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One property value a `$.telemetry` row may carry. | |
| 5 | + * | |
| 6 | + * As the telemetry mod's contract declares it: a finite number, a boolean, | |
| 7 | + * or a string chosen from the list declared beside it. | |
| 8 | + */ | |
| 9 | +export type TelemetryProp = NonNullable<TelemetryRow['props']>[string] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './telemetry-row.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,7 @@ | ||
| 1 | +import type { EngineInterface } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What `$.telemetry.log` takes, as the telemetry mod's contract declares | |
| 5 | + * it: the event's name and its properties by snake_case key. | |
| 6 | + */ | |
| 7 | +export type TelemetryRow = Parameters<EngineInterface['telemetry']['log']>[0] |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The `sad` mark's reason when the AGENTS.md walk threw and the load went on | |
| 3 | + * with nothing found. | |
| 4 | + */ | |
| 5 | +export const WALK_FAILED_REASON = 'walk_failed' |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { FsAncestor } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One instruction file a walk found. | |
| 5 | + * | |
| 6 | + * @param dir the directory the file stands in | |
| 7 | + * @param name the file's name as the walk asked for it | |
| 8 | + * @param content the file's text | |
| 9 | + * @returns the found file | |
| 10 | + */ | |
| 11 | +export const ancestorOf = ( | |
| 12 | + dir: string, | |
| 13 | + name: string, | |
| 14 | + content: string, | |
| 15 | +): FsAncestor => ({ | |
| 16 | + dir, | |
| 17 | + name, | |
| 18 | + content, | |
| 19 | + parts: [{ path: `${dir}/${name}`, content }], | |
| 20 | +}) |
| @@ -1,0 +1,10 @@ | ||
| 1 | +export * from './ancestor-of.js' | |
| 2 | +export * from './project-of.js' | |
| 3 | +export * from './recording.js' | |
| 4 | +export * from './rows-of.js' | |
| 5 | +export * from './session.js' | |
| 6 | +export * from './started-of.js' | |
| 7 | +export * from './types' | |
| 8 | +export * from './unreadable-project-of.js' | |
| 9 | + | |
| 10 | +export * as default from '.' |
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type { FsAncestor, On } from 'claude-code' | |
| 2 | + | |
| 3 | +import { startedOf } from './started-of.js' | |
| 4 | +import type { Started } from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A project whose walks up from the working directory find the given files, | |
| 8 | + * one list per set of names, its session starting untouched (startedOf); | |
| 9 | + * each walk's first name is kept in `walks`. | |
| 10 | + * | |
| 11 | + * @param on the test's `on` | |
| 12 | + * @param agents what a walk for AGENTS.md and .claude/AGENTS.md finds | |
| 13 | + * @param claude what a walk for CLAUDE.md, .claude/CLAUDE.md and | |
| 14 | + * CLAUDE.local.md finds | |
| 15 | + * @returns the toasts, lines and walks the plugins raised, in order | |
| 16 | + */ | |
| 17 | +export function projectOf( | |
| 18 | + on: On, | |
| 19 | + agents: readonly FsAncestor[], | |
| 20 | + claude: readonly FsAncestor[], | |
| 21 | +): Started { | |
| 22 | + const started = startedOf(on) | |
| 23 | + | |
| 24 | + on('fs.ancestors', ($, e) => { | |
| 25 | + started.walks.push(e.names[0] ?? '') | |
| 26 | + | |
| 27 | + return { value: e.names.includes('AGENTS.md') ? agents : claude } | |
| 28 | + }) | |
| 29 | + | |
| 30 | + return started | |
| 31 | +} |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import type { Plugin } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A plugin standing in for the telemetry built-in: it adds the `telemetry` | |
| 5 | + * noun at `engine.create` over the nouns beneath. | |
| 6 | + * | |
| 7 | + * Its `log` and `mark` write the entry they are handed as one `$.ui.log` | |
| 8 | + * line, `<op> <json>`, which startedOf keeps and rowsOf reads back. | |
| 9 | + */ | |
| 10 | +export const RECORDING: Plugin = { | |
| 11 | + name: 'recording', | |
| 12 | + tier: 'builtin', | |
| 13 | + register(on) { | |
| 14 | + on('engine.create', async ($, e, next) => { | |
| 15 | + const beneath = await next(e) | |
| 16 | + const built = { ...beneath } | |
| 17 | + built.telemetry = { | |
| 18 | + log: async entry => { | |
| 19 | + await beneath.ui.log(`log ${JSON.stringify(entry)}`) | |
| 20 | + }, | |
| 21 | + mark: async entry => { | |
| 22 | + await beneath.ui.log(`mark ${JSON.stringify(entry)}`) | |
| 23 | + }, | |
| 24 | + } | |
| 25 | + | |
| 26 | + return built | |
| 27 | + }) | |
| 28 | + }, | |
| 29 | +} |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type { Row, Started } from './types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What the plugin handed the telemetry noun so far, read off the transcript | |
| 5 | + * lines RECORDING wrote, in the order handed; other lines are passed over. | |
| 6 | + * | |
| 7 | + * @param started the session's start, whose lines hold the rows | |
| 8 | + * @returns the `[op, entry]` pairs | |
| 9 | + */ | |
| 10 | +export const rowsOf = (started: Started): Row[] => | |
| 11 | + started.lines | |
| 12 | + .filter(line => line.startsWith('log ') || line.startsWith('mark ')) | |
| 13 | + .map((line): Row => { | |
| 14 | + const at = line.indexOf(' ') | |
| 15 | + | |
| 16 | + return [line.slice(0, at), JSON.parse(line.slice(at + 1))] | |
| 17 | + }) |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { SessionStartInput } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * An interactive terminal session two directories under /repo. | |
| 5 | + */ | |
| 6 | +export const SESSION: SessionStartInput = { | |
| 7 | + surface: 'terminal', | |
| 8 | + isInteractive: true, | |
| 9 | + cwd: '/repo/a/b', | |
| 10 | +} |
| @@ -1,0 +1,36 @@ | ||
| 1 | +import type { On } from 'claude-code' | |
| 2 | +import { mock } from 'claude-code/testing' | |
| 3 | + | |
| 4 | +import { SESSION } from './session.js' | |
| 5 | +import type { Started, ToastAsked } from './types' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * A session that starts untouched beneath the plugins, rooted at SESSION's | |
| 9 | + * working directory, every toast and transcript line they raise kept for the | |
| 10 | + * test to read, on a mock clock the test settles before it reads them. | |
| 11 | + * | |
| 12 | + * @param on the test's `on` | |
| 13 | + * @returns the toasts, lines and walks raised, in order, and the clock | |
| 14 | + */ | |
| 15 | +export function startedOf(on: On): Started { | |
| 16 | + const toasts: ToastAsked[] = [] | |
| 17 | + const lines: string[] = [] | |
| 18 | + | |
| 19 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 20 | + | |
| 21 | + on('session.root', () => ({ value: SESSION.cwd })) | |
| 22 | + | |
| 23 | + on('ui.toast', ($, e) => { | |
| 24 | + toasts.push(e) | |
| 25 | + | |
| 26 | + return { value: undefined } | |
| 27 | + }) | |
| 28 | + | |
| 29 | + on('ui.log', ($, e) => { | |
| 30 | + lines.push(e.text) | |
| 31 | + | |
| 32 | + return { value: undefined } | |
| 33 | + }) | |
| 34 | + | |
| 35 | + return { toasts, lines, walks: [], clock: mock.clock(on) } | |
| 36 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './row' | |
| 2 | +export * from './started' | |
| 3 | +export * from './toast-asked' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './row.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * One entry the plugin handed the telemetry noun, as RECORDING wrote it: | |
| 3 | + * which call (`log` or `mark`) and the entry. | |
| 4 | + */ | |
| 5 | +export type Row = [op: string, entry: unknown] |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './started.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import type { MockClock } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import type { ToastAsked } from '../toast-asked' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * What the plugins raised while a session ran: each toast as asked for, each | |
| 7 | + * transcript line, the first name of each walk they asked for; `clock` | |
| 8 | + * settles what they floated. | |
| 9 | + */ | |
| 10 | +export type Started = { | |
| 11 | + toasts: ToastAsked[] | |
| 12 | + lines: string[] | |
| 13 | + walks: string[] | |
| 14 | + clock: MockClock | |
| 15 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './toast-asked.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * One `$.ui.toast` call as the plugin made it: the text and, when it named | |
| 3 | + * one, how long the bar keeps it. | |
| 4 | + */ | |
| 5 | +export type ToastAsked = { | |
| 6 | + text: string | |
| 7 | + timeoutMs?: number | |
| 8 | +} |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type { On } from 'claude-code' | |
| 2 | + | |
| 3 | +import { startedOf } from './started-of.js' | |
| 4 | +import type { Started } from './types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A project whose walk fails, its session starting untouched (startedOf). | |
| 8 | + * | |
| 9 | + * @param on the test's `on` | |
| 10 | + * @returns the toasts and lines the plugins raised, in order | |
| 11 | + */ | |
| 12 | +export function unreadableProjectOf(on: On): Started { | |
| 13 | + const started = startedOf(on) | |
| 14 | + | |
| 15 | + on('fs.ancestors', () => { | |
| 16 | + throw new Error('no walk today') | |
| 17 | + }) | |
| 18 | + | |
| 19 | + return started | |
| 20 | +} |
| @@ -1,0 +1,137 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import Hooks from '../hooks' | |
| 4 | +import Fixtures from './fixtures' | |
| 5 | + | |
| 6 | +tier('builtin') | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The blocks the engine hands `prompt.context`. | |
| 10 | + */ | |
| 11 | +const BLOCKS = [ | |
| 12 | + { name: 'claudeMd', text: 'x' }, | |
| 13 | + { name: 'currentDate', text: 'today' }, | |
| 14 | +] | |
| 15 | + | |
| 16 | +describe('register', () => { | |
| 17 | + test('by default a project with AGENTS.md alone gets it', async ($, on) => { | |
| 18 | + const started = Fixtures.projectOf( | |
| 19 | + on, | |
| 20 | + [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')], | |
| 21 | + [], | |
| 22 | + ) | |
| 23 | + | |
| 24 | + on('prompt.context', ($, e) => ({ | |
| 25 | + blocks: e.blocks, | |
| 26 | + instructionFiles: e.instructionFiles, | |
| 27 | + })) | |
| 28 | + | |
| 29 | + const context = await $.prompt.context({ | |
| 30 | + blocks: BLOCKS, | |
| 31 | + instructionFiles: [], | |
| 32 | + }) | |
| 33 | + | |
| 34 | + expect(context.instructionFiles).toEqual([ | |
| 35 | + { path: '/repo/AGENTS.md', kind: 'project', content: '# top\n' }, | |
| 36 | + ]) | |
| 37 | + expect(context.blocks?.[0]?.text).toContain( | |
| 38 | + 'Contents of /repo/AGENTS.md (project instructions, checked into the ' + | |
| 39 | + 'codebase):\n\n# top', | |
| 40 | + ) | |
| 41 | + | |
| 42 | + await started.clock.settle() | |
| 43 | + | |
| 44 | + expect(started.lines).toEqual([ | |
| 45 | + 'no CLAUDE.md found; AGENTS.md loaded: /repo/AGENTS.md', | |
| 46 | + ]) | |
| 47 | + }) | |
| 48 | + | |
| 49 | + test('by default a project with a CLAUDE.md is left to the engine', async ($, on) => { | |
| 50 | + const started = Fixtures.projectOf( | |
| 51 | + on, | |
| 52 | + [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')], | |
| 53 | + [Fixtures.ancestorOf('/repo/a/b', 'CLAUDE.md', '# mine\n')], | |
| 54 | + ) | |
| 55 | + const handed = [ | |
| 56 | + { path: '/repo/a/b/CLAUDE.md', kind: 'project' as const, content: '#' }, | |
| 57 | + ] | |
| 58 | + | |
| 59 | + on('prompt.context', ($, e) => ({ | |
| 60 | + blocks: e.blocks, | |
| 61 | + instructionFiles: e.instructionFiles, | |
| 62 | + })) | |
| 63 | + | |
| 64 | + expect( | |
| 65 | + await $.prompt.context({ blocks: BLOCKS, instructionFiles: handed }), | |
| 66 | + ).toEqual({ blocks: BLOCKS, instructionFiles: handed }) | |
| 67 | + | |
| 68 | + await started.clock.settle() | |
| 69 | + | |
| 70 | + expect(started.walks).toEqual([]) | |
| 71 | + expect(started.lines).toEqual([]) | |
| 72 | + }) | |
| 73 | + | |
| 74 | + test('a failed walk leaves the context as handed', async ($, on) => { | |
| 75 | + Fixtures.unreadableProjectOf(on) | |
| 76 | + | |
| 77 | + on('prompt.context', ($, e) => ({ | |
| 78 | + blocks: e.blocks, | |
| 79 | + instructionFiles: e.instructionFiles, | |
| 80 | + })) | |
| 81 | + | |
| 82 | + expect( | |
| 83 | + await $.prompt.context({ blocks: BLOCKS, instructionFiles: [] }), | |
| 84 | + ).toEqual({ blocks: BLOCKS, instructionFiles: [] }) | |
| 85 | + }) | |
| 86 | + | |
| 87 | + test( | |
| 88 | + 'the start sends the mode row alone: no walk, no toast', | |
| 89 | + { plugins: [Fixtures.RECORDING] }, | |
| 90 | + async ($, on) => { | |
| 91 | + const started = Fixtures.projectOf( | |
| 92 | + on, | |
| 93 | + [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')], | |
| 94 | + [], | |
| 95 | + ) | |
| 96 | + | |
| 97 | + expect(await $.session.start(Fixtures.SESSION)).toEqual({ | |
| 98 | + cwd: '/repo/a/b', | |
| 99 | + }) | |
| 100 | + | |
| 101 | + await started.clock.settle() | |
| 102 | + | |
| 103 | + expect(Fixtures.rowsOf(started)).toEqual([ | |
| 104 | + [ | |
| 105 | + 'log', | |
| 106 | + { | |
| 107 | + event: Hooks.MODE_EVENT, | |
| 108 | + props: { | |
| 109 | + mode: { value: 'claude-md-or-agents-md', of: [...Hooks.MODES] }, | |
| 110 | + is_interactive: true, | |
| 111 | + }, | |
| 112 | + }, | |
| 113 | + ], | |
| 114 | + ]) | |
| 115 | + expect(Hooks.MODE_EVENT).toBe('agents_md_mode') | |
| 116 | + expect(started.walks).toEqual([]) | |
| 117 | + expect(started.toasts).toEqual([]) | |
| 118 | + }, | |
| 119 | + ) | |
| 120 | + | |
| 121 | + test('with no telemetry noun the start goes on untouched', async ($, on) => { | |
| 122 | + const started = Fixtures.projectOf( | |
| 123 | + on, | |
| 124 | + [Fixtures.ancestorOf('/repo', 'AGENTS.md', '# top\n')], | |
| 125 | + [], | |
| 126 | + ) | |
| 127 | + | |
| 128 | + expect( | |
| 129 | + await $.session.start({ ...Fixtures.SESSION, isInteractive: false }), | |
| 130 | + ).toEqual({ cwd: '/repo/a/b' }) | |
| 131 | + | |
| 132 | + await started.clock.settle() | |
| 133 | + | |
| 134 | + expect(Fixtures.rowsOf(started)).toEqual([]) | |
| 135 | + expect(started.toasts).toEqual([]) | |
| 136 | + }) | |
| 137 | +}) |
| @@ -9,10 +9,12 @@ | ||
| 9 | 9 | export function oldFiles(on: On) { |
| 10 | 10 | on('fs.list', () => ({ |
| 11 | 11 | value: [ |
| 12 | - { name: 'old.ts', kind: 'file', size: 2 }, | |
| 13 | - { name: 'moved.ts', kind: 'file', size: 2 }, | |
| 12 | + { name: 'old.ts', kind: 'file', size: 2, isLink: false }, | |
| 13 | + { name: 'moved.ts', kind: 'file', size: 2, isLink: false }, | |
| 14 | 14 | ], |
| 15 | 15 | })) |
| 16 | 16 | |
| 17 | - on('fs.stat', () => ({ value: { kind: 'file', size: 2, mtimeMs: 0 } })) | |
| 17 | + on('fs.stat', () => ({ | |
| 18 | + value: { kind: 'file', size: 2, mtimeMs: 0, isLink: false }, | |
| 19 | + })) | |
| 18 | 20 | } |
Large file, diff not shown.