The ubc agent command

ubc agent is the engine behind Pharaoh: a set of subcommands that read your graph and your [workflow] config and report what to do next, what the graph looks like, and whether it is ready to release. All of them are read-only with two exceptions: run drives your AI, and install / update scaffold config — nothing else changes your requirements.

Caution

ubc agent is in alpha: it works today, but the subcommand set and the JSON shapes may change between releases. List the subcommands with ubc agent --help and a specific one with ubc agent <command> --help.

Three conventions worth knowing

Every verb pins its project and its need the same way. -p <PATH> (--project) names the project root — a repository can hold several ubproject.toml files, and without it a verb runs against the current directory. The verbs that read a single need all take it as --id <NEED_ID>; the inspect verbs (audit, context, impact) also accept the id positionally.

Everything is JSON, except prompt, and except a payload that was never produced. When a verb produces a payload it prints it as a JSON object on stdout, designed to be parsed by an AI agent rather than read as prose. When it fails before it has one it prints nothing there and logs the reason on stderr instead, so empty stdout is a pointer to stderr rather than a parse error. Which failures come before the payload differs per verb, so read what the verb actually printed rather than assuming. prompt prints a plain-text instruction instead (see its entry below). The reports (next, status, review-brief) report rather than gate. The gates (gaps, trace, release-check, verdict-check, config-validate, doctor, update) exit non-zero exactly when their check fails; the inspect verbs (audit, context, impact, prompt) exit non-zero when the requested id does not exist; and the two write verbs (run, verdict-submit) exit non-zero when they cannot produce their artefact. The exit code is a convenience, the payload is the contract. Those groupings say what each verb is for, not what $? will be on every input: a report still exits non-zero when its arguments are unusable, and a write verb still exits 0 when there was nothing to write. A non-zero exit always arrives with evidence: the payload the verb printed, whose shape is that verb’s own (see its entry below), or a logged error on stderr. Branch on that evidence, never on the exit code alone. The one verb outside this is prompt, which prints plain text: its output is handed to an agent as an instruction, not parsed, so branch on its FIRST LINE (NO ACTION (<reason>) versus === WORKING DIRECTORY ===). When its stdout is empty it printed only an error, and the words needs an anchor need to brief from are a request for --id. Any other stderr text there is a real failure, so read it and fix what it names rather than re-running with an anchor.

The loop verbs act on the “active stream”, not the whole graph. next, run, and prompt infer the stream you are working on from changed need files, or take it from the stream anchored on --id <NEED_ID>. On a dirty tree inference reads the files changed in your working tree (staged, unstaged, or untracked but not gitignored), and only a fully clean tree falls back to the branch’s changes since the merge-base. When your working tree carries changes from several streams they merge into one, so pass --id to pick which one to drive. When it carries no changed need file, inference yields nothing and all three fall back to the whole graph. next reports from it as usual, while run and prompt may instead ask you for an --id, depending on what the fallback finds and on the flags you passed. Read what they print rather than predicting it. The JSON verbs mark the fallback with "source": "none" in $.stream (prompt prints plain text and carries no such field), so check it before treating any stage as stream-scoped. An --id that is not a need is reported as a failure rather than silently falling back to the whole graph, so a typo can never be mistaken for progress. Pre-existing gaps elsewhere are surfaced as non-blocking open_streams so one stream’s work is not drowned out by another’s.

Driving the loop

next

Report the single next actionable stage (0 or 1, never a list) for the active stream, as JSON. A report, not a gate: it exits 0 on a readable graph, except a misused --id or --stage, which reports ok false with reason id_not_found or stage_not_found and exits non-zero, so a typo is never mistaken for progress. When there is nothing to do it returns stage: null with a reason (empty / done / blocked). This is the heartbeat you poll to drive authoring. Pass --stage <STAGE_ID> to brief an explicit stage — for example a global risks / decisions arm — instead of the computed recommendation; an unknown stage id exits non-zero.

run

Execute the recommended stage through the configured runner, then rebuild and report what was produced. --id <NEED_ID> picks a stream. Pass it when your working tree’s changed need files cannot seed one, for example right after a review when only verdict files changed. --skill <NAME> runs a named skill instead of the stage’s default — the execution surface for targeted intents such as the change-request cascade. The produced artefact is left uncommitted for human or UI approval.

prompt

Print the resolved instruction for the next action as plain text: the prompt run would have piped to its runner, emitted on stdout instead of executed and prefixed with a === WORKING DIRECTORY === header naming the project root. run spawns its runner in that directory, so the piped text may leave a command or a path relative to it. prompt hands the same text to an agent whose working directory is its own, so the header states what run supplied implicitly. It resolves the same stream and the same arm run does: an author instruction for a ready stage, a review instruction for a review-held one, a fix instruction when every review target already failed. When no stage is actionable it prints the one action for that state (empty / done / blocked) and still exits 0. Executes nothing and resolves no runner, so it needs no [agent.runner]. Use it to drive the loop from an agent you are already talking to. --id <NEED_ID> picks the stream. Pass it when your working tree’s changed need files cannot seed one, for example right after a review when only verdict files changed. --stage <STAGE_ID> briefs an explicit stage (for example a global risks / decisions arm) instead of the computed recommendation, the same flag next takes. Unlike next --stage, it reads the named stage’s state first: a stage whose own depends_on is not met yet is reported as NO ACTION (blocked) rather than briefed, so the briefing can never order an artefact across a layer that does not exist. A stage that reads done for the stream is briefed, led by a line naming that state, and for a need-producing stage that line says the artefact it orders is an additional one. A stage that still carries a gap or an unfinished review reads ready and is briefed with no such line. A code stage (one that authors no need and instead gates a type through require_code) is briefed only while a need of that gated type exists for it to implement, and reported otherwise. An --id that is not a need, or an unknown --stage, exits non-zero. So does a ready stage with no anchor to brief from, when the graph has needs but none of them resolves as the anchor. A graph with no needs at all is briefed from its first stage instead, and exits 0.

The instruction states the two facts an authoring skill asks for and cannot derive on its own. The id prefix of the produced type (its [[needs.types]] prefix, read from the merged config so a project whose types arrive through extend or -c gets its real one) is stated on every route, not only on a path carrying an {id} placeholder. The status a brand-new need opens in is the first entry of the project’s ordered [workflow.lifecycle] states, or draft when the project declares none. Where an inlined skill’s worked example shows a different :status:, the instruction says outright that it is the authoritative one, so a stale local install cannot re-introduce a value the project’s own status_undeclared gate rejects. next reports the same value in stage.default_status, for a driver that reads the JSON instead.

status

The full heartbeat across the whole graph (it takes no stream anchor): one row per stage with its state (done / ready / blocked), coverage counts, failing gates, and route. Always exits 0.

Inspecting the graph

audit --id <ID>

A read briefing for one need: its id, title, type, status, body, and source pointer, plus a trace sub-object and the full map of every named link on it. Exits non-zero if the id is absent (it never substitutes a stand-in).

context --id <ID>

One need’s neighbourhood: the anchor plus every directly linked need (forward and _back), each with its own body and the relation it came in on. This is the briefing the authoring skills read. --no-code (alias --graph-only) returns the graph briefing without attempting to resolve related source.

impact --id <ID>

The blast radius of one need: the multi-hop, up-and-down trace closure of everything that depends on it, to a --depth bound, each entry tagged with its depth and direction and carrying its code/test URLs. Also lists stale_gaps already attributed to the anchor or anything it reaches. Used by the change-request cascade to scope an edit.

Gating and coverage

gaps

The full structural gap list ({gaps, by_category, summary, ok}). Exits non-zero when any gap is found — the whole-graph structural gate. --scope <NEED_ID> narrows it to the one stream that need anchors — its entry root and the whole trace-subtree beneath — and recomputes the tally and exit code over that subset. Scoping is by the trace graph, not a specs/ path; an --scope id that resolves to no need exits non-zero rather than gating nothing.

trace

The bidirectional trace-coverage matrix: one row per declared trace edge with forward and backward covered/total counts and the surviving gap ids. Exits non-zero if any edge is uncovered in either direction.

release-check

The release gate. By default every open structural gap fails it. With --baseline <PATH> it gates only new gaps against a baseline of accepted debt: a baselined gap is reported as non-blocking, a gap absent from the baseline fails. Add --update-baseline to ratchet the baseline down on a passing run — fixed gaps are dropped so they can never be readmitted; without the flag the gate never mutates state. --with-verdicts folds the substance gate in: the combined gate is green only when both the structural gaps and the AI-review verdicts are clear.

verdict-check

The AI-review substance gate. Reads the review verdicts from .pharaoh/verdicts/ and reports which review-required needs block a release: missing (no valid verdict), failing (a recorded failure), malformed (an unusable file), outdated (the need changed after its verdict was recorded), and unverifiable. Exits non-zero on any of those; a leftover stale verdict (for a need no longer in the graph) warns but never blocks.

Reviewing needs

See Reviews and quality analysis for the model these two verbs serve; the entries here are the reference.

review-brief

The reviewer’s contract, as JSON — the review-arm counterpart to next. review-brief <TYPE> (or --ids <ID…>) emits, per reviewed type: the review_skill to run, the absolute verdicts_dir, the review_needs still needing a verdict (already-fresh needs are listed under fresh_skipped and excluded unless --all), each need’s current one-hop content fingerprint, the resolved criteria pack (its guidance and scored axes), the derived verdict_schema, and the submit_command template. Two advisory strings come with it: independence, the requirement that the review run in a context other than the one that authored the needs, and provenance, the ask that each verdict carry the reviewing agent and model. Both are asks on the caller and neither gates. The engine cannot observe the reviewing context at all, so independence is unverifiable by construction. provenance it does record: verdict-submit keeps agent and model in the verdict file when a verdict carries them. No ubc agent verb reports them back, though, so do not expect to read them out of any payload documented here. The editor’s Run QA detail view is the one surface that shows them. Nothing scores or gates on them either way, and the engine never fills them in for you. A report: it exits 0 even when everything is already fresh.

verdict-submit

Write one need’s verdict. verdict-submit <NEED_ID> --file <PATH|-> reads a scored verdict (- = stdin) and writes it atomically to .pharaoh/verdicts/<NEED_ID>.json. The engine stamps the provenance (schema, need, criteria, and both fingerprints), validates the scores against the need type’s resolved pack, and refuses an in-repo draft path — so a reviewer cannot forge a verdict’s identity. --fingerprint and --criteria-fingerprint arm race guards that reject a verdict scored against content or a rubric that has since changed. Exits non-zero, with a machine-readable reason, on any rejection.

Setup and health

config-validate

Cross-check [workflow] against the ubproject.toml ontology as JSON ({ok, errors, warnings}): every type a stage produces or traces against must be a declared type, every referenced link a declared link, every transition state a declared lifecycle state. It also validates the [quality] gate against the loaded criteria packs — a floor above an axis’s max_score, or a floor naming an axis no pack declares, is an error. Reads config only — never builds the graph.

doctor

The pre-flight an agent runs before driving the loop, so it fails on a misconfiguration before spending tokens. Composes config-validate and then probes only the tools the resolved config actually requires (a diagram renderer when the diagram gate is on; codelinks when a code-trace gate is configured). A required-but-missing tool is a blocking error with a remediation hint.

An mcp-registration:<harness> check reports on the MCP registration (see --register-mcp below): ok when the harness launches this ubc with this project’s config, a warning when it records something else, and an error when the harness has the server disabled for the project, because that means the tools are not loading at all. It is silent when nothing is recorded and nothing is disabled, so a project that never registered is not nagged about a feature it does not use.

install / update

Bootstrap a project into the loop from a profile, and reconcile a previously installed project against the current profile. Covered in the quick start (install, update).

install --register-mcp <harness-id> additionally registers the ubCode MCP server with that harness’s own CLI, so a terminal agent gets the ubCode tools. Opt-in: it delegates to a third-party CLI and writes into that harness’s own configuration, and nothing is spawned unless the flag is passed.

The outcome lands in the register_mcp object of the JSON report. An unknown harness id is an argument error and exits non-zero before anything is written. A registration that could not be applied (the harness CLI is absent, refuses, times out, or records something else) is REPORTED and does not change the exit code, because the project scaffold is complete and correct in that case. So || exit 1 does not fail closed on a registration that did not take: read register_mcp.registered to branch on it. REQ_SETUP_EXIT_RULE records the rule and the reasoning.

See also

Quick start shows these verbs in the order you actually use them; Configuring the workflow explains the [workflow] keys the gates enforce.