Instruction files stack from the project root down to the working directory

instructionChain (spec 3.0.0, filesystem.md): from the boundary — the project
root — down to the start directory, outermost first, each directory gives the
first of AGENTS.md, CLAUDE.md that is a file really inside the project; one
that is not (a link out) is passed over and the walk goes on below it. Before,
only the nearest file counted. The chain's files are named to the model by
their path in the project; agentsBlock reads them all: none means "make one",
and git is settled when any of them settles it.

Conformance runner for the `instructions` area (all 9 cases pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-27 20:04:03 +00:00
1 parent bc78aec424
commit 4bbee18cee
3 files changed
+126 -27

No files matched your search

+57 -24
View File
@@ -1,6 +1,6 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
import { platform, release } from "node:os"
import { dirname, join, resolve, sep } from "node:path"
import { dirname, join, relative, resolve, sep } from "node:path"
import type { Mode } from "../config/schema.ts"
import { expandHome, paths } from "../config/paths.ts"
import { render, type Plan } from "../plan/plan.ts"
@@ -135,8 +135,6 @@ export function knowledgeScope(names: string[] | undefined): string {
return `Knowledge searches here cover only: ${names.join(", ")}. Finding nothing there means nothing is there, not that the library is empty.`
}
/** Instruction files: the global AGENTS.md, then the nearest project AGENTS.md or CLAUDE.md
* walking up to the project root (first match wins, as OpenCode does — ancestors do not stack). */
/** An `instructions` entry from config.yaml. A project's may only name files inside the project:
* a cloned repository must not be able to put ~/.ssh/… into what is sent to the model. */
export interface ExtraInstruction {
@@ -144,29 +142,58 @@ export interface ExtraInstruction {
global: boolean
}
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string }[] {
const out: { path: string; text: string }[] = []
/** The names a directory's instruction file may have, in order of preference: one per directory. */
export const INSTRUCTION_NAMES = ["AGENTS.md", "CLAUDE.md"] as const
/** The stacked instruction chain (harness spec 3.0.0, filesystem.md *Instruction files, stacked*;
* conformance `instructions`): from the boundary down to the start directory, outermost first, each
* directory gives the first of AGENTS.md, CLAUDE.md that `readable` accepts — never both; one that is
* not readable is passed over and the walk goes on below it. A start outside the boundary is read as
* the boundary. Paths only, nothing read: the CLI answers `readable` from the disk, the conformance
* runner from a case's files and grants. */
export function instructionChain(boundary: string, start: string, readable: (path: string) => boolean): string[] {
const top = resolve(boundary)
let at = resolve(start)
if (at !== top && !at.startsWith(top === sep ? top : top + sep)) at = top
const dirs: string[] = []
for (let d = at; ; d = dirname(d)) {
dirs.unshift(d)
if (d === top || dirname(d) === d) break
}
const out: string[] = []
for (const d of dirs) {
const hit = INSTRUCTION_NAMES.map((f) => join(d, f)).find(readable)
if (hit) out.push(hit)
}
return out
}
/** Instruction files: the global AGENTS.md, then the project's chain from its root down to the
* working directory (instructionChain: every directory's AGENTS.md or CLAUDE.md, outermost first —
* before 3.0.0 only the nearest one counted), then the configured `instructions`. `chain` marks the
* chain's files: they are the project's own (agentsBlock), named to the model relative to the root. */
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string; chain?: true }[] {
const out: { path: string; text: string; chain?: true }[] = []
const global = join(paths.config, "AGENTS.md")
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
let dir = cwd
let realRoot = root
try {
realRoot = realpathSync(root)
} catch {}
for (;;) {
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
if (hit) {
// Read only when it really is a file in the project: a repository could commit AGENTS.md as
// a link to anything readable, and it would go to the model in every trust state.
// Read only when it really is a file in the project: a repository could commit AGENTS.md as a
// link to anything readable, and it would go to the model in every trust state.
const texts = new Map<string, string>()
const readable = (path: string) => {
try {
const real = realpathSync(hit)
if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) out.push({ path: hit, text: readFileSync(real, "utf8") })
} catch {}
break
const real = realpathSync(path)
if ((real !== realRoot && !real.startsWith(realRoot + sep)) || !statSync(real).isFile()) return false
texts.set(path, readFileSync(real, "utf8"))
return true
} catch {
return false
}
if (dir === root || dirname(dir) === dir) break
dir = dirname(dir)
}
for (const path of instructionChain(root, cwd, readable)) out.push({ path, text: texts.get(path)!, chain: true })
for (const e of extra) {
const path = resolve(root, expandHome(e.path))
if (out.some((f) => f.path === path)) continue
@@ -198,7 +225,7 @@ export interface PromptContext {
/** describeRepo(): branch, changes, recent commits. */
git?: string
/** The instruction files (AGENTS.md…) as read at the session's start; read now when absent. */
files?: { path: string; text: string }[]
files?: { path: string; text: string; chain?: true }[]
/** Open items on the project's task board. */
tasks?: string
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
@@ -283,7 +310,9 @@ export function assembleSystem(ctx: PromptContext): string {
let text = f.text.trim()
if (text.length > MAX_INSTRUCTION_CHARS)
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]`
sections.push(`Instructions from ${f.path}:\n<instructions>\n${text}\n</instructions>`)
// The chain's files by their path in the project (the spec's "relative to the boundary").
const named = f.chain ? relative(ctx.root, f.path) || f.path : f.path
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
}
if (ctx.mcp) sections.push(ctx.mcp)
@@ -334,11 +363,15 @@ const GIT_QUESTION =
const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask."
/** The project's own AGENTS.md (or CLAUDE.md): make it when missing, settle git when it does not,
* and keep it current. The global ~/.config/lembas/AGENTS.md is not the project's. */
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
* and keep it current. The project's are the chain's files (instructionFiles `chain`), stacked from
* its root down: none of them is missing, and git is settled when any of them settles it. The
* global ~/.config/lembas/AGENTS.md and the configured `instructions` are not the project's. */
function agentsBlock(ctx: PromptContext, files: { path: string; text: string; chain?: true }[]): string {
// Files handed in without the mark (an older caller): anything but the global file, as before.
const marked = files.some((f) => f.chain)
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
if (!own) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
const keep = promptText("blocks/agents-keep.md")
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
}
+19
View File
@@ -18,11 +18,24 @@ import { parseInfo, titleFor } from "../src/prototype/fence.ts"
import { resolveBase } from "../src/library/store.ts"
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
import { fillPrompt } from "../src/prompt/fill.ts"
import { instructionChain } from "../src/prompt/assemble.ts"
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
type Case = Record<string, unknown>
/** Whether a path is at `ro` or above under `grants` (fs_access's rule: the most specific grant on
* the path or above it wins; none where nothing is granted). No grants at all: everything readable. */
function readableUnder(grants: Record<string, string> | undefined, path: string): boolean {
if (!grants) return true
let best: string | undefined
for (const g of Object.keys(grants)) {
const covers = g === "/" || path === g || path.startsWith(g + "/")
if (covers && (best === undefined || g.length > best.length)) best = g
}
return best !== undefined && grants[best] !== "none"
}
export const AREAS: Record<string, (c: Case) => unknown> = {
hardline: (c) => hardlineCommand(c.line as string, BUILTIN_HARDLINE)?.id ?? null,
split: (c) => splitCommand(c.line as string),
@@ -96,6 +109,12 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
return { ok: r.ok, entries: r.ok ? r.after : c.entries, message: r.message, usage: r.usage, shown: Boolean(r.entries) }
},
jobs: (c) => jobNote(c as unknown as NoteCase),
// The instruction chain (filesystem.md): the CLI's own walk, with the case's files and grants as
// what it may read.
instructions: (c) => {
const files = new Set(c.files as string[])
return instructionChain(c.boundary as string, c.start as string, (p) => files.has(p) && readableUnder(c.grants as Record<string, string> | undefined, p))
},
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+50 -3
View File
@@ -1,11 +1,12 @@
// config.yaml `instructions`: more files for the system prompt.
// Instruction files: the stacked chain from the project root to the working directory (spec 3.0.0),
// and config.yaml `instruction_files`, more files for the system prompt.
import { expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadConfig } from "../src/config/load.ts"
import { paths } from "../src/config/paths.ts"
import { instructionFiles } from "../src/prompt/assemble.ts"
import { assembleSystem, instructionFiles } from "../src/prompt/assemble.ts"
test("global entries may be anywhere; a project's only inside the project, links followed", () => {
const outside = mkdtempSync(join(tmpdir(), "ph-outside-"))
@@ -31,3 +32,49 @@ test("global entries may be anywhere; a project's only inside the project, links
expect(files.map((f) => f.text)).toEqual(["Write tersely.", "Tabs, not spaces."])
writeFileSync(join(paths.config, "config.yaml"), "")
})
// Spec 3.0.0: instruction files stack from the project root down to the working directory,
// outermost first, one per directory (AGENTS.md before CLAUDE.md); before, only the nearest counted.
test("instruction files stack from the root down to the working directory, outermost first", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "ph-stack-")))
const outside = mkdtempSync(join(tmpdir(), "ph-stack-out-"))
writeFileSync(join(outside, "AGENTS.md"), "NOT THE PROJECT'S")
mkdirSync(join(root, "pkg", "src", "deep"), { recursive: true })
writeFileSync(join(root, "AGENTS.md"), "root rules")
writeFileSync(join(root, "CLAUDE.md"), "never both")
writeFileSync(join(root, "pkg", "CLAUDE.md"), "package rules\n## Git\ncommit freely")
// A link out of the project is no instruction file: the walk goes on below it.
symlinkSync(join(outside, "AGENTS.md"), join(root, "pkg", "src", "AGENTS.md"))
writeFileSync(join(root, "pkg", "src", "deep", "AGENTS.md"), "deep rules")
// Below the working directory: not in the chain (the read tool hands it over when reached).
mkdirSync(join(root, "pkg", "src", "deep", "below"))
writeFileSync(join(root, "pkg", "src", "deep", "below", "AGENTS.md"), "below")
writeFileSync(join(paths.config, "config.yaml"), "")
const files = instructionFiles(join(root, "pkg", "src", "deep"), root)
expect(files.filter((f) => f.chain).map((f) => [f.path.slice(root.length + 1), f.text])).toEqual([
["AGENTS.md", "root rules"],
["pkg/CLAUDE.md", "package rules\n## Git\ncommit freely"],
["pkg/src/deep/AGENTS.md", "deep rules"],
])
// A working directory outside the root is read as the root.
expect(instructionFiles(outside, root).filter((f) => f.chain).map((f) => f.path)).toEqual([join(root, "AGENTS.md")])
// In the prompt: each by its path in the project, outermost first; git is settled by any of them.
const system = assembleSystem({ modelRef: "s/m", family: "local", cwd: join(root, "pkg", "src", "deep"), root, isGit: true, mode: "manual", planDir: join(root, ".agent", "plans"), toolNames: [] })
const at = (s: string) => system.indexOf(s)
expect(at("Instructions from AGENTS.md:")).toBeGreaterThan(-1)
expect(at("Instructions from AGENTS.md:")).toBeLessThan(at("Instructions from pkg/CLAUDE.md:"))
expect(at("Instructions from pkg/CLAUDE.md:")).toBeLessThan(at("Instructions from pkg/src/deep/AGENTS.md:"))
expect(system).not.toContain("NOT THE PROJECT'S")
expect(system).not.toContain("never both")
expect(system).toContain("## Keeping AGENTS.md")
expect(system).not.toContain("## Git in this project")
expect(system).not.toContain("This project has no AGENTS.md yet")
})
test("no instruction file anywhere on the way: the project is told to make one", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "ph-stack-none-")))
mkdirSync(join(root, "a"))
writeFileSync(join(paths.config, "config.yaml"), "")
const system = assembleSystem({ modelRef: "s/m", family: "local", cwd: join(root, "a"), root, isGit: false, mode: "manual", planDir: join(root, ".agent", "plans"), toolNames: [] })
expect(system).toContain(`It goes at the project root: ${root}/AGENTS.md.`)
})