AGENTS.md up and down the tree like X_CODER.md, by the instructionFiles option: under x-coder-md-or-agents-md (the default; where the project has no X_CODER.md…

AGENTS.md read the way X-Coder reads X_CODER.md, as a plugin, under one option, instructionFiles:
x-coder-md: only X_CODER.md is loaded, by the engine, as today. The plugin adds nothing.x-coder-md-or-agents-md (the default): a project with no instruction files of its own gets its AGENTS.md files instead, loaded exactly where and how X_CODER.md would be. "Of its own" is read off what the engine loaded for the context: a X_CODER.md, .x-coder/X_CODER.md or X_CODER.local.md in any directory from the root down to the working directory leaves the whole project to the engine, and the plugin stays out (the organization's managed file, the person's ~/.x-coder/X_CODER.md, a .x-coder/rules file and an added directory's X_CODER.md do not count, as the nested walk does not see them either). With none, every AGENTS.md and .x-coder/AGENTS.md on that path joins the instruction files the engine renders, and a Read under a subdirectory attaches that directory's AGENTS.md unless a X_CODER.md there claims it.x-coder-md-and-agents-md: every AGENTS.md is loaded beside X_CODER.md, up and down the tree; a file X_CODER.md already @-imports, or is a link to, is not loaded a second time (compared by path, then by content).managed-only: the project's checked-in and private instruction files and the person's own are dropped from the context; the organization's managed X_CODER.md and the engine's memory stay. The engine's nested X_CODER.md attachments on Read are not an event yet and still arrive. (The engine's x-coderMdExcludes setting also exists, for user, project and local files, and applies to the AGENTS.md files this plugin reads too.)How the files reach the model is the engine's doing, not the plugin's: prompt.context hands a hook the instruction files behind x-coderMd ({ path, kind, content, parent? }, kinds managed, user, project, local, memory, in load order) and a hook answers the list changed. The engine then renders x-coderMd from the answered files with its own preamble and framing, announces them by name, and keeps only the managed ones for an agent that omits project instructions (Explore, Plan, a custom agent with omitX-CoderMd). So an AGENTS.md this plugin adds as a project file is, to everything downstream, a project instruction file: same place in the context, same framing, same omission rules, same announcement. An organization's prepended plugin on prompt.context sits above this one and has the last word on the files.
hooks/register.ts is the module; everything under hooks/ is its parts, importing x-coder and one another alone. tests/ runs under x-coder plugin test <this folder>.
As a built-in its option is the /config row "Project instructions", a picker over the four values, each described there. By hand it is
{
"pluginConfigs": {
"agents-md@builtin": {
"options": { "instructionFiles": "x-coder-md-and-agents-md" }
}
}
}
in user settings (~/.x-coder/settings.json), --settings, or managed settings; a project's .x-coder/settings.json is not read for plugin options. Changing it reloads the module, and the next context the engine builds (the next turn after the reload, a new conversation, /clear, a compaction) carries the new mode's files. A hand-typed value outside the four is told once in the transcript and reads as the default. /plugin lists the plugin among the built-ins, where a person can turn it off; with it off the engine reads X_CODER.md alone. No hooks setting or CLI mode turns it off (disableAllHooks, allowManagedHooksOnly and --bare govern settings hooks and installed plugins, not built-ins); where the engine loads no instruction files (--bare without --add-dir, --safe-mode, X_CODER_DISABLE_X_CODER_MDS) its walk finds none and it adds none, X_CODER.md and AGENTS.md alike.
The option was first keyed projectInstructions, with the values x-coder, agents-fallback, both and none. A value still stored under that key is honoured for now while instructionFiles reads as its default: none as managed-only, x-coder as x-coder-md, agents-fallback as x-coder-md-or-agents-md, both as x-coder-md-and-agents-md, any other value as x-coder-md (which adds nothing, never as the default, which loads AGENTS.md); the first session.start of a load says in the transcript how it was read. Once instructionFiles is set to anything but its default, the old key is not read and the transcript says to remove it.
Run from this folder instead (x-coder --plugin-dir mods/agents-md), the same entry is keyed "agents-md".
| event | what the hook does |
|---|---|
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 |
prompt.context | under x-coder-md-or-agents-md and x-coder-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 x-coder-md-or-agents-md it answers nothing when the project has a X_CODER.md of its own (among the handed files, else found by a $.fs.ancestors walk, so a X_CODER.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 x-coderMd 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 |
agent.spawn on fork: true | under x-coder-md-or-agents-md and x-coder-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) |
tool.call on Read | under x-coder-md-or-agents-md and x-coder-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 X_CODER.md there; and nothing anywhere in a run where the engine attaches nothing to a turn, --bare with its X_CODER_SIMPLE or X_CODER_DISABLE_ATTACHMENTS, read on every Read through $.env.get as the engine reads them on every turn): 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 X_CODER.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 X_CODER.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 X_CODER.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 |
$fs.ancestors (with each found file's parts: the file and its imports apart; with below on a Read; it finds nothing on a thin client, whose workspace files are remote, as the engine's own walk does), session.root, session.cwd, env.get (HOME and USERPROFILE, once per load, the profile first on a Windows spelling of the working directory, so a ~/ path the model hands a Read resolves where the Read tool reads it; X_CODER_SIMPLE and X_CODER_DISABLE_ATTACHMENTS on every Read), ui.log, telemetry.log and telemetry.mark.
$.telemetry is the telemetry plugin's noun; where that plugin is not seated the calls find no noun and are dropped without a trace, and nothing else changes.
Counts and closed choices only; no path and no file text. Each row goes through $.telemetry.log, so it exists only where the telemetry plugin does:
| event | when | properties | |||
|---|---|---|---|---|---|
agents_md_mode | once per fresh load, at session.start | mode (x-coder-md \ | x-coder-md-or-agents-md \ | x-coder-md-and-agents-md \ | managed-only), is_interactive |
agents_md_load | the first context of a load, under x-coder-md-or-agents-md and x-coder-md-and-agents-md | mode, file_count (AGENTS.md files handed to the engine), import_count (their @ imports), total_content_length, yielded (x-coder-md-or-agents-md stood down for a X_CODER.md of the project's own), walk_failed; with it one $.telemetry.mark for feature agents_md: ok, or sad with reason walk_failed | |||
agents_md_nested | a Read that attached nested files | mode, file_count |
All of these apply only to the modes that load AGENTS.md, x-coder-md-or-agents-md (the default) and x-coder-md-and-agents-md. Each names a loader fact a plugin cannot reach through the events it has today.
Read only. The engine also attaches a directory's X_CODER.md for a file @-mentioned in the prompt, for the IDE's opened file or selection, and for the Read tool's notebook, image and PDF results.Read under that directory instead), and a change to it mid-session is not re-announced./cd carries the new tree's X_CODER.md in its own notice; the plugin's files for the new tree arrive in the same next request through the engine's instructions announcement instead.--add-dir directories contribute no AGENTS.md, where the engine can load their X_CODER.md./memory and the # shortcut do not know AGENTS.md files, and the engine's own initial-load row does not count them (this plugin's agents_md_load row does).@ import outside the working directory inside an AGENTS.md is honoured only once the approval the engine asks for a X_CODER.md's external imports has been given (without it the import is left out, as a X_CODER.md's is); the approval dialog itself is raised for X_CODER.md imports alone.AGENTS.md at its own first Read under that directory even when its parent's loop was already given it; the engine does not hand such a subagent the nested X_CODER.md again. A fork matches the engine on both sides.x-coder plugin test mods/agents-md
tests/register.test.ts covers the default mode: a project with AGENTS.md alone gets it as a project instruction file and one transcript line naming it, a project with a X_CODER.md of its own is left to the engine without a walk, a failed walk leaves the context as handed, and the start hands $.telemetry the mode row alone where a test seats a provider for that noun, and goes on untouched where none is seated.
hooks/register.ts 312 lines1import type {
2 EngineInterface,
3 FsAncestor,
4 InstructionFile,
5 On,
6 PluginOptions,
7} from 'x-coder'
8
9import Files from './files'
10import Frames from './frames'
11import Modes from './modes'
12import Names from './names'
13import Switches from './switches'
14import Telemetry from './telemetry'
15
16/**
17 * No files: what a walk not taken, or one that failed, found.
18 */
19const NONE: readonly FsAncestor[] = []
20
21/**
22 * Registers the plugin's hooks for the mode `instructionFiles` names
23 * (`x-coder-md-or-agents-md` when unset; the manifest lists the four, nothing
24 * else arrives).
25 *
26 * `x-coder-md`: nothing beyond the usage row, the engine's X_CODER.md walk
27 * standing alone. `managed-only`: the project's and the person's instruction
28 * files dropped, the organization's kept. `x-coder-md-or-agents-md` (a project
29 * with none of its own) and `x-coder-md-and-agents-md`: AGENTS.md files joined
30 * to the engine's instruction files, nested ones on a Read except in a run
31 * where the engine attaches nothing to a turn (--bare, which sets
32 * X_CODER_SIMPLE, or X_CODER_DISABLE_ATTACHMENTS). Every mode sends
33 * its usage rows through `$.telemetry` where that noun is seated and drops
34 * them where it is not.
35 *
36 * @param on the engine's registrar
37 * @param options the plugin's options; `instructionFiles` is `x-coder-md`,
38 * `x-coder-md-or-agents-md`, `x-coder-md-and-agents-md` or `managed-only`
39 */
40export function register(on: On, options: PluginOptions): void {
41 const named = Modes.modeOf(options.instructionFiles)
42 // COMPAT_BREAK(agents-md-project-instructions): drop the projectInstructions
43 // mapping once stored settings have migrated. The host fills in the default,
44 // so an instructionFiles set to it cannot be told from one left unset.
45 const legacy = Modes.legacyModeOf(options.projectInstructions)
46 const isLegacyRead = legacy !== undefined && named === Modes.DEFAULT_MODE
47 const mode = isLegacyRead ? legacy : named
48 let isRenameTold = legacy === undefined
49
50 on('session.start', ($, e, next) => {
51 Telemetry.quietly(() =>
52 $.telemetry.log(Telemetry.modeRowOf(mode, e.isInteractive)),
53 )
54
55 if (!isRenameTold) {
56 isRenameTold = true
57 $.ui.log(
58 isLegacyRead
59 ? 'option projectInstructions in settings is honoured for now, ' +
60 `read as instructionFiles ${mode}; set instructionFiles to ` +
61 `${mode} and remove projectInstructions`
62 : `option projectInstructions in settings is not read: ` +
63 `instructionFiles ${mode} is set; remove projectInstructions`,
64 )
65 }
66
67 return next(e)
68 })
69
70 if (mode === 'x-coder-md') {
71 return
72 }
73
74 if (mode === 'managed-only') {
75 on(
76 'prompt.context',
77 { instructionFiles: { kind: Files.DROPPED_KINDS } },
78 ($, e, next) =>
79 next({
80 ...e,
81 instructionFiles: e.instructionFiles?.filter(
82 Files.isKeptWithoutInstructions,
83 ),
84 }),
85 )
86
87 return
88 }
89
90 const isFallback = mode === 'x-coder-md-or-agents-md'
91 const given = new Map<string, Set<string>>()
92 let inContext: readonly InstructionFile[] = []
93 let home: string | undefined
94 let isX-CoderProject: boolean | undefined
95 let rootSeen: string | undefined
96 let rootLogged: string | undefined
97 let isCounted = false
98
99 on('prompt.context', async ($, e, next) => {
100 const handed = e.instructionFiles
101
102 if (handed === undefined) {
103 return next(e).finally(() => {
104 given.clear()
105 })
106 }
107
108 const root = isFallback ? await $.session.root() : undefined
109 rootSeen = root ?? rootSeen
110 let isWalkFailed = false
111 // The walk, not only what was handed: the engine can withhold a project
112 // X_CODER.md it loaded, and the project still has one.
113 isX-CoderProject =
114 root !== undefined &&
115 (handed.some(file => Files.isX-CoderFileOnWalk(file, root)) ||
116 (await $.fs.ancestors({ names: Names.X_CODER_NAMES }).then(
117 files => files.length > 0,
118 () => {
119 isWalkFailed = true
120
121 return true
122 },
123 )))
124 const found = isX-CoderProject
125 ? NONE
126 : await $.fs.ancestors({ names: Names.AGENTS_NAMES }).catch(() => {
127 isWalkFailed = true
128
129 return NONE
130 })
131 const added = Files.unseenFiles(Files.filesOf(found), handed)
132
133 if (!isCounted) {
134 isCounted = true
135 const counts = Telemetry.loadCountsOf(
136 added,
137 isX-CoderProject && !isWalkFailed,
138 isWalkFailed,
139 )
140 Telemetry.quietly(() =>
141 $.telemetry.log(Telemetry.loadRowOf(mode, counts)),
142 )
143 Telemetry.quietly(() => $.telemetry.mark(Telemetry.loadMarkOf(counts)))
144 }
145
146 const isFirstLoad =
147 root !== undefined && root !== rootLogged && added.length > 0
148
149 if (isFirstLoad) {
150 rootLogged = root
151 $.ui.log(
152 'no X_CODER.md found; AGENTS.md loaded: ' +
153 added
154 .filter(file => file.parent === undefined)
155 .map(file => file.path)
156 .join(', '),
157 )
158 }
159
160 const instructionFiles = Files.withProjectFiles(handed, added)
161
162 return next({ ...e, instructionFiles }).finally(() => {
163 given.clear()
164 inContext = instructionFiles
165 })
166 })
167
168 on('agent.spawn', { fork: true }, async ($, e, next) => {
169 const result = await next(e)
170
171 if (result.agentId !== undefined) {
172 const parent = given.get(e.parentAgentId ?? Names.MAIN_LOOP)
173 given.set(result.agentId, new Set(parent))
174 }
175
176 return result
177 })
178
179 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
180 const result = await next(e)
181 const isSettledElsewhere =
182 e.tool !== 'Read' || result.deny !== undefined || result.isError
183
184 if (isSettledElsewhere) {
185 return result
186 }
187
188 if (!(await attachesOnRead($))) {
189 return result
190 }
191
192 const [root, cwd] = await Promise.all([$.session.root(), $.session.cwd()])
193 home ??= await homeOf($, cwd)
194 const read = Frames.absoluteOf(e.file_path, cwd, home)
195
196 if (root !== rootSeen) {
197 rootSeen = root
198 isX-CoderProject = undefined
199 given.clear()
200 }
201
202 isX-CoderProject ??=
203 isFallback &&
204 (await $.fs.ancestors({ names: Names.X_CODER_NAMES })).length > 0
205 const isOutOfReach = isX-CoderProject || !Frames.isBelow(read, root)
206
207 if (isOutOfReach) {
208 return result
209 }
210
211 const [stack, x-coder] = await Promise.all([
212 $.fs.ancestors({ names: Names.AGENTS_NAMES, of: read, below: root }),
213 $.fs.ancestors({ names: Names.X_CODER_NAMES, of: read, below: root }),
214 ]).catch((): [typeof NONE, typeof NONE] => [NONE, NONE])
215 const loop = e.agentId ?? Names.MAIN_LOOP
216 const sent = given.get(loop) ?? new Set<string>()
217 given.set(loop, sent)
218 const nested = (
219 isFallback ? Frames.outsideX-CoderDirs(stack, x-coder) : stack
220 ).filter(file => Frames.isBelow(file.dir, root))
221 const fresh = Files.unseenFiles(Files.filesOf(nested), [
222 ...inContext,
223 ...Files.filesOf(x-coder),
224 ]).filter(file => !sent.has(file.path))
225 const attached = fresh.filter(file => !Frames.isFileAt(file, read))
226 const output = result.result
227 const isPartialText =
228 output?.type === 'text' &&
229 (output.file.truncatedByTokenCap ||
230 output.file.startLine !== 1 ||
231 output.file.numLines < output.file.totalLines)
232 const isWholeRead =
233 e.offset === undefined && e.limit === undefined && !isPartialText
234
235 for (const file of fresh) {
236 const isSent =
237 attached.includes(file) || (Frames.isFileAt(file, read) && isWholeRead)
238
239 if (isSent) {
240 sent.add(file.path)
241 }
242 }
243
244 const hasAttached = attached.length > 0
245
246 if (hasAttached) {
247 Telemetry.quietly(() =>
248 $.telemetry.log(Telemetry.nestedRowOf(mode, attached.length)),
249 )
250 }
251
252 return hasAttached
253 ? {
254 ...result,
255 context: [
256 ...(result.context ?? []),
257 ...attached.map(Frames.nestedFrame),
258 ],
259 }
260 : result
261 })
262}
263
264/**
265 * Whether a Read attaches nested AGENTS.md files in this run: not where the
266 * engine attaches nothing to a turn, a nested X_CODER.md included, which is a
267 * --bare run (it sets X_CODER_SIMPLE) or one with
268 * X_CODER_DISABLE_ATTACHMENTS on.
269 *
270 * Read on every Read, as the engine reads them on every turn: a settings
271 * `env` block or a managed delivery can flip either mid-session. The files of
272 * the walk itself need no such check: where the engine loads no instruction
273 * files `$.fs.ancestors` finds none.
274 *
275 * @param $ the engine, as the `tool.call` hook holds it
276 * @returns whether nested files ride a Read's result here
277 */
278async function attachesOnRead($: EngineInterface): Promise<boolean> {
279 const [simple, attachmentsOff] = await Promise.all([
280 $.env.get('X_CODER_SIMPLE'),
281 $.env.get('X_CODER_DISABLE_ATTACHMENTS'),
282 ])
283
284 return (
285 !Switches.isSwitchedOn(simple) && !Switches.isSwitchedOn(attachmentsOff)
286 )
287}
288
289/**
290 * The home directory a `~` in a Read's path stands for, read the way the
291 * Read tool reads it.
292 *
293 * The profile directory on a Windows spelling of the working directory,
294 * else `HOME`, each falling back to the other.
295 *
296 * @param $ the engine, as the `tool.call` hook holds it
297 * @param cwd the session's working directory, whose spelling names the platform
298 * @returns the home directory, or undefined when the environment names none
299 */
300async function homeOf(
301 $: EngineInterface,
302 cwd: string,
303): Promise<string | undefined> {
304 const [home, profile] = await Promise.all([
305 $.env.get('HOME'),
306 $.env.get('USERPROFILE'),
307 ])
308 const isWindowsSpelling = cwd.includes('\\')
309
310 return isWindowsSpelling ? (profile ?? home) : (home ?? profile)
311}
312hooks/files/index.ts 13 lines1export * from './chain-root-of.js'
2export * from './dropped-kinds.js'
3export * from './files-of.js'
4export * from './insertion-index.js'
5export * from './is-x-coder-file-on-walk.js'
6export * from './is-kept-without-instructions.js'
7export * from './is-project-own.js'
8export * from './project-dir-of.js'
9export * from './unseen-files.js'
10export * from './with-project-files.js'
11
12export * as default from '.'
13hooks/frames/index.ts 9 lines1export * from './absolute-of.js'
2export * from './is-below.js'
3export * from './is-file-at.js'
4export * from './nested-frame.js'
5export * from './normal-spelling-of.js'
6export * from './outside-x-coder-dirs.js'
7
8export * as default from '.'
9hooks/modes/index.ts 8 lines1export * from './default-mode.js'
2export * from './legacy-mode-of.js'
3export * from './mode-of.js'
4export * from './modes.js'
5export * from './types'
6
7export * as default from '.'
8hooks/names/index.ts 6 lines1export * from './agents-names.js'
2export * from './x-coder-names.js'
3export * from './main-loop.js'
4
5export * as default from '.'
6hooks/switches/index.ts 4 lines1export * from './is-switched-on.js'
2
3export * as default from '.'
4hooks/telemetry/index.ts 16 lines1export * from './feature-name.js'
2export * from './load-counts-of.js'
3export * from './load-event.js'
4export * from './load-mark-of.js'
5export * from './load-row-of.js'
6export * from './mode-choice-of.js'
7export * from './mode-event.js'
8export * from './mode-row-of.js'
9export * from './nested-event.js'
10export * from './nested-row-of.js'
11export * from './quietly.js'
12export * from './types'
13export * from './walk-failed-reason.js'
14
15export * as default from '.'
16hooks/files/chain-root-of.ts 29 lines1import type { InstructionFile } from 'x-coder'
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 */
13export 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}
29hooks/files/dropped-kinds.ts 13 lines1import type { InstructionFileKind } from 'x-coder'
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 */
8export const DROPPED_KINDS: readonly InstructionFileKind[] = [
9 'project',
10 'local',
11 'user',
12]
13hooks/files/files-of.ts 21 lines1import type { FsAncestor, InstructionFile } from 'x-coder'
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 */
12export 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 )
21hooks/files/insertion-index.ts 45 lines1import type { InstructionFile } from 'x-coder'
2
3import Frames from '../frames'
4import { chainRootOf } from './chain-root-of.js'
5import { isProjectOwn } from './is-project-own.js'
6import { 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 */
20export 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}
45hooks/files/is-x-coder-file-on-walk.ts 40 lines1import type { InstructionFile } from 'x-coder'
2
3import Frames from '../frames'
4import Names from '../names'
5import { isProjectOwn } from './is-project-own.js'
6import { projectDirOf } from './project-dir-of.js'
7
8/**
9 * Whether a handed instruction file is a X_CODER.md, `.x-coder/X_CODER.md` or
10 * X_CODER.local.md of a directory on the walk down to the session's root.
11 *
12 * What makes a project "have a X_CODER.md of its own" for
13 * `x-coder-md-or-agents-md`, the same files the Read walk asks for; a rules
14 * file, an imported file or an added directory's X_CODER.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 X_CODER.md files on the walk
19 */
20export function isX-CoderFileOnWalk(
21 file: InstructionFile,
22 root: string,
23): boolean {
24 const isOwnX-CoderFile =
25 isProjectOwn(file) &&
26 file.parent === undefined &&
27 Names.X_CODER_NAMES.some(name =>
28 Frames.normalSpellingOf(file.path).endsWith(`/${name}`),
29 )
30
31 if (!isOwnX-CoderFile) {
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}
40