Envoy-and-orchestrator workflow for Claude Code with Herdr: one envoy session you talk to, an orchestrator that runs role agents in panes, briefs as files…

Orchestrator-plus-team-agents workflow for Claude Code with Herdr. One envoy session you talk to, an orchestrator that runs the team, role agents in panes, briefs as files, open questions as cards, a numbered decisions file, reports by hook. This plugin is the kernel: a project-agnostic process, role definitions, templates, commands, a Claude Code mod, and glue scripts around the Herdr CLI. Everything about a specific repository lives in that repository's overlay.
Requires Claude Code 2.1.292 or newer (the team mod runs on its function hooks; the Team and Questions tabs, the status line and the toast were checked on that build, not on older ones).
/team:init. It is pull-driven. It speaks when you speak to it, shows the question queue, records your decisions with decide, and passes operational requests to the orchestrator with relay. Nothing pushes a turn into it./team:init starts an orchestrator in its own worker tab. It plans, briefs, reads reports, and acts on DECISION and RELAY lines. It never talks to you in chat: anything it needs from you becomes a card. It never does operational work itself - no investigating, testing, browsing, or editing. Every such task goes to an agent.team-start. Every agent is addressed by its Claude session id, never by its herdr name: herdr does not restore names after a restart, session ids survive it.ask tool): context, question, options with cost, a recommendation, what waits, and whether the door is one-way or two-way with an estimate of the rework. A worker parks a two-way card with about an hour of rework or less and continues; it waits on the others. The envoy shows cards grouped by tag, and decide closes one or several with one numbered decision. An urgent card shows as a status line and one toast in the envoy session, never as a turn.scratchpad/current/, composed by team-brief from templates plus your project overlay. The orchestrator sends the one-line kick-off with the brief_send tool.scratchpad/current/decisions-<ticket>.md) is the single binding source. Every brief reads it first. team-init creates it; after that the envoy is its only writer, through decide.scratchpad/current/reports/<name>-<topic>.md.REPORT lines and new decisions, checks the agents, and sends what it found as one prompt. The envoy half draws the Team and Questions tabs beside the transcript and keeps the urgent badge.The full protocol is the team-orchestration skill. Operational lessons are in its references/lessons.md (not auto-loaded).
Develop against a local checkout:
claude --plugin-dir plugins/team
Or install from the marketplace once published:
/plugin marketplace add abrose/claude-plugins
/plugin install team@abrose-plugins
| Command | Does | |
|---|---|---|
/team:init <ticket> | Run it in the session you talk to: that session becomes the envoy. Archives the previous run, writes the decisions file and the plan file (team-init does it; a ticket must be a plain name such as ABC-123), starts the orchestrator in a worker tab, writes the first roster. The team mod then opens the Team and Questions tabs. Agents are started on demand, not chosen up front. | |
/team:brief <name> <topic> | The orchestrator runs it: compose a brief, fill its task section, send the kick-off with brief_send, note the status line in the plan file. | |
/team:status | Run it in the envoy session. Read the roster (the orchestrator and the workers), read idle agents that owe a report, flag anything that needs you. | |
| `/team:release [name ... | all]` | all (the end of a session) runs in the envoy session; <name> runs in the orchestrator or the envoy session. Clear and close finished agents, then forget their records and index entries; refuse a working one. Never closes the envoy's pane or the pane of the session it runs in. all also clears the mod's state files, but keeps delivered.json so the orchestrator gets no old DECISION line again. |
/team:resurrect | Run it in the envoy session. After a restart, relaunch the orchestrator and the workers that came back without their team flags, resuming their sessions. It starts no agent that has no record. | |
/team-overview | Hide or show the Team and Questions tabs (a mod command; the choice is kept per team). In a herdr pane narrower than 144 columns the tabs wait undrawn; the first call draws them. |
In the Team pane, click an agent row to jump to its herdr pane. With the keyboard: ctrl+x tab focuses the pane, then 1-9 jumps to that agent.
bin/, on PATH when enabled)| Script | Does | ||
|---|---|---|---|
| `team-id slug\ | hash\ | for` | Compute a team id: slug a ticket, or a random hash. |
team-init <ticket> [--envoy-pane <id>] | Refuse a ticket that is not a plain name (exit 2), check the Claude Code version, archive the previous run to scratchpad/.archive/ (refuses while its agents are live), write decisions-<ticket>.md and progress-<ticket>.md from the templates (never overwrites), write the team id and config with this session as envoy_session, seed the safe permission allowlist, record the envoy's tab, start the orchestrator in a worker tab and record its session and tab, prune stale session index entries. | ||
team-start <name> <role> | Start a role agent (investigator, implementer, tester or orchestrator) in a pane with a session id it assigns (--session-id), verify its status bar, record it under .team/ with its launch flags, and write its session index entry. The new claude loads the plugin this script came from (--plugin-dir), unless that is an installed copy. --new-tab opens a worker tab with the agent in its root pane. | ||
| `team-brief compose\ | prepare` | Compose a brief from templates + overlay, or prepare its kick-off prompt (update the record, mirror into a worktree, print the prompt). | |
team-slice <branch> <parent> | Create a worktree (with the repo's own worktree_cmd from the overlay; refuses without one) and a Herdr tab for one slice. | ||
team-status | Match herdr agent list to .team/ records by session id into a roster. Each session appears once; the envoy and the calling session never do. | ||
team-resurrect | Relaunch the orchestrator and the workers a herdr restore marked as restored, with their saved flags and the same --plugin-dir rule as team-start. A marked agent /cleared before it ran is adopted: the session its recorded pane runs, when no record claims it, becomes its identity. | ||
team-forget <name> ... | Delete the session index entry and the record .team/<name>.json of each named agent, once its pane is closed (/team:release runs it). Keeps both for an agent herdr still lists, and accepts only plain names and plain session ids. |
| Role | Agent | Model | Default effort | Read-only |
|---|---|---|---|---|
| investigator | team-investigator | Opus 5.5 | medium | yes (tool filter) |
| implementer | team-implementer | Sonnet 5.5 | medium | no |
| tester | team-tester | Sonnet 5.5 | low | yes, except test files (by rule) |
| orchestrator | team-orchestrator | Opus 5.5 | medium | by rule (never edits, never investigates); started by team-init |
| envoy | team-envoy | Opus 5.5 (fresh session only) | - | In a fresh session started with claude --agent team-envoy: yes (Edit, Write blocked). In your own session after /team:init: no. It keeps every tool and its own model; the prose rule of team-role-envoy is its only guard |
The orchestrator overrides the defaults per job with team-start --model opus-5-5|sonnet-5-5|haiku-5-5 and --effort low|medium|high|xhigh|max. Any other model is refused. Without --effort, haiku-5-5 runs at effort high.
Reviewer and post-notes work run on team-investigator with the brief-review and brief-post-notes templates. Mechanical jobs run on team-implementer with --model haiku-5-5.
/team:init <ticket> assigns the run a short team id (a slug of the ticket) and writes it to .team/config.json. Every agent name becomes <team_id>-<label>; the orchestrator is <team_id>-orch. Init checks the live herdr agent list and, if that name namespace is already taken (for example by a prior run for the same ticket), disambiguates the team id (app-1, then app-1-2, then a random hash). team-start likewise refuses a <team_id>-<label> that a live agent already holds. A team's mod only ever acts on the agents and tabs recorded under its own .team/ directory.
hooks/mod/ is a Claude Code mod in the same plugin. It loads in every session with the plugin enabled and activates per role: the orchestrator half in the session that .team/config.json names as orchestrator_session, the envoy half in the one it names as envoy_session. A config without envoy_session (an older run) runs both halves in the orchestrator session. On session.start it sets TEAM_SESSION_ID, which team-init reads.
Every 15 s the envoy half reads the plan, the cards and the agent rows for the Team and Questions tabs, sets the status line to <n> urgent: ... while an urgent card waits, and toasts each new urgent card once. It also lists each idle worker tab in the Team tab as tab <id> idle, consider release (<pane> <status>, ...). It sends no prompt.
Every 15 s the orchestrator half:
session to a herdr agent and writes a moved pane id back to the record, so pane ids heal after a restart;.team/delivered.json and sends their REPORT lines (the first activation only sets the marks), and sends a DECISION line for each new decision the envoy recorded;WATCH line for a transition into blocked (with the dialog's first line), and for an agent that has been quiet for 2 minutes without a report, once per quiet spell. Quiet means idle or done in herdr, or no new turn in its transcript since its last stop. A report counts only when the report file holds a REPORT line and is newer than the brief;team-start has not marked it pending; never touches the orchestrator's own tab (the one herdr shows it in, and orchestrator_tab in the config) or the envoy's (envoy_tab), which team-init records;idle or done with a fresh report for 30 minutes (the timer resets on working or blocked), when it has no open or assumed card and its pane is in a team tab (one team-start made, listed in tabs.json) that is not the orchestrator's, the envoy's or the human's. It runs the steps of /team:release <name> (/clear, close the pane, team-forget), then sends released <name> (<pane>) after 30 min idle. A step that fails sends WATCH <name>: auto-release failed at <step>: <reason> and is not tried again until the agent worked, blocked or lost its report, or herdr was down (then after a fresh 30 minutes). An agent without a report is never released;WATCH herdr unreachable: <reason> while herdr is down.All lines of one tick go out as one prompt (REPORT lines first, then DECISION, then WATCH and the released lines), which waits until the orchestrator is idle and never touches a draft you are typing.
The brief_send tool (mcp__team__brief_send) sends a kick-off by session id. It works only in the orchestrator session; elsewhere (a worker, the envoy, another repo) it refuses.
The card tools are ask, queue, decide and relay:
| Tool | Where | Does |
|---|---|---|
ask | orchestrator and workers (not the envoy, except in a run with one session) | File a card. Returns Q-<n>; the worker names it in its REPORT. |
queue | envoy | List open and assumed cards grouped by tag, most pressing first. |
decide | envoy | Close cards with one numbered decision (appended to the decisions file), or mark them obsolete. Lists in overrides the assumed cards the answer changes. |
relay | envoy | Send an operational request to the orchestrator as a RELAY line. Refuses a text equal to the last one sent. |
Auto mode reviews that send as a SendMessage with no user request behind it, so its classifier gives no verdict; team-init therefore adds SendMessage to permissions.allow in .claude/settings.local.json, which decides it without the classifier. Remove that entry if you want to approve each send yourself. When the agent got a brief in its current session and was neither cleared nor compacted since, it waits up to 6 s for a /clear or /compact to land, then refuses.
A run started before 0.6.0 has no envoy_session in its config. It keeps working: the orchestrator session holds both roles, shows the Team and Questions tabs, and can call queue and decide itself. relay refuses there, because no separate envoy exists. Start the next run with /team:init to get the envoy and the orchestrator in separate sessions.
A team started before 0.5.0 has no orchestrator_session in its config, so the mod stays inactive for it and nothing delivers its REPORT lines; team-status warns about it. Finish such a run by reading reports/ by hand, or start a new run with /team:init.
A project provides zero or more of these under its own repository. The kernel runs with an empty overlay; a missing file it would have used produces one warning.
| Path | Used by | Content |
|---|---|---|
.claude/team/project.yaml | team-slice, /team:release, brief compose | stacked, spdd, worktree_cmd (required by team-slice), worktree_dir (required by team-slice), release_check, gate_cmd |
.claude/team/env.md | brief compose ("Environment") | how to run and test locally; a "Slice kick-off" checklist |
.claude/team/gate.md | brief compose ("Gate") | test command, lint, the greps a delivery must pass |
.claude/team/tracker.md | analysis and post-notes briefs | ticket system and hygiene rules |
.claude/team/roles/<role>.md | brief compose | extra brief sections for one role in this repo |
.claude/skills/project-<role>/SKILL.md | agent preload | standing rules for one role in this repo |
All under $TEAM_SCRATCH (default scratchpad/current/, git-ignored). /team:init archives the previous run and loose scratchpad entries to scratchpad/.archive/. decisions-<ticket>.md, progress-<ticket>.md, orchestration-decisions.md, brief-<name>-<topic>.md, reports/<name>-<topic>.md, .team/<name>.json, .team/config.json, .team/tabs.json, .team/watch-state.json, .team/layout-flags.json, .team/delivered.json, .team/toasted.json (ids of urgent cards seen or toasted), .team/last-relay.txt (the last relayed text and its orchestrator session), .team/questions/ (one Q-<n>.json per card, and claims/), .team/decisions/ (one <n>.json per decision, which the orchestrator's tick reads), .team/stops/, .team/restored/, .team/roster.md.
One file lives outside the run dir: the session index, ${TEAM_INDEX_DIR:-${CLAUDE_CONFIG_DIR:-~/.claude}/team/sessions}/<session>.json ({ "scratch", "name" } per agent). It follows the Claude Code profile, and team-start passes its caller's CLAUDE_CONFIG_DIR to the agent's pane. The envoy's team-init starts the orchestrator, and the orchestrator starts the workers, so a team runs in one profile.
The Stop hook finds its agent in this order:
TEAM_NAME and an absolute TEAM_SCRATCH in its own environment. team-start stamps both onto the agent's pane; they survive /clear but not a restart.session_id through the session index, then the record, whose session must match.Neither: the hook exits silently (any non-team session). A SessionStart hook keeps the identity current: on /clear (a new session id in the same process) it moves the record's session and the index entry to the new id; on a resume without TEAM_NAME (a herdr restore) it marks the worker in .team/restored/ for /team:resurrect. The mod follows the /clear of the orchestrator session and of the envoy session the same way, through session.end.
python3 -m unittest discover -s plugins/team/tests
claude plugin test plugins/team
The bash tests put a shim directory on PATH that exposes tests/fake-herdr as herdr, tests/fake-git as git and tests/fake-claude as claude. They record every invocation and answer with canned output, so the scripts run against a real (fake) CLI, never a mock. The mod tests (tests/mod/) answer each engine call the mod makes from an in-memory world (tests/mod/world.ts): files, herdr output, store, env, clock.
MIT. See LICENSE.
hooks/mod/team.tsx 281 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3import type { TeamAgentRow, TeamCard, TeamPlan } from '../../types'
4import { envoyConfig, noteClear, sessionRole } from './activation'
5import type { TeamConfig } from './activation'
6import { badgeText, freshUrgent, toastText } from './badge'
7import { BRIEF_TOOL, BRIEF_TOOL_SPEC, briefSend } from './brief'
8import { readJson, writeJson } from './io'
9import type { Io } from './io'
10import { herdrAgents, herdrFocus } from './herdr'
11import type { HerdrListing } from './herdr'
12import { closeHides, drawPane, drawQuestions, hiddenKey, QUESTIONS_PANE, TEAM_PANE } from './pane'
13import { runDir, setCwd, teamDir } from './paths'
14import { parsePlan } from './plan'
15import { DECIDE_TOOL_SPEC, decideTool, QUEUE_TOOL_SPEC, queueTool } from './decide'
16import { ASK_TOOL_SPEC, askTool, readCards } from './questions'
17import { RELAY_TOOL_SPEC, relayTool } from './relay'
18import { pickDecisions } from './decisions'
19import { agentRows, idleTabRows, newReportLines, readLedger, readRecords, syncPanes, watchTick } from './tick'
20import type { TeamRecord } from './tick'
21
22export const TICK_MS = 15000
23const active = atom({ plugin: 'team', key: 'active' } as const, false)
24const agents = atom({ plugin: 'team', key: 'agents' } as const, [] as TeamAgentRow[])
25const idle = atom({ plugin: 'team', key: 'idleTabs' } as const, [] as string[])
26const plan = atom({ plugin: 'team', key: 'plan' } as const, null as TeamPlan | null)
27const cards = atom({ plugin: 'team', key: 'cards' } as const, [] as TeamCard[])
28const urgent = atom({ plugin: 'team', key: 'urgent' } as const, 0)
29const planPath = atom({ plugin: 'team', key: 'planPath' } as const, '')
30const tickAt = atom({ plugin: 'team', key: 'tickAt' } as const, '')
31const error = atom({ plugin: 'team', key: 'error' } as const, '')
32// One tick loop per module: a second session.start replaces it, never adds one.
33let tickTimer: Timer | null = null
34// The status text this module set last; null until a session sets one, so its first tick always sets the line.
35let lastBadge: string | undefined | null = null
36
37function makeIo($: EngineInterface): Io {
38 return {
39 sessionId: () => $.session.id(),
40 envGet: async name =>
41 name === 'TEAM_SCRATCH' ? $.env.get('TEAM_SCRATCH') : $.env.get('HERDR_BIN_PATH'),
42 setSessionEnv: id => $.env.set('TEAM_SESSION_ID', id),
43 readText: async path => {
44 try {
45 const text = await $.fs.read(path)
46 return typeof text === 'string' ? text : null
47 } catch {
48 return null
49 }
50 },
51 writeText: (path, text) => $.fs.write(path, text),
52 list: async dir => {
53 try {
54 return (await $.fs.list(dir)).map(e => e.name)
55 } catch {
56 return []
57 }
58 },
59 mtime: async path => {
60 try {
61 return (await $.fs.stat(path)).mtimeMs
62 } catch {
63 return null
64 }
65 },
66 run: async (argv, timeoutMs = 10000) => {
67 try {
68 return await $.process.run(argv, { timeoutMs })
69 } catch (err) {
70 return { exitCode: -1, stdout: '', stderr: String(err) }
71 }
72 },
73 sleep: ms => $.clock.sleep(ms),
74 now: () => $.clock.now(),
75 sendTo: async (sessionId, text) => {
76 const sent = await $.session.send({ to: { sessionId }, text })
77 return sent.isDelivered ? { isDelivered: true } : { isDelivered: false, reason: sent.reason }
78 },
79 pluginRoot: $.plugin.root,
80 }
81}
82
83// Both tabs open on the first activation and on show; the last one opened is the one shown.
84async function openPanes($: EngineInterface): Promise<void> {
85 await $.ui.open({ id: TEAM_PANE, title: 'Team' })
86 await $.ui.open({ id: QUESTIONS_PANE, title: 'Questions' })
87}
88
89// A person's close of either tab closes the other one too and stores hidden:
90// the choice is per run, not per tab.
91async function hideForRun($: EngineInterface): Promise<void> {
92 const cfg = await envoyConfig(makeIo($))
93 if (!cfg) return
94 await $.store.set(hiddenKey(cfg.team_id), true)
95 const open = (await $.ui.panes()).map(p => p.id)
96 for (const id of [TEAM_PANE, QUESTIONS_PANE]) if (open.includes(id)) await $.ui.close({ id })
97}
98
99async function showPanes($: EngineInterface, teamId: string): Promise<void> {
100 if ((await $.store.get(hiddenKey(teamId))) === true) return
101 await openPanes($)
102}
103
104const NOT_SENT = 'prompt not sent: '
105
106/** The tabs layout hygiene and the release leave alone, besides the tab of the session itself. */
107const exemptTabs = (cfg: TeamConfig) => [cfg.orchestrator_tab, cfg.envoy_tab].filter((t): t is string => !!t)
108
109/** Sets the status line when its text differs from the one this module set last. */
110async function setStatus($: EngineInterface, text: string | undefined): Promise<void> {
111 if (text === lastBadge) return
112 await $.ui.status(text)
113 lastBadge = text
114}
115
116/**
117 * The orchestrator half: keeps records true, then sends REPORT, DECISION and WATCH lines as one prompt.
118 * `split` is true when another session is the envoy: no pane shows the error, so the status line does.
119 */
120async function orchestratorHalf(
121 $: EngineInterface, io: Io, cfg: TeamConfig, teamdir: string,
122 records: Record<string, TeamRecord>, listed: HerdrListing, split: boolean,
123): Promise<void> {
124 await syncPanes(io, teamdir, records, listed)
125 const run = await runDir(io)
126 const now = await $.clock.now()
127 // A card still waiting for the human (open, or parked and so assumed) holds its sender's release back.
128 const waiting = new Set((await readCards(io, teamdir))
129 .filter(c => c.status === 'open' || c.status === 'assumed').map(c => c.from))
130 const watch = await watchTick(io, run, teamdir, records, listed, now, await $.session.id(), exemptTabs(cfg), waiting)
131 const reports = await newReportLines(io, run, teamdir, records)
132 const decisions = pickDecisions(await readLedger(io, teamdir), reports.delivered._decision)
133 const lines = [...reports.lines, ...decisions.lines, ...watch]
134 try {
135 if (lines.length > 0) await $.prompt.submit({ text: lines.join('\n') })
136 await reports.commit({ _decision: decisions.mark })
137 if (split && lines.length > 0 && lastBadge?.startsWith(NOT_SENT)) await setStatus($, undefined)
138 } catch (err) {
139 const text = `${NOT_SENT}${String(err).slice(0, 160)}`
140 await update($, error, () => text)
141 if (split) await setStatus($, text)
142 }
143}
144
145/** The envoy half: the plan, the cards and the agent rows the panes draw. */
146async function envoyHalf(
147 $: EngineInterface, io: Io, cfg: TeamConfig, teamdir: string,
148 records: Record<string, TeamRecord>, listed: HerdrListing,
149): Promise<void> {
150 const path = `${await runDir(io)}/progress-${cfg.ticket}.md`
151 const text = await io.readText(path)
152 await update($, planPath, () => path)
153 await update($, plan, () => (text === null ? null : parsePlan(text)))
154 const found = await readCards(io, teamdir)
155 await update($, cards, () => found)
156 const rows = agentRows(records, listed)
157 await update($, agents, () => rows)
158 const idleRows = await idleTabRows(io, await runDir(io), teamdir, records, listed, await $.session.id(), exemptTabs(cfg))
159 await update($, idle, () => idleRows)
160 await update($, error, () => (listed.ok ? '' : `herdr: ${listed.reason}`))
161 await urgentBadge($, io, teamdir, found)
162}
163
164/** The status line shows the urgent count; each new urgent card gets one toast. Neither starts a turn. */
165async function urgentBadge($: EngineInterface, io: Io, teamdir: string, found: TeamCard[]): Promise<void> {
166 const text = badgeText(found)
167 await update($, urgent, () => found.filter(c => c.urgent && (c.status === 'open' || c.status === 'assumed')).length)
168 await setStatus($, text)
169 const marksPath = `${teamdir}/toasted.json`
170 const fresh = freshUrgent(found, await readJson<string[]>(io, marksPath))
171 for (const c of fresh.toast) await $.ui.toast(toastText(c))
172 await writeJson(io, marksPath, fresh.seen)
173}
174
175async function tick($: EngineInterface, io: Io): Promise<void> {
176 const held = await sessionRole(io)
177 const was = await read($, active)
178 await update($, active, () => held !== null)
179 // Every team status line belongs to a role. Clear it once when the session loses its role. A session
180 // that still holds only the orchestrator role keeps its `prompt not sent` line: a good submit clears it.
181 const keepsError = !!held?.role.orchestrator && !!lastBadge?.startsWith(NOT_SENT)
182 if (!held?.role.envoy && lastBadge && !keepsError) await setStatus($, undefined)
183 if (!held) return
184 const { cfg, role } = held
185 if (role.envoy && !was) await showPanes($, cfg.team_id)
186
187 const teamdir = await teamDir(io)
188 const listed = await herdrAgents(io)
189 const records = await readRecords(io, teamdir)
190 // The envoy half first: the orchestrator half may report a failed prompt, which must not be cleared.
191 if (role.envoy) await envoyHalf($, io, cfg, teamdir, records, listed)
192 if (role.orchestrator) await orchestratorHalf($, io, cfg, teamdir, records, listed, !role.envoy)
193
194 const at = new Date(await $.clock.now()).toISOString().slice(11, 19)
195 await update($, tickAt, () => at)
196}
197
198export const register: Register = on => {
199 on('session.start', async ($, e, next) => {
200 setCwd(e.cwd)
201 const io = makeIo($)
202 await $.env.set('TEAM_SESSION_ID', await $.session.id())
203 lastBadge = null
204 await $.command.register({ name: 'team-overview', description: 'Show or hide the Team and Questions tabs' })
205 await $.tool.register(BRIEF_TOOL_SPEC)
206 await $.tool.register(ASK_TOOL_SPEC)
207 await $.tool.register(QUEUE_TOOL_SPEC)
208 await $.tool.register(DECIDE_TOOL_SPEC)
209 await $.tool.register(RELAY_TOOL_SPEC)
210 tickTimer?.cancel()
211 tickTimer = $.clock.every(TICK_MS, () => tick($, io))
212 return next(e)
213 })
214
215 on('session.end', async ($, e, next) => {
216 if (e.reason === 'clear') noteClear(e.sessionId)
217 return next(e)
218 })
219
220 on('tool.call', { tool: `mcp__team__${BRIEF_TOOL}` }, async ($, e) =>
221 briefSend(makeIo($), String(e.name ?? ''), String(e.topic ?? '')))
222
223 on('tool.call', { tool: 'mcp__team__ask' }, async ($, e) => askTool(makeIo($), e))
224
225 on('tool.call', { tool: 'mcp__team__queue' }, async ($, e) => queueTool(makeIo($), e))
226
227 on('tool.call', { tool: 'mcp__team__decide' }, async ($, e) => decideTool(makeIo($), e))
228
229 on('tool.call', { tool: 'mcp__team__relay' }, async ($, e) => relayTool(makeIo($), e))
230
231 on('command.run', { command: 'team-overview' }, async $ => {
232 const cfg = await envoyConfig(makeIo($))
233 if (!cfg) return { text: 'No team run in this session.' }
234 // A pane that is open but not placed (a herdr pane under 144 columns) is not drawn, so it counts as closed.
235 const open = (await $.ui.panes()).filter(p => p.isPlaced).map(p => p.id)
236 if (open.includes(TEAM_PANE) || open.includes(QUESTIONS_PANE)) {
237 await $.ui.close({ id: TEAM_PANE })
238 await $.ui.close({ id: QUESTIONS_PANE })
239 await $.store.set(hiddenKey(cfg.team_id), true)
240 return { text: 'Team overview hidden.' }
241 }
242 await $.store.set(hiddenKey(cfg.team_id), false)
243 await openPanes($)
244 return { text: 'Team overview shown.' }
245 })
246
247 on('ui.close', { id: TEAM_PANE }, async ($, e, next) => {
248 const done = await next(e)
249 if (closeHides(e.origin)) await hideForRun($)
250 return done
251 })
252
253 on('ui.close', { id: QUESTIONS_PANE }, async ($, e, next) => {
254 const done = await next(e)
255 if (closeHides(e.origin)) await hideForRun($)
256 return done
257 })
258
259 on('ui.render', { component: 'Pane', requestId: QUESTIONS_PANE }, async ($, e) => {
260 const { Box, Text } = $.ui.resolve(e)
261 return drawQuestions({ Box, Text } as never, await read($, cards))
262 })
263
264 on('ui.render', { component: 'Pane', requestId: TEAM_PANE }, async ($, e) => {
265 const { Box, Text, Button } = $.ui.resolve(e)
266 return drawPane({ Box, Text, Button } as never, {
267 agents: await read($, agents),
268 idle: await read($, idle),
269 plan: await read($, plan),
270 cards: await read($, cards),
271 planPath: await read($, planPath),
272 tickAt: await read($, tickAt),
273 error: await read($, error),
274 rows: e.viewport?.rows ?? 30,
275 }, async a => {
276 const reason = await herdrFocus(makeIo($), a.pane)
277 if (reason !== '') await update($, error, () => `focus ${a.name}: ${reason}`)
278 })
279 })
280}
281hooks/mod/activation.ts 75 lines1import { readJson, writeJson } from './io'
2import type { Io } from './io'
3import { teamDir } from './paths'
4
5export type TeamConfig = {
6 team_id: string
7 ticket: string
8 orchestrator: string
9 orchestrator_session?: string
10 orchestrator_tab?: string
11 envoy_session?: string
12 envoy_tab?: string
13}
14
15let clearedFrom: string | null = null
16
17export const noteClear = (oldId: string) => {
18 clearedFrom = oldId
19}
20
21export type Role = { orchestrator: boolean; envoy: boolean }
22
23/** Without envoy_session the orchestrator session also holds the envoy role. */
24export function roleOf(cfg: TeamConfig | null, id: string): Role {
25 const orchestrator = !!cfg && cfg.orchestrator_session === id
26 const envoy = !!cfg && (cfg.envoy_session ? cfg.envoy_session === id : orchestrator)
27 return { orchestrator, envoy }
28}
29
30async function followClear(io: Io): Promise<void> {
31 if (clearedFrom === null) return
32 const old = clearedFrom
33 clearedFrom = null
34 const path = `${await teamDir(io)}/config.json`
35 const cfg = await readJson<TeamConfig>(io, path)
36 const id = await io.sessionId()
37 await io.setSessionEnv(id)
38 if (!cfg || old === id) return
39 const moved = {
40 ...cfg,
41 ...(cfg.orchestrator_session === old ? { orchestrator_session: id } : {}),
42 ...(cfg.envoy_session === old ? { envoy_session: id } : {}),
43 }
44 if (moved.orchestrator_session !== cfg.orchestrator_session || moved.envoy_session !== cfg.envoy_session) {
45 await writeJson(io, path, moved)
46 }
47}
48
49// Follows a pending /clear first, so a command or tool call right after one
50// does not wait for the next tick to find the team.
51async function readConfig(io: Io): Promise<TeamConfig | null> {
52 await followClear(io)
53 return readJson<TeamConfig>(io, `${await teamDir(io)}/config.json`)
54}
55
56/** The run config and the halves this session holds, or null when it holds none. */
57export async function sessionRole(io: Io): Promise<{ cfg: TeamConfig; role: Role } | null> {
58 const cfg = await readConfig(io)
59 if (!cfg) return null
60 const role = roleOf(cfg, await io.sessionId())
61 return role.orchestrator || role.envoy ? { cfg, role } : null
62}
63
64/** The run config when this session holds the orchestrator role, else null. */
65export async function activeConfig(io: Io): Promise<TeamConfig | null> {
66 const found = await sessionRole(io)
67 return found?.role.orchestrator ? found.cfg : null
68}
69
70/** The run config when this session holds the envoy role, else null. The envoy half needs no orchestrator_session. */
71export async function envoyConfig(io: Io): Promise<TeamConfig | null> {
72 const found = await sessionRole(io)
73 return found?.role.envoy ? found.cfg : null
74}
75hooks/mod/badge.ts 26 lines1import type { TeamCard } from '../../types'
2
3const unresolvedUrgent = (cards: TeamCard[]) =>
4 cards.filter(c => c.urgent && (c.status === 'open' || c.status === 'assumed')).sort((a, b) => a.n - b.n)
5
6/** The status line text: how many urgent cards wait, and the oldest of them. Undefined when none wait. */
7export function badgeText(cards: TeamCard[]): string | undefined {
8 const waiting = unresolvedUrgent(cards)
9 const oldest = waiting[0]
10 return oldest ? `${waiting.length} urgent: ${oldest.id} ${oldest.from}: ${oldest.question}` : undefined
11}
12
13export const toastText = (c: TeamCard) => `URGENT ${c.id} ${c.from}: ${c.question}`
14
15/**
16 * The urgent cards to toast, and the marks to keep. `seen` is null when no marks
17 * exist yet: that run is a baseline, so it marks every urgent card and toasts none.
18 */
19export function freshUrgent(cards: TeamCard[], seen: string[] | null): { toast: TeamCard[]; seen: string[] } {
20 const urgent = cards.filter(c => c.urgent).sort((a, b) => a.n - b.n)
21 const marks = new Set(seen ?? [])
22 const toast = seen === null ? [] : unresolvedUrgent(cards).filter(c => !marks.has(c.id))
23 for (const c of urgent) marks.add(c.id)
24 return { toast, seen: [...marks] }
25}
26hooks/mod/brief.ts 62 lines1import { activeConfig } from './activation'
2import { bySession, herdrAgents } from './herdr'
3import { readJson, writeJson } from './io'
4import type { Io, ToolResult } from './io'
5import { teamDir } from './paths'
6import type { TeamRecord } from './tick'
7
8export const BRIEF_TOOL = 'brief_send'
9export const BRIEF_TOOL_SPEC = {
10 name: BRIEF_TOOL,
11 description:
12 'Send a composed brief to a team agent by its session id. Run `team-brief compose` first. ' +
13 'Returns "<name>: <state>"; an error names the reason.',
14 inputSchema: {
15 type: 'object',
16 properties: { name: { type: 'string' }, topic: { type: 'string' } },
17 required: ['name', 'topic'],
18 },
19}
20// A hook gets 10 s and a clock wait counts against it; leave room for the send.
21const WAIT_MS = 6000
22const POLL_MS = 500
23
24const fail = (result: string): ToolResult => ({ result, isError: true })
25
26const briefedInThisSession = (rec: TeamRecord) =>
27 rec.session !== undefined && rec.brief_sent_session === rec.session
28
29/**
30 * The record once it is no longer briefed in its current session, or after
31 * WAIT_MS. A /clear moves `session` on; a /compact keeps it, and the
32 * SessionStart hook drops `brief_sent_session` instead.
33 */
34async function clearedRecord(io: Io, path: string): Promise<TeamRecord | null> {
35 for (let waited = 0; ; waited += POLL_MS) {
36 const rec = await readJson<TeamRecord>(io, path)
37 if (!rec || !briefedInThisSession(rec) || waited >= WAIT_MS) return rec
38 await io.sleep(POLL_MS)
39 }
40}
41
42export async function briefSend(io: Io, name: string, topic: string): Promise<ToolResult> {
43 if (!(await activeConfig(io))) return fail('brief_send works only in the orchestrator session')
44 if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name)) return fail(`bad name: ${name}`)
45 const path = `${await teamDir(io)}/${name}.json`
46 const rec = await clearedRecord(io, path)
47 if (!rec) return fail(`no agent record: ${name}`)
48 if (!rec.session) return fail(`${name} has no session id; start it again with team-start`)
49 if (briefedInThisSession(rec)) return fail(`${name} was not cleared or compacted since its last brief`)
50
51 const prepared = await io.run([`${io.pluginRoot}/bin/team-brief`, 'prepare', name, '--topic', topic])
52 if (prepared.exitCode !== 0) return fail(prepared.stderr.trim() || `team-brief prepare failed (${prepared.exitCode})`)
53 const sent = await io.sendTo(rec.session, prepared.stdout.trim())
54 if (!sent.isDelivered) return fail(`not delivered: ${sent.reason ?? 'unknown reason'}`)
55
56 const latest = (await readJson<TeamRecord>(io, path)) ?? rec
57 await writeJson(io, path, { ...latest, brief_sent_session: rec.session })
58 const listed = await herdrAgents(io)
59 const state = listed.ok ? bySession(listed.agents).get(rec.session)?.agent_status ?? 'gone' : 'unknown'
60 return { result: `${name}: ${state}` }
61}
62hooks/mod/io.ts 39 lines1export type RunResult = { exitCode: number; stdout: string; stderr: string }
2
3export type ToolResult = { result: string; isError?: true }
4
5/** Everything the mod's logic needs from the engine, built over `$` in team.tsx. */
6export type Io = {
7 sessionId: () => Promise<string>
8 envGet: (name: 'TEAM_SCRATCH' | 'HERDR_BIN_PATH') => Promise<string | undefined>
9 setSessionEnv: (id: string) => Promise<void>
10 readText: (path: string) => Promise<string | null>
11 writeText: (path: string, text: string) => Promise<void>
12 list: (dir: string) => Promise<string[]>
13 mtime: (path: string) => Promise<number | null>
14 run: (argv: string[], timeoutMs?: number) => Promise<RunResult>
15 sleep: (ms: number) => Promise<void>
16 now: () => Promise<number>
17 sendTo: (sessionId: string, text: string) => Promise<{ isDelivered: boolean; reason?: string }>
18 pluginRoot: string
19}
20
21export async function readJson<T>(io: Io, path: string): Promise<T | null> {
22 const text = await io.readText(path)
23 if (text === null) return null
24 try {
25 return JSON.parse(text) as T
26 } catch {
27 return null
28 }
29}
30
31export async function writeJson(io: Io, path: string, value: unknown): Promise<void> {
32 await io.writeText(path, JSON.stringify(value))
33}
34
35export async function tailText(io: Io, path: string, bytes: number): Promise<string> {
36 const r = await io.run(['tail', '-c', String(bytes), path])
37 return r.exitCode === 0 ? r.stdout : ''
38}
39hooks/mod/herdr.ts 73 lines1import type { Io } from './io'
2
3export type HerdrAgent = {
4 pane_id: string
5 tab_id?: string
6 agent_status: string
7 agent_session?: { value: string }
8}
9export type HerdrPane = { pane_id: string; tab_id: string; agent_status: string }
10export type HerdrListing = { ok: true; agents: HerdrAgent[] } | { ok: false; reason: string }
11
12async function herdr(io: Io, args: string[]) {
13 const bin = (await io.envGet('HERDR_BIN_PATH')) ?? 'herdr'
14 return io.run([bin, ...args])
15}
16
17function failure(r: { exitCode: number; stdout: string; stderr: string }): string {
18 return (r.stderr || r.stdout).trim().slice(0, 200) || `exit ${r.exitCode}`
19}
20
21export async function herdrAgents(io: Io): Promise<HerdrListing> {
22 const r = await herdr(io, ['agent', 'list'])
23 if (r.exitCode !== 0) return { ok: false, reason: failure(r) }
24 try {
25 return { ok: true, agents: JSON.parse(r.stdout)?.result?.agents ?? [] }
26 } catch {
27 return { ok: false, reason: 'bad output from herdr agent list' }
28 }
29}
30
31export async function herdrPanes(io: Io): Promise<HerdrPane[] | null> {
32 const r = await herdr(io, ['pane', 'list'])
33 if (r.exitCode !== 0) return null
34 try {
35 return JSON.parse(r.stdout)?.result?.panes ?? []
36 } catch {
37 return null
38 }
39}
40
41export async function herdrDialog(io: Io, pane: string): Promise<string> {
42 const r = await herdr(io, ['agent', 'read', pane, '--source', 'detection', '--lines', '20'])
43 return r.stdout.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
44}
45
46/** Focuses the agent's pane; the failure reason, or '' when it worked. */
47export async function herdrFocus(io: Io, pane: string): Promise<string> {
48 const r = await herdr(io, ['agent', 'focus', pane])
49 return r.exitCode === 0 ? '' : failure(r)
50}
51
52/** Closes the pane; the failure reason, or '' when it worked. */
53export async function herdrClose(io: Io, pane: string): Promise<string> {
54 const r = await herdr(io, ['pane', 'close', pane])
55 return r.exitCode === 0 ? '' : failure(r)
56}
57
58/**
59 * Sends /clear to the agent in the pane; the failure reason, or '' when it worked. /clear never starts a
60 * turn, so herdr always answers `agent_prompt_stalled`: that is the expected answer here, not a failure.
61 */
62export async function herdrClear(io: Io, pane: string): Promise<string> {
63 const r = await herdr(io, ['agent', 'prompt', pane, '/clear', '--wait'])
64 if (r.exitCode === 0 || `${r.stderr}${r.stdout}`.includes('agent_prompt_stalled')) return ''
65 return failure(r)
66}
67
68export function bySession(list: HerdrAgent[]): Map<string, HerdrAgent> {
69 const map = new Map<string, HerdrAgent>()
70 for (const a of list) if (a.agent_session?.value) map.set(a.agent_session.value, a)
71 return map
72}
73hooks/mod/pane.tsx 109 lines1import type { Elements } from 'claude-code'
2import type { TeamAgentRow, TeamCard, TeamPlan, TeamPlanItem } from '../../types'
3import { queueGroups } from './cards'
4import type { QueueGroup } from './cards'
5import { fitPlan } from './plan'
6
7export const TEAM_PANE = 'team'
8export const QUESTIONS_PANE = 'questions'
9export const hiddenKey = (teamId: string) => `overviewHidden:${teamId}`
10/** Only the person's own close hides the overview. A close by a plugin or on unload does not. */
11export const closeHides = (origin: { kind: string }) => origin.kind === 'person'
12
13export type PaneView = {
14 agents: TeamAgentRow[]
15 /** Rows of idle team tabs, with the pane statuses seen: "tab w4:t19 idle, consider release (w4:p3K done)". */
16 idle: string[]
17 plan: TeamPlan | null
18 cards: TeamCard[]
19 planPath: string
20 tickAt: string
21 error: string
22 rows: number
23}
24
25const MARK = {
26 x: { glyph: '✓', color: 'green', dim: true },
27 '>': { glyph: '▶', color: 'yellow', dim: false },
28 ' ': { glyph: '○', color: undefined, dim: false },
29} as const
30
31export function drawPane({ Box, Text, Button }: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>, view: PaneView,
32 focus: (agent: TeamAgentRow) => void,
33) {
34 const groups = queueGroups(view.cards)
35 const unresolved = groups.flatMap(g => g.cards)
36 const urgent = unresolved.filter(c => c.urgent)
37 const tally = (cards: TeamCard[], status: 'open' | 'assumed') => cards.filter(c => c.status === status).length
38 const groupRow = (g: QueueGroup) =>
39 [g.tag, ...(['open', 'assumed'] as const)
40 .filter(s => tally(g.cards, s) > 0)
41 .map(s => `${tally(g.cards, s)} ${s}`)].join(' ')
42 const questionRows = (unresolved.length === 0 ? 1 : 1 + urgent.length + groups.length) + 1
43 const room = Math.max(view.rows - view.agents.length - view.idle.length - 12 - questionRows, 3)
44 const fitted = view.plan ? fitPlan(view.plan, room) : null
45 const item = (i: TeamPlanItem) => (
46 <Text wrap="truncate-end" dimColor={MARK[i.mark].dim}>
47 {' '}
48 <Text color={MARK[i.mark].color}>{MARK[i.mark].glyph}</Text> {i.text}
49 </Text>
50 )
51 const section = (title: string, items: TeamPlanItem[]) => (
52 <Box flexDirection="column">
53 <Text dimColor>{title}</Text>
54 {items.length === 0 ? <Text dimColor> -</Text> : items.map(item)}
55 </Box>
56 )
57 return (
58 <Box flexDirection="column">
59 {fitted ? (
60 <Box flexDirection="column">
61 <Text bold wrap="truncate-end">{fitted.plan.title || 'Plan'}</Text>
62 {section(fitted.hiddenDone ? `DONE (+${fitted.hiddenDone} earlier)` : 'DONE', fitted.plan.done)}
63 {section('RUNNING', fitted.plan.running)}
64 {section('NEXT', fitted.plan.next)}
65 </Box>
66 ) : (
67 <Text dimColor wrap="truncate-end">no plan yet: {view.planPath}</Text>
68 )}
69 <Text> </Text>
70 {unresolved.length === 0 ? (
71 <Text dimColor>Questions: none open</Text>
72 ) : (
73 <Box flexDirection="column">
74 <Text bold>{`Questions (${tally(unresolved, 'open')} open, ${tally(unresolved, 'assumed')} assumed)`}</Text>
75 {urgent.map(c => (
76 <Text key={c.id} wrap="truncate-end">
77 <Text color="yellow">!</Text> {c.id} {c.from}: {c.question}
78 </Text>
79 ))}
80 {groups.map(g => <Text key={g.tag} wrap="truncate-end">{` ${groupRow(g)}`}</Text>)}
81 </Box>
82 )}
83 <Text> </Text>
84 <Text bold>Agents</Text>
85 {view.agents.length === 0 && <Text dimColor> (none)</Text>}
86 {view.agents.map((a, i) => (
87 <Button key={a.name} plain hotkey={i < 9 ? String(i + 1) : undefined} onPress={() => focus(a)}>
88 {`${a.state.padEnd(8)} ${a.name} ${a.pane}`}
89 </Button>
90 ))}
91 {view.idle.map(row => <Text key={row} dimColor wrap="truncate-end">{row}</Text>)}
92 {view.error !== '' && <Text color="red" wrap="truncate-end">{view.error}</Text>}
93 <Text dimColor>tick {view.tickAt || '-'}</Text>
94 </Box>
95 )
96}
97
98/** The question log: every card, newest first, one row each with how it stands or closed. */
99export function drawQuestions({ Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>, cards: TeamCard[]) {
100 const row = (c: TeamCard) =>
101 `${c.id} ${c.status}${c.decision === undefined ? '' : ` #${c.decision}`} ${c.door} ${c.from}/${c.tag}: ${c.question}`
102 return (
103 <Box flexDirection="column">
104 {cards.length === 0 && <Text dimColor>no questions yet</Text>}
105 {[...cards].sort((a, b) => b.n - a.n).map(c => <Text key={c.id} wrap="truncate-end">{row(c)}</Text>)}
106 </Box>
107 )
108}
109hooks/mod/paths.ts 17 lines1import type { Io } from './io'
2
3let sessionCwd = ''
4
5export const setCwd = (cwd: string) => {
6 sessionCwd = cwd
7}
8
9export async function runDir(io: Io): Promise<string> {
10 const scratch = (await io.envGet('TEAM_SCRATCH')) ?? 'scratchpad/current'
11 return scratch.startsWith('/') ? scratch : `${sessionCwd}/${scratch}`
12}
13
14export async function teamDir(io: Io): Promise<string> {
15 return `${await runDir(io)}/.team`
16}
17hooks/mod/plan.ts 33 lines1import type { TeamPlan, TeamPlanItem } from '../../types'
2
3const ITEM = /^\s*[-*]\s+\[([xX> ])\]\s*(.*?)\s*$/
4const SECTIONS = new Set(['DONE', 'RUNNING', 'NEXT'])
5const BUCKET = { x: 'done', '>': 'running', ' ': 'next' } as const
6
7/** Buckets items by their mark, not their heading: the orchestrator flips a
8 * mark in place and does not always move the line to the matching section. */
9export function parsePlan(text: string): TeamPlan {
10 const plan: TeamPlan = { title: '', done: [], running: [], next: [] }
11 let inPlan = false
12 for (const line of text.split('\n')) {
13 if (line.startsWith('# ') && !plan.title) {
14 plan.title = line.slice(2).trim()
15 } else if (line.startsWith('## ')) {
16 inPlan = SECTIONS.has(line.slice(3).trim().toUpperCase())
17 } else {
18 const m = ITEM.exec(line)
19 if (!m || !inPlan) continue
20 const mark = (m[1] ?? ' ').toLowerCase() as TeamPlanItem['mark']
21 plan[BUCKET[mark]].push({ mark, text: m[2] ?? '' })
22 }
23 }
24 return plan
25}
26
27/** Fits the plan into `rows` item lines; DONE gives up its oldest items first. */
28export function fitPlan(plan: TeamPlan, rows: number): { plan: TeamPlan; hiddenDone: number } {
29 const room = Math.max(rows - plan.running.length - plan.next.length, 0)
30 const done = plan.done.slice(Math.max(plan.done.length - room, 0))
31 return { plan: { ...plan, done }, hiddenDone: plan.done.length - done.length }
32}
33hooks/mod/decide.ts 168 lines1import type { TeamCard } from '../../types'
2import { envoyConfig } from './activation'
3import type { TeamConfig } from './activation'
4import { queueGroups } from './cards'
5import { writeJson } from './io'
6import type { Io, ToolResult } from './io'
7import { runDir, teamDir } from './paths'
8import { readCards, writeCard } from './questions'
9
10export type DecisionEntry = {
11 n: number
12 cards: { id: string; from: string; tag: string }[]
13 answer: string
14 rationale: string
15 overrides: string[]
16 at: number
17}
18
19const fail = (result: string): ToolResult => ({ result, isError: true })
20
21const isText = (v: unknown): v is string => typeof v === 'string' && v.trim() !== ''
22
23/** The run config, or the refusal when this session does not hold the envoy role. */
24export async function envoyOnly(io: Io, tool: string): Promise<TeamConfig | ToolResult> {
25 return (await envoyConfig(io)) ?? fail(`${tool} works only in the envoy session`)
26}
27
28export async function queueTool(io: Io, input: unknown): Promise<ToolResult> {
29 const cfg = await envoyOnly(io, 'queue')
30 if ('result' in cfg) return cfg
31 const tag = (input as { tag?: unknown } | null)?.tag
32 const cards = await readCards(io, await teamDir(io))
33 return { result: JSON.stringify({ groups: queueGroups(cards, typeof tag === 'string' ? tag : undefined) }) }
34}
35
36/** 1 + the highest decision number in the file, amendments (3a) counted under their number. */
37export function nextDecision(text: string): number {
38 const numbers = [...text.matchAll(/^(\d+)[a-z]?\.\s/gm)].map(m => Number(m[1]))
39 return Math.max(0, ...numbers) + 1
40}
41
42export const decisionParagraph = (n: number, date: string, answer: string, rationale: string, ids: string[]) =>
43 `${n}. (${date}) ${answer} Why: ${rationale} Cards: ${ids.join(', ')}.\n`
44
45type DecideInput =
46 | { obsolete: true; cards: string[]; reason: string }
47 | { obsolete: false; cards: string[]; answer: string; rationale: string; overrides: string[] }
48
49function checkDecide(input: unknown): DecideInput | string {
50 const v = (input ?? {}) as Record<string, unknown>
51 const ids = v.cards
52 if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => typeof id === 'string' && /^Q-\d+$/.test(id))) {
53 return 'cards must list at least one Q-<n> id'
54 }
55 const listed = v.overrides ?? []
56 if (!Array.isArray(listed) || !listed.every((id): id is string => typeof id === 'string')) {
57 return 'overrides must list Q-<n> ids'
58 }
59 const overrides = [...new Set<string>(listed)]
60 const cards = [...new Set<string>(ids)]
61 if (v.obsolete === true) {
62 if (!isText(v.reason)) return 'reason is required to mark cards obsolete'
63 return { obsolete: true, cards, reason: v.reason }
64 }
65 if (!isText(v.answer) || !isText(v.rationale)) return 'answer and rationale are required'
66 // U+2028 and U+2029 count as line breaks: with the m flag, `^` matches after them (nextDecision).
67 if (/[\r\n\u{2028}\u{2029}]/u.test(v.answer)) return 'answer must be one line'
68 if (/[\r\n\u{2028}\u{2029}]/u.test(v.rationale)) return 'rationale must be one line'
69 return { obsolete: false, cards, answer: v.answer, rationale: v.rationale, overrides }
70}
71
72const unusable = (card: TeamCard) =>
73 card.status === 'answered' ? `${card.id} is already answered (decision ${card.decision})`
74 : card.status === 'obsolete' ? `${card.id} is already obsolete`
75 : null
76
77// One chain for the module: two calls in flight run one at a time, so each reads the
78// decisions file after the one before it has written. A failed call must not break the chain.
79let decideChain: Promise<unknown> = Promise.resolve()
80
81export function decideTool(io: Io, input: unknown): Promise<ToolResult> {
82 const run = decideChain.then(() => decideOnce(io, input))
83 decideChain = run.catch(() => undefined)
84 return run
85}
86
87async function decideOnce(io: Io, input: unknown): Promise<ToolResult> {
88 const cfg = await envoyOnly(io, 'decide')
89 if ('result' in cfg) return cfg
90 const checked = checkDecide(input)
91 if (typeof checked === 'string') return fail(checked)
92
93 const teamdir = await teamDir(io)
94 const byId = new Map((await readCards(io, teamdir)).map(c => [c.id, c]))
95 const picked: TeamCard[] = []
96 for (const id of checked.cards) {
97 const card = byId.get(id)
98 if (!card) return fail(`no card ${id}`)
99 const bad = unusable(card)
100 if (bad) return fail(bad)
101 picked.push(card)
102 }
103
104 if (checked.obsolete) {
105 for (const card of picked) await writeCard(io, teamdir, { ...card, status: 'obsolete', reason: checked.reason })
106 return { result: `Obsolete: ${checked.cards.join(', ')}` }
107 }
108
109 for (const id of checked.overrides) {
110 if (byId.get(id)?.status !== 'assumed' || !checked.cards.includes(id)) {
111 return fail(`${id} is not an assumed card of this decision`)
112 }
113 }
114 const { answer, rationale, overrides } = checked
115 const path = `${await runDir(io)}/decisions-${cfg.ticket}.md`
116 const text = await io.readText(path)
117 if (text === null) return fail(`no decisions file: ${path}`)
118
119 const n = nextDecision(text)
120 const now = await io.now()
121 const date = new Date(now).toISOString().slice(0, 10)
122 const paragraph = decisionParagraph(n, date, answer, rationale, checked.cards)
123 // Not atomic, by decision 31: paragraph, then ledger, then cards. If a write throws after the
124 // paragraph, the file holds a paragraph with no ledger entry (no DECISION line), the cards stay
125 // open, and a retry appends a second paragraph under the next number.
126 await io.writeText(path, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${paragraph}`)
127
128 const entry: DecisionEntry = {
129 n,
130 cards: picked.map(c => ({ id: c.id, from: c.from, tag: c.tag })),
131 answer,
132 rationale,
133 overrides,
134 at: now,
135 }
136 await writeJson(io, `${teamdir}/decisions/${n}.json`, entry)
137 for (const card of picked) await writeCard(io, teamdir, { ...card, status: 'answered', decision: n })
138 return { result: `Decision ${n}` }
139}
140
141export const QUEUE_TOOL_SPEC = {
142 name: 'queue',
143 description: 'List the open and assumed cards grouped by tag, most pressing first. Envoy session only.',
144 inputSchema: {
145 type: 'object',
146 properties: { tag: { type: 'string', description: 'Only this group.' } },
147 },
148}
149
150export const DECIDE_TOOL_SPEC = {
151 name: 'decide',
152 description:
153 'Close cards with one numbered decision, or mark them obsolete. List in overrides the assumed cards ' +
154 'whose assumption this answer changes. Envoy session only. Returns "Decision <n>".',
155 inputSchema: {
156 type: 'object',
157 properties: {
158 cards: { type: 'array', items: { type: 'string' }, description: 'Card ids, Q-<n>.' },
159 answer: { type: 'string', description: 'The decision, as the human gave it.' },
160 rationale: { type: 'string', description: 'Why, in one sentence.' },
161 overrides: { type: 'array', items: { type: 'string' }, description: 'Assumed cards this answer changes.' },
162 obsolete: { type: 'boolean', description: 'true: mark the cards obsolete instead of answering them.' },
163 reason: { type: 'string', description: 'Why the cards are obsolete.' },
164 },
165 required: ['cards'],
166 },
167}
168hooks/mod/questions.ts 99 lines1import type { TeamCard } from '../../types'
2import { readRecords } from './tick'
3import { readJson, writeJson } from './io'
4import type { Io, ToolResult } from './io'
5import { buildCard, checkAsk } from './cards'
6import type { AskInput } from './cards'
7import { teamDir } from './paths'
8
9const MAX_CLAIM_TRIES = 20
10
11export const questionsDir = (teamdir: string) => `${teamdir}/questions`
12
13const fail = (result: string): ToolResult => ({ result, isError: true })
14
15const cardNumber = (name: string) => /^Q-(\d+)\.json$/.exec(name)?.[1]
16
17export async function readCards(io: Io, teamdir: string): Promise<TeamCard[]> {
18 const dir = questionsDir(teamdir)
19 const cards: TeamCard[] = []
20 for (const name of await io.list(dir)) {
21 if (!cardNumber(name)) continue
22 const card = await readJson<TeamCard>(io, `${dir}/${name}`)
23 if (card) cards.push(card)
24 }
25 return cards.sort((a, b) => a.n - b.n)
26}
27
28export async function writeCard(io: Io, teamdir: string, card: TeamCard): Promise<void> {
29 await writeJson(io, `${questionsDir(teamdir)}/${card.id}.json`, card)
30}
31
32/** Takes the next free card number. A claim is a directory: mkdir fails when the path exists. */
33export async function claimNumber(io: Io, teamdir: string): Promise<number | null> {
34 const dir = questionsDir(teamdir)
35 await io.run(['mkdir', '-p', `${dir}/claims`])
36 const taken = [...(await io.list(dir)).map(cardNumber), ...(await io.list(`${dir}/claims`))]
37 .map(n => Number(n))
38 .filter(n => Number.isInteger(n))
39 let n = Math.max(0, ...taken) + 1
40 for (let tries = 0; tries < MAX_CLAIM_TRIES; tries++, n++) {
41 if ((await io.run(['mkdir', `${dir}/claims/${n}`])).exitCode === 0) return n
42 }
43 return null
44}
45
46async function asker(io: Io, teamdir: string): Promise<{ from: string; tag: string } | null> {
47 const session = await io.sessionId()
48 const records = await readRecords(io, teamdir)
49 for (const [name, rec] of Object.entries(records)) {
50 if (rec.session === session) return { from: name, tag: rec.topic || 'general' }
51 }
52 const cfg = await readJson<{ orchestrator: string; orchestrator_session?: string }>(io, `${teamdir}/config.json`)
53 if (cfg?.orchestrator_session === session) return { from: cfg.orchestrator, tag: 'general' }
54 return null
55}
56
57export async function askTool(io: Io, input: unknown): Promise<ToolResult> {
58 const teamdir = await teamDir(io)
59 const who = await asker(io, teamdir)
60 if (!who) return fail('ask works only in a team session (an agent started by team-start, or the orchestrator)')
61 const bad = checkAsk(input)
62 if (bad) return fail(bad)
63 const n = await claimNumber(io, teamdir)
64 if (n === null) return fail(`no free card number after ${MAX_CLAIM_TRIES} tries`)
65 await writeCard(io, teamdir, buildCard(input as AskInput, n, who.from, who.tag, await io.now()))
66 return { result: `Q-${n}` }
67}
68
69export const ASK_TOOL_SPEC = {
70 name: 'ask',
71 description:
72 'File an open question for the human as a card. State door and rework; park (parked: true) only a ' +
73 'two-way question with about an hour of rework or less, and continue on your recommendation. ' +
74 'Returns the card id, Q-<n>; name it in your REPORT line.',
75 inputSchema: {
76 type: 'object',
77 properties: {
78 context: { type: 'string', description: 'What the human needs to know to decide.' },
79 question: { type: 'string' },
80 options: {
81 type: 'array',
82 items: {
83 type: 'object',
84 properties: { option: { type: 'string' }, cost: { type: 'string' } },
85 required: ['option', 'cost'],
86 },
87 },
88 recommendation: { type: 'string', description: 'Your pick and why.' },
89 blocks: { type: 'string', description: 'What waits on the answer.' },
90 door: { type: 'string', enum: ['one-way', 'two-way'] },
91 rework: { type: 'string', description: 'Cost to undo the wrong pick, with its size.' },
92 parked: { type: 'boolean', description: 'true: continue on your recommendation without waiting.' },
93 urgent: { type: 'boolean' },
94 refs: { type: 'array', items: { type: 'string' } },
95 },
96 required: ['context', 'question', 'options', 'recommendation', 'blocks', 'door', 'rework', 'parked'],
97 },
98}
99hooks/mod/relay.ts 41 lines1import { envoyOnly } from './decide'
2import type { Io, ToolResult } from './io'
3import { teamDir } from './paths'
4
5const fail = (result: string): ToolResult => ({ result, isError: true })
6
7export async function relayTool(io: Io, input: unknown): Promise<ToolResult> {
8 const cfg = await envoyOnly(io, 'relay')
9 if ('result' in cfg) return cfg
10 if (!cfg.envoy_session) return fail('relay needs a separate envoy session; this session runs the orchestrator')
11 if (!cfg.orchestrator_session) return fail('relay needs a running orchestrator: the config has no orchestrator_session')
12 const raw = (input as { message?: unknown } | null)?.message
13 const message = typeof raw === 'string' ? raw.trim().replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n') : ''
14 if (message === '') return fail('message is required')
15 // Lines after the first are indented: no relayed line starts with REPORT, DECISION or WATCH.
16 const text = `RELAY ${message.replace(/\n/g, '\n ')}`
17 // Claude Code drops a peer message identical to the previous one yet still reports it as sent,
18 // so the relay refuses it. "Last" is the last text sent to the orchestrator, kept in a file of the
19 // run: it survives an envoy /clear and a mod reload, and a message not delivered never counts.
20 // The file holds the orchestrator session on its first line, then the text: a text sent to
21 // another orchestrator session (or a file without a session line) is not the previous message.
22 const lastPath = `${await teamDir(io)}/last-relay.txt`
23 const stored = `${cfg.orchestrator_session}\n${text}`
24 if (stored === (await io.readText(lastPath))) return fail('relay refused: same text as the last relay')
25 const sent = await io.sendTo(cfg.orchestrator_session, text)
26 if (!sent.isDelivered) return fail(`not delivered: ${sent.reason ?? 'unknown reason'}`)
27 await io.writeText(lastPath, stored)
28 return { result: 'sent' }
29}
30
31export const RELAY_TOOL_SPEC = {
32 name: 'relay',
33 description:
34 'Pass an operational request from the human to the orchestrator. Envoy session only. Returns "sent".',
35 inputSchema: {
36 type: 'object',
37 properties: { message: { type: 'string', description: 'The request, as the human gave it.' } },
38 required: ['message'],
39 },
40}
41