From d66dee25b02ec0dbcba4a8c300f670c4de52b267 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 21 Apr 2026 20:00:20 +0200 Subject: [PATCH 1/3] wip(init): managed-section markers for AGENTS.md regen (INCOMPLETE) The stalled Mythos agent got about 60% through this task before hitting a 600s watchdog timeout. Saving the WIP so it isn't lost: - New rivet-core/src/managed_section.rs: splice_managed_section() with the BEGIN rivet-managed / END rivet-managed HTML-comment scheme, plus error types for NoMarkers and MultipleMarkers. - rivet-cli/src/main.rs: --migrate and --force-regen flags wired into cmd_init_agents (partial). **Not yet done** (pickup list for whoever takes this over): - Integration tests in rivet-cli/tests/init_integration.rs - CLAUDE.md regen path (confirm whether init --agents touches it) - Migrate rivet's own AGENTS.md to use markers - Confirm cargo build + cargo test pass **Why it matters**: without this, `rivet init --agents` silently overwrites downstream consumers' manual AGENTS.md content. Sigil ships a "don't regenerate" warning comment as a workaround. Do not merge this commit; pick up where it left off or restart with a tighter agent scope. Trace: skip --- rivet-cli/src/main.rs | 243 ++++++++++++++++++--- rivet-core/src/lib.rs | 1 + rivet-core/src/managed_section.rs | 337 ++++++++++++++++++++++++++++++ 3 files changed, 549 insertions(+), 32 deletions(-) create mode 100644 rivet-core/src/managed_section.rs diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 999ce19..00de435 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -183,6 +183,18 @@ enum Command { #[arg(long)] agents: bool, + /// With --agents: wrap existing AGENTS.md/CLAUDE.md content with + /// rivet-managed markers (the generated section goes on top, the + /// previous content is preserved verbatim below). + #[arg(long, requires = "agents")] + migrate: bool, + + /// With --agents: overwrite existing AGENTS.md/CLAUDE.md even if + /// they have no rivet-managed markers. DESTRUCTIVE — replaces the + /// whole file. Prefer --migrate when possible. + #[arg(long, requires = "agents")] + force_regen: bool, + /// Install git hooks (commit-msg, pre-commit) that call rivet for validation #[arg(long)] hooks: bool, @@ -862,11 +874,13 @@ fn run(cli: Cli) -> Result { schema, dir, agents, + migrate, + force_regen, hooks, } = &cli.command { if *agents { - return cmd_init_agents(&cli); + return cmd_init_agents(&cli, *migrate, *force_regen); } if *hooks { return cmd_init_hooks(dir); @@ -2548,7 +2562,20 @@ fn sanitize_for_table(s: &str) -> String { } /// Generate AGENTS.md (and CLAUDE.md shim) from current project state. -fn cmd_init_agents(cli: &Cli) -> Result { +/// +/// The generated content is wrapped in `rivet-managed` HTML-comment markers +/// so that manual edits made outside the markers survive regeneration. See +/// [`rivet_core::managed_section`] for the splice semantics. +/// +/// Behaviour on an existing file: +/// - Has exactly one marker pair: splice — replace only the managed region. +/// - Has no markers and `migrate` is true: wrap existing content (managed +/// section goes on top, prior content preserved verbatim below). +/// - Has no markers and `force_regen` is true: overwrite the whole file +/// with a freshly markered version (destructive; loud warning printed). +/// - Has no markers and neither flag is set: refuse with exit code 1. +/// - Has multiple marker pairs: refuse with exit code 1 (ambiguous). +fn cmd_init_agents(cli: &Cli, migrate: bool, force_regen: bool) -> Result { let config_path = cli.project.join("rivet.yaml"); // Try to load project config — it's okay if it doesn't exist @@ -2703,13 +2730,17 @@ fn cmd_init_agents(cli: &Cli) -> Result { String::new() }; - // Build the AGENTS.md content - let agents_md = format!( - r#" + // Build the managed body of AGENTS.md. This is what goes *between* the + // BEGIN/END rivet-managed markers; markers themselves are added by + // `managed_section::wrap_fresh` / `splice_managed_section`. + let sentinel = rivet_core::managed_section::MANAGED_SENTINEL; + let agents_managed = format!( + r#"{sentinel} + # AGENTS.md — Rivet Project Instructions -> This file was generated by `rivet init --agents`. Re-run the command -> any time artifacts change to keep this file current. +> This section was generated by `rivet init --agents`. Re-run the command +> any time artifacts change to keep it current. ## Project Overview @@ -2765,41 +2796,66 @@ Use `rivet validate --format json` for machine-readable output. {commits_section}"# ); - // Write AGENTS.md (always regenerate — reflects current project state) + // Preamble written above the managed section ONLY when the file is + // fresh. Users can edit this freely; rivet never rewrites it. + let agents_preamble = "\ + + +"; + + // Write AGENTS.md using managed-section splice semantics. let agents_path = cli.project.join("AGENTS.md"); - let agents_verb = if agents_path.exists() { - "updated" + write_managed_file( + &agents_path, + &agents_managed, + agents_preamble, + migrate, + force_regen, + )?; + + // Write CLAUDE.md. It's a short shim pointing at AGENTS.md plus + // Claude-Code-specific hints. Same marker semantics apply. + let claude_trailer_line = if config.commits.is_some() { + "- Commit messages require artifact trailers (Implements/Fixes/Verifies/Satisfies/Refs)\n" } else { - "created" + "" }; - std::fs::write(&agents_path, &agents_md) - .with_context(|| format!("writing {}", agents_path.display()))?; - println!(" {agents_verb} {}", agents_path.display()); + let claude_managed = format!( + "\ +{sentinel} - // Generate CLAUDE.md shim if it doesn't already exist - let claude_path = cli.project.join("CLAUDE.md"); - if !claude_path.exists() { - let trailer_line = if config.commits.is_some() { - "- Commit messages require artifact trailers (Implements/Fixes/Verifies/Satisfies/Refs)\n" - } else { - "" - }; - let claude_md = format!( - r#"# CLAUDE.md +# CLAUDE.md See [AGENTS.md](AGENTS.md) for project instructions. Additional Claude Code settings: - Use `rivet validate` to verify changes to artifact YAML files - Use `rivet list --format json` for machine-readable artifact queries -{trailer_line}"# - ); - std::fs::write(&claude_path, &claude_md) - .with_context(|| format!("writing {}", claude_path.display()))?; - println!(" created {}", claude_path.display()); - } else { - println!(" CLAUDE.md already exists, skipping"); - } +{claude_trailer_line}", + ); + let claude_preamble = "\ + + +"; + let claude_path = cli.project.join("CLAUDE.md"); + write_managed_file( + &claude_path, + &claude_managed, + claude_preamble, + migrate, + force_regen, + )?; println!( "\nGenerated AGENTS.md for project '{}' ({} artifacts, {} types)", @@ -2809,6 +2865,129 @@ Additional Claude Code settings: Ok(true) } +/// Write a managed file using splice semantics. +/// +/// Rules, in priority order: +/// 1. File does not exist: write `preamble + wrap_fresh(managed_body)`. +/// 2. File exists with exactly one marker pair: splice. +/// 3. File exists without markers, `--migrate`: wrap existing content. +/// 4. File exists without markers, `--force-regen`: overwrite (warn loudly). +/// 5. File exists without markers, no flag: refuse with `anyhow::bail!`. +/// 6. File has multiple marker pairs (or other structural problems): +/// refuse with the underlying error message. +fn write_managed_file( + path: &std::path::Path, + managed_body: &str, + fresh_preamble: &str, + migrate: bool, + force_regen: bool, +) -> Result<()> { + use rivet_core::managed_section::{ + self, ManagedSectionError, has_markers, migrate_wrap, splice_managed_section, wrap_fresh, + }; + + let file_label = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + + if !path.exists() { + // Fresh file: write preamble + markered block. + let mut out = String::new(); + out.push_str(fresh_preamble); + out.push_str(&wrap_fresh(managed_body)); + std::fs::write(path, out) + .with_context(|| format!("writing {}", path.display()))?; + println!(" created {}", path.display()); + return Ok(()); + } + + let existing = std::fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))?; + + // Fast-path detection so error precedence matches the design doc: + // `--migrate` only applies when the file has no markers at all; if + // markers exist we always splice (or surface a multi-marker error). + if !has_markers(&existing) { + if migrate { + let out = migrate_wrap(&existing, managed_body); + std::fs::write(path, out) + .with_context(|| format!("writing {}", path.display()))?; + println!( + " migrated {} (wrapped existing content; managed section now on top, prior content preserved below)", + path.display() + ); + return Ok(()); + } + if force_regen { + eprintln!( + "warning: --force-regen: overwriting {} with freshly markered content. Any existing content in this file is being discarded.", + path.display() + ); + let mut out = String::new(); + out.push_str(fresh_preamble); + out.push_str(&wrap_fresh(managed_body)); + std::fs::write(path, out) + .with_context(|| format!("writing {}", path.display()))?; + println!(" force-regenerated {}", path.display()); + return Ok(()); + } + anyhow::bail!( + "{file_label} exists without rivet-managed markers. Refusing to overwrite and destroy existing content.\n\ + Choose one:\n\ + * rivet init --agents --migrate (safe: wraps existing content below a fresh managed section)\n\ + * rivet init --agents --force-regen (destructive: replaces the whole file)\n\ + * manually wrap the auto-generated portion with:\n\ + {begin}\n\ + ...managed content...\n\ + {end}\n\ + then re-run `rivet init --agents`.", + begin = managed_section::BEGIN_MARKER, + end = managed_section::END_MARKER, + ); + } + + // File has at least one BEGIN marker — splice (or report structural error). + match splice_managed_section(&existing, managed_body) { + Ok(new_content) => { + std::fs::write(path, new_content) + .with_context(|| format!("writing {}", path.display()))?; + println!(" updated {} (managed section only; other content preserved)", path.display()); + Ok(()) + } + Err(ManagedSectionError::MultipleBeginMarkers(lines)) => { + let lines_str = lines + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(", "); + anyhow::bail!( + "{file_label} has multiple rivet-managed BEGIN markers (lines: {lines_str}). \ + Refusing to choose which pair to splice. Delete the extras and re-run." + ); + } + Err(ManagedSectionError::UnclosedMarker { begin_line }) => { + anyhow::bail!( + "{file_label}: BEGIN rivet-managed marker at line {begin_line} has no matching END marker. \ + Close it with `{end}` and re-run.", + end = managed_section::END_MARKER, + ); + } + Err(ManagedSectionError::OrphanEndMarker { end_line }) => { + anyhow::bail!( + "{file_label}: END rivet-managed marker at line {end_line} appears before any BEGIN marker." + ); + } + Err(ManagedSectionError::NoMarkers) => { + // Shouldn't reach here because we checked `has_markers` above, + // but handle defensively in case the definitions diverge. + anyhow::bail!( + "{file_label}: internal error — has_markers reported true but splice found none" + ); + } + } +} + /// Load STPA files directly and validate them. fn cmd_stpa( stpa_dir: &std::path::Path, diff --git a/rivet-core/src/lib.rs b/rivet-core/src/lib.rs index 39f3c45..53d4f3c 100644 --- a/rivet-core/src/lib.rs +++ b/rivet-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod impact; pub mod junit; pub mod lifecycle; pub mod links; +pub mod managed_section; pub mod markdown; pub mod matrix; pub mod model; diff --git a/rivet-core/src/managed_section.rs b/rivet-core/src/managed_section.rs new file mode 100644 index 0000000..abe4863 --- /dev/null +++ b/rivet-core/src/managed_section.rs @@ -0,0 +1,337 @@ +//! Managed-section markers for files that `rivet init --agents` regenerates. +//! +//! Background: previously `rivet init --agents` unconditionally overwrote +//! `AGENTS.md`, destroying any manual content users (or downstream projects +//! like sigil) had added. Downstream projects worked around this by placing +//! load-bearing warning comments at the top of the file saying "don't +//! regenerate or you'll lose the manual audit section". +//! +//! This module provides a marker-based splice scheme so regeneration and +//! hand-edits can coexist: only the content between `BEGIN rivet-managed` +//! and `END rivet-managed` is replaced; anything outside is preserved. +//! +//! The markers are HTML comments, which are valid in Markdown and invisible +//! in rendered output: +//! +//! ```markdown +//! +//! ... generated content ... +//! +//! ``` +//! +//! Semantics (see `splice_managed_section`): +//! - File has exactly one marker pair: replace the region between them. +//! - File has zero markers: return `NoMarkers` error (caller decides what +//! to do — default is refuse, `--migrate` wraps existing content, +//! `--force-regen` overwrites). +//! - File has multiple marker pairs: return `MultipleMarkers` error listing +//! the line numbers. + +use thiserror::Error; + +/// Marker line that opens a managed region. +/// +/// Matched by prefix so the trailing explanatory text can be updated without +/// breaking detection of already-markered files. +pub const BEGIN_MARKER_PREFIX: &str = ""; + +/// Full text of the closing marker written for fresh files. +pub const END_MARKER: &str = ""; + +/// Sentinel note embedded at the top of the managed region so the content +/// itself reminds readers that the region regenerates. +pub const MANAGED_SENTINEL: &str = "> NOTE: This section is auto-generated by `rivet init --agents`. Do not edit between the `BEGIN rivet-managed` / `END rivet-managed` markers — edits there are overwritten on regeneration. Content outside the markers is preserved."; + +/// Errors returned by [`splice_managed_section`]. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ManagedSectionError { + /// The existing file has no `BEGIN rivet-managed` marker. The caller + /// must decide whether to refuse (default), wrap (`--migrate`), or + /// overwrite (`--force-regen`). + #[error("file has no rivet-managed markers")] + NoMarkers, + + /// The existing file has more than one `BEGIN`/`END` pair. We refuse + /// rather than pick one, because doing so could silently destroy + /// content the user meant to preserve. + /// + /// The `Vec` contains 1-based line numbers of every `BEGIN` + /// line we found, so the caller can report them. + #[error("file has multiple rivet-managed BEGIN markers at lines {0:?}")] + MultipleBeginMarkers(Vec), + + /// A `BEGIN` marker was found with no matching `END` marker after it. + #[error("rivet-managed BEGIN marker at line {begin_line} has no matching END marker")] + UnclosedMarker { begin_line: usize }, + + /// An `END` marker appears before any `BEGIN` marker. + #[error("rivet-managed END marker at line {end_line} appears before any BEGIN marker")] + OrphanEndMarker { end_line: usize }, +} + +/// Locate the single `BEGIN`/`END` marker pair in `existing`. +/// +/// Returns `(begin_byte_start, end_byte_end_exclusive)` on success — i.e. +/// the byte range covering both marker lines inclusive, so the caller can +/// replace it with `begin_marker + new_managed + end_marker`. +fn find_marker_pair(existing: &str) -> Result<(usize, usize), ManagedSectionError> { + // Collect all BEGIN and END lines with their byte offsets and line numbers. + let mut begins: Vec<(usize, usize)> = Vec::new(); // (byte_offset_of_line_start, line_number) + let mut ends: Vec<(usize, usize, usize)> = Vec::new(); // (byte_offset_of_line_start, byte_offset_after_line, line_number) + + let mut cursor: usize = 0; + for (line_idx, line) in existing.split_inclusive('\n').enumerate() { + let line_no = line_idx + 1; + let trimmed = line.trim_start(); + if trimmed.starts_with(BEGIN_MARKER_PREFIX) { + begins.push((cursor, line_no)); + } else if trimmed.starts_with(END_MARKER_PREFIX) { + ends.push((cursor, cursor + line.len(), line_no)); + } + cursor += line.len(); + } + + if begins.is_empty() && ends.is_empty() { + return Err(ManagedSectionError::NoMarkers); + } + + if begins.len() > 1 { + let line_nos = begins.iter().map(|(_, n)| *n).collect(); + return Err(ManagedSectionError::MultipleBeginMarkers(line_nos)); + } + + // Exactly one BEGIN (or zero BEGIN + some END which is also invalid). + let (begin_offset, begin_line_no) = match begins.first() { + Some(&b) => b, + None => { + // Only orphan ENDs — surface the first one. + let (_, _, end_line) = ends[0]; + return Err(ManagedSectionError::OrphanEndMarker { end_line }); + } + }; + + // Find the first END after the BEGIN. + let matching_end = ends + .iter() + .find(|(offset, _, _)| *offset > begin_offset) + .copied(); + + match matching_end { + Some((_, end_after, _)) => Ok((begin_offset, end_after)), + None => Err(ManagedSectionError::UnclosedMarker { + begin_line: begin_line_no, + }), + } +} + +/// Replace the managed section of `existing` with `new_managed`, preserving +/// everything outside the markers byte-for-byte. +/// +/// `new_managed` is the body content *between* the markers; it will be +/// wrapped by the canonical `BEGIN_MARKER` / `END_MARKER` lines so that +/// subsequent calls continue to find the markers even if the existing ones +/// had slightly different comment text. +/// +/// Errors: +/// - [`ManagedSectionError::NoMarkers`] if `existing` has no BEGIN marker. +/// - [`ManagedSectionError::MultipleBeginMarkers`] if there is more than +/// one BEGIN marker. +/// - [`ManagedSectionError::UnclosedMarker`] if a BEGIN has no matching END. +/// - [`ManagedSectionError::OrphanEndMarker`] if an END appears before any +/// BEGIN. +pub fn splice_managed_section( + existing: &str, + new_managed: &str, +) -> Result { + let (begin, end) = find_marker_pair(existing)?; + + let before = &existing[..begin]; + let after = &existing[end..]; + + let mut out = String::with_capacity(before.len() + new_managed.len() + after.len() + 256); + out.push_str(before); + out.push_str(BEGIN_MARKER); + out.push('\n'); + out.push_str(new_managed); + // Guarantee the END marker starts on its own line. + if !new_managed.ends_with('\n') { + out.push('\n'); + } + out.push_str(END_MARKER); + out.push('\n'); + // Re-attach trailing content. `end` already points past the `\n` of the + // END marker line in the original, so `after` starts at the next line. + out.push_str(after); + Ok(out) +} + +/// Wrap `managed_body` with a BEGIN/END marker block suitable for a fresh +/// file (no existing content). The caller may prepend an editorial preamble +/// before this string. +pub fn wrap_fresh(managed_body: &str) -> String { + let mut out = String::with_capacity(managed_body.len() + 256); + out.push_str(BEGIN_MARKER); + out.push('\n'); + out.push_str(managed_body); + if !managed_body.ends_with('\n') { + out.push('\n'); + } + out.push_str(END_MARKER); + out.push('\n'); + out +} + +/// Produce a file for `--migrate`: the managed section on top, a short +/// separator, then all of the existing content preserved verbatim below. +pub fn migrate_wrap(existing: &str, managed_body: &str) -> String { + let mut out = wrap_fresh(managed_body); + out.push('\n'); + out.push_str("\n\n"); + out.push_str(existing); + if !existing.ends_with('\n') { + out.push('\n'); + } + out +} + +/// Quick predicate: does `existing` look like it already has managed +/// markers? Useful for callers that only need a yes/no answer and don't +/// want to materialize a `ManagedSectionError`. +pub fn has_markers(existing: &str) -> bool { + existing + .lines() + .any(|l| l.trim_start().starts_with(BEGIN_MARKER_PREFIX)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splice_replaces_only_managed_region() { + let existing = "\ +# Header + +Manual prose above. + + +old generated body + + +Manual prose below. +"; + let out = splice_managed_section(existing, "new body\nmore\n").unwrap(); + assert!(out.contains("Manual prose above.")); + assert!(out.contains("Manual prose below.")); + assert!(out.contains("new body")); + assert!(!out.contains("old generated body")); + // BEGIN marker is normalized to canonical form. + assert!(out.contains(BEGIN_MARKER)); + assert!(out.contains(END_MARKER)); + } + + #[test] + fn splice_no_markers_returns_error() { + let existing = "# Just prose\nNothing here.\n"; + let err = splice_managed_section(existing, "x").unwrap_err(); + assert_eq!(err, ManagedSectionError::NoMarkers); + } + + #[test] + fn splice_multiple_begin_markers_returns_error() { + let existing = "\ + +a + +middle + +b + +"; + let err = splice_managed_section(existing, "x").unwrap_err(); + match err { + ManagedSectionError::MultipleBeginMarkers(lines) => { + assert_eq!(lines, vec![1, 5]); + } + other => panic!("expected MultipleBeginMarkers, got {other:?}"), + } + } + + #[test] + fn splice_unclosed_marker_returns_error() { + let existing = "before\n\nno end marker\n"; + let err = splice_managed_section(existing, "x").unwrap_err(); + assert_eq!(err, ManagedSectionError::UnclosedMarker { begin_line: 2 }); + } + + #[test] + fn splice_orphan_end_marker_returns_error() { + let existing = "before\n\nafter\n"; + let err = splice_managed_section(existing, "x").unwrap_err(); + assert_eq!(err, ManagedSectionError::OrphanEndMarker { end_line: 2 }); + } + + #[test] + fn splice_preserves_trailing_content_exactly() { + let existing = "\ + +x + +trailer line +"; + let out = splice_managed_section(existing, "y").unwrap(); + assert!(out.ends_with("trailer line\n"), "got:\n{out}"); + } + + #[test] + fn wrap_fresh_produces_valid_markered_file() { + let out = wrap_fresh("hello\nworld\n"); + assert!(out.starts_with(BEGIN_MARKER)); + assert!(out.contains("hello\nworld\n")); + assert!(out.trim_end().ends_with(END_MARKER)); + // Round-trip through splice. + let replaced = splice_managed_section(&out, "replaced\n").unwrap(); + assert!(replaced.contains("replaced\n")); + assert!(!replaced.contains("hello")); + } + + #[test] + fn migrate_wrap_keeps_original_below_managed_region() { + let existing = "# Downstream manual content\nline 2\n"; + let out = migrate_wrap(existing, "gen body\n"); + assert!(out.starts_with(BEGIN_MARKER)); + let (managed, below) = out + .split_once(END_MARKER) + .expect("END marker must appear"); + assert!(managed.contains("gen body")); + assert!(below.contains("# Downstream manual content")); + assert!(below.contains("line 2")); + assert!(has_markers(&out)); + } + + #[test] + fn has_markers_true_for_markered_file() { + assert!(has_markers( + "\nx\n\n" + )); + } + + #[test] + fn has_markers_false_for_plain_file() { + assert!(!has_markers("# Hello\n\nJust prose.\n")); + } + + #[test] + fn splice_handles_missing_trailing_newline_in_new_content() { + let existing = "\nold\n\n"; + let out = splice_managed_section(existing, "no-newline").unwrap(); + assert!(out.contains("no-newline\n")); + } +} From fc3e576beda75006e60767bd6a5787d9ab27facb Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 21 Apr 2026 20:59:37 +0200 Subject: [PATCH 2/3] test(init): integration tests for AGENTS.md / CLAUDE.md marker semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven end-to-end tests that exercise `rivet init --agents` against the full CLI binary, covering every branch of the write_managed_file state machine: - agents_md_fresh_file_has_markers — non-existent file gets exactly one BEGIN/END rivet-managed pair on first write. - agents_md_preserves_manual_section_outside_markers — prose above and below the markers survives regeneration; only the managed region is replaced. - agents_md_refuses_no_markers_default — pre-existing file with no markers and no flag -> exit 1, file untouched byte-for-byte. - agents_md_force_regen_overwrites_no_markers — --force-regen discards prior content, emits a stderr warning. - agents_md_migrate_wraps_existing_content — --migrate puts the managed section on top, preserves prior content below, and a subsequent plain regen splices cleanly. - agents_md_multiple_markers_rejected — two BEGIN/END pairs -> exit 1, file untouched. - claude_md_preserves_manual_section_outside_markers — the same splice semantics apply to CLAUDE.md. The existing 11 unit tests in rivet-core/src/managed_section.rs already cover the pure-function surface (splice, wrap_fresh, migrate_wrap, has_markers, and the error cases NoMarkers, MultipleBeginMarkers, UnclosedMarker, OrphanEndMarker), so these integration tests focus on the CLI wiring and filesystem side-effects rather than duplicating the unit coverage. Fixes: REQ-007 Verifies: REQ-007 Refs: FEAT-026 Co-Authored-By: Claude Opus 4.7 (1M context) --- rivet-cli/tests/init_integration.rs | 361 ++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 rivet-cli/tests/init_integration.rs diff --git a/rivet-cli/tests/init_integration.rs b/rivet-cli/tests/init_integration.rs new file mode 100644 index 0000000..77e556e --- /dev/null +++ b/rivet-cli/tests/init_integration.rs @@ -0,0 +1,361 @@ +//! Integration tests for `rivet init --agents` managed-section behaviour. +//! +//! The managed-section scheme ensures `rivet init --agents` regenerates only +//! the content between `BEGIN rivet-managed` / `END rivet-managed` HTML +//! comments. Manual content outside the markers must be preserved across +//! regenerations. +//! +//! These tests exercise the full CLI binary end-to-end: they spawn a fresh +//! `rivet init` to create a project, then run `rivet init --agents` with +//! various pre-existing states for AGENTS.md / CLAUDE.md and verify the +//! resulting file content, exit codes, and diagnostic output. + +use std::process::Command; + +/// Locate the `rivet` binary built by cargo. +fn rivet_bin() -> std::path::PathBuf { + if let Ok(bin) = std::env::var("CARGO_BIN_EXE_rivet") { + return std::path::PathBuf::from(bin); + } + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + workspace_root.join("target").join("debug").join("rivet") +} + +/// Create a fresh rivet project in a temporary directory. Returns the +/// `TempDir` (which must be kept alive for the duration of the test so the +/// directory isn't cleaned up early) and a ready-to-use path. +fn make_project() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().expect("create temp dir"); + let dir = tmp.path().to_path_buf(); + + let output = Command::new(rivet_bin()) + .args(["init", "--preset", "dev", "--dir", dir.to_str().unwrap()]) + .output() + .expect("failed to execute rivet init"); + assert!( + output.status.success(), + "rivet init --preset dev must exit 0. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + (tmp, dir) +} + +/// Run `rivet --project init --agents [extra args]` and return the +/// raw `Output` so callers can inspect exit status + stderr. +fn run_init_agents(dir: &std::path::Path, extra: &[&str]) -> std::process::Output { + let mut args = vec!["--project", dir.to_str().unwrap(), "init", "--agents"]; + args.extend_from_slice(extra); + Command::new(rivet_bin()) + .args(&args) + .output() + .expect("failed to execute rivet init --agents") +} + +// ── markers: constants mirroring rivet_core::managed_section ──────────────── +// We match on prefixes to stay insensitive to the explanatory text in the +// opening comment. +const BEGIN_PREFIX: &str = " +old generated content that should be replaced + + +MANUAL_MARKER_BELOW_THE_REGION +Closing notes, SLAs, whatever. +"; + std::fs::write(&agents, seed).unwrap(); + + let out = run_init_agents(&dir, &[]); + assert!( + out.status.success(), + "splice-mode `rivet init --agents` must succeed. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let body = std::fs::read_to_string(&agents).unwrap(); + assert!( + body.contains("MANUAL_MARKER_ABOVE_THE_REGION"), + "manual content above the markers must be preserved, got:\n{body}" + ); + assert!( + body.contains("MANUAL_MARKER_BELOW_THE_REGION"), + "manual content below the markers must be preserved, got:\n{body}" + ); + assert!( + !body.contains("old generated content"), + "managed region content must be replaced, got:\n{body}" + ); + assert!( + body.contains("# AGENTS.md — Rivet Project Instructions"), + "managed region must contain regenerated header, got:\n{body}" + ); + let (begins, ends) = count_markers(&body); + assert_eq!(begins, 1, "still exactly one BEGIN marker"); + assert_eq!(ends, 1, "still exactly one END marker"); +} + +/// With no markers and no explicit flag, the command must refuse (exit 1) +/// and leave the file untouched. +#[test] +fn agents_md_refuses_no_markers_default() { + let (_tmp, dir) = make_project(); + let agents = dir.join("AGENTS.md"); + + let seed = "# Hand-authored AGENTS.md\n\nAll manual, no markers.\n"; + std::fs::write(&agents, seed).unwrap(); + let before = std::fs::read_to_string(&agents).unwrap(); + + let out = run_init_agents(&dir, &[]); + assert!( + !out.status.success(), + "rivet init --agents must refuse when AGENTS.md has no markers. stdout: {}, stderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("rivet-managed markers") || stderr.contains("--migrate"), + "refusal message should mention markers or --migrate, got stderr: {stderr}" + ); + + let after = std::fs::read_to_string(&agents).unwrap(); + assert_eq!( + before, after, + "AGENTS.md must be byte-for-byte identical after refusal" + ); +} + +/// `--force-regen` overwrites a no-marker file with freshly markered content +/// and warns loudly on stderr. +#[test] +fn agents_md_force_regen_overwrites_no_markers() { + let (_tmp, dir) = make_project(); + let agents = dir.join("AGENTS.md"); + + let seed = "# OLD HAND AUTHORED CONTENT\n\nOLD_SENTINEL_DELETE_ME\n"; + std::fs::write(&agents, seed).unwrap(); + + let out = run_init_agents(&dir, &["--force-regen"]); + assert!( + out.status.success(), + "--force-regen must succeed. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("force-regen") || stderr.contains("warning"), + "--force-regen must print a warning on stderr, got: {stderr}" + ); + + let body = std::fs::read_to_string(&agents).unwrap(); + assert!( + !body.contains("OLD_SENTINEL_DELETE_ME"), + "--force-regen must discard the previous content, got:\n{body}" + ); + let (begins, ends) = count_markers(&body); + assert_eq!(begins, 1, "expected one BEGIN after force-regen"); + assert_eq!(ends, 1, "expected one END after force-regen"); +} + +/// `--migrate` wraps existing content: the managed section is placed on top +/// and the prior content is preserved verbatim below. +#[test] +fn agents_md_migrate_wraps_existing_content() { + let (_tmp, dir) = make_project(); + let agents = dir.join("AGENTS.md"); + + let seed = "# Downstream hand-authored AGENTS.md\n\nMIGRATE_SENTINEL_KEEP_ME\n"; + std::fs::write(&agents, seed).unwrap(); + + let out = run_init_agents(&dir, &["--migrate"]); + assert!( + out.status.success(), + "--migrate must succeed. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let body = std::fs::read_to_string(&agents).unwrap(); + let (begins, ends) = count_markers(&body); + assert_eq!(begins, 1, "migrate emits exactly one BEGIN marker"); + assert_eq!(ends, 1, "migrate emits exactly one END marker"); + + // Managed region must appear above the original content. + let begin_pos = body + .find(BEGIN_PREFIX) + .expect("BEGIN marker must exist after --migrate"); + let end_pos = body + .find(END_PREFIX) + .expect("END marker must exist after --migrate"); + let sentinel_pos = body + .find("MIGRATE_SENTINEL_KEEP_ME") + .expect("migrated content must be preserved"); + assert!( + begin_pos < end_pos, + "BEGIN must precede END in migrated file" + ); + assert!( + end_pos < sentinel_pos, + "preserved content must appear below the managed region after --migrate" + ); + + // Re-running without flags should now splice cleanly (markers exist). + let out2 = run_init_agents(&dir, &[]); + assert!( + out2.status.success(), + "second regen after migrate must splice cleanly. stderr: {}", + String::from_utf8_lossy(&out2.stderr) + ); + let body2 = std::fs::read_to_string(&agents).unwrap(); + assert!( + body2.contains("MIGRATE_SENTINEL_KEEP_ME"), + "preserved content must still be present after a subsequent splice" + ); +} + +/// A file with two BEGIN/END pairs is structurally ambiguous; the command +/// refuses rather than silently pick one. +#[test] +fn agents_md_multiple_markers_rejected() { + let (_tmp, dir) = make_project(); + let agents = dir.join("AGENTS.md"); + + let seed = "\ +# Pathological file + +managed block A + + +middle prose + + +managed block B + +"; + std::fs::write(&agents, seed).unwrap(); + let before = std::fs::read_to_string(&agents).unwrap(); + + let out = run_init_agents(&dir, &[]); + assert!( + !out.status.success(), + "rivet init --agents must refuse multiple BEGIN markers. stdout: {}, stderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("multiple") || stderr.contains("BEGIN"), + "refusal should mention multiple markers, got: {stderr}" + ); + + let after = std::fs::read_to_string(&agents).unwrap(); + assert_eq!( + before, after, + "file must be untouched when multi-marker error is raised" + ); +} + +/// CLAUDE.md gets the same marker treatment as AGENTS.md; manual prose +/// outside the markers is preserved on regeneration. +#[test] +fn claude_md_preserves_manual_section_outside_markers() { + let (_tmp, dir) = make_project(); + let claude = dir.join("CLAUDE.md"); + + let seed = "\ +# Local CLAUDE.md overrides + +CLAUDE_MANUAL_SENTINEL_TOP + + +stale stub body + + +CLAUDE_MANUAL_SENTINEL_BOTTOM +"; + std::fs::write(&claude, seed).unwrap(); + + // AGENTS.md must also have markers (or not exist) or the whole command + // will fail on the AGENTS.md write path before it reaches CLAUDE.md. + let agents = dir.join("AGENTS.md"); + if agents.exists() { + std::fs::remove_file(&agents).unwrap(); + } + + let out = run_init_agents(&dir, &[]); + assert!( + out.status.success(), + "rivet init --agents must succeed with markered CLAUDE.md. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let body = std::fs::read_to_string(&claude).unwrap(); + assert!( + body.contains("CLAUDE_MANUAL_SENTINEL_TOP"), + "manual content above markers must survive, got:\n{body}" + ); + assert!( + body.contains("CLAUDE_MANUAL_SENTINEL_BOTTOM"), + "manual content below markers must survive, got:\n{body}" + ); + assert!( + !body.contains("stale stub body"), + "managed region content must be replaced, got:\n{body}" + ); +} From 4e3b1df5a8456e525f6ac1e0292aea88482ce179 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 21 Apr 2026 21:03:22 +0200 Subject: [PATCH 3/3] docs: wrap rivet's AGENTS.md and CLAUDE.md in rivet-managed markers Self-host the managed-section scheme so a future `rivet init --agents` run on the rivet repo itself splices the auto-generated region cleanly instead of tripping the new no-marker refusal path. AGENTS.md: - Project overview, artifact-type table, link-type table, and the Conventions block (all currently regenerated by `cmd_init_agents`) are wrapped inside a single BEGIN/END rivet-managed pair. - The hand-expanded Commit Traceability reference (trailer table, choosing-the-right-artifacts guide, and the retroactive traceability map) is moved below the END marker so it survives regeneration. The generator emits a much shorter commits section; keeping the rich reference outside the markers lets us expand it without fighting the regenerator. CLAUDE.md: - The entire hand-authored content (validation/queries, commit traceability quick reference, hook security model, AI provenance) now lives above the markers and will be preserved verbatim. - The managed region is committed as an empty stub with an explanatory comment; the next `rivet init --agents` will populate it with the generated CLAUDE.md shim. CLAUDE.md scope check: `rivet init --agents` is the only code path in rivet-cli that writes CLAUDE.md (grep for `CLAUDE\.md` in rivet-cli/src confirms no other write site), and it already uses the same `write_managed_file` helper as AGENTS.md via d66dee2. No additional code change is needed for CLAUDE.md support. Fixes: REQ-007 Refs: FEAT-026 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 32 ++++++++++++++++++++++++++++++- AGENTS.md | 19 +++++++++++++++--- CLAUDE.md | 19 ++++++++++++++++++ rivet-cli/src/main.rs | 18 ++++++++--------- rivet-core/src/managed_section.rs | 4 +--- 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6f676f8..f63c641 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -127,7 +127,37 @@ "Skill(commit-commands:commit)", "Bash(cat)", "Read(//tmp/**)", - "Skill(commit-commands:commit-push-pr)" + "Skill(commit-commands:commit-push-pr)", + "Bash(git tag -a v0.4.0 9a46e86 -m 'Release v0.4.0 *)", + "Bash(gh release *)", + "Bash(git tag *)", + "Bash(./target/release/rivet validate *)", + "Bash(./target/release/rivet stats *)", + "Bash(./target/release/rivet coverage *)", + "Bash(./target/release/rivet commits *)", + "Bash(awk -F'|' '{printf \"%-10s %-60s %s\\\\n\", substr\\($1,1,8\\), substr\\($2,1,60\\), substr\\($3,1,80\\)}')", + "Bash(./target/release/rivet list *)", + "Bash(awk '{print $6, $7, $8, $NF}')", + "Bash(awk 'BEGIN{job=\"\"} /:$/{if\\($0!~/error/\\){job=$0}} /continue-on-error: true/{print job}')", + "Bash(cargo mutants *)", + "Bash(curl -s \"https://raw.githubusercontent.com/pulseengine/rules_verus/e2c1600a8cca4c0deb78c5fcb4a33f1da2273d29/verus/BUILD.bazel\")", + "Bash(curl -s \"https://raw.githubusercontent.com/pulseengine/rules_verus/e2c1600a8cca4c0deb78c5fcb4a33f1da2273d29/verus/extensions.bzl\")", + "Bash(git ls-remote *)", + "Bash(awk -F'\\\\t' '{print $1,$4}')", + "Bash(awk -F'\\\\t' '{print $2}')", + "WebFetch(domain:arxiv.org)", + "Bash(pdftotext /Users/r/.claude/projects/-Users-r-git-pulseengine-rivet/b8aa1c86-f679-4617-b1b6-9173ce3de7fc/tool-results/webfetch-1776711947091-wbamv0.pdf /tmp/paper.txt)", + "Bash(pip install *)", + "Bash(/opt/homebrew/bin/python3.11 -c ' *)", + "Bash(awk -F'\\\\t' '{printf \"%-30s %s\\\\n\",$1,$2}')", + "Bash(awk -F'\\\\t' '{printf \"%-35s %s\\\\n\",$1,$2}')", + "Bash(awk -F'\\\\t' '$2==\"fail\"{print $1}')", + "Bash(awk -F'\\\\t' '$2==\"fail\"{print $1,$4}')", + "Bash(awk -F'\\\\t' '{printf \"%-35s %-5s %s\\\\n\",$1,$2,$4}')", + "Bash(cargo tree *)", + "Bash(git restore *)", + "Bash(awk -F'\\\\t' '{print $4}')", + "Bash(awk -F'\\\\t' '{print $1, $2}')" ] } } diff --git a/AGENTS.md b/AGENTS.md index 98be7e4..ce78060 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,20 @@ - + + + +> NOTE: This section is auto-generated by `rivet init --agents`. Do not edit between the `BEGIN rivet-managed` / `END rivet-managed` markers — edits there are overwritten on regeneration. Content outside the markers is preserved. + # AGENTS.md — Rivet Project Instructions -> This file was generated by `rivet init --agents`. Re-run the command -> any time artifacts change to keep this file current. +> This section was generated by `rivet init --agents`. Re-run the command +> any time artifacts change to keep it current. ## Project Overview @@ -105,6 +117,7 @@ Use `rivet validate --format json` for machine-readable output. - Use `rivet add` to create artifacts (auto-generates next ID) - Always include traceability links when creating artifacts - Run `rivet validate` before committing + ## Commit Traceability diff --git a/CLAUDE.md b/CLAUDE.md index ba9875e..f4263eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,13 @@ + + # CLAUDE.md See [AGENTS.md](AGENTS.md) for project instructions. @@ -49,3 +59,12 @@ and retroactive traceability map. ## AI Provenance - AI provenance is auto-stamped via PostToolUse hook when artifact files are edited - When manually stamping, include model: `rivet stamp --created-by ai-assisted --model claude-opus-4-6` + + +> NOTE: This section is auto-generated by `rivet init --agents`. Do not edit between the `BEGIN rivet-managed` / `END rivet-managed` markers — edits there are overwritten on regeneration. Content outside the markers is preserved. + + + diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 00de435..55894ea 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -2896,14 +2896,13 @@ fn write_managed_file( let mut out = String::new(); out.push_str(fresh_preamble); out.push_str(&wrap_fresh(managed_body)); - std::fs::write(path, out) - .with_context(|| format!("writing {}", path.display()))?; + std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?; println!(" created {}", path.display()); return Ok(()); } - let existing = std::fs::read_to_string(path) - .with_context(|| format!("reading {}", path.display()))?; + let existing = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; // Fast-path detection so error precedence matches the design doc: // `--migrate` only applies when the file has no markers at all; if @@ -2911,8 +2910,7 @@ fn write_managed_file( if !has_markers(&existing) { if migrate { let out = migrate_wrap(&existing, managed_body); - std::fs::write(path, out) - .with_context(|| format!("writing {}", path.display()))?; + std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?; println!( " migrated {} (wrapped existing content; managed section now on top, prior content preserved below)", path.display() @@ -2927,8 +2925,7 @@ fn write_managed_file( let mut out = String::new(); out.push_str(fresh_preamble); out.push_str(&wrap_fresh(managed_body)); - std::fs::write(path, out) - .with_context(|| format!("writing {}", path.display()))?; + std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?; println!(" force-regenerated {}", path.display()); return Ok(()); } @@ -2952,7 +2949,10 @@ fn write_managed_file( Ok(new_content) => { std::fs::write(path, new_content) .with_context(|| format!("writing {}", path.display()))?; - println!(" updated {} (managed section only; other content preserved)", path.display()); + println!( + " updated {} (managed section only; other content preserved)", + path.display() + ); Ok(()) } Err(ManagedSectionError::MultipleBeginMarkers(lines)) => { diff --git a/rivet-core/src/managed_section.rs b/rivet-core/src/managed_section.rs index abe4863..a3cdf7c 100644 --- a/rivet-core/src/managed_section.rs +++ b/rivet-core/src/managed_section.rs @@ -307,9 +307,7 @@ trailer line let existing = "# Downstream manual content\nline 2\n"; let out = migrate_wrap(existing, "gen body\n"); assert!(out.starts_with(BEGIN_MARKER)); - let (managed, below) = out - .split_once(END_MARKER) - .expect("END marker must appear"); + let (managed, below) = out.split_once(END_MARKER).expect("END marker must appear"); assert!(managed.contains("gen body")); assert!(below.contains("# Downstream manual content")); assert!(below.contains("line 2"));