SLOPSHOPPER

com.infiquetra.claude

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

newpaneguardcommandtoolprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · com.infiquetra.claude
│ ┃ orchestrate-fleet ✕ › fix the failing auth test and add an audit log call │ ┃ No run is shown. /fleet-view <issue> opens │ ┃ one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ][ Close ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet-view │ ⎿ com.infiquetra.claude: Usage: /fleet-view <issue> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · orchestrate-fleet
No run is shown. /fleet-view <issue> opens one. [ Refresh ][ Close ]
Pane · orchestrate-launch
No launch table is waiting.
README

Infiquetra Agent Plugins

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.

Status

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:

  • Vendor-neutral core at the package root (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.
  • Claude-only behaviour under 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).
  • Codex packaging at 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.
  • Fleet Core is a library, bundled at build time into each consumer's _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.
  • Tests travel with the package under 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:

Packages

PackageVersionDescriptionInstallable surfaces
plugins/agent-launcher/1.7.1Create one verified coding-agent session through the agents wrapper and HerdrClaude, Codex, 1 skill
plugins/agy/0.6.2Antigravity-backed coder reviewer bridge agents that run in a disposable clone and…Claude, Codex, 1 skill
plugins/codex/0.1.5Portable Codex delegation wrapperClaude, Codex, 1 skill
plugins/deploy/0.2.3Tag-promotion deployment operations for Infiquetra repositoriesClaude, Codex, 1 skill
plugins/fleet-core/0.32.0Authored Fleet Core library: staffing (work shape, role, and lens to model and…Codex
plugins/hermes-profile-evolution/0.1.5Portable request adapter for target-sovereign Hermes profile evolutionClaude, Codex, 1 skill
plugins/home-lab-ops/1.2.2Proxmox VE cluster operations, Ansible pre-flight validation, Ceph management,…Claude, Codex, 6 skills
plugins/house-style/0.1.1Claude-only packageClaude, Codex
plugins/mission-control/2.21.1SDLC management for Operations, Asgard, and CAMPPS: prepared issue drafts, live schema…Claude, Codex, 8 skills
plugins/orchestrate/6.0.1Run one piece of work across several herdr agent sessions, one git worktree per unitClaude, Codex, 1 skill
plugins/redis-channel/0.5.3Portable Redis Streams bridgeClaude, Codex, 1 skill
plugins/saga/1.2.1Infiquetra lifecycle plugin: one automatic run per issue — admission, plan, plan…Claude, Codex, 14 skills
plugins/unifi/2.0.7Portable UniFi Network and Protect package: two Agent Skills with their bundled Python…Claude, Codex, 2 skills
plugins/voice/0.4.0Portable voice package: a spoken conversational loop for one explicitly bound,…Claude, Codex, 1 skill

Portable Fleet Core scope

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.

Operator site profile

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.

Repository layout

PathPurpose
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.mdPlace 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.

Validation

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.

Development

  • Read AGENTS.md before changing the repository.
  • Use conventional commit messages.
  • Use pull requests after the initial repository bootstrap.
  • Update the engineering journal when work creates a durable learning, repository decision, or deferred item.
  • Do not commit credentials, generated installed copies, or local agent state.

Public development guidance is summarized in docs/public-safe-summary.md.

Source 5 files
mods/index.ts 29 lines
1// 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}
29
mods/fleet-view.tsx 186 lines
1// 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}
186
mods/launch-approval.tsx 136 lines
1// 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}
136
types/index.d.ts 136 lines
1// 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}
136
mods/orchestrate-script.ts 105 lines
1// 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