#!/usr/bin/env nu # reflection/bin/jjw.nu — jj + ontoref + rad orchestration for agent workspaces. # # Wraps three CLIs (jj, ontoref, rad) into a coherent agent lifecycle: # jjw agent create → workspace + ontoref run # jjw agent status → list active workspaces and their run state # jjw agent step → report a step in the ontoref run # jjw agent publish → validate + push + open Radicle patch # jjw agent merge → merge commit + advance main + cleanup # jjw agent discard → abandon workspace and cancel run # # Requires: jj >= 0.40, ontoref in PATH, rad >= 1.0 (optional for publish) # # NCL auto-merge for .ontology/ conflicts: # Register jjw-ncl-merge.nu as a jj merge tool in ~/.config/jj/config.toml: # [merge-tools.ncl] # program = "nu" # args = ["{ONTOREF_ROOT}/reflection/bin/jjw-ncl-merge.nu", "$left", "$right", "$base", "$output"] # Then resolve: jj resolve --tool ncl -- .ontology/core.ncl # # .ontoref-run metadata file lives in each workspace dir: # .agents//.ontoref-run # # ONTOREF_ROOT must be set (done by `use env.nu *` in ontoref sessions). # When called standalone, set ONTOREF_ROOT to the ontoref installation path. const ONTOREF_RUN_FILE = ".ontoref-run" const AGENTS_DIR = ".agents" # ── Helpers ────────────────────────────────────────────────────────────────── def project-root []: nothing -> string { let pr = ($env.ONTOREF_PROJECT_ROOT? | default "") if ($pr | is-not-empty) { $pr } else { pwd | path expand } } def agents-dir []: nothing -> string { $"(project-root)/($AGENTS_DIR)" } def workspace-path [run_id: string]: nothing -> string { $"(agents-dir)/($run_id)" } def run-file [run_id: string]: nothing -> string { $"(workspace-path $run_id)/($ONTOREF_RUN_FILE)" } def ontoref-bin []: nothing -> string { let root = ($env.ONTOREF_ROOT? | default "") if ($root | is-not-empty) { let p = $"($root)/ontoref" if ($p | path exists) { return $p } } # Fall back to PATH let which_r = do { ^which ontoref } | complete if $which_r.exit_code == 0 { $which_r.stdout | str trim } else { "ontoref" } } def jj [args: list]: nothing -> string { let root = (project-root) let r = do { ^jj --no-pager --repository $root ...$args } | complete if $r.exit_code != 0 { error make { msg: $"jj ($args | str join ' '): ($r.stderr | str trim)" } } $r.stdout } def onto [args: list]: nothing -> string { let bin = (ontoref-bin) let r = do { ^nu $bin ...$args } | complete if $r.exit_code != 0 { error make { msg: $"ontoref ($args | str join ' '): ($r.stderr | str trim)" } } $r.stdout } def read-run-file [run_id: string]: nothing -> record { let f = (run-file $run_id) if not ($f | path exists) { error make { msg: $"No .ontoref-run for workspace '($run_id)'" } } open $f | from json } def get-change-id [run_id: string]: nothing -> string { let ws_name = $run_id jj ["log", "--no-graph", "-r", $"($ws_name)@", "-T", "change_id ++ \"\\n\""] | str trim } def has-rad-remote []: nothing -> bool { let r = do { ^jj --no-pager git remote list } | complete if $r.exit_code != 0 { return false } $r.stdout | lines | any { |l| ($l | str trim | str starts-with "rad") } } # ── Commands ───────────────────────────────────────────────────────────────── # Create a new agent workspace tied to an ontoref run. export def "agent create" [ agent_id: string, # Logical agent identifier (e.g. "architect-01") --task (-t): string = "", # Backlog task ID to associate --mode (-m): string = "sync-ontology", ]: nothing -> nothing { let root = (project-root) let agents = (agents-dir) mkdir $agents # Start ontoref run — get run_id from JSON output let run_json = (onto ["run", "start", $mode, "--task", $task, "--fmt", "json"]) let run_data = ($run_json | from json) let run_id = $run_data.run_id let ws_path = (workspace-path $run_id) let ws_path_str = $ws_path # jj workspace add — workspace name = run_id (unique UUID-like string) jj ["workspace", "add", $ws_path_str, "--name", $run_id] | ignore let change_id = (get-change-id $run_id) # Write .ontoref-run metadata let meta = { run_id: $run_id, agent_id: $agent_id, mode: $mode, task: $task, workspace_name: $run_id, workspace_path: $ws_path_str, change_id: $change_id, created_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ"), remote: (if (has-rad-remote) { "rad" } else { "origin" }), } $meta | to json | save --force (run-file $run_id) print $" workspace: ($ws_path_str)" print $" run_id: ($run_id)" print $" change_id: ($change_id)" } # List active agent workspaces and their ontoref run state. export def "agent status" [ agent_id?: string, # Filter by agent_id (optional) ]: nothing -> nothing { let agents = (agents-dir) if not ($agents | path exists) { print " no active agent workspaces" return } let entries = (ls $agents | where type == "dir") if ($entries | is-empty) { print " no active agent workspaces" return } let rows = $entries | each { |e| let run_id = ($e.name | path basename) let rf = $"($e.name)/($ONTOREF_RUN_FILE)" if not ($rf | path exists) { return null } let meta = (open $rf | from json) if ($agent_id | is-not-empty) and ($meta.agent_id != $agent_id) { return null } let change_id = ( do { jj ["log", "--no-graph", "-r", $"($run_id)@", "-T", "change_id ++ \"\\n\""] } | complete | if $in.exit_code == 0 { $in.stdout | str trim } else { "unknown" } ) { run_id: $run_id, agent_id: $meta.agent_id, mode: $meta.mode, task: $meta.task, change_id: $change_id, created: $meta.created_at, } } | where { |r| $r != null } if ($rows | is-empty) { print $" no workspaces for agent ($agent_id? | default "any")" } else { print ($rows | table) } } # Report a step in the ontoref run for an agent workspace. export def "agent step" [ run_id: string, # Workspace / run ID step_id: string, # Step to report --status (-s): string = "", # pass | fail | skip ]: nothing -> nothing { if ($status | is-empty) or not ($status in ["pass", "fail", "skip"]) { error make { msg: $"--status required: pass | fail | skip (got: '($status)')" } } let meta = (read-run-file $run_id) onto ["step", "report", $meta.mode, $step_id, "--status", $status] | ignore print $" ($run_id) step ($step_id) → ($status)" } # Validate, push, and open a Radicle patch (or git push) for an agent workspace. export def "agent publish" [ run_id: string, ]: nothing -> nothing { let meta = (read-run-file $run_id) # 1. Ontology drift check onto ["sync", "diff", "--fail-on-drift"] | ignore # 2. Constraint validation onto ["validate", "check-all"] | ignore # 3. Verify all mode steps are reported onto ["mode", "complete", $meta.mode] | ignore # 4. Set commit description let desc = $"agent(($meta.agent_id)): ($meta.task)" jj ["describe", "-m", $desc, "-r", $meta.change_id] | ignore # 5. Create/update bookmark — `set` is idempotent (safe on re-push after amend) let bookmark = $"agent/($meta.agent_id)/($run_id)" jj ["bookmark", "set", $bookmark, "-r", $meta.change_id] | ignore # 6. Push let remote = $meta.remote jj ["git", "push", "--remote", $remote, "--bookmark", $bookmark] | ignore # 7. Open Radicle patch only if rad remote is configured if $remote == "rad" { let r = do { ^rad patch open --title $desc } | complete if $r.exit_code != 0 { print $" warning: rad patch open failed: ($r.stderr | str trim)" } else { print $" patch opened on Radicle" } } else { print $" pushed to ($remote) — no Radicle remote, skipped rad patch open" let modified = (jj ["diff", "--name-only", "-r", $meta.change_id]) if ($modified | lines | any { |l| $l | str starts-with ".ontology/" }) { print $" WARNING: workspace modified .ontology/ files but remote is git-only." print $" merge may be rejected by deny_ontology_writes on the server." } } print $" published: ($bookmark) → ($remote)" } # Merge workspace into main, advance bookmark, and clean up. export def "agent merge" [ run_id: string, ]: nothing -> nothing { let meta = (read-run-file $run_id) let change_id = $meta.change_id # 1. Create merge commit (no-edit — doesn't touch working copies) jj ["new", "--no-edit", "main", $change_id] | ignore # 2. Find the merge commit: heads of change_id's descendants, excluding # change_id itself. `heads(change_id::)` alone can match change_id if it # is a head in the workspace — `~ $change_id` excludes the original commit. let merge_revset = $"heads(($change_id)::) ~ ($change_id)" # 3. Check for conflicts — only block on .ontology/ conflicts let conflict_out = (jj ["log", "--no-graph", "-r", $merge_revset, "-T", "if(conflict, \"CONFLICT\", \"CLEAN\") ++ \"\\n\""]) if ($conflict_out | str trim) == "CONFLICT" { let files_out = ( do { jj ["diff", "--name-only", "-r", $merge_revset] } | complete | get -o result | default "" ) let onto_conflicts = ($files_out | lines | where { |l| $l | str starts-with ".ontology/" }) if ($onto_conflicts | is-not-empty) { # Abandon the conflicted merge commit before aborting do { jj ["abandon", $merge_revset] } | complete | ignore error make { msg: $".ontology/ conflicts require NCL merge resolution: ($onto_conflicts | str join ', ')" } } print $" WARNING: merge commit has non-ontology conflicts (stored as jj data)" print $" use `jj resolve -r ($merge_revset)` in workspace to clean up" } # 4. Advance main bookmark to the merge commit jj ["bookmark", "set", "main", "-r", $merge_revset] | ignore # 5. Dispatch VCS event so ontoref syncs let hooks = $"($env.ONTOREF_ROOT? | default '')/reflection/hooks/git-event.nu" if ($hooks | path exists) { do { ^nu -c $"use ($hooks) *; on-vcs-event 'post-merge'" } | complete | ignore } # 6. Forget workspace and remove directory do { ^jj --no-pager workspace forget $run_id } | complete | ignore let ws_path = (workspace-path $run_id) if ($ws_path | path exists) { rm -rf $ws_path } print $" merged ($run_id) into main, workspace removed" } # Abandon an agent workspace without merging. Cancels the ontoref run. export def "agent discard" [ run_id: string, ]: nothing -> nothing { # Forget from jj do { ^jj --no-pager workspace forget $run_id } | complete | ignore # Remove workspace directory let ws_path = (workspace-path $run_id) if ($ws_path | path exists) { rm -rf $ws_path } # Cancel the ontoref run (best-effort — run cancel may not exist in all versions) let r = do { onto ["run", "cancel", $run_id] } | complete if $r.exit_code != 0 { print $" advisory: ontoref run cancel returned non-zero (run may already be complete)" } print $" discarded workspace ($run_id)" }