| name | fray |
| description | Load this skill IMMEDIATELY — as your FIRST action, before any other tool call or response — whenever the user mentions "fray" in ANY form ("fray", "fray mode", "enter/start fray", "load fray", "use fray", "in fray", "the fray skill"), OR asks to orchestrate / run / coordinate a multi-effort push, audit, or campaign through sub-agents. Also the default for any large, mixed set of efforts — investigations + decided fixes + verifications — toward a goal (a launch push, a pre-release audit, a refactor campaign) where the human wants to stay in the loop on what the investigations surface; the default for any multi-effort push that is part "find out what's true" and part "land the decided thing." Use it instead of hardcoding a multi-agent DAG up front — those bury the decision points and fan out expensively before the facts are in. Treat any "fray" mention as an explicit instruction to load this skill, never as ambient context. |
| version | 1.30.0 |
| metadata | {"internal":true} |
fray
v1.30.0 — the board-RECONCILIATION hook layer is DISABLED (maintainer decision, 2026-07-09). The fray-reminder per-turn pulse, fray-stop-reminder reconcile nudge, session-seed seeding, fray-notify-surface idle surfacing, agent-bind bindings, fray-thread-edit-steer, and session-end liveness no longer fire — each chat session manages ONLY its own dispatched sub-agents and is never pulsed with the full set of in-flight fray threads (shared board state was cross-notifying concurrent sessions). Still active: agent-dispatch (background-dispatch enforcement + epilogue) and the SubagentStop rest guards. The .fray/ thread files remain the durable record and the fray board CLI still computes the board ON DEMAND — read it when you want it; nothing pushes it at you. Passages below describing automatic hook pulses/nags/bindings predate this and describe the disabled layer (their registrations are parked under _disabled_board_reconciliation in hooks/hooks.json for revival).
fray is the orchestrator-first methodology for driving a large, mixed set of efforts (investigations + decided fixes + verifications) through individually-dispatched background sub-agents. A fray is a tangle of concurrent threads; each thread is one ongoing effort (possibly a chain of sub-agents). The human stays in the loop on the decisions the investigations surface. fray is the default for any multi-effort push that is part find-out-what's-true and part land-the-decided-thing — use it instead of a hardcoded Workflow DAG, which fans out expensively and buries the decision points before the facts are in.
(The control surface is a directory of per-thread files under .fray/ — not one bloated todo.md; the board is computed on demand.)
Activation + bootstrap — fray is GLOBAL but DORMANT until you opt a repo in
fray ships as a globally-loaded Claude Code plugin, so its hooks and the fray board command are available in EVERY project. But fray stays completely dormant until a session OPTS IN — every hook is a silent no-op (it injects nothing, blocks nothing, creates nothing) unless the project has a .fray/ directory AND the current session has explicitly opted in (an on sentinel — written by STEP 0 below). A virgin repo with the plugin installed sees zero fray output; so does a brand-new session in a fray repo that hasn't run fray on. This is deliberate: install once, dormant everywhere, activate per session.
STEP 0 — ENABLE fray for THIS session. The FIRST action when this skill loads: run fray on. Activation is opt-IN and PER-SESSION, so loading the skill does nothing until the session opts in. fray on writes the per-session activation sentinel (.fray/.session-state/<session_id>) that every hook re-reads each turn — without it, fray stays dormant in this session even though .fray/ exists. (If .fray/ doesn't exist yet, BOOTSTRAP it first per the next block, then run fray on.) This is what makes the plugin's "dormant until you run /fray" contract literally true. Enabling the session is NOT a trigger to start work — see the next section.
BOOTSTRAP is the FIRST thing this skill does when fray is invoked in a repo that has no .fray/ yet. When you load this skill and .fray/ is absent, create it before anything else:
mkdir -p .fray
- write
.fray/config.yml with the default globals:
autonomous_mode: off
state:
- ensure
.fray/ is git-ignored so the orchestration control surface never gets committed to the host repo: if the repo has a .gitignore, append a .fray/ entry (under a # fray orchestration control surface comment) when one isn't already present; if there's no .gitignore, create one containing that entry. (Skip only if the repo deliberately tracks .fray/ — rare; default is ignore.)
- (optional) drop a
.fray/backlog.md parking-lot thread when the first loose one-off appears.
Once .fray/ exists AND the session has run fray on, the hooks activate (the per-turn pulse, the dispatch enforcement + epilogue, the rest/stop guards, the session seed). Bootstrapping + fray on are the ONLY things the skill does eagerly on load — it does NOT then scan the board or start work (see the next section).
The per-session toggle (per-session, mid-session, no relaunch): fray on ACTIVATES this session; fray off silences it; fray reset clears the override (back to the dormant default); fray status reports the current state. Enablement is keyed on the Claude Code session id (CLAUDE_CODE_SESSION_ID — the same id the hooks receive), so toggling one session never affects other concurrent sessions in the same repo. fray on/off write a sentinel at .fray/.session-state/<session_id> that every hook re-reads each turn; an agent can flip it via a single Bash tool call or a Write to that path. The DEFAULT (no sentinel) is DORMANT — activation is opt-in per session (run fray on). This per-session gate composes ON TOP of Claude Code's own plugin enablement (enabledPlugins in ~/.claude/settings.json): the plugin being enabled globally is necessary but not sufficient — a session is fray-active only when the repo ALSO has a .fray/ AND the session has opted in. (There is no repo-global enabled flag anymore — it was un-scopable and not mid-session-toggleable.)
Per-thread SESSION OWNERSHIP (multiple orchestrators, one repo). The human runs several fray sessions at once, each driving its OWN set of threads. A thread can record the session RESPONSIBLE for it — an owner_session: <session_id> frontmatter field — so the board shows who's on what and sessions don't collide. It is ADVISORY (it surfaces coordination, it does not hard-lock an edit) and its liveness is DERIVED from a per-session HEARTBEAT (every fray-active session stamps .fray/.session-state/<id>.seen each turn), never a stored "alive" flag.
- Claim / release:
fray claim <slug> takes ownership; fray disown <slug> releases it; fray owners lists every thread's owner + live/dead/mine/orphaned state (fray owners --gc clears dead owners). Claiming is EASY when a thread is unowned or its owner is DEAD (heartbeat stale/absent → the thread reads ORPHANED). Claiming a thread owned by a DIFFERENT, still-LIVE session is DISCOURAGED — it REFUSES unless you pass --force (explicit "I know another session is on this, take it anyway").
- Stale-owner clearing (the crux): a session that terminates must not lock its threads forever. Graceful exit fires the
SessionEnd hook, which deletes that session's heartbeat → its threads read ORPHANED (claimable) on the next board render. A hard crash skips SessionEnd, so the HEARTBEAT staleness window (state.owner_stale_min, default 180m) is the crash-safe fallback: once the owner's last beat is older than the window, its threads orphan automatically. NO hook ever rewrites a thread .md — the inert owner_session string is cleaned up lazily by the next fray claim or fray owners --gc. (This makes concrete the older "don't touch active threads — another orchestrator owns those" convention: ownership is now tracked, not just social.)
Requirement — in-flight steering needs the experimental agent-teams feature. The ALWAYS-STEER core of fray (message a running or completed sub-agent, warm-resume, answer a question an agent raised) depends on Claude Code's experimental agent-teams feature (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1). Verify with echo $CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS. Without it, SendMessage/warm-resume are unavailable and you fall back to the blocked + blocking_threads sequencing model for everything (the board still works; the dispatch/rest/reconcile mechanics still work) — only live steering is lost. It is experimental and harness-dependent; do not assume it is on.
Loading this skill ≠ a command to act — and NEVER self-select work off the board (HIGH PRIORITY)
Loading/invoking the fray skill just means: internalize this methodology so you operate correctly for the rest of the session. It is NOT a trigger to scan the board, reconcile everything, or start prepping/dispatching work. After the skill loads, do NOT reflexively run fray and dive into threads. Confirm you're ready and ASK the human what to work on (or wait for the explicit task).
NEVER start work on a planned (or any) thread proactively — not because the board is non-empty, not because autonomous_mode: on says "bias to action." Self-selecting work off the board (reading parked threads, prepping dispatches) is the over-proactive failure: the human runs multiple orchestrators in parallel and steers what each one works on, so self-selected work risks duplicating/colliding with another orchestrator. Do not touch active threads — another orchestrator owns those. The trigger to work a thread is an EXPLICIT instruction to work it; the board having pending items is not. When a session opens or a task completes with no explicit next instruction → reconcile only genuine returns you actually dispatched, then ASK what's next. (This narrows the autonomous-mode "keep the fleet busy" guidance: it applies to advancing work the human has already pointed you at, not to mining the board for new work unprompted.)
The control surface: .fray/ — a directory of independent threads, board computed on demand
.fray/<slug>.md — one file per LIVE multi-step / long-lived thread. The filename slug IS the id — the filesystem dedupes, so there is NO id frontmatter field.
.fray/config.yml — the ONLY non-thread file. Holds the globals that belong to no single thread: autonomous_mode (on/off) and a state: block (the few cross-cutting "what's true now" facts — e.g. published, release, a current pin/version, …). Nothing here duplicates per-thread state.
.fray/<slug>.findings/<id>.md — sub-agent findings sidecars (write-ownership, below).
- There is NO stored board, and NO unified ledger. The board/status view is COMPUTED ON DEMAND by
fray (default board grouped by status · --status <s> · --search <q> · --validate / --check · --json · decisions · reconcile) by scanning each thread's frontmatter. Per-thread frontmatter is the SINGLE source of truth. The default board hoists a ⚖ awaiting you section to the top — every human-blocked thread (status: blocked with no blocking_threads/revalidate_at mechanism), surfaced by its FULL status_text (untruncated); fray decisions is the same queue as a standalone read. Nothing is stored: the queue is just the live set of human-blocked threads. A stored board — or a unified agent/dependency ledger — is a cache that drifts out of sync with the threads (the exact failure that bloated the old single-file tracker), so a unified ledger was considered and REJECTED: it breaks self-containment. Never write one; always compute the board from the threads.
- Thread DEPENDENCIES live in frontmatter, not prose. A thread held behind another sets
blocking_threads: [<other-slug>, …] — bare entries are OTHER THREAD SLUGS (the same files the board scans; never an external registry, preserving self-containment); typed pr:/issue:/ci:/external: entries are external gates. The board computes, per thread, whether every thread-slug target is now terminal (done/dismissed): if so (and no external gate remains) it prints ▶ READY — dependencies clear, dispatch now; otherwise it lists the outstanding ⏳ blocked on: <slugs>. So the dispatch trigger is VISIBLE on the board instead of held in prose or your head. depends_on is still read as an alias for blocking_threads. The validator (--validate/--check) flags a blocking_threads slug that doesn't resolve to an existing thread file (a dangling dependency).
- Per turn, the
fray-reminder hook validates every thread's frontmatter and lists the pending ACTIONABLE threads BY NAME (the SURFACED in-flight statuses planning/active/blocked — PARKED planned is excluded from the nag, though the board still shows it) — so a malformed or stalled thread surfaces immediately, not whenever you happen to look. The hook also emits a ⚠ BOARD RECONCILE STALE instruction when the board hasn't been re-grounded recently (see board hygiene below).
CANONICAL THREAD STRUCTURE (encode it exactly — it's the fix for the foot-guns)
Frontmatter (flat key: value): title, status (required) · last_update · optional status_text (a 1–2-sentence plain-English gloss of the CURRENT status — surfaced on the board under the thread as » …; use it for the nuance that a one-word status can't carry: a complex dependency, a "waiting on X because Y", a caveat that would otherwise get lost in the body; the board warns if it runs past ~2 sentences; the board also warns if a non-terminal thread lacks it. When the thread is human-blocked (no blocking_threads/revalidate_at field), its status_text doubles as the ⚖ awaiting-you queue entry — write it as a CONCISE one-line statement of THE decision needed (aim ≤~200 chars, soft, not enforced); the board + per-turn reminder surface every human-blocked thread's status_text UNTRUNCATED, and the ~2-sentence length warning is exempted for it.) · optional blocking_threads: [entry, …] (bare slug = other-thread sequencing dep, drives the board's READY/auto-fire surfacing; typed pr:/issue:/ci:/external: entry = an external gate, parks the thread as "waiting on"; depends_on is still read as an alias) · optional revalidate_at: <ISO8601 UTC> + last_checked: <ISO8601> (the Revalidate timer — see the section in STATUS VOCAB; while revalidate_at is in the future the thread is parked + quiet, once due it surfaces loudly for a recheck) · decision / blocked_by / gates_release. Unrecognized fields are allowed — the validator checks required title+status, the status vocab, that every blocking_threads slug resolves to a real thread, and that a blocked thread sets at most one resolution mechanism. No id (the slug is the id). Quote a status_text value that contains a leading # or : so the flat parser keeps it intact.
Body — exactly these sections, IN THIS ORDER:
## Goal — the objective + WHY (the north star). NOT a rigid todo list.
## Status — current state; what's done; and what's IN-FLIGHT NOW (the running agent + what it's doing). Living, not a changelog.
## Decisions — the SETTLED calls. A decided thing lives HERE, never under Open questions. ("none yet" if empty.)
## Open questions — ONLY genuinely-unresolved items (awaiting a decision / data / the human). The instant one is answered, MOVE it to Decisions. ("none" if empty.)
## Steps / follow-up queue — a - [ ] / - [x] LIVING checklist of actionable work, including queued follow-ups: when an agent is running and a follow-up is known, ENQUEUE it here marked (QUEUED — dispatch on <agent>'s return[, conditional on <result>]). Do NOT interrupt the running agent; do NOT hold the follow-up in your head. DRAIN the queue when the agent returns.
## Next step — the single immediate next action, or AWAITING <agent>; queued follow-ups above. (The board tool surfaces this line as the thread's "→" cell, so keep it one crisp line.)
STATUS VOCAB + the hygiene rules (load-bearing)
planning · planned · active · blocked · done · dismissed — and status (frontmatter) is the authoritative PHASE of the thread. (A per-thread ## Steps checklist tracks the granular work; the validator flags status↔steps mismatches.) Legacy todo / plan / enqueued / needs-decision are still accepted as read-aliases — normalized to planned / planned / blocked / blocked — so an old thread file validates fine and buckets canonically; write the canonical set going forward.
The unified waiting model (2026-07-01): blocked is the ONE status for "this thread CANNOT RUN right now." It absorbed the old enqueued AND needs-decision — there is no longer a separate status for "held on a trigger" vs "awaiting the human." Instead, HOW a blocked thread unblocks is an orthogonal RESOLUTION-MECHANISM field — set EXACTLY ONE per blocked thread, and the board derives its color, urgency, ordering, and auto-fire from WHICH field is set:
- (1) HUMAN — no machine field.
status: blocked with NEITHER blocking_threads NOR revalidate_at means a HUMAN must decide/act before it can move. This is the ⚖ awaiting you queue and THE top-priority human-facing bucket: surfaced FIRST in the nag, hoisted into the board's ⚖ awaiting you section, YELLOW on the status line, and the ONLY mechanism the Stop hook pops by its status_text. It REQUIRES a status_text — a concise one-line statement of THE decision/action needed (the board + reminder surface it UNTRUNCATED; the ~2-sentence length warning is exempted for it). (This is the old needs-decision.)
- (2) OTHER THREADS / AN EXTERNAL GATE —
blocking_threads: [<entry>, …]. The thread waits on named work. Each entry is either a bare thread slug — another in-flight fray thread (a sequencing dependency; AUTO-FIRES: the instant every thread-slug dep goes terminal the board flips it ▶ READY — dispatch now + a DROP-RISK callout) — or a typed external gate, pr:owner/repo#N · issue:owner/repo#N · ci:<desc> · external:<free-form>, which does NOT auto-fire but PARKS the thread (⏳ waiting on: <ref>, never flagged dangling) and resolves by RE-POLLING (pair with revalidate_at) or a manual edit dropping the gate. A thread stays non-▶ READY while ANY external gate is outstanding, even after its thread-slug deps clear. GRAY on the status line, EXCLUDED from the ⚖ awaiting you queue + the Stop-hook pop. depends_on: is still read as an alias for blocking_threads (old threads validate unchanged); write blocking_threads going forward. (This is the old enqueued + depends_on. For a follow-up that touches a file an in-flight agent owns, PREFER SendMessage to fold it into the owning agent (agent-teams is ON); use a blocking_threads dep as the fallback when the agent is about to finish or you need its completed output.)
- (3) A TIMER —
revalidate_at: <ISO8601 UTC>. The thread waits on external state you can't watch — an upstream PR review/merge, external CI, a third-party response — and re-surfaces itself on a timer instead of being forgotten. Quiet while the timer is in the future (⏰ next check in <eta>), LOUD once due (⏰ REVALIDATE DUE: <slug>). GRAY/dim, EXCLUDED from the ⚖ awaiting you queue. See the Revalidate section. (This is the old enqueued + revalidate_at.)
Exactly ONE mechanism per blocked thread — the validator WARNS if a thread sets more than one (both blocking_threads and revalidate_at), and WARNS if a human-blocked thread (no machine field) lacks a status_text. When disambiguating, precedence is timer > threads > human. The mechanism is a FIELD, not a status word: two threads both status: blocked differ ONLY in which field they carry, and that is what the board reads to color/order/auto-fire them.
planning = ACTIVE design discussion happening RIGHT NOW — the deliverable is the DESIGN itself, not an implementation. The thread's ## Open questions are driving the work; you'd work it WITH the human or dispatch a Plan/architect agent — NEVER an implementer (there is nothing settled to build yet). SURFACED in the per-turn nag — it is the active-equivalent for a plan (design in motion is in-flight work, not parked). TRANSITION RULE (load-bearing): planning → planned at a design stopping point — the instant active discussion pauses or the design locks (or straight to active if you dispatch the implementer that same turn). Don't leave a parked/settled design sitting in planning, and don't open implementation work as planning. (A design thread whose open questions you're still WORKING OUT is planning; it becomes human-blocked only once it's genuinely stalled on the maintainer's call.)
planned = PARKED — scoped/designed but NOT actively worked. The "thought-through, has a doc, not yet scheduled" bucket; NO defer-reason ceremony — planned just means "not picked up yet." Covers BOTH a design that has been parked (the old plan) and an implementation that is scoped-but-unstarted (the old todo). PARKED → NOT auto-surfaced in the per-turn / stop nag — the board shows it; the per-turn pulse stays quiet (pull it up via fray when you choose to action it). A ready thread waiting on a TRANSIENT blocker (a PR merge, a wave drain, a prior agent's output) is NOT planned — it is blocked + blocking_threads (see the discipline below).
active = dispatched / a live agent is on it RIGHT NOW. This is where a just-decided, ready-to-run thread goes when you dispatch it this turn.
- THE DISCIPLINE (load-bearing — its violation silently DROPPED two decided-and-ready threads): a transient blocker →
blocked + blocking_threads (which auto-fires); NEVER planned, never a prose-only defer-note. The board computes the dispatch trigger from blocking_threads frontmatter — encode the dependency THERE, not in ## Next step prose. A thread leaving human-blocked (just decided) transitions to active (dispatch it THIS turn) or blocked + blocking_threads (it waits on a still-running thread; auto-fires when the board flips it ▶ READY) — a ready thread waiting on a transient blocker is blocked + blocking_threads, not planned. (The burn: two decided threads were parked with their blocker stated only in prose + no blocking_threads — they looked handled, never auto-fired, and stalled silently. The per-turn reminder now emits a LOUD by-name DROP-RISK callout for any thread whose thread-slug deps have all gone terminal — re-read and dispatch those the instant they surface.)
- MATERIALIZE THE THREAD BEFORE YOU MOVE ON — "blocked behind X" in CHAT with no
.fray/ file is ALREADY a dropped thread (load-bearing — this exact failure dropped a decided follow-up). The blocking_threads auto-fire only works if the thread FILE EXISTS — the board can't surface a follow-up it can't see. So the instant you describe a follow-up as "blocked on / waiting on / once X returns / will fire when X lands" — whether in a chat reply, in config.yml prose, or just in your head — STOP and create .fray/<slug>.md with status: blocked + blocking_threads: [<blocker-slug>] THAT TURN, before the next message. A follow-up that lives only as a sentence you typed is invisible to the board and WILL rot during a busy Q&A stretch (burned: a conformance config-leak fix described as "enqueued behind the conformance-fixes agent" was never filed, and was dropped when that agent returned). The tell: you typed "blocked behind X" (or any waiting-on-X phrasing) and there is no thread file carrying that blocking_threads — you have already failed; fix it before doing anything else.
done = the thread's GOAL was ACHIEVED and the result SHIPPED + STANDS. dismissed = the pursued thing was decided-AGAINST, abandoned, superseded, or REVERTED — a feature that LANDED and was then RIPPED OUT is dismissed, NOT done (the revert IS the act of dismissing it; do NOT mark a thread done just because the revert/cleanup work finished). Litmus: did the thread's goal ship and STAND? → done. Killed / reversed / never-pursued / implemented-then-reverted? → dismissed.
- BOTH are TERMINAL and KEPT — NEVER deleted. Each thread is its own file, excluded from the active board AND the per-turn pending list by status — so a finished or dismissed thread is ZERO bloat. This is a core benefit of per-file threads over the old single-file
todo.md, which DID require deleting done items to stay lean. Do not "clean up" terminal threads.
- The no-CHANGELOG rule applies WITHIN a thread. Do not accumulate chronological "update" entries inside a thread body — edit the
## Status / ## Decisions in place so the thread always reads as CURRENT truth. (Git history holds the past.) Global structured state goes in config.yml, never narrated as prose in a thread.
Board hygiene is the orchestrator's standing job — the per-turn surfacing is FOR catching drift
The per-turn fray-reminder hook lists every pending ACTIONABLE thread by status, in PRIORITY order (the SURFACED in-flight statuses — human-blocked FIRST, then active, planning, and machine-blocked; PARKED planned is excluded — pull those up via the fray board). That is a STANDING PROMPT to notice rot, not just a dispatch menu. Proactively run a cleanup pass whenever the board looks bloated or stale — many threads parked in one status, or the human-blocked queue growing. Do NOT wait to be told.
Sharpened status semantics (apply these in cleanup):
planning vs planned — the active-design / parked split: planning = the DESIGN is the work RIGHT NOW — open questions in motion, no settled thing to build, dispatched to a Plan/architect agent or worked with the human; it is SURFACED (active design is in-flight). planned = the work is scoped/designed but PARKED — either a design that's been parked or an implementation that's scoped-but-unstarted; it is NOT surfaced. A feature whose design is being finalized lives in planning (its open questions in ## Open questions); the moment active discussion pauses or the design locks, flip it planning → planned (or active if you dispatch the build that turn).
- human-
blocked (no machine field) = awaiting a HUMAN DECISION/ACTION, and ONLY that — work that cannot proceed until the maintainer decides/answers/approves. A planning/design thread whose open questions are still being WORKED OUT is NOT human-blocked — it is planning. A thread waiting on a NON-human trigger (another fray thread, or an external party like an upstream PR/CI) is NOT human-blocked — it carries a blocking_threads or revalidate_at field. Human-blocked is exclusively "a call only the human can make," and it is the COMPUTED awaiting-you queue — the board hoists a ⚖ awaiting you section FIRST, the reminder surfaces each by its FULL status_text, and the status line shows it YELLOW. Write that status_text as a concise one-line statement of the DECISION needed; nothing is stored — the queue is just the live set of human-blocked threads. TEMPORARY: on resolution, record the call in ## Decisions and flip OUT of blocked (→ active/done/a machine-blocked mechanism).
- machine-
blocked (blocking_threads or revalidate_at set) = waiting on a NON-human blocker — another fray thread, a pending PR/CI, an external merge, or a timer. It does NOT clamor for attention (GRAY on the status line, rendered LAST, NOT in the awaiting-you queue, never popped by the Stop hook). A blocking_threads dep with bare thread slugs AUTO-FIRES (board flips it ▶ READY when they go terminal); a typed external gate or a revalidate_at timer parks it until re-polled.
done = a research/brainstorm/investigation thread whose deliverable WAS surfacing findings, with no remaining autonomous work — terminal and KEPT. The investigation was the whole deliverable; that is the end of the thread until revisited.
dismissed = superseded, abandoned, no-longer-relevant, OR implemented-then-reverted (a landed feature that was ripped out is dismissed, not done — the revert is the dismissal; e.g. a feature shipped to main and then reverted before release).
- Quick map: human-
blocked = awaiting the human (the ⚖ queue, surfaced first, yellow); active = a live agent is building it now; machine-blocked + blocking_threads = ready, auto-fires when a named in-session dependency clears; machine-blocked + a timer/external gate = waiting on a non-human blocker (gray, de-emphasized).
The cleanup mechanism: when drift is detected, dispatch an L1 cleanup-lead (Sonnet is enough for classification, Opus if threads are complex) that fans L2 readers to check each stale thread — including referenced PR/git state — applies the re-statuses, preserves every thread body, and returns a per-thread table (thread · old status · new status · why). Verify the table before the changes are written, or instruct the L1 to write and report. Run on demand and whenever the reminder hook shows accumulation.
BOARD RECONCILE is a FORCING-FUNCTION, not just folding agent returns (anti-drift). The board is COMPUTED from each thread's frontmatter, so a thread whose status drifted from reality — a PR merged but the thread never flipped to done, work that shipped but still reads active — is surfaced as live truth until something RE-GROUNDS it. fray persists a last-complete-reconcile timestamp at .fray/.last-reconcile (gitignored runtime, epoch-ms) and nudges via a TWO-TRIGGER gate (no cry-wolf): (1) DIRTY — the newest mtime among NON-TERMINAL threads is newer than the last reconcile → a thread MOVED since you re-grounded, so re-ground it (the precise trigger); (2) BACKSTOP — a long wall-clock fallback (default 120 min, configurable via .fray/config.yml → state.reconcile_threshold_min) that exists ONLY to catch EXTERNAL drift the dirty-gate can't see — a PR merging / CI flipping with no thread edit (touches no file → no mtime bump). A missing timestamp counts as stale (a first reconcile). When neither trigger is hot the nudge is SILENT — that's the point. Three refinements keep the DIRTY trigger from crying wolf on write-ownership: (a) an owning-agent edit is NOT drift — a thread whose latest edit was its OWN dispatched agent reconciling it (recorded at its SubagentStop into .fray/.owner-reconciled.json) is excluded from the dirty-gate, so an agent editing its own thread never nags a board re-ground; only a NON-owning change (an orchestrator edit, a referenced PR/CI moving) counts; (b) the nudge is DEBOUNCED — a dirty signal must persist both >3 min AND >2 turns before it fires, so a burst of return-folding doesn't nag every turn; (c) it is SCOPED — the instruction NAMES the drifted thread(s) (re-ground <slug>), not "re-ground everything."
The nudge fires from two surfaces: its PRIMARY, causal home is the Stop-hook rest path — a sub-agent coming to REST is the event that actually moves board truth (it edited threads / a PR moved), so the nudge lands in your context right when the board most likely drifted. The per-turn fray-reminder hook is a lightweight backstop for external drift (no rest to key off) and the first-reconcile baseline.
When you see ⚠ BOARD RECONCILE STALE, auto-dispatch a BACKGROUND reconcile sub-agent reflexively (don't deliberate turn-by-turn) — reconcile is a JUDGMENT task, so dispatch it at Opus, high effort (fray:fray-opus-high), never a cheap tier. It re-grounds EVERY non-terminal thread against ground truth (is its PR merged? does the symbol it added exist? did the work actually ship?), flips every drifted status to match, then — as its LAST step, AFTER every thread edit — runs fray reconcile to stamp .fray/.last-reconcile. Stamp LAST is REQUIRED: the reconcile agent's own edits bump thread mtimes, so stamping before them would leave the board dirty forever (the dirty-gate would re-fire next turn); fray reconcile stamps now, so the natural order (edit threads → then fray reconcile) is correct. The natural EVENT trigger for external drift is reconcile-on-agent-return: when a landing agent reports a PR merged / CI flipped, re-ground that thread then. The definition of "reconciled" is exact: every non-terminal thread has been re-grounded against the actual code/PRs this session. Keep this OFF the hooks' hot path — they do only mtime/timestamp math (no gh/network calls); the PR-liveness checking is the dispatched sub-agent's job.
WATCHER/AGENT DROP-GUARD — never trust "watcher running = fine" (anti-drop forcing function). A dispatched agent that has RUN past ~35 min while still emitting output (looks alive) but with NO terminal thread result gets a LOUD ⚠ VERIFY DIRECTLY line — from the per-turn reminder and, critically, from the Stop hook (the ONLY surface during the "Waiting for N background agents" idle-wait). On a pure idle-wait the Stop-hook block RE-ARMS at each coarse runtime multiple (~2×, ~3× the threshold), so a multi-hour hung watcher forces a fresh block instead of being flagged once and then rationalized as "still fine." This is the exact class that stranded a reviewed, effectively-green PR for HOURS: a ci-watch hung on a nameless GHOST check (a check-run that never reports) polls forever, so its output stays fresh and it never looks stranded — it just never terminates. When you see it, do NOT keep waiting on the watcher — check the target rollup YOURSELF (gh pr view <n> --json statusCheckRollup -q '.statusCheckRollup[]|select(.status!="COMPLETED")' shows the ghost instantly) and act (merge if required checks are green, or resume/kill the watcher). A watcher blocking on a check that will never complete is a stall, not progress.
When surfacing decisions to the human: surface ONLY the genuine current-workstream decisions (the curated set), NEVER a raw board dump. With human-blocked reserved for real human decisions (waiting-on-external lives in a blocking_threads gate or a revalidate_at timer), the human-blocked list should already BE the decision set — but still curate: drop stale brainstorms and surface only what actually needs an answer right now.
Revalidate — time-based recheck for threads waiting on EXTERNAL state
A thread can wait on something with no in-session auto-trigger: a PR to an external repo awaiting a maintainer, a CI run you cannot watch, a third-party response. A live-polling shell is the wrong tool — it dies on session end and burns a slot. Revalidate is the durable alternative: a frontmatter timestamp the thread carries so it resurfaces itself on a timer instead of being forgotten.
- The field —
revalidate_at: <ISO8601 UTC> (e.g. revalidate_at: 2026-06-27T18:00:00Z), optionally with last_checked: <ISO8601> recording the previous poll. Both are OPTIONAL and backward-compatible — a thread without them behaves exactly as before, and a malformed timestamp degrades to "no timer" (the board warns; nothing crashes).
- Quiet until due. While
revalidate_at is in the FUTURE the thread is parked on a timer — SUPPRESSED from the per-turn nag and the ⚖ awaiting you queue (it waits on external state, not a human decision this turn). The board shows it with ⏰ next check in <eta>. Once now ≥ revalidate_at it is due: the per-turn reminder emits a LOUD ⏰ REVALIDATE DUE: <slug> by-name callout and the board shows ⏰ revalidate due. The timer is the thread's SINGLE surfacing channel — no double-nag.
- The recheck workflow. When you block a thread on external state, SET
revalidate_at = now + interval (sensible band 6–12h; default a +8h bump when you have no reason to pick otherwise — faster for a hot CI run, slower for a maintainer who reviews weekly). When the hook surfaces it due → RE-POLL the external state (e.g. gh pr view <n> --json state,reviews,statusCheckRollup). CHANGED → act (unblock / respond to the review / advance the thread). UNCHANGED → bump revalidate_at = now + interval again and stamp last_checked = now. Re-arm until the thread goes terminal (merged / closed / resolved). The timestamps are durable, so the recheck survives session end, compaction, and restarts.
When to create a thread — and when NOT to
Create a .fray/<slug>.md only when an effort is genuinely multi-step / multi-dispatch / long-lived — concretely, when it meets ANY of:
- it will take ≥2 sub-agents (especially a CHAIN: probe → fix → self-review → land), or
- it carries a human-owned decision (a default / security / product / brand / API-config call), or
- it outlives the current turn (spans a human-gated pause, survives compaction).
One-shots get NO file. A single-agent fix, a quick doc tweak, a tiny rename — dispatch it and let it die; tracking it as a thread is ceremony. (Granular per-turn / cross-thread one-offs go into the owning thread's ## Steps, or .fray/backlog.md if they belong to no thread — NEVER a native todo.)
CREATE THE THREAD FILE FIRST THING — before dispatching any agent for it. The instant an effort meets the bar above — a NEW task, anything SPLIT OFF from an existing task, or any time the human says "spin up a thread/spike" — your VERY FIRST action is to write .fray/<slug>.md with all the context you currently have. THEN ask the human follow-up questions. THEN iterate on the file. Do NOT dispatch the agent, do NOT "do it and file it after" — the file comes first, always. (The recurring burn: efforts that split off from an existing one get dispatched WITHOUT a thread file, and then get forgotten.) This is enforced by the dispatch hook: a THREAD:-tagged dispatch whose .fray/<slug>.md doesn't exist is DENIED.
The initial thread is MINIMAL — the dispatched L1 does the investigation, not the orchestrator. When creating a thread to delegate a multi-step effort, write ONLY: the high-level task (roughly in the human's own terms), pointers (links, relevant file paths, related thread slugs), and the genuine already-known constraints + decision boundary (what the L1 may vs may NOT land autonomously). Do NOT pre-do the investigation — do not reproduce source material (changelogs, specs, file/code dumps) into the initial thread, do not enumerate every sub-item, do not pre-classify or pre-research. The dispatched L1 OWNS the investigation and fills the thread in as it goes (write-ownership above). The orchestrator's job is the high-level framing, then consuming the L1's FINAL REPORT for cross-thread orchestration, decisions, and surfacing open questions to the human. Over-stuffing the initial thread is wasted orchestrator effort that just duplicates what the L1 will do anyway — and pulls the orchestrator down into the weeds instead of staying at the orchestration layer. Minimal ≠ vague: still include the REAL constraints and the decision boundary; the cut is the research/enumeration, not the framing or the guardrails. A good test: the initial thread should read like the human's ask + the known guardrails, not like a finished investigation.
RESEARCH → IMPL retooling: keep ONE thread. A thread commonly starts as a research/investigation/design effort (status planning — the design itself is the work, open questions in motion), then — once the facts are in and the design LOCKS — the SAME doc is retooled in place into the implementation plan (the ## Goal sharpens, ## Open questions collapse into ## Decisions, ## Steps fills with the build work) and its status flips planning → planned (the design is settled; the next dispatch is an implementer — or straight to active if you dispatch it that turn). Do NOT spawn a second thread for the impl phase. The thread is the effort across its whole lifecycle; one doc carries it from "find out what's true / what should we build" (planning) through "land the decided thing" (planned → active → done).
Write-ownership — the dispatched sub-agent OWNS its thread's edits
The doing-agent edits its OWN dispatched thread .md directly (## Status / ## Decisions / ## Next step / ## Steps), because it has the full context on its effort and structurally best represents that thread's current truth. The orchestrator reconstructing a thread's status from a packaged summary is lossy — so the agent writes the state, the orchestrator does NOT re-transcribe it. (This SUPERSEDES the old absolute rule "ONLY the orchestrator edits threads; sub-agents NEVER edit a thread file." That rule is gone — the doing-agent owns its thread doc now.)
The discipline the sub-agent follows when editing its thread:
- Edit IN PLACE (the Edit tool), never a full-file rewrite, never a changelog append. Keep the single-voice, current-truth discipline: the thread always reads as CURRENT state, not a chronological log (git history holds the past). Update
## Status / ## Decisions / ## Next step / ## Steps to reflect what the agent did.
- Write the depth INTO the thread itself — the thread is ONE self-contained, unified document (the complete canonical record of its effort). Do NOT default to a separate findings sidecar: now that the dispatched agent edits its own thread directly, a stub-thread-plus-scattered-sidecars split is exactly the fragmentation to avoid. A sidecar
.fray/<thread>.findings/<id>.md is justified ONLY for a PARALLEL fan-out (N agents feeding ONE effort, where concurrent writes to a single file would clobber) — and even then it is a TRANSIENT working artifact: the orchestrator MUST integrate every sidecar into the single unified thread doc so the resting state is one mega-doc, never a permanent stub+sidecars split. Single agent on a thread → everything goes in the thread, no sidecar.
- Never edit ANOTHER thread's
.md — an agent edits only the thread it was dispatched for. Cross-thread edits are the orchestrator's job (next section).
Clobber safety — why direct sub-agent edits are safe. The file tools enforce read-before-write, REJECT a stale edit, and write atomically — so even a concurrent same-file edit can NOT silently clobber (the second writer is forced to re-read first). GUIDELINE (not a prohibition): keep ONE live agent per thread where possible — dispatch chains are naturally sequential (probe → fix → review), so a thread usually has exactly one live agent at a time. When multiple agents genuinely share a thread concurrently, each edits only its own section. This is the safeguard, not a wall.
When you STEER an agent, do NOT also exo-edit its thread doc to reflect the steer — let the agent fold it in. The steer message (SendMessage to a running OR completed agent) carries the context; the agent OWNS its thread and will update its own ## Status/## Decisions/## Steps on its next tick. The orchestrator pre-emptively editing the steered agent's thread duplicates work, risks a stale-edit race against the agent's own next write, and re-introduces the lossy re-transcription write-ownership exists to kill. So: steer via message, and have the message EXPLICITLY ask the agent to record the change in its thread (e.g. "…and reflect this in your thread"). The orchestrator still edits only its RESIDUAL surfaces (cross-thread [[links]], config.yml, the decision queue) — never an agent's own thread to mirror a steer. (Corollary: a thread that just went terminal can still be re-steered — the agent resumes, does the work, and re-updates its own doc; you don't hand-edit it back open.)
The orchestrator's RESIDUAL role — what stays the orchestrator's because a local-context agent structurally cannot do it:
- Cross-thread linkage —
[[other-thread]] references, and noticing that a finding in one thread REVERSES a decision in another. A single-effort agent can't see across the fray; the orchestrator holds the whole picture.
- The human-decision queue / decision-gating — routing Open questions to the human, holding a thread at human-
blocked (no machine field), deciding the next move.
- Dispatch + synthesis across efforts — deciding what runs next, fanning out, reconciling the board.
- Editing
config.yml + the board surfacing — the cross-cutting globals and the dispatch-binding bookkeeping (next section).
The orchestrator does NOT re-transcribe what a single agent already wrote into its own thread. But for a PARALLEL fan-out whose agents wrote transient sidecars, the orchestrator's synthesis job INCLUDES integrating those sidecars into the one unified thread doc (the mega-doc) — so the effort ends as a single self-contained document — plus the cross-thread / decision / dispatch work above.
NEVER use the native todo tool while fray is active — the .fray/ threads ARE the canonical to-do list
Do NOT maintain a parallel native checklist (TaskCreate/TaskUpdate) when fray is running. The .fray/ threads are the ONE canonical to-do substrate; the board (fray) is its view. Over-reliance on the native to-dos tool causes problems; the fray files are the canonical to-do list — rectify with extreme prejudice. A second list competes with the threads, drifts out of sync, and splits attention about which is authoritative — the exact failure that lets efforts get lost.
So, for every follow-up the INSTANT it is discussed (never let it live only in chat — chat scrolls away):
- Thread-scoped? → into that thread's
## Steps (mark (QUEUED — dispatch on <agent>'s return) if it's waiting on something).
- A NEW effort / anything split off from an existing one / any "spin up a thread/spike"? → create the
.fray/<slug>.md file FIRST THING, with all current context, BEFORE dispatching any agent for it (see the rule below). This is paramount — it's how you avoid forgetting it.
- Waiting on an in-flight agent ("blocked behind X" / "once X lands")? → that is a NEW thread too: create
.fray/<slug>.md with status: blocked + blocking_threads: [<blocker-slug>] THE SAME TURN you say it (see THE DISCIPLINE in the status-vocab section). Describing it as blocked-behind in chat without the file is a dropped thread — the board can only auto-fire a thread that exists.
- A loose cross-cutting one-off that belongs to no thread? →
.fray/backlog.md (the parking-lot thread).
The tell you're failing: the human has to say "make sure you remember everything you need to do," or "you're not creating files for these efforts" — or you typed "blocked behind X" / "waiting on X" / "once X returns" in a reply and no thread file carries that blocking_threads.
Core shape
You (the main session) are the orchestrator, and the ONLY decider. You hold the whole picture, dispatch sub-agents as instruments, ingest what they return, decide the next move. You do not hand the steering wheel to a workflow script or a sub-agent.
DEFAULT TO DISPATCH — investigation is delegable work, and doing it in the foreground is the #1 recurring failure. The reflex to kill: a problem appears (a red CI job, a bug, a "why does X happen", a log to read, several files to trace) and you start doing it YOURSELF, one foreground tool-call at a time. STOP. Forensics, diagnosis, log-reading, code-tracing, repro, and fixes are exactly what sub-agents are for. The moment a task is more than a single quick lookup, dispatch a sub-agent (or several, model-tiered) instead of single-threading it. The foreground is for ORCHESTRATION ONLY: synthesizing returns, deciding, updating threads, and the final git that finalizes reviewed work. The tell you're failing: you're on your 2nd+ foreground Bash/Read/grep of an investigation (not a quick orchestration check) — that work belongs in a sub-agent's context, not yours. When two+ problems are open, dispatch them as parallel lanes in ONE message. An idle fleet while you DIY is the anti-pattern.
NEVER foreground-fetch data that a sub-agent will act on — tell the agent what to fetch, and it fetches it. This generalizes DEFAULT TO DISPATCH to data-reading specifically: when a sub-agent needs GitHub state — PR reviews, inline comments, CI/check status, issue threads, PR diffs, any gh api output — DO NOT pull that data into the orchestrator's context to pre-digest and relay. Instruct the agent which PR/issue to look at and what to find; the agent runs gh pr view / gh api repos/.../pulls/<n>/reviews / gh pr checks / etc. in its own context. The orchestrator foreground is for dispatch, synthesis of returns, decisions, and control-surface edits — not data-gathering on a sub-agent's behalf. (Exception: a one-line state check the orchestrator genuinely needs for its OWN routing decision, e.g. "is this PR already merged" — that is orchestration, not pre-digesting an agent's input.) Reading a PR's reviews so an agent can address them IS delegable investigation; the agent should own it.
The FAN-OUT DECISION itself stays at L0 — triage/portfolio work that decides how to spin up many parallel threads is the ORCHESTRATOR's own job, never delegated (HIGH PRIORITY). DEFAULT TO DISPATCH governs the legwork inside one effort (diagnose this bug, read this PR's reviews). It does NOT govern the meta-step of surveying a SET of independent items — "triage every open issue", "evaluate all these PRs", "which of these N things are worth doing" — and deciding which become threads. That portfolio/triage pass is the orchestrator at L-Zero: YOU read the set, evaluate each against the design positions + decision record, and decide the fan-out — because the whole point is the cross-item judgment that picks what to dispatch, and a sub-agent handed "triage the issues" just relays a list you then have to re-judge anyway (and risks pre-deciding work the human owns). Do the survey yourself in the foreground; THEN dispatch a thread per item you (and the human) greenlight. The tell you're failing: you spun up an agent (or a fray thread) to "go evaluate the issues/PRs and tell me what's worth doing" instead of doing that evaluation as the orchestrator. (Per-item deep work — building a fix, a heavy repro — still dispatches; it's the triage/selection layer that stays L0.)
YOU hold the full context — synthesize it into the dispatch; a sub-agent only knows what you tell it. A sub-agent starts with a fresh cache and zero awareness of everything across the effort — every superseded number, reversed decision, renamed thing, newer finding. Before EVERY dispatch, stop and think: given everything I now know, what should the answer actually be? — then encode that synthesized direction in the prompt. Especially: catch STALE/SUPERSEDED info (a number a later benchmark overturned) and a tempting-but-UNVERIFIED claim (a figure from a non-neutral harness) — resolve which is true yourself before the sub-agent bakes the wrong one into a user-facing artifact. This is the orchestrator's central cognitive job, not optional relaying.
Sub-agents are instruments, not deciders. A probe returns facts — divergences, traces, measurements, file paths, exact errors — not verdicts. No sub-agent autonomously lands a change to a default / security posture / product behavior / brand / API-config-env surface / error-contract; those route back to the human as an Open question. Mechanical / clearly-a-bug fixes may land (you review the diff). Every sub-agent prompt is self-contained — embed the codebase-map slice and the exact task, because a model switch starts a fresh cache and nothing carries over.
SURFACE, DON'T GUESS — the explicit stop criterion (bake it into dispatch prompts). A sub-agent operates autonomously ONLY until something human-owned or genuinely ambiguous arises: a default / security / product / brand / API-config-env decision, a fork between materially-different approaches (where the choice has consequences, not a coin-flip), or an unexpected blocker. At that point it does NOT guess and does NOT land it — it COMES TO REST and surfaces the choice (with the options and its recommendation) to the orchestrator, who surfaces it to the human. Guessing past one of these and shipping the guess is the failure; coming to rest with a crisp question is the success. (Distinct from a clear-cut bug, which the child fixes — the line is decision-vs-bug, per above.)
Be proactive on obvious bugs AND obvious corrective actions — just do it, don't surface-and-wait. When an investigation turns up an obvious, clear-cut bug (a correctness failure, a false claim, a broken contract), dispatch the fix and report it done. This extends past code bugs to ANY obvious-corrective work: a broken CI/release/infra wiring, a stale published artifact that should already match an already-shipped release (e.g. re-running a publish workflow to backfill an image/package to the version that's already public), a misconfigured trigger. The line is decision-vs-mechanism, not big-vs-small and not code-vs-infra: if the action is the obviously-correct fix for a clear defect and is REVERSIBLE, DO IT and report — do NOT tack on a "want me to?" The genuine gates stay narrow: a posture/default change, a NEW external commitment (cutting a fresh version, a public announcement), or a truly-irreversible/destructive act route to the human; everything else you just handle. The "want me to dispatch/trigger that?" reflex on an obvious reversible fix IS the over-ask failure — catch it: if you're about to ask permission for something corrective and reversible, you've already decided it's right, so act instead. (Re-publishing the official image to match the already-released version ≠ a new external publish; it's maintenance. The new-version cut IS the gate.)
Act on a clear next action — never stop at "blocker identified." When the next action is clear and authorized by context, take it THIS turn: dispatch the right work, apply the safe fix, run the verification. An outcome-shaped ask from the human is authorization to proceed through the safe implementation/verification loop. Do not stop at naming a P0 / blocker / known-required fix and leave it idle — if no human-owned decision blocks it, dispatch the work and report what happened. Delegation-first means dispatching the work, not sitting idle on it. (Common slip: identifying the required fix, writing it up in the thread, and going idle — instead of dispatching it. Identifying it IS the trigger to dispatch it.)
Acknowledge strategy-impacting input explicitly — don't silently fold it. When the human says something that shifts the strategy/approach/a decision, your chat reply must LEAD with: (1) an explicit ack naming what they said, (2) how it changes the plan, (3) what you're now doing about it. Do NOT just quietly update a thread and proceed with tool calls — the human can't see the thread mid-turn. (Distinct from low-stakes acks; this is for input that moves the strategy.)
Model- AND effort-tier every dispatch — through a fray PROFILE (subagent_type: fray-<model>-<effort>)
Two orthogonal levers — which model and how much effort — and BOTH ride on the subagent_type, not on the orchestrator. The fray plugin ships one thin profile per cell: fray-{opus,sonnet,fable}-{low,medium,high,xhigh,max} plus fray-haiku (Haiku takes no effort param). Naming the profile is how you set the tier per dispatch.
MANDATE: every sub-agent dispatch goes through a fray profile — subagent_type: fray-<model>-<effort> — never an ad-hoc model-only dispatch. This is the default dispatch mechanism. (If your harness requires the plugin-qualified form, it is fray:fray-<model>-<effort>.) WHY this is the structural fix: the Agent tool exposes a model param but no per-call effort param, and a background worker INHERITS the orchestrator's effort — so "snappy orchestrator + high-effort worker" is impossible via plain dispatch. A profile's effort: frontmatter overrides that inheritance, making per-dispatch effort a first-class selector and fully decoupling worker tier from how the orchestrator itself is running.
Cost ladder cheapest → priciest: Haiku < Sonnet 5 < Opus. Tier by how much the sub-agent must self-steer AND how load-bearing its output is. (Fable 5 is NOT in the dispatch rotation — it is being removed from the subscription plan; the fray-fable-* profiles exist but don't route work through them. Opus is the top tier.)
fray-haiku — fully-scripted mechanical ONLY: run THESE commands, harvest THIS output, every decision pre-made by you. Cheap but CANNOT self-steer — give it a script, not a question. (200K context; no effort variant — Haiku rejects the param.)
fray-sonnet-medium — Sonnet 5, the DAILY DRIVER for the supporting cast. The primary tier for less-sophisticated work: probes where the finding is an OBSERVABLE fact (run X and Y, diff the output — the divergence is the result), test scaffolding, doc updates, CI-watching, gates/settle, mechanical-but-not-trivial edits. Sonnet CAN self-steer; its failure mode is confident-but-wrong on subtle reasoning — do NOT hand it a probe whose deliverable is a judgment about subtle correctness/security. Bump to fray-sonnet-high when the probe needs more care. (Sonnet 5 is the ONLY Sonnet tier — it replaces any earlier Sonnet everywhere; there is no 4.x cell.)
fray-opus-high / fray-opus-xhigh — the sophisticated tier. The fix that lands; diagnosis; architecture / adversarial review; gnarly debugging; and any probe whose deliverable is a load-bearing VERDICT requiring subtle reasoning. xhigh for coding/agentic; high for ordinary substantive work; reserve fray-opus-max for the single hardest problems.
Pick the cell deliberately every dispatch. Everyday mapping: impl / review / the-fix-that-lands / anything sophisticated → fray-opus-high (or fray-opus-xhigh for the gnarliest); supporting cast / light-judgment / daily-driver work → fray-sonnet-medium (Sonnet 5); fully-scripted harvest → fray-haiku. These are the cells you reach for daily.
COROLLARY — bias HARD toward the sophisticated tier (Opus) when the orchestrator is running cheap/snappy. The decoupling is the whole point: a low-effort orchestrator no longer pulls workers down a tier, so let the PROFILE set the floor, not the orchestrator. Route investigations, differential probes, compat audits, and anything whose output you'll reason over to fray-opus-high+ by default, never Sonnet. Reserve Sonnet 5 for genuinely mechanical supporting-cast and Haiku for fully-scripted harvest. When in doubt: Opus. (Cost is the lesser risk; a confidently-wrong cheap-tier audit you then act on is the bigger one.)
Pattern: cheap tier gathers & packages → Opus does the real engineering → Sonnet 5 handles the supporting cast → you verify. Re-verify cheap-tier load-bearing claims yourself — a Haiku/Sonnet "this is a security bug" or "these two diverge" is a lead, not a fact; confirm against code or a foreground experiment. Trust the data they harvest; validate the conclusions. Tier EVERY dispatch — single one-offs as much as multi-agent chains.
Two operational caveats:
- Profiles register at session START — a newly-shipped or newly-bumped profile is NOT live mid-session. After a plugin update, run
/reload-plugins (or restart the session) before dispatching through a new cell.
- The effort-override is VERIFIED-ON-FIRST-USE. The
effort: frontmatter overriding inherited effort is documented, not yet probed end-to-end. On the first post-restart dispatch through a profile, confirm the worker actually ran at the intended effort before trusting the override for load-bearing work.
MANDATORY: every substantive piece of work gets a SEPARATE self-review + integration pass — a distinct dispatch, never the doing-agent grading its own homework (the doing-agent's self-assessment shares the blind spots that produced the work). "Substantive" = anything that lands code, changes user-facing copy/behavior, touches a config/security/API surface, or produces a load-bearing number/verdict. The doing-agent declaring "done + verified" does NOT close the loop — its self-assessment shares the blind spots that produced the work. After it returns, dispatch a SECOND agent (fresh context, Opus for anything load-bearing) to: (1) adversarially self-review — re-derive the claim / re-read the diff for correctness, brand-boundary, copy rules, regressions the author would rationalize; (2) integration pass — confirm it actually builds/tests/renders in the real tree, fits the surrounding code, and isn't half-merged or in a staging-race-corrupted commit. Only after the independent pass clears do you mark the thread done. The review pass is itself self-contained + model-tiered. Rigor comes from an independent second look, not from the first agent trying harder.
SCALE THE REVIEW TO THE BLAST RADIUS — one reviewer for ordinary substantive work; a MULTI-LENS FLEET for FAR-REACHING work (HIGH PRIORITY — never skip self-review, never under-review a wide change). ALWAYS self-review substantive changes — that floor is non-negotiable (the rule above). But a single reviewer is calibrated for an ordinary substantive change; when the work is FAR-REACHING — a registry/network write, an auth/security/credential path, a config/lockfile/PM-compat surface, a default flip, a wide refactor, anything that can corrupt user state or a published artifact — dispatch a FLEET of INDEPENDENT reviewers, each with a DISTINCT LENS, run concurrently (disjoint read-only review parallelizes freely): e.g. a correctness lens (re-derive the logic / re-read the diff), a safety/destructive-op lens (can this clobber user data, a registry, a lockfile, a published package?), a compat/reference-behavior lens (does it match what the reference tool actually does, verified against its source or an empirical probe?), an API-surface lens, and a regression/integration lens. Diversity beats redundancy: N reviewers with the SAME lens mostly agree; N reviewers with DIFFERENT lenses each catch a failure mode the others structurally can't. Require ≥1 independent confirmation on any "it's safe" verdict for a destructive-capable path before merge. And keep a reviewer HONEST about reconciliation: a finding that starts as "RISKY/BROKEN" and is then downgraded to "fine" must show the EVIDENCE that downgraded it (the reference-tool behavior, the semantics, the shipping-precedent) — a confident "actually it's fine" with no new evidence is itself a finding to re-check. (The burn that motivated this: a far-reaching change touching registry writes, auth, and credential CRUD needed exactly this multi-lens pass; a single lens would have missed or mis-called the data-clobber question.)
The loop is build → self-review → INCORPORATE the findings → done/merged. Incorporation is NOT optional, and the gate is the MERGE/commit-as-final boundary — NOT release (release is a separate, not-always-happening step; most work merges without ever being released). A self-review that surfaces a REAL finding — a bug, a regression, an edge case the fix introduces OR extends (even a pre-existing one the change makes reachable/worse) — is NOT closed by noting it. You either (a) FIX it before the work is marked done, or (b) DEFER it with EXPLICIT maintainer sign-off obtained BEFORE you treat the work as complete. "The review found X; we merged/shipped anyway; X is filed as blocked" is the FAILURE mode (burned: a fix was treated as done + folded downstream while its own self-review's edge-case finding sat parked-unincorporated, without sign-off to defer). The review exists to CHANGE the artifact before it lands as final, not to annotate it after.
What a dispatch prompt must REQUIRE of the child
Beyond the self-contained context, these requirements raise child output from "a finding" to "a landed result" — bake them into the prompt:
- REQUIRED on EVERY thread-scoped dispatch: the thread-ownership directive. Write-ownership (above) is the principle; this is the operational rule — the dispatch PROMPT must EXPLICITLY instruct the child to update
.fray/<slug>.md (## Status / ## Decisions / ## Steps / ## Next step) IN PLACE before finishing. Every time, not as an aspiration. The failure mode to name in the prompt: defaulting to "do the work and report back to me" — that forces the orchestrator into lossy re-transcription of what the child already knew best. Make "did I include the thread-ownership directive?" a fixed item on your pre-dispatch checklist; a thread-scoped dispatch missing it is malformed. (The dispatch hook's auto-epilogue restates this, but the prompt must carry it deliberately, not rely on the hook.)
- REQUIRED on EVERY research / investigation / audit dispatch: rigorous PRIMARY-SOURCE citation — an exact
file:line or URL for each load-bearing claim, so the orchestrator can VERIFY rather than trust. A claim the child cannot cite to a source it actually OPENED is a LEAD, not a finding; the prompt must require it be flagged as such, never asserted as fact. Without this, a child embellishes from training knowledge or rhetorical momentum — burned once when a debater asserted "the Node port.start() doc literally apologizes for the wart" without ever opening the doc; it was false, and it propagated into a research doc before being caught. Citation is the MECHANISM that makes the re-verify-the-lead step possible — a cited claim is checkable in one click; an uncited one is a fabrication waiting to surface.
- REQUIRED on EVERY repo-editing / PR-opening dispatch: point the child at the repo's agent-orientation doc AND inline the task-relevant load-bearing rules — belt-and-suspenders. A dispatched agent starts FRESH and may not read (or fully internalize) a long orchestration-heavy
AGENTS.md/CLAUDE.md, so it misses load-bearing rules (the Closes #N keyword, the brand boundary, fork-discipline, the worktree-PR flow). So the prompt must do BOTH: (i) TELL the agent to read the repo's agent-orientation doc — especially any top "Non-negotiables" quick-reference block — and follow it; AND (ii) still INLINE, in the prompt text, the specific load-bearing rules that govern THIS task (the closing keyword for an issue-PR, the brand-boundary line for a public-API change, fork-discipline for a vendored-dep edit, etc.). A pointer alone is unreliable (agents skim); a self-contained prompt is what actually holds. Do NOT create a second l1_AGENTS.md / agent-specific orientation file — the repo's single AGENTS.md is the source of truth; a parallel file drifts. Pointer + inlined-criticals, every repo-editing dispatch.
- A substantive implementation child is a mini-orchestrator within its scope. Tell it to: plan briefly → implement → run the scoped local verification → self-review its own diff → and for landing work, open a PR from an isolated git WORKTREE by default (see the workflow below), then report the PR URL. The child does NOT merge its own PR; the orchestrator reviews + merges. EXCEPTIONS that commit directly to
main (no PR): the orchestrator's own control-surface edits (.fray/ threads, the fray plugin, project agent-orientation docs), and trivial doc/typo fixes. When CI applies and the change touches code/tests, the child waits for CI and fixes in-scope failures rather than handing off after the first push. (This does NOT replace the orchestrator's separate independent self-review/integration pass below — a child grading its own homework never closes the loop.) (Adapt the branch/commit policy to the host project's conventions — some repos commit straight to main, some require a PR, some forbid pushing; the child's prompt names which applies.)
- The worktree PR workflow (copy this into the child's prompt):
git worktree add /tmp/wt-<slug> -b <branch> (the shared main tree is left untouched) → if the repo has submodules the build needs, git -C /tmp/wt-<slug> submodule update --init <path> (submodules are NOT auto-populated in a new worktree) → cd in + give the worktree its OWN build/output dir so a sibling's build can't contaminate it → implement, commit, git push -u origin <branch>, gh pr create → report the URL → after merge, git worktree remove … --force. Reserve a full git clone --depth 1 file://$PWD + own build dir for the fully-isolated trustworthy-build case (a deliverable whose whole point is "passes tests + preserves behavior"). NEVER git checkout -b/reset/checkout/stash/branch on the SHARED tree — that clobbers concurrent agents; all branching happens in the worktree.
- For any GitHub issue/PR task, require
gh context BEFORE any diagnosis or fix. Minimum: gh issue view <n> for an issue; gh pr list searches for linked/open PRs; gh pr view <n> for any candidate. The child's final report must list the gh commands it ran and what they showed, and must not propose or land fresh work on an issue until existing linked/open PRs are checked. Read the comments and the resolution, not just the issue body — a closed/rejected request's real rationale lives in the thread, and the body alone will make a child confidently wrong. After context is known, drive the outcome (fix, push, comment, close, verify) rather than stopping at a diagnosis. Close an issue with a brief, factual gh comment — gh issue close <n> --comment "<what fixed it, or why no code change is needed>" — never close silently, and keep the comment as short as the explanation allows.
- NON-NEGOTIABLE on EVERY PR-opening dispatch for an issue: the prompt MUST instruct the child to put a closing keyword (
Closes #N / Fixes #N / Resolves #N) in the PR BODY, and to VERIFY it is there as the last step before gh pr create. The closing keyword is what auto-closes the issue on merge — a resolving PR that lands without it leaves the issue silently open. The orchestrator carries this in the dispatch (it's part of the thread-ownership/PR contract, not optional), and the agent confirms the keyword is in the body before opening. A PR that merely RELATES to an issue uses Refs #N (no auto-close). Make "does the dispatch tell the child to put Closes #N in the PR body?" a fixed item on the pre-dispatch checklist for any issue-resolving PR.
- Driving a multi-PR goal (a release, a merge train) → keep ONE standing thread that ENUMERATES every open PR + its merge status; never track the train from memory or prose. When the goal is "land these N PRs / cut this release," a contributor can open a fresh PR and a sitting-mergeable PR can stall — both invisible if you rely on recollection. Maintain a single
.fray/<release-or-train>.md thread whose ## Status lists every relevant PR (number · title · author · CI state · mergeable?) and refresh it each turn by RUNNING gh pr list (e.g. gh pr list --state open --json number,title,author,mergeStateStatus,statusCheckRollup) against the goal, not from memory — so a new or sitting PR is SURFACED, not remembered. The board + that one re-derived-from-gh list is the source of truth for what still has to merge; chat prose and config.yml notes are not. (Burned near a release: a contributor's new PR and an already-mergeable PR were both missed because nothing enumerated the open-PR set each turn.)
- Shipping a user-facing change INCLUDES updating the user-facing docs, in the SAME effort. When the work adds or changes a feature, flag, default, or behavior a user can observe, updating the docs (README, docs site, help text — whatever the project uses) is a STEP of that effort, not a separate later thread. Bake it into the issue→fix→verify→ship lifecycle the dispatch prescribes: a change that lands code but leaves the docs describing the old behavior is half-done.
"workflow" keyword — auto-escalate to the L1+L2 spike
When the maintainer uses the word "workflow" in a task request, default to running that task as a full L1+L2 high-effort dev spike — dispatch an L1 lead agent that fans out L2 sub-agents (investigate → implement → self-review → land), NOT a solo pass or a minimal-effort dispatch. "workflow" is the maintainer's spoken keyword that means "give this the full multi-agent treatment": an L1 that owns a worktree/thread and fans L2 waves (parallel where the work parallelizes), synthesizes, and self-reviews before landing. Absent the word, judge effort normally; with it, escalate to the L1+L2 spike by default.
Disambiguation: this is the maintainer's SPOKEN word "workflow" (in a task request), and the response is this DYNAMIC fray L1+L2 spike pattern (individually-dispatched background sub-agents) — NOT necessarily the deterministic Workflow tool/DAG. The trigger is the human's word; the machinery is the nested-lead pattern described in the section below.
The two-level nested-lead pattern (L1→L2) — a dispatched lead that fans out its OWN sub-agents
The shapes above are FLAT (orchestrator dispatches instruments; orchestrator reviews). But a dispatched sub-agent (general-purpose, Opus) is itself a LEAD ("L1") with the Agent tool — so it can spin up its OWN sub-agents ("L2s"), exactly like the orchestrator does. Two distinct uses of this, both encouraged:
Use A — L1 FANS OUT L2s for independent sub-work (parallelism AND/OR clean context), then synthesizes
When an L1 lead's task DECOMPOSES into genuinely-independent pieces, it should spin up as many (or as few) L2s as the task genuinely warrants — collect their results, and synthesize ONE deliverable — rather than grinding through the pieces serially in its own context. The count is pure JUDGMENT, driven by the work — there is NO prescribed number or typical band. It might be one L2 for a clean isolated context, two for a simple split, or many for a wide independent fan-out. Two things make an L2 worth dispatching, and EITHER alone is sufficient: (1) parallelism (independent pieces run at once, the wall-clock win), and (2) context isolation — a focused subtask deserves its own FRESH context window even with no parallelism at all. Spinning up a SINGLE L2, purely to give one gnarly subtask a clean isolated context, is a legitimate and encouraged use; the value of an L2 is context hygiene as much as concurrency. This is the pattern that consistently wins on wall-clock:
- Research "to its logical conclusion" across N paths — one L2 per architecture path / design option / candidate approach, then the L1 synthesizes the comparison.
- A survey across N independent units — one L2 per attack / reference / file / subsystem / fixture, then the L1 rolls the findings into one report.
- Separable implementation pieces — one L2 per independent module/file when the pieces don't share state, then the L1 integrates + builds.
ORCHESTRATOR DISPATCH MANDATE — a multi-pronged research / investigation / audit effort MUST be dispatched WITH AN EXPLICIT FAN-OUT INSTRUCTION in the prompt (codified 2026-06-27, after a 4-prong bun-lockfile-v2 research was dispatched as a SOLO serial pass with no fan-out line). When the effort decomposes into ≥~3 genuinely-independent questions/units (the canonical tell: a task phrased as a list of distinct sub-questions — "understand X, survey Y, identify Z, check W"), the orchestrator's dispatch PROMPT must PLAINLY tell the L1 to spin up parallel sub-agents, one per prong, and synthesize their results — do NOT rely on the L1 to elect fan-out on its own; left unsaid, an L1 routinely grinds the prongs serially in one context. The tell you FAILED: you dispatched a wide multi-question research/audit task as one agent and the prompt has no "spin up sub-agents in parallel" sentence. (Tier each prong: survey/harvest prongs → Sonnet, hard-analysis prongs → Opus. This is the research/audit twin of the implementation-dispatch mandate — significant L1 work names its own sub-agent process in the prompt because the L1 starts with a fresh context and won't infer it.)
WHEN to dispatch L2s: the sub-pieces are INDEPENDENT (no piece needs another's output) AND EITHER parallelism saves real wall-clock OR a subtask is heavy enough to deserve its own clean context. WHEN to stay inline instead: a true dependency chain (piece B consumes piece A's result — sequence them, don't fan out), or the work is trivial enough that dispatch overhead exceeds the benefit (just do it inline). Mirror the orchestrator's own discipline: parallelize across DISJOINT sub-questions, serialize across OVERLAPPING ones; ONE L2 per subject (wide, not deep).
MECHANICS the L1 must follow:
- NEVER put orchestrator-internal "L1/L2" vocabulary in the PROMPT TEXT sent to a dispatched agent. The L1/L2 tier model is the ORCHESTRATOR's mental model — a dispatched agent does not share it, so "you are the L1 lead, fan out L2 sub-agents" reads as confusing jargon to the agent receiving it. When you want a dispatched agent to use sub-agents, say so PLAINLY: "spin up sub-agents to research/do X, Y, Z in parallel and synthesize their results." The agent only needs to know it CAN and SHOULD dispatch sub-agents — not what tier it occupies. Keep "L1"/"L2" for the orchestrator's own reasoning, threads, and this skill's framing; never in prompt text a sub-agent reads.
- As many as the task warrants — but never an unbounded swarm — no prescribed number; let the work set the count. The ONE hard limit is the anti-runaway budget guard: do not spray an uncontrolled heavy swarm. The real ceiling is 429 backoff, not a fixed count.
- Nested dispatch is supported and smoke-tested — an L2's completion returns to the DISPATCHING L1 (not up to the main orchestrator), so the L1 owns collecting them.
- Do NOT set
name/team_name on the L2 dispatch — the dispatch hook auto-strips both (it strands nested agents otherwise); just omit them.
- TIER each L2 by cognitive load — mechanical/survey research → cheaper model (Sonnet); hard analysis / engineering / adversarial review → Opus. Don't run an all-Opus fan-out for survey work.
- COLLECT + SYNTHESIZE — the L1 folds every L2 finding into ONE coherent deliverable; it never returns scattered, un-integrated sub-results. (Sidecar
.fray/<thread>.findings/<id>.md files are the transient per-L2 scratch; the L1 integrates them into its single unified deliverable.)
- The L1 still owns its thread + reports UP to the orchestrator as usual — the L2 fan-out is the L1's internal mechanism, invisible to the orchestrator except in the quality + speed of what the L1 hands back.
Use B — L1 self-organizes its own REVIEW via L2s (the nested-implementer)
For substantive new functionality, the L1 implementer self-organizes its OWN review via L2 sub-agents. The level-1 child runs:
- PLAN — write the implementation plan.
- PLAN-REVIEW — dispatch a level-2 sub-agent to critique the plan (gaps, wrong assumptions, simpler approach, missed constraint).
- SECOND-PASS the plan — fold in the valid critique; this is a real revision, not a rubber-stamp.
- IMPLEMENT against the refined plan.
- SELF-REVIEW — dispatch level-2 reviewer(s); for a MAJOR change, MULTIPLE PARALLEL reviewers each with a DISTINCT LENS (correctness / security / a specific subsystem) — disjoint read-only review parallelizes freely.
- CRITICALLY INCORPORATE — the implementer judges each review ON MERIT and folds in only what's valid. It explicitly does NOT blind-trust: a level-2 reviewer has NARROWER and possibly STALE context than the implementer, so the implementer owns the final call. Reviews are advice, not verdicts.
Invariants:
- Review at BOTH stages — the plan AND the implementation. Catching a wrong plan before code exists is the cheapest fix in the loop.
- Depth scales with blast radius — a trivial change needs NO nesting (just implement + the orchestrator's own review pass); a major one gets the parallel-lens review panel. Don't ceremony-wrap a one-liner; don't single-lens a far-reaching change.
- The implementer owns judgment over its reviewers — it incorporates critically, never mechanically.