Jesús Pérez 13b03d6edf
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
feat: mode guards, convergence, manifest coverage, doc authoring pattern
## Mode guards and convergence loops (ADR-011)

  - `Guard` and `Converge` types added to `reflection/schema.ncl` and
    `reflection/defaults.ncl`. Guards run pre-flight checks (Block/Warn);
    converge loops iterate until a condition is met (RetryFailed/RetryAll).
  - `sync-ontology.ncl`: 3 guards + converge (zero-drift condition, max 2 iter).
  - `coder-workflow.ncl`: guard (coder-dir-exists) + `novelty-check` step.
  - Rust types in `ontoref-reflection/src/mode.rs`; executor in `executor.rs`
    evaluates guards before steps and convergence loop after.
  - `adrs/adr-011-mode-guards-and-convergence.ncl` added.

  ## Manifest capability completeness

  - `.ontology/manifest.ncl`: 3 → 19 declared capabilities covering the full
    action surface (daemon API, modes, Task Composer, QA, bookmarks, etc.).
  - `sync.nu`: `audit-manifest-coverage` + `sync manifest-check` command.
  - `validate-project.ncl`: 6th category `manifest-cov`.
  - Pre-commit hook `manifest-coverage` added.
  - Migrations `0010-manifest-capability-completeness`,
    `0011-manifest-coverage-hooks`.

  ## Rust doc authoring pattern — canonical `///` convention

  - `#[onto_api]`: `description = "..."` optional when `///` doc comment exists
    above handler — first line used as fallback. `#[derive(OntologyNode)]` same.
  - `ontoref-daemon/src/api.rs`: 42 handlers migrated to `///` doc comments;
    `description = "..."` removed from all `#[onto_api]` blocks.
  - `sync diff --docs --fail-on-drift`: exits 1 on crate `//!` drift; used by
    new `docs-drift` pre-commit hook. `docs-links` hook checks rustdoc broken links.
  - `generator.nu`: mdBook `crates/` chapter — per-crate page from `//!` doc,
    coverage badge, feature flags, implementing practice nodes.
  - `.claude/CLAUDE.md`: `### Documentation Authoring (Rust)` section added.
  - Migration `0012-rust-doc-authoring-pattern`.

  ## OntologyNode derive fixes

  - `#[derive(OntologyNode)]`: `name` and `paths` attributes supported; `///`
    doc fallback for `description`; `artifact_paths` correctly populated.
  - `Core::from_value` calls `merge_contributors()` behind `#[cfg(feature = "derive")]`.

  ## Bug fixes

  - `sync.nu` drift check: exact crate path match (not `str starts-with`);
    first-path-only rule; split on `. ` not `.` to avoid `.ontology/` truncation.
  - `find-unclaimed-artifacts`: fixed absolute vs relative path comparison.
  - Rustdoc broken intra-doc links fixed across all three crates.
  - `ci-docs` recipe now sets `RUSTDOCFLAGS` and actually fails on errors.

  mode guards/converge, manifest coverage validation, 19 capabilities (ADR-011)

  Extend the mode schema with Guard (pre-flight Block/Warn checks) and Converge
  (RetryFailed/RetryAll post-execution loops) — protocol pushes back on invalid
  state and iterates until convergence. ADR-011 records the decision to extend
  modes rather than create a separate action subsystem.

  Manifest expanded from 3 to 19 capabilities covering the full action surface
  (compose, plans, backlog graduation, notifications, coder pipeline, forms,
  templates, drift, quick actions, migrations, config, onboarding). New
  audit-manifest-coverage validator + pre-commit hook + SessionStart hook
  ensure agents always see complete project self-description.

  Bug fix: find-unclaimed-artifacts absolute vs relative path comparison —
  19 phantom MISSING items resolved. Health 43% → 100%.

  Anti-slop: coder novelty-check step (Jaccard overlap against published+QA)
  inserted between triage and publish in coder-workflow.

  Justfile restructured into 5 modules (build/test/dev/ci/assets).
  Migrations 0010-0011 propagate requirements to consumer projects.
2026-03-30 19:08:25 +01:00

404 lines
15 KiB
Plaintext

#!/usr/bin/env nu
# reflection/modules/run.nu — step execution tracking and run lifecycle.
# Provides: run start, step report, run status
#
# Storage: .coder/<actor>/runs/<run_id>/
# run.json — run metadata
# steps.jsonl — one JSON record per reported step
# current.json — pointer to active run (in .coder/<actor>/runs/)
use ../modules/store.nu [daemon-export-safe]
use ../modules/describe.nu [nickel-import-path]
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 runs-dir []: nothing -> string {
let root = (project-root)
let actor = ($env.ONTOREF_ACTOR? | default "developer")
$"($root)/.coder/($actor)/runs"
}
def current-run-file []: nothing -> string {
$"(runs-dir)/current.json"
}
def load-current-run []: nothing -> record {
let f = (current-run-file)
if not ($f | path exists) { return {} }
open $f
}
def load-run-steps [run_id: string]: nothing -> list<record> {
let f = $"(runs-dir)/($run_id)/steps.jsonl"
if not ($f | path exists) { return [] }
open $f | lines | where { |l| $l | str trim | is-not-empty } | each { |l| $l | from json }
}
def find-mode-file [mode: string]: nothing -> string {
let root = (project-root)
let project_file = $"($root)/reflection/modes/($mode).ncl"
let ontoref_file = $"($env.ONTOREF_ROOT)/reflection/modes/($mode).ncl"
if ($project_file | path exists) { $project_file } else if ($ontoref_file | path exists) { $ontoref_file } else { "" }
}
def load-mode-dag [mode: string]: nothing -> record {
let root = (project-root)
let mode_file = (find-mode-file $mode)
if ($mode_file | is-empty) {
error make { msg: $"Mode '($mode)' not found in project or ontoref reflection/modes/" }
}
let mode_root = if ($mode_file | str starts-with $root) and ($root != $env.ONTOREF_ROOT) {
$root
} else {
$env.ONTOREF_ROOT
}
let ip = (nickel-import-path $mode_root)
let result = (daemon-export-safe $mode_file --import-path $ip)
if $result == null {
error make { msg: $"Failed to export mode '($mode)' — check NCL syntax and import paths" }
}
$result
}
def now-iso []: nothing -> string {
date now | format date "%Y-%m-%dT%H:%M:%SZ"
}
def run-id-for [mode: string, task: string]: nothing -> string {
if ($task | is-not-empty) {
$"($mode)-($task)"
} else {
let ts = (date now | format date "%Y%m%dT%H%M%S")
$"($mode)-($ts)"
}
}
# Start a new run for a mode. Establishes run context for subsequent `step report` calls.
export def "run start" [
mode: string, # Mode ID (must exist in reflection/modes/)
--task (-t): string = "", # Backlog task ID to associate with this run
--fmt (-f): string = "",
]: nothing -> nothing {
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
let mode_data = (load-mode-dag $mode)
let run_id = (run-id-for $mode $task)
let dir = $"(runs-dir)/($run_id)"
mkdir $dir
let step_count = ($mode_data.steps? | default [] | length)
let state = {
run_id: $run_id,
mode: $mode,
task: $task,
actor: $actor,
started_at: (now-iso),
steps_total: $step_count,
}
$state | to json | save --force $"($dir)/run.json"
$state | to json | save --force (current-run-file)
# Seed empty steps.jsonl so append works consistently
"" | save --force $"($dir)/steps.jsonl"
if $fmt == "json" {
print ($state | to json)
} else {
print $"Run started: ($run_id)"
print $" Mode: ($mode) ($step_count) steps"
if ($task | is-not-empty) { print $" Task: ($task)" }
print $" Dir: ($dir)"
print $" Next: ontoref step report ($mode) <step_id> --status pass|fail"
}
}
# Report the result of a step. Validates step exists and dependencies are satisfied.
export def "step report" [
mode: string, # Mode ID
step_id: string, # Step ID to report
--status (-s): string, # pass | fail | skip
--exit-code (-e): int = 0,
--artifacts (-a): list<string> = [],
--warnings (-w): int = 0,
--run (-r): string = "", # Override run ID (default: active run)
--fmt (-f): string = "",
]: nothing -> nothing {
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
if not ($status in ["pass", "fail", "skip"]) {
error make { msg: $"Invalid status '($status)'. Must be: pass | fail | skip" }
}
# Resolve run context
let current = (load-current-run)
let run_id = if ($run | is-not-empty) {
$run
} else if ($current | is-not-empty) and ($current.mode? | default "") == $mode {
$current.run_id? | default ""
} else {
error make { msg: $"No active run for mode '($mode)'. Start one with: ontoref run start ($mode)" }
}
let dir = $"(runs-dir)/($run_id)"
if not ($dir | path exists) {
error make { msg: $"Run directory not found: ($dir). Start with: ontoref run start ($mode)" }
}
let steps_file = $"($dir)/steps.jsonl"
# Validate step exists in mode DAG
let mode_data = (load-mode-dag $mode)
# Validate guards — warn if any Block guard would fail (informational in step report context)
let guards = ($mode_data.guards? | default [])
let blocking_guards = ($guards | where { |g| ($g.severity? | default "Block") == "Block" })
for g in $blocking_guards {
let result = (do { bash -c $g.cmd } | complete)
if $result.exit_code != 0 {
if $fmt != "json" {
print $"(ansi yellow)WARN(ansi reset): guard '($g.id)' would block mode execution: ($g.reason)"
}
}
}
let matching_steps = ($mode_data.steps? | default [] | where { |s| $s.id == $step_id })
let step_def = if ($matching_steps | is-not-empty) { $matching_steps | first } else { null }
if ($step_def | is-empty) {
error make { msg: $"Step '($step_id)' not found in mode '($mode)'. Valid steps: ($mode_data.steps? | default [] | each { |s| $s.id } | str join ', ')" }
}
# Validate blocking dependencies are satisfied
let completed = (load-run-steps $run_id)
let blocking = ($step_def.depends_on? | default [] | where { |d|
($d.kind? | default "Always") in ["Always", "OnSuccess"]
})
for dep in $blocking {
let prev_list = ($completed | where { |r| $r.step == $dep.step })
let prev = if ($prev_list | is-not-empty) { $prev_list | first } else { null }
if ($prev | is-empty) {
error make { msg: $"Step '($step_id)' requires '($dep.step)' to be reported first" }
}
if ($dep.kind? | default "Always") == "OnSuccess" and ($prev.status? | default "") != "pass" {
error make { msg: $"Step '($step_id)' depends on '($dep.step)' with OnSuccess, but it ($prev.status? | default 'unknown')" }
}
}
let entry = {
run_id: $run_id,
mode: $mode,
step: $step_id,
status: $status,
exit_code: $exit_code,
warnings: $warnings,
artifacts: $artifacts,
actor: $actor,
ts: (now-iso),
}
$"\n($entry | to json -r)" | save --append $steps_file
# Emit side-effect on fail+Stop
let on_error_strategy = ($step_def.on_error?.strategy? | default "Stop")
if $status == "fail" and $on_error_strategy == "Stop" {
if $fmt != "json" {
print $"(ansi red)BLOCKED(ansi reset): step '($step_id)' failed with on_error=Stop"
print $" Run '($run_id)' is now blocked. Resolve and re-run this step."
}
}
if $fmt == "json" {
print ($entry | to json)
} else {
let mark = if $status == "pass" { $"(ansi green)✓(ansi reset)" } else if $status == "fail" { $"(ansi red)✗(ansi reset)" } else { $"(ansi yellow)-(ansi reset)" }
print $" ($mark) ($step_id) [($mode) / ($run_id)]"
if $warnings > 0 { print $" ($warnings) warning(s)" }
if ($artifacts | is-not-empty) { print $" artifacts: ($artifacts | str join ', ')" }
}
}
# Show current run progress.
export def "run status" [
--run (-r): string = "", # Specific run ID (default: active run)
--fmt (-f): string = "",
]: nothing -> nothing {
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
let current = (load-current-run)
let run_id = if ($run | is-not-empty) { $run } else if ($current | is-not-empty) { $current.run_id? | default "" } else { "" }
if ($run_id | is-empty) {
if $fmt == "json" { print "null" } else { print "No active run. Start with: ontoref run start <mode>" }
return
}
let mode_id = ($current.mode? | default "")
let mode_data = if ($mode_id | is-not-empty) {
try { load-mode-dag $mode_id } catch { {} }
} else { {} }
let all_step_ids = ($mode_data.steps? | default [] | each { |s| $s.id })
let reported = (load-run-steps $run_id)
let steps_view = ($all_step_ids | each { |id|
let r_list = ($reported | where { |s| $s.step == $id })
let r = if ($r_list | is-not-empty) { $r_list | first } else { null }
if ($r | is-not-empty) {
{ id: $id, status: $r.status, exit_code: ($r.exit_code? | default 0), ts: ($r.ts? | default ""), warnings: ($r.warnings? | default 0) }
} else {
{ id: $id, status: "pending", exit_code: null, ts: "", warnings: 0 }
}
})
let data = {
run_id: $run_id,
mode: $mode_id,
task: ($current.task? | default ""),
started_at: ($current.started_at? | default ""),
steps_total: ($all_step_ids | length),
steps_reported: ($reported | length),
steps: $steps_view,
}
if $fmt == "json" {
print ($data | to json)
} else {
print ""
print $"RUN: ($data.run_id)"
print "══════════════════════════════════════════════════════════════════"
if ($data.task | is-not-empty) { print $" Task: ($data.task)" }
print $" Mode: ($data.mode)"
print $" Started: ($data.started_at)"
print $" Progress: ($data.steps_reported)/($data.steps_total) steps reported"
print ""
for s in $data.steps {
let mark = match $s.status {
"pass" => $"(ansi green)✓(ansi reset)",
"fail" => $"(ansi red)✗(ansi reset)",
"skip" => $"(ansi yellow)-(ansi reset)",
"pending" => $"(ansi dark_gray)·(ansi reset)",
_ => " ",
}
let warn_tag = if ($s.warnings? | default 0) > 0 { $" ⚠ ($s.warnings) warnings" } else { "" }
print $" ($mark) ($s.id)($warn_tag)"
}
print ""
}
}
# Verify a run is complete and emit side effects (backlog close, artifacts summary).
# Fails with a specific message if any required step is missing or blocked.
export def "mode complete" [
mode: string, # Mode ID
--task (-t): string = "", # Backlog task ID to close on success
--run (-r): string = "", # Override run ID (default: active run)
--fmt (-f): string = "",
]: nothing -> nothing {
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" }
# Resolve run context
let current = (load-current-run)
let run_id = if ($run | is-not-empty) { $run } else if ($current | is-not-empty) and ($current.mode? | default "") == $mode { $current.run_id? | default "" } else { "" }
if ($run_id | is-empty) {
error make { msg: $"No active run for mode '($mode)'. Start one with: ontoref run start ($mode)" }
}
let dir = $"(runs-dir)/($run_id)"
if not ($dir | path exists) {
error make { msg: $"Run directory not found: ($dir)" }
}
# Load mode DAG and reported steps
let mode_data = (load-mode-dag $mode)
let all_steps = ($mode_data.steps? | default [])
let reported = (load-run-steps $run_id)
# Build verification results — one record per step
let results = ($all_steps | each { |step_def|
let rep_list = ($reported | where { |r| $r.step == $step_def.id })
let rep = if ($rep_list | is-not-empty) { $rep_list | first } else { null }
let strategy = ($step_def.on_error?.strategy? | default "Stop")
{
id: $step_def.id,
reported: ($rep | is-not-empty),
status: (if ($rep | is-not-empty) { $rep.status } else { "pending" }),
strategy: $strategy,
# A step blocks completion if: not reported AND strategy=Stop,
# OR reported as fail AND strategy=Stop
blocks: (
(($rep | is-empty) and $strategy == "Stop") or
(($rep | is-not-empty) and $rep.status == "fail" and $strategy == "Stop")
),
}
})
let blockers = ($results | where blocks == true)
let warnings = ($results | where { |r| not $r.blocks and $r.status in ["fail", "pending"] })
let all_artifacts = ($reported | each { |r| $r.artifacts? | default [] } | flatten)
let total_warnings = ($reported | each { |r| $r.warnings? | default 0 } | math sum)
let resolved_task = if ($task | is-not-empty) { $task } else { $current.task? | default "" }
let ok = ($blockers | is-empty)
let result = {
run_id: $run_id,
mode: $mode,
task: $resolved_task,
ok: $ok,
steps_total: ($all_steps | length),
steps_reported: ($reported | length),
blockers: ($blockers | each { |b| { id: $b.id, status: $b.status, strategy: $b.strategy } }),
warnings: ($warnings | each { |w| { id: $w.id, status: $w.status } }),
artifacts: $all_artifacts,
total_warnings: $total_warnings,
}
if $ok {
# Mark run as completed in run.json
let run_meta = (open $"($dir)/run.json")
$run_meta | merge { completed_at: (now-iso), ok: true } | to json | save --force $"($dir)/run.json"
# Clear current.json — no active run
"{}" | save --force (current-run-file)
}
if $fmt == "json" {
print ($result | to json)
} else if $ok {
print ""
print $"(ansi green)✓ RUN COMPLETE(ansi reset): ($run_id)"
print "══════════════════════════════════════════════════════════════════"
print $" Mode: ($mode)"
if ($resolved_task | is-not-empty) { print $" Task: ($resolved_task)" }
print $" Steps: ($result.steps_reported)/($result.steps_total) reported"
if $total_warnings > 0 { print $" Warnings: ($total_warnings) advisory (non-blocking)" }
if ($all_artifacts | is-not-empty) {
print ""
print " Artifacts:"
for a in $all_artifacts { print $" ($a)" }
}
if ($resolved_task | is-not-empty) {
print ""
print $" Close backlog item with: ontoref backlog done ($resolved_task)"
}
print ""
} else {
print ""
print $"(ansi red)✗ RUN INCOMPLETE(ansi reset): ($run_id)"
print "══════════════════════════════════════════════════════════════════"
print $" ($blockers | length) blocker(s):"
for b in $blockers {
let reason = if $b.status == "pending" { "not reported" } else { $"($b.status) with on_error=Stop" }
print $" ($b.id) — ($reason)"
}
print ""
}
}