Portable Agent Skills, Agent Plugins, and vendor adapters for Infiquetra workflows

Portable Agent Skills, Agent Plugins, and vendor adapters for Infiquetra engineering workflows.
This public repository is the design and source catalog for behavior that does not belong to one coding-agent vendor. It complements, and is intended to eventually generate parts of, the existing Claude Code, Codex, Antigravity, OpenCode, and Hermes plugin repositories. Those repositories remain the runtime sources of truth until a recorded custody decision moves that authority here, and no such decision has been made.
Custody moved here on 2026-09-22. This repository is the source of truth for every Infiquetra plugin. The thirteen live plugins of infiquetra-claude-plugins (commit acc99fe7; the fourteenth, team-execution, had already been archived upstream) were imported once, package by package, and are authored here from that commit: there is no upstream pin and no provenance manifest any more. The decision, its rationale, and the four rules it changed are the 2026-09-22 entry in DECISIONS.md; the run plan is docs/plans/2026-09-22-custody-move-and-claude-plugins-retirement-plan.md.
What every package looks like now:
plugin.json, skills/, scripts/, references/, tests/). A package whose upstream shipped only Claude commands now also carries a portable skill over its scripts, so the four skill-scoped harnesses (OpenCode, Gemini CLI, Muse, Hermes) can reach them.com.infiquetra.claude/ (commands, agents, hooks, MCP registration, output styles, Claude Code mods: TypeScript hooks modules under mods/ and a types/index.d.ts state contract). The root .claude-plugin/plugin.json and the generated root marketplace carry paths and identity only (scripts/sync_marketplace.py, checked by check_repo.py).plugins/<pkg>/.codex-plugin/plugin.json and the root .agents/plugins/marketplace.json, both generated (scripts/sync_codex_packaging.py), for the same reason and under the same paths-only rule._bundled/ directory (scripts/bundle_fleet_module.py); it is not installed as a plugin by any harness. The upstream discovery shim (fleet_commons_shim.py) does not cross the boundary.plugins/<pkg>/tests/ and run in CI with pytest's importlib import mode; repository-level tests stay standard-library-only.How a harness on a machine installs the catalog is docs/runbooks/install-clients.md (scripts/install_client.py, with --check readback and a legacy uninstall for placements that recorded the old repository).
Compatibility evidence binds to a released package version rather than to every tree (2026-09-22 decision). The ten-client matrices under docs/evidence/ that predate the import are superseded and kept as historical context; fresh assessments for the authored versions are queued work, not a claim this README makes.
The record of the work, in the order a new reader should take it:
docs/engineering-journal/narratives/ (2026-09-22-import-<package>.md).| Package | Version | Description | Installable surfaces |
|---|---|---|---|
plugins/agent-launcher/ | 1.7.1 | Create one verified coding-agent session through the agents wrapper and Herdr | Claude, Codex, 1 skill |
plugins/agy/ | 0.6.2 | Antigravity-backed coder reviewer bridge agents that run in a disposable clone and… | Claude, Codex, 1 skill |
plugins/codex/ | 0.1.5 | Portable Codex delegation wrapper | Claude, Codex, 1 skill |
plugins/deploy/ | 0.2.3 | Tag-promotion deployment operations for Infiquetra repositories | Claude, Codex, 1 skill |
plugins/fleet-core/ | 0.32.0 | Authored Fleet Core library: staffing (work shape, role, and lens to model and… | Codex |
plugins/hermes-profile-evolution/ | 0.1.5 | Portable request adapter for target-sovereign Hermes profile evolution | Claude, Codex, 1 skill |
plugins/home-lab-ops/ | 1.2.2 | Proxmox VE cluster operations, Ansible pre-flight validation, Ceph management,… | Claude, Codex, 6 skills |
plugins/house-style/ | 0.1.1 | Claude-only package | Claude, Codex |
plugins/mission-control/ | 2.21.1 | SDLC management for Operations, Asgard, and CAMPPS: prepared issue drafts, live schema… | Claude, Codex, 8 skills |
plugins/orchestrate/ | 6.0.1 | Run one piece of work across several herdr agent sessions, one git worktree per unit | Claude, Codex, 1 skill |
plugins/redis-channel/ | 0.5.3 | Portable Redis Streams bridge | Claude, Codex, 1 skill |
plugins/saga/ | 1.2.1 | Infiquetra lifecycle plugin: one automatic run per issue — admission, plan, plan… | Claude, Codex, 14 skills |
plugins/unifi/ | 2.0.7 | Portable UniFi Network and Protect package: two Agent Skills with their bundled Python… | Claude, Codex, 2 skills |
plugins/voice/ | 0.4.0 | Portable voice package: a spoken conversational loop for one explicitly bound,… | Claude, Codex, 1 skill |
The full Fleet Core module set at the import commit is here (plugins/fleet-core/scripts/fleet_commons/, plus scripts/jev.py and references/). What is deliberately absent is stated in plugins/fleet-core/DEFERRED.md: the Claude-specific discovery shim, and one residual data file kept only while a consumer still declares it.
The portable UniFi package can read an optional operator site profile — a JSON file describing one site's topology and intent — from a machine-local path. The profile is optional in the strong sense: a runtime with no profile anywhere loads successfully in discovery-only mode and infers no trust role, criticality, or ownership rather than guessing a default.
How a profile is authored and deployed is the operator's business. The Infiquetra instance keeps its profile in a private repository and renders JSON at deployment time through an existing Ansible harness. That arrangement is one operator's custody instance and is not required by the portable contract, which knows only a path. See plugins/unifi/references/site-profile.md for the contract itself.
| Path | Purpose |
|---|---|
plugins/ | Portable packages, authored here since the 2026-09-22 custody move |
ports/ | One port descriptor per package: identity, custody, and assessment settings |
schemas/ | JSON Schemas for the contracts this repository validates |
scripts/ | Validation, synchronization, bundling, and inventory tools |
docs/runbooks/install-clients.md | Place this catalog on each harness, read the placement back, and remove the old marketplace |
docs/ | Architecture, public guidance, and durable repository knowledge |
docs/plans/ | Approved implementation plans |
docs/evidence/ | Assessment records, written under the public evidence rules |
docs/engineering-journal/ | Learnings, decisions, queued work, and archive |
.github/ | Pull request, issue, and validation workflow configuration |
Client-specific material lives in an explicit adapter directory inside the package that needs it, never at the package root. plugins/unifi/com.infiquetra.claude/ and plugins/mission-control/com.infiquetra.claude/ are Claude adapters; the portable manifests, skills, schemas, and scripts beside them carry no client-specific assumption.
Run the same checks used by continuous integration (CI):
python3 scripts/check_repo.py
python3 -m unittest discover -s tests -v
git diff --check
The validation script checks the repository baseline, local Markdown links, the Agent Plugin manifests under plugins/, each package's provenance manifest, the build declarations and generated bundle stamps, the portable skills' frontmatter, and that no TypeScript or JavaScript module source (nor the tsconfig.json Claude Code writes at a package root) sits outside a Claude adapter. It installs nothing and makes no network call, so this baseline cannot be broken by a package index outage. A second continuous integration job pins the catalog's declared floor, python>=3.12, installs requests, urllib3, and pytest, and runs the ported plugin tests. The pin is the floor itself rather than the newest interpreter, because a floor that is never exercised is not a floor. The floor is a single value with a single owner, tests/test_python_floor.py, and every place the catalog states it is checked against that owner.
A third continuous integration job, claude-mods, installs Claude Code at the recorded floor build (2.1.286) and at the pinned build (2.1.289) and, for every package whose Claude adapter names a hooks module, runs claude plugin validate --strict and claude plugin test. Neither command needs a login.
AGENTS.md before changing the repository.Public development guidance is summarized in docs/public-safe-summary.md.
mods/index.ts 29 lines1// Orchestrate's one hooks module, named from `../hooks/hooks.json` under `modules`.
2//
3// The engine loads exactly one module per plugin, so every orchestrate mod
4// registers from here: each mod lives in its own file beside this one and
5// exports a function this `register` calls. Orchestrate's mods read state
6// through orchestrate.py's own read-only commands (`orchestrate-script.ts`),
7// never through saga's files: each plugin's scripts own that plugin's state, and
8// a plugin's module can import only files inside its own folder. No mod
9// launches, lands or closes anything; those stay with the script.
10
11//
12// The engine allows one `session.start` hook per plugin without a matcher, and
13// refuses `$` passed into a function imported from another file, so the one
14// start hook lives here and registers what each mod declares as plain data.
15
16import type { Register } from 'claude-code'
17import { FLEET_COMMAND_SPEC, registerFleetView } from './fleet-view.tsx'
18import { APPROVAL_TOOL_SPEC, registerLaunchApproval } from './launch-approval.tsx'
19
20export const register: Register = (on) => {
21 on('session.start', async ($, e, next) => {
22 await $.command.register(FLEET_COMMAND_SPEC)
23 await $.tool.register(APPROVAL_TOOL_SPEC)
24 return next(e)
25 })
26 registerFleetView(on)
27 registerLaunchApproval(on)
28}
29mods/fleet-view.tsx 186 lines1// The fleet pane: `/fleet-view <issue>` shows every unit of one orchestrate run.
2//
3// It reads `orchestrate.py status --issue <N> --json` when opened, every
4// fifteen seconds while it stays open (a timer the command starts and closing
5// the pane stops), and when the operator presses Refresh. The command itself is
6// registered at session start by `index.ts`, which holds the plugin's one
7// `session.start` hook.
8// It holds no launch, settle, merge or clean action: the only script it runs is
9// `status`, through `orchestrate-script.ts`, which refuses anything else.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, On, Timer } from 'claude-code'
13import type { OrchestrateStatus, OrchestrateStatusUnit } from '../types/index.d.ts'
14import { readJsonWith, statusArgs } from './orchestrate-script.ts'
15
16export const FLEET_PANE = 'orchestrate-fleet'
17export const FLEET_COMMAND = 'fleet-view'
18export const REFRESH_MS = 15_000
19const STATUS_SCHEMA = 'orchestrate.status.v1'
20/** `status` asks git and herdr; a slow herdr costs its own timeout once, so allow for it. */
21const STATUS_TIMEOUT_MS = 60_000
22
23const fleetIssue = atom({ plugin: 'orchestrate', key: 'fleetIssue' } as const, null)
24const fleet = atom({ plugin: 'orchestrate', key: 'fleet' } as const, null)
25const fleetRefreshedAt = atom({ plugin: 'orchestrate', key: 'fleetRefreshedAt' } as const, null)
26const fleetBusy = atom({ plugin: 'orchestrate', key: 'fleetBusy' } as const, false)
27
28/** `7` or `#7` to 7; anything else to null. */
29export function parseIssue(args: string): number | null {
30 const found = /^#?(\d+)$/.exec(args.trim())
31 if (found === null) return null
32 const issue = Number(found[1])
33 return Number.isSafeInteger(issue) && issue > 0 ? issue : null
34}
35
36const COLUMNS = ['unit', 'vendor', 'model', 'effort', 'state', 'herdr', 'branch', 'commits', 'landed', 'waits on'] as const
37
38function cells(unit: OrchestrateStatusUnit): string[] {
39 return [
40 unit.name,
41 unit.vendor,
42 unit.model ?? '-',
43 unit.effort ?? '-',
44 unit.state,
45 unit.herdr ?? '-',
46 unit.branch ?? '-',
47 unit.commits === null ? (unit.landed === null ? '-' : '?') : String(unit.commits),
48 unit.landed ?? '-',
49 unit.waits_on || '-',
50 ]
51}
52
53/** The unit table as fixed-width lines, each cut to the pane's width. */
54export function fleetLines(status: OrchestrateStatus, width: number): string[] {
55 const rows = status.units.map(cells)
56 const widths = COLUMNS.map((head, index) => Math.max(head.length, ...rows.map((row) => row[index].length)))
57 const line = (values: readonly string[]) =>
58 values.map((value, index) => value.padEnd(widths[index])).join(' ').trimEnd()
59 const fit = (text: string) => (text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text)
60 return [line(COLUMNS), '-'.repeat(widths.reduce((sum, w) => sum + w, 0) + widths.length - 1), ...rows.map(line)].map(fit)
61}
62
63async function refresh($: EngineInterface, issue: number): Promise<void> {
64 if (await read($, fleetBusy)) return
65 await update($, fleetBusy, () => true)
66 try {
67 const value = await readJsonWith<OrchestrateStatus>(
68 (argv) => $.process.run(argv, { timeoutMs: STATUS_TIMEOUT_MS }),
69 $.plugin.root,
70 () => statusArgs(issue),
71 STATUS_SCHEMA,
72 )
73 // The pane may have closed, or moved to another issue, while the script ran.
74 if ((await read($, fleetIssue)) !== issue) return
75 await update($, fleet, () => value)
76 const now = await $.clock.now()
77 await update($, fleetRefreshedAt, () => now)
78 } finally {
79 await update($, fleetBusy, () => false)
80 }
81}
82
83async function tick($: EngineInterface): Promise<void> {
84 const issue = await read($, fleetIssue)
85 if (issue === null) return
86 const isOpen = (await $.ui.panes()).some((pane) => pane.id === FLEET_PANE)
87 if (!isOpen) return
88 await refresh($, issue)
89}
90
91/** What `index.ts` registers at session start, so the command is listed from turn one. */
92export const FLEET_COMMAND_SPEC = {
93 name: FLEET_COMMAND,
94 description: 'Show an orchestrate run in a pane (read-only; refreshes every 15 seconds while open)',
95 argumentHint: '<issue>',
96}
97
98/**
99 * The refresh timer while the pane is open. A handle, not drawn state: a module
100 * reload drops the engine's timers and this variable together, and the next
101 * `/fleet-view` starts a new one.
102 */
103let timer: Timer | null = null
104
105export function registerFleetView(on: On): void {
106 on('command.run', { command: FLEET_COMMAND }, async ($, e) => {
107 const issue = parseIssue(e.args)
108 if (issue === null) return { text: 'Usage: /fleet-view <issue>' }
109 await update($, fleetIssue, () => issue)
110 await update($, fleet, () => null)
111 await refresh($, issue)
112 await $.ui.open({ id: FLEET_PANE, title: `orchestrate #${issue}` })
113 timer ??= $.clock.every(REFRESH_MS, () => {
114 void tick($)
115 })
116 return { text: `Showing orchestrate run for #${issue}; it refreshes every ${REFRESH_MS / 1000} seconds.` }
117 })
118
119 on('ui.close', { id: FLEET_PANE }, async ($, e, next) => {
120 timer?.cancel()
121 timer = null
122 await update($, fleetIssue, () => null)
123 await update($, fleet, () => null)
124 return next(e)
125 })
126
127 on('ui.render', { component: 'Pane', requestId: FLEET_PANE }, async ($, e) => {
128 const { Box, Button, Text } = $.ui.resolve(e)
129 const issue = await read($, fleetIssue)
130 const reading = await read($, fleet)
131 const refreshedAt = await read($, fleetRefreshedAt)
132 const width = Math.max(20, e.props.bodyColumns)
133 const header: string[] = []
134 let body: string[] = []
135 let error: string | null = null
136 if (reading === null) {
137 header.push(issue === null ? 'No run is shown. /fleet-view <issue> opens one.' : `Reading run #${issue}...`)
138 } else if (!reading.ok) {
139 error = reading.error
140 } else {
141 const status = reading.value
142 header.push(`run ${status.run_id} branch ${status.branch || '-'} #${status.issue}`)
143 if (status.unresolvable_branch) header.push(`run branch ${status.unresolvable_branch} does not resolve`)
144 body = fleetLines(status, width)
145 body.push(...status.unrecorded.map((found) => `UNRECORDED ${found.name} -- branch ${found.branch}`))
146 body.push(
147 ...status.operator_actions.map(
148 (action) => `OPERATOR ACTION: ${action.owner} owns fix ${action.fix_id} for ${action.touched_paths.join(', ')}`,
149 ),
150 )
151 }
152 const refreshed = refreshedAt === null ? '' : `refreshed at ${new Date(refreshedAt).toISOString().slice(11, 19)} UTC`
153 return (
154 <Box flexDirection="column">
155 {header.map((line, index) => (
156 <Text key={`header-${index}`} bold={index === 0}>
157 {line}
158 </Text>
159 ))}
160 {error === null ? null : (
161 <Text key="error" color="red">
162 {error}
163 </Text>
164 )}
165 {body.map((line, index) => (
166 <Text key={`row-${index}`}>{line}</Text>
167 ))}
168 <Text key="refreshed" dimColor>
169 {refreshed}
170 </Text>
171 <Box flexDirection="row">
172 <Button
173 key="refresh"
174 label="Refresh"
175 onPress={async () => {
176 const current = await read($, fleetIssue)
177 if (current !== null) await refresh($, current)
178 }}
179 />
180 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: FLEET_PANE })} />
181 </Box>
182 </Box>
183 )
184 })
185}
186mods/launch-approval.tsx 136 lines1// The launch-approval pane: the operator's answer to orchestrate's launch table.
2//
3// The model calls `mcp__orchestrate__review_launch_table` with the plan file it
4// wrote. The mod runs `orchestrate.py launch-table --plan <file> [--issue <N>]
5// --json`, shows the script's table in a pane exactly as printed, asks Approve,
6// Change or Cancel in the engine's own question dialog, and returns that answer
7// as the tool's result. It never launches anything: the only script it runs is
8// `launch-table`, which creates nothing, and the model still runs `start` or
9// `expand` itself once the answer is `approved`. Nothing launches until the
10// operator approves the table.
11//
12// Why a dialog and not pane buttons: a hook's own time is capped (ten seconds),
13// and only a wait inside a `$` call is free, so a tool call cannot wait on a
14// press. `$.ui.ask` is that free wait. It returns only the label, though, and
15// the engine's dialog resolves itself when the operator is away; the
16// AskUserQuestion hook below turns such a resolution into a refusal, so an
17// unattended dialog can never read as approval.
18
19import { atom, read, update } from 'claude-code'
20import type { On } from 'claude-code'
21import type { OrchestrateDecision, OrchestrateLaunchTable } from '../types/index.d.ts'
22import { launchTableArgs, readJsonWith } from './orchestrate-script.ts'
23
24export const APPROVAL_PANE = 'orchestrate-launch'
25export const APPROVAL_TOOL = 'review_launch_table'
26export const APPROVAL_QUESTION = 'Approve this launch table?'
27/** Approve is deliberately not first, so a reflexive Enter does not approve. */
28export const APPROVAL_OPTIONS = ['Change', 'Cancel', 'Approve'] as const
29const LAUNCH_TABLE_SCHEMA = 'orchestrate.launch_table.v1'
30const LAUNCH_TABLE_TIMEOUT_MS = 60_000
31export const UNSEEN_TABLE = 'the launch table could not be shown, so nothing was asked or approved'
32export const AFK_REFUSAL = 'the dialog resolved itself while the operator was away; nothing was approved'
33
34const launchTable = atom({ plugin: 'orchestrate', key: 'launchTable' } as const, null)
35
36/** Map the dialog's answer to the decision the model receives. Only the exact label approves. */
37export function decisionFor(answer: string, table: Pick<OrchestrateLaunchTable, 'plan' | 'plan_sha256'>): OrchestrateDecision {
38 if (answer === 'Approve') return { decision: 'approved', plan: table.plan, plan_sha256: table.plan_sha256 }
39 if (answer === 'Cancel') return { decision: 'cancelled' }
40 if (answer === 'Change') return { decision: 'change', request: null }
41 return { decision: 'change', request: answer }
42}
43
44type ApprovalInput = { plan?: unknown; issue?: unknown }
45
46/** What `index.ts` registers at session start; the model sees it as `mcp__orchestrate__review_launch_table`. */
47export const APPROVAL_TOOL_SPEC = {
48 name: APPROVAL_TOOL,
49 description:
50 "Show orchestrate's launch table for a plan file to the operator and return their decision " +
51 '(approved, change with their request, cancelled, dismissed, or refused when the plan does not validate). ' +
52 'It runs `orchestrate.py launch-table` and launches nothing: on `approved`, run `start` (or `expand` ' +
53 'with `issue`) on that same plan file yourself. Pass `issue` when the plan expands a run in flight.',
54 inputSchema: {
55 type: 'object',
56 properties: {
57 plan: { type: 'string', description: 'path of the plan JSON file' },
58 issue: { type: 'integer', description: 'the run to expand, for a Phase 5 table' },
59 },
60 required: ['plan'],
61 },
62}
63
64export function registerLaunchApproval(on: On): void {
65 // The literal name, so `claude plugin validate` can read the matcher.
66 on('tool.call', { tool: 'mcp__orchestrate__review_launch_table' }, async ($, e) => {
67 const input = e as unknown as ApprovalInput
68 const plan = typeof input.plan === 'string' ? input.plan : ''
69 const issue = typeof input.issue === 'number' ? input.issue : undefined
70 const table = await readJsonWith<OrchestrateLaunchTable>(
71 (argv) => $.process.run(argv, { timeoutMs: LAUNCH_TABLE_TIMEOUT_MS }),
72 $.plugin.root,
73 () => launchTableArgs(plan, issue),
74 LAUNCH_TABLE_SCHEMA,
75 )
76 if (!table.ok) {
77 const refused: OrchestrateDecision = { decision: 'refused', reason: table.error }
78 return { result: refused }
79 }
80 await update($, launchTable, () => table.value.text)
81 const opened = await $.ui.open({ id: APPROVAL_PANE, title: `Launch table: ${table.value.run_id}` })
82 if (!opened.isPlaced) {
83 // A tool call opens the pane unasked, and an unasked pane waits undrawn
84 // below 144 columns or where no surface places panes. Never ask the
85 // operator to approve a table they cannot see: hand the text back instead.
86 await $.ui.close({ id: APPROVAL_PANE })
87 await update($, launchTable, () => null)
88 const unseen: OrchestrateDecision = {
89 decision: 'dismissed',
90 reason: `${UNSEEN_TABLE} (${opened.reason}); print \`text\` verbatim and ask the operator in plain text`,
91 text: table.value.text,
92 }
93 return { result: unseen }
94 }
95 let decision: OrchestrateDecision
96 try {
97 const answer = await $.ui.ask(APPROVAL_QUESTION, { options: APPROVAL_OPTIONS, header: 'Launch' })
98 decision = decisionFor(answer, table.value)
99 } catch (err) {
100 const reason = err instanceof Error ? err.message : String(err)
101 decision = { decision: 'dismissed', reason }
102 }
103 await $.ui.close({ id: APPROVAL_PANE })
104 await update($, launchTable, () => null)
105 return { result: decision }
106 })
107
108 // The engine's dialog resolves itself when the operator is away and says so
109 // only in `afkTimeoutMs`, which `$.ui.ask` does not pass on. Refuse such a
110 // resolution of this one question, so it reaches the mod as a dismissal.
111 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
112 const questions = (e as unknown as { questions?: { question?: unknown }[] }).questions ?? []
113 if (!questions.some((q) => q.question === APPROVAL_QUESTION)) return next(e)
114 const ran = await next(e)
115 const result = (ran as { result?: { afkTimeoutMs?: unknown } }).result
116 if (result !== undefined && result !== null && result.afkTimeoutMs !== undefined) return { deny: AFK_REFUSAL }
117 return ran
118 })
119
120 on('ui.render', { component: 'Pane', requestId: APPROVAL_PANE }, async ($, e) => {
121 const { Box, Text } = $.ui.resolve(e)
122 const text = await read($, launchTable)
123 const lines = text === null ? ['No launch table is waiting.'] : text.replace(/\n$/, '').split('\n')
124 // Line for line, never re-wrapped: the script's columns are the format the operator approves.
125 return (
126 <Box flexDirection="column">
127 {lines.map((line, index) => (
128 <Text key={`line-${index}`}>
129 {line === '' ? ' ' : line}
130 </Text>
131 ))}
132 </Box>
133 )
134 })
135}
136types/index.d.ts 136 lines1// Orchestrate's state contract for Claude Code mods.
2//
3// Orchestrate's script owns every run; a mod only reads it, by running
4// `skills/orchestrate/scripts/orchestrate.py status --issue <N> --json` or
5// `launch-table --plan <file> [--issue <N>] --json` and parsing the JSON that
6// prints. These types describe that JSON. The authority is orchestrate.py
7// (`status_snapshot`, `STATUS_SCHEMA`, `cmd_launch_table`,
8// `LAUNCH_TABLE_SCHEMA`); when it changes a shape, it changes the schema name,
9// and this contract and the mods change with it.
10//
11// Self-contained on purpose: no import and no reference, as the engine requires
12// of a plugin's `types` file. Every exported name is led by `Orchestrate`.
13
14/** The one `status --json` version this contract reads. */
15export type OrchestrateStatusSchema = 'orchestrate.status.v1'
16
17/** The one `launch-table --json` version this contract reads. */
18export type OrchestrateLaunchTableSchema = 'orchestrate.launch_table.v1'
19
20/** One unit of a run, as `status --json` prints it. */
21export type OrchestrateStatusUnit = {
22 name: string
23 vendor: string
24 model: string | null
25 effort: string | null
26 /** The unit's recorded state: pending, running, done, orphaned, parked, ... */
27 state: string
28 /** Herdr's live reading for a running unit, `unknown` without the companion, else null. */
29 herdr: string | null
30 branch: string | null
31 /** Commits on the unit's branch; null where git could not count or there is no branch. */
32 commits: number | null
33 /** `yes`, `no`, `unknown` or `missing`; null when the unit has no branch. */
34 landed: string | null
35 /** What holds a pending unit (`needs output from x`, `serialized behind y`); empty otherwise. */
36 waits_on: string
37 task: string
38 note: string
39 role: string | null
40 lifecycle: string | null
41 merge_state: string
42 after: string[]
43 serialize: string[]
44}
45
46/** One Code Review controller's recorded result. */
47export type OrchestrateStatusReview = {
48 controller: string | null
49 lifecycle: string | null
50 outcome: string | null
51 state: string
52 recorded_unrouted: boolean
53 note_contradicts: string | null
54}
55
56/** One run, as `status --json` prints it. */
57export type OrchestrateStatus = {
58 schema: OrchestrateStatusSchema
59 issue: number
60 run_id: string
61 source: string
62 base: string
63 branch: string
64 unresolvable_branch: string | null
65 companion_available: boolean
66 units: OrchestrateStatusUnit[]
67 unrecorded: { name: string; branch: string }[]
68 reviews: OrchestrateStatusReview[]
69 operator_actions: { owner: string; fix_id: string; touched_paths: string[] }[]
70}
71
72/** One row of the launch table, as `launch-table --json` prints it. */
73export type OrchestrateLaunchUnit = {
74 name: string
75 cap: string | null
76 vendor: string
77 model: string | null
78 effort: string | null
79 effort_via_setup: boolean
80 permission: string
81 permission_declared: boolean
82 after: string[]
83 serialize: string[]
84 role: string | null
85 task: string
86}
87
88/** The table the operator approves, as `launch-table --json` prints it. */
89export type OrchestrateLaunchTable = {
90 schema: OrchestrateLaunchTableSchema
91 issue: number | null
92 run_id: string
93 source: string
94 plan: string
95 plan_sha256: string
96 vendors_allowed: string[]
97 workspace: string | null
98 account: string | null
99 units: OrchestrateLaunchUnit[]
100 later_phases: { phase: string; what: string; cap: string | null; after: string[] }[]
101 /** The fixed-format table exactly as the script prints it without `--json`. */
102 text: string
103}
104
105/** The outcome of one script read. A mod shows `error` rather than guessing. */
106export type OrchestrateRead<T> = { ok: true; value: T } | { ok: false; error: string }
107
108/**
109 * What `mcp__orchestrate__review_launch_table` returns to the model: the
110 * operator's answer, never an action. `approved` carries the digest of the plan
111 * file the operator saw, so the model starts exactly that file.
112 */
113export type OrchestrateDecision =
114 | { decision: 'approved'; plan: string; plan_sha256: string }
115 | { decision: 'change'; request: string | null }
116 | { decision: 'cancelled' }
117 | { decision: 'dismissed'; reason: string; /** The script's table, when the pane could not draw it. */ text?: string }
118 | { decision: 'refused'; reason: string }
119
120declare module 'claude-code' {
121 interface PluginState {
122 orchestrate: {
123 /** The issue the fleet pane shows; null while it is closed. */
124 fleetIssue: number | null
125 /** The last `status --json` read for that issue. */
126 fleet: OrchestrateRead<OrchestrateStatus> | null
127 /** When that read finished, in the engine clock's milliseconds. */
128 fleetRefreshedAt: number | null
129 /** Whether a read is in flight, so the timer never stacks reads. */
130 fleetBusy: boolean
131 /** The launch-table text the approval pane shows while the operator decides. */
132 launchTable: string | null
133 }
134 }
135}
136mods/orchestrate-script.ts 105 lines1// The one way an orchestrate mod reads orchestrate state.
2//
3// Orchestrate's script owns every run. A mod never opens the run record and
4// never parses prose: it runs `python3 <plugin root>/skills/orchestrate/
5// scripts/orchestrate.py <read-only subcommand> ... --json` and parses the JSON
6// that prints. Only two subcommands are allowed here, `status` and
7// `launch-table`, both read-only; `orchestrateArgv` refuses every other one, so
8// no mod can reach `start`, `go`, `expand`, `merge`, `settle` or `clean`
9// through this module.
10//
11// The engine's validator follows `$` only into functions declared in the same
12// file as the hook, never across an import, so nothing here takes `$`. A mod's
13// hook passes a closure over `$.process.run` instead:
14//
15// const read = await readJsonWith<OrchestrateStatus>(
16// (argv) => $.process.run(argv, { timeoutMs: 60_000 }), $.plugin.root, statusArgs(issue), 'orchestrate.status.v1')
17
18import type { ProcessRunResult } from 'claude-code'
19import type { OrchestrateRead } from '../types/index.d.ts'
20
21/** What `$.process.run` resolves to, narrowed to the fields the parser reads. */
22export type ProcessResult = Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr' | 'isStdoutTruncated'>
23
24/** Runs one argv and resolves to its result; a mod passes a closure over `$.process.run`. */
25export type ProcessRunner = (argv: string[]) => Promise<ProcessResult>
26
27/** The read-only subcommands a mod may run. Nothing that launches, lands or closes is here. */
28export const READ_ONLY_SUBCOMMANDS = ['status', 'launch-table'] as const
29
30/** Where the driver sits under the plugin root (`$.plugin.root`, the folder of `.claude-plugin/`). */
31export const SCRIPT_PATH = 'skills/orchestrate/scripts/orchestrate.py'
32
33/** The argv that runs one read-only subcommand of orchestrate.py; anything else throws. */
34export function orchestrateArgv(pluginRoot: string, args: readonly string[]): string[] {
35 const subcommand = args[0]
36 if (!(READ_ONLY_SUBCOMMANDS as readonly string[]).includes(subcommand ?? '')) {
37 throw new RangeError(`not a read-only orchestrate subcommand: ${subcommand}`)
38 }
39 return ['python3', `${pluginRoot}/${SCRIPT_PATH}`, ...args]
40}
41
42/** `status --issue <N> --json`, for a positive whole issue number. */
43export function statusArgs(issue: number): string[] {
44 if (!Number.isInteger(issue) || issue <= 0) throw new RangeError(`not an issue number: ${issue}`)
45 return ['status', '--issue', String(issue), '--json']
46}
47
48/** `launch-table --plan <file> [--issue <N>] --json`. */
49export function launchTableArgs(plan: string, issue?: number): string[] {
50 if (plan.trim() === '') throw new RangeError('no plan file named')
51 const args = ['launch-table', '--plan', plan]
52 if (issue !== undefined) {
53 if (!Number.isInteger(issue) || issue <= 0) throw new RangeError(`not an issue number: ${issue}`)
54 args.push('--issue', String(issue))
55 }
56 args.push('--json')
57 return args
58}
59
60/** The script's refusal: the last line it wrote to stderr (notices come first), or the exit. */
61function refusal(ran: ProcessResult): string {
62 const lines = ran.stderr.split('\n').map((line) => line.trim()).filter(Boolean)
63 return lines.at(-1) ?? `orchestrate.py exited ${ran.exitCode} without a message`
64}
65
66/** Turn what one `--json` run did into its value, or the reason there is none. */
67export function parseJsonRun<T>(ran: ProcessResult, schema: string): OrchestrateRead<T> {
68 if (ran.exitCode !== 0) return { ok: false, error: refusal(ran) }
69 if (ran.isStdoutTruncated) {
70 return { ok: false, error: 'orchestrate.py printed more than the engine keeps (4 MiB); the JSON was cut off' }
71 }
72 let parsed: unknown
73 try {
74 parsed = JSON.parse(ran.stdout)
75 } catch {
76 return { ok: false, error: 'orchestrate.py printed no JSON' }
77 }
78 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
79 return { ok: false, error: 'orchestrate.py printed no JSON object' }
80 }
81 const found = (parsed as { schema?: unknown }).schema
82 if (found !== schema) {
83 return { ok: false, error: `orchestrate.py printed ${JSON.stringify(found)}, not ${schema}` }
84 }
85 return { ok: true, value: parsed as T }
86}
87
88/**
89 * Run one read-only subcommand through `run` and parse its JSON. Never rejects:
90 * a run that could not start or timed out, and arguments that are not valid,
91 * come back as `{ ok: false, error }`, so a mod draws the error instead of throwing.
92 */
93export async function readJsonWith<T>(
94 run: ProcessRunner,
95 pluginRoot: string,
96 args: () => string[],
97 schema: string,
98): Promise<OrchestrateRead<T>> {
99 try {
100 return parseJsonRun<T>(await run(orchestrateArgv(pluginRoot, args())), schema)
101 } catch (err) {
102 return { ok: false, error: `orchestrate.py did not run: ${err instanceof Error ? err.message : String(err)}` }
103 }
104}
105