From 9097b2dda82e752d0c0bc531a4483ed859f7057d Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 1 Sep 2026 11:24:28 +0200 Subject: [PATCH] Document ensure-line contract imports --- README.md | 23 +++++++++ tests/materialize.rs | 111 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/README.md b/README.md index 5f773f8a..55e3f517 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,29 @@ printf 'ready\n' } ``` +Use `ensure-line` when a workspace-owned text file must import a catalog-owned contract: + +```kdl +copy "_templates/CONTRACT.md" ".st2/CONTRACT.md" +ensure-line "CLAUDE.md" "@.st2/CONTRACT.md" +ensure-line "AGENTS.md" "@.st2/CONTRACT.md" +``` + +Each harness loads the import from its native contract file. A later boot-prompt rewrite cannot +silently remove the contract-loading instruction. On 2026-09-01, Nathan stated that Codex supports +`@` imports in `AGENTS.md`. This Codex behavior is undocumented and was not source-verified for this +change. + +`ensure-line` searches a UTF-8 file for one exact full line. An existing match causes no write. +If the line is absent, st2 preserves all existing bytes, adds a separator newline when necessary, +and appends the declared line with a final newline. st2 creates a missing target in a non-Git +workspace or when Git does not track that path. + +For a Git-tracked target, `ensure-line` is a verifier. st2 accepts an exact existing line but refuses +to add a missing line. The refusal occurs before any render operation writes to the workspace. The +repository owner must add and commit the line outside st2. This rule prevents materialization from +leaving a customer repository dirty or changing a repository that assistants can only read. + `git-exclude` is advisory. `copy`, `file`, `json-upsert`, and `ensure-line` are boot-gating. ## Run diff --git a/tests/materialize.rs b/tests/materialize.rs index 2930c889..475edd57 100644 --- a/tests/materialize.rs +++ b/tests/materialize.rs @@ -490,6 +490,117 @@ fn every_directive_materializes_in_order_and_is_idempotent() { assert_eq!(exclude.lines().filter(|line| *line == ".st2/").count(), 1); } +#[test] +fn ensure_line_preserves_contract_files_and_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + write( + &workspace.join("CLAUDE.md"), + "owner prose without a final newline", + ); + write( + &catalog.join("agents/Silber/cos/agent.kdl"), + agent_kdl( + &workspace, + r#" ensure-line "CLAUDE.md" "@.st2/CONTRACT.md" + ensure-line "AGENTS.md" "@.st2/CONTRACT.md""#, + ), + ); + + let found = discover(&catalog); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + let first = materialize_catalog(&catalog, &found.specs, "Silber"); + assert!(first.is_clean(), "{:?}", first.errors); + assert_eq!(first.materialized.len(), 2); + assert_eq!( + fs::read_to_string(workspace.join("CLAUDE.md")).unwrap(), + "owner prose without a final newline\n@.st2/CONTRACT.md\n" + ); + assert_eq!( + fs::read_to_string(workspace.join("AGENTS.md")).unwrap(), + "@.st2/CONTRACT.md\n" + ); + + let second = materialize_catalog(&catalog, &found.specs, "Silber"); + assert!(second.is_clean(), "{:?}", second.errors); + assert!(second.materialized.is_empty(), "{:?}", second.materialized); +} + +#[test] +fn ensure_line_verifies_tracked_contract_files_without_rewriting() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + init_git(&workspace); + for name in ["CLAUDE.md", "AGENTS.md"] { + write(&workspace.join(name), "owner prose\n@.st2/CONTRACT.md\n"); + fs::set_permissions(workspace.join(name), fs::Permissions::from_mode(0o644)).unwrap(); + track(&workspace, name); + } + write( + &catalog.join("agents/Silber/cos/agent.kdl"), + agent_kdl( + &workspace, + r#" ensure-line "CLAUDE.md" "@.st2/CONTRACT.md" + ensure-line "AGENTS.md" "@.st2/CONTRACT.md""#, + ), + ); + + let found = discover(&catalog); + let report = materialize_catalog(&catalog, &found.specs, "Silber"); + + assert!(report.is_clean(), "{:?}", report.errors); + assert!(report.materialized.is_empty(), "{:?}", report.materialized); + for name in ["CLAUDE.md", "AGENTS.md"] { + assert_eq!( + fs::read_to_string(workspace.join(name)).unwrap(), + "owner prose\n@.st2/CONTRACT.md\n" + ); + } +} + +#[test] +fn ensure_line_refuses_missing_contract_line_in_a_tracked_file_before_any_write() { + for name in ["CLAUDE.md", "AGENTS.md"] { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + init_git(&workspace); + write(&workspace.join(name), "owner prose\n"); + track(&workspace, name); + write( + &catalog.join("agents/Silber/cos/agent.kdl"), + agent_kdl( + &workspace, + &format!( + " ensure-line \"{name}\" \"@.st2/CONTRACT.md\"\n file \"must-not-exist\" \"blocked\"" + ), + ), + ); + + let found = discover(&catalog); + let report = materialize_catalog(&catalog, &found.specs, "Silber"); + + assert_eq!(report.errors.len(), 1, "{name}: {:?}", report.errors); + assert!( + report.errors[0].contains("generated materialization would change Git-tracked target") + && report.errors[0].contains(name), + "{name}: {:?}", + report.errors + ); + assert_eq!( + fs::read_to_string(workspace.join(name)).unwrap(), + "owner prose\n" + ); + assert!(!workspace.join("must-not-exist").exists()); + } +} + #[test] fn every_content_directive_refuses_to_change_a_tracked_target_before_any_write() { for (name, initial, directive, template) in [