Domain extension system (ADR-012): bash-layer dispatch activates repo_kind-conditional CLI domains. install.nu copies domains/ tree; short_alias wrappers generated (personal, prov). ore help and describe capabilities domain-aware. personal domain (PersonalOntology): career skills/talks/publications/positioning, CFP pipeline (Watching→Delivered), opportunities lifecycle, content pipeline, Sessionize integration. Daemon pages: /career, /personal. provisioning domain (DevWorkspace/Mixed): FSM state, next transitions, connections graph, gates, workspace card, capabilities, backlog. Daemon page: /provisioning. VCS abstraction layer (ADR-013): reflection/modules/vcs.nu — uniform jj/git API via filesystem detection (.jj/ vs .git/). opmode.nu and git-event.nu migrated off ^git. reflection/bin/jjw.nu — jj + ontoref + Radicle agent workspace lifecycle. jjw-ncl-merge.nu registered as jj merge tool for .ontology/ NCL conflicts. init-repo.nu for new_project mode. jj/rad not in ontoref requirements — belong in orchestration project manifests. 'Framework RepoKind: ontology/schemas/manifest.ncl gains 'Framework variant; ontoref self-identifies as framework — no domain activates for the protocol itself. Web presence: personal.html and provisioning.html domain subpages. index.html gains "Project Types — Domain Extensions" section with type cards and subpage links. Nav compacted (Arch/Prov labels, solid backdrop-filter background). on+re: vcs-abstraction (adrs: adr-013) and agent-workspace-orchestration Practice nodes; 21 manifest capabilities; state.ncl catalysts updated.
323 lines
12 KiB
Text
323 lines
12 KiB
Text
#!/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/<run_id>/.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<string>]: 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<string>]: 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)"
|
|
}
|