Skip to content

Commit 9706d07

Browse files
BunsDevclaude
andcommitted
feat(substrate): wire coven-specific integrations into /coven
Phase 5. Surfaces the coven-internal subsystems that have no daemon HTTP endpoint yet but live behind the `coven` CLI or as plain JSON files under ~/.coven/. New native subcommand: * /coven calls [--limit N] Read and render the delegation ledger written by coven-cli/src/coven_calls.rs at ~/.coven/cave-coven-calls.json. Friendly fallback when the file is absent or empty. New shell-out subcommands (parity with coven-cli): * /coven claim acquire|release|status|heartbeat|canary [args] parallel-work claim protocol from parallel_protocol.rs. * /coven hooks-install (alias: /coven hooks) install pre-commit/pre-push hooks for the claim protocol. * /coven adapter list|doctor [id] inspect external harness adapters (harness.rs registry). * /coven logs prune [--days N] * /coven wt <branch>|--list|--doctor|--prune-merged|--prune-stale [DAYS] Help text reorganized into Coven Calls / Parallel-work protocol / Harness adapters & maintenance sections. Six new tests cover argv validation and the ledger reader (empty-tempdir + populated-tempdir both gated by a local COVEN_HOME mutex). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c38e699 commit 9706d07

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

  • src-rust/crates/commands/src

src-rust/crates/commands/src/lib.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9893,6 +9893,18 @@ fn coven_help_text() -> &'static str {
98939893
Control plane\n\
98949894
/coven actions <action> [json] POST a control-plane action\n\
98959895
\n\
9896+
Coven Calls (delegation ledger)\n\
9897+
/coven calls [--limit N] Read ~/.coven/cave-coven-calls.json\n\
9898+
\n\
9899+
Parallel-work protocol\n\
9900+
/coven claim acquire|release|status|heartbeat|canary [args]\n\
9901+
/coven hooks-install Install git hooks for the claim protocol\n\
9902+
\n\
9903+
Harness adapters & maintenance\n\
9904+
/coven adapter list|doctor [id]\n\
9905+
/coven logs prune [--days N]\n\
9906+
/coven wt <branch>|--list|--doctor|--prune-merged|--prune-stale [DAYS]\n\
9907+
\n\
98969908
Workflows\n\
98979909
/coven patch [name] [issue] Open the OpenClaw repair flow\n\
98989910
/coven pc [status|top|disk|...] macOS system diagnostics\n\
@@ -9978,6 +9990,89 @@ fn coven_format_sessions(
99789990
out
99799991
}
99809992

9993+
/// Read and pretty-print the Coven Calls delegation ledger written by
9994+
/// `coven-cli/src/coven_calls.rs`. Returns a user-facing message —
9995+
/// either a rendered table of the most recent `limit` calls, or an
9996+
/// explanatory string when the file is missing/empty/unparsable.
9997+
///
9998+
/// The on-disk shape (camelCase JSON, ledger version 1) is:
9999+
/// `{ "version": 1, "calls": [ { id, callerFamiliarId, calleeFamiliarId,
10000+
/// request, status, createdAt, endedAt?, sessionId?, artifact? } ] }`.
10001+
fn coven_read_calls_ledger(limit: usize) -> String {
10002+
let Some(home) = claurst_core::coven_shared::coven_home() else {
10003+
return "Could not determine ~/.coven; is the daemon installed?".to_string();
10004+
};
10005+
let path = home.join("cave-coven-calls.json");
10006+
let bytes = match std::fs::read(&path) {
10007+
Ok(b) => b,
10008+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
10009+
return format!(
10010+
"No delegation ledger yet at {}. Familiars only write here once they cast a Coven Call.",
10011+
path.display()
10012+
);
10013+
}
10014+
Err(e) => return format!("Could not read {}: {e}", path.display()),
10015+
};
10016+
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
10017+
Ok(v) => v,
10018+
Err(e) => return format!("Could not parse {}: {e}", path.display()),
10019+
};
10020+
let calls = value
10021+
.get("calls")
10022+
.and_then(|v| v.as_array())
10023+
.cloned()
10024+
.unwrap_or_default();
10025+
if calls.is_empty() {
10026+
return "Delegation ledger is empty.".to_string();
10027+
}
10028+
let total = calls.len();
10029+
let take = limit.max(1).min(total);
10030+
let mut out = String::new();
10031+
out.push_str(&format!(
10032+
"{:<8} {:<8} {:<10} {:<20} request\n",
10033+
"caller", "callee", "status", "createdAt"
10034+
));
10035+
out.push_str(&format!("{}\n", "-".repeat(78)));
10036+
for call in calls.iter().rev().take(take) {
10037+
let caller = call
10038+
.get("callerFamiliarId")
10039+
.and_then(|v| v.as_str())
10040+
.unwrap_or("?");
10041+
let callee = call
10042+
.get("calleeFamiliarId")
10043+
.and_then(|v| v.as_str())
10044+
.unwrap_or("?");
10045+
let status = call
10046+
.get("status")
10047+
.and_then(|v| v.as_str())
10048+
.unwrap_or("?");
10049+
let created = call
10050+
.get("createdAt")
10051+
.and_then(|v| v.as_str())
10052+
.unwrap_or("?");
10053+
let request = call
10054+
.get("request")
10055+
.and_then(|v| v.as_str())
10056+
.unwrap_or("");
10057+
let trimmed = if request.chars().count() > 36 {
10058+
let s: String = request.chars().take(36).collect();
10059+
format!("{s}…")
10060+
} else {
10061+
request.to_string()
10062+
};
10063+
out.push_str(&format!(
10064+
"{:<8} {:<8} {:<10} {:<20} {}\n",
10065+
caller, callee, status, created, trimmed
10066+
));
10067+
}
10068+
if take < total {
10069+
out.push_str(&format!(
10070+
"\n…showing {take} most recent of {total} calls. Use `/coven calls --limit N` to widen.\n"
10071+
));
10072+
}
10073+
out
10074+
}
10075+
998110076
/// Spawn the `coven` binary with the given argv tail and capture stdout/stderr.
998210077
/// Returns the combined human-readable output (or an explanatory error if the
998310078
/// binary is missing).
@@ -10402,6 +10497,65 @@ impl SlashCommand for CovenCommand {
1040210497
argv.extend(rest.split_whitespace());
1040310498
CommandResult::Message(coven_shell_out(&argv))
1040410499
}
10500+
10501+
// Coven-specific integrations.
10502+
"calls" => {
10503+
// Native FS read of the delegation ledger written by
10504+
// coven-cli/src/coven_calls.rs. The shape is documented in
10505+
// that file and in coven-cave's lib/coven-calls-types.ts.
10506+
let mut limit: usize = 20;
10507+
let mut tokens = rest.split_whitespace();
10508+
while let Some(tok) = tokens.next() {
10509+
if tok == "--limit" {
10510+
if let Some(v) = tokens.next() {
10511+
limit = v.parse().unwrap_or(limit);
10512+
}
10513+
}
10514+
}
10515+
CommandResult::Message(coven_read_calls_ledger(limit))
10516+
}
10517+
"claim" => {
10518+
if rest.is_empty() {
10519+
return CommandResult::Error(
10520+
"Usage: /coven claim acquire|release|status|heartbeat|canary [args]"
10521+
.to_string(),
10522+
);
10523+
}
10524+
let mut argv: Vec<&str> = vec!["claim"];
10525+
argv.extend(rest.split_whitespace());
10526+
CommandResult::Message(coven_shell_out(&argv))
10527+
}
10528+
"hooks-install" | "hooks" => {
10529+
// Both names map to `coven hooks install`. We accept "hooks"
10530+
// as an alias to mirror the coven-cli subcommand shape.
10531+
CommandResult::Message(coven_shell_out(&["hooks", "install"]))
10532+
}
10533+
"adapter" => {
10534+
if rest.is_empty() {
10535+
return CommandResult::Error(
10536+
"Usage: /coven adapter list [--json] | adapter doctor [id]".to_string(),
10537+
);
10538+
}
10539+
let mut argv: Vec<&str> = vec!["adapter"];
10540+
argv.extend(rest.split_whitespace());
10541+
CommandResult::Message(coven_shell_out(&argv))
10542+
}
10543+
"logs" => {
10544+
if rest.is_empty() {
10545+
return CommandResult::Error(
10546+
"Usage: /coven logs prune [--days N]".to_string(),
10547+
);
10548+
}
10549+
let mut argv: Vec<&str> = vec!["logs"];
10550+
argv.extend(rest.split_whitespace());
10551+
CommandResult::Message(coven_shell_out(&argv))
10552+
}
10553+
"wt" => {
10554+
let mut argv: Vec<&str> = vec!["wt"];
10555+
argv.extend(rest.split_whitespace());
10556+
CommandResult::Message(coven_shell_out(&argv))
10557+
}
10558+
1040510559
other => CommandResult::Error(format!(
1040610560
"Unknown /coven subcommand: '{other}'.\nRun `/coven help` for usage."
1040710561
)),
@@ -11224,6 +11378,96 @@ mod tests {
1122411378
assert!(matches!(result, CommandResult::Error(_)));
1122511379
}
1122611380

11381+
#[tokio::test]
11382+
async fn test_coven_help_lists_integration_subcommands() {
11383+
let mut ctx = make_ctx();
11384+
let cmd = find_command("coven").unwrap();
11385+
let result = cmd.execute("help", &mut ctx).await;
11386+
match result {
11387+
CommandResult::Message(msg) => {
11388+
for verb in [
11389+
"calls", "claim", "hooks-install", "adapter", "logs", "wt",
11390+
] {
11391+
assert!(msg.contains(verb), "help should mention {verb}: {msg}");
11392+
}
11393+
}
11394+
other => panic!("expected Message, got {:?}", other),
11395+
}
11396+
}
11397+
11398+
#[tokio::test]
11399+
async fn test_coven_claim_requires_action() {
11400+
let mut ctx = make_ctx();
11401+
let cmd = find_command("coven").unwrap();
11402+
let result = cmd.execute("claim", &mut ctx).await;
11403+
assert!(matches!(result, CommandResult::Error(_)));
11404+
}
11405+
11406+
#[tokio::test]
11407+
async fn test_coven_adapter_requires_subaction() {
11408+
let mut ctx = make_ctx();
11409+
let cmd = find_command("coven").unwrap();
11410+
let result = cmd.execute("adapter", &mut ctx).await;
11411+
assert!(matches!(result, CommandResult::Error(_)));
11412+
}
11413+
11414+
#[tokio::test]
11415+
async fn test_coven_logs_requires_subaction() {
11416+
let mut ctx = make_ctx();
11417+
let cmd = find_command("coven").unwrap();
11418+
let result = cmd.execute("logs", &mut ctx).await;
11419+
assert!(matches!(result, CommandResult::Error(_)));
11420+
}
11421+
11422+
// `COVEN_HOME` is process-global, so the two ledger tests must not race.
11423+
static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
11424+
11425+
#[test]
11426+
fn coven_calls_ledger_returns_message_when_file_missing() {
11427+
let _guard = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
11428+
let prev = std::env::var("COVEN_HOME").ok();
11429+
let dir = tempfile::tempdir().unwrap();
11430+
std::env::set_var("COVEN_HOME", dir.path());
11431+
let msg = coven_read_calls_ledger(20);
11432+
assert!(
11433+
msg.contains("No delegation ledger yet") || msg.contains("Could not"),
11434+
"expected friendly message, got: {msg}"
11435+
);
11436+
if let Some(v) = prev {
11437+
std::env::set_var("COVEN_HOME", v);
11438+
} else {
11439+
std::env::remove_var("COVEN_HOME");
11440+
}
11441+
}
11442+
11443+
#[test]
11444+
fn coven_calls_ledger_renders_recent_entries() {
11445+
let _guard = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
11446+
let prev = std::env::var("COVEN_HOME").ok();
11447+
let dir = tempfile::tempdir().unwrap();
11448+
std::env::set_var("COVEN_HOME", dir.path());
11449+
let ledger = r#"{
11450+
"version": 1,
11451+
"calls": [
11452+
{"id":"a","callerFamiliarId":"nova","calleeFamiliarId":"sage",
11453+
"request":"first task","status":"completed","createdAt":"2026-06-07T00:00:00Z"},
11454+
{"id":"b","callerFamiliarId":"sage","calleeFamiliarId":"kitty",
11455+
"request":"second task","status":"running","createdAt":"2026-06-07T00:01:00Z"}
11456+
]
11457+
}"#;
11458+
std::fs::write(dir.path().join("cave-coven-calls.json"), ledger).unwrap();
11459+
let out = coven_read_calls_ledger(20);
11460+
assert!(out.contains("nova"));
11461+
assert!(out.contains("sage"));
11462+
assert!(out.contains("kitty"));
11463+
assert!(out.contains("running"));
11464+
if let Some(v) = prev {
11465+
std::env::set_var("COVEN_HOME", v);
11466+
} else {
11467+
std::env::remove_var("COVEN_HOME");
11468+
}
11469+
}
11470+
1122711471
#[test]
1122811472
fn test_split_command_args_preserves_quoted_segments() {
1122911473
assert_eq!(

0 commit comments

Comments
 (0)