3000 lines
115 KiB
Plaintext
3000 lines
115 KiB
Plaintext
#!/usr/bin/env nu
|
||
# reflection/modules/describe.nu — project self-knowledge query layer.
|
||
# Aggregates from ontology, ADRs, modes, manifest, justfiles, .claude, CI config
|
||
# and renders answers from the perspective of a specific actor.
|
||
#
|
||
# The entry point for anyone (human, agent, CI) arriving at a project cold.
|
||
|
||
def project-root []: nothing -> string {
|
||
let pr = ($env.ONTOREF_PROJECT_ROOT? | default "")
|
||
if ($pr | is-not-empty) and ($pr != $env.ONTOREF_ROOT) { $pr } else { $env.ONTOREF_ROOT }
|
||
}
|
||
|
||
def actor-default []: nothing -> string {
|
||
$env.ONTOREF_ACTOR? | default "developer"
|
||
}
|
||
|
||
# Build NICKEL_IMPORT_PATH for a given project root.
|
||
# Includes project-local ontology, onref symlinked schemas, ADR defaults,
|
||
# and the existing NICKEL_IMPORT_PATH from the environment.
|
||
export def nickel-import-path [root: string]: nothing -> string {
|
||
let entries = [
|
||
$"($root)/.ontology"
|
||
$"($root)/adrs"
|
||
$"($root)/.ontoref/ontology/schemas"
|
||
$"($root)/.ontoref/adrs"
|
||
$"($root)/.onref"
|
||
$root
|
||
$"($env.ONTOREF_ROOT)/ontology"
|
||
$"($env.ONTOREF_ROOT)/ontology/schemas"
|
||
$"($env.ONTOREF_ROOT)/adrs"
|
||
$env.ONTOREF_ROOT
|
||
]
|
||
let valid = ($entries | where { |p| $p | path exists } | uniq)
|
||
let existing = ($env.NICKEL_IMPORT_PATH? | default "")
|
||
if ($existing | is-not-empty) {
|
||
($valid | append $existing) | str join ":"
|
||
} else {
|
||
$valid | str join ":"
|
||
}
|
||
}
|
||
|
||
use ../modules/store.nu [daemon-export-safe]
|
||
|
||
# Centralized output dispatcher for all describe commands.
|
||
# Handles text (via render callback), json, yaml, toml, table.
|
||
def emit-output [data: record, fmt: string, renderer: closure]: nothing -> nothing {
|
||
match $fmt {
|
||
"json" => { print ($data | to json) },
|
||
"yaml" => { print ($data | to yaml) },
|
||
"toml" => {
|
||
print "# TOML cannot represent nested arrays of records. Falling back to JSON."
|
||
print ($data | to json)
|
||
},
|
||
"table" => { print ($data | table --expand) },
|
||
_ => { do $renderer },
|
||
}
|
||
}
|
||
|
||
# ── describe project ────────────────────────────────────────────────────────────
|
||
# "What IS this project? What does it believe? What does it protect?"
|
||
|
||
export def "describe project" [
|
||
--fmt: string = "", # Output format: text* | json | yaml | toml | table
|
||
--actor: string = "", # Perspective: developer | agent | ci | auditor
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let identity = (collect-identity $root)
|
||
let axioms = (collect-axioms $root)
|
||
let tensions = (collect-tensions $root)
|
||
let practices = (collect-practices $root)
|
||
let gates = (collect-gates $root)
|
||
let adrs = (collect-adr-summary $root)
|
||
let dimensions = (collect-dimensions $root)
|
||
|
||
let data = {
|
||
identity: $identity,
|
||
axioms: $axioms,
|
||
tensions: $tensions,
|
||
practices: $practices,
|
||
gates: $gates,
|
||
adrs: $adrs,
|
||
dimensions: $dimensions,
|
||
}
|
||
|
||
emit-output $data $f { || render-project-text $data $a $root }
|
||
}
|
||
|
||
# ── describe capabilities ───────────────────────────────────────────────────────
|
||
# "What can I DO here? What commands, modes, recipes, tools exist?"
|
||
|
||
export def "describe capabilities" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let project_flags = (scan-project-flags $root)
|
||
let just_modules = (scan-just-modules $root)
|
||
let just_recipes = (scan-just-recipes $root)
|
||
let ontoref_commands = (scan-ontoref-commands)
|
||
let modes = (scan-reflection-modes $root)
|
||
let claude = (scan-claude-capabilities $root)
|
||
let ci_tools = (scan-ci-tools $root)
|
||
let manifest_modes = (scan-manifest-modes $root)
|
||
let manifest = (load-manifest-safe $root)
|
||
let manifest_capabilities = ($manifest.capabilities? | default [])
|
||
let backlog_pending = (count-backlog-pending $root)
|
||
|
||
let data = {
|
||
project_flags: $project_flags,
|
||
just_modules: $just_modules,
|
||
just_recipes: $just_recipes,
|
||
ontoref_commands: $ontoref_commands,
|
||
reflection_modes: $modes,
|
||
claude_capabilities: $claude,
|
||
ci_tools: $ci_tools,
|
||
manifest_modes: $manifest_modes,
|
||
manifest_capabilities: $manifest_capabilities,
|
||
backlog_pending: $backlog_pending,
|
||
}
|
||
|
||
emit-output $data $f { || render-capabilities-text $data $a $root }
|
||
}
|
||
|
||
# ── describe mode ────────────────────────────────────────────────────────────────
|
||
# "What steps does this mode define? In what order? What does each step do?"
|
||
|
||
export def "describe mode" [
|
||
name?: string, # Mode ID (without .ncl extension). Omit to list all.
|
||
--fmt: string = "", # Output format: text* | json | yaml | table
|
||
--actor: string = "", # Perspective: developer | agent | ci
|
||
--with-capabilities, # Annotate each step with applicable flag (requires capabilities scan)
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
# List mode — no name given
|
||
if ($name | is-empty) {
|
||
let modes = (scan-reflection-modes $root)
|
||
let data = { modes: $modes }
|
||
emit-output $data $f {||
|
||
print ""
|
||
print "AVAILABLE MODES"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
for m in $modes {
|
||
let src = if $m.source == "project" { " [project]" } else { "" }
|
||
print $" ($m.id)($src) — ($m.steps) steps"
|
||
if ($m.trigger | is-not-empty) { print $" ($m.trigger)" }
|
||
}
|
||
print ""
|
||
print $"Run: ontoref describe mode <name>"
|
||
}
|
||
return
|
||
}
|
||
|
||
# Locate mode file — project-local takes precedence over ontoref
|
||
let project_file = $"($root)/reflection/modes/($name).ncl"
|
||
let ontoref_file = $"($env.ONTOREF_ROOT)/reflection/modes/($name).ncl"
|
||
let mode_root = if ($project_file | path exists) { $root } else { $env.ONTOREF_ROOT }
|
||
let mode_file = if ($project_file | path exists) { $project_file } else { $ontoref_file }
|
||
|
||
if not ($mode_file | path exists) {
|
||
print $"(ansi red)Mode '($name)' not found.(ansi reset)"
|
||
print $" Searched: ($project_file)"
|
||
print $" ($ontoref_file)"
|
||
return
|
||
}
|
||
|
||
let ip = (nickel-import-path $mode_root)
|
||
let mode = (daemon-export-safe $mode_file --import-path $ip)
|
||
if $mode == null {
|
||
print $"(ansi red)Failed to export mode '($name)' — check NCL syntax.(ansi reset)"
|
||
return
|
||
}
|
||
|
||
# Optionally annotate steps with capability flags
|
||
let flags = if $with_capabilities { (scan-project-flags $root) } else { {} }
|
||
let steps = ($mode.steps? | default [] | each { |s|
|
||
if ($flags | is-not-empty) {
|
||
$s | insert "_applicable" true # placeholder — extended in T3 schema with `needs`
|
||
} else { $s }
|
||
})
|
||
|
||
let data = {
|
||
id: ($mode.id? | default $name),
|
||
trigger: ($mode.trigger? | default ""),
|
||
preconditions: ($mode.preconditions? | default []),
|
||
steps: $steps,
|
||
postconditions: ($mode.postconditions? | default []),
|
||
source: (if ($project_file | path exists) { "project" } else { "ontoref" }),
|
||
file: $mode_file,
|
||
}
|
||
|
||
emit-output $data $f {||
|
||
print ""
|
||
print $"MODE: ($data.id) [($data.source)]"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
if ($data.trigger | is-not-empty) {
|
||
print $" ($data.trigger)"
|
||
}
|
||
|
||
if ($data.preconditions | is-not-empty) {
|
||
print ""
|
||
print " PRECONDITIONS"
|
||
for p in $data.preconditions { print $" · ($p)" }
|
||
}
|
||
|
||
print ""
|
||
print " STEPS"
|
||
print " ──────────────────────────────────────────────────────────────"
|
||
for s in $data.steps {
|
||
let deps = if ($s.depends_on? | default [] | is-not-empty) {
|
||
let dep_ids = ($s.depends_on | each { |d|
|
||
let kind = ($d.kind? | default "Always")
|
||
if $kind != "Always" { $"($d.step)[($kind)]" } else { $d.step }
|
||
})
|
||
$" after: ($dep_ids | str join ', ')"
|
||
} else { "" }
|
||
let actor_tag = match ($s.actor? | default "Both") {
|
||
"Human" => " [human]",
|
||
"Agent" => " [agent]",
|
||
_ => "",
|
||
}
|
||
let err = ($s.on_error?.strategy? | default "Stop")
|
||
print $" ($s.id)($actor_tag) on_error=($err)($deps)"
|
||
print $" ($s.action? | default '')"
|
||
if ($s.cmd? | default "" | is-not-empty) {
|
||
print $" $ ($s.cmd)"
|
||
}
|
||
if ($s.verify? | default "" | is-not-empty) {
|
||
print $" verify: ($s.verify)"
|
||
}
|
||
}
|
||
|
||
if ($data.postconditions | is-not-empty) {
|
||
print ""
|
||
print " POSTCONDITIONS"
|
||
for p in $data.postconditions { print $" · ($p)" }
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# ── describe requirements ────────────────────────────────────────────────────────
|
||
# "What does this project need to run? What are the prod/dev prerequisites?"
|
||
|
||
export def "describe requirements" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
--environment: string = "", # filter by environment: production | development | both
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let manifest = (load-manifest-safe $root)
|
||
let all_reqs = ($manifest.requirements? | default [])
|
||
let critical = ($manifest.critical_deps? | default [])
|
||
|
||
let requirements = if ($environment | is-not-empty) {
|
||
$all_reqs | where { |r| ($r.env? | default "Both") == ($environment | str capitalize) }
|
||
} else {
|
||
$all_reqs
|
||
}
|
||
|
||
let data = {
|
||
requirements: $requirements,
|
||
critical_deps: $critical,
|
||
}
|
||
|
||
emit-output $data $f {||
|
||
print ""
|
||
print "REQUIREMENTS"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
if ($requirements | is-not-empty) {
|
||
let prod = ($requirements | where { |r| ($r.env? | default "Both") in ["Production", "Both"] })
|
||
let dev = ($requirements | where { |r| ($r.env? | default "Both") in ["Development", "Both"] })
|
||
|
||
if ($prod | is-not-empty) {
|
||
print ""
|
||
print "PRODUCTION"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for r in $prod {
|
||
let req_flag = if ($r.required? | default true) { "(required)" } else { "(optional)" }
|
||
let ver = if ($r.version? | default "" | is-not-empty) { $" >= ($r.version)" } else { "" }
|
||
print $" ($r.name)($ver) [($r.kind? | default '')] ($req_flag)"
|
||
if ($r.impact? | default "" | is-not-empty) { print $" Impact: ($r.impact)" }
|
||
if ($r.provision? | default "" | is-not-empty) { print $" Provision: ($r.provision)" }
|
||
}
|
||
}
|
||
|
||
if ($dev | is-not-empty) {
|
||
print ""
|
||
print "DEVELOPMENT"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for r in $dev {
|
||
let req_flag = if ($r.required? | default true) { "(required)" } else { "(optional)" }
|
||
let ver = if ($r.version? | default "" | is-not-empty) { $" >= ($r.version)" } else { "" }
|
||
print $" ($r.name)($ver) [($r.kind? | default '')] ($req_flag)"
|
||
if ($r.impact? | default "" | is-not-empty) { print $" Impact: ($r.impact)" }
|
||
if ($r.provision? | default "" | is-not-empty) { print $" Provision: ($r.provision)" }
|
||
}
|
||
}
|
||
} else {
|
||
print " (no requirements declared in manifest)"
|
||
}
|
||
|
||
if ($critical | is-not-empty) {
|
||
print ""
|
||
print "CRITICAL DEPENDENCIES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for d in $critical {
|
||
print $" ($d.name) [($d.ref? | default '')]"
|
||
print $" Used for: ($d.used_for? | default '')"
|
||
print $" Failure: ($d.failure_impact? | default '')"
|
||
if ($d.mitigation? | default "" | is-not-empty) {
|
||
print $" Mitigation: ($d.mitigation)"
|
||
}
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# ── describe constraints ────────────────────────────────────────────────────────
|
||
# "What can I NOT do? What are the Hard rules?"
|
||
|
||
export def "describe constraints" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let axioms = (collect-axioms $root)
|
||
let hard_constraints = (collect-hard-constraints $root)
|
||
let gates = (collect-gates $root)
|
||
|
||
let data = {
|
||
invariants: $axioms,
|
||
hard_constraints: $hard_constraints,
|
||
active_gates: $gates,
|
||
}
|
||
|
||
emit-output $data $f { || render-constraints-text $data $a }
|
||
}
|
||
|
||
# ── describe tools ──────────────────────────────────────────────────────────────
|
||
# "What dev/CI tools does this project use? How do I call them?"
|
||
|
||
export def "describe tools" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let ci_tools = (scan-ci-tools $root)
|
||
let just_recipes = (scan-just-recipes $root)
|
||
let dev_tools = (scan-dev-tools $root)
|
||
|
||
let data = {
|
||
ci_tools: $ci_tools,
|
||
just_recipes: $just_recipes,
|
||
dev_tools: $dev_tools,
|
||
}
|
||
|
||
emit-output $data $f { || render-tools-text $data $a $root }
|
||
}
|
||
|
||
# ── describe impact ─────────────────────────────────────────────────────────────
|
||
# "If I change X, what else is affected?"
|
||
|
||
export def "describe impact" [
|
||
node_id: string, # Ontology node id to trace
|
||
--depth: int = 2, # How many edge hops to follow
|
||
--include-external, # Follow connections.ncl to external projects via daemon
|
||
--fmt: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let f = if ($fmt | is-not-empty) { $fmt } else { "text" }
|
||
let ontology = (load-ontology-safe $root)
|
||
|
||
if ($ontology | is-empty) {
|
||
print " No .ontology/core.ncl found."
|
||
return
|
||
}
|
||
|
||
let nodes = ($ontology.nodes? | default [])
|
||
let edges = ($ontology.edges? | default [])
|
||
|
||
let target = ($nodes | where id == $node_id)
|
||
if ($target | is-empty) {
|
||
let available = ($nodes | get id | str join ", ")
|
||
print $" Node '($node_id)' not found. Available: ($available)"
|
||
return
|
||
}
|
||
|
||
let local_impacts = (trace-impacts $node_id $edges $nodes $depth)
|
||
|
||
# When --include-external, query the daemon for cross-project entries
|
||
let external_impacts = if $include_external {
|
||
let daemon_url = ($env.ONTOREF_DAEMON_URL? | default "http://127.0.0.1:7891")
|
||
let result = do {
|
||
http get $"($daemon_url)/graph/impact?node=($node_id)&depth=($depth)&include_external=true"
|
||
} | complete
|
||
if $result.exit_code == 0 {
|
||
let resp = ($result.stdout | from json)
|
||
$resp.impacts? | default [] | each { |e|
|
||
{
|
||
id: $e.node_id,
|
||
name: ($e.node_name? | default $e.node_id),
|
||
level: "external",
|
||
description: $"[$($e.slug)] via ($e.via)",
|
||
depth: $e.depth,
|
||
direction: $e.direction,
|
||
external: true,
|
||
}
|
||
}
|
||
} else {
|
||
[]
|
||
}
|
||
} else {
|
||
[]
|
||
}
|
||
|
||
let all_impacts = ($local_impacts | append $external_impacts)
|
||
|
||
let data = {
|
||
source: ($target | first),
|
||
impacts: $all_impacts,
|
||
include_external: $include_external,
|
||
}
|
||
|
||
emit-output $data $f { || render-impact-text $data }
|
||
}
|
||
|
||
# ── describe why ────────────────────────────────────────────────────────────────
|
||
# "Why does this decision/constraint/practice exist?"
|
||
|
||
export def "describe why" [
|
||
id: string, # Node id, ADR id, or constraint id
|
||
--fmt: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let f = if ($fmt | is-not-empty) { $fmt } else { "text" }
|
||
|
||
let ontology = (load-ontology-safe $root)
|
||
let adr_data = (load-all-adrs $root)
|
||
|
||
# Search in ontology nodes
|
||
let node_match = if ($ontology | is-not-empty) {
|
||
$ontology.nodes? | default [] | where id == $id
|
||
} else { [] }
|
||
|
||
# Search in ADRs
|
||
let adr_match = ($adr_data | where { |a| $a.id == $id or $a.id == $"adr-($id)" })
|
||
|
||
let data = {
|
||
node: (if ($node_match | is-not-empty) { $node_match | first } else { null }),
|
||
adr: (if ($adr_match | is-not-empty) { $adr_match | first } else { null }),
|
||
edges_from: (if ($ontology | is-not-empty) {
|
||
$ontology.edges? | default [] | where from == $id
|
||
} else { [] }),
|
||
edges_to: (if ($ontology | is-not-empty) {
|
||
$ontology.edges? | default [] | where to == $id
|
||
} else { [] }),
|
||
}
|
||
|
||
emit-output $data $f { || render-why-text $data $id }
|
||
}
|
||
|
||
# ── describe find ──────────────────────────────────────────────────────────────
|
||
# HOWTO-oriented search: What is it, Why it exists, How to use it, Where to look.
|
||
# Extracts doc comments from Rust source, finds examples/tests, shows related nodes.
|
||
# Human: interactive selector loop. Agent: structured JSON.
|
||
|
||
export def "describe search" [
|
||
term: string, # Search term (case-insensitive substring match)
|
||
--level: string = "", # Filter by level: Axiom | Tension | Practice | Project
|
||
--fmt: string = "",
|
||
--clip, # Copy selected result to clipboard after rendering
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let actor = (actor-default)
|
||
let raw_fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
|
||
let f = match $raw_fmt {
|
||
"j" => "json",
|
||
"y" => "yaml",
|
||
"t" => "toml",
|
||
"m" => "md",
|
||
_ => $raw_fmt,
|
||
}
|
||
|
||
let ontology = (load-ontology-safe $root)
|
||
if ($ontology | is-empty) {
|
||
print " No .ontology/core.ncl found."
|
||
return
|
||
}
|
||
|
||
let nodes = ($ontology.nodes? | default [])
|
||
let edges = ($ontology.edges? | default [])
|
||
let term_lower = ($term | str downcase)
|
||
|
||
mut matches = ($nodes | where { |n|
|
||
let id_match = ($n.id | str downcase | str contains $term_lower)
|
||
let label_match = ($n.name? | default "" | str downcase | str contains $term_lower)
|
||
let desc_match = ($n.description? | default "" | str downcase | str contains $term_lower)
|
||
$id_match or $label_match or $desc_match
|
||
})
|
||
|
||
if ($level | is-not-empty) {
|
||
$matches = ($matches | where { |n| ($n.level? | default "") == $level })
|
||
}
|
||
|
||
if ($matches | is-empty) {
|
||
print $" No nodes matching '($term)'."
|
||
if ($level | is-not-empty) { print $" (ansi dark_gray)Level filter: ($level)(ansi reset)" }
|
||
return
|
||
}
|
||
|
||
if $f == "json" or $f == "yaml" or $f == "toml" {
|
||
# Use $matches directly — no daemon/build-howto needed for structured output.
|
||
let results = ($matches | each { |n| {
|
||
id: $n.id,
|
||
name: ($n.name? | default ""),
|
||
level: ($n.level? | default ""),
|
||
description: ($n.description? | default ""),
|
||
pole: ($n.pole? | default ""),
|
||
invariant: ($n.invariant? | default false),
|
||
edges_from: ($edges | where from == $n.id | select kind to),
|
||
edges_to: ($edges | where to == $n.id | select kind from),
|
||
}})
|
||
let payload = { term: $term, count: ($results | length), results: $results }
|
||
match $f {
|
||
"json" => { print ($payload | to json) },
|
||
"yaml" => { print ($payload | to yaml) },
|
||
"toml" => { print ({ find: $payload } | to toml) },
|
||
}
|
||
return
|
||
}
|
||
|
||
if $f == "md" {
|
||
let results = ($matches | each { |n| build-howto $n $nodes $edges $root })
|
||
for r in $results { render-howto-md $r }
|
||
return
|
||
}
|
||
|
||
if ($matches | length) == 1 {
|
||
let node = ($matches | first)
|
||
render-howto $node $nodes $edges $root
|
||
if $clip {
|
||
let h = (build-howto $node $nodes $edges $root)
|
||
clip-text (howto-to-md-string $h)
|
||
}
|
||
return
|
||
}
|
||
|
||
# No TTY (subprocess, pipe, CI): print summary list without interactive selector.
|
||
let is_tty = (do { ^test -t 0 } | complete | get exit_code) == 0
|
||
if not $is_tty {
|
||
print ""
|
||
print $" (ansi white_bold)Search:(ansi reset) '($term)' ($matches | length) results"
|
||
print ""
|
||
for m in $matches {
|
||
let level_str = ($m.level? | default "" | fill -w 9)
|
||
let name_str = ($m.name? | default $m.id)
|
||
let desc_str = ($m.description? | default "")
|
||
print $" (ansi cyan)($level_str)(ansi reset) (ansi white_bold)($m.id)(ansi reset) ($name_str)"
|
||
if ($desc_str | is-not-empty) {
|
||
print $" (ansi dark_gray)($desc_str)(ansi reset)"
|
||
}
|
||
}
|
||
print ""
|
||
return
|
||
}
|
||
|
||
find-interactive-loop $matches $nodes $edges $root $term $clip
|
||
}
|
||
|
||
# Backward-compatible alias — delegates to describe search.
|
||
export def "describe find" [
|
||
term: string,
|
||
--level: string = "",
|
||
--fmt: string = "",
|
||
--clip,
|
||
]: nothing -> nothing {
|
||
describe search $term --level $level --fmt $fmt --clip=$clip
|
||
}
|
||
|
||
# Load entries from a qa.ncl file path. Returns empty list on missing file or export failure.
|
||
def qa-load-entries [qa_path: string]: nothing -> list {
|
||
if not ($qa_path | path exists) { return [] }
|
||
let r = (do { ^nickel export --format json $qa_path } | complete)
|
||
if $r.exit_code != 0 { return [] }
|
||
($r.stdout | from json | get entries? | default [])
|
||
}
|
||
|
||
# Word-overlap score: count of query words present in the combined entry text.
|
||
def qa-score-entry [words: list, entry: record]: nothing -> int {
|
||
let text = ($"($entry.question? | default '') ($entry.answer? | default '') ($entry.tags? | default [] | str join ' ')" | str downcase)
|
||
$words | each { |w| if ($text | str contains $w) { 1 } else { 0 } } | math sum
|
||
}
|
||
|
||
# Search Q&A entries in reflection/qa.ncl with word-overlap scoring.
|
||
# Falls back to describe search when no QA hits are found.
|
||
export def "qa search" [
|
||
term: string, # Natural-language query
|
||
--global (-g), # Also search ONTOREF_ROOT qa.ncl
|
||
--no-fallback, # Do not fall back to ontology search
|
||
--fmt: string = "",
|
||
--clip, # Copy output to clipboard after rendering
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let actor = (actor-default)
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
|
||
let words = ($term | str downcase | split words | where { |w| ($w | str length) > 2 })
|
||
|
||
let project_entries = (qa-load-entries $"($root)/reflection/qa.ncl")
|
||
| each { |e| $e | insert scope "project" }
|
||
|
||
mut entries = $project_entries
|
||
|
||
if $global {
|
||
let global_root = $env.ONTOREF_ROOT
|
||
if $global_root != $root {
|
||
let global_entries = (qa-load-entries $"($global_root)/reflection/qa.ncl")
|
||
| each { |e| $e | insert scope "global" }
|
||
$entries = ($entries | append $global_entries)
|
||
}
|
||
}
|
||
|
||
let scored = ($entries
|
||
| each { |e| $e | insert _score (qa-score-entry $words $e) }
|
||
| where { |e| $e._score > 0 }
|
||
| sort-by _score --reverse
|
||
)
|
||
|
||
if ($scored | is-empty) {
|
||
if not $no_fallback {
|
||
print $" (ansi dark_gray)No QA entries matching '($term)' — searching ontology…(ansi reset)"
|
||
describe search $term --fmt $fmt --clip=$clip
|
||
} else {
|
||
print $" No QA entries matching '($term)'."
|
||
}
|
||
return
|
||
}
|
||
|
||
if $f == "json" {
|
||
let out = ($scored | reject _score | to json)
|
||
print $out
|
||
if $clip { clip-text $out }
|
||
return
|
||
}
|
||
|
||
mut clip_lines: list<string> = []
|
||
for e in $scored {
|
||
let scope_tag = $"(ansi dark_gray)[($e.scope)](ansi reset)"
|
||
let id_tag = $"(ansi cyan)($e.id)(ansi reset)"
|
||
print $"($scope_tag) ($id_tag) (ansi white_bold)($e.question)(ansi reset)"
|
||
if ($e.answer? | default "" | is-not-empty) {
|
||
print $" ($e.answer)"
|
||
}
|
||
print ""
|
||
if $clip {
|
||
$clip_lines = ($clip_lines | append $"[($e.scope)] ($e.id) ($e.question)")
|
||
if ($e.answer? | default "" | is-not-empty) {
|
||
$clip_lines = ($clip_lines | append $" ($e.answer)")
|
||
}
|
||
$clip_lines = ($clip_lines | append "")
|
||
}
|
||
}
|
||
if $clip and ($clip_lines | is-not-empty) {
|
||
clip-text ($clip_lines | str join "\n")
|
||
}
|
||
}
|
||
|
||
# ── HOWTO builder ─────────────────────────────────────────────────────────────
|
||
|
||
# Extract //! or /// doc comments from a Rust file (module-level docs).
|
||
def extract-rust-docs [file_path: string]: nothing -> string {
|
||
if not ($file_path | path exists) { return "" }
|
||
let lines = (open $file_path --raw | lines)
|
||
mut doc_lines = []
|
||
mut in_docs = true
|
||
for line in $lines {
|
||
if not $in_docs { break }
|
||
let trimmed = ($line | str trim)
|
||
if ($trimmed | str starts-with "//!") {
|
||
let content = ($trimmed | str replace "//! " "" | str replace "//!" "")
|
||
$doc_lines = ($doc_lines | append $content)
|
||
} else if ($trimmed | str starts-with "///") {
|
||
let content = ($trimmed | str replace "/// " "" | str replace "///" "")
|
||
$doc_lines = ($doc_lines | append $content)
|
||
} else if ($trimmed | is-empty) and ($doc_lines | is-not-empty) {
|
||
$doc_lines = ($doc_lines | append "")
|
||
} else if ($doc_lines | is-not-empty) {
|
||
$in_docs = false
|
||
}
|
||
}
|
||
$doc_lines | str join "\n" | str trim
|
||
}
|
||
|
||
# Find examples related to a crate.
|
||
def find-examples [root: string, crate_name: string]: nothing -> list<record> {
|
||
let examples_dir = $"($root)/crates/($crate_name)/examples"
|
||
if not ($examples_dir | path exists) { return [] }
|
||
glob $"($examples_dir)/*.rs" | each { |e|
|
||
let name = ($e | path basename | str replace ".rs" "")
|
||
let docs = (extract-rust-docs $e)
|
||
let short = if ($docs | is-not-empty) {
|
||
$docs | lines | first
|
||
} else { "" }
|
||
{ name: $name, cmd: $"cargo run -p ($crate_name) --example ($name)", description: $short }
|
||
}
|
||
}
|
||
|
||
# Find test files that reference a module/artifact.
|
||
def find-tests [root: string, artifact_path: string]: nothing -> list<record> {
|
||
# Derive crate name and module name from artifact path.
|
||
let parts = ($artifact_path | split row "/")
|
||
if ($parts | length) < 2 { return [] }
|
||
if ($parts | first) != "crates" { return [] }
|
||
let crate_name = ($parts | get 1)
|
||
let tests_dir = $"($root)/crates/($crate_name)/tests"
|
||
if not ($tests_dir | path exists) { return [] }
|
||
|
||
# Search for test files whose name relates to the artifact.
|
||
let module_name = ($artifact_path | path basename | str replace ".rs" "")
|
||
let test_files = (glob $"($tests_dir)/*.rs")
|
||
$test_files | each { |tf|
|
||
let test_name = ($tf | path basename | str replace ".rs" "")
|
||
let content = (open $tf --raw)
|
||
let module_lower = ($module_name | str downcase)
|
||
let test_lower = ($test_name | str downcase)
|
||
if ($test_lower | str contains $module_lower) or ($content | str downcase | str contains $module_lower) {
|
||
let docs = (extract-rust-docs $tf)
|
||
let short = if ($docs | is-not-empty) { $docs | lines | first } else { "" }
|
||
{ name: $test_name, cmd: $"cargo test -p ($crate_name) --test ($test_name)", description: $short }
|
||
} else { null }
|
||
} | compact
|
||
}
|
||
|
||
# Copy text to system clipboard (pbcopy / xclip / wl-copy).
|
||
def clip-text [text: string]: nothing -> nothing {
|
||
if (which pbcopy | is-not-empty) {
|
||
$text | ^pbcopy
|
||
print --stderr " ✓ Copied to clipboard"
|
||
} else if (which xclip | is-not-empty) {
|
||
$text | ^xclip -selection clipboard
|
||
print --stderr " ✓ Copied to clipboard"
|
||
} else if (which "wl-copy" | is-not-empty) {
|
||
$text | ^wl-copy
|
||
print --stderr " ✓ Copied to clipboard"
|
||
} else {
|
||
print --stderr " No clipboard tool found (install pbcopy, xclip, or wl-copy)"
|
||
}
|
||
}
|
||
|
||
# Build a plain markdown string from a howto record (mirrors render-howto-md).
|
||
def howto-to-md-string [h: record]: nothing -> string {
|
||
mut lines: list<string> = []
|
||
let inv = if $h.invariant { " **invariant**" } else { "" }
|
||
$lines = ($lines | append $"# ($h.id)($inv)")
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append $"**Level**: ($h.level) **Name**: ($h.name)")
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## What")
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append $h.what)
|
||
if ($h.what_docs | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append $h.what_docs)
|
||
}
|
||
if ($h.source | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Source")
|
||
$lines = ($lines | append "")
|
||
for s in $h.source {
|
||
if ($s.modules? | is-not-empty) {
|
||
$lines = ($lines | append $"- `($s.path)/`")
|
||
let mods = ($s.modules | each { |m| $m | str replace ".rs" "" } | where { |m| $m != "mod" })
|
||
if ($mods | is-not-empty) {
|
||
let mod_str = ($mods | each { |m| $"`($m)`" } | str join ", ")
|
||
$lines = ($lines | append $" Modules: ($mod_str)")
|
||
}
|
||
} else {
|
||
$lines = ($lines | append $"- `($s.path)`")
|
||
}
|
||
}
|
||
}
|
||
if ($h.examples | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Examples")
|
||
$lines = ($lines | append "")
|
||
for ex in $h.examples {
|
||
$lines = ($lines | append "```sh")
|
||
$lines = ($lines | append $ex.cmd)
|
||
$lines = ($lines | append "```")
|
||
if ($ex.description | is-not-empty) { $lines = ($lines | append $ex.description) }
|
||
$lines = ($lines | append "")
|
||
}
|
||
}
|
||
if ($h.tests | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Tests")
|
||
$lines = ($lines | append "")
|
||
for t in $h.tests {
|
||
$lines = ($lines | append "```sh")
|
||
$lines = ($lines | append $t.cmd)
|
||
$lines = ($lines | append "```")
|
||
if ($t.description | is-not-empty) { $lines = ($lines | append $t.description) }
|
||
$lines = ($lines | append "")
|
||
}
|
||
}
|
||
if ($h.related_to | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Related")
|
||
$lines = ($lines | append "")
|
||
for r in $h.related_to { $lines = ($lines | append $"- → `($r.id)` ($r.name)") }
|
||
}
|
||
if ($h.used_by | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Used by")
|
||
$lines = ($lines | append "")
|
||
for u in $h.used_by { $lines = ($lines | append $"- ← `($u.id)` ($u.name)") }
|
||
}
|
||
if ($h.adrs | is-not-empty) {
|
||
$lines = ($lines | append "")
|
||
$lines = ($lines | append "## Validated by")
|
||
$lines = ($lines | append "")
|
||
for adr in $h.adrs { $lines = ($lines | append $"- `($adr)`") }
|
||
}
|
||
$lines = ($lines | append "")
|
||
$lines | str join "\n"
|
||
}
|
||
|
||
# Build full HOWTO record for a node.
|
||
def build-howto [
|
||
n: record,
|
||
all_nodes: list<record>,
|
||
edges: list<record>,
|
||
root: string,
|
||
]: nothing -> record {
|
||
let artifacts = ($n.artifact_paths? | default [])
|
||
|
||
# WHAT: doc comments from the artifact entry point.
|
||
mut what_docs = ""
|
||
mut source_files = []
|
||
for a in $artifacts {
|
||
let full = $"($root)/($a)"
|
||
if ($full | path exists) {
|
||
if ($full | path type) == "dir" {
|
||
# Directory artifact: read mod.rs or lib.rs.
|
||
let mod_rs = $"($full)/mod.rs"
|
||
let lib_rs = $"($full)/lib.rs"
|
||
let entry = if ($mod_rs | path exists) { $mod_rs } else if ($lib_rs | path exists) { $lib_rs } else { "" }
|
||
if ($entry | is-not-empty) {
|
||
let docs = (extract-rust-docs $entry)
|
||
if ($docs | is-not-empty) { $what_docs = $docs }
|
||
$source_files = ($source_files | append { path: $a, entry: ($entry | path basename) })
|
||
}
|
||
# List public source files in the directory.
|
||
let rs_files = (glob ($full | path join "*.rs") | each { |f| $f | path basename } | sort)
|
||
$source_files = ($source_files | append { path: $a, modules: $rs_files })
|
||
} else if ($full | str ends-with ".rs") {
|
||
let docs = (extract-rust-docs $full)
|
||
if ($docs | is-not-empty) and ($what_docs | is-empty) { $what_docs = $docs }
|
||
$source_files = ($source_files | append { path: $a })
|
||
} else {
|
||
$source_files = ($source_files | append { path: $a })
|
||
}
|
||
}
|
||
}
|
||
|
||
# HOW: examples and tests.
|
||
mut examples = []
|
||
mut tests = []
|
||
for a in $artifacts {
|
||
if ($a | str starts-with "crates/") {
|
||
let crate_name = ($a | split row "/" | get 1)
|
||
let found_examples = (find-examples $root $crate_name)
|
||
$examples = ($examples | append $found_examples)
|
||
let found_tests = (find-tests $root $a)
|
||
$tests = ($tests | append $found_tests)
|
||
}
|
||
}
|
||
$examples = ($examples | uniq-by name)
|
||
$tests = ($tests | uniq-by name)
|
||
|
||
# RELATED: connected nodes (compact — just id + name for context, not structural dump).
|
||
let related = ($edges | where from == $n.id | each { |e|
|
||
let t = ($all_nodes | where id == $e.to)
|
||
{ id: $e.to, name: (if ($t | is-not-empty) { ($t | first).name? | default $e.to } else { $e.to }), relation: $e.kind }
|
||
})
|
||
let used_by = ($edges | where to == $n.id | each { |e|
|
||
let s = ($all_nodes | where id == $e.from)
|
||
{ id: $e.from, name: (if ($s | is-not-empty) { ($s | first).name? | default $e.from } else { $e.from }), relation: $e.kind }
|
||
})
|
||
|
||
{
|
||
id: $n.id,
|
||
name: ($n.name? | default ""),
|
||
level: ($n.level? | default ""),
|
||
invariant: ($n.invariant? | default false),
|
||
what: ($n.description? | default ""),
|
||
what_docs: $what_docs,
|
||
source: $source_files,
|
||
examples: $examples,
|
||
tests: $tests,
|
||
related_to: $related,
|
||
used_by: $used_by,
|
||
adrs: ($n.adrs? | default []),
|
||
}
|
||
}
|
||
|
||
# ── Render HOWTO (human) ──────────────────────────────────────────────────────
|
||
|
||
def render-howto [
|
||
n: record,
|
||
all_nodes: list<record>,
|
||
edges: list<record>,
|
||
root: string,
|
||
] {
|
||
let h = (build-howto $n $all_nodes $edges $root)
|
||
let level_val_color = match $h.level {
|
||
"Axiom" => (ansi red),
|
||
"Tension" => (ansi yellow),
|
||
"Practice" => (ansi green),
|
||
_ => (ansi cyan),
|
||
}
|
||
let inv = if $h.invariant { $" (ansi red_bold)invariant(ansi reset)" } else { "" }
|
||
|
||
print ""
|
||
print $" (ansi white_bold)($h.id)(ansi reset)($inv)"
|
||
print $" ($level_val_color)($h.level)(ansi reset) ($h.name)"
|
||
|
||
# WHAT — ontology description + source doc comments.
|
||
print ""
|
||
print $" (ansi white_bold)What(ansi reset)"
|
||
print $" ($h.what)"
|
||
if ($h.what_docs | is-not-empty) {
|
||
print ""
|
||
let doc_lines = ($h.what_docs | lines)
|
||
for line in $doc_lines {
|
||
print $" (ansi dark_gray)($line)(ansi reset)"
|
||
}
|
||
}
|
||
|
||
# SOURCE — where the code lives, public modules.
|
||
if ($h.source | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Source(ansi reset)"
|
||
for s in $h.source {
|
||
if ($s.modules? | is-not-empty) {
|
||
print $" (ansi cyan)($s.path)/(ansi reset)"
|
||
let mods = ($s.modules | each { |m| $m | str replace ".rs" "" } | where { |m| $m != "mod" })
|
||
if ($mods | is-not-empty) {
|
||
let mod_str = ($mods | str join " ")
|
||
print $" (ansi dark_gray)($mod_str)(ansi reset)"
|
||
}
|
||
} else {
|
||
print $" (ansi cyan)($s.path)(ansi reset)"
|
||
}
|
||
}
|
||
}
|
||
|
||
# HOW — examples to run.
|
||
if ($h.examples | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Examples(ansi reset)"
|
||
for ex in $h.examples {
|
||
print $" (ansi green)(ansi reset) ($ex.cmd)"
|
||
if ($ex.description | is-not-empty) {
|
||
print $" (ansi dark_gray)($ex.description)(ansi reset)"
|
||
}
|
||
}
|
||
}
|
||
|
||
# HOW — tests to run.
|
||
if ($h.tests | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Tests(ansi reset)"
|
||
for t in $h.tests {
|
||
print $" (ansi green)(ansi reset) ($t.cmd)"
|
||
if ($t.description | is-not-empty) {
|
||
print $" (ansi dark_gray)($t.description)(ansi reset)"
|
||
}
|
||
}
|
||
}
|
||
|
||
# RELATED — connected nodes (compact, for context).
|
||
if ($h.related_to | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Related(ansi reset)"
|
||
for r in $h.related_to {
|
||
print $" (ansi green)→(ansi reset) (ansi cyan)($r.id)(ansi reset) ($r.name)"
|
||
}
|
||
}
|
||
if ($h.used_by | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Used by(ansi reset)"
|
||
for u in $h.used_by {
|
||
print $" (ansi yellow)←(ansi reset) (ansi cyan)($u.id)(ansi reset) ($u.name)"
|
||
}
|
||
}
|
||
if ($h.adrs | is-not-empty) {
|
||
print ""
|
||
print $" (ansi white_bold)Validated by(ansi reset)"
|
||
for adr in $h.adrs {
|
||
print $" (ansi magenta)◆(ansi reset) (ansi cyan)($adr)(ansi reset)"
|
||
}
|
||
}
|
||
|
||
print ""
|
||
}
|
||
|
||
# ── Render HOWTO (markdown) ─────────────────────────────────────────────────
|
||
|
||
def render-howto-md [h: record] {
|
||
let inv = if $h.invariant { " **invariant**" } else { "" }
|
||
print $"# ($h.id)($inv)"
|
||
print ""
|
||
print $"**Level**: ($h.level) **Name**: ($h.name)"
|
||
print ""
|
||
print "## What"
|
||
print ""
|
||
print $h.what
|
||
if ($h.what_docs | is-not-empty) {
|
||
print ""
|
||
print $h.what_docs
|
||
}
|
||
if ($h.source | is-not-empty) {
|
||
print ""
|
||
print "## Source"
|
||
print ""
|
||
for s in $h.source {
|
||
if ($s.modules? | is-not-empty) {
|
||
print $"- `($s.path)/`"
|
||
let mods = ($s.modules | each { |m| $m | str replace ".rs" "" } | where { |m| $m != "mod" })
|
||
if ($mods | is-not-empty) {
|
||
let mod_str = ($mods | each { |m| $"`($m)`" } | str join ", ")
|
||
print $" Modules: ($mod_str)"
|
||
}
|
||
} else {
|
||
print $"- `($s.path)`"
|
||
}
|
||
}
|
||
}
|
||
if ($h.examples | is-not-empty) {
|
||
print ""
|
||
print "## Examples"
|
||
print ""
|
||
for ex in $h.examples {
|
||
print "```sh"
|
||
print $ex.cmd
|
||
print "```"
|
||
if ($ex.description | is-not-empty) { print $ex.description }
|
||
print ""
|
||
}
|
||
}
|
||
if ($h.tests | is-not-empty) {
|
||
print ""
|
||
print "## Tests"
|
||
print ""
|
||
for t in $h.tests {
|
||
print "```sh"
|
||
print $t.cmd
|
||
print "```"
|
||
if ($t.description | is-not-empty) { print $t.description }
|
||
print ""
|
||
}
|
||
}
|
||
if ($h.related_to | is-not-empty) {
|
||
print ""
|
||
print "## Related"
|
||
print ""
|
||
for r in $h.related_to { print $"- → `($r.id)` ($r.name)" }
|
||
}
|
||
if ($h.used_by | is-not-empty) {
|
||
print ""
|
||
print "## Used by"
|
||
print ""
|
||
for u in $h.used_by { print $"- ← `($u.id)` ($u.name)" }
|
||
}
|
||
if ($h.adrs | is-not-empty) {
|
||
print ""
|
||
print "## Validated by"
|
||
print ""
|
||
for adr in $h.adrs { print $"- `($adr)`" }
|
||
}
|
||
print ""
|
||
}
|
||
|
||
# ── Interactive loop ──────────────────────────────────────────────────────────
|
||
|
||
def find-interactive-loop [
|
||
matches: list<record>,
|
||
all_nodes: list<record>,
|
||
edges: list<record>,
|
||
root: string,
|
||
term: string,
|
||
clip: bool,
|
||
] {
|
||
let match_count = ($matches | length)
|
||
print ""
|
||
print $" (ansi white_bold)Search:(ansi reset) '($term)' ($match_count) results"
|
||
print ""
|
||
|
||
let labels = ($matches | each { |n|
|
||
let level_val_tag = ($n.level? | default "?" | fill -w 9)
|
||
$"($level_val_tag) ($n.id) — ($n.name? | default '')"
|
||
})
|
||
let quit_label = "← quit"
|
||
let selector_items = ($labels | append $quit_label)
|
||
|
||
loop {
|
||
let pick = ($selector_items | input list $" (ansi cyan_bold)Select:(ansi reset) ")
|
||
if ($pick | is-empty) or ($pick == $quit_label) { break }
|
||
|
||
let picked_parts = ($pick | split row " — ")
|
||
let picked_prefix = ($picked_parts | first | str trim)
|
||
let picked_id = ($picked_prefix | split row " " | last)
|
||
|
||
let node_matches = ($matches | where id == $picked_id)
|
||
if ($node_matches | is-empty) { continue }
|
||
|
||
let selected_node = ($node_matches | first)
|
||
render-howto $selected_node $all_nodes $edges $root
|
||
|
||
# Offer to jump to a related node, back to results, or quit.
|
||
let h = (build-howto $selected_node $all_nodes $edges $root)
|
||
if $clip { clip-text (howto-to-md-string $h) }
|
||
let conn_ids = ($h.related_to | get id) | append ($h.used_by | get id) | uniq
|
||
if ($conn_ids | is-not-empty) {
|
||
let jump_items = ($conn_ids | append "← back" | append "← quit")
|
||
let jump = ($jump_items | input list $" (ansi cyan_bold)Jump to:(ansi reset) ")
|
||
if ($jump | is-empty) or ($jump == "← quit") { break }
|
||
if $jump == "← back" { continue }
|
||
|
||
let jumped = ($all_nodes | where id == $jump)
|
||
if ($jumped | is-not-empty) {
|
||
let jumped_node = ($jumped | first)
|
||
render-howto $jumped_node $all_nodes $edges $root
|
||
if $clip {
|
||
let jh = (build-howto $jumped_node $all_nodes $edges $root)
|
||
clip-text (howto-to-md-string $jh)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
# ── describe features ──────────────────────────────────────────────────────────
|
||
# "What concrete capabilities does this project have?"
|
||
# No args → list all features. With id → detailed view of one feature.
|
||
|
||
export def "describe features" [
|
||
id?: string, # Feature id to detail (optional — omit for list)
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let ontology = (load-ontology-safe $root)
|
||
if ($ontology | is-empty) {
|
||
print " No .ontology/core.ncl found."
|
||
return
|
||
}
|
||
|
||
let nodes = ($ontology.nodes? | default [])
|
||
let edges = ($ontology.edges? | default [])
|
||
|
||
# Features = all non-Axiom, non-Tension nodes (Practice, etc.)
|
||
let features = ($nodes | where { |n|
|
||
let level_val = ($n.level? | default "")
|
||
$level_val != "Axiom" and $level_val != "Tension"
|
||
})
|
||
|
||
# Cargo features (compile-time toggles)
|
||
let cargo_features = (collect-cargo-features $root)
|
||
|
||
if ($id | is-empty) or ($id == "") {
|
||
# List mode
|
||
let data = {
|
||
features: ($features | each { |n| {
|
||
id: $n.id,
|
||
name: ($n.name? | default $n.id),
|
||
level: ($n.level? | default ""),
|
||
description: ($n.description? | default ""),
|
||
artifacts: ($n.artifact_paths? | default [] | length),
|
||
}}),
|
||
cargo_features: $cargo_features,
|
||
}
|
||
emit-output $data $f { || render-features-list-text $data $root }
|
||
} else {
|
||
# Detail mode
|
||
let target = ($features | where { |n| $n.id == $id })
|
||
if ($target | is-empty) {
|
||
let available = ($features | get id | str join ", ")
|
||
print $" Feature '($id)' not found. Available: ($available)"
|
||
return
|
||
}
|
||
let node = ($target | first)
|
||
|
||
# Verify artifact paths
|
||
let artifacts = ($node.artifact_paths? | default [] | each { |p|
|
||
let full = $"($root)/($p)"
|
||
{ path: $p, exists: ($full | path exists) }
|
||
})
|
||
|
||
# Edges: dependencies (outgoing) and dependents (incoming)
|
||
let outgoing = ($edges | where from == $id | each { |e|
|
||
let target_node = ($nodes | where id == $e.to)
|
||
let label = if ($target_node | is-not-empty) { ($target_node | first).name? | default $e.to } else { $e.to }
|
||
{ id: $e.to, name: $label, relation: ($e.kind? | default ""), direction: "depends_on" }
|
||
})
|
||
let incoming = ($edges | where to == $id | each { |e|
|
||
let source_node = ($nodes | where id == $e.from)
|
||
let label = if ($source_node | is-not-empty) { ($source_node | first).name? | default $e.from } else { $e.from }
|
||
{ id: $e.from, name: $label, relation: ($e.kind? | default ""), direction: "depended_by" }
|
||
})
|
||
|
||
# Related state dimensions
|
||
let dims = (collect-dimensions $root)
|
||
let related_dims = ($dims | where { |d|
|
||
($d.id | str contains $id) or ($id | str contains $d.id)
|
||
})
|
||
|
||
# Related ADR constraints
|
||
let hard_constraints = (collect-hard-constraints $root)
|
||
let related_constraints = ($hard_constraints | where { |c|
|
||
($c.check_hint? | default "" | str contains $id) or ($c.adr_id? | default "" | str contains $id)
|
||
})
|
||
|
||
# Cargo deps for the crate if feature maps to one
|
||
let crate_deps = (collect-crate-deps $root $id)
|
||
|
||
let data = {
|
||
id: $node.id,
|
||
name: ($node.name? | default $node.id),
|
||
level: ($node.level? | default ""),
|
||
pole: ($node.pole? | default ""),
|
||
description: ($node.description? | default ""),
|
||
invariant: ($node.invariant? | default false),
|
||
artifacts: $artifacts,
|
||
depends_on: $outgoing,
|
||
depended_by: $incoming,
|
||
dimensions: $related_dims,
|
||
constraints: $related_constraints,
|
||
crate_deps: $crate_deps,
|
||
}
|
||
|
||
emit-output $data $f { || render-feature-detail-text $data $root }
|
||
}
|
||
}
|
||
|
||
# ── describe guides ─────────────────────────────────────────────────────────────
|
||
# "Give me everything an actor needs to operate correctly in this project."
|
||
# Single deterministic JSON output: identity, axioms, practices, constraints,
|
||
# gate_state, dimensions, available_modes, actor_policy, language_guides,
|
||
# content_assets, templates, connections.
|
||
|
||
export def "describe guides" [
|
||
--actor: string = "", # Actor context: developer | agent | ci | admin
|
||
--fmt: string = "", # Output format: json | yaml | text (default json for agent, text otherwise)
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "json" }
|
||
|
||
let identity = (collect-identity $root)
|
||
let axioms = (collect-axioms $root)
|
||
let practices = (collect-practices $root)
|
||
let gates = (collect-gates $root)
|
||
let dimensions = (collect-dimensions $root)
|
||
let adrs = (collect-adr-summary $root)
|
||
let modes = (scan-reflection-modes $root)
|
||
let claude = (scan-claude-capabilities $root)
|
||
let manifest = (load-manifest-safe $root)
|
||
let conns = (collect-connections $root)
|
||
|
||
let constraints = (collect-constraint-summary $root)
|
||
let actor_policy = (derive-actor-policy $gates $a)
|
||
|
||
let content_assets = ($manifest.content_assets? | default [])
|
||
let templates = ($manifest.templates? | default [])
|
||
let manifest_capabilities = ($manifest.capabilities? | default [])
|
||
let manifest_requirements = ($manifest.requirements? | default [])
|
||
let manifest_critical_deps = ($manifest.critical_deps? | default [])
|
||
|
||
# Fetch API surface from daemon; empty list if daemon is not reachable.
|
||
let daemon_url = ($env.ONTOREF_DAEMON_URL? | default "http://127.0.0.1:7891")
|
||
let api_surface = do {
|
||
let r = (do { http get $"($daemon_url)/api/catalog" } | complete)
|
||
if $r.exit_code == 0 {
|
||
let resp = ($r.stdout | from json)
|
||
let all = ($resp.routes? | default [])
|
||
if ($a | is-not-empty) {
|
||
$all | where { |route| $route.actors | any { |act| $act == $a } }
|
||
} else {
|
||
$all
|
||
}
|
||
} else {
|
||
[]
|
||
}
|
||
}
|
||
|
||
let data = {
|
||
identity: $identity,
|
||
axioms: $axioms,
|
||
practices: $practices,
|
||
constraints: $constraints,
|
||
gate_state: $gates,
|
||
dimensions: $dimensions,
|
||
adrs: $adrs,
|
||
available_modes: $modes,
|
||
actor_policy: $actor_policy,
|
||
language_guides: $claude,
|
||
content_assets: $content_assets,
|
||
templates: $templates,
|
||
connections: $conns,
|
||
api_surface: $api_surface,
|
||
capabilities: $manifest_capabilities,
|
||
requirements: $manifest_requirements,
|
||
critical_deps: $manifest_critical_deps,
|
||
}
|
||
|
||
emit-output $data $f {||
|
||
print $"=== Project Guides: ($identity.name) [actor: ($a)] ==="
|
||
print ""
|
||
print $"Identity: ($identity.name) / ($identity.kind)"
|
||
print $"Axioms: ($axioms | length)"
|
||
print $"Practices: ($practices | length)"
|
||
print $"Modes: ($modes | length)"
|
||
print $"Gates: ($gates | length) active"
|
||
print $"Connections: ($conns | length)"
|
||
print $"API surface: ($api_surface | length) endpoints visible to actor"
|
||
print ""
|
||
print "Actor policy:"
|
||
print ($actor_policy | table)
|
||
print ""
|
||
print "Constraint summary:"
|
||
print ($constraints | table)
|
||
}
|
||
}
|
||
|
||
# ── describe api ────────────────────────────────────────────────────────────────
|
||
# "What HTTP endpoints does the daemon expose? How do I call them?"
|
||
# Queries GET /api/catalog from the daemon and renders the full surface.
|
||
|
||
export def "describe api" [
|
||
--actor: string = "", # Filter to endpoints whose actors include this role
|
||
--tag: string = "", # Filter by tag (e.g. "graph", "describe", "auth")
|
||
--auth: string = "", # Filter by auth level: none | viewer | admin
|
||
--fmt: string = "", # Output format: text* | json
|
||
]: nothing -> nothing {
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
let daemon_url = ($env.ONTOREF_DAEMON_URL? | default "http://127.0.0.1:7891")
|
||
|
||
let result = (do { http get $"($daemon_url)/api/catalog" } | complete)
|
||
if $result.exit_code != 0 {
|
||
print $" (ansi red)Daemon unreachable at ($daemon_url) — is it running?(ansi reset)"
|
||
return
|
||
}
|
||
|
||
let resp = ($result.stdout | from json)
|
||
mut routes = ($resp.routes? | default [])
|
||
|
||
if ($actor | is-not-empty) {
|
||
$routes = ($routes | where { |r| $r.actors | any { |act| $act == $actor } })
|
||
}
|
||
if ($tag | is-not-empty) {
|
||
$routes = ($routes | where { |r| $r.tags | any { |t| $t == $tag } })
|
||
}
|
||
if ($auth | is-not-empty) {
|
||
$routes = ($routes | where auth == $auth)
|
||
}
|
||
|
||
let data = { count: ($routes | length), routes: $routes }
|
||
|
||
emit-output $data $f { || render-api-text $data }
|
||
}
|
||
|
||
def render-api-text [data: record]: nothing -> nothing {
|
||
print $"(ansi white_bold)Daemon API surface(ansi reset) ($data.count) endpoints"
|
||
print ""
|
||
|
||
# Group by first tag for readable sectioning
|
||
let grouped = ($data.routes | group-by { |r| if ($r.tags | is-empty) { "other" } else { $r.tags | first } })
|
||
|
||
for section in ($grouped | transpose key value | sort-by key) {
|
||
print $"(ansi cyan_bold)── ($section.key | str upcase) ──────────────────────────────────────(ansi reset)"
|
||
for r in $section.value {
|
||
let auth_badge = match $r.auth {
|
||
"none" => $"(ansi dark_gray)[open](ansi reset)",
|
||
"viewer" => $"(ansi yellow)[viewer](ansi reset)",
|
||
"admin" => $"(ansi red)[admin](ansi reset)",
|
||
_ => $"(ansi dark_gray)[?](ansi reset)"
|
||
}
|
||
let actors_str = ($r.actors | str join ", ")
|
||
let feat = if ($r.feature | is-not-empty) { $" (ansi dark_gray)feature:($r.feature)(ansi reset)" } else { "" }
|
||
print $" (ansi white_bold)($r.method)(ansi reset) (ansi green)($r.path)(ansi reset) ($auth_badge)($feat)"
|
||
print $" ($r.description)"
|
||
if ($r.actors | is-not-empty) {
|
||
print $" (ansi dark_gray)actors: ($actors_str)(ansi reset)"
|
||
}
|
||
if ($r.params | is-not-empty) {
|
||
for p in $r.params {
|
||
let con = $"(ansi dark_gray)($p.constraint)(ansi reset)"
|
||
print $" (ansi dark_gray)· ($p.name) [($p.kind)] ($con) — ($p.description)(ansi reset)"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
}
|
||
|
||
# ── describe diff ───────────────────────────────────────────────────────────────
|
||
# "What changed in the ontology since the last commit?"
|
||
# Compares the current working-tree core.ncl against the HEAD-committed version.
|
||
# Outputs structured added/removed/changed diffs for nodes and edges.
|
||
|
||
export def "describe diff" [
|
||
--fmt: string = "", # Output format: text* | json
|
||
--file: string = "", # Ontology file to diff (relative to project root, default .ontology/core.ncl)
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let f = if ($fmt | is-not-empty) { $fmt } else { "text" }
|
||
let rel = if ($file | is-not-empty) { $file } else { ".ontology/core.ncl" }
|
||
|
||
let current = (load-ontology-safe $root)
|
||
let committed = (diff-export-committed $rel $root)
|
||
|
||
let curr_nodes = ($current.nodes? | default [] | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), level: ($n.level? | default ""), pole: ($n.pole? | default ""), invariant: ($n.invariant? | default false) } })
|
||
let comm_nodes = ($committed.nodes? | default [] | each { |n| { id: $n.id, name: ($n.name? | default ""), description: ($n.description? | default ""), level: ($n.level? | default ""), pole: ($n.pole? | default ""), invariant: ($n.invariant? | default false) } })
|
||
|
||
let curr_ids = ($curr_nodes | get id)
|
||
let comm_ids = ($comm_nodes | get id)
|
||
|
||
let nodes_added = ($curr_nodes | where { |n| not ($comm_ids | any { |id| $id == $n.id }) })
|
||
let nodes_removed = ($comm_nodes | where { |n| not ($curr_ids | any { |id| $id == $n.id }) })
|
||
|
||
# Nodes present in both — compare field by field.
|
||
let both_ids = ($curr_ids | where { |id| $comm_ids | any { |cid| $cid == $id } })
|
||
let nodes_changed = ($both_ids | each { |id|
|
||
let curr = ($curr_nodes | where id == $id | first)
|
||
let prev = ($comm_nodes | where id == $id | first)
|
||
if ($curr.name != $prev.name or $curr.description != $prev.description or $curr.level != $prev.level or $curr.pole != $prev.pole or $curr.invariant != $prev.invariant) {
|
||
{ id: $id, before: $prev, after: $curr }
|
||
} else {
|
||
null
|
||
}
|
||
} | compact)
|
||
|
||
let curr_edges = ($current.edges? | default [] | each { |e|
|
||
let ef = ($e.from? | default "")
|
||
let et = ($e.to? | default "")
|
||
let ek = ($e.kind? | default "")
|
||
{ key: $"($ef)->($et)[($ek)]", from: $ef, to: $et, kind: $ek }
|
||
})
|
||
let comm_edges = ($committed.edges? | default [] | each { |e|
|
||
let ef = ($e.from? | default "")
|
||
let et = ($e.to? | default "")
|
||
let ek = ($e.kind? | default "")
|
||
{ key: $"($ef)->($et)[($ek)]", from: $ef, to: $et, kind: $ek }
|
||
})
|
||
|
||
let curr_ekeys = ($curr_edges | get key)
|
||
let comm_ekeys = ($comm_edges | get key)
|
||
|
||
let edges_added = ($curr_edges | where { |e| not ($comm_ekeys | any { |k| $k == $e.key }) })
|
||
let edges_removed = ($comm_edges | where { |e| not ($curr_ekeys | any { |k| $k == $e.key }) })
|
||
|
||
let data = {
|
||
file: $rel,
|
||
nodes_added: $nodes_added,
|
||
nodes_removed: $nodes_removed,
|
||
nodes_changed: $nodes_changed,
|
||
edges_added: $edges_added,
|
||
edges_removed: $edges_removed,
|
||
summary: {
|
||
nodes_added: ($nodes_added | length),
|
||
nodes_removed: ($nodes_removed | length),
|
||
nodes_changed: ($nodes_changed | length),
|
||
edges_added: ($edges_added | length),
|
||
edges_removed: ($edges_removed | length),
|
||
},
|
||
}
|
||
|
||
emit-output $data $f { || render-diff-text $data }
|
||
}
|
||
|
||
def diff-export-committed [rel_path: string, root: string]: nothing -> record {
|
||
let ip = (nickel-import-path $root)
|
||
let show = (do { ^git -C $root show $"HEAD:($rel_path)" } | complete)
|
||
if $show.exit_code != 0 { return {} }
|
||
let mk = (do { ^mktemp } | complete)
|
||
if $mk.exit_code != 0 { return {} }
|
||
let tmp = ($mk.stdout | str trim)
|
||
$show.stdout | save --force $tmp
|
||
let r = (do { ^nickel export --format json --import-path $ip $tmp } | complete)
|
||
do { ^rm -f $tmp } | complete | ignore
|
||
if $r.exit_code != 0 { return {} }
|
||
$r.stdout | from json
|
||
}
|
||
|
||
def render-diff-text [data: record]: nothing -> nothing {
|
||
let s = $data.summary
|
||
let total = ($s.nodes_added + $s.nodes_removed + $s.nodes_changed + $s.edges_added + $s.edges_removed)
|
||
|
||
print $"(ansi white_bold)Ontology diff vs HEAD:(ansi reset) ($data.file)"
|
||
print ""
|
||
|
||
if $total == 0 {
|
||
print $" (ansi dark_gray)No changes — working tree matches HEAD.(ansi reset)"
|
||
return
|
||
}
|
||
|
||
if $s.nodes_added > 0 {
|
||
print $"(ansi green_bold)Nodes added ($s.nodes_added):(ansi reset)"
|
||
for n in $data.nodes_added {
|
||
print $" + (ansi green)($n.id)(ansi reset) [($n.level)] ($n.name)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if $s.nodes_removed > 0 {
|
||
print $"(ansi red_bold)Nodes removed ($s.nodes_removed):(ansi reset)"
|
||
for n in $data.nodes_removed {
|
||
print $" - (ansi red)($n.id)(ansi reset) [($n.level)] ($n.name)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if $s.nodes_changed > 0 {
|
||
print $"(ansi yellow_bold)Nodes changed ($s.nodes_changed):(ansi reset)"
|
||
for c in $data.nodes_changed {
|
||
print $" ~ (ansi yellow)($c.id)(ansi reset)"
|
||
if $c.before.name != $c.after.name {
|
||
print $" name: (ansi dark_gray)($c.before.name)(ansi reset) → ($c.after.name)"
|
||
}
|
||
if $c.before.description != $c.after.description {
|
||
let prev = ($c.before.description | str substring 0..60)
|
||
let curr = ($c.after.description | str substring 0..60)
|
||
print $" description: (ansi dark_gray)($prev)…(ansi reset) → ($curr)…"
|
||
}
|
||
if $c.before.level != $c.after.level {
|
||
print $" level: (ansi dark_gray)($c.before.level)(ansi reset) → ($c.after.level)"
|
||
}
|
||
if $c.before.pole != $c.after.pole {
|
||
print $" pole: (ansi dark_gray)($c.before.pole)(ansi reset) → ($c.after.pole)"
|
||
}
|
||
if $c.before.invariant != $c.after.invariant {
|
||
print $" invariant: (ansi dark_gray)($c.before.invariant)(ansi reset) → ($c.after.invariant)"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if $s.edges_added > 0 {
|
||
print $"(ansi cyan_bold)Edges added ($s.edges_added):(ansi reset)"
|
||
for e in $data.edges_added {
|
||
print $" + (ansi cyan)($e.from)(ansi reset) →[($e.kind)]→ (ansi cyan)($e.to)(ansi reset)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if $s.edges_removed > 0 {
|
||
print $"(ansi magenta_bold)Edges removed ($s.edges_removed):(ansi reset)"
|
||
for e in $data.edges_removed {
|
||
print $" - (ansi magenta)($e.from)(ansi reset) →[($e.kind)]→ (ansi magenta)($e.to)(ansi reset)"
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# ── Collectors ──────────────────────────────────────────────────────────────────
|
||
|
||
def collect-identity [root: string]: nothing -> record {
|
||
# From Cargo.toml or manifest
|
||
let cargo = $"($root)/Cargo.toml"
|
||
let name = if ($cargo | path exists) {
|
||
let cargo_data = (open $cargo)
|
||
if ($cargo_data | get -o package.name | is-not-empty) {
|
||
$cargo_data | get package.name
|
||
} else if ($cargo_data | get -o workspace | is-not-empty) {
|
||
$root | path basename
|
||
} else {
|
||
$root | path basename
|
||
}
|
||
} else {
|
||
$root | path basename
|
||
}
|
||
|
||
let manifest = (load-manifest-safe $root)
|
||
let kind = if ($manifest | is-not-empty) { $manifest.repo_kind? | default "" } else { "" }
|
||
let description = if ($manifest | is-not-empty) { $manifest.description? | default "" } else { "" }
|
||
|
||
{
|
||
name: $name,
|
||
kind: $kind,
|
||
description: $description,
|
||
root: $root,
|
||
has_ontology: ($"($root)/.ontology/core.ncl" | path exists),
|
||
has_adrs: ((glob $"($root)/adrs/adr-*.ncl" | length) > 0),
|
||
has_reflection: ($"($root)/reflection" | path exists),
|
||
has_manifest: ($"($root)/.ontology/manifest.ncl" | path exists),
|
||
has_coder: ($"($root)/.coder" | path exists),
|
||
}
|
||
}
|
||
|
||
def collect-axioms [root: string]: nothing -> list<record> {
|
||
let ontology = (load-ontology-safe $root)
|
||
if ($ontology | is-empty) { return [] }
|
||
$ontology.nodes? | default [] | where { |n|
|
||
($n.invariant? | default false) == true
|
||
} | each { |n| { id: $n.id, name: $n.name, description: $n.description } }
|
||
}
|
||
|
||
def collect-tensions [root: string]: nothing -> list<record> {
|
||
let ontology = (load-ontology-safe $root)
|
||
if ($ontology | is-empty) { return [] }
|
||
$ontology.nodes? | default [] | where { |n|
|
||
($n.level? | default "") == "Tension"
|
||
} | each { |n| { id: $n.id, name: $n.name, description: $n.description } }
|
||
}
|
||
|
||
def collect-practices [root: string]: nothing -> list<record> {
|
||
let ontology = (load-ontology-safe $root)
|
||
if ($ontology | is-empty) { return [] }
|
||
$ontology.nodes? | default [] | where { |n|
|
||
($n.level? | default "") == "Practice"
|
||
} | each { |n| {
|
||
id: $n.id,
|
||
name: $n.name,
|
||
description: $n.description,
|
||
artifact_paths: ($n.artifact_paths? | default []),
|
||
}}
|
||
}
|
||
|
||
def collect-gates [root: string]: nothing -> list<record> {
|
||
let gate_file = $"($root)/.ontology/gate.ncl"
|
||
if not ($gate_file | path exists) { return [] }
|
||
let ip = (nickel-import-path $root)
|
||
let gate = (daemon-export-safe $gate_file --import-path $ip)
|
||
if $gate == null { return [] }
|
||
$gate.membranes? | default [] | where { |m| ($m.active? | default false) == true }
|
||
| each { |m| {
|
||
id: $m.id,
|
||
name: $m.name,
|
||
permeability: ($m.permeability? | default ""),
|
||
accepts: ($m.accepts? | default []),
|
||
}}
|
||
}
|
||
|
||
def collect-dimensions [root: string]: nothing -> list<record> {
|
||
let state_file = $"($root)/.ontology/state.ncl"
|
||
if not ($state_file | path exists) { return [] }
|
||
let ip = (nickel-import-path $root)
|
||
let state = (daemon-export-safe $state_file --import-path $ip)
|
||
if $state == null { return [] }
|
||
$state.dimensions? | default [] | each { |d| {
|
||
id: $d.id,
|
||
name: $d.name,
|
||
current_state: $d.current_state,
|
||
desired_state: $d.desired_state,
|
||
horizon: ($d.horizon? | default ""),
|
||
reached: ($d.current_state == $d.desired_state),
|
||
}}
|
||
}
|
||
|
||
def collect-adr-summary [root: string]: nothing -> list<record> {
|
||
let files = (glob $"($root)/adrs/adr-*.ncl")
|
||
let ip = (nickel-import-path $root)
|
||
$files | each { |f|
|
||
let adr = (daemon-export-safe $f --import-path $ip)
|
||
if $adr != null {
|
||
{
|
||
id: ($adr.id? | default ""),
|
||
title: ($adr.title? | default ""),
|
||
status: ($adr.status? | default ""),
|
||
constraint_count: ($adr.constraints? | default [] | length),
|
||
}
|
||
} else { null }
|
||
} | compact
|
||
}
|
||
|
||
def collect-hard-constraints [root: string]: nothing -> list<record> {
|
||
let files = (glob $"($root)/adrs/adr-*.ncl")
|
||
let ip = (nickel-import-path $root)
|
||
$files | each { |f|
|
||
let adr = (daemon-export-safe $f --import-path $ip)
|
||
if $adr != null {
|
||
if ($adr.status? | default "") == "Accepted" {
|
||
let constraints = ($adr.constraints? | default [])
|
||
$constraints | where { |c| ($c.severity? | default "") == "Hard" }
|
||
| each { |c| {
|
||
adr_id: ($adr.id? | default ""),
|
||
check_hint: ($c.check_hint? | default ""),
|
||
description: ($c.description? | default ""),
|
||
}}
|
||
} else { [] }
|
||
} else { [] }
|
||
} | flatten
|
||
}
|
||
|
||
def collect-constraint-summary [root: string]: nothing -> list<record> {
|
||
let files = (glob $"($root)/adrs/adr-*.ncl")
|
||
let ip = (nickel-import-path $root)
|
||
$files | each { |f|
|
||
let adr = (daemon-export-safe $f --import-path $ip)
|
||
if $adr != null {
|
||
if ($adr.status? | default "") == "Accepted" {
|
||
let constraints = ($adr.constraints? | default [])
|
||
$constraints | each { |c| {
|
||
adr_id: ($adr.id? | default ""),
|
||
severity: ($c.severity? | default ""),
|
||
description: ($c.description? | default ""),
|
||
check_tag: ($c.check?.tag? | default ($c.check_hint? | default "")),
|
||
}}
|
||
} else { [] }
|
||
} else { [] }
|
||
} | flatten
|
||
}
|
||
|
||
def collect-connections [root: string]: nothing -> list<record> {
|
||
let conn_file = $"($root)/.ontology/connections.ncl"
|
||
if not ($conn_file | path exists) { return [] }
|
||
let ip = (nickel-import-path $root)
|
||
let conn = (daemon-export-safe $conn_file --import-path $ip)
|
||
if $conn == null { return [] }
|
||
$conn.connections? | default []
|
||
}
|
||
|
||
# Derive what an actor is allowed to do based on the active gate membranes.
|
||
# Permeability: Open → full access; Controlled/Locked → restricted; Closed → read-only.
|
||
def derive-actor-policy [gates: list<record>, actor: string]: nothing -> record {
|
||
let is_agent = ($actor == "agent")
|
||
let is_ci = ($actor == "ci")
|
||
|
||
# Find the most restrictive membrane that constrains the actor.
|
||
let permeabilities = ($gates | get -o permeability | compact | uniq)
|
||
|
||
let most_restrictive = if ($permeabilities | any { |p| $p == "Closed" }) {
|
||
"Closed"
|
||
} else if ($permeabilities | any { |p| $p == "Locked" }) {
|
||
"Locked"
|
||
} else if ($permeabilities | any { |p| $p == "Controlled" }) {
|
||
"Controlled"
|
||
} else {
|
||
"Open"
|
||
}
|
||
|
||
let base_open = ($most_restrictive == "Open")
|
||
let base_controlled = ($most_restrictive == "Controlled" or $most_restrictive == "Open")
|
||
|
||
{
|
||
actor: $actor,
|
||
gate_permeability: $most_restrictive,
|
||
can_read_ontology: true,
|
||
can_read_adrs: true,
|
||
can_read_manifest: true,
|
||
can_run_modes: (if $is_agent { $base_controlled } else { true }),
|
||
can_modify_adrs: (if ($is_agent or $is_ci) { $base_open } else { $base_controlled }),
|
||
can_modify_ontology: (if ($is_agent or $is_ci) { $base_open } else { $base_controlled }),
|
||
can_push_sync: (if $is_agent { false } else { $base_controlled }),
|
||
}
|
||
}
|
||
|
||
# ── Scanners ────────────────────────────────────────────────────────────────────
|
||
|
||
def scan-just-modules [root: string]: nothing -> list<record> {
|
||
let justfile = $"($root)/justfile"
|
||
if not ($justfile | path exists) { return [{ status: "no_justfile" }] }
|
||
let content = (open $justfile --raw)
|
||
let mod_lines = ($content | lines | where { |l| ($l | str starts-with "mod ") or ($l | str starts-with "mod? ") })
|
||
let import_lines = ($content | lines | where { |l| ($l | str starts-with "import ") or ($l | str starts-with "import? ") })
|
||
|
||
mut modules = []
|
||
for line in $mod_lines {
|
||
let parts = ($line | split row " " | where { |p| ($p | is-not-empty) })
|
||
let name = if ($parts | length) >= 2 { $parts | get 1 | str replace "?" "" } else { "unknown" }
|
||
$modules = ($modules | append { type: "mod", name: $name, line: $line })
|
||
}
|
||
for line in $import_lines {
|
||
let parts = ($line | split row "'" | where { |p| ($p | is-not-empty) })
|
||
let path = if ($parts | length) >= 2 { $parts | get 1 | str trim } else { "unknown" }
|
||
let name = ($path | path basename | str replace ".just" "")
|
||
$modules = ($modules | append { type: "import", name: $name, path: $path })
|
||
}
|
||
|
||
# Also check justfiles/ directory
|
||
let justfiles_dir = $"($root)/justfiles"
|
||
if ($justfiles_dir | path exists) {
|
||
let files = (glob $"($justfiles_dir)/*.just")
|
||
for f in $files {
|
||
let name = ($f | path basename | str replace ".just" "")
|
||
let already = ($modules | where { |m| $m.name == $name })
|
||
if ($already | is-empty) {
|
||
$modules = ($modules | append { type: "file_only", name: $name, path: ($f | str replace $root ".") })
|
||
}
|
||
}
|
||
}
|
||
|
||
$modules
|
||
}
|
||
|
||
def categorize-recipe [name: string]: nothing -> string {
|
||
if ($name | str starts-with "ci") { "ci" } else if ($name | str starts-with "build") { "build" } else if ($name | str starts-with "test") or ($name == "test") { "test" } else if ($name | str starts-with "doc") { "docs" } else if ($name | str starts-with "deploy") { "deploy" } else if ($name | str starts-with "nickel") { "nickel" } else if ($name | str starts-with "install") or ($name | str starts-with "release") or ($name | str starts-with "package") or ($name | str starts-with "dist") { "distro" } else if ($name in ["fmt", "format", "lint", "watch", "dev", "setup", "setup-hooks", "clean"]) or ($name | str starts-with "fmt") or ($name | str starts-with "lint") or ($name | str starts-with "watch") { "dev" } else { "other" }
|
||
}
|
||
|
||
def scan-just-recipes [root: string]: nothing -> list<record> {
|
||
let result = do { ^just --list --unsorted --justfile $"($root)/justfile" } | complete
|
||
if $result.exit_code != 0 { return [] }
|
||
$result.stdout | lines | where { |l| ($l | str trim | is-not-empty) and not ($l | str starts-with "Available") }
|
||
| each { |l|
|
||
let trimmed = ($l | str trim)
|
||
let parts = ($trimmed | split row " # ")
|
||
let name = ($parts | first | str trim)
|
||
let desc = if ($parts | length) > 1 { $parts | skip 1 | str join " # " | str trim } else { "" }
|
||
{ name: $name, category: (categorize-recipe $name), description: $desc }
|
||
}
|
||
}
|
||
|
||
def scan-ontoref-commands []: nothing -> list<string> {
|
||
[
|
||
"check", "form list", "form run",
|
||
"mode list", "mode show", "mode run", "mode select",
|
||
"adr list", "adr validate", "adr show", "adr accept",
|
||
"constraint", "register",
|
||
"backlog roadmap", "backlog list", "backlog add", "backlog done",
|
||
"config show", "config verify", "config audit", "config apply",
|
||
"sync scan", "sync diff", "sync propose", "sync apply", "sync audit",
|
||
"coder init", "coder record", "coder log", "coder export", "coder triage",
|
||
"manifest mode", "manifest publish", "manifest layers", "manifest consumers",
|
||
"describe project", "describe capabilities", "describe constraints",
|
||
"describe tools", "describe features", "describe impact", "describe why",
|
||
"describe guides", "describe diff", "describe api",
|
||
]
|
||
}
|
||
|
||
def scan-reflection-modes [root: string]: nothing -> list<record> {
|
||
let ontoref_modes = (glob $"($env.ONTOREF_ROOT)/reflection/modes/*.ncl")
|
||
let project_modes = if $root != $env.ONTOREF_ROOT {
|
||
glob $"($root)/reflection/modes/*.ncl"
|
||
} else { [] }
|
||
let all_modes = ($ontoref_modes | append $project_modes | uniq)
|
||
|
||
$all_modes | each { |f|
|
||
let mode_root = if ($f | str starts-with $root) and ($root != $env.ONTOREF_ROOT) { $root } else { $env.ONTOREF_ROOT }
|
||
let ip = (nickel-import-path $mode_root)
|
||
let m = (daemon-export-safe $f --import-path $ip)
|
||
if $m != null {
|
||
{
|
||
id: ($m.id? | default ""),
|
||
trigger: ($m.trigger? | default ""),
|
||
steps: ($m.steps? | default [] | length),
|
||
source: (if ($f | str starts-with $root) and ($root != $env.ONTOREF_ROOT) { "project" } else { "ontoref" }),
|
||
}
|
||
} else { null }
|
||
} | compact
|
||
}
|
||
|
||
def scan-claude-capabilities [root: string]: nothing -> record {
|
||
let claude_dir = $"($root)/.claude"
|
||
if not ($claude_dir | path exists) {
|
||
return { present: false }
|
||
}
|
||
|
||
let has_claude_md = ($"($claude_dir)/CLAUDE.md" | path exists)
|
||
let guidelines = (glob $"($claude_dir)/guidelines/*/" | each { |d| $d | path basename })
|
||
let commands = (glob $"($claude_dir)/commands/*.md" | length)
|
||
let skills = (glob $"($claude_dir)/skills/*/" | each { |d| $d | path basename })
|
||
let hooks = (glob $"($claude_dir)/hooks/*" | length)
|
||
let agents = (glob $"($claude_dir)/agents/*.md" | each { |f| $f | path basename | str replace ".md" "" })
|
||
let profiles = (glob $"($claude_dir)/profiles/*/" | each { |d| $d | path basename })
|
||
let stacks = (glob $"($claude_dir)/stacks/*/" | each { |d| $d | path basename })
|
||
|
||
{
|
||
present: true,
|
||
has_claude_md: $has_claude_md,
|
||
guidelines: $guidelines,
|
||
commands_count: $commands,
|
||
skills: $skills,
|
||
hooks_count: $hooks,
|
||
agents: $agents,
|
||
profiles: $profiles,
|
||
stacks: $stacks,
|
||
}
|
||
}
|
||
|
||
def scan-ci-tools [root: string]: nothing -> list<record> {
|
||
let config_path = $"($root)/.typedialog/ci/config.ncl"
|
||
if not ($config_path | path exists) { return [] }
|
||
let ip = (nickel-import-path $root)
|
||
let config = (daemon-export-safe $config_path --import-path $ip)
|
||
if $config == null { return [] }
|
||
let tools = ($config.ci?.tools? | default {})
|
||
$tools | transpose name cfg | each { |t| {
|
||
name: $t.name,
|
||
enabled: ($t.cfg.enabled? | default false),
|
||
install_method: ($t.cfg.install_method? | default "unknown"),
|
||
}}
|
||
}
|
||
|
||
def scan-manifest-modes [root: string]: nothing -> list<record> {
|
||
let manifest = (load-manifest-safe $root)
|
||
if ($manifest | is-empty) { return [] }
|
||
$manifest.operational_modes? | default [] | each { |m| {
|
||
id: ($m.id? | default ""),
|
||
description: ($m.description? | default ""),
|
||
audit_level: ($m.audit_level? | default ""),
|
||
}}
|
||
}
|
||
|
||
def scan-dev-tools [root: string]: nothing -> list<record> {
|
||
mut tools = []
|
||
if ($"($root)/bacon.toml" | path exists) {
|
||
$tools = ($tools | append { tool: "bacon", config: "bacon.toml", purpose: "File watcher with diagnostics export" })
|
||
}
|
||
if ($"($root)/.cargo/config.toml" | path exists) {
|
||
$tools = ($tools | append { tool: "cargo", config: ".cargo/config.toml", purpose: "Build system with custom aliases" })
|
||
}
|
||
if ($"($root)/.pre-commit-config.yaml" | path exists) {
|
||
$tools = ($tools | append { tool: "pre-commit", config: ".pre-commit-config.yaml", purpose: "Pre-commit hook runner" })
|
||
}
|
||
if ($"($root)/deny.toml" | path exists) or ($"($root)/.cargo/deny.toml" | path exists) {
|
||
$tools = ($tools | append { tool: "cargo-deny", config: "deny.toml", purpose: "License and advisory checker" })
|
||
}
|
||
if ($"($root)/rustfmt.toml" | path exists) or ($"($root)/.rustfmt.toml" | path exists) {
|
||
$tools = ($tools | append { tool: "rustfmt", config: "rustfmt.toml", purpose: "Code formatter" })
|
||
}
|
||
if ($"($root)/clippy.toml" | path exists) or ($"($root)/.clippy.toml" | path exists) {
|
||
$tools = ($tools | append { tool: "clippy", config: "clippy.toml", purpose: "Linter" })
|
||
}
|
||
$tools
|
||
}
|
||
|
||
def git-remote-slug [root: string]: nothing -> string {
|
||
let r = do { ^git -C $root remote get-url origin } | complete
|
||
if $r.exit_code != 0 { return "" }
|
||
let url = ($r.stdout | str trim)
|
||
if ($url | str contains "@") {
|
||
# git@host:owner/repo.git
|
||
$url | split row ":" | last | str replace -r '\.git$' "" | str trim
|
||
} else {
|
||
# https://host/owner/repo.git
|
||
let parts = ($url | split row "/" | last 2)
|
||
($parts | str join "/") | str replace -r '\.git$' ""
|
||
}
|
||
}
|
||
|
||
def scan-project-flags [root: string]: nothing -> record {
|
||
let cargo_toml = $"($root)/Cargo.toml"
|
||
let has_rust = ($cargo_toml | path exists)
|
||
|
||
let crates = if $has_rust {
|
||
let cargo = (open $cargo_toml)
|
||
let members = ($cargo | get -o workspace.members | default [])
|
||
if ($members | is-not-empty) {
|
||
$members | each { |m|
|
||
glob $"($root)/($m)/Cargo.toml"
|
||
} | flatten | each { |f|
|
||
let c = (open $f)
|
||
$c | get -o package.name | default ($f | path dirname | path basename)
|
||
}
|
||
} else {
|
||
let name = ($cargo | get -o package.name | default ($root | path basename))
|
||
[$name]
|
||
}
|
||
} else { [] }
|
||
|
||
let config_file = $"($root)/.ontoref/config.ncl"
|
||
let has_nats = if ($config_file | path exists) {
|
||
let ip = (nickel-import-path $root)
|
||
let cfg = (daemon-export-safe $config_file --import-path $ip)
|
||
if $cfg != null { $cfg.nats_events?.enabled? | default false } else { false }
|
||
} else { false }
|
||
|
||
let has_git = ($"($root)/.git" | path exists)
|
||
let git_slug = if $has_git { git-remote-slug $root } else { "" }
|
||
let has_git_remote = ($git_slug | is-not-empty)
|
||
|
||
let open_prs = if $has_git_remote and (which gh | is-not-empty) {
|
||
let r = do { ^gh pr list --repo $git_slug --state open --json number --jq length } | complete
|
||
if $r.exit_code == 0 { $r.stdout | str trim | into int } else { 0 }
|
||
} else { 0 }
|
||
|
||
{
|
||
has_rust: $has_rust,
|
||
has_ui: (($"($root)/templates" | path exists) or ($"($root)/assets" | path exists)),
|
||
has_mdbook: ($"($root)/docs/SUMMARY.md" | path exists),
|
||
has_nats: $has_nats,
|
||
has_precommit: ($"($root)/.pre-commit-config.yaml" | path exists),
|
||
has_backlog: ($"($root)/reflection/backlog.ncl" | path exists),
|
||
has_git_remote: $has_git_remote,
|
||
git_slug: $git_slug,
|
||
open_prs: $open_prs,
|
||
crates: $crates,
|
||
}
|
||
}
|
||
|
||
def count-backlog-pending [root: string]: nothing -> int {
|
||
let file = $"($root)/reflection/backlog.ncl"
|
||
if not ($file | path exists) { return 0 }
|
||
let ip = (nickel-import-path $root)
|
||
let backlog = (daemon-export-safe $file --import-path $ip)
|
||
if $backlog == null { return 0 }
|
||
($backlog.items? | default [])
|
||
| where { |i| ($i.status? | default "open") not-in ["done", "graduated"] }
|
||
| length
|
||
}
|
||
|
||
# ── Feature collectors ────────────────────────────────────────────────────────
|
||
|
||
def collect-cargo-features [root: string]: nothing -> list<record> {
|
||
let cargo_toml = $"($root)/Cargo.toml"
|
||
if not ($cargo_toml | path exists) { return [] }
|
||
let cargo = (open $cargo_toml)
|
||
let members = ($cargo | get -o workspace.members | default [])
|
||
|
||
if ($members | is-empty) {
|
||
let features = ($cargo | get -o features | default {})
|
||
if ($features | columns | is-empty) { return [] }
|
||
$features | transpose name deps | each { |f| {
|
||
crate: ($cargo | get -o package.name | default ($root | path basename)),
|
||
feature: $f.name,
|
||
enables: ($f.deps | str join ", "),
|
||
}}
|
||
} else {
|
||
mut result = []
|
||
for member in $members {
|
||
let expanded = (glob $"($root)/($member)/Cargo.toml")
|
||
for ct in $expanded {
|
||
let c = (open $ct)
|
||
let cname = ($c | get -o package.name | default ($ct | path dirname | path basename))
|
||
let features = ($c | get -o features | default {})
|
||
if ($features | columns | is-not-empty) {
|
||
let feats = ($features | transpose name deps | each { |f| {
|
||
crate: $cname,
|
||
feature: $f.name,
|
||
enables: ($f.deps | str join ", "),
|
||
}})
|
||
$result = ($result | append $feats)
|
||
}
|
||
}
|
||
}
|
||
$result
|
||
}
|
||
}
|
||
|
||
def collect-crate-deps [root: string, feature_id: string]: nothing -> list<record> {
|
||
let cargo_toml = $"($root)/Cargo.toml"
|
||
if not ($cargo_toml | path exists) { return [] }
|
||
let cargo = (open $cargo_toml)
|
||
let members = ($cargo | get -o workspace.members | default [])
|
||
|
||
# Try to find a crate whose name matches or contains the feature_id
|
||
let id_parts = ($feature_id | split row "-")
|
||
|
||
mut found_deps = []
|
||
let tomls = if ($members | is-empty) {
|
||
[$cargo_toml]
|
||
} else {
|
||
mut paths = []
|
||
for member in $members {
|
||
let expanded = (glob $"($root)/($member)/Cargo.toml")
|
||
$paths = ($paths | append $expanded)
|
||
}
|
||
$paths
|
||
}
|
||
|
||
for ct in $tomls {
|
||
let c = (open $ct)
|
||
let cname = ($c | get -o package.name | default ($ct | path dirname | path basename))
|
||
let cname_parts = ($cname | split row "-")
|
||
# Match if feature_id contains crate name or vice versa
|
||
let is_match = ($cname | str contains $feature_id) or ($feature_id | str contains $cname) or (($id_parts | where { |p| $p in $cname_parts }) | is-not-empty)
|
||
if $is_match {
|
||
let deps = ($c | get -o dependencies | default {})
|
||
$found_deps = ($found_deps | append ($deps | transpose name spec | each { |d|
|
||
let version = if ($d.spec | describe | str starts-with "string") {
|
||
$d.spec
|
||
} else {
|
||
$d.spec | get -o version | default ""
|
||
}
|
||
{ crate: $cname, dep: $d.name, version: $version }
|
||
}))
|
||
}
|
||
}
|
||
$found_deps
|
||
}
|
||
|
||
# ── Loaders ─────────────────────────────────────────────────────────────────────
|
||
|
||
def load-ontology-safe [root: string]: nothing -> record {
|
||
let core = $"($root)/.ontology/core.ncl"
|
||
if not ($core | path exists) { return {} }
|
||
let ip = (nickel-import-path $root)
|
||
daemon-export-safe $core --import-path $ip | default {}
|
||
}
|
||
|
||
def load-manifest-safe [root: string]: nothing -> record {
|
||
let manifest = $"($root)/.ontology/manifest.ncl"
|
||
if not ($manifest | path exists) { return {} }
|
||
let ip = (nickel-import-path $root)
|
||
daemon-export-safe $manifest --import-path $ip | default {}
|
||
}
|
||
|
||
def load-all-adrs [root: string]: nothing -> list<record> {
|
||
let files = (glob $"($root)/adrs/adr-*.ncl")
|
||
let ip = (nickel-import-path $root)
|
||
$files | each { |f|
|
||
daemon-export-safe $f --import-path $ip
|
||
} | compact
|
||
}
|
||
|
||
def list-ontology-extensions [root: string]: nothing -> list<string> {
|
||
let dir = $"($root)/.ontology"
|
||
let core = ["core.ncl", "state.ncl", "gate.ncl"]
|
||
glob ($dir | path join "*.ncl")
|
||
| each { |f| $f | path basename }
|
||
| where { |f| $f not-in $core }
|
||
| each { |f| $f | str replace ".ncl" "" }
|
||
| sort
|
||
}
|
||
|
||
def load-ontology-extension [root: string, stem: string]: nothing -> any {
|
||
let file = $"($root)/.ontology/($stem).ncl"
|
||
if not ($file | path exists) { return null }
|
||
let ip = (nickel-import-path $root)
|
||
daemon-export-safe $file --import-path $ip
|
||
}
|
||
|
||
# ── Impact tracer ───────────────────────────────────────────────────────────────
|
||
|
||
def trace-impacts [
|
||
node_id: string,
|
||
edges: list<record>,
|
||
nodes: list<record>,
|
||
max_depth: int,
|
||
]: nothing -> list<record> {
|
||
# Depth-1: direct neighbors
|
||
let depth1 = (find-neighbors $node_id $edges $nodes [$node_id] 1)
|
||
|
||
if $max_depth < 2 or ($depth1 | is-empty) {
|
||
return ($depth1 | sort-by depth id)
|
||
}
|
||
|
||
# Depth-2: neighbors of depth-1 nodes
|
||
let visited_after_1 = ([$node_id] | append ($depth1 | get id))
|
||
let depth2 = ($depth1 | each { |d1|
|
||
find-neighbors $d1.id $edges $nodes $visited_after_1 2
|
||
} | flatten)
|
||
|
||
if $max_depth < 3 or ($depth2 | is-empty) {
|
||
return ($depth1 | append $depth2 | sort-by depth id)
|
||
}
|
||
|
||
# Depth-3: neighbors of depth-2 nodes
|
||
let visited_after_2 = ($visited_after_1 | append ($depth2 | get id))
|
||
let depth3 = ($depth2 | each { |d2|
|
||
find-neighbors $d2.id $edges $nodes $visited_after_2 3
|
||
} | flatten)
|
||
|
||
$depth1 | append $depth2 | append $depth3 | sort-by depth id
|
||
}
|
||
|
||
def find-neighbors [
|
||
current: string,
|
||
edges: list<record>,
|
||
nodes: list<record>,
|
||
visited: list<string>,
|
||
depth: int,
|
||
]: nothing -> list<record> {
|
||
let outgoing = ($edges | where from == $current)
|
||
let incoming = ($edges | where to == $current)
|
||
let candidates = ($outgoing | each { |e| { target: $e.to, edge_type: $e.kind, direction: "outgoing" } })
|
||
| append ($incoming | each { |e| { target: $e.from, edge_type: $e.kind, direction: "incoming" } })
|
||
|
||
$candidates | where { |n| not ($n.target in $visited) }
|
||
| each { |n|
|
||
let target_node = ($nodes | where id == $n.target)
|
||
let label = if ($target_node | is-not-empty) { ($target_node | first).name } else { $n.target }
|
||
{
|
||
id: $n.target,
|
||
name: $label,
|
||
edge_type: $n.edge_type,
|
||
direction: $n.direction,
|
||
depth: $depth,
|
||
}
|
||
}
|
||
}
|
||
|
||
# ── Renderers ───────────────────────────────────────────────────────────────────
|
||
|
||
def render-project-text [data: record, actor: string, root: string]: nothing -> nothing {
|
||
let name = $data.identity.name
|
||
print ""
|
||
print $"($name)"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
if ($data.identity.description | is-not-empty) {
|
||
print $data.identity.description
|
||
print ""
|
||
}
|
||
if ($data.identity.kind | is-not-empty) {
|
||
print $"Kind: ($data.identity.kind)"
|
||
}
|
||
|
||
# Systems present
|
||
let systems = [
|
||
(if $data.identity.has_ontology { "ontology" } else { null }),
|
||
(if $data.identity.has_adrs { "ADRs" } else { null }),
|
||
(if $data.identity.has_reflection { "reflection" } else { null }),
|
||
(if $data.identity.has_manifest { "manifest" } else { null }),
|
||
(if $data.identity.has_coder { ".coder" } else { null }),
|
||
] | compact
|
||
print $"Systems: ($systems | str join ', ')"
|
||
print ""
|
||
|
||
if ($data.axioms | is-not-empty) {
|
||
print "INVARIANTS (non-negotiable)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for ax in $data.axioms {
|
||
print $" ■ ($ax.name)"
|
||
print $" ($ax.description)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if $actor == "auditor" or $actor == "developer" {
|
||
if ($data.tensions | is-not-empty) {
|
||
print "TENSIONS (active trade-offs)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for t in $data.tensions {
|
||
print $" ⇄ ($t.name)"
|
||
print $" ($t.description)"
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
if ($data.dimensions | is-not-empty) {
|
||
print "STATE DIMENSIONS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for d in $data.dimensions {
|
||
let marker = if $d.reached { "●" } else { "○" }
|
||
print $" ($marker) ($d.name): ($d.current_state) → ($d.desired_state)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.gates | is-not-empty) {
|
||
print "ACTIVE GATES (protected boundaries)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for g in $data.gates {
|
||
print $" ⊘ ($g.name) [permeability: ($g.permeability)]"
|
||
if ($g.accepts | is-not-empty) {
|
||
print $" Accepts: ($g.accepts | str join ', ')"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.adrs | is-not-empty) {
|
||
print "ARCHITECTURAL DECISIONS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for adr in $data.adrs {
|
||
let status_marker = match $adr.status { "Accepted" => "✓", "Proposed" => "?", _ => "×" }
|
||
print $" ($status_marker) ($adr.id): ($adr.title) [($adr.constraint_count) constraints]"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.practices | is-not-empty) and ($actor != "ci") {
|
||
print "PRACTICES & SYSTEMS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for p in $data.practices {
|
||
print $" ◆ ($p.name)"
|
||
if ($p.artifact_paths | is-not-empty) {
|
||
print $" Artifacts: ($p.artifact_paths | str join ', ')"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
def render-capabilities-text [data: record, actor: string, root: string]: nothing -> nothing {
|
||
print ""
|
||
print "CAPABILITIES"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
let flags = ($data.project_flags? | default {})
|
||
if ($flags | is-not-empty) {
|
||
print ""
|
||
print "PROJECT FLAGS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
let flag_pairs = [
|
||
["has_rust", "Rust"],
|
||
["has_ui", "UI (templates/assets)"],
|
||
["has_mdbook", "mdBook (docs/SUMMARY.md)"],
|
||
["has_nats", "NATS events"],
|
||
["has_precommit", "pre-commit hooks"],
|
||
["has_backlog", "backlog (reflection/backlog.ncl)"],
|
||
["has_git_remote", "git remote"],
|
||
]
|
||
for pair in $flag_pairs {
|
||
let key = ($pair | first)
|
||
let label = ($pair | last)
|
||
let val = ($flags | get -o $key | default false)
|
||
let mark = if $val { "✓" } else { "○" }
|
||
print $" ($mark) ($label)"
|
||
}
|
||
let crates = ($flags.crates? | default [])
|
||
if ($crates | is-not-empty) {
|
||
print $" Crates: ($crates | str join ', ')"
|
||
}
|
||
if ($data.backlog_pending? | default 0) > 0 {
|
||
print $" Backlog pending: ($data.backlog_pending)"
|
||
}
|
||
let open_prs = ($flags.open_prs? | default 0)
|
||
if $open_prs > 0 {
|
||
print $" Open PRs: ($open_prs) [($flags.git_slug? | default '')]"
|
||
}
|
||
}
|
||
|
||
if ($data.just_recipes? | default [] | is-not-empty) {
|
||
print ""
|
||
print "JUST RECIPES (by category)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
let by_cat = ($data.just_recipes | group-by category)
|
||
for cat in ($by_cat | columns | sort) {
|
||
let recipes = ($by_cat | get $cat)
|
||
print $" [($cat)]"
|
||
for r in $recipes {
|
||
let desc = if ($r.description | is-not-empty) { $" — ($r.description)" } else { "" }
|
||
print $" ($r.name)($desc)"
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($data.just_modules | is-not-empty) {
|
||
print ""
|
||
print "JUST MODULES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for m in $data.just_modules {
|
||
if ($m.status? | default "") == "no_justfile" {
|
||
print " (no root justfile found)"
|
||
} else {
|
||
let prefix = if ($m.type? | default "") == "mod" { "mod" } else if ($m.type? | default "") == "import" { "import" } else { "file" }
|
||
print $" [($prefix)] ($m.name)"
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($data.manifest_modes | is-not-empty) {
|
||
print ""
|
||
print "PROJECT MODES (manifest)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for m in $data.manifest_modes {
|
||
print $" ./onref manifest mode ($m.id)"
|
||
if ($m.description | is-not-empty) { print $" ($m.description)" }
|
||
}
|
||
}
|
||
|
||
if ($data.reflection_modes | is-not-empty) {
|
||
print ""
|
||
print "OPERATIONAL MODES (reflection)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for m in $data.reflection_modes {
|
||
let src = if $m.source == "project" { " [local]" } else { "" }
|
||
print $" ./onref mode ($m.id)($src)"
|
||
if ($m.trigger | is-not-empty) { print $" ($m.trigger)" }
|
||
}
|
||
}
|
||
|
||
if $actor == "agent" {
|
||
print ""
|
||
print "ONTOREF COMMANDS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for cmd in $data.ontoref_commands {
|
||
print $" ./onref ($cmd)"
|
||
}
|
||
}
|
||
|
||
if ($data.claude_capabilities.present? | default false) {
|
||
let cc = $data.claude_capabilities
|
||
print ""
|
||
print ".CLAUDE CAPABILITIES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
print $" CLAUDE.md: (if $cc.has_claude_md { 'yes' } else { 'no' })"
|
||
if ($cc.guidelines | is-not-empty) {
|
||
print $" Guidelines: ($cc.guidelines | str join ', ')"
|
||
}
|
||
print $" Commands: ($cc.commands_count)"
|
||
if ($cc.skills | is-not-empty) {
|
||
print $" Skills: ($cc.skills | str join ', ')"
|
||
}
|
||
if ($cc.agents | is-not-empty) {
|
||
print $" Agents: ($cc.agents | str join ', ')"
|
||
}
|
||
print $" Hooks: ($cc.hooks_count)"
|
||
if ($cc.profiles | is-not-empty) {
|
||
print $" Profiles: ($cc.profiles | str join ', ')"
|
||
}
|
||
}
|
||
|
||
if ($data.ci_tools | is-not-empty) and ($actor == "ci" or $actor == "agent") {
|
||
print ""
|
||
print "CI TOOLS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for t in ($data.ci_tools | where enabled == true) {
|
||
print $" ✓ ($t.name) [($t.install_method)]"
|
||
}
|
||
let disabled = ($data.ci_tools | where enabled == false)
|
||
if ($disabled | is-not-empty) {
|
||
for t in $disabled {
|
||
print $" ○ ($t.name) [disabled]"
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($data.manifest_capabilities? | default [] | is-not-empty) {
|
||
print ""
|
||
print "PROJECT CAPABILITIES (manifest)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for c in ($data.manifest_capabilities? | default []) {
|
||
print $" ($c.name): ($c.summary)"
|
||
if ($c.artifacts? | default [] | is-not-empty) {
|
||
print $" Artifacts: ($c.artifacts | str join ', ')"
|
||
}
|
||
}
|
||
}
|
||
|
||
print ""
|
||
}
|
||
|
||
def render-constraints-text [data: record, actor: string]: nothing -> nothing {
|
||
print ""
|
||
print "CONSTRAINTS"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
if ($data.invariants | is-not-empty) {
|
||
print ""
|
||
print "INVARIANTS (cannot be violated)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for ax in $data.invariants {
|
||
print $" ■ ($ax.name)"
|
||
print $" ($ax.description)"
|
||
}
|
||
}
|
||
|
||
if ($data.hard_constraints | is-not-empty) {
|
||
print ""
|
||
print "HARD CONSTRAINTS (from Accepted ADRs)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for c in $data.hard_constraints {
|
||
print $" [($c.adr_id)] ($c.description)"
|
||
if ($c.check_hint | is-not-empty) {
|
||
print $" Check: ($c.check_hint)"
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($data.active_gates | is-not-empty) {
|
||
print ""
|
||
print "ACTIVE GATES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for g in $data.active_gates {
|
||
print $" ⊘ ($g.name) [($g.permeability)]"
|
||
if ($g.accepts | is-not-empty) {
|
||
print $" Only accepts: ($g.accepts | str join ', ')"
|
||
}
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
def render-tools-text [data: record, actor: string, root: string]: nothing -> nothing {
|
||
print ""
|
||
print "TOOLS"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
if ($data.dev_tools | is-not-empty) {
|
||
print ""
|
||
print "DEV TOOLS (detected from config files)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for t in $data.dev_tools {
|
||
print $" ($t.tool) — ($t.purpose)"
|
||
print $" Config: ($t.config)"
|
||
}
|
||
}
|
||
|
||
if ($data.ci_tools | is-not-empty) {
|
||
print ""
|
||
print "CI TOOLS (from .typedialog/ci/config.ncl)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for t in ($data.ci_tools | where enabled == true) {
|
||
print $" ✓ ($t.name) via ($t.install_method)"
|
||
}
|
||
}
|
||
|
||
if ($data.just_recipes | is-not-empty) {
|
||
print ""
|
||
let relevant = match $actor {
|
||
"ci" => { $data.just_recipes | where { |r| ($r.name | str starts-with "ci") or ($r.name | str contains "::ci") } },
|
||
"developer" => { $data.just_recipes | where { |r| ($r.name | str starts-with "dev") or ($r.name | str starts-with "build") or ($r.name | str starts-with "test") or ($r.name | str contains "::dev") or ($r.name | str contains "::build") or ($r.name | str contains "::test") } },
|
||
_ => { $data.just_recipes },
|
||
}
|
||
|
||
if ($relevant | is-not-empty) {
|
||
let actor_label = $"JUST RECIPES [($actor)]"
|
||
print $actor_label
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for r in $relevant {
|
||
if ($r.description | is-not-empty) {
|
||
print $" just ($r.name) # ($r.description)"
|
||
} else {
|
||
print $" just ($r.name)"
|
||
}
|
||
}
|
||
} else {
|
||
print "JUST RECIPES (all)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for r in $data.just_recipes {
|
||
print $" just ($r.name)"
|
||
}
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
def render-impact-text [data: record]: nothing -> nothing {
|
||
let src = $data.source
|
||
print ""
|
||
print $"IMPACT ANALYSIS: ($src.name? | default $src.id)"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
if ($src.description? | is-not-empty) {
|
||
print $src.description
|
||
}
|
||
if ($src.artifact_paths? | default [] | is-not-empty) {
|
||
print $"Artifacts: ($src.artifact_paths | str join ', ')"
|
||
}
|
||
print ""
|
||
|
||
if ($data.impacts | is-not-empty) {
|
||
print "AFFECTED NODES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for i in $data.impacts {
|
||
let arrow = if $i.direction == "outgoing" { "→" } else { "←" }
|
||
let indent = (0..<($i.depth) | each { " " } | str join "")
|
||
print $"($indent)($arrow) ($i.name) [($i.edge_type)] depth=($i.depth)"
|
||
}
|
||
} else {
|
||
print " No connected nodes found."
|
||
}
|
||
print ""
|
||
}
|
||
|
||
def render-why-text [data: record, id: string]: nothing -> nothing {
|
||
print ""
|
||
print $"WHY: ($id)"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
if ($data.node != null) {
|
||
let n = $data.node
|
||
print ""
|
||
print $"Ontology node: ($n.name? | default $n.id)"
|
||
print $"Level: ($n.level? | default 'unknown')"
|
||
print $"Pole: ($n.pole? | default 'unknown')"
|
||
print $"Invariant: ($n.invariant? | default false)"
|
||
print ""
|
||
print ($n.description? | default "")
|
||
print ""
|
||
|
||
if ($n.artifact_paths? | default [] | is-not-empty) {
|
||
print $"Artifacts: ($n.artifact_paths | str join ', ')"
|
||
print ""
|
||
}
|
||
}
|
||
|
||
if ($data.adr != null) {
|
||
let a = $data.adr
|
||
print "DECISION (ADR)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
print $" ($a.id? | default ''): ($a.titulo? | default ($a.title? | default ''))"
|
||
print $" Status: ($a.status? | default '')"
|
||
if ($a.contexto? | is-not-empty) { print $" Context: ($a.contexto)" }
|
||
if ($a.decision? | is-not-empty) { print $" Decision: ($a.decision)" }
|
||
if ($a.rationale? | is-not-empty) { print $" Rationale: ($a.rationale)" }
|
||
print ""
|
||
|
||
let constraints = ($a.constraints? | default [])
|
||
if ($constraints | is-not-empty) {
|
||
print " Constraints:"
|
||
for c in $constraints {
|
||
let sev = ($c.severity? | default "")
|
||
let desc = ($c.description? | default "")
|
||
print $" [($sev)] ($desc)"
|
||
if ($c.check_hint? | is-not-empty) {
|
||
print $" Check: ($c.check_hint)"
|
||
}
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.edges_from | is-not-empty) {
|
||
print "CONNECTIONS (outgoing)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for e in $data.edges_from {
|
||
print $" → ($e.to) [($e.kind)]"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.edges_to | is-not-empty) {
|
||
print "CONNECTIONS (incoming)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for e in $data.edges_to {
|
||
print $" ← ($e.from) [($e.kind)]"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.node == null) and ($data.adr == null) {
|
||
print $" No ontology node or ADR found with id '($id)'."
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# ── Feature renderers ────────────────────────────────────────────────────────
|
||
|
||
def render-features-list-text [data: record, root: string]: nothing -> nothing {
|
||
let name = ($root | path basename)
|
||
print ""
|
||
print $"FEATURES — ($name)"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
|
||
if ($data.features | is-not-empty) {
|
||
print ""
|
||
for f in $data.features {
|
||
let art_count = $f.artifacts
|
||
let art_label = if $art_count > 0 { $" ($art_count) artifacts" } else { "" }
|
||
let level_val_label = $"[($f.level)]"
|
||
print $" ◆ ($f.id) ($level_val_label)($art_label)"
|
||
print $" ($f.name)"
|
||
if ($f.description | is-not-empty) {
|
||
# Truncate to first sentence for list view
|
||
let first_sentence = ($f.description | split row "." | first)
|
||
let desc = if ($first_sentence | str length) > 100 {
|
||
$"($first_sentence | str substring 0..99)…"
|
||
} else {
|
||
$first_sentence
|
||
}
|
||
print $" ($desc)"
|
||
}
|
||
}
|
||
} else {
|
||
print " No ontology features found."
|
||
}
|
||
|
||
if ($data.cargo_features | is-not-empty) {
|
||
print ""
|
||
print "CARGO FEATURES (compile-time)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
let grouped = ($data.cargo_features | group-by crate)
|
||
for group in ($grouped | transpose crate_name feats) {
|
||
print $" ($group.crate_name)"
|
||
for cf in $group.feats {
|
||
let enables = if ($cf.enables | is-not-empty) { $" → ($cf.enables)" } else { "" }
|
||
print $" · ($cf.feature)($enables)"
|
||
}
|
||
}
|
||
}
|
||
|
||
print ""
|
||
print $" Use 'describe features <id>' for details on a specific feature."
|
||
print ""
|
||
}
|
||
|
||
def render-feature-detail-text [data: record, root: string]: nothing -> nothing {
|
||
print ""
|
||
print $"($data.name)"
|
||
print "══════════════════════════════════════════════════════════════════"
|
||
print $" id: ($data.id)"
|
||
print $" level: ($data.level)"
|
||
print $" pole: ($data.pole)"
|
||
let inv_label = if $data.invariant { "yes" } else { "no" }
|
||
print $" invariant: ($inv_label)"
|
||
print ""
|
||
|
||
if ($data.description | is-not-empty) {
|
||
print $data.description
|
||
print ""
|
||
}
|
||
|
||
if ($data.artifacts | is-not-empty) {
|
||
print "ARTIFACTS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for a in $data.artifacts {
|
||
let marker = if $a.exists { "✓" } else { "✗" }
|
||
print $" ($marker) ($a.path)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.depends_on | is-not-empty) {
|
||
print "DEPENDS ON (outgoing edges)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for d in $data.depends_on {
|
||
print $" → ($d.name) [($d.relation)]"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.depended_by | is-not-empty) {
|
||
print "DEPENDED BY (incoming edges)"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for d in $data.depended_by {
|
||
print $" ← ($d.name) [($d.relation)]"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.dimensions | is-not-empty) {
|
||
print "RELATED STATE DIMENSIONS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for d in $data.dimensions {
|
||
let marker = if $d.reached { "●" } else { "○" }
|
||
print $" ($marker) ($d.id): ($d.current_state) → ($d.desired_state)"
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.constraints | is-not-empty) {
|
||
print "RELATED CONSTRAINTS"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
for c in $data.constraints {
|
||
print $" [($c.adr_id)] ($c.description)"
|
||
if ($c.check_hint | is-not-empty) {
|
||
print $" Check: ($c.check_hint)"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
|
||
if ($data.crate_deps | is-not-empty) {
|
||
print "CRATE DEPENDENCIES"
|
||
print "──────────────────────────────────────────────────────────────────"
|
||
let grouped = ($data.crate_deps | group-by crate)
|
||
for group in ($grouped | transpose crate_name deps) {
|
||
print $" ($group.crate_name)"
|
||
for d in $group.deps {
|
||
let ver = if ($d.version | is-not-empty) { $" ($d.version)" } else { "" }
|
||
print $" ($d.dep)($ver)"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# ── describe connections ──────────────────────────────────────────────────────
|
||
# "How does this project connect to other projects?"
|
||
|
||
export def "describe connections" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let conn_path = $"($root)/.ontology/connections.ncl"
|
||
|
||
if not ($conn_path | path exists) {
|
||
if $f == "json" {
|
||
print ({"upstream": [], "downstream": [], "peers": []} | to json)
|
||
} else {
|
||
print "No connections.ncl found in .ontology/"
|
||
}
|
||
return
|
||
}
|
||
|
||
let data = (daemon-export-safe $conn_path)
|
||
|
||
if $data == null {
|
||
if $f == "json" {
|
||
print ({"upstream": [], "downstream": [], "peers": [], "error": "export failed"} | to json)
|
||
} else {
|
||
print "Failed to export connections.ncl"
|
||
}
|
||
return
|
||
}
|
||
|
||
emit-output $data $f {||
|
||
let up = ($data | get upstream | default [])
|
||
let down = ($data | get downstream | default [])
|
||
let peer = ($data | get peers | default [])
|
||
|
||
print ""
|
||
print $"(ansi white_bold)PROJECT CONNECTIONS(ansi reset)"
|
||
print $"(ansi dark_gray)─────────────────────────────────(ansi reset)"
|
||
|
||
if ($up | is-not-empty) {
|
||
print $"\n(ansi cyan_bold)Upstream(ansi reset) (consumes from)"
|
||
for c in $up {
|
||
print $" → (ansi white)($c.project)(ansi reset) (ansi dark_gray)[($c.kind)](ansi reset) ($c.note? | default '')"
|
||
}
|
||
}
|
||
if ($down | is-not-empty) {
|
||
print $"\n(ansi cyan_bold)Downstream(ansi reset) (consumed by)"
|
||
for c in $down {
|
||
print $" → (ansi white)($c.project)(ansi reset) (ansi dark_gray)[($c.kind)](ansi reset) ($c.note? | default '')"
|
||
}
|
||
}
|
||
if ($peer | is-not-empty) {
|
||
print $"\n(ansi cyan_bold)Peers(ansi reset) (co-developed or sibling)"
|
||
for c in $peer {
|
||
print $" → (ansi white)($c.project)(ansi reset) (ansi dark_gray)[($c.kind)](ansi reset) ($c.note? | default '')"
|
||
}
|
||
}
|
||
if ($up | is-empty) and ($down | is-empty) and ($peer | is-empty) {
|
||
print " (no connections declared)"
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
|
||
# Coerce any NCL value to a plain string safe for a GFM table cell.
|
||
# Uses `to json` throughout — accepts any input type including nothing.
|
||
def md-cell []: any -> any {
|
||
let value = $in
|
||
let t = ($value | describe)
|
||
if ($t | str starts-with "table") or ($t | str starts-with "list") {
|
||
$value | to json | str replace -ar '^\[|\]$' '' | str replace -a '"' '' | str trim
|
||
} else if ($t | str starts-with "record") {
|
||
$value | to json
|
||
} else if $t == "nothing" {
|
||
""
|
||
} else {
|
||
$value | to json | str replace -ar '^"|"$' ''
|
||
}
|
||
}
|
||
|
||
# Render one value as a markdown section body (no heading).
|
||
def render-val-md [val: any]: nothing -> any {
|
||
if $val == null { return "" }
|
||
let t = ($val | describe)
|
||
if ($t | str starts-with "table") {
|
||
# Render each record as vertical key: value block, separated by ---
|
||
let cols = ($val | columns)
|
||
$val | each { |row|
|
||
$cols | each { |c|
|
||
let v = ($row | get --optional $c)
|
||
let cell = if $v == null { "" } else { $v | md-cell }
|
||
$"**($c)**: ($cell) "
|
||
} | str join "\n"
|
||
} | str join "\n\n---\n\n"
|
||
} else if ($t | str starts-with "list") {
|
||
if ($val | is-empty) {
|
||
"_empty_"
|
||
} else {
|
||
# split row returns list<string> which each can accept; avoids each on any-typed val
|
||
$val | to json | str replace -ar '^\[|\]$' '' | str replace -a '"' '' | str trim
|
||
| split row ", " | each { |item| $"- ($item | str trim)" } | str join "\n"
|
||
}
|
||
} else if ($t | str starts-with "record") {
|
||
$val | columns | each { |c|
|
||
let raw = ($val | get $c)
|
||
let v = if $raw == null { "" } else { $raw | md-cell }
|
||
$"- **($c)**: ($v)"
|
||
} | str join "\n"
|
||
} else {
|
||
$val | to json | str replace -ar '^"|"$' ''
|
||
}
|
||
}
|
||
|
||
# Try to render a section via a Tera template at {root}/layouts/{stem}/{section}.tera.
|
||
# Returns the rendered string if the template exists, null otherwise.
|
||
def render-section-tera [root: string, stem: string, section: string, val: any]: nothing -> any {
|
||
let tmpl = $"($root)/layouts/($stem)/($section).tera"
|
||
if not ($tmpl | path exists) { return null }
|
||
let t = ($val | describe)
|
||
let ctx = if ($t | str starts-with "table") or ($t | str starts-with "list") {
|
||
{items: $val}
|
||
} else {
|
||
$val
|
||
}
|
||
$ctx | tera-render $tmpl
|
||
}
|
||
|
||
# Render an arbitrary extension record as Markdown, using Tera templates when available.
|
||
def render-extension-md [data: record, stem: string, root: string]: nothing -> any {
|
||
let sections = ($data | columns | each { |key|
|
||
let val = ($data | get $key)
|
||
let body = (
|
||
render-section-tera $root $stem $key $val
|
||
| default (render-val-md $val)
|
||
)
|
||
$"\n## ($key)\n\n($body)\n"
|
||
})
|
||
([$"# ($stem)"] | append $sections | str join "\n")
|
||
}
|
||
|
||
# List and optionally dump ontology extension files (.ontology/*.ncl beyond core/state/gate)
|
||
export def "describe extensions" [
|
||
--fmt: string = "",
|
||
--actor: string = "",
|
||
--dump: string = "", # stem to dump (e.g. career, personal); omit to list
|
||
--clip, # copy output to clipboard (dump only)
|
||
]: nothing -> nothing {
|
||
let root = (project-root)
|
||
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
|
||
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
|
||
|
||
let exts = (list-ontology-extensions $root)
|
||
|
||
if ($dump | is-not-empty) {
|
||
let data = (load-ontology-extension $root $dump)
|
||
if $data == null {
|
||
if $f == "json" {
|
||
print ({"error": $"extension '($dump)' not found"} | to json)
|
||
} else {
|
||
print $"Extension '($dump).ncl' not found in .ontology/"
|
||
}
|
||
return
|
||
}
|
||
|
||
let is_rec = ($data | describe | str starts-with "record")
|
||
let wrapped = if $is_rec { $data } else { {value: $data} }
|
||
|
||
match $f {
|
||
"md" => {
|
||
let md = (render-extension-md $wrapped $dump $root)
|
||
if $clip { $md | clip } else { print $md }
|
||
},
|
||
"json" => { print ($wrapped | to json) },
|
||
"yaml" => { print ($wrapped | to yaml) },
|
||
_ => {
|
||
emit-output $wrapped $f {||
|
||
print ""
|
||
print $"(ansi white_bold)EXTENSION: ($dump)(ansi reset)"
|
||
print $"(ansi dark_gray)─────────────────────────────────(ansi reset)"
|
||
for key in ($wrapped | columns) {
|
||
let val = ($wrapped | get $key)
|
||
let t = ($val | describe)
|
||
print $"\n(ansi cyan_bold)($key)(ansi reset)"
|
||
if ($t | str starts-with "list") {
|
||
if ($val | is-empty) {
|
||
print " (empty)"
|
||
} else if (($val | first | describe) | str starts-with "record") {
|
||
print ($val | table)
|
||
} else {
|
||
for item in $val { print $" · ($item)" }
|
||
}
|
||
} else if ($t | str starts-with "record") {
|
||
print ($val | table)
|
||
} else {
|
||
print $" ($val)"
|
||
}
|
||
}
|
||
print ""
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
let payload = {extensions: $exts}
|
||
emit-output $payload $f {||
|
||
print ""
|
||
print $"(ansi white_bold)ONTOLOGY EXTENSIONS(ansi reset)"
|
||
print $"(ansi dark_gray)─────────────────────────────────(ansi reset)"
|
||
if ($exts | is-empty) {
|
||
print " (no extensions — only core/state/gate declared)"
|
||
} else {
|
||
for stem in $exts {
|
||
print $" (ansi cyan)◆(ansi reset) ($stem).ncl"
|
||
}
|
||
print ""
|
||
print $"(ansi dark_gray)Use --dump <stem> to inspect a specific extension(ansi reset)"
|
||
}
|
||
print ""
|
||
}
|
||
}
|