#!/usr/bin/env nu
# reflection/modules/sync.nu — ontology↔code synchronization.
# Detects drift between .ontology/ declarations and actual project artifacts.
# Scans: crates, scenarios, agents, CI, forms, modes, justfiles, .claude/.
# Operates on $env.ONTOREF_PROJECT_ROOT (set by onref wrapper).

use store.nu [daemon-export, daemon-export-safe]

# Resolve project root: ONTOREF_PROJECT_ROOT if set and different from ONTOREF_ROOT, else ONTOREF_ROOT.
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 }
}

# ── scan ──────────────────────────────────────────────────────────────────────

# Analyze a project's real structure: crates, scenarios, agents, CI, forms, modes, justfiles, claude.
export def "sync scan" [
  --level: string = "auto",  # Analysis level: structural | full | auto (detect nightly)
]: nothing -> record {
  let root = (project-root)

  let crates    = (scan-crates $root $level)
  let scenarios = (scan-scenarios $root)
  let agents    = (scan-agents $root)
  let ci        = (scan-ci $root)
  let forms     = (scan-forms $root)
  let modes     = (scan-modes $root)
  let justfiles = (scan-justfiles $root)
  let claude    = (scan-claude $root)

  let api_level = if ($crates | any { |c| ($c.pub_api | length) > 0 }) { "full" } else { "structural" }

  {
    crates: $crates,
    scenarios: $scenarios,
    agents: $agents,
    ci: $ci,
    forms: $forms,
    modes: $modes,
    justfiles: $justfiles,
    claude: $claude,
    api_level: $api_level,
  }
}

# ── diff ──────────────────────────────────────────────────────────────────────

# Compare scan against ontology, producing drift report.
export def "sync diff" [
  --quick,           # Skip nickel exports; parse NCL text directly for speed
  --level: string = "", # Extra checks: "full" adds ADR violations, content assets, connection health
  --docs,            # Check for drift between crate //! doc comments and ontology node descriptions
  --fail-on-drift,   # Exit 1 if any DRIFT items are found (for pre-commit use)
]: nothing -> table {
  let root = (project-root)
  let scan = (sync scan --level structural)

  let ontology = if $quick {
    load-ontology-quick $root
  } else {
    load-ontology $root
  }

  if ($ontology | is-empty) {
    print "  No .ontology/core.ncl found — nothing to diff."
    return []
  }

  let nodes  = ($ontology.nodes? | default [])
  let edges = ($ontology.edges? | default [])
  let node_ids = ($nodes | each { |n| $n | get -o id | default "" } | where { $in | is-not-empty })

  # Build set of all existing paths from scan
  let existing_paths = (collect-existing-paths $scan $root)

  mut report = []

  # Check each node: does at least one artifact_path exist on disk?
  for node in $nodes {
    let id = $node.id
    let paths = ($node.artifact_paths? | default [])
    if ($paths | is-empty) {
      # Tensions and abstract concepts have no artifact_paths — always OK
      $report = ($report | append { status: "OK", id: $id, artifact_path: "", detail: "No artifact_paths declared (abstract node)" })
    } else {
      # Check if at least one declared path exists in the project
      let found = ($paths | any { |p|
        let full = $"($root)/($p)"
        ($full | path exists) or ($existing_paths | any { |ep| $ep starts-with $p or $p starts-with $ep })
      })
      if $found {
        $report = ($report | append { status: "OK", id: $id, artifact_path: ($paths | first), detail: "" })
      } else {
        $report = ($report | append {
          status: "STALE",
          id: $id,
          artifact_path: ($paths | str join ", "),
          detail: $"Declared paths not found: ($paths | str join ', ')",
        })
      }
    }
  }

  # Check scan artifacts that no node claims via artifact_paths
  let all_node_paths = ($nodes | each { |n| $n.artifact_paths? | default [] } | flatten)
  let unclaimed = (find-unclaimed-artifacts $scan $root $all_node_paths)
  for art in $unclaimed {
    $report = ($report | append {
      status: "MISSING",
      id: $art.id,
      artifact_path: $art.path,
      detail: $"Artifact exists but no ontology node claims it: ($art.kind) at ($art.path)",
    })
  }

  # Check edge integrity
  for edge in $edges {
    if not ($edge.from in $node_ids) {
      $report = ($report | append {
        status: "BROKEN",
        id: $"edge:($edge.from)->($edge.to)",
        artifact_path: "",
        detail: $"Edge source '($edge.from)' not found in nodes",
      })
    }
    if not ($edge.to in $node_ids) {
      $report = ($report | append {
        status: "BROKEN",
        id: $"edge:($edge.from)->($edge.to)",
        artifact_path: "",
        detail: $"Edge target '($edge.to)' not found in nodes",
      })
    }
  }

  # ── Full level: ADR violations, content assets, connection health ────────────
  if $level == "full" {
    # ADR violations: run validate check-all and capture failures
    let adr_result = do { nu --no-config-file -c "use reflection/modules/validate.nu *; validate check-all --fmt json" } | complete
    if $adr_result.exit_code != 0 {
      let violations = do { $adr_result.stdout | from json } | complete
      if $violations.exit_code == 0 {
        let failed = ($violations.stdout | where { |r| ($r.passed? | default true) == false })
        for v in $failed {
          $report = ($report | append {
            status:        "BROKEN",
            id:            ($v.constraint_id? | default "adr-constraint"),
            artifact_path: "",
            detail:        $"ADR constraint failed: ($v.description? | default '')",
          })
        }
      }
    }

    # Content assets: verify source_path exists on disk
    let manifest_file = $"($root)/.ontology/manifest.ncl"
    if ($manifest_file | path exists) {
      let manifest_result = do { nickel export --format json $manifest_file } | complete
      if $manifest_result.exit_code == 0 {
        let manifest = ($manifest_result.stdout | from json)
        let assets = ($manifest.content_assets? | default [])
        for asset in $assets {
          let src = ($asset.source_path? | default "")
          if ($src | is-not-empty) and not ($"($root)/($src)" | path exists) {
            $report = ($report | append {
              status:        "MISSING",
              id:            ($asset.id? | default $src),
              artifact_path: $src,
              detail:        $"content_asset source_path not found on disk: ($src)",
            })
          }
        }
      }
    }

    # Connection health: check declared project slugs exist in daemon registry
    let conn_file = $"($root)/.ontology/connections.ncl"
    if ($conn_file | path exists) {
      let conn_result = do { nickel export --format json $conn_file } | complete
      if $conn_result.exit_code == 0 {
        let connections = ($conn_result.stdout | from json)
        let daemon_url = ($env.ONTOREF_DAEMON_URL? | default "http://127.0.0.1:7891")
        let projects_result = do { http get $"($daemon_url)/projects" } | complete
        if $projects_result.exit_code == 0 {
          let registered = ($projects_result.stdout | from json | get slugs? | default [])
          for direction in ["upstream", "downstream", "peers"] {
            let conns = ($connections | get -o $direction | default [])
            for conn in $conns {
              let target = ($conn.project? | default "")
              if ($target | is-not-empty) and not ($target in $registered) {
                $report = ($report | append {
                  status:        "BROKEN",
                  id:            $"connection:($target)",
                  artifact_path: "",
                  detail:        $"connection ($direction) references unregistered project: ($target)",
                })
              }
            }
          }
        }
      }
    }
  }

  if $docs {
    let doc_scan = (sync scan --level structural)
    let doc_drifts = (check-crate-doc-drift $root $nodes $doc_scan)
    $report = ($report | append $doc_drifts)
  }

  let sorted = ($report | sort-by status id)

  if $fail_on_drift {
    let drifts = ($sorted | where status == "DRIFT")
    if ($drifts | is-not-empty) {
      print $"(ansi red)✗ ($drifts | length) crate\(s\) have doc drift:(ansi reset)"
      for d in $drifts {
        print $"  ($d.id)  ($d.artifact_path)"
        print $"  ($d.detail)"
      }
      exit 1
    }
  }

  $sorted
}

# ── propose ───────────────────────────────────────────────────────────────────

# Generate NCL patches for drift items.
export def "sync propose" []: nothing -> string {
  let diff = (sync diff)
  let missing  = ($diff | where status == "MISSING")
  let stale    = ($diff | where status == "STALE")
  let broken   = ($diff | where status == "BROKEN")

  mut lines = []

  if ($missing | is-not-empty) {
    $lines = ($lines | append "# ── New nodes (MISSING artifacts found) ──")
    $lines = ($lines | append "")
    for item in $missing {
      let id = $item.id
      let path = $item.artifact_path
      let kind = (detect-artifact-kind $path)
      $lines = ($lines | append (generate-node-ncl $id $path $kind))
      $lines = ($lines | append "")
    }
  }

  if ($stale | is-not-empty) {
    $lines = ($lines | append "# ── Stale nodes (no artifact found) — remove or update ──")
    $lines = ($lines | append "")
    for item in $stale {
      $lines = ($lines | append $"# REMOVE: ($item.id) — ($item.detail)")
    }
    $lines = ($lines | append "")
  }

  if ($broken | is-not-empty) {
    $lines = ($lines | append "# ── Broken edges — remove ──")
    $lines = ($lines | append "")
    for item in $broken {
      $lines = ($lines | append $"# REMOVE EDGE: ($item.id) — ($item.detail)")
    }
    $lines = ($lines | append "")
  }

  if ($lines | is-empty) {
    "# No drift detected — ontology is in sync."
  } else {
    $lines | str join "\n"
  }
}

# ── apply ─────────────────────────────────────────────────────────────────────

# Apply proposed changes: insert new nodes, remove stale nodes from core.ncl.
export def "sync apply" []: nothing -> nothing {
  let diff = (sync diff)
  let missing  = ($diff | where status == "MISSING")
  let stale    = ($diff | where status == "STALE")
  let broken   = ($diff | where status == "BROKEN")

  if ($missing | is-empty) and ($stale | is-empty) and ($broken | is-empty) {
    print "  Ontology is in sync — nothing to apply."
    return
  }

  let root = (project-root)
  let core_ncl = $"($root)/.ontology/core.ncl"

  if not ($core_ncl | path exists) {
    print $"  Error: ($core_ncl) not found."
    return
  }

  print ""
  print "Proposed changes:"
  print "──────────────────────────────────────────────────────────────────"

  if ($missing | is-not-empty) {
    print $"  ADD: ($missing | length) new nodes"
    for item in $missing { print $"    + ($item.id) ← ($item.artifact_path)" }
  }
  if ($stale | is-not-empty) {
    print $"  REMOVE: ($stale | length) stale nodes"
    for item in $stale { print $"    - ($item.id)" }
  }
  if ($broken | is-not-empty) {
    print $"  FIX: ($broken | length) broken edges"
    for item in $broken { print $"    ! ($item.id)" }
  }

  print ""
  let confirm = (input "Apply changes? [y/N] " | str trim | str downcase)
  if $confirm != "y" {
    print "  Aborted."
    return
  }

  mut lines = (open $core_ncl --raw | lines | each { |l| $l })

  # Insert new nodes before the '],\n\n  edges' boundary
  if ($missing | is-not-empty) {
    # Find the line index of '  edges = ['
    let edges_match = ($lines | enumerate | where { |row| ($row.item | str trim) == "edges = [" })
    if ($edges_match | is-empty) {
      print "  Cannot find 'edges = [' in core.ncl — file format not recognized. Skipping node insertion."
    } else {
    let edges_idx = ($edges_match | first | get index)
    # Find the top-level '],' closing the nodes array before edges.
    # Top-level = exactly 2 leading spaces (same indent as 'edges = [').
    mut insert_idx = $edges_idx - 1
    while $insert_idx > 0 {
      let line = ($lines | get $insert_idx)
      let trimmed = ($line | str trim)
      # Top-level array close: line is "  ]," (2-space indent, not nested deeper)
      if $trimmed == "]," and ($line | str starts-with "  ") and (not ($line | str starts-with "    ")) { break }
      $insert_idx = $insert_idx - 1
    }
    if $insert_idx == 0 {
      print "  Cannot find nodes array closing bracket in core.ncl. Skipping node insertion."
    } else {

    mut new_lines = []
    for item in $missing {
      let kind = (detect-artifact-kind $item.artifact_path)
      let block = (generate-node-ncl $item.id $item.artifact_path $kind)
      $new_lines = ($new_lines | append "")
      $new_lines = ($new_lines | append ($block | lines))
    }

    let before = ($lines | first $insert_idx)
    let after = ($lines | skip $insert_idx)
    $lines = ($before | append $new_lines | append $after)
    print $"  Inserted ($missing | length) new nodes."
    } # insert_idx guard
    } # edges_match guard
  }

  # Remove stale nodes
  for item in $stale {
    let id = $item.id
    let id_pattern = $"\"($id)\""
    let id_matches = ($lines | enumerate | where { |row| ($row.item | str contains $id_pattern) and ($row.item | str contains "id ") })

    if ($id_matches | is-not-empty) {
      let id_line_idx = ($id_matches | first | get index)
      # Walk back to find 'd.make_node'
      mut start_idx = $id_line_idx
      while $start_idx > 0 {
        if ($lines | get $start_idx | str contains "d.make_node") { break }
        $start_idx = $start_idx - 1
      }
      # Walk forward to find '},' or lone '}' (last node in array has no trailing comma)
      mut end_idx = $id_line_idx
      let total = ($lines | length)
      while $end_idx < ($total - 1) {
        let trimmed_line = ($lines | get $end_idx | str trim)
        if ($trimmed_line | str starts-with "},") or ($trimmed_line == "}") { break }
        $end_idx = $end_idx + 1
      }
      # Guard: only remove if we actually found the closing brace
      let end_line_trimmed = ($lines | get $end_idx | str trim)
      if not (($end_line_trimmed | str starts-with "},") or ($end_line_trimmed == "}")) {
        print $"  warn: could not find closing brace for node '($id)' — skipping removal"
        continue
      }
      # Remove lines [start_idx..end_idx] inclusive
      let before = ($lines | first $start_idx)
      let after = ($lines | skip ($end_idx + 1))
      # Skip leading blank line in after
      let trimmed_after = if ($after | is-not-empty) and (($after | first) | str trim | is-empty) {
        $after | skip 1
      } else {
        $after
      }
      $lines = ($before | append $trimmed_after)
      print $"  Removed node '($id)'."
    }
  }

  # Remove broken edges (handles both single-line and multi-line make_edge blocks)
  # Deduplicate: an edge with both from+to broken produces two BROKEN entries with the same id.
  let broken = ($broken | uniq-by id)
  for item in $broken {
    let edge_id = $item.id
    let parts = ($edge_id | str replace "edge:" "" | split row "->")
    if ($parts | length) == 2 {
      let src = ($parts | first)
      let tgt = ($parts | last)
      let from_pattern = $"from = \"($src)\""
      let to_pattern = $"to = \"($tgt)\""

      # Try single-line match first (from and to on same line)
      let single_matches = ($lines | enumerate | where { |row|
        ($row.item | str contains $from_pattern) and ($row.item | str contains $to_pattern)
      })

      if ($single_matches | is-not-empty) {
        let edge_idx = ($single_matches | first | get index)
        let before = ($lines | first $edge_idx)
        let after = ($lines | skip ($edge_idx + 1))
        $lines = ($before | append $after)
        print $"  Removed edge ($src) → ($tgt)."
      } else {
        # Multi-line: find the make_edge block containing both from and to
        let from_lines = ($lines | enumerate | where { |row| $row.item | str contains $from_pattern })
        if ($from_lines | is-not-empty) {
          let from_idx = ($from_lines | first | get index)
          # Search for matching to within ±5 lines
          let search_start = ([$from_idx - 5, 0] | math max)
          let search_end = ([($from_idx + 5), (($lines | length) - 1)] | math min)
          let nearby = ($lines | skip $search_start | first ($search_end - $search_start + 1))
          let has_to = ($nearby | any { |l| $l | str contains $to_pattern })
          if $has_to {
            # Walk back to find d.make_edge
            mut start = $from_idx
            while $start > 0 and (not ($lines | get $start | str contains "d.make_edge")) {
              $start = $start - 1
            }
            # Walk forward to find the closing }
            mut end = $from_idx
            while $end < (($lines | length) - 1) {
              let l = ($lines | get $end | str trim)
              if ($l == "}," or $l == "}") and $end > $from_idx { break }
              $end = $end + 1
            }
            let before = ($lines | first $start)
            let after = ($lines | skip ($end + 1))
            $lines = ($before | append $after)
            print $"  Removed multi-line edge ($src) → ($tgt)."
          }
        }
      }
    }
  }

  $lines | str join "\n" | $in + "\n" | save --force $core_ncl
  print ""
  print "  Changes written to .ontology/core.ncl"
  print "  Run 'sync diff' to verify."
}

# ── state ─────────────────────────────────────────────────────────────────────

# Compare state.ncl dimensions against project reality.
export def "sync state" []: nothing -> table {
  let root = (project-root)
  let state_ncl = $"($root)/.ontology/state.ncl"

  if not ($state_ncl | path exists) {
    print "  No .ontology/state.ncl found."
    return []
  }

  let state = (daemon-export-safe $state_ncl)
  if $state == null {
    print "  Failed to export state.ncl"
    return []
  }
  let dims = ($state.dimensions? | default [])

  mut report = []
  for dim in $dims {
    let current = ($dim.current_state? | default "unknown")
    let desired = ($dim.desired_state? | default "unknown")
    let status = if $current == $desired { "REACHED" } else { "ACTIVE" }
    $report = ($report | append {
      id: $dim.id,
      current: $current,
      desired: $desired,
      status: $status,
      horizon: ($dim.horizon? | default ""),
    })
  }

  $report
}

# ── audit ─────────────────────────────────────────────────────────────────────

# Full audit: scan + diff + ADR constraints + gate conditions + NCL integrity.
export def "sync audit" [
  --fmt: string = "",       # Output format: table* | json | silent
  --strict,                 # Exit with error on MISSING/STALE/BROKEN
  --quick,                  # Skip expensive operations (no nickel exports for API surface)
]: nothing -> record {
  let root = (project-root)
  let actor = ($env.ONTOREF_ACTOR? | default "developer")
  let f = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "table" }

  # Node drift
  let diff = if $quick { sync diff --quick } else { sync diff }
  let ok_count      = ($diff | where status == "OK" | length)
  let missing_count = ($diff | where status == "MISSING" | length)
  let stale_count   = ($diff | where status == "STALE" | length)
  let broken_count  = ($diff | where status == "BROKEN" | length)
  let total_nodes   = ($ok_count + $missing_count + $stale_count + $broken_count)

  # ADR constraint checks
  let adr_results = if $quick { [] } else { audit-adr-constraints $root }

  # Gate status (pass diff so breach detection can check protected nodes)
  let gate_results = if $quick { [] } else { audit-gates $root $diff }

  # State dimensions
  let state_report = if $quick { [] } else { sync state }

  # Justfile + .claude + tools audits (manifest-driven when available)
  let scan = if $quick { { justfiles: { exists: false }, claude: { exists: false } } } else {
    sync scan --level structural
  }
  let manifest = (load-manifest-safe $root)
  let justfile_results = if $quick { [] } else { audit-justfiles ($scan.justfiles? | default { exists: false }) $manifest }
  let claude_results = if $quick { [] } else { audit-claude ($scan.claude? | default { exists: false }) $manifest }
  let tools_results = if $quick { [] } else { audit-tools $manifest }
  let manifest_cov_results = if $quick { [] } else { audit-manifest-coverage $root }

  # Health score (0-100) — pooled weighted model
  #
  # Each check earns points proportional to its architectural weight:
  #   node OK        = 1pt    (ontology completeness)
  #   ADR Hard PASS  = 3pt    (architectural guardrail — violation is severe)
  #   ADR Soft PASS  = 1pt    (advisory constraint)
  #   infra PASS     = 1pt    (tooling / .claude / justfiles)
  # Penalties (subtracted from earned points):
  #   broken edge    = -2pt   (graph integrity)
  #   gate BREACH    = -5pt   (active membrana violated)
  #
  # health = earned / max_possible * 100, floored at 0

  let adr_pass = ($adr_results | where status == "PASS" | length)
  let adr_total = ($adr_results | length)
  let all_infra = ($justfile_results | append $claude_results | append $tools_results | append $manifest_cov_results)
  let jc_pass = ($all_infra | where status == "PASS" | length)
  let jc_total = ($all_infra | length)

  let node_earned = ($ok_count | into float)
  let node_max = ($total_nodes | into float)

  # ADR severity weighting: Hard = 3pt, Soft = 1pt
  let adr_earned_list = ($adr_results | where status == "PASS" | each { |r|
    if ($r.severity? | default "Hard") == "Hard" { 3 } else { 1 }
  })
  let adr_earned = if ($adr_earned_list | is-empty) { 0.0 } else { $adr_earned_list | math sum | into float }
  let adr_max_list = ($adr_results | each { |r|
    if ($r.severity? | default "Hard") == "Hard" { 3 } else { 1 }
  })
  let adr_max = if ($adr_max_list | is-empty) { 0.0 } else { $adr_max_list | math sum | into float }
  let infra_earned = ($jc_pass | into float)
  let infra_max = ($jc_total | into float)

  let breach_count = ($gate_results | where status == "BREACH" | length)
  let penalty = (($broken_count * 2 + $breach_count * 5) | into float)

  let max_possible = ($node_max + $adr_max + $infra_max)
  let earned = ($node_earned + $adr_earned + $infra_earned - $penalty)

  let health = if $max_possible == 0.0 { 100.0 } else {
    let raw = ($earned / $max_possible * 100.0)
    if $raw < 0.0 { 0.0 } else if $raw > 100.0 { 100.0 } else { $raw }
  } | math round --precision 1

  let report = {
    nodes: {
      ok: $ok_count,
      missing: $missing_count,
      stale: $stale_count,
      broken_edges: $broken_count,
      details: $diff,
    },
    adrs: $adr_results,
    gates: $gate_results,
    state: $state_report,
    justfiles: $justfile_results,
    claude: $claude_results,
    tools: $tools_results,
    health: $health,
  }

  if $f == "json" {
    print ($report | to json)
  } else if $f == "silent" {
    # No output — caller uses return value only
  } else {
    print ""
    print $"Ontology Audit — ($root | path basename)"
    print "──────────────────────────────────────────────────────────────────"
    print $"  Nodes: ($ok_count) OK / ($missing_count) MISSING / ($stale_count) STALE"
    print $"  Edges: ($broken_count) broken"
    if ($adr_results | is-not-empty) {
      let adr_fail = ($adr_results | where status == "FAIL" | length)
      let hard_fail = ($adr_results | where status == "FAIL" and severity == "Hard" | length)
      let soft_fail = ($adr_results | where status == "FAIL" and severity == "Soft" | length)
      let severity_detail = if ($hard_fail + $soft_fail) > 0 {
        $" \(($hard_fail)H ($soft_fail)S\)"
      } else { "" }
      print $"  ADR constraints: ($adr_pass) PASS / ($adr_fail) FAIL($severity_detail)"
    }
    if ($gate_results | is-not-empty) {
      let active_gates = ($gate_results | where status == "ACTIVE" | length)
      let breach_gates = ($gate_results | where status == "BREACH" | length)
      print $"  Gates: ($active_gates) ACTIVE / ($breach_gates) BREACH"
    }
    if ($state_report | is-not-empty) {
      let reached = ($state_report | where status == "REACHED" | length)
      let active_dims = ($state_report | where status == "ACTIVE" | length)
      print $"  State: ($reached) REACHED / ($active_dims) ACTIVE"
    }
    if ($justfile_results | is-not-empty) {
      let jf_pass = ($justfile_results | where status == "PASS" | length)
      let jf_miss = ($justfile_results | where status == "MISSING" | length)
      print $"  Justfiles: ($jf_pass) PASS / ($jf_miss) MISSING"
    }
    if ($claude_results | is-not-empty) {
      let cl_pass = ($claude_results | where status == "PASS" | length)
      let cl_miss = ($claude_results | where status == "MISSING" | length)
      print $"  .claude/: ($cl_pass) PASS / ($cl_miss) MISSING"
    }
    if ($tools_results | is-not-empty) {
      let tl_pass = ($tools_results | where status == "PASS" | length)
      let tl_miss = ($tools_results | where status == "MISSING" | length)
      let tl_opt = ($tools_results | where status == "OPTIONAL" | length)
      print $"  Tools: ($tl_pass) PASS / ($tl_miss) MISSING / ($tl_opt) optional"
    }
    print $"  Health: ($health)%"
    print ""

    # Show problem items
    let problems = ($diff | where status != "OK")
    if ($problems | is-not-empty) {
      print "  Issues:"
      for p in $problems {
        print $"    [($p.status)] ($p.id) ($p.detail)"
      }
      print ""
    }

    # Show justfile/claude/tools issues
    let jc_issues = ($justfile_results | append $claude_results | append $tools_results | where status == "MISSING")
    if ($jc_issues | is-not-empty) {
      print "  Infrastructure gaps:"
      for p in $jc_issues {
        print $"    [MISSING] ($p.check): ($p.detail)"
      }
      print ""
    }

    # Show manifest capability coverage issues
    let manifest_issues = ($manifest_cov_results | where { |r| $r.status != "PASS" })
    if ($manifest_issues | is-not-empty) {
      print "  Manifest capability gaps:"
      for p in $manifest_issues {
        print $"    [($p.status)] ($p.check): ($p.detail)"
      }
      print ""
    }
  }

  if $strict and (($missing_count + $stale_count + $broken_count) > 0) {
    error make { msg: $"Audit failed: ($missing_count) MISSING, ($stale_count) STALE, ($broken_count) BROKEN" }
  }

  $report
}

# ── manifest-check ───────────────────────────────────────────────────────────
# Quick manifest capability coverage check — used by pre-commit hooks and CI.
# Exits 0 if no Hard failures. Prints warnings but does not block on Soft issues.
export def "sync manifest-check" [
  --strict,  # Also fail on WARN (Soft) issues
]: nothing -> nothing {
  let root = (project-root)
  let results = (audit-manifest-coverage $root)
  let fails = ($results | where status == "FAIL")
  let warns = ($results | where status == "WARN")

  if ($fails | is-not-empty) {
    for f in $fails {
      print $"(ansi red)✗ ($f.detail)(ansi reset)"
    }
    error make { msg: $"Manifest coverage: ($fails | length) Hard failure\(s\)" }
  }

  if $strict and ($warns | is-not-empty) {
    for w in $warns {
      print $"(ansi yellow)⚠ ($w.check): ($w.detail)(ansi reset)"
    }
    error make { msg: $"Manifest coverage: ($warns | length) warnings \(strict mode\)" }
  }

  if ($warns | is-not-empty) {
    print $"(ansi yellow)⚠ ($warns | length) manifest coverage warnings — run `ontoref sync audit` for details(ansi reset)"
  }

  let pass = ($results | where status == "PASS")
  if ($pass | is-not-empty) {
    print $"(ansi green)✓ ($pass | first | get detail)(ansi reset)"
  }
}

# ── watch ─────────────────────────────────────────────────────────────────────

# Launch bacon in headless mode with ontology-watch job, monitor drift file.
export def "sync watch" []: nothing -> nothing {
  let root = (project-root)
  let bacon_toml = $"($root)/bacon.toml"

  if not ($bacon_toml | path exists) {
    print "  No bacon.toml found — cannot watch."
    print "  Run setup_reflection.nu to install bacon configuration."
    return
  }

  # Verify ontology-watch job exists in bacon.toml
  let bacon_content = (open $bacon_toml --raw)
  if not ($bacon_content | str contains "[jobs.ontology-watch]") {
    print "  bacon.toml found but missing [jobs.ontology-watch] job."
    print "  Re-run setup_reflection.nu to enable the ontology-reflection layer."
    return
  }

  let has_bacon = (which bacon | is-not-empty)
  if not $has_bacon {
    print "  bacon not found — install with: cargo install bacon"
    return
  }

  print "  Starting ontology drift watch via bacon..."
  print "  Press Ctrl+C to stop."
  ^bacon --headless -j ontology-watch
}

# ── Internal helpers ──────────────────────────────────────────────────────────

def scan-crates [root: string, level: string]: nothing -> list {
  let cargo_toml = $"($root)/Cargo.toml"
  if not ($cargo_toml | path exists) { return [] }

  let cargo = (open $cargo_toml)
  let workspace_members = ($cargo | get -o workspace.members | default [])

  let crate_paths = if ($workspace_members | is-empty) { ["."] } else { $workspace_members }

  mut crates = []
  for member in $crate_paths {
    let crate_dir = $"($root)/($member)"
    let crate_toml_path = $"($crate_dir)/Cargo.toml"
    if ($crate_toml_path | path exists) {
      $crates = ($crates | append (parse-single-crate $crate_dir $level $root))
    } else {
      # Try globbing (e.g., "crates/*")
      let expanded = (glob $"($root)/($member)/Cargo.toml")
      for ct in $expanded {
        $crates = ($crates | append (parse-single-crate ($ct | path dirname) $level $root))
      }
    }
  }
  $crates
}

def parse-single-crate [crate_dir: string, level: string, root: string]: nothing -> record {
  let crate_toml = $"($crate_dir)/Cargo.toml"
  let cargo = (open $crate_toml)
  let name = ($cargo | get -o package.name | default ($crate_dir | path basename))
  let features = ($cargo | get -o features | default {} | columns)
  let deps_count = ($cargo | get -o dependencies | default {} | columns | length)
  let src_dir = $"($crate_dir)/src"

  let src_modules = if ($src_dir | path exists) {
    glob $"($src_dir)/**/*.rs" | each { |f| $f | path relative-to $crate_dir }
  } else { [] }

  let test_dir = $"($crate_dir)/tests"
  let test_count = if ($test_dir | path exists) {
    glob $"($test_dir)/**/*.rs" | length
  } else { 0 }

  let do_full = match $level {
    "full" => true,
    "structural" => false,
    _ => { (which "cargo" | is-not-empty) and ((do { ^cargo +nightly --version } | complete).exit_code == 0) },
  }

  let pub_api = if $do_full {
    extract-pub-api $crate_dir $name
  } else { [] }

  let crate_doc = (extract-crate-doc $crate_dir)

  {
    name: $name,
    path: ($crate_dir | path relative-to $root),
    features: $features,
    deps_count: $deps_count,
    src_modules: $src_modules,
    test_count: $test_count,
    pub_api: $pub_api,
    crate_doc: $crate_doc,
  }
}

def extract-pub-api [crate_dir: string, crate_name: string]: nothing -> list {
  let result = do {
    cd $crate_dir
    ^cargo +nightly rustdoc -p $crate_name -- -Z unstable-options --output-format json
  } | complete

  if $result.exit_code != 0 { return [] }

  # Find the generated JSON doc
  let doc_name = ($crate_name | str replace "-" "_")
  let doc_json = $"($crate_dir)/target/doc/($doc_name).json"

  if not ($doc_json | path exists) { return [] }

  let doc = (open $doc_json)
  let index = ($doc | get -o index | default {})

  $index | transpose k v | where {|item|
    let vis = ($item.v | get -o visibility | default "")
    $vis == "public"
  } | each { |item|
    let inner = ($item.v | get -o inner | default {})
    let kind = ($inner | columns | first | default "unknown")
    {
      kind: $kind,
      path: ($item.v | get -o name | default ""),
      signature: ($item.v | get -o docs | default "" | str substring 0..120),
    }
  }
}

def extract-crate-doc [crate_dir: string]: nothing -> string {
  let candidates = [$"($crate_dir)/src/lib.rs", $"($crate_dir)/src/main.rs"]
  let existing = ($candidates | where { |f| $f | path exists })
  if ($existing | is-empty) { return "" }

  open --raw ($existing | first)
  | lines
  | skip while { |l|
      let t = ($l | str trim)
      ($t | is-empty) or ($t | str starts-with "#![")
    }
  | take while { |l|
      let t = ($l | str trim)
      ($t | is-empty) or ($t | str starts-with "//!")
    }
  | where { |l| $l | str starts-with "//!" }
  | each { |l| $l | str replace --regex '^//!\s?' "" }
  | str join "\n"
  | str trim
}

def word-overlap [a: string, b: string]: nothing -> float {
  let stop = ["that", "this", "with", "from", "have", "will", "been", "when", "they", "each"]
  let words_a = ($a | str downcase | split row --regex '\W+'
    | where { |w| ($w | str length) > 3 and not ($w in $stop) } | sort | uniq)
  let words_b = ($b | str downcase | split row --regex '\W+'
    | where { |w| ($w | str length) > 3 and not ($w in $stop) } | sort | uniq)
  if ($words_a | is-empty) or ($words_b | is-empty) { return 0.0 }
  let intersection = ($words_a | where { |w| $w in $words_b } | length)
  let union_size = ($words_a | append $words_b | sort | uniq | length)
  ($intersection | into float) / ($union_size | into float)
}

def check-crate-doc-drift [root: string, nodes: list<record>, scan: record]: nothing -> list<record> {
  let crates = ($scan.crates? | default [])
  if ($crates | is-empty) { return [] }

  mut drifts = []

  for node in $nodes {
    let paths = ($node.artifact_paths? | default [])
    if ($paths | is-empty) { continue }

    let node_desc = ($node.description? | default "" | str trim)
    # First sentence of description for summary-level comparison.
    # Split on ". " (period + space) to avoid breaking on path fragments like ".ontology/".
    let node_summary = ($node_desc | split row ". " | where { |s| not ($s | str trim | is-empty) } | if ($in | is-not-empty) { first | str trim } else { "" })

    # Only check the FIRST artifact_path for crate-doc drift.
    # A crate appearing mid-list means "this crate implements the concept" —
    # different abstraction level from the concept description itself.
    let first_path = ($paths | if ($in | is-not-empty) { first } else { "" })
    let p_norm = ($first_path | str replace --regex '/$' "")
    let matched = ($crates | where { |c| $c.path == $p_norm })

    if ($matched | is-not-empty) {
      let cr = ($matched | first)
      let crate_doc = ($cr.crate_doc? | default "" | str trim)

      if ($crate_doc | is-not-empty) {
        let crate_first = ($crate_doc | lines | where { |l| $l | is-not-empty } | if ($in | is-not-empty) { first } else { "" })

        if ($node_summary | is-empty) {
          $drifts = ($drifts | append {
            status:       "DRIFT",
            id:           $node.id,
            artifact_path: $cr.path,
            detail:       $"Node has no description. Crate //! doc: \"($crate_first | str substring 0..120)\"",
          })
        } else {
          # Compare first sentences: both are "summary" granularity regardless of total length.
          let overlap = (word-overlap $node_summary $crate_first)
          if $overlap < 0.20 {
            $drifts = ($drifts | append {
              status:       "DRIFT",
              id:           $node.id,
              artifact_path: $cr.path,
              detail:       $"Doc mismatch \(overlap: ($overlap * 100 | math round)%\). Node: \"($node_summary | str substring 0..80)\" | Crate: \"($crate_first | str substring 0..80)\"",
            })
          }
        }
      }
    }
  }

  $drifts
}

def scan-scenarios [root: string]: nothing -> list {
  let scenarios_dir = $"($root)/reflection/scenarios"
  if not ($scenarios_dir | path exists) {
    # Fallback: check examples/
    let examples_dir = $"($root)/examples"
    if not ($examples_dir | path exists) { return [] }
    ls $examples_dir | where type == "dir" | each { |d|
      {
        category: ($d.name | path basename),
        path: ($d.name | path relative-to $root),
        description: "",
        files: (glob $"($d.name)/*" | length),
        actor: "developer",
      }
    }
  } else {
    ls $scenarios_dir | where type == "dir" | each { |d|
      let meta_file = $"($d.name)/scenario.ncl"
      let meta = if ($meta_file | path exists) {
        daemon-export-safe $meta_file | default {}
      } else { {} }
      {
        category: ($d.name | path basename),
        path: ($d.name | path relative-to $root),
        description: ($meta | get -o purpose | default ""),
        files: (glob $"($d.name)/*" | length),
        actor: ($meta | get -o actor | default "developer"),
        validates: ($meta | get -o validates | default []),
      }
    }
  }
}

def scan-agents [root: string]: nothing -> list {
  let agent_files = (glob $"($root)/**/*.agent.mdx")
  $agent_files | each { |f|
    {
      name: ($f | path basename | str replace ".agent.mdx" ""),
      path: ($f | path relative-to $root),
      format: "mdx",
    }
  }
}

def scan-ci [root: string]: nothing -> list {
  mut providers = []
  if ($"($root)/.github/workflows" | path exists) {
    let wf = (glob $"($root)/.github/workflows/*.yml" | append (glob $"($root)/.github/workflows/*.yaml"))
    for f in $wf {
      $providers = ($providers | append { provider: "github-actions", path: ($f | path relative-to $root) })
    }
  }
  if ($"($root)/.woodpecker" | path exists) {
    let wf = (glob $"($root)/.woodpecker/*.yml" | append (glob $"($root)/.woodpecker/*.yaml"))
    for f in $wf {
      $providers = ($providers | append { provider: "woodpecker", path: ($f | path relative-to $root) })
    }
  }
  $providers
}

def scan-forms [root: string]: nothing -> list {
  let forms_dir = $"($root)/reflection/forms"
  if not ($forms_dir | path exists) { return [] }
  glob $"($forms_dir)/*.ncl" | each { |f|
    { name: ($f | path basename | str replace ".ncl" ""), path: ($f | path relative-to $root) }
  }
}

def scan-modes [root: string]: nothing -> list {
  # Collect modes from both ONTOREF_ROOT and project root
  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 = ($ontoref_modes | append $project_modes | uniq)

  $all | each { |f|
    { name: ($f | path basename | str replace ".ncl" ""), path: $f }
  }
}

# ── Justfile scanner ────────────────────────────────────────────────────────
# Detects justfile modules, recipes, module system (import/mod), and variables.

def scan-justfiles [root: string]: nothing -> record {
  let justfile_path = $"($root)/justfile"
  if not ($justfile_path | path exists) {
    return { exists: false, system: "", modules: [], recipes: [], variables: [] }
  }

  let raw = (open $justfile_path --raw)
  let lines = ($raw | lines)

  # Detect module system: import vs mod
  let has_import = ($lines | any { |l| ($l | str trim | str starts-with "import ") or ($l | str trim | str starts-with "import?") })
  let has_mod = ($lines | any { |l| ($l | str trim | str starts-with "mod ") or ($l | str trim | str starts-with "mod?") })
  let system = if $has_import and $has_mod { "hybrid" } else if $has_import { "import" } else if $has_mod { "mod" } else { "flat" }

  # Extract module declarations
  let mod_lines = ($lines | where { |l|
    let t = ($l | str trim)
    ($t | str starts-with "import ") or ($t | str starts-with "import? ") or ($t | str starts-with "mod ") or ($t | str starts-with "mod? ")
  })
  let modules = ($mod_lines | each { |l|
    let t = ($l | str trim)
    let optional = ($t | str starts-with "import?") or ($t | str starts-with "mod?")
    let path_match = ($t | parse --regex "'([^']+)'|\"([^\"]+)\"")
    let mod_path = if ($path_match | is-not-empty) {
      let m = ($path_match | first)
      let c0 = ($m | get -o capture0 | default "")
      let c1 = ($m | get -o capture1 | default "")
      if ($c0 | is-not-empty) { $c0 } else { $c1 }
    } else { "" }
    let name_match = ($t | parse --regex '(?:mod\??\s+)(\w+)')
    let name = if ($name_match | is-not-empty) {
      $name_match | first | get capture0
    } else {
      $mod_path | path basename | str replace ".just" ""
    }
    { name: $name, path: $mod_path, optional: $optional }
  })

  # Scan justfiles/ directory for .just files
  let justfiles_dir = $"($root)/justfiles"
  let just_files = if ($justfiles_dir | path exists) {
    glob $"($justfiles_dir)/*.just" | each { |f| $f | path relative-to $root }
  } else { [] }

  # Extract top-level variables (VAR := "value" or VAR := `command`)
  let variables = ($lines | where { |l|
    let t = ($l | str trim)
    ($t =~ '^\w+\s*:=') and (not ($t | str starts-with "#"))
  } | each { |l|
    let parts = ($l | str trim | split row ":=" | each { |p| $p | str trim })
    if ($parts | length) >= 2 { $parts | first } else { "" }
  } | where { |v| $v | is-not-empty })

  # Extract recipe names (lines matching "name:", "@name:", "name PARAM:", "name PARAM="val":")
  let recipes = ($lines | where { |l|
    let t = ($l | str trim)
    ($t =~ '^@?\w[\w-]*(\s.*)?:') and (not ($t | str starts-with "#")) and (not ($t =~ '^\w+\s*:='))
  } | each { |l|
    let t = ($l | str trim | str replace "@" "")
    $t | split row " " | first | str replace ":" ""
  })

  {
    exists: true,
    system: $system,
    modules: $modules,
    module_files: $just_files,
    recipes: $recipes,
    variables: $variables,
  }
}

# ── .claude/ scanner ───────────────────────────────────────────────────────
# Inventories .claude/ capabilities: guidelines, commands, hooks, agents, skills.

def scan-claude [root: string]: nothing -> record {
  let claude_dir = $"($root)/.claude"
  if not ($claude_dir | path exists) {
    return { exists: false, has_claude_md: false, guidelines: [], commands: [], hooks: [], settings: [] }
  }

  let has_claude_md = ($"($claude_dir)/CLAUDE.md" | path exists)

  # Guidelines: check each language subdirectory
  let guidelines_dir = $"($claude_dir)/guidelines"
  let guidelines = if ($guidelines_dir | path exists) {
    ls $guidelines_dir | where type == "dir" | each { |d|
      let lang = ($d.name | path basename)
      let files = (glob $"($d.name)/*.md" | each { |f| $f | path basename })
      { language: $lang, files: $files }
    }
  } else { [] }

  # Commands
  let commands_dir = $"($claude_dir)/commands"
  let commands = if ($commands_dir | path exists) {
    glob $"($commands_dir)/*.md" | each { |f|
      let name = ($f | path basename | str replace ".md" "")
      let is_symlink = ((do { ^test -L $f } | complete).exit_code == 0)
      { name: $name, symlink: $is_symlink }
    }
  } else { [] }

  # Hooks (check settings.json for hook definitions)
  let settings_path = $"($claude_dir)/settings.json"
  let hooks = if ($settings_path | path exists) {
    let settings = (open $settings_path)
    let hook_events = ($settings | get -o hooks | default {} | columns)
    $hook_events
  } else { [] }

  # Settings files
  let settings = (glob $"($claude_dir)/settings*.json" | each { |f| $f | path basename })

  # Layout conventions
  let has_layout = ($"($claude_dir)/layout_conventions.md" | path exists)

  # Session hook — check both legacy and current locations
  let has_session_hook = (
    ($"($claude_dir)/hooks/session-context.sh" | path exists)
    or ($"($claude_dir)/ontoref-session-start.sh" | path exists)
  )

  {
    exists: true,
    has_claude_md: $has_claude_md,
    has_layout_conventions: $has_layout,
    has_session_hook: $has_session_hook,
    guidelines: $guidelines,
    commands: $commands,
    hooks: $hooks,
    settings: $settings,
  }
}

# Load ontology via daemon-export (full fidelity, includes all fields).
def load-ontology [root: string]: nothing -> record {
  let core_ncl = $"($root)/.ontology/core.ncl"
  if not ($core_ncl | path exists) { return {} }
  let data = (daemon-export-safe $core_ncl)
  if $data == null {
    print "  Warning: failed to export core.ncl"
    return {}
  }
  $data
}

# Load ontology by parsing NCL text directly — no nickel binary needed.
# Extracts id and artifact_paths from node blocks, and from/to from edge blocks.
# Intentionally lossy: sufficient for drift detection in pre-commit/quick mode.
def load-ontology-quick [root: string]: nothing -> record {
  let core_ncl = $"($root)/.ontology/core.ncl"
  if not ($core_ncl | path exists) { return {} }

  let raw = (open $core_ncl --raw)
  let lines = ($raw | lines)

  mut nodes = []
  mut edges = []
  mut in_node = false
  mut in_paths = false
  mut in_edge_section = false
  mut in_edge = false
  mut edge_from = ""
  mut edge_to = ""
  mut current_id = ""
  mut current_paths = []

  for line in $lines {
    let trimmed = ($line | str trim)

    # Detect section boundaries
    if ($trimmed | str starts-with "nodes = [") {
      $in_edge_section = false
    } else if ($trimmed | str starts-with "edges = [") {
      $in_edge_section = true
    }

    # Parse node blocks
    if not $in_edge_section {
      if ($trimmed | str contains "d.make_node") {
        $in_node = true
        $in_paths = false
        $current_id = ""
        $current_paths = []
      } else if $in_node and ($trimmed | str starts-with "},") {
        if ($current_id | is-not-empty) {
          $nodes = ($nodes | append { id: $current_id, artifact_paths: $current_paths })
        }
        $in_node = false
        $in_paths = false
      } else if $in_node {
        # Extract id
        if ($trimmed | str starts-with "id ") {
          let id_match = ($trimmed | parse --regex 'id\s+=\s+"([^"]+)"')
          if ($id_match | is-not-empty) {
            $current_id = ($id_match | first | get capture0)
          }
        }
        # Extract artifact_paths (handles multiline arrays)
        if ($trimmed | str starts-with "artifact_paths") {
          $in_paths = true
          let paths_match = ($trimmed | parse --regex '"([^"]+)"')
          if ($paths_match | is-not-empty) {
            $current_paths = ($current_paths | append ($paths_match | get capture0))
          }
        } else if $in_paths {
          if ($trimmed | str starts-with "],") or ($trimmed == "]") {
            $in_paths = false
          } else {
            let paths_match = ($trimmed | parse --regex '"([^"]+)"')
            if ($paths_match | is-not-empty) {
              $current_paths = ($current_paths | append ($paths_match | get capture0))
            }
          }
        }
      }
    }

    # Parse edge blocks (single-line or multi-line d.make_edge)
    if $in_edge_section and ($trimmed | str contains "d.make_edge") {
      $in_edge = true
      $edge_from = ""
      $edge_to = ""
      # Check if single-line (from + to on same line as d.make_edge)
      let from_match = ($trimmed | parse --regex 'from\s*=\s*"([^"]+)"')
      let to_match = ($trimmed | parse --regex 'to\s*=\s*"([^"]+)"')
      if ($from_match | is-not-empty) { $edge_from = ($from_match | first | get capture0) }
      if ($to_match | is-not-empty) { $edge_to = ($to_match | first | get capture0) }
      if ($edge_from | is-not-empty) and ($edge_to | is-not-empty) {
        $edges = ($edges | append { from: $edge_from, to: $edge_to })
        $in_edge = false
      }
    } else if $in_edge {
      # Multi-line: accumulate from/to across lines
      let from_match = ($trimmed | parse --regex 'from\s*=\s*"([^"]+)"')
      let to_match = ($trimmed | parse --regex 'to\s*=\s*"([^"]+)"')
      if ($from_match | is-not-empty) { $edge_from = ($from_match | first | get capture0) }
      if ($to_match | is-not-empty) { $edge_to = ($to_match | first | get capture0) }
      if ($trimmed == "}," or $trimmed == "}") {
        if ($edge_from | is-not-empty) and ($edge_to | is-not-empty) {
          $edges = ($edges | append { from: $edge_from, to: $edge_to })
        }
        $in_edge = false
      }
    }
  }

  { nodes: $nodes, edges: $edges }
}

# Collect all paths that exist in the scan output (relative to root).
def collect-existing-paths [scan: record, root: string]: nothing -> list<string> {
  mut paths = []

  for c in $scan.crates {
    $paths = ($paths | append $c.path)
  }
  for s in $scan.scenarios {
    $paths = ($paths | append $s.path)
  }
  for a in $scan.agents {
    $paths = ($paths | append $a.path)
  }
  for ci in $scan.ci {
    $paths = ($paths | append $ci.path)
  }
  # Forms
  for f in ($scan.forms? | default []) {
    $paths = ($paths | append $f.path)
  }
  # Modes
  for m in ($scan.modes? | default []) {
    $paths = ($paths | append $m.path)
  }
  # Justfile module files
  if ($scan.justfiles?.module_files? | default [] | is-not-empty) {
    for jf in $scan.justfiles.module_files {
      $paths = ($paths | append $jf)
    }
  }
  # Entry points
  if ($"($root)/onref" | path exists) { $paths = ($paths | append "onref") }
  if ($"($root)/onref" | path exists) { $paths = ($paths | append "onref") }

  $paths
}

# Find scan artifacts not claimed by any node's artifact_paths.
def find-unclaimed-artifacts [scan: record, root: string, node_paths: list<string>]: nothing -> list {
  mut unclaimed = []

  for c in $scan.crates {
    let crate_rel = $c.path
    let claimed = ($node_paths | any { |np| $crate_rel starts-with $np or $np starts-with $crate_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $c.name, path: $crate_rel, kind: "crate" })
    }
  }

  for s in $scan.scenarios {
    let scen_rel = $s.path
    let claimed = ($node_paths | any { |np| $scen_rel starts-with $np or $np starts-with $scen_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $"scenario-($s.category)", path: $scen_rel, kind: "scenario" })
    }
  }

  for a in $scan.agents {
    let agent_rel = $a.path
    let claimed = ($node_paths | any { |np| $agent_rel starts-with $np or $np starts-with $agent_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $"agent-($a.name)", path: $agent_rel, kind: "agent" })
    }
  }

  for ci in $scan.ci {
    let ci_rel = $ci.path
    let claimed = ($node_paths | any { |np| $ci_rel starts-with $np or $np starts-with $ci_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $"ci-($ci.provider)", path: $ci_rel, kind: "ci" })
    }
  }

  for f in ($scan.forms? | default []) {
    # form.path may be absolute — normalize to relative for comparison
    let form_rel = if ($f.path | str starts-with $root) {
      $f.path | path relative-to $root
    } else { $f.path }
    let claimed = ($node_paths | any { |np| $form_rel starts-with $np or $np starts-with $form_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $"form-($f.name)", path: $form_rel, kind: "form" })
    }
  }

  for m in ($scan.modes? | default []) {
    # Skip global framework modes (live under ONTOREF_ROOT, not project root)
    if not ($m.path | str starts-with $root) { continue }
    let mode_rel = $m.path | path relative-to $root
    let claimed = ($node_paths | any { |np| $mode_rel starts-with $np or $np starts-with $mode_rel })
    if not $claimed {
      $unclaimed = ($unclaimed | append { id: $"mode-($m.name)", path: $mode_rel, kind: "mode" })
    }
  }

  $unclaimed
}

def detect-artifact-kind [path: string]: nothing -> string {
  if ($path | str contains "crates/") {
    "crate"
  } else if ($path | str contains "scenarios/") or ($path | str contains "examples/") {
    "scenario"
  } else if ($path | str contains ".agent.mdx") {
    "agent"
  } else if ($path | str contains ".github/") or ($path | str contains ".woodpecker/") {
    "ci"
  } else if ($path | str contains "justfiles/") or ($path == "justfile") {
    "justfile"
  } else if ($path | str contains ".claude/") {
    "claude"
  } else if ($path | str contains "reflection/modules/") {
    "module"
  } else if ($path | str contains "reflection/modes/") {
    "mode"
  } else {
    "unknown"
  }
}

def generate-node-ncl [id: string, path: string, kind: string]: nothing -> string {
  let level = match $kind {
    "crate" => "'Project",
    "scenario" => "'Moment",
    "agent" => "'Practice",
    _ => "'Project",
  }
  [
    $"    d.make_node {",
    $"      id              = \"($id)\",",
    $"      name            = \"($id | str replace -a '-' ' ' | split words | each { |w| ($w | str capitalize) } | str join ' ')\",",
    $"      pole            = 'Yang,",
    $"      level           = ($level),",
    $"      description     = \"Detected from ($kind) at ($path)\",",
    $"      invariant       = false,",
    $"      artifact_paths  = [\"($path)\"],",
    $"    },",
  ] | str join "\n"
}

def audit-adr-constraints [root: string]: nothing -> list {
  let adr_files = (glob $"($root)/adrs/adr-*.ncl")
  mut results = []

  for f in $adr_files {
    let adr = (daemon-export-safe $f)
    if $adr == null { continue }
    if ($adr.status? | default "") != "Accepted" { continue }

    let constraints = ($adr.constraints? | default [])
    for c in $constraints {
      let severity = ($c.severity? | default "")
      if $severity not-in ["Hard", "Soft"] { continue }
      let hint = ($c.check_hint? | default "")
      if ($hint | is-empty) { continue }

      # Guard: only execute check_hints that start with a known safe command prefix.
      # Prevents arbitrary shell injection from ADR constraint fields.
      # Only read-only commands that do NOT accept code execution flags.
      # Excluded: nu (accepts -c), cargo (accepts run), nickel (accepts eval),
      # cat/jq (can be piped into shell via bash -c).
      let hint_trimmed = ($hint | str trim)
      let parts = ($hint_trimmed | split row " ")
      let cmd = ($parts | first)
      let args = ($parts | skip 1)

      let safe_commands = ["rg", "grep", "test", "[", "ls", "wc", "file", "stat"]
      if $cmd not-in $safe_commands {
        $results = ($results | append {
          adr: ($adr.id? | default ""),
          constraint: ($c.id? | default ""),
          severity: $severity,
          status: "SKIP",
          detail: $"check_hint command not in whitelist: ($cmd)",
        })
        continue
      }

      # Block flags that allow code execution via whitelisted commands
      # (e.g., rg --pre <script> executes an external command per file).
      let dangerous_flags = ["--pre", "--pre-glob", "--exec", "--exec-batch"]
      let has_dangerous = ($args | any { |a| $a in $dangerous_flags })
      if $has_dangerous {
        $results = ($results | append {
          adr: ($adr.id? | default ""),
          constraint: ($c.id? | default ""),
          severity: $severity,
          status: "SKIP",
          detail: $"check_hint contains blocked flag: ($args | str join ' ')",
        })
        continue
      }

      let check = do { cd $root; run-external $cmd ...$args } | complete
      let status = if $check.exit_code == 0 { "PASS" } else { "FAIL" }
      $results = ($results | append {
        adr: ($adr.id? | default ""),
        constraint: ($c.id? | default ""),
        severity: $severity,
        status: $status,
        detail: ($c.claim? | default ""),
      })
    }
  }

  $results
}

# Load manifest safely — returns empty record if not available.
def load-manifest-safe [root: string]: nothing -> record {
  let manifest_ncl = $"($root)/.ontology/manifest.ncl"
  if not ($manifest_ncl | path exists) { return {} }

  let ontoref_ontology = if ($env.ONTOREF_ROOT? | is-not-empty) { $"($env.ONTOREF_ROOT)/ontology" } else { "" }
  let import_path = ([$"($root)/.ontology", $ontoref_ontology, ($env.NICKEL_IMPORT_PATH? | default "")] | where { $in | is-not-empty } | str join ":")

  daemon-export-safe $manifest_ncl --import-path $import_path | default {}
}

# Verify tools declared in manifest are available on PATH.
def audit-tools [manifest: record]: nothing -> list {
  let tools = ($manifest.tools? | default [])
  if ($tools | is-empty) { return [] }

  $tools | each { |t|
    let name = $t.name
    let required = ($t.required? | default true)
    let found = (which $name | is-not-empty)
    let status = if $found { "PASS" } else if $required { "MISSING" } else { "OPTIONAL" }
    let method = ($t.install_method? | default "Builtin")
    { check: $"tool-($name)", status: $status, detail: $"($name) [($method)]" }
  }
}

# Check justfile completeness against manifest convention or canonical defaults.
def audit-justfiles [justfiles: record, manifest: record]: nothing -> list {
  if not ($justfiles.exists? | default false) { return [] }

  mut results = []
  let jf_conv = ($manifest.justfile? | default {})
  let canonical_required = ($jf_conv.required_modules? | default ["build" "test" "dev" "ci"])
  let canonical_recipes = ($jf_conv.required_recipes? | default ["default" "help"])

  let module_names = ($justfiles.modules? | default [] | get name)
  for req in $canonical_required {
    let found = ($module_names | any { |n| $n == $req })
    let status = if $found { "PASS" } else { "MISSING" }
    $results = ($results | append { check: $"justfile-module-($req)", status: $status, detail: $"Canonical module '($req)'" })
  }

  let recipe_names = ($justfiles.recipes? | default [])
  for req in $canonical_recipes {
    let found = ($recipe_names | any { |r| $r == $req })
    let status = if $found { "PASS" } else { "MISSING" }
    $results = ($results | append { check: $"justfile-recipe-($req)", status: $status, detail: $"Required recipe '($req)'" })
  }

  $results
}

# Check .claude/ completeness against manifest baseline or defaults.
def audit-claude [claude: record, manifest: record]: nothing -> list {
  if not ($claude.exists? | default false) {
    return [{ check: "claude-dir", status: "MISSING", detail: ".claude/ directory not found" }]
  }

  mut results = []

  let checks = [
    ["claude-md" ($claude.has_claude_md? | default false) "CLAUDE.md"]
    ["layout-conventions" ($claude.has_layout_conventions? | default false) "layout_conventions.md"]
    ["session-hook" ($claude.has_session_hook? | default false) "hooks/session-context.sh"]
  ]

  for c in $checks {
    let status = if $c.1 { "PASS" } else { "MISSING" }
    $results = ($results | append { check: $c.0, status: $status, detail: $c.2 })
  }

  # Check expected guideline languages (from manifest or defaults)
  let guideline_langs = ($claude.guidelines? | default [] | get language)
  let cl_baseline = ($manifest.claude? | default {})
  let expected_langs = ($cl_baseline.guidelines? | default ["bash" "nushell"])
  for lang in $expected_langs {
    let found = ($guideline_langs | any { |l| $l == $lang })
    let status = if $found { "PASS" } else { "MISSING" }
    $results = ($results | append { check: $"guideline-($lang)", status: $status, detail: $"Guidelines for ($lang)" })
  }

  $results
}

# Audit manifest capability completeness.
# Cross-references Practice nodes, reflection modes, and daemon UI pages
# against declared capabilities[].artifacts and capabilities[].nodes to find
# undeclared functionality that agents and sessions will never see.
def audit-manifest-coverage [root: string]: nothing -> list {
  let manifest = (load-manifest-safe $root)
  let caps = ($manifest.capabilities? | default [])

  if ($caps | is-empty) {
    return [{
      check: "manifest-capabilities",
      status: "FAIL",
      detail: "No capabilities declared in manifest.ncl — project functionality invisible to agents",
      severity: "Hard",
    }]
  }

  let all_cap_nodes = ($caps | each { |c| $c.nodes? | default [] } | flatten | uniq)
  let all_cap_artifacts = ($caps | each { |c| $c.artifacts? | default [] } | flatten)

  mut results = []

  # Check 1: Practice nodes without any capability referencing them
  let core_ncl = $"($root)/.ontology/core.ncl"
  if ($core_ncl | path exists) {
    let core = (daemon-export-safe $core_ncl)
    if $core != null {
      let practices = ($core.nodes? | default [] | where { |n| ($n.level? | default "") == "Practice" })
      let uncovered = ($practices | where { |p|
        not ($all_cap_nodes | any { |n| $n == $p.id })
      })
      for p in $uncovered {
        $results = ($results | append {
          check: "practice-node-uncovered",
          status: "WARN",
          detail: $"Practice node '($p.id)' \(($p.name)\) has no capability referencing it in nodes[]",
          severity: "Soft",
        })
      }
    }
  }

  # Check 2: Reflection modes not mentioned in any capability's artifacts
  let modes_dir = $"($root)/reflection/modes"
  if ($modes_dir | path exists) {
    let mode_files = (glob $"($modes_dir)/*.ncl" | each { |f| $f | path basename | str replace ".ncl" "" })
    for mode_id in $mode_files {
      let mode_path = $"reflection/modes/($mode_id).ncl"
      let referenced = ($all_cap_artifacts | any { |a|
        ($a == $mode_path) or ($a | str starts-with "reflection/modes/")
      })
      if not $referenced {
        $results = ($results | append {
          check: "mode-uncovered",
          status: "WARN",
          detail: $"Reflection mode '($mode_id)' not referenced by any capability — invisible to agents",
          severity: "Soft",
        })
      }
    }
  }

  # Check 3: Capability quality — summaries too thin or missing artifacts
  for c in $caps {
    let summary = ($c.summary? | default "")
    if ($summary | str length) < 30 {
      $results = ($results | append {
        check: "capability-thin-summary",
        status: "WARN",
        detail: $"Capability '($c.id)' summary under 30 chars — too thin for agent comprehension",
        severity: "Soft",
      })
    }
    let arts = ($c.artifacts? | default [])
    if ($arts | is-empty) {
      $results = ($results | append {
        check: "capability-no-artifacts",
        status: "WARN",
        detail: $"Capability '($c.id)' declares no artifacts — cannot verify existence",
        severity: "Soft",
      })
    }
  }

  if ($results | is-empty) {
    [{
      check: "manifest-coverage",
      status: "PASS",
      detail: $"All ($caps | length) capabilities verified: practice nodes covered, modes referenced, summaries adequate",
      severity: "Hard",
    }]
  } else {
    $results
  }
}

def audit-gates [root: string, diff: list]: nothing -> list {
  let gate_ncl = $"($root)/.ontology/gate.ncl"
  if not ($gate_ncl | path exists) { return [] }

  let gates = (daemon-export-safe $gate_ncl)
  if $gates == null { return [] }
  let membranes = ($gates.membranes? | default [])

  # Collect node ids that are not OK (MISSING, STALE, BROKEN)
  let bad_nodes = ($diff | where status != "OK" | get id)

  $membranes | each { |m|
    let active = ($m.active? | default false)
    let protects = ($m.protects? | default [])

    # An active gate is in BREACH when any node it protects is not OK
    let breached_nodes = if $active and ($protects | is-not-empty) {
      $protects | where { |p| $bad_nodes | any { |b| $b == $p } }
    } else { [] }

    let status = if not $active {
      "INACTIVE"
    } else if ($breached_nodes | is-not-empty) {
      "BREACH"
    } else {
      "ACTIVE"
    }

    {
      id: ($m.id? | default ""),
      status: $status,
      protects: $protects,
      breached_nodes: $breached_nodes,
    }
  }
}
