Run Project — Codex Router

SkillDev tools

Codex-native router for executing HQ PRD stories. Default inline dispatches into pooled per-worker lanes claimed through conduct-pool.sh, typically 3-4 live lanes (preflight explorer, story worker, regression gate), never more than CONDUCT_POOL_CAP (default 8), regardless of how many stories the PR

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Run Project — Codex Router skill

What this skill tells your AI

The instructions your AI receives, as published by indigoai-us/hq-core in .claude/skills/run-project/SKILL.md and read by ahel’s review.

Codex does not use Claude Code's Task, Plan sub-agents, ExitPlanMode, /checkpoint, or /compact primitives. The default path needs none of them: every story is a detached lane. Where a host has no engine CLI and falls back in-session, Codex's spawn_agent / wait_agent map the per-story boundary to a worker agent and the read-only preflight to an explorer agent.

Live children: typical 3–4, worst case CONDUCT_POOL_CAP (default 8). Every agent this skill dispatches is a pooled lane claimed through core/scripts/conduct-pool.sh and dispatched as a detached process per .claude/skills/_shared/lane-dispatch-protocol.md, so the count is set by the pool, not by the number of stories — a 40-story PRD opens no more lanes than a 4-story one. Story coordinators claim story:{worker-id}; the phases inside them claim the bare worker id, so a story can never block on a lane it is holding itself. --interactive runs in the parent and claims none.

User's input: $ARGUMENTS

Script Resolution

Resolve the shell orchestrator before any shell delegation:

  1. Prefer core/scripts/run-project.sh if it exists.
  2. Otherwise use .claude/scripts/run-project.sh.
  3. If neither exists, stop with a clear error.

Store the chosen path as {run_project_script}. In this HQ workspace today, the expected path is .claude/scripts/run-project.sh.

Work Mesh Live — trusted bind (do this first)

Before any other tool call that touches project work, bind the session per .claude/skills/_shared/work-mesh-live-bind.md (US-011):

bash core/scripts/work-mesh-live-bind-trusted.sh \
  --company "{co}" --project "{project}" --task "{task}"

Omit --task when unknown. This writes workspace/sessions/<sid>/meta.yaml and reconciles with observation.trustedContext (no --trusted CLI flag).

Step 1 — Parse Arguments

Extract from $ARGUMENTS:

  • {project} — project name, required unless --status or --help
  • --status — show orchestrator status, then stop
  • --dry-run — show story order, then stop
  • --resume — pass through to the chosen execution path
  • --inline — the default: one detached lane per story, pooled (Step 3). The name is historical; it means story-delegated, as opposed to --interactive's parent-driven editing. It has not meant "in the parent session" since stories became lanes.
  • --interactive or --session-mode — parent-driven Codex execution
  • --ralph-mode — the same pooled lane loop as default, run unattended: skip the preflight approval, auto-advance through every story without between-story pauses, and report once at the end
  • --in-place — (ralph) skip feature-branch pre-creation; work on the current checkout
  • --timeout N — (ralph) per-story wall-clock budget in minutes before a story is marked blocked: TIMEOUT. This is the waiter's deadline file (dispatch protocol §4-§5), not the runner's timeoutSecs — that one only warns and never kills, so it cannot bound anything on its own.
  • --resume continues from the next incomplete story (read from state.json)

If no execution mode is supplied, route to --inline. This is the default because it preserves the Ralph story loop and keeps implementation context out of the parent session — each story runs in its own detached lane, so the parent holds coordination state only.

Use --interactive only when the user asks to steer edits directly in the parent session; it is the one mode that does not dispatch lanes at all. Use --ralph-mode for long unattended runs where you do not want to be prompted between stories — it executes the identical worker-authoritative lane loop, just without the pauses.

Recommendation posture: always recommend --inline. It runs continuously — no pause between stories — except when either condition holds:

  1. The PRD has more than 10 incomplete stories → pause at the preflight plan for one approval, then run continuously.
  2. A story requires session mode (its notes or classification demand parent-driven interactive steering) → pause before that story only, run it interactively, then resume continuous flow.

Everything else auto-continues exactly like ralph-mode. Failed or blocked stories still stop the queue.

Note on the legacy detached orchestrator — not the same thing as a lane. Earlier versions of this skill launched a detached shell subprocess (nohup bash run-project.sh … --engine claude) that ran one headless claude -p builder per story, outside the pool and outside the worker system. That path is retired: run-project.sh's execution loop is frozen and kept only for --status, --dry-run, and --help; it no longer runs stories, and it has no claude builder. The --engine/--builder, --swarm, --tmux, --codex-autofix, and --no-monitor flags belonged to it and are not accepted.

Today's lanes are a different mechanism and share none of that: they are pooled, capped by CONDUCT_POOL_CAP, worker-authoritative, engine-chosen from the dispatch protocol's roster, and each one runs /execute-task rather than a bare headless builder. "Detached" describes both, which is why this note exists — do not read the retirement above as a claim that stories run in the parent session.

Step 2 — Status, Help, and Dry Run

Use {run_project_script}:

bash {run_project_script} --status
bash {run_project_script} --help
bash {run_project_script} --dry-run {project}

Display the important output to the user and stop.

Step 2.5 — Design-Lock Check (soft gate)

Before execution, read the project's prd.json and check metadata.designLocked.

  • If designLocked: true (or the project has no UI surface) — proceed silently.
  • If the PRD is UI-bearing (stories reference screens, pages, components, or metadata.designRef/audiences imply a user-facing interface) and designLocked is absent or false — surface a one-line soft warning: "Heads up — design isn't locked for {project}. Consider /storyboard {project} first to lock visuals and fold any design changes into the PRD before building. Proceed anyway? [Y/n]"

This is advisory, not a hard stop — many projects (CLIs, infra, libraries) have no visual surface and should proceed without friction. Honor an explicit "yes/proceed" and continue.

Step 3 — Default Execution: Pooled, Detached Lanes

Default mode is story-delegated, pooled, and detached. The parent session plans and coordinates; each story runs in a lane claimed from the session pool — one live lane per HQ worker id, reused across stories rather than a fresh child per story — that invokes /execute-task {project}/{story-id} internally.

A lane is a detached OS process, not an in-session sub-agent. Dispatch follows .claude/skills/_shared/lane-dispatch-protocol.md, the same mechanism /conduct uses: a brief on disk, a detached workflow-runner.mjs process, and a background waiter. Three things follow from that, and they are the reason this is the default:

  • Work in flight survives the parent session. Compaction, a restart, or an ended session no longer kills the running story — the previous in-session loop died with its parent, mid-story, with the slot still marked running and the work lost. Be precise about the scope: the story survives, the coordinator does not. The waiter, the validation and the dispatch of the next story are all parent-side, so a run that loses its session finishes the in-flight story and then waits for a new session to resume it (Step 5). It is resumable, not self-driving.
  • A story can be corrected while it runs (dispatch protocol §6) instead of being killed and relaunched.
  • A host with no in-session sub-agent primitive can still run a project.

Inside the lane, /execute-task maps its worker phases to that engine's own sub-agents (spawn_agent under Codex, Task under Claude) per .claude/skills/execute-task/SKILL.md. Those phases are children of the lane, not of this session, and they claim their bare-worker-id slots from the same pool — which is why the lane must carry HQ_SESSION_ID (dispatch protocol §4).

Resolve the engine once, before the first dispatch (dispatch protocol §2): an engine the user named, else codex, else the first of grok/claude that resolves. Record it in workspace/orchestrator/{project}/state.json and reuse it for the preflight, every story, and the regression gate.

CONFIRM the engine, model and effort with the user before the first dispatch. Resolution above picks a default; it is not a decision the user has made. A run is many hours of lane time on whatever model this picks, and the wrong pick is only visible once the work comes back — so ask once, up front, and never silently default into a long run. Skip the question ONLY when the user named an engine or model in the invocation itself.

Ask with a single AskUserQuestion (text fallback per core/policies/hq-codex-decision-gate-fallback.md) naming concrete options — the resolved default first, labelled as such — and covering engine, model, and reasoning effort together. Then record all three in state.json as engine, engine_model, and engine_effort, and pass them on every lane dispatch.

Passing model and effort to a lane (dispatch protocol §4's agent() opts):

EngineModelEffort
codexmodel: opt, else tier map (gpt-5.6-sol plan / gpt-5.6-terra exec)effort: opt → -c model_reasoning_effort=…
grokmodel: opt, else grok-4.6effort: opt → --reasoning-effort
claudemodel: opt, else tier map (opus plan / sonnet exec)not wiredworkflow-runner.mjs skips effort for claude. Pass it yourself as extraArgs: ["--effort", "low"]; the installed CLI accepts low|medium|high|xhigh|max

Do not assume effort reaches a claude lane through the normal opt. It does not, and a run asked for at low effort will quietly execute at the CLI default.

Record the session id next to it, in the same write. Both the pool and the run dirs are keyed by the session that created them: conduct-pool.sh resolves the session from the environment, and the protocol mints run dirs under the session id (dispatch protocol §3). A later session therefore looks in its own pool, finds nothing, and concludes the run has no live lanes — while the original slots are still running and the original lane is still committing. That is how a story gets dispatched twice. So:

So write two fields, not one: engine, and session_id set to the output of bash core/scripts/hq-session.sh current. Use jq and a temp file rather than editing the JSON in place.

Write session_id before the first dispatch, and never overwrite it on a resume — it names the session that owns the lanes, not the session reading the file. Re-resolving per lane gives a run whose stories ran on different models — results that are not comparable and a failure you cannot attribute. /run-project takes no --engine flag; that flag belonged to the retired subprocess loop.

Fallback — no engine CLI. If no codex/grok/claude CLI resolves, detached lanes are unavailable on this host: say so plainly, then run the story loop in-session with spawn_agent/wait_agent (or Task) exactly as described in 3b's fallback note. Everything else in this step — the pool claim, the return contract, the retry, the proof gates — is identical on both paths. Do not discover a missing engine once per story.

3a. Preflight Plan

The preflight explorer is a named pool slot, not a throwaway. Claim and validate it exactly as .claude/skills/_shared/pool-lane-protocol.md describes — assign, honour exits 3 and 4, mkdir -p the slot dir, check owner.json before the spawn/resume split, and record running immediately after launch — a lane left claimed while work is live is one cancel may retire as undispatched (pool protocol §4):

bash core/scripts/conduct-pool.sh assign --worker-id "explorer" --task "{project} preflight"

The ownership check is not optional here just because the id is a constant — it is more necessary. explorer is the same lane id for every project and every company, so a second /run-project in one session resumes the first one's planning transcript unless the stamp is checked. On a company or project mismatch: cancel (not recycle — nothing has been dispatched into this claim yet), clear, re-stamp, dispatch cold. Then:

Dispatch it as a lane per .claude/skills/_shared/lane-dispatch-protocol.md ({caller} = run-project, {lane} = explorer — the protocol mints the run dir; do not spell one here — {tier} = plan — this is analysis, not execution, and the protocol's tier is a caller-supplied placeholder precisely so this lane does not inherit exec). The brief:

Read and analyze the PRD for {project}. Resolve prd.json, identify incomplete
userStories, sort by dependencies then priority then array order, classify each
story using /execute-task rules, identify worker sequences, read applicable hard
policy rules, and return only a concise markdown implementation plan followed by
a JSON block with project, company, repoPath, ordered_stories, hard_policies,
quality_gates, and resume_from.

Wait on it with the protocol's background waiter, then read the plan out of agent-1.result.json per protocol §7 — jq -r '.value', never lane.log, which also carries the runner's narration. On the no-engine fallback, the same brief goes to spawn_agent({agent_type: "explorer", reasoning_effort: "low", …}) + wait_agent(...) instead.

Display the plan. If the queue has ≤10 incomplete stories and none require session mode, proceed directly into the story loop without an approval stop. Otherwise (>10 stories, or session-mode stories present) ask the user to approve, adjust, switch to --interactive, switch to --ralph-mode, or stop. If structured question tooling is unavailable, use the plain-text fallback required by core/policies/hq-codex-decision-gate-fallback.md.

3b. Story Loop

For each approved incomplete story:

  1. Announce story ID, title, and planned worker sequence.

  2. Perform only lightweight parent orchestration: branch setup, state file update, and best-effort Linear sync.

  3. Claim the story worker's lane, then dispatch into it. Classify the story to an HQ worker id first (same classification /execute-task uses), then:

    bash core/scripts/conduct-pool.sh assign \
      --worker-id "story:{worker-id}" --task "{project}/{story-id}"
    

    The story: prefix is not cosmetic. This wrapper does no domain work; it runs /execute-task, whose phases claim the bare worker id in their own assign. A wrapper holding backend-dev would send its own api_development phase to exit 4 — waiting for a lane the wrapper itself is holding, with the wrapper waiting on that phase. Neither ever finishes. Coordinator lanes live in the story: namespace; phase lanes use the bare id; they can never collide. The delimiter is a colon, not a slash: conduct-pool.sh restricts worker ids to [A-Za-z0-9._:-], so story/backend-dev exits 1 and no coordinator lane is claimed at all.

    Then follow .claude/skills/_shared/pool-lane-protocol.md for the slot — the exit codes (3 and 4 both mean wait), the mkdir -p and owner.json check before the spawn/resume split — and .claude/skills/_shared/lane-dispatch-protocol.md for the dispatch itself. record --status running against the run-dir basename immediately after launch; a lane left claimed while work is live is one cancel may retire as undispatched (pool protocol §4). record … --status idle the moment the lane exits — before reading its JSON and before deciding whether to retry. Step 3b.4's one retry on malformed JSON re-dispatches through assign, so a lane released only after the JSON validates sends exactly that retry to exit 4, waiting on a coordinator that has already finished.

    {caller} = run-project, {lane} = {story-id} — the protocol mints the run dir, session-scoped and collision-free; do not spell a path here. Pool lane id: story:{worker-id}. {tier} = exec. deadline = now + the per-story budget (--timeout N minutes, default 20). The brief is the prompt below.

    {"action":"spawn",...} means dispatch a fresh lane with the full prompt.

    {"action":"resume",...} means that coordinator already has an idle lane holding everything it learned on earlier stories of this class. A lane is a process that has already exited — there is nothing to reattach to — so do not treat this branch as unimplementable and do not leave it. A coordinator that cannot act on a resume leaves the slot stuck and every later claim for that worker exits 4. Use the pool protocol's disk-backed restart: dispatch a new lane into the same slot, with the full prompt below prefixed by

    You are resuming your own lane. Every story you already ran in this session is
    recorded in {slot dir}/handoffs.jsonl — read it first and do not redo anything
    it shows as done. That file belongs to this session and this company only; if
    it is absent, start from the brief.
    

    then record the new run-dir basename against the same slot. The cap counts slots, not restarts. Append each validated story JSON to that file so the next restart can see it.

    Two stories that classify to the same worker id share one coordinator lane and therefore serialize on it. That is the intended behaviour, not a stall — their phases would have serialized on the bare worker lane anyway.

The brief written to the lane's brief.md (on the no-engine fallback, the same text is the message: of spawn_agent({agent_type: "worker", reasoning_effort: "low", …}) followed by wait_agent(...)):

Execute story {project}/{story-id} by running /execute-task {project}/{story-id}.

You are not alone in the codebase. Own only this story and its declared files;
do not revert edits made by others; adapt to existing changes you encounter.

When /execute-task asks for a sub-agent, use your engine's own runtime adapter so
the nested HQ worker phases run as isolated agents inside this lane. They claim
their phase slots from the same session pool this lane was granted from.

Commit your story work before returning. If your runtime returns an integration
patch instead of a parent-visible commit, say so in notes and list every changed
path.

RETURN CONTRACT: json

Return ONLY this JSON object — no prose, no markdown fences, nothing before or after:
{
  "status": "passed" | "failed" | "blocked",
  "story_id": "{story-id}",
  "commits": ["<short-sha>", ...],
  "files_changed": <int>,
  "back_pressure": {
    "tests": "pass" | "fail" | "skip",
    "lint": "pass" | "fail" | "skip",
    "typecheck": "pass" | "fail" | "skip",
    "build": "pass" | "fail" | "skip"
  },
  "workers_run": ["architect", "backend-dev", ...],
  "evidence": ["repo-path:src/lib/foo.ts", "path:companies/{co}/projects/{project}/report.md", "url:https://...", ...],
  "notes": "<1-2 sentence summary; include blocker description if status != passed>"
}
"evidence" lists what you actually produced, in these forms: path:<hq-relative> ·
repo-path:<relative to the repo> · branch:<name> · url:<https://…>. Commits go
in "commits" and count as evidence automatically. List only things that exist —
every item is verified on disk/git before the story is marked done, and a claim
that does not exist fails the story. An empty list is allowed.

Arm the protocol's background waiter on the lane. The story's JSON arrives in the run dir, not as a tool return, and not in lane.log — read it per protocol §7:

raw="$(jq -r '.value' "{run dir}/agent-1.result.json")"   # unwrap the runner envelope
printf '%s' "$raw" | jq -e '.status, .workers_run, .evidence'

lane.log is the runner's whole stdout — narration plus JSON.stringify(result), and because the story worker is schema-less that result is a JSON string holding the story JSON. jq -e . against lane.log therefore passes while .status, .workers_run, and .evidence all come back empty, so step 4 would wave through a story it never actually read and step 5's worker-proof gate would reject a worker that did run its phases. Two jq calls, on agent-1.result.json, not one on the log.

  1. Validate $raw — the unwrapped value, not the log — as JSON with jq -e . (or equivalent). If invalid, retry exactly once with this stricter prompt addition: Your previous reply was not valid JSON. Emit ONLY the JSON object specified above. No prose, no fences, no trailing newline. If still invalid, mark the story blocked with reason INVALID_RETURN_FORMAT, surface to user, do NOT advance to the next story. (Enforced by ralph-orchestrator-context-discipline.)
  2. Enforce the worker proof gate: passed stories must include at least one real HQ worker ID in workers_run; reject placeholder-only values like codex, worker, general-purpose, or commit.
  3. Verify commits are parent-visible with git log --oneline -n {len(commits)}. If the worker produced an integration patch instead of a visible commit, review/integrate it in the parent and create the story commit before continuing.
  4. Verify the worker's evidence and mark the story. Run bash core/scripts/verify-story-deliverables.sh --prd <prd.json> --story <id> [--repo <repoPath>] --evidence-json '<reply.evidence>' --commits-json '<reply.commits>' --write. Exit 0 records the verified references on the story as evidence[] (audit trail) and you may write passes: true. Exit 3 names what does not exist — a claimed path/branch/commit/URL that is missing, or a deliverable the PRD declared that was never produced — and the story is NOT done: do not write the flag, treat it like a failed back-pressure check. A worker that returns no evidence and a PRD that declares none is allowed (the story is marked done but unverified); this gate is deliberately not strict. Mark passes: true only after status is passed, worker proof passes, back-pressure is acceptable, commit verification succeeds, and this evidence check exits 0.
  5. Update workspace/orchestrator/{project}/state.json. Record the story's run dir on the story entry as you dispatch it, not after it returns — a run that loses its session mid-story is exactly the case where the path is needed, and it is the only record of which lane belongs to which story.
  6. Narrate one line per story to the user: [{story_id}] {status} · {files_changed} files · {first_commit_short_sha}. Anything longer goes to workspace/threads/journal/<date>/<story-id>.md, not the parent transcript.
  7. Auto-continue to the next incomplete story until the queue is empty or a story is failed/blocked (then surface and stop). Pause only per the recommendation-posture exceptions in Step 1: >10 incomplete stories (single preflight approval) or a story that requires session mode (pause before that story only).

3c. Regression Gates

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
85
Forks
16
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
run-project
Source
github.com/indigoai-us/hq-core