diff --git a/src/prompt/assemble.ts b/src/prompt/assemble.ts index a516f9e..94a748b 100644 --- a/src/prompt/assemble.ts +++ b/src/prompt/assemble.ts @@ -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. - 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 + // 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() + const readable = (path: string) => { + try { + 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\n${text}\n`) + // 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\n${text}\n`) } 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}` } diff --git a/tests/conformance.ts b/tests/conformance.ts index 7b9aed5..b90b3f1 100644 --- a/tests/conformance.ts +++ b/tests/conformance.ts @@ -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 +/** 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 | 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 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 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 | 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`. diff --git a/tests/instructions.test.ts b/tests/instructions.test.ts index a7f5c20..b13e6b0 100644 --- a/tests/instructions.test.ts +++ b/tests/instructions.test.ts @@ -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.`) +})