@@ -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}'.\n Run `/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