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:
3 files changed
+127
-28
No files matched your search
+58
-25
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||||
import { platform, release } from "node:os"
|
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 type { Mode } from "../config/schema.ts"
|
||||||
import { expandHome, paths } from "../config/paths.ts"
|
import { expandHome, paths } from "../config/paths.ts"
|
||||||
import { render, type Plan } from "../plan/plan.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.`
|
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:
|
/** 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. */
|
* a cloned repository must not be able to put ~/.ssh/… into what is sent to the model. */
|
||||||
export interface ExtraInstruction {
|
export interface ExtraInstruction {
|
||||||
@@ -144,29 +142,58 @@ export interface ExtraInstruction {
|
|||||||
global: boolean
|
global: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string }[] {
|
/** The names a directory's instruction file may have, in order of preference: one per directory. */
|
||||||
const out: { path: string; text: string }[] = []
|
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")
|
const global = join(paths.config, "AGENTS.md")
|
||||||
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
|
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
|
||||||
let dir = cwd
|
|
||||||
let realRoot = root
|
let realRoot = root
|
||||||
try {
|
try {
|
||||||
realRoot = realpathSync(root)
|
realRoot = realpathSync(root)
|
||||||
} catch {}
|
} catch {}
|
||||||
for (;;) {
|
// Read only when it really is a file in the project: a repository could commit AGENTS.md as a
|
||||||
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
|
// link to anything readable, and it would go to the model in every trust state.
|
||||||
if (hit) {
|
const texts = new Map<string, string>()
|
||||||
// Read only when it really is a file in the project: a repository could commit AGENTS.md as
|
const readable = (path: string) => {
|
||||||
// a link to anything readable, and it would go to the model in every trust state.
|
try {
|
||||||
try {
|
const real = realpathSync(path)
|
||||||
const real = realpathSync(hit)
|
if ((real !== realRoot && !real.startsWith(realRoot + sep)) || !statSync(real).isFile()) return false
|
||||||
if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) out.push({ path: hit, text: readFileSync(real, "utf8") })
|
texts.set(path, readFileSync(real, "utf8"))
|
||||||
} catch {}
|
return true
|
||||||
break
|
} 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) {
|
for (const e of extra) {
|
||||||
const path = resolve(root, expandHome(e.path))
|
const path = resolve(root, expandHome(e.path))
|
||||||
if (out.some((f) => f.path === path)) continue
|
if (out.some((f) => f.path === path)) continue
|
||||||
@@ -198,7 +225,7 @@ export interface PromptContext {
|
|||||||
/** describeRepo(): branch, changes, recent commits. */
|
/** describeRepo(): branch, changes, recent commits. */
|
||||||
git?: string
|
git?: string
|
||||||
/** The instruction files (AGENTS.md…) as read at the session's start; read now when absent. */
|
/** 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. */
|
/** Open items on the project's task board. */
|
||||||
tasks?: string
|
tasks?: string
|
||||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
/** 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()
|
let text = f.text.trim()
|
||||||
if (text.length > MAX_INSTRUCTION_CHARS)
|
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]`
|
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)
|
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."
|
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,
|
/** 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. */
|
* and keep it current. The project's are the chain's files (instructionFiles `chain`), stacked from
|
||||||
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
|
* its root down: none of them is missing, and git is settled when any of them settles it. The
|
||||||
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
|
* 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 }
|
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")
|
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}`
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,24 @@ import { parseInfo, titleFor } from "../src/prototype/fence.ts"
|
|||||||
import { resolveBase } from "../src/library/store.ts"
|
import { resolveBase } from "../src/library/store.ts"
|
||||||
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
|
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
|
||||||
import { fillPrompt } from "../src/prompt/fill.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 { applyOps, type Op, type Target } from "../src/memory/store.ts"
|
||||||
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
||||||
|
|
||||||
type Case = Record<string, unknown>
|
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> = {
|
export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||||
hardline: (c) => hardlineCommand(c.line as string, BUILTIN_HARDLINE)?.id ?? null,
|
hardline: (c) => hardlineCommand(c.line as string, BUILTIN_HARDLINE)?.id ?? null,
|
||||||
split: (c) => splitCommand(c.line as string),
|
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) }
|
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),
|
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),
|
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||||
toolcall_index: (c) => {
|
toolcall_index: (c) => {
|
||||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||||
|
|||||||
@@ -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 { 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 { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { loadConfig } from "../src/config/load.ts"
|
import { loadConfig } from "../src/config/load.ts"
|
||||||
import { paths } from "../src/config/paths.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", () => {
|
test("global entries may be anywhere; a project's only inside the project, links followed", () => {
|
||||||
const outside = mkdtempSync(join(tmpdir(), "ph-outside-"))
|
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."])
|
expect(files.map((f) => f.text)).toEqual(["Write tersely.", "Tabs, not spaces."])
|
||||||
writeFileSync(join(paths.config, "config.yaml"), "")
|
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.`)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user