Skip to content

Cross-project projection

Cross-project orchestration is a first-class run shape, not a single-project run with extra metadata. The cross runner owns a multi-project plan, fans out into per-project sub-pipelines, then validates the integration contract across the whole change before writing one terminal status.

run_cross_pipeline(task, projects, profile)
-> project_cross_profile(profile) # split into global + project steps
-> CROSS-PLAN -> CROSS-VALIDATE-PLAN # global steps
-> extract subtasks by project alias
-> per alias:
write implementation_handoff.{json,md} # JSON canonical
run_pipeline(project_steps, plan_source="cross")
-> contract_check # per-alias gate, runner-owned
-> cross_final_acceptance # system release gate, runner-owned
-> run.end (status = done | failed)

That shape, as a real run — one feature across an api and a web project: the cross plan fans out into per-project sub-pipelines, the runner-owned contract check and cross final acceptance validate the whole change, and both projects are delivered under one terminal status:

orcho cross · one feature across api + webanimated

Recorded from a mock pipeline (—mock) — the cross-run anatomy is exactly what a real run prints. A real cross run takes tens of minutes per project.

This page is the design view. For the operator view — when to use the mode and how to start it — read Cross-project mode.

The run.start event is a discriminated union on run_kind:

run_kindRequired payload
single_projecttask, project, profile; optional parent_run_id, project_alias
cross_projecttask, projects[], cross_mode, profile, plan_source; optional projected_profile

This avoids lossy fields such as project=null or “project OR projects”. A consumer can validate the payload without guessing which run shape it is reading, and the cross topology stays visible in the event stream instead of being flattened into single-project events plus annotations.

The operator supplies one profile; there is no separate sub-profile flag. The cross runner loads it and splits it into global_steps and project_steps using a per-step cross: { scope, handler? } annotation:

ScopeEffect
globalRuns once at the cross level. Optional handler selects the cross-level function (cross_plan, cross_validate_plan).
projectRuns inside each child sub-pipeline.
bothAppears in both lists.
skipOmitted entirely in cross mode.

A global handler preserves the step’s semantic phase name, so loop predicates like until: validate_plan.approved still match. Children run an in-memory Profile built from project_steps — the same profile language drives both levels.

Projection carries coherence rules. LoopStep inner steps must agree on scope; mixed loops are an error. Project-scoped implement or repair_changes requires at least one global plan / validate_plan step to produce a handoff, so the task profile is rejected for cross mode. Profiles without cross metadata stay valid for mono runs; the cross runner rejects them with an actionable error.

contract_check and cross_final_acceptance are not profile steps. The cross runner appends them after all project pipelines finish, and mono runs never invoke them. Keeping them out of child profiles means no projection or profile edit can opt a cross run out of its system boundary.

contract_check runs per alias. Each verdict is recorded in session.phases.contract_check[alias] using the same typed JSON contract as every other reviewer phase — verdict, short_summary, findings, rendered (the generated markdown), and raw_response (the model’s JSON for re-validation). Malformed structured output is downgraded to REJECTED with a parse_error field: the contract is the gate, not prose heuristics.

cross_final_acceptance is the system release gate — one terminal step that answers “can the coordinated multi-repo change ship as one system?”, distinct from per-alias contract matching. It runs in two paths:

  • Precondition path (no agent call) — synthesises a REJECTED release verdict when any upstream signal already blocks ship.
  • Agent path — preconditions pass, so the cross reviewer is invoked with the cross plan, per-project release verdicts, and contract-check results, parsed via parse_release.

Each precondition violation becomes a named release blocker:

Blocker id prefixCause
CFA_MISSING_CHILD_<alias>Child sub-pipeline crashed or never produced a sub-session entry.
CFA_MISSING_RELEASE_<alias>Child sub-session has no final_acceptance entry or release fields.
CFA_CHILD_REJECTED_<alias>Per-project final_acceptance.ship_ready == false.
CFA_CONTRACT_REJECTED_<alias>contract_check[alias].verdict == "REJECTED".
CFA_PARSE_ERROR_<alias>Parse error in upstream final_acceptance or contract_check.

The runner sets session.status in exactly one place, after the gate records its phase entry. contract_check does not write terminal status in full cross runs; it produces results the gate consumes through preconditions.

After cross planning approves, the runner writes per-child implementation_handoff.{json,md} under <run_dir>/<alias>/. The JSON is the source of truth: write_handoff validates the typed Handoff and returns the JSON path, which is passed to the child run_pipeline via handoff_path. The .md is a derived audit view rendered from the same typed object — not the data channel. The child loads and re-validates the JSON, then renders the prompt body from the typed object.

v1 handoffs carry full_cross_plan_markdown, project_subtask, cross_validation_summary, and sibling_aliases. The source project_path is retained in the JSON for audit only and is never rendered into the runtime body; the authoritative path is the child worktree from its own context block. A handoff is required exactly when the projected profile contains implement or repair_changes — review-only profiles run without one.

Each child run’s run.start carries two extra fields:

  • parent_run_id — the parent cross run’s directory name;
  • project_alias — the alias from the parent’s projects[] manifest.

Both are optional but paired: the schema validator rejects half-set payloads. With them, an MCP or evidence consumer can rebuild the full parent → children → phases timeline from events.jsonl alone, without reading meta.json or inferring filesystem layout.

Each participating repo carries its own decisions: a per-project final_acceptance release verdict (ship_ready) from its sub-pipeline, and a per-alias contract_check verdict at the cross level. The system gate consumes both, so a rejection in one repo surfaces as a named CFA_* blocker for that alias rather than an undifferentiated cross failure — and the coordinated change ships only when the gate approves the whole set.

The canonical engineering doc lives with the code: