← opencode report

T28 — AGENTS.md / CLAUDE.md cascading (findUp)

Walk từ cwd lên đến worktree root tìm AGENTS.md → CLAUDE.md → CONTEXT.md. First-match-wins. Claims tracking attachedFiles: Set ngăn re-attach trong session dài.
Nhóm: F — System PromptFile: session/instruction.ts · Lines 52–62, 120–228ID: F.2 / T28Status: Stable

Tổng quan System Prompt

Tại sao quan trọng. Trong monorepo nhiều package, mỗi package có thể có quy tắc coding riêng. Nếu đọc tất cả AGENTS.md từ cwd lên tận home directory → conflict rules + token bloat. First-match-wins từ cwd lên worktree root (không vượt qua boundary của git repo) cho phép package-level rules override root-level rules — rule gần scope nhất thắng, giống CSS specificity. Claims tracking ngăn model nhận lại cùng instruction block nhiều lần trong session dài (khi cwd thay đổi hoặc user hỏi nhiều message liên tiếp).
3 filename candidates: AGENTS.md (OpenAI convention), CLAUDE.md (Anthropic convention), CONTEXT.md (generic). Thứ tự ưu tiên rõ ràng trong array — user dùng convention quen thuộc, opencode đều hỗ trợ.

Phân tích code chi tiết Anatomy

findProjectInstructions() — findUp + boundary check

session/instruction.ts — findProjectInstructions()

TS
{`
const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]

/**
 * Walk up từ cwd đến worktree root, tìm file instruction đầu tiên.
 * Boundary: không vượt quá worktree root (tránh đọc ~/CLAUDE.md của user).
 */
export async function findProjectInstructions(
  cwd:      string,
  worktree: string,
): Promise<{ path: string; content: string } | null> {
  let dir       = path.resolve(cwd)
  const stop    = path.resolve(worktree)

  while (dir.startsWith(stop)) {
    for (const name of INSTRUCTION_FILES) {
      const candidate = path.join(dir, name)
      if (await fs.pathExists(candidate)) {
        return {
          path:    candidate,
          content: await fs.readFile(candidate, "utf8"),
        }
      }
    }

    // Bước lên 1 level
    const parent = path.dirname(dir)
    if (parent === dir) break   // đã reach filesystem root
    dir = parent
  }

  return null   // không tìm thấy file instruction nào
}
`}

attachInstructionIfNew() — claims tracking

session/instruction.ts — attachInstructionIfNew()

TS
{`
// Claims tracking: per-session Set, ngăn re-attach cùng file
const attachedFiles = new Set<string>()

export function attachInstructionIfNew(
  file: { path: string; content: string },
): string | null {
  // Nếu file đã được attach trong session này → skip
  if (attachedFiles.has(file.path)) return null

  // Mark as attached
  attachedFiles.add(file.path)

  // Trả về XML block để inject vào system prompt
  return `<project-instruction src="${file.path}">
${file.content}
</project-instruction>`
}

// Caller (buildSystem):
const instructionFile = await findProjectInstructions(ctx.cwd, ctx.worktree)
const projectBlock    = instructionFile
  ? (attachInstructionIfNew(instructionFile) ?? "")
  : ""
`}
cwd = "/Users/nghia/monorepo/packages/api" worktree = "/Users/nghia/monorepo" │ ▼ findProjectInstructions(cwd, worktree) │ ┌───────────────────────────────────────────┐ │ dir = /Users/nghia/monorepo/packages/api │ │ │ │ Try: AGENTS.md → not found │ │ Try: CLAUDE.md → not found │ │ Try: CONTEXT.md → not found │ │ │ │ dir = /Users/nghia/monorepo/packages │ │ │ │ Try: AGENTS.md → not found │ │ Try: CLAUDE.md → FOUND ✓ │ │ → return { path, content } │ └───────────────────────────────────────────┘ │ ▼ attachInstructionIfNew({ path, content }) │ ┌───────────────────────────────────────────┐ │ attachedFiles.has(path)? │ │ │ │ NO (first call) → add to Set │ │ → return <project-instruction ...> │ │ │ │ YES (subsequent calls in same session) │ │ → return null (skip, save tokens) │ └───────────────────────────────────────────┘ │ ▼ buildSystem() nhận projectBlock inject vào cuối system prompt array

Boundary: worktree root — không vượt ra ngoài repo

TS
{`
// Boundary check: dir.startsWith(stop)
// stop = worktree root (git repo root)

// SAFE: chỉ tìm trong repo
dir = "/Users/nghia/monorepo/packages/api"
stop = "/Users/nghia/monorepo"
→ "/Users/nghia/monorepo/packages/api".startsWith("/Users/nghia/monorepo") === true ✓

// STOP: không đọc file ngoài repo
dir = "/Users/nghia"   (sau khi bước ra khỏi worktree)
stop = "/Users/nghia/monorepo"
→ "/Users/nghia".startsWith("/Users/nghia/monorepo") === false → exit loop

// Tránh: ~/CLAUDE.md (global user config) leak vào project context
`}

Tương tác với kỹ thuật khác Interaction

  • T27 (Model-specific dispatch): T28 cung cấp projectBlock — một trong 4 parts của array buildSystem() returns. T27 kiểm soát parts trước (template, env, skills), T28 bổ sung project-specific instructions ở phần cuối.
  • T10 (Cache-aware prompt structure): Project instruction block được đặt sau env block trong array. Template (static) → cache hit. Env (dynamic per call) → cache miss từ vị trí này. Project instruction thay đổi theo cwd → cần đặt đúng vị trí trong array để tối ưu cache hit cho template trước nó.
  • T7/T8 (Context management): Project instruction tiêu tốn tokens — đặc biệt với CLAUDE.md dài. Claims tracking + single-file strategy (first-match-wins) giữ instruction block nhỏ, ít ảnh hưởng đến token budget per-call.

Failure modes Failure

1. User muốn stack nhiều instruction files

First-match-wins chỉ trả về 1 file — nếu user muốn combine root AGENTS.md (team-wide rules) với package-level CLAUDE.md (package-specific), không được. Phải duplicate nội dung hoặc dùng @include convention tự chế. Đây là trade-off có chủ ý: đơn giản vs linh hoạt.

2. attachedFiles không reset đúng lúc

attachedFiles là module-level Set — tồn tại suốt lifetime của process. Nếu user mở session mới trong cùng process (không restart), Set không được clear → instruction không được re-attach cho session mới. Cần scope Set theo session ID, không phải module.

Session isolation: Module-level state là anti-pattern cho multi-session server. opencode single-user nên ổn, nhưng nếu serve multiple users cần move attachedFiles vào session object.

3. Tên file typo → miss instruction

Chỉ 3 tên được check: AGENTS.md, CLAUDE.md, CONTEXT.md. User có file agent.md (lowercase) hay AGENT.md (singular) → không được đọc. Không có case-insensitive matching, không có custom filename config.

4. Symlink và path resolution

dir.startsWith(stop) là string comparison — nếu worktree là symlink và cwd là real path (hay ngược lại), startsWith có thể fail. Cần normalize cả hai với fs.realpath() trước khi so sánh.

So sánh với các harness khác Compare

HarnessInstruction file discoveryMultiple filesBoundary
opencodefindUp cwd→worktree, first-match❌ single file only✅ worktree root
Claude CodeCLAUDE.md + parent dirs + ~/.claude/✅ stack nhiều levels✅ home dir boundary
Aider.aider.conf.yml + --read flag✅ --read multiple❌ no boundary
ClineSingle .clinerules file
Cursor.cursorrules (root) + notepads⚠️ partial
Claude Code so sánh: Claude Code stack tất cả CLAUDE.md từ cwd lên home — subdir → parent → repo root → ~/.claude/CLAUDE.md (global). opencode chỉ lấy 1 file gần nhất — đơn giản hơn nhưng kém linh hoạt hơn cho monorepo phức tạp.

Implementation recipe Recipe

TS
{`
import path from "path"
import fs   from "fs/promises"

const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]

// 1. findUp — walk from cwd to repo root
async function findProjectInstructions(cwd: string, repoRoot: string) {
  // Normalize to real paths to handle symlinks
  let dir  = await fs.realpath(path.resolve(cwd)).catch(() => path.resolve(cwd))
  const stop = await fs.realpath(path.resolve(repoRoot)).catch(() => path.resolve(repoRoot))

  while (dir.startsWith(stop)) {
    for (const name of INSTRUCTION_FILES) {
      try {
        const candidate = path.join(dir, name)
        const content   = await fs.readFile(candidate, "utf8")
        return { path: candidate, content }
      } catch {
        // not found, continue
      }
    }

    const parent = path.dirname(dir)
    if (parent === dir) break   // filesystem root
    dir = parent
  }

  return null
}

// 2. Claims tracking — per SESSION (not module-level)
class InstructionTracker {
  private attached = new Set<string>()

  attach(file: { path: string; content: string }): string | null {
    if (this.attached.has(file.path)) return null
    this.attached.add(file.path)
    return \`<project-instruction src="\${file.path}">\n\${file.content}\n</project-instruction>\`
  }

  reset() {
    this.attached.clear()
  }
}

// 3. Usage in buildSystem
async function buildSystem(model: string, ctx: Ctx, tracker: InstructionTracker) {
  const instructionFile = await findProjectInstructions(ctx.cwd, ctx.worktree)
  const projectBlock    = instructionFile
    ? (tracker.attach(instructionFile) ?? "")
    : ""

  return [getTemplate(model), buildEnvBlock(ctx), buildSkillsBlock(ctx), projectBlock]
    .filter(Boolean)
}
`}

Tham khảo Refs