diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index c21ec1a03c..afa92bb826 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -449,10 +449,13 @@ fn base_source_entries( } if let Some(content) = project_context.instructions.as_deref() { - let source = project_context - .source_path - .as_ref() - .map_or_else(|| "project".to_string(), |p| p.display().to_string()); + // Same helper as ProjectContext::as_system_block, so the report's + // source token derives from the same label the prompt shows. (The + // entry below still displays the absolute source path for operators; + // only the prompt label is relativized.) + let source = crate::project_context::project_instructions_source_label( + project_context.source_path.as_deref(), + ); let mut block = format!( "\n{content}\n" ); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 5aafb59944..8dec398656 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -28,6 +28,7 @@ use self::pack::generate_bounded_project_overview; pub use self::pack::generate_project_context_pack; pub use self::types::ProjectContext; use self::types::ProjectContextError; +pub(crate) use self::types::project_instructions_source_label; /// Names of project context files to look for, in priority order. /// @@ -1395,6 +1396,51 @@ mod tests { assert!(block.contains("")); } + #[test] + fn project_instructions_source_label_falls_back_to_project() { + use crate::project_context::project_instructions_source_label; + assert_eq!( + project_instructions_source_label(None), + "project", + "a missing source path keeps the historical literal label" + ); + assert_eq!( + project_instructions_source_label(Some(std::path::Path::new("/etc/AGENTS.md"))), + "AGENTS.md" + ); + } + + #[test] + fn forkguard_project_instructions_source_is_file_name_not_absolute_path() { + // The source label sits inside the pinned system prompt: loading the + // same AGENTS.md from a different directory must leave the + // instructions block byte-identical, so a move emits no spurious + // `` history append and no absolute path enters a + // provider-bound label. Scope note: ancestor-chain and project-rule + // labels keep their own (absolute) spellings; relativizing those is + // a separate decision. + let dir_a = tempdir().expect("tempdir a"); + let dir_b = tempdir().expect("tempdir b"); + fs::write(dir_a.path().join("AGENTS.md"), "Pinned content").expect("write a"); + fs::write(dir_b.path().join("AGENTS.md"), "Pinned content").expect("write b"); + + let block_a = load_project_context(dir_a.path()) + .as_system_block() + .expect("block a"); + let block_b = load_project_context(dir_b.path()) + .as_system_block() + .expect("block b"); + assert_eq!( + block_a, block_b, + "a directory move must not change the project instructions block" + ); + assert!(block_a.contains("source=\"AGENTS.md\"")); + assert!( + !block_a.contains(&dir_a.path().display().to_string()), + "absolute paths must not enter prompt source labels" + ); + } + #[test] fn test_empty_file_warning() { let tmp = tempdir().expect("tempdir"); @@ -1661,6 +1707,14 @@ mod tests { .as_deref() .expect("constitution block rendered"); assert!(block.contains("\nCodewhale-specific repo authority policy (local law: subordinate to the global Constitution and the current user request, but above memory and old handoffs; WHALE.md is ignored and should be migrated, not treated as law).\n\n{}", - source.display(), + // Same origin-label convention as ``: file + // name only. The rendered `source` here is a runtime-canonicalized + // absolute path (workspace-relative traversal from `REPO_CONSTITUTION_RELATIVE_PATH`), + // but its final segment is that compile-time constant, so the + // base name is stable across directory moves and recasings. This + // keeps absolute paths out of provider-bound prompt labels. + // Operators still get the locator via `constitution_source_path` + // in the report and `/constitution`. + super::project_instructions_source_label(Some(source)), body.trim_end() ) } diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index d12d219ccf..3adc4a0455 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -2,7 +2,7 @@ //! `ProjectContext` value that carries loaded instructions, rules, and the //! rendered repo-constitution block into the system prompt. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use thiserror::Error; @@ -87,10 +87,7 @@ impl ProjectContext { /// cross-agent `` prose. Either may be absent. pub fn as_system_block(&self) -> Option { let instructions_block = self.instructions.as_ref().map(|content| { - let source = self - .source_path - .as_ref() - .map_or_else(|| "project".to_string(), |p| p.display().to_string()); + let source = project_instructions_source_label(self.source_path.as_deref()); let mut block = format!( "\n{content}\n" @@ -126,6 +123,21 @@ impl ProjectContext { } } +/// The `source` label for `` (and repo constitution) +/// blocks: the context file's name only, never its absolute path. The label +/// sits inside the pinned system prompt, so keeping it stable across +/// directory moves and recasings means an unchanged file does not emit a +/// spurious `` history append after a move, and absolute +/// project paths stay out of provider-bound prompt labels. Directory +/// identity stays discoverable via the shell; the label names the origin, +/// it is not a locator. +pub(crate) fn project_instructions_source_label(source_path: Option<&Path>) -> String { + source_path + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "project".to_string()) +} + /// Merge multiple project contexts (e.g., from nested directories) #[allow(dead_code)] // Public API for monorepo context merging pub fn merge_contexts(contexts: &[ProjectContext]) -> Option {