An agent swarm, Oracle, Manager, Lead, Coder, and an optional Driver, that takes a PRD to built, tested, reviewed code. Enforced reviews, a shared ledger, and…

A Claude Code plugin that runs an agent swarm to take a PRD to built, tested, reviewed code. An Oracle plans the run, Managers own phases, Leads own modules, and Coders write one file each. A Driver tests the app between waves when the host has cartographer and a driver plugin. Enforced reviews, a shared ledger, and a scoring rubric hold every role accountable, and every hand-off requires recorded evidence, not a claim.
claude plugin marketplace add Alexk413x/marketplace
claude plugin install codebase-kg@alexk413x --scope project
claude plugin install sentinel-swarm@alexk413x --scope project
Run the install commands from the repo the swarm builds in. sentinel-swarm depends on codebase-kg, and Claude Code refuses to load sentinel-swarm in a project where codebase-kg is not installed. Every role queries the code graph before it plans or writes, and each Coder updates the graph for its own file.
Pre-1.0. A small run works from start to finish: the hello-world smoke test passes with every role as its own background session, and a stopped session resumes and finishes its work. The hook transport runs on function hooks, an early-access Claude Code API that changes between releases.
PATH as py, python3, or python. The ledger imports only the standard library, so it needs no venv. On a Python older than codebase-kg's floor (3.10), graph writes run codebase-kg through uv, so install uv too.claude in it once and accept the trust prompt. claude --bg refuses an untrusted folder./sentinel-swarm:setup in the host repo. It writes the role files to .claude/agents/swarm-<role>.md, the hook shim to .sentinel-swarm/hook.py, the settings file, and the git excludes, and it builds the code graph when there is none./sentinel-swarm:run with the PRD. It starts the repo's ledger server and the Oracle as a background session./sentinel-swarm:status reads the run's state, and /sentinel-swarm:resume picks up a stopped run.The run and resume skills only run when you ask for them. The swarm works in your current working tree and commits nothing.
| Role | Color | Default model | Created by |
|---|---|---|---|
| Oracle | cyan | opus | The user |
| Manager | green | opus | Oracle |
| Lead | purple | sonnet | Manager |
| Coder | orange | sonnet | Lead |
| Driver | yellow | sonnet | Oracle |
Each parent-child pair follows the same cycle: brief, work, self-review, hand up, review, then approve, return, or accept as incomplete. Only the Coder writes project files. A parent starts its child with the ledger tool agent_spawn; no role has the Agent tool except the Driver, which may run only cartographer's agents.
claude --bg sessions for every role; one ledger server per host repo, on a loopback port with a bearer token; the host's test, build, and lint commands; git; and codebase-kg's tools..claude/sentinel-swarm.local.md, and Claude Code's plugin registry, to find the installed plugins..claude/agents/, the run's records in .sentinel-swarm/ (the ledger database, the server's port and token, and the report), and the project files the Coders write. It shows desktop notifications unless notify turns them off.The ledger is a Python project under mcp/. Run every check before you commit:
cd mcp
uv sync
uv run pytest
uv run pyright
uv run ruff check
uv run ruff format --check
cd ..
claude plugin test .
claude plugin validate --strict .claude-plugin/plugin.json
scripts/smoke.sh runs a hello-world swarm in a scratch host repo under runs/hello/. knowledge/prd/README.md indexes the specification, one document per subject; the code must match it. Run sh .githooks/install.sh once per clone.
Free to use, including at work, and free to fork and share. You may not sell it, a fork of it, or paid setup or hosting of it, and you may not use it for fraud or scams. See LICENSE for the full terms.
hooks/register.ts 351 lines1import type { EngineInterface, Register, SessionSendAddress } from 'claude-code'
2
3import type { SentinelSwarmSession } from '../types'
4
5type Answer = Record<string, unknown> | null
6type Owed = {
7 session_id: string
8 text: string
9 wakeup_ids: number[]
10}
11
12const ROLE_TYPE = /^swarm-(oracle|manager|lead|coder|driver)$/
13const LEDGER_TOOL = /^mcp__swarm-ledger__/
14const TOKEN = /^[A-Za-z0-9_-]{32,128}$/
15const REPO_HEADER = 'X-Sentinel-Swarm-Repo'
16const TRANSPORT_KEY = 'sentinel_swarm_transport'
17const FIRED_AT_KEY = 'sentinel_swarm_fired_at'
18const RECORDS = '.sentinel-swarm'
19const SHIM = `${RECORDS}/hook.py`
20const PYTHONS = ['python3', 'python']
21const FAST_MS = 10_000
22const SLOW_MS: Readonly<Record<string, number>> = { stop: 40_000, session_end: 40_000 }
23const SHIM_MS = 50_000
24const SEND_MS = 15_000
25const SEND_RETRY_MS = [2_000, 4_000, 8_000]
26const NOT_YET_LIVE = /no live session/i
27const WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
28const WRITE_TOOL = new RegExp(`^(${WRITE_TOOLS.join('|')})$`)
29const SHELL_TOOL = /^(Bash|PowerShell)$/
30// Must equal SYNC_POST_TOOLS in mcp/src/swarm_ledger/hooks/events.py.
31const SYNC_POST_TOOLS = ['SendMessage', 'PushNotification', 'Monitor', ...WRITE_TOOLS]
32const RECEIVED_FROM = ['peer', 'peer-send-message', 'coordinator']
33const PRE_TOOL_GATES: readonly {
34 tool: string | RegExp
35 event: string
36 role?: string
37 skip?: (e: object) => boolean
38}[] = [
39 { tool: 'Agent', event: 'pre_agent' },
40 { tool: WRITE_TOOL, event: 'pre_write' },
41 { tool: SHELL_TOOL, event: 'pre_shell' },
42 { tool: 'Monitor', event: 'pre_monitor' },
43 // $.session.send runs as a SendMessage call to the target's pipe address, not its session id;
44 // the ledger checked the recipient before the mod sent, so the mod's own text passes.
45 { tool: 'SendMessage', event: 'pre_send_message', skip: isOwnSend },
46 { tool: 'Skill', event: 'pre_skill', role: 'driver' },
47 { tool: LEDGER_TOOL, event: 'pre_ledger' },
48]
49const sessionRef = { plugin: 'sentinel-swarm', key: 'session' } as const
50
51let session: SentinelSwarmSession | null | undefined
52const sending = new Set<number>()
53const paying = new Set<Promise<void>>()
54const sendingTexts = new Map<string, number>()
55
56export const register: Register = on => {
57 on('classic.SessionStart', async ($, e, next) => {
58 const s = await adopt($, e)
59 if (s === null) return next(e)
60 const answer = await runHook($, s, 'session_start', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
61 const result = await next(e)
62 const text = field(answer, 'additionalContext')
63 return text === undefined ? result : { ...result, additionalContext: [...(result.additionalContext ?? []), text] }
64 })
65
66 for (const { tool, event, role, skip } of PRE_TOOL_GATES) {
67 on('tool.call', { tool }, ($, e, next) => (skip?.(e) ? next(e) : judge($, e, next, event, role))).catch(($, e, next) =>
68 next.called ? next(e) : { deny: cannotCheck(next.error) },
69 )
70 }
71
72 on('classic.PostToolUse', async ($, e, next) => {
73 const s = await active($)
74 if (s === null) return next(e)
75 const payload = { ...e, [TRANSPORT_KEY]: 'mod' }
76 if (SYNC_POST_TOOLS.includes(e.tool_name)) {
77 await runHook($, s, 'post_any', payload).catch(() => null)
78 } else {
79 void runHook($, s, 'post_activity', { ...payload, [FIRED_AT_KEY]: new Date().toISOString() }).catch(() => null)
80 }
81 if (s.role === 'coder' && SHELL_TOOL.test(e.tool_name)) {
82 await runHook($, s, 'post_shell', payload).catch(() => null)
83 }
84 if (LEDGER_TOOL.test(e.tool_name)) void pay($, s)
85 return next(e)
86 })
87
88 on('classic.PreCompact', async ($, e, next) => {
89 const s = await active($)
90 if (s !== null) await runHook($, s, 'pre_compact', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
91 return next(e)
92 })
93
94 on('classic.Stop', async ($, e, next) => {
95 const s = await active($)
96 if (s === null) return next(e)
97 await Promise.all(paying)
98 await pay($, s)
99 const answer = await runHook($, s, 'stop', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
100 const result = await next(e)
101 const reason = isRecord(answer) && answer.decision === 'block' ? String(answer.reason ?? '') : undefined
102 return reason ? { ...result, block: reason } : result
103 })
104
105 on('classic.SessionEnd', async ($, e, next) => {
106 const s = await active($)
107 if (s !== null) await runHook($, s, 'session_end', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
108 return next(e)
109 })
110
111 on('session.receive', async ($, e, next) => {
112 const s = await active($)
113 if (s === null || e.agentId !== undefined || !RECEIVED_FROM.includes(e.origin.kind)) return next(e)
114 const taken = await post($, s, 'inbox_take', base(s)).catch(() => null)
115 const claim = isRecord(taken) && typeof taken.claim === 'string' ? taken.claim : null
116 if (claim === null) return next(e)
117 let result: Awaited<ReturnType<typeof next>>
118 try {
119 result = await next({ ...e, text: withInbox(e.text, taken) })
120 } catch (err) {
121 await post($, s, 'inbox_release', { ...base(s), claim }).catch(() => null)
122 throw err
123 }
124 const settle = result.consumed === undefined ? 'inbox_ack' : 'inbox_release'
125 await post($, s, settle, { ...base(s), claim }).catch(() => null)
126 return result
127 }).catch(($, e, next) => next(e))
128}
129
130async function judge<E extends object, R>(
131 $: EngineInterface,
132 e: E,
133 next: (e: E) => Promise<R>,
134 event: string,
135 role?: string,
136): Promise<R | { deny: string }> {
137 const s = await active($)
138 if (s === null || (role !== undefined && s.role !== role)) return next(e)
139 const { tool, tool_use_id, agentId, ...input } = e as Record<string, unknown>
140 const payload = {
141 ...base(s),
142 ...(typeof agentId === 'string' ? { agent_id: agentId, agent_type: undefined } : {}),
143 hook_event_name: 'PreToolUse',
144 tool_name: tool,
145 tool_input: input,
146 tool_use_id,
147 }
148 let answer: Answer
149 try {
150 answer = await runHook($, s, event, payload)
151 } catch (err) {
152 return { deny: cannotCheck(err) }
153 }
154 const reason = field(answer, 'permissionDecisionReason', 'deny')
155 if (reason !== undefined) return { deny: reason }
156 const updated = hookOutput(answer)?.updatedInput
157 if (!isRecord(updated)) return next(e)
158 return next({ tool, tool_use_id, ...updated } as E)
159}
160
161async function adopt(
162 $: EngineInterface,
163 e: { session_id: string; transcript_path: string; cwd: string; agent_type?: string; agent_id?: string },
164): Promise<SentinelSwarmSession | null> {
165 const role = ROLE_TYPE.exec(e.agent_type ?? '')?.[1]
166 const found: SentinelSwarmSession | null =
167 role !== undefined && e.agent_id === undefined
168 ? { sessionId: e.session_id, agentType: e.agent_type ?? '', role, cwd: e.cwd, transcriptPath: e.transcript_path }
169 : null
170 session = found
171 await $.state.set(sessionRef, found).catch(() => undefined)
172 return found
173}
174
175async function active($: EngineInterface): Promise<SentinelSwarmSession | null> {
176 if (session === undefined) {
177 const held = await $.state.get(sessionRef).catch(() => undefined)
178 session = held?.value ?? null
179 }
180 return session
181}
182
183function base(s: SentinelSwarmSession): Record<string, unknown> {
184 return {
185 session_id: s.sessionId,
186 transcript_path: s.transcriptPath,
187 cwd: s.cwd,
188 agent_type: s.agentType,
189 [TRANSPORT_KEY]: 'mod',
190 }
191}
192
193async function runHook($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
194 try {
195 return await post($, s, event, payload)
196 } catch {
197 return viaShim($, s, event, payload)
198 }
199}
200
201async function post($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
202 const [port, token] = await Promise.all([serverPort($, s.cwd), serverToken($, s.cwd)])
203 const answer = await within(
204 $,
205 SLOW_MS[event] ?? FAST_MS,
206 $.http.fetch(`http://127.0.0.1:${port}/hook/${event}`, {
207 method: 'POST',
208 headers: {
209 'Content-Type': 'application/json',
210 Authorization: `Bearer ${token}`,
211 [REPO_HEADER]: encodeURIComponent(s.cwd),
212 },
213 body: JSON.stringify(payload),
214 }),
215 )
216 if (answer.status !== 200 || !(REPO_HEADER.toLowerCase() in answer.headers)) {
217 throw new Error(`the ledger server answered ${answer.status}`)
218 }
219 return parse(answer.text)
220}
221
222async function viaShim($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
223 let reason = 'no Python interpreter ran the shim'
224 for (const python of PYTHONS) {
225 const ran = await $.process
226 .run([python, SHIM, 'hook', event], { cwd: s.cwd, stdin: JSON.stringify(payload), timeoutMs: SHIM_MS })
227 .catch((err: unknown) => {
228 reason = errorText(err)
229 return undefined
230 })
231 if (ran === undefined) continue
232 if (ran.exitCode === 0) return parse(ran.stdout)
233 reason = `${python} ${SHIM} exited with status ${ran.exitCode}`
234 }
235 throw new Error(reason)
236}
237
238async function serverPort($: EngineInterface, cwd: string): Promise<number> {
239 const info: unknown = JSON.parse(await $.fs.read(`${cwd}/${RECORDS}/server.json`))
240 const port = isRecord(info) ? Number(info.port) : NaN
241 if (!Number.isInteger(port) || port <= 0 || port >= 65536) throw new Error('server.json names no port')
242 return port
243}
244
245async function serverToken($: EngineInterface, cwd: string): Promise<string> {
246 const token = (await $.fs.read(`${cwd}/${RECORDS}/http-token`)).trim()
247 if (!TOKEN.test(token)) throw new Error('the ledger token is missing or malformed')
248 return token
249}
250
251function within<T>($: EngineInterface, ms: number, work: Promise<T>): Promise<T> {
252 let timer: { cancel: () => void } | undefined
253 const late = new Promise<never>((_, reject) => {
254 timer = $.clock.after(ms, () => reject(new Error(`no answer within ${ms / 1000} s`)))
255 })
256 return Promise.race([work, late]).finally(() => timer?.cancel())
257}
258
259function pay($: EngineInterface, s: SentinelSwarmSession): Promise<void> {
260 const work = payOwed($, s).catch(() => undefined)
261 paying.add(work)
262 void work.finally(() => paying.delete(work))
263 return work
264}
265
266async function payOwed($: EngineInterface, s: SentinelSwarmSession): Promise<void> {
267 const answer = await post($, s, 'owed', base(s)).catch(() => null)
268 const listed = isRecord(answer) && Array.isArray(answer.wakeups) ? (answer.wakeups as Owed[]) : []
269 const owed = listed
270 .map(wakeup => ({ ...wakeup, wakeup_ids: wakeup.wakeup_ids.filter(id => !sending.has(id)) }))
271 .filter(wakeup => wakeup.wakeup_ids.length > 0)
272 if (owed.length === 0) return
273 const ids = owed.flatMap(wakeup => wakeup.wakeup_ids)
274 ids.forEach(id => sending.add(id))
275 try {
276 const delivered = (await Promise.all(owed.map(wakeup => deliver($, wakeup)))).flat()
277 if (delivered.length > 0) await post($, s, 'wake_sent', { ...base(s), wakeup_ids: delivered }).catch(() => null)
278 } finally {
279 ids.forEach(id => sending.delete(id))
280 }
281}
282
283function isOwnSend(e: { message?: unknown }): boolean {
284 return typeof e.message === 'string' && sendingTexts.has(e.message)
285}
286
287async function deliver($: EngineInterface, wakeup: Owed): Promise<number[]> {
288 sendingTexts.set(wakeup.text, (sendingTexts.get(wakeup.text) ?? 0) + 1)
289 try {
290 return await sendWithRetry($, wakeup)
291 } finally {
292 const left = (sendingTexts.get(wakeup.text) ?? 1) - 1
293 if (left > 0) sendingTexts.set(wakeup.text, left)
294 else sendingTexts.delete(wakeup.text)
295 }
296}
297
298async function sendWithRetry($: EngineInterface, wakeup: Owed): Promise<number[]> {
299 const to: SessionSendAddress = { sessionId: wakeup.session_id }
300 for (const pause of [0, ...SEND_RETRY_MS]) {
301 if (pause > 0) await $.clock.sleep(pause)
302 const sent = await within($, SEND_MS, $.session.send({ to, text: wakeup.text })).catch(() => undefined)
303 if (sent?.isDelivered) return wakeup.wakeup_ids
304 // A session registers 10 to 14 s after it starts; a send before that finds no live session.
305 if (sent === undefined || !NOT_YET_LIVE.test(sent.reason)) return []
306 }
307 return []
308}
309
310function withInbox(text: string, taken: Answer): string {
311 const messages = isRecord(taken) && Array.isArray(taken.messages) ? taken.messages : []
312 const remaining = isRecord(taken) && typeof taken.remaining === 'number' ? taken.remaining : 0
313 const rest = remaining > 0 ? ` ${remaining} more wait: call message_inbox for them.` : ''
314 return (
315 `${text}\n\nsentinel-swarm read your inbox with this wake-up and marked these messages read; ` +
316 `do not call message_inbox for them.${rest}\nmessage_inbox() returned: ${JSON.stringify({ messages, remaining })}`
317 )
318}
319
320function parse(text: string): Answer {
321 if (text.trim() === '') return null
322 const value: unknown = JSON.parse(text)
323 return isRecord(value) ? value : null
324}
325
326function hookOutput(answer: Answer): Record<string, unknown> | undefined {
327 const output = answer?.hookSpecificOutput
328 return isRecord(output) ? output : undefined
329}
330
331function field(answer: Answer, name: string, decision?: string): string | undefined {
332 const output = hookOutput(answer)
333 if (output === undefined || (decision !== undefined && output.permissionDecision !== decision)) return undefined
334 const value = output[name]
335 return typeof value === 'string' ? value : decision !== undefined ? 'denied by sentinel-swarm' : undefined
336}
337
338function cannotCheck(err: unknown): string {
339 return `sentinel-swarm cannot check this call: ${errorText(err)}; run /sentinel-swarm:setup`
340}
341
342function errorText(err: unknown): string {
343 if (err instanceof Error) return err.message
344 if (isRecord(err) && typeof err.message === 'string') return err.message
345 return String(err)
346}
347
348function isRecord(value: unknown): value is Record<string, unknown> {
349 return typeof value === 'object' && value !== null && !Array.isArray(value)
350}
351types/index.d.ts 14 lines1export type SentinelSwarmSession = {
2 sessionId: string
3 agentType: string
4 role: string
5 cwd: string
6 transcriptPath: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'sentinel-swarm': { session: SentinelSwarmSession | null }
12 }
13}
14