Imported from bllshttng/footnote (
skills/triage/SKILL.md). Install upstream withnpx skills add bllshttng/footnote --skill triage. Copyright stays with the author.
Triage
Propose an optimal ordering for pending specs on ~/.fno/graph.json
based on dependencies, sequencing, priority versus project goals, and
duplicate detection.
Philosophy
- LLM proposes, human approves. Triage never auto-applies priority changes — priority is a business decision.
- Shallow by default. Metadata-only reasoning keeps token cost low
for large backlogs. Use
deepwhen you want richer inference from the plan contents. - Cycles are dropped automatically. If a proposed dependency would create a cycle (A blocks B, B blocks A), validation drops the offending edge and logs the conflict.
- Project-scoped by default. Triage filters to the current repo's
project (matched by canonical repo root, stable across worktrees).
Pass
allto triage across every project or--project <name>to target a specific one. Matches the scoping ofroadmap-tasks.py next/ready.
Setup
Project scoping requires at least one node registered to the current repo.
The first /blueprint with auto-intake (or a manual roadmap-tasks.py intake)
stores the canonical repo root as the node's cwd and derives a project
name from the repo directory basename. Subsequent triage runs from the
same repo — including from any git worktree of it — auto-detect that
project.
If no project is detected (no adopted node yet for this repo), triage
falls through to all projects and prints a hint in the scope line so
the behavior is visible, not silent.
Invocation
Positional modifiers (boolean switches): deep, all, dry-run.
Value-carriers (flags with arguments): --project NAME, --roadmap-id ID.
| Form | Effect |
|---|---|
/triage |
Shallow triage, scoped to the current project |
/triage deep |
Read the first 150 lines of each plan_path for richer inference |
/triage all |
Include pending nodes from every project |
/triage each |
Iterate through every project with pending nodes, running the full pipeline and per-project approval once per project |
/triage deep all |
Combine: cross-project, read plan contents |
/triage --project <name> |
Scope to a specific project by name |
/triage --roadmap-id rm-X |
Triage only nodes tagged to a specific roadmap |
/triage dry-run |
Print candidates only, no LLM call, no mutations |
Invocation surface (CLI-source-agnostic)
The skill issues triage verbs through whichever implementation is available. Preference order:
fno backlog triage <verb> <flags>- the v2 CLI (canonical)python3 scripts/triage.py <verb> <flags>- the shim, which itself forwards to v2 whenfnois on PATH or falls back to the in-repo module viacli/src
Both satisfy the same JSON protocol - callers see identical
input/output. Every scripts/triage.py … command in the steps below is
equivalent to fno backlog triage …; prefer the latter when fno is
on PATH so the call path is one process shorter.
Translating positional modifiers to the underlying command
Both implementations use standard argparse/Typer conventions
(--deep, --all, --dry-run). When executing a user invocation,
translate positional words into the equivalent flag:
| User typed | Pass through |
|---|---|
deep |
--deep |
all |
--all |
dry-run |
--dry-run |
each |
(no direct flag; orchestrates multi-run invocation in the skill, not the command) |
--project foo |
--project foo (unchanged) |
--roadmap-id rm-X |
--roadmap-id rm-X (unchanged) |
Mutually-exclusive and composition rules
eachcombined with--project <name>is an error. The skill must refuse this combination with the exact message"each and --project are mutually exclusive: --project scopes to one, each iterates many"and exit without calling the script. Confirms before any graph mutation.eachcombined withallis redundant (each is stricter). Silently prefereachand note in the scope line printed to the user.eachcomposes normally withdeep,dry-run, and--roadmap-id. Those modifiers forward into every per-project invocation.
Process
-
Gather context. Run the context generator to get pending nodes plus project goals from config.toml. Forward the user's positional modifiers as the matching flags (e.g.
deep->--deep):# v2 preferred; shim used when `fno` is not on PATH (fno backlog triage context [--deep] [--all] [--project NAME] [--roadmap-id ID] 2>/dev/null \ || python3 scripts/triage.py context [--deep] [--all] [--project NAME] [--roadmap-id ID]) \ > /tmp/triage-ctx.jsonThe context JSON has two top-level arrays:
candidates: claim-ready rows (status: readyorblocked). These are what the LLM should reason about for ordering, blocker edges, and priority changes.ideas: plan-less rows (status: idea). These are NOT claimable work - they are captured thoughts waiting for a spec. The LLM should mention them in its summary with the recommendation "write a spec for this idea before claiming it" rather than proposing dependency edges or claim ordering. Run/blueprintagainst the idea's title + details, thenfno backlog intake <plan_path>to flip the node tostatus: ready.
If
candidatesis empty ANDideasis empty, print "no pending nodes to triage" and exit. Done. Ifcandidatesis empty butideasis non-empty, surface the idea list to the user with the spec-it-first recommendation and exit (no LLM reasoning needed - there is no claim-ordering decision to make until a spec lands). -
LLM reasoning. Spawn a subagent (Task tool) with the context JSON and this instruction. Run the subagent at temperature 0 - triage is a classification task and determinism drives the 95%+ consistency target (
fno backlog triage consistencymeasures it).You are a backlog triage classifier. First reason, then label - this ordering (analysis before the JSON) is what makes the classification consistent, so never emit the JSON first.
First, in a short
## Reasoningsection, work through the candidates: which specs block which, which priorities are misaligned with the project goals, which are stale or duplicated. Name the primary concern of each spec you touch. When a spec raises several concerns at once, classify on its primary intent, not the loudest surface signal.Then, output an optimal ordering as JSON with four keys:
dependencies: edges where one spec must complete before another (e.g.{"from": "ab-X", "to": "ab-Y", "reason": "..."}means Y is blocked_by X).priority_changes: spec priority adjustments that align better with the project's stated goals.defer: nodes to pause with{"id": "ab-X", "reason": "..."}. Use when a node is stale, waiting on an external decision, or otherwise not suitable for the current cycle. Defer is reversible viafno backlog undefer <id>. Reason is required and is surfaced in the kanban view alongside the card.duplicates: specs that look like near-duplicates, with arecommendedaction of"merge"and a reason. To pause one of a pair without merging, use thedeferaction above instead.
Every entry MUST include a
reasonfield (one line) referencing the plan contents (deep mode) or the title/priority signal (shallow mode). An entry without a reason is dropped by validation, so a missing reason is a wasted proposal. Do not propose self-edges or edges that create cycles.Field reference: each candidate carries enrichment fields beyond the basic id/title/priority.
sizeis S/M/L (omit size-based reasoning when null).domainis the execution domain (code/research/etc).detailsis the user's implementation guidance captured at intake; cite it for shallow-mode reasoning instead of paying for a deep read.claim_history.session_count > 1means the node has been claimed and released before;claim_history.total_cost_usdis cumulative spend without shipping.ship_state.pr_numberset means a PR exists - the node may already be done and just needs adoneflip rather than re-triaging.ship_state.merge_statuscarries the PR's merge state (merged/closed/null).IMPORTANT: only reason over the
candidatesarray. Theideasarray contains plan-less rows that are not claimable yet - do NOT propose dependencies or priority changes for ideas. Pass them through unchanged in your summary so the user sees them and can spec them next.Examples (reasoning before label; classify on primary intent):
- Implicit dependency. Candidate
ab-9f"add rate-limit headers to the API" andab-3c"build the API gateway". The gateway is never named as a blocker, but headers cannot ship before the gateway exists. Reasoning: ab-9f's work sits on top of ab-3c's surface. Label:dependencies: [{"from":"ab-3c","to":"ab-9f","reason":"rate-limit headers need the gateway surface first"}]. - Emotion over intent. Candidate details read "this flaky test has wasted
HOURS, it is infuriating, the whole suite is garbage." The heat is noise;
the actionable intent is one flaky test.
Reasoning: strip the frustration, the core ask is de-flaking one test - a
normal p2, not a p0 because it is loud.
Label: no priority bump; a
deferonly if it is blocked on an external fix. - Multiple issues, pick the primary. Candidate details raise a perf regression, a typo in a log line, and a docs gap. The perf regression is the reason the node exists; the rest are incidental. Reasoning: classify on the perf regression (the primary), mention the others in the summary, do not split the node. Label: priority reflects the perf regression only.
The subagent returns proposal JSON.
Tournament ordering (large backlogs). When
count(fromtriage context) is large enough that a one-shot ordering gets unreliable - roughlycount >= 6- prefer comparative judgment over absolute scoring for the claim-order decision: ask the LLM to pick a winner per candidate PAIR ("ship X or Y first?") rather than rank all at once. Enumerate the candidate pairs, collect each verdict as{"winner": "ab-X", "loser": "ab-Y"}, write them to/tmp/triage-verdicts.json, then fold them into a single consistent order:fno backlog triage rank --verdicts /tmp/triage-verdicts.jsonIt aggregates by Copeland score (wins minus losses), tolerating the occasional contradictory or cyclic verdict, and emits a best-first
order. Apply that order to the board so board position matches work order: seed the top item withfno backlog rank <first-id> --top, then chain each remaining id after its predecessor withfno backlog rank <id> --after <previous-id>. Do NOT call--topfor every id in a forward pass:--topinserts before the current front of the lane, so iterating best-first would reverse the order (the last id processed would land first). Feed the ranking into thepriority_changesrationale too. For a small backlog the one-shot proposal above is cheaper and fine; reserve the pairwise pass for when the node count makes it worthwhile. -
Validate. Run:
(fno backlog triage validate /tmp/triage-proposal.json 2>/dev/null \ || python3 scripts/triage.py validate /tmp/triage-proposal.json) \ > /tmp/triage-cleaned.jsonStderr surfaces cycles, unknown IDs, or invalid priorities that were dropped. Stdout is the cleaned proposal.
-
Present to user via AskUserQuestion. Show a summary of the proposed changes with per-item rationale. Offer:
- Approve all — apply every edge, priority change, and flag every duplicate
- Pick — select a subset to apply (pass
--pick id1,id2,...toapply) - Reject — discard the proposal; exit 0
-
Apply. On approve or pick:
fno backlog triage apply /tmp/triage-cleaned.json [--pick id1,id2] \ || python3 scripts/triage.py apply /tmp/triage-cleaned.json [--pick id1,id2]Apply runs inside a single
locked_mutate_graph()sograph.mdre-renders once with all mutations. Report the applied count plus any flagged duplicates for the user to resolve manually. -
Suggest next step. If any dependencies or priorities changed, invite the user to run
/targetto pick the now-correctly-ordered top node.
Iteration Mode (each)
When each is present, the skill orchestrates the Process pipeline once
per project with pending nodes, preserving project boundaries so the LLM
never reasons across unrelated repos. This makes it structurally
impossible to propose cross-project dependency edges or flag
cross-project duplicates - the reasoner simply never sees the other
project's candidates.
-
Enumerate. Call
fno backlog triage projects(orpython3 scripts/triage.py projectsiffnois not on PATH) to get the list of projects with at least one pending node. -
Guard. If the list is empty, print
"no pending nodes in any project"and exit 0. Done. -
Iterate. For each project (in the order returned by the script):
a. Print a scope banner:
"=== Triaging project '{name}' ({count} pending) ==="b. Run the existing Process steps 1-5 scoped to that project by passing--project {name}on every script invocation. c. Forward any composable modifiers from the outer invocation:deep->--deep,dry-run->--dry-run,--roadmap-id X->--roadmap-id X. d. Present the proposal via AskUserQuestion as in the single-project flow. On Approve or Pick, call apply scoped to the project. On Reject, skip that project's apply and advance to the next. e. Accumulate per-project applied counts for the final summary. -
Summarize. After the loop completes, print a single summary line:
"Triaged N projects: X edges applied, Y priority changes, Z duplicates flagged."Include a breakdown per project if any project errored.
Failure handling
If a per-project invocation fails (context generation returns an error,
proposal JSON is malformed, apply hits a locked-mutate conflict), log the
error to stderr with the project name, mark that project as errored in
the summary, and continue to the next project. Never abort the whole
iteration because one project failed. The user can re-run
/triage --project {name} later to address the single failure.
Cost profile
One LLM reasoning call per project. For N projects with M average pending nodes each:
- Shallow: ~0.5 KB/node -> per-project LLM call ~M * 0.5 KB input
- Deep: ~5 KB/node -> per-project LLM call ~M * 5 KB input
Total tokens scale linearly with project count. For users with >10 active
projects, prefer each over all deep - the sum of N small calls is
usually cheaper than one call over the union, and the LLM reasons better
on smaller focused sets.
Token Cost
| Mode | Bytes per node | 20-node backlog |
|---|---|---|
| Shallow (default) | ~0.5 KB metadata | ~10 KB total |
Deep (deep) |
~5 KB with 150-line excerpt | ~100 KB total |
Chunk into two passes if pending count exceeds 50 in deep mode.
Integration with /target
When /target is invoked with no arguments, the wizard offers
"Pick from backlog" as an input type. That path runs this skill
inline, presents the top-ranked ready node with rationale, and on
confirmation execs /target {plan_path} for that node.
Out of Scope
- Fully automated triage (no human in the loop). Intentionally rejected.
- Cross-roadmap priority comparison. Single-roadmap or all-pending only.
- Auto-merging detected duplicates. Flagged for human resolution.
- Auto-undeferring on a calendar trigger (time-bounded defer). Defer is
reversed manually via
fno backlog undefer <id>for now. - Learning from past decisions. Prompt-only reasoning for now.
Health monitoring (background, deterministic)
/triage itself is interactive and LLM-driven. For continuous backlog
hygiene without human attention, use the deterministic check:
# Hourly local check; produces no output unless something is wrong
/loop 1h fno backlog triage health --check --quiet
Exit code 4 indicates a threshold breach (idea pile, stale-ready,
failure-prone, or collisions). Configurable via
config.health_monitor in config.toml; full schema in
CLAUDE.md > Backlog Health Monitoring.
The monitor is pull-based (poll every interval) and never auto-mutates
the graph; breaches notify and the human (or a follow-up /triage)
decides what to do.
Known Limitations and Deferred Work
- Unreadable graphs block ownership decisions. See LIMITATIONS.md.
References
fno backlog triage(canonical) — the v2 CLI sub-app; source incli/src/fno/graph/triage.pyscripts/triage.py— compatibility shim that forwards to the v2 CLI (or falls back to the in-repo module whenfnois not on PATH)cli/src/fno/graph/store.py—locked_mutate_graph()entry point~/.fno/graph.md— the kanban view that reflects applied changescli/src/fno/health_monitor.py— threshold evaluation, notification dispatch, history append/read, trend summary