import { COMMAND_DETAILS, COMMAND_HELP_TAIL } from "./command-help.ts"; import { AMEND_AREA_IDS } from "./setup.ts"; import type { CommandScope } from "./commands/context.ts"; export type CommandCompleter = "project" | "shell" | "area"; export interface CommandFlag { name: string; alias?: string; description: string; takesValue: boolean; completer?: CommandCompleter; } export interface CommandPositional { name: string; completer?: CommandCompleter; variadic?: boolean; } export interface CommandManifestEntry { name: string; description: string; scope: CommandScope; usage: readonly string[]; subcommands?: readonly { name: string; description: string }[]; flags: readonly CommandFlag[]; positionals?: readonly CommandPositional[]; /** Long-form operator help, kept on the manifest so generated help cannot drift. */ details?: string; } const project = (): CommandFlag => ({ name: "--project", description: "target the named configured project", takesValue: true, completer: "project", }); const all = (): CommandFlag => ({ name: "--all", description: "target every configured project", takesValue: false, }); const value = ( name: string, description: string, completer?: CommandCompleter, ): CommandFlag => ({ name, description, takesValue: true, completer }); const toggle = (name: string, description: string, alias?: string): CommandFlag => ({ name, alias, description, takesValue: false, }); export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [ { name: "setup", description: "interview, configure the fleet, or install host and graph surfaces", scope: "project", usage: [ "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]", "setup host [NAME] [--project NAME]", "setup graph [--no-seed] [--print] [--project NAME]", ], subcommands: [ { name: "host", description: "stage and install the host services (run it from a shell outside the Herdr session it restarts)", }, { name: "graph", description: "install and seed code-graph indexes" }, ], flags: [ project(), toggle("--no-ai", "ask every question without AI repository probes"), value("--answers", "answer every prompt from a JSON file"), value("--save-answers", "save successful prompt answers as JSON"), toggle("--no-seed", "enable graph indexing without the initial seed"), toggle("--print", "print the graph install plan without changing anything"), ], positionals: [ { name: AMEND_AREA_IDS.join("|"), completer: "area" }, { name: "project", completer: "project" }, ], }, { name: "start", description: "start the installed service or background dispatch daemon", scope: "host", usage: ["start [--port N] [--project NAME]"], flags: [value("--port", "health endpoint port"), project()], }, { name: "version", description: "print the installed package version (also --version, -V)", scope: "none", usage: ["--version"], flags: [], }, { name: "stop", description: "hold the fleet and stop the dispatch daemon", scope: "fleet", usage: ["stop [--pane] [--project NAME | --all]"], flags: [toggle("--pane", "also stop and pin off pane recovery"), project(), all()], }, { name: "restart", description: "drain and restart the shared dispatch daemon", scope: "host", usage: ["restart [--now] [--timeout SECONDS] [--port N] [--project NAME]"], flags: [ toggle("--now", "restart immediately without draining workers"), value("--timeout", "maximum drain time in seconds"), value("--port", "health endpoint port"), project(), ], }, { name: "upgrade", description: "upgrade all installed conductor surfaces as one transaction", scope: "host", usage: ["upgrade [--to VERSION] [--bootstrap SHA --source PATH] [--project NAME]"], flags: [value("--to", "package version to install"), project()], }, { name: "upgrade-install", description: "run the detached upgrade transaction", scope: "host", usage: ["upgrade-install --to VERSION [--project NAME]"], flags: [value("--to", "package version to install"), project()], }, { name: "upgrade-rollback", description: "restore surfaces from the durable pre-upgrade snapshot", scope: "host", usage: ["upgrade-rollback"], flags: [], }, { name: "reconcile-units", description: "install the host unit files this version renders (units only)", scope: "host", usage: ["reconcile-units [--yes] [--dry-run] [--protect PATH]... [--project NAME]"], flags: [ toggle("--yes", "skip the confirm (the upgrade transaction's own consent)"), toggle("--dry-run", "print what would be installed and run nothing"), value("--protect", "a destination to leave alone (repeatable)"), project(), ], }, { name: "board", description: "open the live keyboard-driven fleet board", scope: "fleet", usage: ["board [--project NAME] [--json]"], flags: [project(), toggle("--json", "print the stable board JSON shape")], }, { name: "companion", description: "render the recovery companion pane's decisions and watches view", scope: "project", usage: ["companion decisions [--project NAME] [--once]"], flags: [ project(), toggle("--once", "draw one frame and exit instead of holding the pane"), ], }, { name: "console", description: "provision the operator console workspace and print its pane command", scope: "project", usage: ["console [--project NAME]"], flags: [project()], }, { name: "dashboard", description: "serve the read-only browser dashboard", scope: "none", usage: ["dashboard [--port N] [--host ADDR]"], flags: [value("--port", "dashboard port"), value("--host", "dashboard bind address")], }, { name: "status", description: "print layered fleet and deployment status", scope: "fleet", usage: ["status [--project NAME] [--json]"], flags: [project(), toggle("--json", "print the stable status JSON shape")], }, { name: "stats", description: "summarize fleet throughput, failures, spend, and GitHub calls", scope: "project", usage: ["stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]"], flags: [ value("--since", "reporting window start"), project(), toggle("--json", "print the stable stats JSON shape"), ], }, { name: "doctor", description: "run read-only deployment health checks", scope: "fleet", usage: ["doctor [--project NAME] [--json] [--probe-telegram]"], flags: [ project(), toggle("--json", "print the stable doctor JSON shape"), toggle("--probe-telegram", "send the self-identified Telegram health probe"), ], }, { name: "snapshot-db", description: "publish a transaction-consistent conductor.db snapshot", scope: "host", usage: ["snapshot-db"], flags: [], details: "Publish one transaction-consistent conductor.db snapshot using SQLite VACUUM INTO, " + "record the action in every configured project's durable event ledger, and apply the " + "daemon cadence's bounded retention. Safe while the daemon is writing.", }, { name: "restore-db", description: "restore conductor.db from a safe snapshot", scope: "host", usage: ["restore-db [SNAPSHOT]"], flags: [], positionals: [{ name: "snapshot" }], }, { name: "ledger", description: "print conductor verb and daemon decision history", scope: "project", usage: ["ledger [--issue N] [--limit N] [--project NAME] [--json]"], flags: [ value("--issue", "filter by issue number"), value("--limit", "maximum rows"), project(), toggle("--json", "print the stable ledger JSON shape"), ], }, { name: "hold", description: "pause claims and disarm ticks while keeping processes up", scope: "fleet", usage: ["hold [--keep-ticks] [--project NAME | --all]"], flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()], }, { name: "drain", description: "start, inspect, or cancel the project's self-expiring admission drain", scope: "project", usage: [ "drain start --until ISO|DURATION [--reason TEXT] [--project NAME]", "drain status [--project NAME]", "drain cancel [--project NAME]", ], subcommands: [ { name: "start", description: "record a bounded drain intent" }, { name: "status", description: "report the drain and its remaining active runs" }, { name: "cancel", description: "remove the project's drain" }, ], flags: [ value("--until", "absolute ISO instant or relative duration (90s, 45m, 2h, 1d)"), value("--reason", "purpose, persisted on the drain record"), project(), ], positionals: [{ name: "action" }], }, { name: "arm", description: "send the arming challenge, then verify the operator's reply", scope: "fleet", usage: ["arm [--project NAME | --all]", 'arm --reply "OPERATOR MESSAGE" [--project NAME]'], flags: [value("--reply", "verify the operator's verbatim reply and write the arm marker"), project(), all()], details: `arm files a short-lived challenge, sends it to the paired Telegram owner and returns immediately — it never waits for the reply, because the reply lands in the console session. Nothing is armed until the second half: pass the operator's message verbatim to arm --reply, which classifies it and arms exactly the projects that challenge recorded. A wrong, expired or absent code arms nothing and says which of the three it was.`, }, { name: "disarm", description: "disarm scheduled ticks without stopping processes", scope: "fleet", usage: ["disarm [--project NAME | --all]"], flags: [project(), all()], }, { name: "tail", description: "follow the newest worker run for an issue", scope: "project", usage: ["tail [--project NAME]"], flags: [project()], positionals: [{ name: "issue" }], }, { name: "extend", description: "raise a live or next-attempt worker turn ceiling", scope: "project", usage: ["extend --turns N [--project NAME]"], flags: [value("--turns", "positive turn ceiling"), project()], positionals: [{ name: "issue" }], }, { name: "worker", description: "pause, resume, or stop one worker session", scope: "project", usage: [ "worker pause [--project NAME]", "worker resume [--project NAME]", "worker stop --reason TEXT [--project NAME]", ], subcommands: [ { name: "pause", description: "park the worker at harness idle" }, { name: "resume", description: "resume the parked worker" }, { name: "stop", description: "stop the worker with a recorded reason" }, ], flags: [value("--reason", "reason for stopping the worker"), project()], positionals: [{ name: "action" }, { name: "issue" }], }, { name: "unblock", description: "clear blocked and failed labels so an issue can be claimed again", scope: "project", usage: ["unblock [--force] [--no-requeue] [--project NAME]"], flags: [ toggle("--force", "accept missing salvage and continue"), toggle("--no-requeue", "clear state labels without restoring the queue label"), project(), ], positionals: [{ name: "issue" }], }, { name: "unfreeze", description: "clear a repository freeze with a recorded reason", scope: "project", usage: ["unfreeze [--reason TEXT] [--project NAME]"], flags: [value("--reason", "reason for clearing the freeze"), project()], positionals: [{ name: "repo" }], }, { name: "release-composition", description: "declare, override, complete or cancel the active release", scope: "project", usage: [ "release-composition declare --campaign ID [--pr URL ...] [--reason TEXT] [--project NAME]", "release-composition override --pr URL [--reason TEXT] [--project NAME]", "release-composition complete [--reason TEXT] [--project NAME]", "release-composition cancel [--reason TEXT] [--project NAME]", "release-composition status [--project NAME]", ], flags: [ value("--campaign", "campaign identifier for the release being assembled"), value("--pr", "full PR URL allowed into (or overridden into) the release"), value("--reason", "reason recorded with the transition"), project(), ], }, { name: "verb", description: "run a gated conductor_* verb as the orchestrator", scope: "project", usage: ["verb [--project NAME] [--arg k=v ...]"], flags: [project(), value("--arg", "repeatable conductor verb argument")], positionals: [{ name: "conductor-verb" }], }, { name: "daemon", description: "run the dispatch loop in the foreground", scope: "host", usage: ["daemon [--once] [--port N] [--project NAME]"], flags: [toggle("--once", "run one tick and exit"), value("--port", "health endpoint port"), project()], }, { name: "resume", description: "clear fleet pause and pane recovery pins without arming ticks", scope: "fleet", usage: ["resume [--project NAME | --all]"], flags: [project(), all()], }, { name: "brief-upgrade", description: "inspect or migrate the project brief overlay", scope: "project", usage: ["brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]"], flags: [ toggle("--migrate", "migrate the owned brief half into POLICY.md"), toggle("--retrofit", "add the ownership cut to an older brief"), toggle("--apply", "write the proposed brief changes"), value("--file", "brief path"), project(), ], }, { name: "friction", description: "record a bounded escalation or report-quality observation", scope: "project", usage: [ "friction --detail TEXT [--issue N] [--project NAME]", ], flags: [value("--detail", "bounded observation detail"), value("--issue", "related issue number"), project()], positionals: [{ name: "category" }], }, { name: "event", description: "persist one ordinary material outcome without sending it", scope: "project", usage: [ "event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]", ], subcommands: [{ name: "record", description: "record a material outcome" }], flags: [ value("--category", "short lowercase event category"), value("--summary", "event summary"), value("--evidence", "verifiable evidence reference"), value("--occurred-at", "ISO occurrence timestamp"), project(), ], positionals: [{ name: "action" }], }, { name: "report", description: "hand a rendered report to the daemon's durable outbox", scope: "project", usage: [ "report --text TEXT [--kind material|digest|tier2|decision-needed|fleet-stopped|confirmed-failure] [--events IDS] [--notices IDS] [--project NAME]", "report withdraw --reason TEXT [--project NAME]", ], subcommands: [{ name: "withdraw", description: "withdraw an unsent report or held notice" }], flags: [ value("--text", "rendered report text"), value("--kind", "material, digest, or escalation category"), value("--events", "comma-separated event ledger ids"), value("--notices", "comma-separated notice ledger ids"), value("--reason", "why a pending handoff is obsolete"), project(), ], positionals: [{ name: "action" }, { name: "id" }], }, { name: "message", description: "deliver one direct message to the project's Telegram topic", scope: "project", usage: ["message --text TEXT [--category CATEGORY] [--blocks TEXT] [--project NAME]"], flags: [ value("--text", "message text"), value("--category", "escalation category"), value("--blocks", "what the message blocks"), project(), ], }, { name: "decision", description: "record, list, resolve, and withdraw operator decisions", scope: "project", usage: [ "decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]", "decision resolve --answer TEXT [--project NAME]", "decision withdraw [--reason TEXT] [--project NAME]", "decision list [--project NAME] [--json]", ], subcommands: [ { name: "open", description: "record a question for the operator" }, { name: "resolve", description: "record the operator's answer" }, { name: "withdraw", description: "withdraw an obsolete question" }, { name: "list", description: "list open operator decisions" }, ], flags: [ value("--question", "question for the operator"), value("--blocks", "what the decision blocks"), value("--resolves-when", "automatic resolution condition"), value("--answer", "operator answer"), value("--reason", "withdrawal reason"), toggle("--json", "print decision list as stable JSON"), project(), ], positionals: [{ name: "action" }, { name: "id" }], }, { name: "epic", description: "approve, revoke, or list standing operator approvals of epic scope", scope: "project", usage: [ "epic approve [--project NAME]", "epic revoke [--project NAME]", "epic list [--project NAME] [--json]", ], details: `An approved epic is standing consent to its scope: brief-complete children of it pass the promotion ready gate and are queued without a fresh operator decision. Approval is a durable fact of its own, not a resolved decision row — it never expires, and a gate can read it without parsing prose. approve is idempotent and first-write-wins: re-approving keeps the original timestamp and reports that nothing changed. revoke withdraws consent, so the epic's children need a decision again, and is a no-op on an epic that was never approved. Whether the issue number really names an epic is not checked here: parents and children are native GitHub sub-issues, which the daemon resolves when it promotes.`, subcommands: [ { name: "approve", description: "approve an epic's scope" }, { name: "revoke", description: "withdraw an epic's approval" }, { name: "list", description: "list approved epics" }, ], flags: [toggle("--json", "print the approval list as stable JSON"), project()], positionals: [{ name: "action" }, { name: "issue" }], }, { name: "watch", description: "record, list, or withdraw orchestrator-only conditions and carry notes", scope: "project", usage: [ "watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]", "watch list [--project NAME] [--json]", "watch withdraw [--reason TEXT] [--project NAME]", ], details: `add records a durable row with no operator answer needed. The running daemon checks conditions; an armed or ungated heartbeat wakes the orchestrator tick when one is met. With no daemon the row is unmonitored. With ticks disarmed the daemon may still mark a condition met, but no tick can be woken until the heartbeat returns. list shows open watches, oldest first. withdraw ends one with a recorded reason — the verb that creates a watch is the verb that ends it. A watch whose PR condition can no longer be observed (the PR merged or closed first) is withdrawn by the daemon itself.`, subcommands: [ { name: "add", description: "record a watch" }, { name: "list", description: "list open watches" }, { name: "withdraw", description: "withdraw an obsolete watch" }, ], flags: [ value("--note", "note carried when the watch resolves"), value("--blocks", "what the watch blocks"), value("--resolves-when", "automatic resolution condition"), value("--reason", "withdrawal reason"), toggle("--json", "print watch list as stable JSON"), project(), ], positionals: [{ name: "action" }, { name: "id" }], }, { name: "intake", description: "capture and groom raw ideas durably", scope: "project", usage: [ 'intake "" [--project NAME]', "intake list [--json] [--project NAME]", "intake dismiss [--project NAME]", "intake groomed --issue [--project NAME]", ], details: `Captures one raw idea into the local store and prints its id. list shows what is still pending (id, age, source, text), oldest first; dismiss drops one by id. The capture is durable — it lives in the sqlite store, not in a session — so it survives daemon restarts. The orchestrator files the idea as an issue and then marks that provenance with groomed: an id already resolved is a no-op with a message, never an error, because ticks retry. The source column names the signal that filed an item, for the ones the daemon's hourly signal-mining pass files itself; a dash means you thought of it. A mined item is filed idempotently, so dismissing one dismisses it for good rather than seeing it again next hour.`, subcommands: [ { name: "list", description: "list pending intake items" }, { name: "dismiss", description: "dismiss an intake item" }, { name: "groomed", description: "link an intake item to its issue" }, ], flags: [ value("--issue", "groomed issue URL"), toggle("--json", "print the pending list as stable JSON"), project(), ], positionals: [{ name: "text|action" }, { name: "id" }], }, { name: "help", description: "print command usage (also --help, -h)", scope: "none", usage: ["help"], flags: [], }, { name: "complete", description: "generate shell setup or answer one completion request", scope: "none", usage: ["complete ", "complete -- "], flags: [], positionals: [ { name: "shell", completer: "shell" }, { name: "args", variadic: true }, ], }, ]; export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_MANIFEST): string { const synopsis = manifest.flatMap((command) => command.usage.map((line) => ` omp-conductor ${line}`), ); const width = Math.max(...manifest.map((command) => command.name.length)); const commands = manifest.map( (command) => ` ${command.name.padEnd(width)} ${command.description}`, ); // The fleet-wide operator reference, printed once. It is *not* harvested // from `details`: that field is one verb's own long-form help, which // `VERB --help` renders, so collecting it here would print `setup`'s // carrier copy of this very blob and repeat each verb's prose twice. const details = [...COMMAND_DETAILS.split("\n"), "", ...COMMAND_HELP_TAIL.split("\n"), ""]; return [ "omp-conductor — dispatch ready issues to omp coding sessions", "", "usage:", ...synopsis, "", "commands:", ...commands, "", "details:", ...details, "recipes:", " hold no claims, no tick sends (inspectable)", " drain start bounded release window, self-expiring (no hold)", " stop hold + stop dispatch daemon", " stop --pane stop + pin conductor-pane recovery off", " resume clear pause and pane pin (ticks stay disarmed)", " resume && arm clear pause and pane pin, then prove inbound Telegram", ].join("\n"); }