Configuring the workflow

Everything Pharaoh does is driven by ubproject.toml. There is no hidden state: the process model, the ontology, and the AI runner all live in the same file your project already uses for linting and need types.

This page walks through the pieces of a complete V-model configuration and explains why each is there. For the exhaustive, key-by-key reference of the [workflow] section, see its configuration page.

Note

ubc agent install writes all of this for you. You only need to understand it when you want to change the process — add a stage, rename a type, or point a route at a different folder.

The process: [workflow] stages

The [workflow] section is the process model — an ordered list of stages, each producing one need type and declaring the traceability it must satisfy.

[workflow]
# Spec artefacts are decomposition steps, gated by their own trace edges rather
# than by code coverage — so they are exempt from the orphan check.
orphan_exempt_types = ["user_story", "req", "arch"]

[[workflow.stages]]
id = "reqs"
produces = "req"
depends_on = ["user_stories"]
author_skill = "draft-requirement"
review_skill = "review-requirement"
authoring = "Decompose the use case into the complete set of requirements it implies — the happy path, the error and edge cases, and the relevant non-functional constraints."

[workflow.stages.route]
scope = "stream"
granularity = "per-root"
path = "specs/{stream}/requirements.rst"

[[workflow.stages.trace]]
up = "user_story"
link = "traces_to"
direction = "both"
min = 1

Reading that stage top to bottom:

produces / depends_on

This stage authors req needs and cannot start until the user_stories stage is satisfied. An unmet dependency reports the stage as blocked.

author_skill / review_skill

The names of the AI skills the harness runs to draft and to review this stage’s artefacts (see the harness assets and the command reference).

Providing a review_skill also activates the quality gate for this stage: it is held ready — never reported done — until every need it produces carries a fresh review verdict that clears the per-axis integer floors set in [quality], whether the global [quality.required] map or a per-type [quality.type_configs.<type>.required] override. A stage with no review_skill has no quality gate — only its structural gates apply. See Reviews: the substance gate below and the [quality] configuration reference.

authoring

A project-specific directive injected into the authoring prompt. Process policy — “produce the complete set of requirements” — lives here in config, not baked into the generic skill, so you can tune it without editing skills.

[workflow.stages.route]

Where the artefacts are authored, and into how many authoring runs the stage is split. scope = "stream" with granularity = "per-root" means one run per stream, writing to the path template. The route keys reward a closer look — see the route, in detail below.

[[workflow.stages.trace]]

The traceability obligation. direction = "both" is bidirectional: every req must link up to a user_story via traces_to, and every user_story must be covered down. This one edge is what the structural gate enforces for the stage.

Where artefacts are authored: the route

Every stage’s [workflow.stages.route] says where its needs live and how the stage’s authoring is shaped. It has three required keys — scope, granularity, path — plus an optional group_field.

path — the write target. The template is resolved by the engine:

  • {stream} is replaced with the active stream’s slug. The engine stores no slug — it recomputes one on every ubc agent next run from the stream’s own needs: it takes the template’s literal prefix (the segments before {stream} — e.g. specs for specs/{stream}/requirements.rst, or docs for docs/{stream}/sys_reqs.rst), finds that prefix in the source path of the stream’s root need (falling back to the stream’s other members), and reads the directory segment immediately after it. A root authored at specs/login/user_stories.rst therefore gives the slug login. The segment counts only when a file follows it, so {stream} always binds to a directory. The recovery is layout-independentspecs/{stream}/, docs/{stream}/, and a bare {stream}/ all resolve — so the slug is a property of the stream’s own needs, not of any fixed specs/ convention.

  • {id} is not filled in by the engine — it is the one placeholder left to the author. So a template with {id} (specs/{stream}/reqs/{id}.rst) means one file per need, while one without it (specs/{stream}/requirements.rst) means one shared file holding all of the stream’s needs of that type.

  • A template with no {stream} placeholder is taken verbatim as the write target — one fixed location for the whole project, the same whatever the active stream. So path = "docs/sys_reqs.rst" always resolves to docs/sys_reqs.rst; that is the shape of a global stage (see scope below, and the risks row in the table).

  • Once the stream exists, the {stream} slug resolves automatically: as soon as one need sits under the resolved directory, the slug is read back from it and every later need of the stream lands beside it. Only the first need of a brand-new stream has no slug to recover yet — the engine leaves the path unresolved (resolved_path is null) and you place that first file by hand; from then on it resolves on its own.

  • A bare directory (src, tests) is the target folder a code/marker stage writes its source into.

scope — whether a stage authors its needs per stream (stream — a subtree per stream) or once for the whole project (global — one shared location).

Scope is only a declared intent, though: the engine decides the real write target from the path alone — whether it carries a {stream} placeholder — never from scope. So keep the two aligned: put {stream} in the path with scope = "stream", and a placeholder-less path with scope = "global". They can disagree without an error — scope = "stream" on a placeholder-less path = "docs/sys_reqs.rst" still resolves to that one shared file, unflagged — it just misleads a reader.

granularity — how a run is briefed. The value that changes engine behaviour today is per-root:

per-root

The stage is briefed per stream: for the active stream the runner receives all the upstream needs it must cover plus the needs already authored, and writes the complete set in one coherent pass. The decomposition stages (user_storiesreqsarchs) use this.

global

Used by the code and tests stages, which author across the whole graph (scanning src/ / tests/ once); that whole-graph behaviour follows from their being marker/code stages.

per-need / single

Author one artefact per run — per-need once per produced need (pair it with {id} in the path), single a one-off into a fixed file.

group_field — an optional field name carried on the route for grouping a stage’s authoring by a shared field value. The engine currently carries it through without acting on it.

Note that the number of authoring runs is not a fixed per-need or per-root count: the engine drives it from the remaining coverage gaps (the heartbeat), so a stage is briefed again only while obligations stay open.

For a stream whose slug is login, the vmodel stages resolve like this:

Stage (scope / granularity)

path template

Resolves to

What it produces

reqs (stream / per-root)

specs/{stream}/requirements.rst

specs/login/requirements.rst

One shared file with all of the stream’s requirements, in one pass.

code (global / global)

src

src

Markers across src/ — one impl need per architecture element.

risks (global / single)

specs/_global/risks.rst

specs/_global/risks.rst

One shared, project-wide file.

(example) (stream / per-need)

specs/{stream}/reqs/{id}.rst

specs/login/reqs/{id}.rst

One file per need — the author fills each {id}.

Implementation and verification as needs

The two lower arms of the V are special: instead of authoring an .rst directive, the stage produces a need from a one-line codelinks marker in your real source. The code stage scans src/ and the tests stage scans tests/:

[[workflow.stages]]
id = "code"
produces = "impl"
depends_on = ["archs"]
author_skill = "draft-impl"

[workflow.stages.route]
scope = "global"
granularity = "global"
path = "src"

[[workflow.stages.trace]]
up = "arch"
link = "implements"
direction = "both"
min = 1

The marker itself is an ordinary code comment, for example in src/login.py:

# @Password hashing, IMPL_PWHASH, impl, [ARCH_AUTH]
def hash_password(raw: str) -> str:
    ...

ubc build materialises that into a real impl need whose implements link points at ARCH_AUTH. The stage’s bidirectional trace edge then counts the arch’s incoming implements_back: an architecture element with no implementing marker fails the gate, and the code stage stays ready until one covers it. The tests stage works identically with verifies markers in your test tree.

The grammar of the marker (its field order and the link-field name) is configured under [codelinks]; see the codelinks guide for the full mechanism. One detail is handled for you: a marker only becomes a need where a .. src-trace:: directive with the matching :project: appears in a built .rst file, and the authoring skills emit that directive alongside the markers — it matters only if you author markers by hand.

The key point: implementation and test coverage are graph facts derived from your code, not fields you maintain by hand.

Note

The alternative way to gate code coverage, [workflow.stages.require_code], checks the presence of a field such as code_url instead of a trace link. This profile prefers the trace edge: it attributes a missing-code gap to the stage that should close it, and it assumes no fixed code-versus-test directory layout. See the require_code reference for the field mechanism and when to prefer it.

The AI runner: [agent.runner]

ubc agent run executes the next stage through a headless AI process. The [agent.runner] table configures which one:

[agent.runner]
command = "claude -p --permission-mode acceptEdits --output-format stream-json --verbose"
model = "sonnet"
timeout_secs = 600

The assembled prompt (recommended skill + context briefing + resolved route) is passed on the process’s stdin; the command defaults to claude -p when the table is absent. The [agent.runner] reference documents each key, the defaults, and the UBC_AGENT_RUNNER* environment overrides.

Note

[agent.runner] only affects the headless ubc agent run path. Driving the loop interactively from Copilot or Claude Code uses that assistant directly and ignores this table.

Reviews: the substance gate

A stage with a review_skill is held until each need it produced carries a fresh, passing review verdict. What “passing” means is configured separately, in the [quality] table: the rubric each reviewed type is scored against (a criteria pack) and the per-axis floors that gate it.

[quality]
enabled = true

[quality.type_configs.req]
criteria_file = "quality/requirement.toml"   # a pack ubc agent install writes for you

[quality.required]
parent_fit = 1                               # the gate: the minimum score per axis

ubc agent install writes this table and the criteria packs. The Reviews and quality analysis guide explains the model, and the [quality] configuration reference documents every key and the criteria-pack format.

Presentation: diagrams and icons

Two optional sub-tables tune how the workflow is presented and gated:

[workflow.diagram]
enabled = false        # keep the diagram gate off...
format = "mermaid"     # ...but tell the runner to author diagrams as Mermaid

[workflow.icons]
req = "req"            # icon shown for each type in the VS Code Workflow view
arch = "arch"
test = "test"

[workflow.diagram] controls the diagram gate (validation of Mermaid/PlantUML blocks) independently of the authoring format hint.

[workflow.icons] maps a need type to one of the built-in, presentation-neutral icon names used by the Workflow view. The icon name is independent of your own type names, so a software_requirement type can render with the req icon. See the full icon reference for every name — from feature, req, and arch to hazard, safety_goal, and component — with the glyph each one renders as.

See also