1212
1313import os
1414import re
15+ import subprocess
1516from pathlib import Path
1617
1718from . import config
@@ -74,6 +75,7 @@ def index_skills(skill_dir: Path | str | None) -> dict[str, tuple[str, str]]:
7475 return {}
7576 skills : dict [str , tuple [str , str ]] = {}
7677 visited : set [str ] = set ()
78+ found : list [tuple [str , str , str ]] = []
7779 for dirpath , dirnames , filenames in os .walk (root , followlinks = True ):
7880 real = os .path .realpath (dirpath )
7981 if real in visited :
@@ -89,7 +91,12 @@ def index_skills(skill_dir: Path | str | None) -> dict[str, tuple[str, str]]:
8991 parsed = _parse_skill_frontmatter (Path (path ))
9092 if parsed :
9193 name , desc = parsed
92- skills [name ] = (path , desc )
94+ found .append ((path , name , desc ))
95+ # os.walk yields directories in arbitrary order, so resolve duplicate
96+ # names by path explicitly: sort first, then insert, and the last file
97+ # in sorted-path order wins (deterministic, like opencode's overwrite).
98+ for path , name , desc in sorted (found ):
99+ skills [name ] = (path , desc )
93100 return dict (sorted (skills .items ()))
94101
95102
@@ -139,36 +146,137 @@ def load_agent_prompt(path: Path | str | None, skill_dir: Path | str | None = No
139146 return text or None
140147
141148
142- def load_context_files (context_dir : Path | str | None ) -> str | None :
143- """Read all files in *context_dir* and format them as context blocks.
149+ def _git_toplevel (directory : str ) -> str :
150+ """Return the git worktree root for *directory*.
151+
152+ Mirrors opencode's git.repo.discover: walk up for ``.git``, then
153+ run ``git rev-parse --show-toplevel``. Falls back to the nearest
154+ ``.git`` parent when git itself is unusable, and to *directory*
155+ when no repository is found (so AGENTS.md lookup still works in
156+ non-git projects, bounded by the project directory).
157+ """
158+ d = Path (directory ).resolve ()
159+ for parent in [d , * d .parents ]:
160+ if (parent / ".git" ).exists ():
161+ try :
162+ proc = subprocess .run (
163+ ["git" , "rev-parse" , "--show-toplevel" ],
164+ cwd = str (parent ),
165+ capture_output = True ,
166+ text = True ,
167+ timeout = 10 ,
168+ )
169+ if proc .returncode == 0 and proc .stdout .strip ():
170+ return proc .stdout .strip ()
171+ except (OSError , subprocess .SubprocessError ):
172+ pass
173+ return str (parent )
174+ return str (d )
175+
176+
177+ def _find_up (filename : str , start : Path , stop : Path ) -> list [str ]:
178+ """Every *filename* from *start* up to and including *stop*.
179+
180+ Mirrors opencode's ``FileSystem.findUp``: collect EVERY match along
181+ the way, not just the nearest one, and stop after *stop* (or at the
182+ filesystem root, whichever comes first).
183+ """
184+ matches : list [str ] = []
185+ current = start
186+ while True :
187+ candidate = current / filename
188+ if candidate .is_file ():
189+ matches .append (str (candidate ))
190+ if current == stop :
191+ break
192+ parent = current .parent
193+ if parent == current :
194+ break
195+ current = parent
196+ return matches
197+
198+
199+ def find_agents_md_files (project_dir : str ) -> list [str ]:
200+ """Locate the project's ``AGENTS.md`` files, nearest first.
201+
202+ Collects EVERY ``AGENTS.md`` walking up from *project_dir* to the
203+ git worktree root (mirrors opencode's ``Instruction.systemPaths``),
204+ so running the agent in a subdirectory still picks up the repo-root
205+ instructions.
206+
207+ ``AGENTS.md`` is the ONLY recognized file: there is no user-global
208+ instruction file and no ``CLAUDE.md``/``CONTEXT.md`` fallback, so a
209+ project without an ``AGENTS.md`` gets nothing injected. The walk
210+ only ever goes upward, bounded by the git worktree root (or
211+ *project_dir* outside a repo), so it neither escapes into unrelated
212+ parent directories nor discovers files in subdirectories.
213+ """
214+ start = Path (project_dir ).resolve ()
215+ stop = Path (_git_toplevel (project_dir )).resolve ()
216+ if not start .is_relative_to (stop ):
217+ # git reported a worktree root that is not an ancestor of the
218+ # resolved project dir (differently-spelled paths — symlinked or
219+ # automounted checkouts). Without this guard _find_up would walk
220+ # to the filesystem root looking for `stop` and pick up AGENTS.md
221+ # files from unrelated ancestors.
222+ stop = start
223+ return _find_up ("AGENTS.md" , start , stop )
224+
225+
226+ def load_context_files (
227+ context_dir : Path | str | None ,
228+ extra_files : list [str ] | None = None ,
229+ ) -> str | None :
230+ """Format *extra_files* plus every file in *context_dir* as context.
144231
145232 Returns a string like:
146233 Request context:
147234
148- In file `~/.emacs.d/contexts/README.md`:
235+ In file `/path/to/project/AGENTS.md`:
236+
237+ <file contents>
238+
239+ In file `/path/to/project/contexts/README.md`:
149240
150- ```
151241 <file contents>
152- ```
153242
154- Returns None if no context directory or no readable files.
243+ *extra_files* are individual files outside the context directory
244+ (the project's ``AGENTS.md`` files) and come first, in the order
245+ given; the context directory's own files follow, sorted by name.
246+ They are read and rendered identically: an ``In file `path`:``
247+ header is the only delimiter, and contents are NOT wrapped in a code
248+ fence (a fence would be closed early by any file containing one).
249+
250+ Unreadable and empty files are skipped, and a file reachable both
251+ ways (a *context_dir* that also holds a discovered ``AGENTS.md``) is
252+ rendered once. Returns None when there is nothing to inject.
155253 """
156- if not context_dir :
157- return None
158- d = Path (context_dir )
159- if not d .is_dir ():
160- return None
254+ paths : list [Path ] = []
255+ seen : set [Path ] = set ()
256+
257+ def _add (path : Path ) -> None :
258+ resolved = path .resolve ()
259+ if resolved in seen :
260+ return
261+ seen .add (resolved )
262+ paths .append (path )
263+
264+ for extra in extra_files or []:
265+ _add (Path (extra ))
266+ d = Path (context_dir ) if context_dir else None
267+ if d and d .is_dir ():
268+ for child in sorted (d .iterdir ()):
269+ if child .is_file ():
270+ _add (child )
161271 blocks : list [str ] = []
162- for child in sorted (d .iterdir ()):
163- if not child .is_file ():
164- continue
272+ for path in paths :
165273 try :
166- content = child .read_text (encoding = "utf-8" , errors = "replace" )
274+ content = path .read_text (encoding = "utf-8" , errors = "replace" )
167275 except OSError :
168276 continue
169277 if not content .strip ():
170278 continue
171- blocks .append (f"In file `{ child } `:\n \n ``` \n { content .rstrip ()} \n ``` " )
279+ blocks .append (f"In file `{ path } `:\n \n { content .rstrip ()} " )
172280 if not blocks :
173281 return None
174282 return "Request context:\n \n " + "\n \n " .join (blocks )
@@ -200,25 +308,31 @@ def assemble_agent_prompt(
200308) -> str | None :
201309 """Assemble the effective system prompt for an agent run.
202310
203- Order: [project context files] -> task-completion-rules.md ->
204- the actual agent prompt. The completion rules are always the LAST
205- context piece, immediately before the agent prompt, so they read as
206- global ground rules rather than part of the task instructions.
207-
208- ``include_context=False`` drops the project context files (rules are
209- still included). This function is NOT used for sub-agents: their
210- system prompt is their own prompt file (subagent.md) only, with no
211- context files and no task-completion rules (see
212- ``cli.make_session`` and ``subagent._subagent_system_prompt``).
213- ``context_path`` overrides the default context directory discovery.
311+ Order: [project context files, AGENTS.md first] ->
312+ task-completion-rules.md -> the actual agent prompt. The completion
313+ rules are always the LAST context piece, immediately before the
314+ agent prompt, so they read as global ground rules rather than part
315+ of the task instructions.
316+
317+ ``include_context=False`` drops the context section (rules are still
318+ included). This function is NOT used for sub-agents: their system
319+ prompt is their own prompt file (subagent.md) only, with no context
320+ files and no task-completion rules (see ``cli.make_session`` and
321+ ``subagent._subagent_system_prompt``). ``context_path`` overrides
322+ the default context directory discovery.
214323 Returns None if every part is empty/missing.
215324 """
216325 parts : list [str ] = []
217326 if include_context :
218327 # lazy import: harness imports this module at call time
219328 from .agent_session import find_context_dir
220329
221- context_block = load_context_files (find_context_dir (project_dir , context_path ))
330+ # the project's AGENTS.md files are just context files that live
331+ # outside the context directory — same block format, same section
332+ context_block = load_context_files (
333+ find_context_dir (project_dir , context_path ),
334+ extra_files = find_agents_md_files (project_dir ),
335+ )
222336 if context_block :
223337 parts .append (context_block )
224338 rules = load_task_completion_rules ()
0 commit comments