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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ without exposing redacted or deleted content to the model.
| `coven-code memory redact <id-or-path> --reason <text>` | Replace the file with a redaction tombstone stub via `redact_memory_file`; the original body is removed. |
| `coven-code memory delete <id-or-path> --reason <text> [--force]` | Replace the file with a deletion tombstone stub. `legal_hold` entries require `--force`. |
| `coven-code memory delete --scope tenant=<t>,install=<i>,repo=<r>[,domain=<d>] --reason <text> [--force]` | Remove the hosted memory directory for a scoped tenant/installation/repo/domain. Scope deletion refuses legal-hold files unless forced. |
| `coven-code memory conflicts [--dir <team-memory-path>] [--json]` | List unresolved team-memory pull conflicts (key, kind, reason). With no `--dir`, uses the project's team-memory directory. Team memory with pending conflicts is treated as unavailable by hosted review until they are resolved. |
| `coven-code memory resolve-conflict <key> [--dir <team-memory-path>]` | Remove the persisted conflict record for `<key>`, unblocking it for the next pull. Keys are validated against path traversal. |
| `coven-code memory ledger [--dir <path>] [--json]` | Export tombstoned entries only: id, path, redacted/deleted timestamp, retention class, tombstone reason line, and provenance source. The ledger reads tombstone stubs and never includes original memory body content. |

### @include directives
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function render() {

<h2>Memory Retention</h2>
<p>AGENTS.md frontmatter supports lifecycle fields for hosted review memory. <code>expires_at</code> uses <code>YYYY-MM-DD</code> and always wins over retention defaults. <code>retention_class</code> can be <code>standard</code> (no automatic expiry), <code>short_lived</code> (30 days from <code>created_at</code>), <code>security</code> (90 days), or <code>legal_hold</code> (no automatic expiry and requires <code>--force</code> for operator expiry/deletion).</p>
<p>Operators can run <code>coven-code memory list</code>, <code>expire</code>, <code>redact</code>, <code>delete</code>, and <code>ledger --json</code>. Redaction and deletion write tombstone stubs; the audit ledger exports ids, timestamps, reasons, and provenance without original removed content.</p>
<p>Operators can run <code>coven-code memory list</code>, <code>expire</code>, <code>redact</code>, <code>delete</code>, <code>conflicts</code>, <code>resolve-conflict</code>, and <code>ledger --json</code>. Redaction and deletion write tombstone stubs; the audit ledger exports ids, timestamps, reasons, and provenance without original removed content.</p>

<h2>Environment Variables</h2>

Expand Down
161 changes: 161 additions & 0 deletions src-rust/crates/cli/src/memory_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub(crate) async fn handle_memory_command(args: &[String]) -> anyhow::Result<()>
Some("expire") => handle_expire(&args[1..]),
Some("redact") => handle_redact(&args[1..]),
Some("delete") => handle_delete(&args[1..]),
Some("conflicts") => handle_conflicts(&args[1..]),
Some("resolve-conflict") => handle_resolve_conflict(&args[1..]),
Some("ledger") => handle_ledger(&args[1..]),
Some("-h") | Some("--help") | None => {
print_usage();
Expand Down Expand Up @@ -177,6 +179,96 @@ fn handle_delete(args: &[String]) -> anyhow::Result<()> {
Ok(())
}

fn handle_conflicts(args: &[String]) -> anyhow::Result<()> {
let mut json = false;
let mut team_dir = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--json" => json = true,
"--dir" => {
index += 1;
team_dir = Some(PathBuf::from(required_arg(args, index, "--dir")?));
}
value if value.starts_with("--dir=") => {
team_dir = Some(PathBuf::from(value.trim_start_matches("--dir=")));
}
flag if flag.starts_with("--") => bail!("unknown memory conflicts flag: {flag}"),
value => bail!("unexpected memory conflicts argument: {value}"),
}
index += 1;
}

let team_dir = match team_dir {
Some(dir) => dir,
None => default_team_memory_dir()?,
};
let conflicts = claurst_core::team_memory_sync::pending_conflicts(&team_dir);
if json {
println!("{}", serde_json::to_string_pretty(&conflicts)?);
return Ok(());
}
if conflicts.is_empty() {
println!("no pending team-memory conflicts in {}", team_dir.display());
return Ok(());
}
Comment on lines +206 to +214
println!("pending team-memory conflicts ({})", conflicts.len());
for conflict in &conflicts {
println!(
" {} [{:?}]: {}",
conflict.key, conflict.kind, conflict.reason
);
}
Ok(())
}

fn handle_resolve_conflict(args: &[String]) -> anyhow::Result<()> {
let mut key = None;
let mut team_dir = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--dir" => {
index += 1;
team_dir = Some(PathBuf::from(required_arg(args, index, "--dir")?));
}
value if value.starts_with("--dir=") => {
team_dir = Some(PathBuf::from(value.trim_start_matches("--dir=")));
}
flag if flag.starts_with("--") => {
bail!("unknown memory resolve-conflict flag: {flag}")
}
value => set_single_target(&mut key, value)?,
}
index += 1;
}
let key = key.context("usage: coven-code memory resolve-conflict <key> [--dir <path>]")?;
claurst_core::team_memory_sync::validate_memory_path(&key)
.with_context(|| format!("invalid memory key '{key}'"))?;

let team_dir = match team_dir {
Some(dir) => dir,
None => default_team_memory_dir()?,
};
let resolved = claurst_core::team_memory_sync::resolve_conflict(&team_dir, &key)
.with_context(|| format!("failed to resolve conflict for '{key}'"))?;
if !resolved {
bail!(
"no pending conflict for key '{key}' in {}",
team_dir.display()
);
}
println!("resolved team-memory conflict {key}");
Ok(())
}

fn default_team_memory_dir() -> anyhow::Result<PathBuf> {
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
Ok(claurst_core::memdir::team_memory_path(&auto_memory_path(
&cwd,
)))
}

fn handle_ledger(args: &[String]) -> anyhow::Result<()> {
let options = parse_common_options(args)?;
let mut entries = collect_entries_from_dirs(&options.dirs)?;
Expand Down Expand Up @@ -649,6 +741,8 @@ fn print_usage() {
eprintln!(
" delete --scope tenant=<t>,install=<i>,repo=<r>[,domain=<d>] --reason <text> [--force]"
);
eprintln!(" conflicts [--dir <team-memory-path>] [--json]");
eprintln!(" resolve-conflict <key> [--dir <team-memory-path>]");
eprintln!(" ledger [--dir <path>] [--json]");
}

Expand All @@ -662,6 +756,73 @@ mod tests {
path
}

fn write_conflict_record(team_dir: &std::path::Path, key: &str) {
let conflicts_dir = team_dir.join(".conflicts");
std::fs::create_dir_all(&conflicts_dir).expect("create conflicts dir");
let record = serde_json::json!({
"conflict": {
"key": key,
"kind": "both_changed",
"local_checksum": "aaa",
"base_checksum": "bbb",
"remote_checksum": "ccc",
"reason": "local and remote both changed",
}
});
std::fs::write(
conflicts_dir.join(format!("{}.json", key.replace('/', "__"))),
record.to_string(),
)
.expect("write conflict record");
}

#[test]
fn conflicts_lists_pending_records_from_dir_override() {
let dir = tempfile::tempdir().expect("tempdir");
write_conflict_record(dir.path(), "MEMORY.md");

handle_conflicts(&[
"--dir".to_string(),
dir.path().to_string_lossy().to_string(),
])
.expect("list conflicts");
let pending = claurst_core::team_memory_sync::pending_conflicts(dir.path());
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].key, "MEMORY.md");
}

#[test]
fn resolve_conflict_removes_record_and_rejects_unknown_key() {
let dir = tempfile::tempdir().expect("tempdir");
write_conflict_record(dir.path(), "MEMORY.md");

handle_resolve_conflict(&[
"MEMORY.md".to_string(),
"--dir".to_string(),
dir.path().to_string_lossy().to_string(),
])
.expect("resolve conflict");
assert!(claurst_core::team_memory_sync::pending_conflicts(dir.path()).is_empty());

let missing = handle_resolve_conflict(&[
"MEMORY.md".to_string(),
"--dir".to_string(),
dir.path().to_string_lossy().to_string(),
]);
assert!(missing.is_err());
}

#[test]
fn resolve_conflict_rejects_traversal_keys() {
let dir = tempfile::tempdir().expect("tempdir");
let result = handle_resolve_conflict(&[
"../escape.md".to_string(),
"--dir".to_string(),
dir.path().to_string_lossy().to_string(),
]);
assert!(result.is_err());
}

#[test]
fn resolve_entry_target_matches_memory_id_or_path() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down