Skip to content

Findings Routing

After the Critic runs, the orchestrator merges the critic's findings (across all three audit axes) and routes each to the right next-spawn destination. Routing is deterministic: finding category → destination is a lookup in lib/nubosloop.cjs::ROUTE_TABLE, not a heuristic.

ADR-0010 Step 6 ratifies the design. The implementation is lib/nubosloop.cjs::routeFindings.

Since the Single-Critic Revision (2026-05-05) one np-critic agent emits findings across all three axes in a single JSON. The Source critic column below points at the audit-surface module that defines the category (agents/np-critic-{style,tests,acceptance}.md, module: true); these are not spawnable agents, the parent np-critic spawn loads them via Read.

The full routing table

Finding categorySource criticRouted toEffect on loop
stylestyle moduleExecutor / Build-FixerRound++
dead-codestyle moduleExecutor / Build-FixerRound++
dangling-threadstyle moduleExecutor / Build-FixerRound++
todo-markerstyle moduleExecutor / Build-FixerRound++
import-hygienestyle moduleExecutor / Build-FixerRound++
comment-hygienestyle moduleExecutor / Build-FixerRound++
lint-violationstyle moduleExecutor / Build-FixerRound++
critic-errorcritic (hard-stop)stuckLoop terminates
rule-9-violationloop-audit-tool-use (Rule 9 enforcement)Executor / Build-FixerRound++ — re-run with explicit instruction to invoke search-knowledge
missing-testtests moduleExecutor / Build-FixerRound++
edge-case-gaptests moduleExecutor / Build-FixerRound++
weak-assertiontests moduleExecutor / Build-FixerRound++
silenced-failuretests moduleExecutor / Build-FixerRound++
test-namingtests moduleExecutor / Build-FixerRound++
non-deterministictests moduleExecutor / Build-FixerRound++
verify-mismatchtests moduleExecutor / Build-FixerRound++
unmet-criterionacceptance moduleExecutor / Build-FixerRound++
scope-creepacceptance moduleExecutor / Build-FixerRound++
information-missingacceptance moduleResearcher-SchwarmNew Researcher round
question-to-useracceptance moduleaskuser (Temporal-style signal-wait)Loop pauses
locked-decision-violationacceptance modulenp-plan-checkerPlan-checker re-run; potential plan revision
infrastructure-mismatchacceptance modulenp-plan-checkerContainer/runtime skew — milestone-level infra config bumps
stuck-detectedcriticstuckLoop terminates

Rule 9 audit injection (rule-9-violation)

rule-9-violation findings are not emitted by any Critic. They originate from the mechanical tool-use audit (Step 4 of the Nubosloop). The orchestrator persists each spawn's audit verdict via loop-audit-tool-use with the current round stamp; at loop-run-round --phase post-critics, auditFindingsForRound(taskId, round, cwd) converts the round's violations into findings with category = rule-9-violation and critic = audit.

These synthetic findings join the merged Critic-Schwarm output before routing, so they:

  • Participate in the same fingerprint dedup (a Critic that already flagged the same gap collapses with the audit finding instead of double-counting).
  • Sort by the same (confirmation, severity, category) order — confirmed_by: ['audit'], severity fail.
  • Route via ROUTE_TABLE['rule-9-violation'] = 'executor' so the next round re-runs the spawn with explicit instruction to invoke search-knowledge.

The round stamp on each audit entry keeps the chain idempotent: re-running --phase post-critics for the same round produces the same findings; advancing the round filters out the prior round's audits.

Merge before routing

lib/nubosloop.cjs::mergeCriticOutputs deduplicates findings by fingerprint:

fingerprint = category | file (lowercased) | line | first-80-chars-of-remediation (lowercased)

The single np-critic spawn emits one merged JSON across all three axes. Fingerprint dedup still matters because the same gap is sometimes surfaced by both an explicit findings[] entry AND auto-promoted from a criteria[] Unsatisfied verdict. After dedup, the merged list sorts by:

  1. Confirmation count (descending). confirmed_by is a single-element list (['critic'] or ['audit']) under the post-Single-Critic regime; legacy three-Critic markers (['style','tests','acceptance']) are still accepted by mergeCriticOutputs for backward-compat with cached outputs.
  2. Severity (fail < risk < nit).
  3. Category (lexicographic) for stable tie-breaking.

The next-round prompt therefore highlights the most critical findings first, and the Executor / Build-Fixer addresses them in priority order.

Verdict-Only Contract (Cost Layer L5). The critic writes the full findings JSON to <report_path> (provided in the spawn prompt). loop-run-round --phase post-critics --critic-outputs-path <file> reads it directly, so only a small envelope traverses parent context. Inline --critic-outputs <json> is the legacy fallback; both at once is loop-run-round-post-critics-conflicting-outputs. See ADR-0010 §L5.

Decision rules

routeFindings(findings) runs after the merge. The next destination is decided by:

ConditionNext destination
any finding has category = stuck-detected OR loop.maxRounds reachedstuck
any finding routes to askuseraskuser
any finding routes to plan-checkerplan-checker
any finding routes to researcherresearcher
any finding routes to executorexecutor
no findingscommit

askuser and plan-checker outrank researcher and executor because they require human or planner intervention, which the executor cannot resolve.

Stuck escalation

Each routed round increments the per-task loop counter persisted in lib/checkpoint.cjs (under the nubosloop key). When the round reaches loop.maxRounds (default 3):

  1. evaluateLoop returns { next_action: 'stuck', stuck: true }.
  2. The orchestrator writes a stuck marker to STATE.md with the last Critic findings appended.
  3. np:dashboard shows the marker with a red badge.
  4. askuser prompts the operator: continue with manual override, escalate to a human developer, or np:reset-slice and re-plan.

The stuck state is first-class; it never silently downgrades to "good enough". This makes Rule 12 (Boil the ocean) of the Completeness Doctrine mechanical.

Worked routing trace

Round 1 of a task that touches an unfamiliar API:

np-critic envelope (final message, ~150 bytes):
  { critic: "critic", task_id: "M001-S001-T0001", round: 1,
    verdict: "issues_found", blockers_count: 3,
    report_path: "/tmp/nubos-pilot/critic-reports/critic-M001-S001-T0001-r1.json",
    run_id: "…" }

loop-run-round --phase post-critics --critic-outputs-path <report_path>
  reads the file:
    findings[]: 3 entries
      [ todo-marker          @ src/api.php:42                       (severity=fail) ]
      [ missing-test         @ tests/Feature/ApiTest.php            (severity=fail) ]
      [ information-missing  @ —                                    (severity=fail, "Need GetAG webhook spec") ]
    criteria[]:  (auto-promoted to findings as needed)

mergeCriticOutputs (no duplicates by fingerprint):
  3 findings retained, sorted by severity (all `fail`), then category.

routeFindings:
  todo-marker         → executor   (bucket: executor)
  missing-test        → executor   (bucket: executor)
  information-missing → researcher (bucket: researcher)

next_destination decision:
  - askuser: empty
  - plan-checker: empty
  - researcher: 1 finding → next = researcher

→ Spawn Researcher-Schwarm for "GetAG webhook spec".
  Round counter advances to 2.
  After researcher returns, Executor consumes consensus + addresses style/test findings.

Configuration

The route table is fixed in lib/nubosloop.cjs::ROUTE_TABLE. Adding a new finding category requires a code change plus an ADR amendment. This rigidity is intentional: it keeps routing deterministic and audit-friendly.