Skip to content

Commit db735f8

Browse files
BunsDevCompleteDotTechCopilot
authored
feat(cli): add memory conflicts and resolve-conflict operator commands (#145)
Ports the team-memory conflict inspection and resolution operations from PR #142 onto the coven-code memory operator surface that landed in #144: - coven-code memory conflicts [--dir <team-memory-path>] [--json] lists unresolved pull conflicts (key, kind, reason) - coven-code memory resolve-conflict <key> [--dir <path>] removes the persisted conflict record after validating the key against traversal closes #142 Co-authored-by: romgenie <5861166+romgenie@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55fab7a commit db735f8

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,8 @@ without exposing redacted or deleted content to the model.
425425
| `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. |
426426
| `coven-code memory delete <id-or-path> --reason <text> [--force]` | Replace the file with a deletion tombstone stub. `legal_hold` entries require `--force`. |
427427
| `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. |
428+
| `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. |
429+
| `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. |
428430
| `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. |
429431

430432
### @include directives

docs/src/content/configuration.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function render() {
9494
9595
<h2>Memory Retention</h2>
9696
<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>
97-
<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>
97+
<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>
9898
9999
<h2>Environment Variables</h2>
100100

src-rust/crates/cli/src/memory_admin.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ pub(crate) async fn handle_memory_command(args: &[String]) -> anyhow::Result<()>
5353
Some("expire") => handle_expire(&args[1..]),
5454
Some("redact") => handle_redact(&args[1..]),
5555
Some("delete") => handle_delete(&args[1..]),
56+
Some("conflicts") => handle_conflicts(&args[1..]),
57+
Some("resolve-conflict") => handle_resolve_conflict(&args[1..]),
5658
Some("ledger") => handle_ledger(&args[1..]),
5759
Some("-h") | Some("--help") | None => {
5860
print_usage();
@@ -177,6 +179,96 @@ fn handle_delete(args: &[String]) -> anyhow::Result<()> {
177179
Ok(())
178180
}
179181

182+
fn handle_conflicts(args: &[String]) -> anyhow::Result<()> {
183+
let mut json = false;
184+
let mut team_dir = None;
185+
let mut index = 0;
186+
while index < args.len() {
187+
match args[index].as_str() {
188+
"--json" => json = true,
189+
"--dir" => {
190+
index += 1;
191+
team_dir = Some(PathBuf::from(required_arg(args, index, "--dir")?));
192+
}
193+
value if value.starts_with("--dir=") => {
194+
team_dir = Some(PathBuf::from(value.trim_start_matches("--dir=")));
195+
}
196+
flag if flag.starts_with("--") => bail!("unknown memory conflicts flag: {flag}"),
197+
value => bail!("unexpected memory conflicts argument: {value}"),
198+
}
199+
index += 1;
200+
}
201+
202+
let team_dir = match team_dir {
203+
Some(dir) => dir,
204+
None => default_team_memory_dir()?,
205+
};
206+
let conflicts = claurst_core::team_memory_sync::pending_conflicts(&team_dir);
207+
if json {
208+
println!("{}", serde_json::to_string_pretty(&conflicts)?);
209+
return Ok(());
210+
}
211+
if conflicts.is_empty() {
212+
println!("no pending team-memory conflicts in {}", team_dir.display());
213+
return Ok(());
214+
}
215+
println!("pending team-memory conflicts ({})", conflicts.len());
216+
for conflict in &conflicts {
217+
println!(
218+
" {} [{:?}]: {}",
219+
conflict.key, conflict.kind, conflict.reason
220+
);
221+
}
222+
Ok(())
223+
}
224+
225+
fn handle_resolve_conflict(args: &[String]) -> anyhow::Result<()> {
226+
let mut key = None;
227+
let mut team_dir = None;
228+
let mut index = 0;
229+
while index < args.len() {
230+
match args[index].as_str() {
231+
"--dir" => {
232+
index += 1;
233+
team_dir = Some(PathBuf::from(required_arg(args, index, "--dir")?));
234+
}
235+
value if value.starts_with("--dir=") => {
236+
team_dir = Some(PathBuf::from(value.trim_start_matches("--dir=")));
237+
}
238+
flag if flag.starts_with("--") => {
239+
bail!("unknown memory resolve-conflict flag: {flag}")
240+
}
241+
value => set_single_target(&mut key, value)?,
242+
}
243+
index += 1;
244+
}
245+
let key = key.context("usage: coven-code memory resolve-conflict <key> [--dir <path>]")?;
246+
claurst_core::team_memory_sync::validate_memory_path(&key)
247+
.with_context(|| format!("invalid memory key '{key}'"))?;
248+
249+
let team_dir = match team_dir {
250+
Some(dir) => dir,
251+
None => default_team_memory_dir()?,
252+
};
253+
let resolved = claurst_core::team_memory_sync::resolve_conflict(&team_dir, &key)
254+
.with_context(|| format!("failed to resolve conflict for '{key}'"))?;
255+
if !resolved {
256+
bail!(
257+
"no pending conflict for key '{key}' in {}",
258+
team_dir.display()
259+
);
260+
}
261+
println!("resolved team-memory conflict {key}");
262+
Ok(())
263+
}
264+
265+
fn default_team_memory_dir() -> anyhow::Result<PathBuf> {
266+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
267+
Ok(claurst_core::memdir::team_memory_path(&auto_memory_path(
268+
&cwd,
269+
)))
270+
}
271+
180272
fn handle_ledger(args: &[String]) -> anyhow::Result<()> {
181273
let options = parse_common_options(args)?;
182274
let mut entries = collect_entries_from_dirs(&options.dirs)?;
@@ -649,6 +741,8 @@ fn print_usage() {
649741
eprintln!(
650742
" delete --scope tenant=<t>,install=<i>,repo=<r>[,domain=<d>] --reason <text> [--force]"
651743
);
744+
eprintln!(" conflicts [--dir <team-memory-path>] [--json]");
745+
eprintln!(" resolve-conflict <key> [--dir <team-memory-path>]");
652746
eprintln!(" ledger [--dir <path>] [--json]");
653747
}
654748

@@ -662,6 +756,73 @@ mod tests {
662756
path
663757
}
664758

759+
fn write_conflict_record(team_dir: &std::path::Path, key: &str) {
760+
let conflicts_dir = team_dir.join(".conflicts");
761+
std::fs::create_dir_all(&conflicts_dir).expect("create conflicts dir");
762+
let record = serde_json::json!({
763+
"conflict": {
764+
"key": key,
765+
"kind": "both_changed",
766+
"local_checksum": "aaa",
767+
"base_checksum": "bbb",
768+
"remote_checksum": "ccc",
769+
"reason": "local and remote both changed",
770+
}
771+
});
772+
std::fs::write(
773+
conflicts_dir.join(format!("{}.json", key.replace('/', "__"))),
774+
record.to_string(),
775+
)
776+
.expect("write conflict record");
777+
}
778+
779+
#[test]
780+
fn conflicts_lists_pending_records_from_dir_override() {
781+
let dir = tempfile::tempdir().expect("tempdir");
782+
write_conflict_record(dir.path(), "MEMORY.md");
783+
784+
handle_conflicts(&[
785+
"--dir".to_string(),
786+
dir.path().to_string_lossy().to_string(),
787+
])
788+
.expect("list conflicts");
789+
let pending = claurst_core::team_memory_sync::pending_conflicts(dir.path());
790+
assert_eq!(pending.len(), 1);
791+
assert_eq!(pending[0].key, "MEMORY.md");
792+
}
793+
794+
#[test]
795+
fn resolve_conflict_removes_record_and_rejects_unknown_key() {
796+
let dir = tempfile::tempdir().expect("tempdir");
797+
write_conflict_record(dir.path(), "MEMORY.md");
798+
799+
handle_resolve_conflict(&[
800+
"MEMORY.md".to_string(),
801+
"--dir".to_string(),
802+
dir.path().to_string_lossy().to_string(),
803+
])
804+
.expect("resolve conflict");
805+
assert!(claurst_core::team_memory_sync::pending_conflicts(dir.path()).is_empty());
806+
807+
let missing = handle_resolve_conflict(&[
808+
"MEMORY.md".to_string(),
809+
"--dir".to_string(),
810+
dir.path().to_string_lossy().to_string(),
811+
]);
812+
assert!(missing.is_err());
813+
}
814+
815+
#[test]
816+
fn resolve_conflict_rejects_traversal_keys() {
817+
let dir = tempfile::tempdir().expect("tempdir");
818+
let result = handle_resolve_conflict(&[
819+
"../escape.md".to_string(),
820+
"--dir".to_string(),
821+
dir.path().to_string_lossy().to_string(),
822+
]);
823+
assert!(result.is_err());
824+
}
825+
665826
#[test]
666827
fn resolve_entry_target_matches_memory_id_or_path() {
667828
let dir = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)