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¶
nextReport the single next actionable stage (
0or1, never a list) for the active stream, as JSON. A report, not a gate: it exits0on a readable graph, except a misused--idor--stage, which reportsokfalse with reasonid_not_foundorstage_not_foundand exits non-zero, so a typo is never mistaken for progress. When there is nothing to do it returnsstage: nullwith areason(empty/done/blocked). This is the heartbeat you poll to drive authoring. Pass--stage <STAGE_ID>to brief an explicit stage — for example a globalrisks/decisionsarm — instead of the computed recommendation; an unknown stage id exits non-zero.runExecute 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 thechange-requestcascade. The produced artefact is left uncommitted for human or UI approval.promptPrint the resolved instruction for the next action as plain text: the prompt
runwould have piped to its runner, emitted on stdout instead of executed and prefixed with a=== WORKING DIRECTORY ===header naming the project root.runspawns its runner in that directory, so the piped text may leave a command or a path relative to it.prompthands the same text to an agent whose working directory is its own, so the header states whatrunsupplied implicitly. It resolves the same stream and the same armrundoes: 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 exits0. 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 globalrisks/decisionsarm) instead of the computed recommendation, the same flagnexttakes. Unlikenext --stage, it reads the named stage’s state first: a stage whose owndepends_onis not met yet is reported asNO ACTION (blocked)rather than briefed, so the briefing can never order an artefact across a layer that does not exist. A stage that readsdonefor 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 readsreadyand is briefed with no such line. A code stage (one that authors no need and instead gates a type throughrequire_code) is briefed only while a need of that gated type exists for it to implement, and reported otherwise. An--idthat 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 exits0.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 throughextendor-cgets its real one) is stated on every route, not only on apathcarrying an{id}placeholder. The status a brand-new need opens in is the first entry of the project’s ordered [workflow.lifecycle]states, ordraftwhen 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 ownstatus_undeclaredgate rejects.nextreports the same value instage.default_status, for a driver that reads the JSON instead.statusThe 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 exits0.
Inspecting the graph¶
audit --id <ID>A read briefing for one need: its
id,title,type,status,body, andsourcepointer, plus atracesub-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
--depthbound, each entry tagged with its depth and direction and carrying its code/test URLs. Also listsstale_gapsalready attributed to the anchor or anything it reaches. Used by the change-request cascade to scope an edit.
Gating and coverage¶
gapsThe 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 aspecs/path; an--scopeid that resolves to no need exits non-zero rather than gating nothing.traceThe 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-checkThe 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-baselineto 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-verdictsfolds the substance gate in: the combined gate is green only when both the structural gaps and the AI-review verdicts are clear.verdict-checkThe 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), andunverifiable. Exits non-zero on any of those; a leftoverstaleverdict (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-briefThe reviewer’s contract, as JSON — the review-arm counterpart to
next.review-brief <TYPE>(or--ids <ID…>) emits, per reviewed type: thereview_skillto run, the absoluteverdicts_dir, thereview_needsstill needing a verdict (already-fresh needs are listed underfresh_skippedand excluded unless--all), each need’s current one-hop contentfingerprint, the resolvedcriteriapack (itsguidanceand scoredaxes), the derivedverdict_schema, and thesubmit_commandtemplate. Two advisory strings come with it:independence, the requirement that the review run in a context other than the one that authored the needs, andprovenance, the ask that each verdict carry the reviewingagentandmodel. Both are asks on the caller and neither gates. The engine cannot observe the reviewing context at all, soindependenceis unverifiable by construction.provenanceit does record:verdict-submitkeepsagentandmodelin the verdict file when a verdict carries them. Noubc agentverb 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 exits0even when everything is already fresh.verdict-submitWrite 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.--fingerprintand--criteria-fingerprintarm 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-validateCross-check
[workflow]against theubproject.tomlontology 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’smax_score, or a floor naming an axis no pack declares, is an error. Reads config only — never builds the graph.doctorThe pre-flight an agent runs before driving the loop, so it fails on a misconfiguration before spending tokens. Composes
config-validateand 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-mcpbelow):okwhen 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/updateBootstrap 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_mcpobject 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 1does not fail closed on a registration that did not take: readregister_mcp.registeredto branch on it.REQ_SETUP_EXIT_RULErecords 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.