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
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.15.7"
version = "0.15.8"
edition = "2021"
license = "MIT"
authors = ["TerminallyLazy"]
Expand Down
12 changes: 10 additions & 2 deletions crates/tree-ring-memory-cli/src/actions/integrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ pub fn status(request: IntegrationStatusRequest) -> Result<IntegrationStatusActi
|| activation::adapters::adapter_capability(&detected.id)
!= Some(harness.adapter_capability)
});
// Detection describes a possible adapter plan, not an installed
// bridge. Init may have preserved an existing AGENTS.md and left
// this harness out of its manifest; status must retain that gap.
let missing_bridge = detected.is_candidate()
&& activation.is_none()
&& detected.state == ActivationState::ConfiguredAwaitingProof;
let receipt = manifest
.as_ref()
.and_then(|manifest| {
Expand Down Expand Up @@ -166,7 +172,7 @@ pub fn status(request: IntegrationStatusRequest) -> Result<IntegrationStatusActi
.map(|receipt| receipt.state)
.unwrap_or(detected.state)
}
} else if stale_adapter {
} else if stale_adapter || missing_bridge {
ActivationState::NeedsUserReview
} else {
receipt
Expand Down Expand Up @@ -209,7 +215,9 @@ pub fn status(request: IntegrationStatusRequest) -> Result<IntegrationStatusActi
})
})
.flatten();
let next_step = if stale_adapter && detected.id != "agent-zero" {
let next_step = if missing_bridge && detected.id != "agent-zero" {
"No managed activation record exists for this harness. Run `tree-ring init`; if existing project instructions or bridge files need review, preserve them and reconcile only Tree Ring's bounded references and hooks.".to_string()
} else if stale_adapter && detected.id != "agent-zero" {
"The installed adapter definition is out of date. Review the managed hook files and activation manifest, then reconfigure them with this CLI; preserve the memory database.".to_string()
} else {
next_step_for_state(state, &detected.next_step)
Expand Down
21 changes: 20 additions & 1 deletion crates/tree-ring-memory-cli/src/activation/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,15 @@ fn is_non_writing_blocked_plan(plan: &AdapterPlan) -> bool {
}

fn validate_project_shape(project: &ActivationProject) -> Result<(), String> {
if project.memory_root != project.project_root.join(".tree-ring") {
// Resolve relative spellings against the same cwd without following any
// symlinks. `.tree-ring`, `./.tree-ring`, and an absolute project-local
// root must agree; descriptor-relative no-follow checks still protect all
// filesystem access below. Do not canonicalize away that boundary.
let actual = std::path::absolute(&project.memory_root)
.map_err(|error| io_error(&project.memory_root, error))?;
let expected = std::path::absolute(project.project_root.join(".tree-ring"))
.map_err(|error| io_error(&project.project_root, error))?;
if actual != expected {
return Err(
"activation project memory root must be the project-local .tree-ring".to_string(),
);
Expand Down Expand Up @@ -3046,6 +3054,17 @@ mod tests {
};
use tempfile::TempDir;

#[test]
fn project_shape_rejects_other_stores_and_parent_traversal() {
for memory_root in ["elsewhere/.tree-ring", "memory", ".tree-ring/../memory"] {
let project = ActivationProject {
project_root: PathBuf::from("."),
memory_root: PathBuf::from(memory_root),
};
assert!(validate_project_shape(&project).is_err(), "{memory_root}");
}
}

fn fixture() -> (TempDir, ActivationProject, ActivationManifest) {
let temp = tempfile::tempdir().unwrap();
let project = ActivationProject::from_project_root(temp.path());
Expand Down
111 changes: 111 additions & 0 deletions crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,117 @@ fn shipped_fixtures_declare_only_project_local_versioned_activation_contracts()
assert_eq!(codex["isolated_root"]["copy_sqlite"], false);
}

#[test]
fn activation_commands_accept_equivalent_project_local_paths() {
let temp = tempdir().unwrap();
let project = temp.path().join("Activation Project With Spaces");
fs::create_dir_all(project.join(".codex")).unwrap();
let project = fs::canonicalize(project).unwrap();
let run = |args: &[&str]| {
Command::new(env!("CARGO_BIN_EXE_tree-ring"))
.current_dir(&project)
.env("PATH", "/usr/bin:/bin")
.env("HOME", temp.path().join("fixture-home"))
.args(["--json"])
.args(args)
.output()
.unwrap()
};
assert_success("init", &run(&["init"]));
let manifest_before = fs::read(project.join(".tree-ring/activation.json")).unwrap();
let hooks_before = fs::read(project.join(".codex/hooks.json")).unwrap();
let absolute_root = project.join(".tree-ring");
for (root, source) in [
(".tree-ring", "."),
("./.tree-ring", "."),
(".tree-ring/", "./"),
(".tree-ring", project.to_str().unwrap()),
(absolute_root.to_str().unwrap(), "."),
(absolute_root.to_str().unwrap(), project.to_str().unwrap()),
] {
for command in ["activate", "link", "deactivate"] {
let output = run(&[
"--root",
root,
"integrations",
command,
"--harness",
"codex",
"--source-root",
source,
]);
assert_success(&format!("{command}: root={root}, source={source}"), &output);
let report = output_json(command, &output);
assert_eq!(report["harness_id"], "codex");
assert_eq!(
report["state"],
if command == "deactivate" {
"needs-user-review"
} else {
"configured-awaiting-proof"
}
);
assert_eq!(
fs::read(project.join(".tree-ring/activation.json")).unwrap(),
manifest_before
);
assert_eq!(
fs::read(project.join(".codex/hooks.json")).unwrap(),
hooks_before
);
}
}
}

#[test]
fn status_does_not_request_review_for_undetected_harnesses() {
let temp = tempdir().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_tree-ring"))
.current_dir(temp.path())
.env("PATH", "/usr/bin:/bin")
.env("HOME", temp.path().join("fixture-home"))
.args(["--json", "integrations", "status"])
.output()
.unwrap();
assert_success("marker-free status", &output);
let report = output_json("marker-free status", &output);
for harness in ["codex", "claude-code"] {
assert_ne!(
record_by_id(&report["integrations"], harness)["state"],
"needs-user-review"
);
}
assert!(!temp.path().join(".tree-ring").exists());
}

#[test]
fn status_keeps_an_unconfigured_codex_bridge_in_review_after_init() {
let temp = tempdir().unwrap();
let project = temp.path().join("Existing Project");
fs::create_dir_all(project.join(".codex")).unwrap();
let instructions = "# Existing project instructions\nPreserve these rules.\n";
fs::write(project.join("AGENTS.md"), instructions).unwrap();
for args in [vec!["init"], vec!["integrations", "status", "--verbose"]] {
let output = Command::new(env!("CARGO_BIN_EXE_tree-ring"))
.current_dir(&project)
.env("PATH", "/usr/bin:/bin")
.env("HOME", temp.path().join("fixture-home"))
.arg("--json")
.args(&args)
.output()
.unwrap();
assert_success("existing instructions", &output);
let report = output_json("existing instructions", &output);
let codex = record_by_id(&report["integrations"], "codex");
assert_eq!(codex["state"], "needs-user-review");
assert_eq!(
fs::read_to_string(project.join("AGENTS.md")).unwrap(),
instructions
);
assert!(!project.join(".codex/hooks.json").exists());
}
}

#[test]
fn default_relative_root_initializes_from_the_project_root() {
let temp = tempdir().unwrap();
Expand Down
11 changes: 11 additions & 0 deletions docs/protocol/harness-activation.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ Advanced commands are `tree-ring integrations status --verbose`,
--harness <id>`. Certification records JSON and Markdown evidence; it is not
required for initialization.

The default `.tree-ring`, explicit `./.tree-ring`, and absolute paths to the
same project-local store are equivalent for activation and deactivation.
Different stores and symlinked bridge targets remain rejected.

Keep an existing root `AGENTS.md` and `.tree-ring/AGENTS.md` separate. The
project's instructions remain authoritative; Tree Ring's managed root block
only references its local guidance. Create-only initialization preserves
existing project instructions, and a harness without a managed activation
record remains `needs-user-review` on subsequent status checks. Review and
reconcile that bounded reference without replacing either instruction file.

## States and proof

| State | Meaning |
Expand Down