2026-03-13 00:21:04 +00:00
|
|
|
#!/usr/bin/env nu
|
|
|
|
|
# reflection/modules/backlog.nu — backlog management commands.
|
|
|
|
|
|
|
|
|
|
use env.nu *
|
|
|
|
|
use store.nu [daemon-export-safe]
|
|
|
|
|
|
|
|
|
|
export def "backlog list" [
|
|
|
|
|
--status: string = "",
|
|
|
|
|
--kind: string = "",
|
|
|
|
|
--fmt: string = "",
|
|
|
|
|
] {
|
|
|
|
|
let f = if ($fmt | is-not-empty) { $fmt } else { "table" }
|
|
|
|
|
let items = (backlog-load)
|
|
|
|
|
let rows = if ($status | is-not-empty) {
|
|
|
|
|
$items | where status == $status
|
|
|
|
|
} else if ($kind | is-not-empty) {
|
|
|
|
|
$items | where kind == $kind
|
|
|
|
|
} else {
|
|
|
|
|
$items | where status != "Done" | where status != "Cancelled"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let display = $rows | select id title kind priority status | sort-by priority
|
|
|
|
|
match $f {
|
|
|
|
|
"json" => { $display | to json },
|
|
|
|
|
"md" => { $display | to md },
|
|
|
|
|
_ => { $display | table },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog show" [id: string] {
|
|
|
|
|
let item = (backlog-load) | where id == $id | first
|
|
|
|
|
print ""
|
|
|
|
|
print $"[($item.status)] ($item.id) — ($item.title)"
|
|
|
|
|
print $"Kind: ($item.kind) Priority: ($item.priority)"
|
|
|
|
|
if ($item.detail? | default "" | str length) > 0 { print ""; print $item.detail }
|
|
|
|
|
let adrs = ($item.related_adrs? | default [])
|
|
|
|
|
let modes = ($item.related_modes? | default [])
|
|
|
|
|
if ($adrs | is-not-empty) { print $"ADRs: ($adrs | str join ', ')" }
|
|
|
|
|
if ($modes | is-not-empty) { print $"Modes: ($modes | str join ', ')" }
|
|
|
|
|
if ($item.related_dim? | is-not-empty) { print $"Dimension: ($item.related_dim)" }
|
|
|
|
|
if ($item.graduates_to? | is-not-empty) { print $"Graduates to: ($item.graduates_to)" }
|
|
|
|
|
print ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog add" [
|
|
|
|
|
title: string,
|
|
|
|
|
--kind: string = "Todo",
|
|
|
|
|
--priority: string = "Medium",
|
|
|
|
|
--detail: string = "",
|
|
|
|
|
--dim: string = "",
|
|
|
|
|
--adr: string = "",
|
|
|
|
|
--mode: string = "",
|
|
|
|
|
] {
|
|
|
|
|
let root = (backlog-root)
|
|
|
|
|
let file = $"($root)/reflection/backlog.ncl"
|
|
|
|
|
if not ($file | path exists) {
|
|
|
|
|
print $"No backlog.ncl at ($file)"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let items = (backlog-load)
|
|
|
|
|
let next_num = if ($items | is-empty) { 1 } else {
|
|
|
|
|
($items | each { |i| $i.id | str replace "bl-" "" | into int } | sort | last) + 1
|
|
|
|
|
}
|
|
|
|
|
let new_id = $"bl-($next_num | fill --alignment right --width 3 --character '0')"
|
|
|
|
|
let today = (date now | format date "%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
let adrs_list = if ($adr | str length) > 0 { [$adr] } else { [] }
|
|
|
|
|
let modes_list = if ($mode | str length) > 0 { [$mode] } else { [] }
|
|
|
|
|
|
|
|
|
|
print ""
|
|
|
|
|
print $"New backlog item: ($new_id)"
|
|
|
|
|
print $" title = ($title)"
|
|
|
|
|
print $" kind = ($kind)"
|
|
|
|
|
print $" priority = ($priority)"
|
|
|
|
|
print ""
|
|
|
|
|
print $"Add to reflection/backlog.ncl:"
|
|
|
|
|
print $" \{"
|
|
|
|
|
print $" id = \"($new_id)\","
|
|
|
|
|
print $" title = \"($title)\","
|
|
|
|
|
print $" kind = '($kind),"
|
|
|
|
|
print $" priority = '($priority),"
|
|
|
|
|
print $" status = 'Open,"
|
|
|
|
|
print $" detail = \"($detail)\","
|
|
|
|
|
print $" created = \"($today)\","
|
|
|
|
|
print $" updated = \"($today)\","
|
|
|
|
|
print $" \},"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog done" [id: string] {
|
|
|
|
|
backlog-set-status $id "Done"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog cancel" [id: string] {
|
|
|
|
|
backlog-set-status $id "Cancelled"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog promote" [id: string] {
|
|
|
|
|
let item = (backlog-load) | where id == $id | first
|
|
|
|
|
|
|
|
|
|
print ""
|
|
|
|
|
print $"Promoting ($item.id): ($item.title)"
|
|
|
|
|
print $"Graduates to: ($item.graduates_to? | default 'unset')"
|
|
|
|
|
print ""
|
|
|
|
|
|
|
|
|
|
let target = ($item.graduates_to? | default "")
|
|
|
|
|
if $target == "Adr" {
|
|
|
|
|
let flag = if ($item.priority == "Critical" or $item.priority == "High") { "-a " } else { "" }
|
|
|
|
|
print "Run in a Claude session:"
|
|
|
|
|
print $" /create-adr ($flag)\"($item.title)\""
|
|
|
|
|
} else if $target == "Mode" {
|
|
|
|
|
let mode_id = ($item.title | str downcase | str replace --all " " "-" | str replace --all --regex '[^a-z0-9-]' "")
|
|
|
|
|
print $" ontoref register → affects_capability=true, mode_id=($mode_id)"
|
|
|
|
|
} else if $target == "StateTransition" {
|
|
|
|
|
let dim = ($item.related_dim? | default "")
|
|
|
|
|
print " ontoref register → changes_ontology_state=true"
|
|
|
|
|
if ($dim | str length) > 0 { print $" dimension_id: ($dim)" }
|
|
|
|
|
} else if $target == "PrItem" {
|
|
|
|
|
print " ontoref mode create-pr"
|
|
|
|
|
} else {
|
|
|
|
|
print " No graduation target set. Edit backlog.ncl to add graduates_to."
|
|
|
|
|
}
|
|
|
|
|
print ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export def "backlog roadmap" [] {
|
|
|
|
|
let root = (backlog-root)
|
|
|
|
|
let bl = (backlog-load) | where status != "Done" | where status != "Cancelled"
|
|
|
|
|
|
|
|
|
|
let state_file = $"($root)/.ontology/state.ncl"
|
|
|
|
|
let dims = if ($state_file | path exists) {
|
|
|
|
|
let data = (daemon-export-safe $state_file)
|
|
|
|
|
if $data != null { $data | get dimensions } else { [] }
|
|
|
|
|
} else { [] }
|
|
|
|
|
|
|
|
|
|
print ""
|
|
|
|
|
print "═══ ROADMAP ═══════════════════════════════════════"
|
|
|
|
|
|
|
|
|
|
if ($dims | is-not-empty) {
|
|
|
|
|
print ""
|
|
|
|
|
print "STATE DIMENSIONS"
|
|
|
|
|
for d in $dims {
|
|
|
|
|
if $d.current_state != $d.desired_state {
|
|
|
|
|
print $" ($d.id) ($d.current_state) → ($d.desired_state) [($d.horizon)]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for p in ["Critical", "High", "Medium", "Low"] {
|
|
|
|
|
let p_items = $bl | where priority == $p
|
|
|
|
|
if ($p_items | is-not-empty) {
|
|
|
|
|
print ""
|
|
|
|
|
print ($p | str upcase)
|
|
|
|
|
for i in $p_items {
|
|
|
|
|
let tag = if $i.status == "InProgress" { "[~]" } else { "[ ]" }
|
|
|
|
|
print $" ($tag) ($i.id) ($i.title) [($i.kind)]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
print ""
|
|
|
|
|
print "═══════════════════════════════════════════════════"
|
|
|
|
|
print ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 08:32:50 +01:00
|
|
|
# Propose a status change for a backlog item (requires admin approval).
|
|
|
|
|
# Sends a backlog_review notification to the daemon. An admin approves or
|
|
|
|
|
# rejects it via the UI notifications page or via `backlog approve`.
|
|
|
|
|
export def "backlog propose-status" [
|
|
|
|
|
id: string, # Item id (e.g. bl-001)
|
|
|
|
|
status: string, # Proposed status: Open | InProgress | Done | Cancelled
|
|
|
|
|
--by: string = "", # Actor label shown in the notification
|
|
|
|
|
--slug: string = "", # Project slug (defaults to primary)
|
|
|
|
|
] {
|
|
|
|
|
if not (daemon-available) {
|
|
|
|
|
print " error: daemon unavailable — cannot propose status"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let url = $"(daemon-url)/backlog/propose-status"
|
|
|
|
|
let body = {
|
|
|
|
|
id: $id,
|
|
|
|
|
proposed_status: $status,
|
|
|
|
|
proposed_by: $by,
|
|
|
|
|
slug: (if $slug == "" { null } else { $slug }),
|
|
|
|
|
} | to json
|
|
|
|
|
let auth = (bearer-args)
|
|
|
|
|
let r = do { ^curl -sf -X POST -H "Content-Type: application/json" ...$auth -d $body $url } | complete
|
|
|
|
|
if $r.exit_code != 0 {
|
|
|
|
|
print $" error: propose-status failed\n ($r.stderr)"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let resp = ($r.stdout | from json)
|
|
|
|
|
print $" ⏳ proposed ($resp.item_id) → ($resp.proposed_status) — awaiting admin approval"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Approve a pending backlog_review notification by notification id.
|
|
|
|
|
# Equivalent to clicking "Approve" in the UI notifications page.
|
|
|
|
|
export def "backlog approve" [
|
|
|
|
|
notif_id: int, # Notification id (from `backlog pending` or the UI)
|
|
|
|
|
--slug: string = "", # Project slug (defaults to primary)
|
|
|
|
|
] {
|
|
|
|
|
if not (daemon-available) {
|
|
|
|
|
print " error: daemon unavailable"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let s = if $slug == "" { (daemon-url | str replace "//" "//x" | split row "/" | get 0) } else { $slug }
|
|
|
|
|
# Resolve slug from config if not provided
|
|
|
|
|
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
|
|
|
|
|
let resolved_slug = if $slug == "" {
|
|
|
|
|
let cfg_path = $"($root)/.ontoref/config.ncl"
|
|
|
|
|
if ($cfg_path | path exists) {
|
|
|
|
|
do { ^nickel export $cfg_path } | complete
|
|
|
|
|
| get stdout | from json | get slug? | default "ontoref"
|
|
|
|
|
} else { "ontoref" }
|
|
|
|
|
} else { $slug }
|
|
|
|
|
|
|
|
|
|
let url = $"(daemon-url | str replace --regex '/$' '')/ui/($resolved_slug)/notifications/($notif_id)/action"
|
|
|
|
|
let auth = (bearer-args)
|
|
|
|
|
let r = do { ^curl -sf -X POST -H "Content-Type: application/x-www-form-urlencoded" ...$auth -d "action_id=approve" $url } | complete
|
|
|
|
|
if $r.exit_code != 0 {
|
|
|
|
|
print $" error: approve failed\n ($r.stderr)"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
print $" ✓ notification ($notif_id) approved"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# List pending backlog_review notifications awaiting approval.
|
|
|
|
|
export def "backlog pending" [
|
|
|
|
|
--slug: string = "",
|
|
|
|
|
] {
|
|
|
|
|
if not (daemon-available) { return [] }
|
|
|
|
|
let url = $"(daemon-url)/notifications/pending"
|
|
|
|
|
let auth = (bearer-args)
|
|
|
|
|
let r = do { ^curl -sf ...$auth $url } | complete
|
|
|
|
|
if $r.exit_code != 0 { return [] }
|
|
|
|
|
let all = ($r.stdout | from json | get notifications? | default [])
|
|
|
|
|
$all | where { |n| ($n.custom_kind? | default "") == "backlog_review" }
|
|
|
|
|
| select id custom_title timestamp? source_actor?
|
|
|
|
|
| rename id title timestamp actor
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 00:21:04 +00:00
|
|
|
# ── Internal ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def backlog-root []: nothing -> string {
|
|
|
|
|
$env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def backlog-load [] {
|
|
|
|
|
let file = $"($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)/reflection/backlog.ncl"
|
|
|
|
|
if not ($file | path exists) { return [] }
|
|
|
|
|
let data = (daemon-export-safe $file)
|
|
|
|
|
if $data == null { return [] }
|
|
|
|
|
$data | get items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def backlog-set-status [id: string, new_status: string] {
|
|
|
|
|
let file = $"($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)/reflection/backlog.ncl"
|
|
|
|
|
let today = (date now | format date "%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
let lines = (open --raw $file | lines)
|
|
|
|
|
|
|
|
|
|
# Find the line index of the id field for this item
|
|
|
|
|
let id_pattern = $"\"($id)\""
|
|
|
|
|
let id_line = ($lines | enumerate | where { |r| $r.item | str contains $id_pattern } | first)
|
|
|
|
|
if ($id_line | is-empty) {
|
|
|
|
|
print $" error: id '($id)' not found in ($file)"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let start = $id_line.index
|
|
|
|
|
|
|
|
|
|
# Scan forward from the id line to find the closing `},` of this item record.
|
|
|
|
|
# Track brace depth: the id line is already inside the outer record (depth=1 going in).
|
|
|
|
|
mut depth = 0
|
|
|
|
|
mut end_line = $start
|
|
|
|
|
for i in ($start..($lines | length | $in - 1)) {
|
|
|
|
|
let l = ($lines | get $i)
|
|
|
|
|
let opens = ($l | split chars | where $it == "{" | length)
|
|
|
|
|
let closes = ($l | split chars | where $it == "}" | length)
|
|
|
|
|
$depth = $depth + $opens - $closes
|
|
|
|
|
if $depth < 0 {
|
|
|
|
|
$end_line = $i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Bind end_line to immutable before closure (Nu forbids capturing mut vars in closures)
|
|
|
|
|
let end_idx = $end_line
|
|
|
|
|
|
|
|
|
|
# Patch status and updated within the located range [start, end_idx]
|
|
|
|
|
let patched = ($lines | enumerate | each { |r|
|
|
|
|
|
if $r.index >= $start and $r.index <= $end_idx {
|
|
|
|
|
$r.item
|
|
|
|
|
| str replace --regex "status[[:space:]]*=[[:space:]]*'[A-Za-z]+" $"status = '($new_status)"
|
|
|
|
|
| str replace --regex "updated[[:space:]]*=[[:space:]]*\"[^\"]*\"" $"updated = \"($today)\""
|
|
|
|
|
} else {
|
|
|
|
|
$r.item
|
|
|
|
|
}
|
|
|
|
|
} | str join "\n")
|
|
|
|
|
|
|
|
|
|
$patched | save --force $file
|
|
|
|
|
|
|
|
|
|
let tc = do { ^nickel typecheck $file } | complete
|
|
|
|
|
if $tc.exit_code != 0 {
|
|
|
|
|
print " error: typecheck failed — reverting"
|
|
|
|
|
do { ^git checkout -- $file } | complete | ignore
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
print $" ($id) → ($new_status)"
|
|
|
|
|
}
|