import { Type, type TSchema } from "typebox" import { Check, Errors } from "typebox/value" import { workflowCommitPhase } from "./adapters/commit.ts" import { workflowDispatch } from "./adapters/dispatch.ts" import { apneaSetup } from "./adapters/setup.ts" import { workflowStart } from "./adapters/start.ts" import { workflowAbandon } from "./adapters/abandon.ts" import { workflowResetRounds, workflowStatus } from "./adapters/status.ts" import { workflowWait } from "./adapters/wait.ts" import { DISPATCH_KINDS } from "./domain/state-machine.ts" import type { ToolResult } from "./result.ts" import { neutralHostAdapter, type ApneaHostAdapter } from "./host-adapter.ts" import { DEAD_POLLS_NEEDED, DEFAULT_BUDGET_MS, GRACE_MS, HOST_SHELL_TIMEOUT_MS, IDLE_NUDGE_AFTER_MS, MAX_AUTO_POLL_MS, MIN_POLL_MS, type WaitParams, } from "./workflows/wait.ts" import type { OperationHooks } from "./operation-hooks.ts" export type Operation = { /** Pi tool name, or null when the operation is not model-facing. */ readonly tool: string | null /** CLI verb and `/apnea` subcommand. */ readonly verb: string /** * Argument syntax shown next to the verb in `/apnea help`, e.g. * `" [--allow-dirty] [--slug=name]"`. Empty string for verbs that * take no arguments (e.g. `status`). Required so a new operation can't * silently omit the human-facing usage the old hand-written helpText() * used to carry. */ readonly usage: string /** One line, shared by the tool description and `--help`. */ readonly summary: string /** Extra prose for the model only; omitted from `--help`. */ readonly guidance?: string readonly params: TSchema /** Human confirmation required; never registered as a tool. */ readonly humanOnly?: true } type RegisteredOperation = Operation & { readonly run: ( params: Record, hooks?: OperationHooks, ) => Promise } export type ExecuteOperation = ( verb: string, params: Record, hooks?: OperationHooks, ) => Promise // Sourced from domain/state-machine.ts (not hardcoded here) so a new kind // added there can't silently drift out of sync with the registry — the same // pattern extension/index.ts already uses. const DispatchKind = Type.Union( DISPATCH_KINDS.map((kind) => Type.Literal(kind)), ) function operationParams(properties: Record): TSchema { return Type.Object(properties, { additionalProperties: false }) } // Workflow order, not alphabetical or tool-name order: setup is the natural // first step for a new checkout, then the start → dispatch → wait → commit // loop, then the always-available status, then the human-only escape hatch. // `/apnea help` and autocomplete (SUBS) both derive their order from this // array, so ordering it once here keeps every rendering in sync for free. function createRegisteredOperations( hostAdapter: ApneaHostAdapter = neutralHostAdapter, ): readonly RegisteredOperation[] { return [ { tool: null, verb: "setup", usage: "[--project] [--force] [--agents-md]", summary: "Write global profiles and optional project role bindings.", params: operationParams({ project: Type.Optional(Type.Boolean()), force: Type.Optional(Type.Boolean()), agents_md: Type.Optional(Type.Boolean()), }), run: (p, hooks) => apneaSetup(p as Parameters[0], hostAdapter, hooks), }, { tool: "workflow_start", verb: "start", usage: " [--allow-dirty] [--slug=name]", summary: "Start or resume an Apnea run.", guidance: "Start only writes state (step=planning) — it does NOT launch roles. After start succeeds you MUST immediately call dispatch_role kind=plan then workflow_wait. Resume never auto-dispatches. Refuses if state exists or tree dirty (unless allow_dirty).", params: operationParams({ goal: Type.Optional( Type.String({ description: "Run goal (required for action=start)" }), ), slug: Type.Optional( Type.String({ description: "Run slug for branch/bookmark" }), ), allow_dirty: Type.Optional(Type.Boolean()), action: Type.Optional( Type.Union([Type.Literal("start"), Type.Literal("resume")]), ), }), // Mirrors the guard in index.ts's execute(): without it, action=start // with no goal reaches slugify(undefined) in workflows/start.ts and // throws instead of returning a clean refusal. run: (p, hooks) => { const params = p as Parameters[0] const action = params.action ?? "start" if (action === "start" && !params.goal?.trim()) { return Promise.resolve({ ok: false, error: "goal is required when action=start", }) } return workflowStart( { goal: params.goal ?? "", slug: params.slug, allow_dirty: params.allow_dirty, action, }, hostAdapter, hooks, ) }, }, { tool: null, verb: "abandon", humanOnly: true, usage: "[--confirm= --stop-panes|--stopped-work] [--acknowledge-corrupt]", summary: "Preview ownership, then archive after human attestation that all run work stopped.", params: operationParams({ confirm: Type.Optional(Type.String()), stopped_work: Type.Optional(Type.Boolean()), stop_panes: Type.Optional(Type.Boolean()), acknowledge_corrupt: Type.Optional(Type.Boolean()), }), run: (p, hooks) => workflowAbandon( p as Parameters[0], hostAdapter, hooks, ), }, { tool: "dispatch_role", verb: "dispatch", usage: " [--rework] [--redeliver]", summary: "Write the task file and launch a role in a Herdr pane.", guidance: "One outstanding dispatch at a time. Persisted review state selects rework and advances its round. rework=true is a deprecated assertion through 0.2.x and grants authority only for ambiguous version-1 plan or code migration. Use redeliver=true only to reuse matching pending ownership after proving the prior delivery is dead. A complete pending artifact refuses redelivery; call workflow_wait to ingest it.", params: operationParams({ kind: DispatchKind, task_markdown: Type.Optional( Type.String({ description: "Extra task body details" }), ), rework: Type.Optional( Type.Boolean({ description: "Deprecated assertion through 0.2.x; persisted state owns rework", }), ), redeliver: Type.Optional( Type.Boolean({ description: "Reuse matching pending ownership after the prior delivery is demonstrably dead", }), ), }), run: (p, hooks) => workflowDispatch( p as Parameters[0], hostAdapter, hooks, ), }, { tool: "workflow_wait", verb: "wait", usage: "[--poll=] [--budget=|--timeout=]", summary: "Wait for the pending artifact's front-matter to be complete.", guidance: "Blocks until the artifact is ready or the role times out. Exit is non-fatal when the call's budget is spent but the role still has time — call again. Omit both parameters unless you have a reason.", // `timeout_ms` was dropped in Task 3: dispatch always stamps the deadline, // so it no-opped for every real run. The role timeout lives in config. // // Only `minimum` is a schema-level bound, because only it is // unconditional. The poll ceiling applies solely when budget_ms is // absent, and a `maximum` here would have declared a limit the runtime // does not enforce — rejecting the legal large-poll-with-explicit-budget // call at the boundary, or lying to a model that never hits it. // // The floor is interpolated from the constants, not spelled out. A // hardcoded formula here goes stale the moment IDLE_NUDGE_AFTER_MS // moves, and then the schema promises a budget the runtime refuses. params: operationParams({ poll_ms: Type.Optional( Type.Integer({ minimum: MIN_POLL_MS, maximum: Number.MAX_SAFE_INTEGER, description: `Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` + `Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` + `below forces a budget past the ${HOST_SHELL_TIMEOUT_MS}ms an agent shell commonly allows, and the call is refused.`, }), ), budget_ms: Type.Optional( Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: `How long THIS call may block — not the role's deadline, which comes from config. ` + `Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` + `can contain a whole recovery rung. Omitting it is usually right: this tool then blocks until the ` + `role finishes, streaming progress and interruptible, because it has no host shell timeout to fit inside. ` + `The apnea CLI, which does, defaults to ${DEFAULT_BUDGET_MS}ms instead and returns exit 3 to be called again.`, }), ), }), // The Pi driver bypasses this handler entirely — Task 5 special-cases // workflow_wait and calls workflowWait directly with its own streaming // hooks, supplying its own budget (see extension/index.ts). This run is // reached only by the CLI, which must not block forever, so params pass // through unchanged and DEFAULT_BUDGET_MS applies when budget_ms // is absent. No hooks parameter here — nothing calls op.run with one. run: (p, hooks) => workflowWait(p as WaitParams, hostAdapter, hooks), }, { tool: "workflow_commit_phase", verb: "commit", usage: "[--done] [message]", summary: "Verify and commit the current phase, then advance.", guidance: "Requires an APPROVED code review. Runs the phase package's verify commands and refuses on non-zero exit. Pass no_remaining_phases=true to move to the PR description instead of the next phase.", params: operationParams({ message: Type.Optional(Type.String()), no_remaining_phases: Type.Optional( Type.Boolean({ description: "If true, go to finishing (PR description) after commit", }), ), }), run: (p, hooks) => workflowCommitPhase( p as Parameters[0], hostAdapter, hooks, ), }, { tool: "workflow_status", verb: "status", usage: "", summary: "Read-only snapshot of run state and legal next calls.", guidance: "Never mutates. Safe to call at any point.", params: operationParams({}), run: (_p, hooks) => workflowStatus(hostAdapter, hooks), }, { tool: null, verb: "reset-rounds", // `--i-am-human` is deliberately not listed here: it's a CLI-only TTY // bypass (the slash handler doesn't accept it — see main.ts's own usage() // and README.md), and this string feeds both `/apnea help` and the CLI's // per-verb line, so listing it here would advertise it on the slash // command too. usage: "", summary: "Reset the rework counter for a gate. Human only.", humanOnly: true, params: operationParams({ gate: Type.String({ description: "Round key, e.g. plan_review or phase-01/code_review", }), }), run: (p, hooks) => workflowResetRounds( p as Parameters[0], hostAdapter, hooks, ), }, ] } function publicOperation(operation: RegisteredOperation): Operation { const { run: _run, ...metadata } = operation return metadata } function executorFor( operations: readonly RegisteredOperation[], ): ExecuteOperation { return async (verb, params, hooks) => { const operation = operations.find((candidate) => candidate.verb === verb) if (!operation) { return { ok: false, error: `unknown operation: ${verb}` } } if (!Check(operation.params, params)) { return { ok: false, error: `invalid parameters for ${verb}`, data: { verb, issues: [...Errors(operation.params, params)].map((issue) => ({ path: issue.instancePath, message: issue.message, })), }, } } return operation.run(params, hooks) } } export function createOperations( hostAdapter: ApneaHostAdapter = neutralHostAdapter, ): readonly Operation[] { return createRegisteredOperations(hostAdapter).map(publicOperation) } export function createExecutor( hostAdapter: ApneaHostAdapter = neutralHostAdapter, ): ExecuteOperation { return executorFor(createRegisteredOperations(hostAdapter)) } const REGISTERED_OPERATIONS = createRegisteredOperations() export const OPERATIONS = REGISTERED_OPERATIONS.map(publicOperation) export const executeOperation = executorFor(REGISTERED_OPERATIONS) export function findByVerb(verb: string): Operation | undefined { return OPERATIONS.find((o) => o.verb === verb) } export function findByTool(tool: string): Operation | undefined { return OPERATIONS.find((o) => o.tool === tool) } /** Canonical tool name → CLI verb, or null when not model-facing. */ export function toolToVerb(tool: string): string | null { return findByTool(tool)?.verb ?? null }