Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,8 +769,11 @@ Model projection is separate from persistence. Trusted operator, model, and Go-c
This design contains authority rather than attempting to classify every malicious sentence. A README can still contain false or contradictory facts, and a model can still reason badly about them, but document text cannot acquire Go capabilities or silently become system/assistant history. Policy, scope, approvals, budgets, leases, and Tool Gateway remain authoritative even if a model follows an indirect injection semantically.

Schema v114 extends that boundary with three deliberately non-authorizing context layers.
`project_instruction_snapshot.v1` discovers bounded root-to-target `AGENTS.md`,
`CLAUDE.md`, and `.prayu` guidance, records path/hash/scope/trust/applicability and
`project_instruction_snapshot.v1` discovers bounded root-to-target guidance, selecting
non-empty `AGENTS.override.md` ahead of same-directory `AGENTS.md`, with fallback when
the override is absent, empty, or explicitly ignored. It also reads `CLAUDE.md`,
`.claude/CLAUDE.md`, and `.prayu` guidance, explains superseded sources in `ignored`,
records path/hash/scope/trust/applicability and
why-effective metadata, and pins the full fingerprint to the Run. Live file drift is
read-only until an operator confirms a diff bound to both the old pinned fingerprint and the
reviewed live fingerprint, then appends a new
Expand Down
26 changes: 16 additions & 10 deletions docs/context-continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,21 @@ Tool、Network、Secret、Debug、Plugin、Hook、MCP、审批、Credential、

发现器只在已注册 Workspace 边界内读取:

- 每一级目录中的 `AGENTS.md` 与 `CLAUDE.md`;
- 每一级目录中优先读取非空 `AGENTS.override.md`;缺失、空白或显式忽略时回退到同目录 `AGENTS.md`;
- 每一级目录中的 `CLAUDE.md` 与 `.claude/CLAUDE.md`,后者使用所在项目目录的 scope;
- 每一级目录中的 `.prayu/instructions.md`;
- 每一级目录下 `.prayu/rules/**/*.md` 的稳定字典序集合;
- Workspace 根的 `.prayu/instructions.ignore`,用于排除规范的相对路径 glob。

顺序从 Workspace 根到目标所在目录,越近的目录优先级越高;同一级依次为
`AGENTS.md`、`CLAUDE.md`、针路簿 instructions、排序后的 rules。每个 source 保存相对
所选 AGENTS 文件、`CLAUDE.md`、`.claude/CLAUDE.md`、针路簿 instructions、排序后的 rules。每个 source 保存相对
path、content SHA-256、scope、depth、precedence、加载时间、trust、适用 target、
`why_effective`、脱敏状态和全部 false 的授权字段。冲突保留双方并解释最近目录获胜,
不会静默删除低优先级来源。
`why_effective`、脱敏状态和全部 false 的授权字段。跨目录冲突保留双方并解释最近目录获胜;
同目录被覆盖的 `AGENTS.md` 记录在 `ignored` 中,说明对应的 `AGENTS.override.md` 来源。

边界为最多 64 个文件、单文件 64 KiB、总计 256 KiB、目标/规则深度 32、ignore
16 KiB/128 条。绝对路径、`..` 逃逸、NUL、无效 UTF-8、空文件、特殊文件、symlink、
16 KiB/128 条。空白 `AGENTS.override.md` 记录跳过原因后回退;其他空文件仍被拒绝。
绝对路径、`..` 逃逸、NUL、无效 UTF-8、特殊文件、symlink、
junction/reparse、并发修改、超限和非法 ignore 均 fail closed。Windows 使用大小写不敏感
的规范路径比较;Linux/macOS 保留大小写语义。内容在 hash 和交付前经过 Secret 脱敏。

Expand Down Expand Up @@ -255,14 +257,18 @@ leases, execution profiles, or policy overrides.

### Hierarchical discovery and immutable Run snapshots

Within the exact registered Workspace, Traverse Board discovers `AGENTS.md`, `CLAUDE.md`,
`.prayu/instructions.md`, and sorted `.prayu/rules/**/*.md` from root to the target directory.
Within the exact registered Workspace, Traverse Board selects a non-empty `AGENTS.override.md`
or falls back to `AGENTS.md` when the override is absent, empty, or explicitly ignored. It also
discovers `CLAUDE.md`, `.claude/CLAUDE.md`, `.prayu/instructions.md`, and sorted
`.prayu/rules/**/*.md` from root to the target directory, in that order at each level.
The hidden Claude file uses the scope of its containing project directory.
Nearer directories have higher precedence. Each source retains its canonical relative path,
content digest, scope, depth, precedence, load time, trust class, target applicability,
why-effective explanation, redaction state, and an all-false authority projection. Conflicts remain
visible rather than silently dropping the lower source.
why-effective explanation, redaction state, and an all-false authority projection. Cross-directory
conflicts retain both sources. A superseded same-directory `AGENTS.md` appears in `ignored`
with its overriding source identified. Empty overrides also retain a skip explanation.

Discovery fails closed on escapes, invalid UTF-8/NUL, special files, symlinks/reparse points,
Discovery fails closed on other empty sources, escapes, invalid UTF-8/NUL, special files, symlinks/reparse points,
concurrent modification, malformed ignore rules, or the 64-file/64-KiB-each/256-KiB-total/32-depth
bounds. Run creation pins the complete snapshot and fingerprint. Later disk drift produces an
explainable diff but cannot change an active Run; an operator must confirm against the exact prior
Expand Down
10 changes: 10 additions & 0 deletions docs/project-instruction-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ sources. A required root or nested `AGENTS.md` is sent in full, with its origina
source, scope, precedence and hashes. The application does not interpret natural
language to find critical clauses, infer overrides or grant execution authority.

Discovery checks each directory from the Workspace root to the target. A non-empty
`AGENTS.override.md` replaces that directory's `AGENTS.md`; an absent, empty or
explicitly ignored override falls back to `AGENTS.md`. Other invalid override
files fail discovery. Superseded and skipped sources remain visible in the
discovery explanation. `CLAUDE.md` and `.claude/CLAUDE.md` are both included,
followed by `.prayu` instructions and rules. The hidden Claude file has the scope
of its containing project directory. All selected sources retain the existing
path, size, UTF-8, redaction and workflow-only authority checks. New discovery
results still require the existing confirmed refresh to replace a pinned Run.

## Confirm a source classification

Read `GET /api/v1/runs/{run_id}/project-instructions` first. Its `pinned` snapshot
Expand Down
7 changes: 5 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1996,8 +1996,11 @@ hooks:

## Durable Context Continuity / 持久上下文连续性

Schema v114 adds hierarchical `AGENTS.md`/`CLAUDE.md`/`.prayu` instruction discovery,
operator-explicit user/project memory, and a browsable Session checkpoint/Fork/Resume tree.
Project instruction discovery (schema v114) selects non-empty `AGENTS.override.md`
ahead of same-directory `AGENTS.md`, falling back when the override is absent, empty,
or explicitly ignored. It also reads `CLAUDE.md`, `.claude/CLAUDE.md`, and `.prayu`
guidance; superseded sources remain explained in `ignored`. The same context layer
provides operator-explicit user/project memory and a browsable Session checkpoint/Fork/Resume tree.
项目指令在 Run 创建时固定;磁盘漂移只有在显示 diff 并以旧 fingerprint 显式确认后才会
追加新 revision。长期记忆由操作者确认写入,支持编辑、禁用、保留期、导出和不可恢复的
物理删除。Schema v198 从带来源的决策/总结笔记生成待确认经验:在 **Context → 当前项目
Expand Down
24 changes: 24 additions & 0 deletions internal/application/project_instruction_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,28 @@ func TestRunPinsProjectInstructionsUntilConfirmedRefresh(t *testing.T) {
if err != nil || len(history) != 2 || history[0].Revision != 2 || history[1].Revision != 1 {
t.Fatalf("history=%#v err=%v", history, err)
}
if err := os.WriteFile(filepath.Join(root, "AGENTS.override.md"), []byte("run override checks\n"), 0o600); err != nil {
t.Fatal(err)
}
service := application.NewProjectInstructionService(state)
preview, err := service.Inspect(ctx, run.ID, ".")
if err != nil || !preview.Stale || len(preview.Live.Sources) != 1 ||
preview.Live.Sources[0].Content != "run override checks\n" ||
preview.Pinned.Snapshot.Fingerprint != live.Fingerprint ||
len(preview.Diff.Added) != 1 || len(preview.Diff.Removed) != 1 {
t.Fatalf("override did not produce a source replacement preview: %+v err=%v", preview, err)
}
unconfirmed, err := service.Refresh(ctx, run.ID, ".", live.Fingerprint,
preview.Live.Fingerprint, "desktop_operator", false)
if err != nil || unconfirmed.RefreshConfirmed || unconfirmed.Pinned.Revision != 2 ||
unconfirmed.Pinned.Snapshot.Sources[0].Content != "run all tests\n" {
t.Fatalf("override bypassed confirmed refresh: %+v err=%v", unconfirmed, err)
}
confirmed, err := service.Refresh(ctx, run.ID, ".", live.Fingerprint,
preview.Live.Fingerprint, "desktop_operator", true)
if err != nil || !confirmed.RefreshConfirmed || confirmed.Pinned.Revision != 3 ||
confirmed.Pinned.Snapshot.Fingerprint != preview.Live.Fingerprint ||
confirmed.Pinned.Snapshot.Sources[0].Content != "run override checks\n" {
t.Fatalf("confirmed override was not pinned: %+v err=%v", confirmed, err)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,74 @@ func TestSupervisorProjectInstructionDeliveryPreservesRequiredOutboundContentUnd
}
}

func TestSupervisorProjectInstructionDeliverySupportsNativeInstructionPaths(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "native-instructions.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
run, snapshot := newDeliveryTestRun(t, st, t.TempDir(), ".", map[string]string{
"AGENTS.md": "SUPERSEDED_AGENT_RULE",
"AGENTS.override.md": "REQUIRED_AGENT_OVERRIDE",
".claude/CLAUDE.md": "REQUIRED_CLAUDE_DIRECTORY_RULE",
}, map[string]projectconfig.InstructionRequirement{
"AGENTS.override.md": projectconfig.InstructionMandatory,
".claude/CLAUDE.md": projectconfig.InstructionMandatory,
}, false)
provider := &scriptedToolProvider{responses: []*llm.ChatResponse{
textResponse(rootActionResponse(domain.RootActionContinue, "native project guidance delivered", "", "")),
}}
if _, err := newToolLoopSupervisor(st, provider).Step(t.Context(), run.ID); err != nil {
t.Fatal(err)
}
requests := provider.Requests()
if len(requests) != 1 || len(snapshot.Sources) != 2 {
t.Fatalf("unexpected native instruction delivery: requests=%d sources=%+v", len(requests), snapshot.Sources)
}
assertRequiredDeliveryRequest(t, requests[0], snapshot)
for _, message := range requests[0].Messages {
if strings.Contains(message.Content, "SUPERSEDED_AGENT_RULE") {
t.Fatal("same-directory AGENTS.md was delivered despite the override")
}
}
}

func TestSupervisorProjectInstructionDeliveryWithClaudeDirectoryTarget(t *testing.T) {
for _, target := range []string{".claude/CLAUDE.md", ".claude/rules/nested/file.go"} {
t.Run(target, func(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "claude-target.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
run, snapshot := newDeliveryTestRun(t, st, t.TempDir(), target, map[string]string{
".claude/CLAUDE.md": "REQUIRED_CLAUDE_CONTAINING_SCOPE",
".claude/rules/nested/file.go": "package fixture\n",
}, map[string]projectconfig.InstructionRequirement{
".claude/CLAUDE.md": projectconfig.InstructionMandatory,
}, false)
if len(snapshot.Sources) != 1 || snapshot.Sources[0].Scope != "" || snapshot.Sources[0].Depth != 0 {
t.Fatalf("hidden instruction lost its containing scope: %+v", snapshot.Sources)
}
inspection, err := application.NewProjectInstructionService(st).Inspect(t.Context(), run.ID, "")
if err != nil || inspection.Stale || inspection.Live.Fingerprint != snapshot.Fingerprint {
t.Fatalf("pinned hidden target could not be inspected: %+v err=%v", inspection, err)
}
provider := &scriptedToolProvider{responses: []*llm.ChatResponse{
textResponse(rootActionResponse(domain.RootActionContinue, "hidden target guidance delivered", "", "")),
}}
if _, err := newToolLoopSupervisor(st, provider).Step(t.Context(), run.ID); err != nil {
t.Fatal(err)
}
requests := provider.Requests()
if len(requests) != 1 {
t.Fatalf("provider requests=%d want=1", len(requests))
}
assertRequiredDeliveryRequest(t, requests[0], snapshot)
})
}
}

func TestSupervisorProjectInstructionDeliveryOmitsLargeEscapedExcludedSource(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "excluded-escaped-bound.db"))
if err != nil {
Expand Down
61 changes: 52 additions & 9 deletions internal/projectconfig/instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const (
MaxInstructionIgnorePatterns = 128
)

var instructionFileNames = []string{"AGENTS.md", "CLAUDE.md"}
var errEmptyInstruction = errors.New("instruction source cannot be empty after normalization")

// InstructionAuthority is deliberately incapable of representing a grant.
// Project-owned text can suggest workflow, formatting, and validation only.
Expand Down Expand Up @@ -172,6 +172,7 @@ func DiscoverInstructions(ctx context.Context, workspaceRoot, targetPath string)
snapshot.Ignored = append(snapshot.Ignored, *ignoreRecord)
}
totalBytes := 0
seenCandidates := make(map[string]struct{})
for depth, directory := range directories {
if err := ctx.Err(); err != nil {
return InstructionSnapshot{}, err
Expand All @@ -180,25 +181,52 @@ func DiscoverInstructions(ctx context.Context, workspaceRoot, targetPath string)
if err != nil {
return InstructionSnapshot{}, err
}
agentsOverride := ""
for _, candidate := range candidates {
relative, err := filepath.Rel(root, candidate.path)
if err != nil || escapesInstructionRoot(relative) {
return InstructionSnapshot{}, errors.New("project instruction candidate escapes the workspace boundary")
}
relative = canonicalInstructionPath(relative)
// Hidden guidance can be encountered again as a direct file when
// the target enters its directory. Keep its first containing scope,
// and count or explain the same canonical path only once.
if _, seen := seenCandidates[relative]; seen {
continue
}
seenCandidates[relative] = struct{}{}
if candidate.kind == "agents" && agentsOverride != "" {
if _, err := os.Lstat(candidate.path); err == nil {
snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{
Path: relative, Reason: "superseded by " + agentsOverride,
})
} else if !errors.Is(err, fs.ErrNotExist) {
return InstructionSnapshot{}, fmt.Errorf("project instruction %s: %w", relative, err)
}
continue
}
if ignoredInstructionPath(relative, patterns) {
snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{
Path: relative, Reason: "matched .prayu/instructions.ignore",
})
continue
}
content, digest, redacted, found, err := readStableInstruction(ctx, candidate.path)
if candidate.agentsOverride && errors.Is(err, errEmptyInstruction) {
snapshot.Ignored = append(snapshot.Ignored, IgnoredInstruction{
Path: relative, Reason: "empty AGENTS.override.md; falling back to AGENTS.md",
})
continue
}
if err != nil {
return InstructionSnapshot{}, fmt.Errorf("project instruction %s: %w", relative, err)
}
if !found {
continue
}
if candidate.agentsOverride {
agentsOverride = relative
}
if len(snapshot.Sources) >= MaxInstructionFiles {
return InstructionSnapshot{}, fmt.Errorf("project instruction count exceeds %d", MaxInstructionFiles)
}
Expand Down Expand Up @@ -331,8 +359,9 @@ func DiffInstructionSnapshots(before, after InstructionSnapshot) InstructionSnap
}

type instructionCandidate struct {
path string
kind string
path string
kind string
agentsOverride bool
}

func instructionBoundary(workspaceRoot, targetPath string) (string, string, string, string, error) {
Expand Down Expand Up @@ -439,11 +468,25 @@ func instructionDirectories(root, targetDir string) ([]string, error) {
}

func instructionCandidates(directory, root string) ([]instructionCandidate, error) {
candidates := make([]instructionCandidate, 0, 4)
for _, name := range instructionFileNames {
candidates := []instructionCandidate{
{path: filepath.Join(directory, "AGENTS.override.md"), kind: "agents", agentsOverride: true},
{path: filepath.Join(directory, "AGENTS.md"), kind: "agents"},
{path: filepath.Join(directory, "CLAUDE.md"), kind: "claude"},
}
claudeDir := filepath.Join(directory, ".claude")
if info, err := os.Lstat(claudeDir); err == nil {
if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() {
return nil, errors.New("project instruction .claude path must be a real directory")
}
resolved, err := filepath.EvalSymlinks(claudeDir)
if err != nil || !sameInstructionPath(claudeDir, resolved) {
return nil, errors.New("project instruction .claude path cannot contain symlink or junction indirection")
}
candidates = append(candidates, instructionCandidate{
path: filepath.Join(directory, name), kind: strings.TrimSuffix(strings.ToLower(name), ".md"),
path: filepath.Join(claudeDir, "CLAUDE.md"), kind: "claude",
})
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
prayuDir := filepath.Join(directory, ConfigDirName)
if info, err := os.Lstat(prayuDir); err == nil {
Expand Down Expand Up @@ -529,7 +572,7 @@ func readStableInstruction(ctx context.Context, filename string) (string, string
if before.Mode()&fs.ModeSymlink != 0 || !before.Mode().IsRegular() {
return "", "", false, false, errors.New("instruction source must be a regular file without symlink or junction indirection")
}
if before.Size() <= 0 || before.Size() > MaxInstructionFileBytes {
if before.Size() > MaxInstructionFileBytes {
return "", "", false, false, fmt.Errorf("instruction source must contain between 1 and %d bytes", MaxInstructionFileBytes)
}
read := func() ([]byte, error) {
Expand All @@ -554,12 +597,12 @@ func readStableInstruction(ctx context.Context, filename string) (string, string
if err != nil || !stableInstructionBytes(first, second) {
return "", "", false, false, errors.New("instruction source changed concurrently")
}
if len(first) == 0 || len(first) > MaxInstructionFileBytes || !utf8.Valid(first) || bytes.IndexByte(first, 0) >= 0 {
if len(first) > MaxInstructionFileBytes || !utf8.Valid(first) || bytes.IndexByte(first, 0) >= 0 {
return "", "", false, false, errors.New("instruction source must be bounded UTF-8 without NUL bytes")
}
content := normalizeInstructionContent(string(first))
if content == "" {
return "", "", false, false, errors.New("instruction source cannot be empty after normalization")
return "", "", false, false, errEmptyInstruction
}
safe := redact.String(content)
redacted := safe != content
Expand Down
Loading
Loading