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
11 changes: 7 additions & 4 deletions crates/tui/src/context_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
);
Expand Down
54 changes: 54 additions & 0 deletions crates/tui/src/project_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -1395,6 +1396,51 @@ mod tests {
assert!(block.contains("</project_instructions>"));
}

#[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
// `<context_update>` 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");
Expand Down Expand Up @@ -1661,6 +1707,14 @@ mod tests {
.as_deref()
.expect("constitution block rendered");
assert!(block.contains("<codewhale_repo_constitution"));
// Same origin-label convention as project_instructions: file name
// only, no absolute path in the provider-bound label (the locator
// stays available via constitution_source_path and /constitution).
assert!(block.contains("source=\"constitution.json\""));
assert!(
!block.contains(&tmp.path().display().to_string()),
"the constitution prompt label must not carry the absolute path"
);
assert!(block.contains("current user request"));
assert!(block.contains("run focused tests"));
assert!(block.contains("keep the tool-catalog head byte-stable"));
Expand Down
10 changes: 9 additions & 1 deletion crates/tui/src/project_context/constitution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,15 @@ impl RepoConstitution {
}
format!(
"<codewhale_repo_constitution source=\"{}\">\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{}</codewhale_repo_constitution>",
source.display(),
// Same origin-label convention as `<project_instructions>`: 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()
)
}
Expand Down
22 changes: 17 additions & 5 deletions crates/tui/src/project_context/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -87,10 +87,7 @@ impl ProjectContext {
/// cross-agent `<project_instructions>` prose. Either may be absent.
pub fn as_system_block(&self) -> Option<String> {
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!(
"<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
Expand Down Expand Up @@ -126,6 +123,21 @@ impl ProjectContext {
}
}

/// The `source` label for `<project_instructions>` (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 `<context_update>` 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<String> {
Expand Down
Loading