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 {