---
description: Pick up a GitHub issue and run the full investigate-build-review-merge pipeline
argument-hint: "[issue number or \"next\" to pick highest priority]"
---
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->

# /work-on — Full Issue Pipeline

**Input**: $ARGUMENTS

Orchestrator for the full issue lifecycle: investigate → decompose (if needed) → build → review → merge → close. GitHub issues are the persistent context layer — read existing comments before starting, write structured reports back, use `workflow:*` labels to track state.

**Agent model policy**: `model: "{DEFAULT_MODEL}"` — resolved from forge.yaml `agents.default_model`, else "sonnet" (standard tier). Fallback: `model: "opus"` if rate-limited. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
**NEVER use plan mode (EnterPlanMode).**
**NEVER use the Agent tool** — this spec uses `Skill(...)` for sub-phase dispatch. The Agent tool spawns opaque subprocesses that bypass phase protocols, skip FORGE annotations, and cannot be constrained by allowed-tools. Always use `Skill(skill="...", args="...")` for sub-phase invocations.

<!-- FORGE:SPEC_LOADED — work-on.md loaded and active. Agent is bound by this spec. -->

## HARD RULES — READ BEFORE ANYTHING ELSE

1. **Every sub-phase MUST be invoked via `Skill(...)`.** You do NOT implement inline. You invoke `Skill(skill="work-on/investigate", ...)`, `Skill(skill="work-on/build", ...)`, etc. The Skill tool invocation is what triggers label updates, FORGE annotations, and structured output. Without it, the phase has no paper trail.

2. **Write to GitHub after EVERY phase.** Every FORGE annotation (HEARTBEAT, INVESTIGATOR, CONTRACT, BUILDER, etc.) must be posted before the next phase starts. A phase that completes without a GitHub write is effectively invisible to the stall detector and future sessions.

3. **Follow the Universal Phase Dispatcher.** The phase sequence table is the SINGLE source of truth for transitions. Do NOT skip phases, do NOT reorder phases, do NOT treat intermediate completions as terminal. Only the terminal states listed in the Dispatcher allow stopping.

4. **PRs NEVER target `main`.** Target `staging` (fast lane) or `milestone/{slug}` (feature lane). A PR to main is a pipeline violation regardless of what the issue description says.

### Compaction Resilience

1. Write state to GitHub after EVERY significant step
2. Read full GitHub state (issue body + comments + labels) ONCE, in Phase 0B. Carry those values in-context for the rest of the run — do NOT re-fetch them at later phase boundaries. The only trigger for a fresh re-read of something already read this session is a **detected compaction**: if a later phase needs a value (issue body, a specific FORGE:* comment, labels) that this session does not actually have in its visible context — because compaction dropped the earlier tool output, or this is a brand-new session resuming mid-pipeline — re-fetch ONLY that missing value, not the full state.
3. After compaction: re-read issue (body + comments + labels) to reconstruct state
4. Key principle: A NEW session running `/work-on {number}` should pick up where the last left off by reading GitHub state alone

**Session state cache convention**: Phases 1A.5, 3A, and 5A previously re-ran `gh issue view`/`gh api .../comments` unconditionally "for safety." That default is now inverted: reuse the value if this session already produced it (e.g., you already have `ISSUE_BODY` from Phase 0B, or already read the FORGE:INVESTIGATOR comment during Phase 1). Only issue the `gh` call if the value is genuinely absent from context. This is what makes re-reads free for a normal single-turn run while remaining exactly as resilient across a real compaction or resume. (Phase 6A's body read is a deliberate exception — see its note — because it immediately precedes a body-mutating write and Phase 5 can involve an external `/review-pr` process.)

### Orchestration Flag

`UNDER_ORCHESTRATION` — resolved once in Phase 0A. Defaults to `false` (solo run). Set to `true` when the invocation args include `--under-orchestration` (this is how `/orchestrate` dispatches `/work-on`; see `commands/orchestrate/phase-4-execution.md`). This flag gates the 4 heartbeat comments below — they exist solely to feed `/orchestrate`'s Step 4B.5 time-based stall detector (which reads the last comment's `updated_at`) and have no consumer in a solo run.

### Universal Phase Dispatcher

<!-- FORGE:DISPATCHER — This is the SINGLE source of truth for phase transitions. Every phase boundary references this section. -->

**Phase sequence** (canonical order):

| Step | Phase | Entry Condition | Terminal? |
|------|-------|-----------------|-----------|
| 1 | Phase 0: Resolve Issue | Always first | No |
| 2 | Phase 1: Investigation | No `INVESTIGATION:COMPLETE` comment | No |
| 3 | Phase 1D: Route | Investigation complete | No |
| 4 | Phase 2: Decomposition | decompose: YES | Yes (spawns sub-issues) |
| 5 | Phase 3: Build (3A–3M) | `workflow:ready-to-build` or `workflow:building` | No |
| 6 | Phase 4: PR Creation | Builder comment posted, no PR exists | No |
| 7 | Phase 5: Auto-Review | PR exists, `workflow:in-review` | No |
| 8 | Phase 6: Close & Cleanup | PR merged | No |
| 9 | Phase 7: Trajectory | Issue closed | Yes |

**Phase 3 sub-phase sequence** (execute in order; 3C.5 and 3C.6 are conditional — see Phase 3B):

3A → 3B → [3C → 3C.5* → 3C.6*] → 3D → 3E → 3F → 3F.5 → 3G → 3H → 3I → 3I.5 → 3J → 3K → 3L → 3M

*3C.5 and 3C.6 are skipped for TRIVIAL tasks; 3C (Builder Contract) is still required. Investigation tasks exit at 3B before 3C.

**Universal continuation rule**: After ANY phase or sub-phase completes, check whether a terminal state has been reached. Terminal states are:
- `workflow:merged` label is set
- `workflow:invalid` label is set
- `needs-human` label is set
- `workflow:awaiting-merge` label is set (remediated + re-reviewed, awaiting a human merge decision — see #1810) <!-- Added: forge#1810 -->
- `workflow:decomposed` label is set (sub-issues spawned)
- Issue state is CLOSED with terminal label

**If the current state is NOT terminal: proceed to the next phase in the sequence immediately. Do NOT stop. Do NOT emit a summary. Do NOT treat any intermediate phase completion as a terminal signal.** Every phase completion — investigation done, quality gate passed, PR merged, review complete — is an intermediate result. Only the terminal states listed above allow stopping.

**Adding a new phase**: Insert it into the phase sequence table above and the sub-phase sequence if it belongs to Phase 3. No per-boundary transition code is needed — the universal continuation rule handles all transitions.

---

## Spawn-Decision Policy

<!-- FORGE:SPAWN_POLICY — Canonical spawn-decision table. Sibling specs (orchestrate.md, review-pr.md) link to this section. Sub-issues #1276–#1279 reference this table. -->

**Default: run inline.** Every skill, phase, and sub-agent runs inline in the current context unless one of the four criteria below explicitly applies. A sub-agent buys exactly three things — parallelism, context isolation, and (below) prompt-cache preservation. If none is needed, forking is waste.

### Spawn-Decision Table

| Row | Criterion | Fork? | Example |
|-----|-----------|-------|---------|
| a | **Parallel fan-out** — two or more independent work units can execute concurrently and the total wall time saving justifies the fork overhead | YES — spawn one sub-agent per work unit | `/orchestrate` dispatching multiple `/work-on` agents; `review-pr` spawning domain-specific reviewers in parallel |
| b | **Fresh-context isolation** — the work unit is a structured review or audit whose value depends on seeing the artefact without the builder's accumulated context bias, AND the review result is load-bearing for the merge decision | YES — spawn a dedicated sub-agent | Phase 5C review-fork when build context is large (see Row c for the quantitative threshold) |
| c | **Parent context near overflow** — the parent agent has made ≥20 Skill invocations OR the build changed ≥10 files, meaning delegating review inline risks a mid-review token overflow | YES — spawn a fresh sub-agent for review | Phase 5C: `Skill(skill="work-on/review", …)` instead of direct `review-pr` invocation |
| d | **Prompt-cache TTL** — the sub-operation (multi-domain review, multi-iteration quality-gate) is expected to run for several minutes, longer than Anthropic's ~5-minute prompt-cache TTL. Leaving it inline lets the parent's already-large accumulated context (investigation/contract/context/architect/implement annotations) sit idle past the TTL; the parent's next turn then re-hydrates that entire context **uncached**, roughly doubling effective token cost for that turn. This is independent of build size — a 1-file fix idles the parent exactly as long as a 20-file one once the sub-operation starts running | YES — spawn a fresh sub-agent, **unconditionally**, regardless of file count or Skill-invocation count | Phase 5C review (always forks — Row d supersedes Row c's threshold as the controlling reason); Phase 3G quality-gate loop (forks under Row d even though it never qualified under Row c) <!-- Added: forge#1825 --> |

**If none of the four rows match: run inline.** Do not fork for convenience, narrative clarity, or to avoid reading a large file. Context cost of a fork (spawning, context reconstruction, result aggregation) is paid every time, even when parallelism, isolation, or cache preservation adds no value.

### Depth Budget

**Available depth**: 5 levels (since Claude Code v2.1.172).

**Target depth for a standard run**: ≤ 3.

| Depth | Agent | Notes |
|-------|-------|-------|
| 1 | `/orchestrate` | Top-level dispatcher — never implements directly |
| 2 | `/work-on` | Issue pipeline — runs build phases inline |
| 3 | Parallel reviewers (Row a/b/c/d fork); quality-gate loop (Row d fork) | Domain review agents spawned by Phase 5C; quality-gate sub-agent spawned by Phase 3G |

**Build phases (3A–3M) run inline at depth 2** — they are sequential sub-phases of `/work-on`, not independent agents. Forking the build into a sub-agent (depth 3) is a violation of Row a/b/c/d unless the build itself fans out independently scoped work units, **except** the Phase 3G quality-gate loop, which forks unconditionally under Row d (cache-TTL economics) regardless of build size. <!-- Updated: forge#1825 -->

**Depth 4–5 are reserved** for exceptional cases (e.g. an orchestrate agent that itself spawns an orchestrate agent for a sub-milestone). Agents that reach depth 4 MUST log a justification comment on the relevant issue.

### Phase 5C Cross-Reference

The Phase 5C review fork is now unconditional, controlled by **Row (d)** (prompt-cache TTL economics — independent of build size). It was previously gated by **Row (c)** alone (≥10 changed files OR ≥20 Skill invocations, i.e. parent-context-overflow risk); that threshold is preserved as a *documented, non-gating* reason the fork is also correct for large builds, but no longer determines *whether* the fork happens — Row (d) means it always does. <!-- Updated: forge#1825 (previously: "quantitative thresholds are not changed") -->

### Phase 3G Cross-Reference

The Phase 3G quality-gate loop forks unconditionally under **Row (d)**: it scans 14+ domains across up to 3 iterations, long enough to idle the parent's accumulated context past the prompt-cache TTL regardless of how many files changed. Unlike Phase 5C, quality-gate never had a Row (c) (file-count) exception — Row (d) is the sole justification for forking it. <!-- Added: forge#1825 -->

### Model and Effort Tiering — What Actually Applies

<!-- FORGE:MODEL_TIER_NOTE — Canonical explanation of the real vs. aspirational tiering mechanism. Every work-on/*.md "Agent model policy" line cross-references this section instead of restating it. -->

Every `work-on/*.md` sub-phase file carries an "Agent model policy" line naming a `model` and an `effort`. These are two different mechanisms with two different scopes, and only one of them changes anything for an in-process `Skill()` call:

- **`effort` is real and applies per `Skill()` invocation.** It is genuine reasoning-depth tuning on the model already running the session, gated correctly on Claude Code >= 2.1.154. Setting `effort: low` on a sub-phase file that is mechanical end-to-end (deterministic label edits, annotation posting, board sync — no root-cause analysis, no architecture planning) is a real, no-fork-required cost reduction.
- **`model` overrides are NOT functional for `Skill()`-dispatched sub-phases.** The `Skill` tool executes "within the main conversation" — it has no model parameter and does not fork a new agent/session, so a sub-phase file cannot switch the model that's already generating the current run. The only tool with real model-override semantics is `Agent(model=...)` (see `tool_param_value_permission_rules`, Claude Code >= 2.1.178) — and HARD RULE #2 above explicitly forbids using the `Agent` tool for `/work-on` sub-phase dispatch, to keep the FORGE-annotation paper trail and phase protocol intact. In practice, the only place a `/work-on` run's model is genuinely chosen is `/orchestrate`'s single `Agent(model=..., ...)` spawn for the entire run (see `commands/orchestrate/phase-4-execution.md`) — every internal `Skill()` call inside that run inherits that same model, with no per-sub-phase override.

**What this means concretely**: a sub-phase file's "Agent model policy" line documents *effort* tiering that genuinely takes effect, plus a *model* value that is aspirational/no-op unless that file is one day dispatched via `Agent(...)` instead of `Skill(...)`. Because mechanical bits (label transitions, `FORGE:CHECKPOINT` writes, heartbeat posts, task-type classification) are interleaved with reasoning-heavy content within the same `Skill()`-invoked file in most sub-phases, they cannot be selectively downtiered without either degrading the reasoning phases sharing that file, or extracting them into a brand-new fork — which the Spawn-Decision Policy above correctly discourages for operations this small (a single `gh issue edit` call does not clear Row a/b/c/d). Do not add a `model: "haiku"` claim to a sub-phase file expecting it to have effect; only add `effort: low`, and only when the whole file is mechanical end-to-end. <!-- Added: forge#1827 -->

---

## Pipeline Rules

- **NEVER merge to main.** PRs target `staging` (fast lane) or `milestone/{slug}` (feature lane).
- **`Closes #N` does not auto-close for non-default-branch PRs.** You MUST explicitly `gh issue close`.
- **Review findings are NOT merge blockers.** They become separate issues.

---

## Project Configuration

Read `forge.yaml` from the repository root before processing any issue.

If `forge.yaml` is missing: stop and tell the user to run `npx forgedock init` to generate it.

**Resolve these values from `forge.yaml`**:

| Variable | Source field | Notes |
|----------|-------------|-------|
| `GH_REPO` | `project.owner` + `/` + `project.repo` | e.g. `acme-org/acme-platform` |
| `GH_FLAG` | `-R {GH_REPO}` | Passed to all `gh` commands |
| `REPO_PATH` | `paths.root` | Absolute path to repo root |
| `WORKTREE_BASE` | `paths.worktree_base` | Base dir for git worktrees |
| `STAGING_BRANCH` | `branches.staging` | Fast-lane PR target |
| `PROJECT_BOARD_OWNER` | `project_board.owner` (or `project.owner` as fallback) | For `gh project` commands |
| `PROJECT_BOARD_NUMBER` | `project_board.project_number` (or `1` as fallback) | Project number in `gh project` commands |

**Multi-repo routing** (when `forge.yaml → repos` section is present):

Parse issue input for a prefix (`<prefix>:<number>`). Look up `<prefix>` in `forge.yaml → repos.satellites[]`. Use that satellite's `repo` and `staging_branch` as `GH_REPO` and `STAGING_BRANCH`. If no prefix is given, use the default (`project.owner/project.repo`).

If `forge.yaml → repos` is absent, only the default repo is available — prefixed issue numbers are invalid.

Satellite repos (those without a `staging` branch) receive fast-lane PRs directly to `main`.

---

## Phase 0: Resolve Issue & Load Context

### 0.0: Pre-Flight Checks (MANDATORY — run before any other Phase 0 step)

Validate the environment before the pipeline spends tokens. Each check fails fast with an actionable error and a pointer to the troubleshooting guide (`docs/site/troubleshooting.md`). Run all checks; report every failure, then STOP if any HARD check fails. <!-- Added: forge#1149 -->

```bash
PREFLIGHT_FAILED=0

# Check 1 — forge.yaml present (HARD)
if [ ! -f forge.yaml ]; then
  echo "ERROR: forge.yaml not found in the repository root."
  echo "  Fix: run \`npx forgedock init\` to generate one, or copy forge.yaml.example."
  echo "  See: docs/site/troubleshooting.md#1-forgeyaml-not-found"
  PREFLIGHT_FAILED=1
fi

# Check 2 — yq installed; forge.yaml is valid YAML (HARD, only if present)
if [ -f forge.yaml ]; then
  if ! command -v yq >/dev/null 2>&1; then
    echo "ERROR: yq is not installed. The pipeline requires yq to parse forge.yaml."
    echo "  Fix: install yq — https://github.com/mikefarah/yq#install"
    echo "  See: docs/site/troubleshooting.md#2-forgeyaml-has-a-syntax-error"
    PREFLIGHT_FAILED=1
  elif ! yq '.' forge.yaml >/dev/null 2>&1; then
    echo "ERROR: forge.yaml has a YAML syntax error."
    echo "  Fix: run \`yq '.' forge.yaml\` to locate the offending line, then correct the indentation/quoting."
    echo "  See: docs/site/troubleshooting.md#2-forgeyaml-has-a-syntax-error"
    PREFLIGHT_FAILED=1
  fi
fi

# Check 3 — gh CLI authenticated (HARD)
if ! gh auth status >/dev/null 2>&1; then
  echo "ERROR: gh CLI is not authenticated. The pipeline cannot read or write GitHub state."
  echo "  Fix: run \`gh auth login\` (ensure repo scope), then \`gh auth status\` to confirm."
  echo "  See: docs/site/troubleshooting.md#3-gh-cli-not-authenticated"
  PREFLIGHT_FAILED=1
fi

# Check 4 — workflow labels exist on the repo (SOFT — warn, auto-recoverable)
if [ -f forge.yaml ] && gh auth status >/dev/null 2>&1; then
  GH_REPO_PF="$(yq -r '.project.owner + "/" + .project.repo' forge.yaml 2>/dev/null)"
  if [ -n "$GH_REPO_PF" ] && ! gh label list -R "$GH_REPO_PF" --search "workflow:" 2>/dev/null | grep -q "workflow:"; then
    echo "WARNING: ForgeDock workflow:* labels not found on $GH_REPO_PF."
    echo "  Fix: run \`npx forgedock labels setup\` (or \`--repo $GH_REPO_PF\`) to bootstrap them."
    echo "  See: docs/site/troubleshooting.md#9-missing-workflow-labels"
  fi
fi

# Check 5 — GitHub API rate limit headroom (SOFT — warn)
if gh auth status >/dev/null 2>&1; then
  RL_REMAINING="$(gh api rate_limit --jq '.resources.core.remaining' 2>/dev/null || echo '')"
  if [ -n "$RL_REMAINING" ] && [ "$RL_REMAINING" -lt 100 ] 2>/dev/null; then
    RL_RESET="$(gh api rate_limit --jq '.resources.core.reset' 2>/dev/null)"
    echo "WARNING: GitHub API rate limit low ($RL_REMAINING remaining; resets at epoch $RL_RESET)."
    echo "  Fix: wait for the reset, reduce orchestration parallelism, or use a higher-limit PAT."
    echo "  See: docs/site/troubleshooting.md#10-github-api-rate-limit-exceeded"
  fi
fi

if [ "$PREFLIGHT_FAILED" -eq 1 ]; then
  echo "Pre-flight checks failed. Resolve the errors above and re-run /work-on {NUMBER}."
  echo "Full recovery guide: docs/site/troubleshooting.md"
  exit 1
fi
```

Worktree/branch-already-exists and stale-label conditions are surfaced later (Phase 3E worktree creation and the `## Error Handling` section) with their own recovery guidance in `docs/site/troubleshooting.md`.

### 0A: Parse input
Extract project prefix and issue number. If `next`/`pick`: list open issues sorted by priority, skip `needs-human`, `workflow:decomposed`, and `workflow:awaiting-merge`, pick highest priority.

**Resolve `UNDER_ORCHESTRATION`**: `true` if the invocation args contain `--under-orchestration`, else `false`. This is a single parse done once, here — every later gated block (heartbeats) just checks this variable, no re-parsing.

**Optional pre-flight**: Before committing to the full pipeline, run `/scope {NUMBER}` to get a complexity estimate (affected files, blast radius, risk flags, and decomposition recommendation). Especially useful for large or ambiguous issues.

### 0A.1: Remediation Mode Detection (`--remediate`) <!-- Added: forge#1813 -->

**Engine coverage** (forge#2379): `remediate` is now a registered phase in the headless engine's phase table (`packages/protocol/src/phases.js`, `bin/engine/phases.mjs`) — see `commands/work-on/remediate.md`'s own "Engine coverage" note for the current, documented limitation (a single continuous headless `runIssue()` walk cannot yet reach it; this prose-layer standalone-invocation path below remains the only way `remediate` actually runs today).

**Check first, before any other Phase 0 routing** — if `$ARGUMENTS` contains `--remediate`, this is NOT a normal issue-pipeline invocation. The first positional argument is a **PR number**, not an issue number:

```bash
if echo "$ARGUMENTS" | grep -qE -- '--remediate\b'; then
  REMEDIATE_PR_NUMBER=$(echo "$ARGUMENTS" | grep -oP '^\s*\K[0-9]+' | head -1)
  REMEDIATE_ISSUE_FLAG=""
  REMEDIATE_ISSUE_NUMBER=$(echo "$ARGUMENTS" | grep -oP -- '--issue\s+\K[0-9]+' | head -1)
  [ -n "$REMEDIATE_ISSUE_NUMBER" ] && REMEDIATE_ISSUE_FLAG="--issue ${REMEDIATE_ISSUE_NUMBER}"

  if [ -z "$REMEDIATE_PR_NUMBER" ]; then
    echo "ERROR: --remediate requires a PR number as the first argument, e.g. /work-on 1234 --remediate"
    exit 1
  fi

  echo "Remediation mode: routing PR #${REMEDIATE_PR_NUMBER} to work-on/remediate (issue flag: ${REMEDIATE_ISSUE_FLAG:-<resolved from PR body>})"
fi
```

If detected, dispatch immediately and STOP — do NOT fall through to Phase 0B's normal issue-number resume logic (an issue number is not even known yet; `work-on/remediate.md` Phase M0 resolves it):

```
Skill(skill="work-on/remediate", args="${REMEDIATE_PR_NUMBER} ${REMEDIATE_ISSUE_FLAG} --repo {GH_REPO} --gh-flag {GH_FLAG}")
```

**After `REMEDIATE_RESULT` returns, STOP unconditionally** — do not run any further Phase 0–7 logic in this file. `work-on/remediate.md` is self-contained: a FIXABLE remediation replaces `needs-human` with the active `workflow:in-review` state only while it is running, then ends at `workflow:merged`, `workflow:awaiting-merge`, or a newly asserted `needs-human` label. When `re_gate_outcome: AUTO-LANDED`, it drives its own close phase internally (Phase M8 invokes `Skill("work-on:close", ...)` directly) before returning. For every other outcome (`HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `BLOCKED`, `ALREADY_DONE`), the issue is already at a terminal state (`workflow:awaiting-merge` or `needs-human`, or already closed) per the Universal Phase Dispatcher — nothing further to do.

This mode is reachable both standalone (a human or script running `/work-on <pr> --remediate` directly) and via the orchestrator (`commands/orchestrate/phase-4-execution.md` item 6.4 auto-dispatches the identical `Skill(skill='work-on', args='{PR} --remediate --issue {N} ...')` invocation against a `needs-human`-gated predecessor's own PR).

**Skip this entire section if `--remediate` is absent from `$ARGUMENTS`** — proceed to the normal parse below.

### 0A.5: Post Heartbeat Annotation (orchestration-only)

**Skip entirely if `UNDER_ORCHESTRATION` is `false`** — a solo run has no stall detector polling comment timestamps, so this write has zero consumer. Do not post it "just in case."

When `UNDER_ORCHESTRATION` is `true`: post a lightweight activity signal immediately after resolving the issue number. This gives the stall detector (orchestrate Step 4B.5) a fresh timestamp to compare against `STALL_TIMEOUT`. Without this, the stall detector can only see the last structured comment (INVESTIGATOR, BUILDER, etc.) which may be hours old during a valid long-running phase.

```bash
PHASE_START_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:HEARTBEAT -->
**Phase**: Phase 0 — starting pipeline
**Timestamp**: ${PHASE_START_TIMESTAMP}
**Issue**: #{NUMBER}"
```

**Also post at major phase entry points** (Phases 1, 3, and 5) — replace `Phase 0` with the correct phase name in each case, and same `UNDER_ORCHESTRATION` gate. These mid-pipeline heartbeats ensure the stall detector sees recent activity during long phases (e.g., a build phase running for 20 minutes is not falsely classified as stalled). Inline snippets are embedded at Phase 1A, Phase 3A, and Phase 5A — agents resuming mid-pipeline encounter them without reading this section. <!-- Added: forge#740 -->

**Skip if**: Issue already has a terminal label (`workflow:merged`, `workflow:invalid`, `needs-human`, `workflow:awaiting-merge`) — no heartbeat needed on a completed issue. (This is in addition to, not instead of, the `UNDER_ORCHESTRATION` gate above.)

### 0B: Load issue + existing context
```bash
gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,comments,milestone
gh api repos/{GH_REPO}/issues/{NUMBER}/comments --jq '.[] | {id: .id, author: .user.login, body: .body}'
```

**Check**: state (closed → STOP), terminal labels (`workflow:merged`/`workflow:invalid`/`workflow:awaiting-merge` → STOP), existing agent comments (`FORGE:INVESTIGATOR`, `FORGE:DECOMPOSED`, `FORGE:CONTRACT`, `FORGE:BUILDER`, `FORGE:TRAJECTORY`, `FORGE:DECISION_RECORD`), parent tracker status, sub-issue status.

**Determine resume point**: No comments → Phase 1. Investigation exists + ready-to-build → Phase 3. Builder:COMPLETE + no PR → Phase 4. Builder without :COMPLETE (partial/interrupted build) + no PR → Phase 3 (partial-build cleanup). Builder + PR open → Phase 5. PR merged + issue open → Phase 6.

### 0B.5: Read Phase Checkpoint (MANDATORY — executes before any phase-skip decision)

Query for the latest `<!-- FORGE:CHECKPOINT -->` comment. This is the machine-readable source of truth for the pipeline's current phase position — it takes priority over all prose-based resume heuristics above.

```bash
CHECKPOINT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:CHECKPOINT"))] | last | .body // ""')

if [ -n "$CHECKPOINT" ]; then
  # Extract next_phase from the JSON block inside the comment
  NEXT_PHASE=$(echo "$CHECKPOINT" | grep -A5 '```json' | grep '"next_phase"' \
    | sed -n 's/.*"next_phase": "\([^"]*\)".*/\1/p')
  echo "Checkpoint found: next_phase=${NEXT_PHASE}"
fi
```

**Routing from checkpoint** (overrides prose heuristics above when a checkpoint exists):

| `next_phase` value | Resume at |
|--------------------|-----------|
| `BUILD` | Phase 3 (skip Phase 1 investigation) |
| `DECOMPOSE` | Phase 2 (skip Phase 1 investigation) |
| `REVIEW` | Phase 4 (skip Phase 1–3) |
| `CLOSE` | Phase 6 (skip Phase 1–5) |
| *(absent or unrecognized)* | Fall back to prose heuristics above |

Note: Phase 1D no longer writes `next_phase: BUILD`/`DECOMPOSE` CHECKPOINT comments (removed as redundant with the `workflow:ready-to-build`/`workflow:decomposed` label transition — see Phase 1D). Those two rows remain here only to route older, pre-existing CHECKPOINT comments correctly; new runs land on the prose-heuristic fallback for those two cases instead, which is equally precise. `REVIEW` and `CLOSE` are still written (Phase 3M and Phase 5D) because each covers a real gap before the corresponding label transition.

**If no checkpoint exists**: fall back to prose resume heuristics in Phase 0B above — treat as fresh start at Phase 1.

**Classify lane**: Milestone → feature lane (`milestone/{slug}`). No milestone → fast lane (`staging`).

**Batch issue detection**: <!-- Added: forge#1333 --> If the issue body contains `<!-- FORGE:BATCH_MEMBERS -->`, this is a P3 batch issue. Set `IS_BATCH=true` and extract the member issue list:

```bash
IS_BATCH=0
BATCH_MEMBERS=()

BATCH_MEMBERS_BLOCK=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
  | sed -n '/<!-- FORGE:BATCH_MEMBERS -->/,/<!-- \/FORGE:BATCH_MEMBERS -->/p' 2>/dev/null || true)

if [ -n "$BATCH_MEMBERS_BLOCK" ]; then
  IS_BATCH=1
  # Extract member issue numbers (- [ ] #NNN: title lines)
  BATCH_MEMBERS=($(echo "$BATCH_MEMBERS_BLOCK" | grep -oP '(?<=- \[ \] #)\d+' || true))
  echo "Batch issue detected — member issues: ${BATCH_MEMBERS[*]}"
fi
```

**Batch issue pipeline rules** (when `IS_BATCH=true`):
- Build phases execute exactly as normal (the batch issue body IS the spec for what to fix)
- Batch members are referenced in the PR body with `Refs #N`, never `Closes #N`; the batch issue is the only issue the PR may close.
- After successful merge, re-read every member's live state and labels before closing it. A member that is `needs-human`, `blocked`, or `operator-only` remains open and is reported as a split outcome:
  ```bash
  for MEMBER in "${BATCH_MEMBERS[@]}"; do
    MEMBER_SNAPSHOT=$(gh issue view "$MEMBER" {GH_FLAG} --json state,labels \
      --jq '{state: .state, labels: [.labels[].name]}' 2>/dev/null) || {
      echo "WARNING: could not verify batch member #${MEMBER}; leaving it open"
      continue
    }
    MEMBER_GATED=$(echo "$MEMBER_SNAPSHOT" | jq -r \
      '(.state != "OPEN") or ([.labels[] | select(. == "needs-human" or . == "blocked" or . == "operator-only")] | length > 0)')
    if [ "$MEMBER_GATED" = "true" ]; then
      echo "SPLIT OUTCOME: #${MEMBER} remains open because it requires a human or operator action."
      continue
    fi
    gh issue close "$MEMBER" {GH_FLAG} \
      --comment "Resolved as part of batch PR #{PR_NUMBER} (#{ISSUE_NUMBER}). See batch issue for details."
    gh issue edit "$MEMBER" {GH_FLAG} --add-label "workflow:merged" 2>/dev/null || true
  done
  ```
- Member issues are closed in Phase 6 (after PR merge) — NOT before

**Source branch for review-findings**: Parse `**Code branch**: \`{branch}\`` from body. Branch from there, not main.

**Script resolution** — Use the following `resolve_script()` function whenever calling a pipeline script. It enforces the 4-level precedence hierarchy (see `devdocs/project/architecture.md → Script Precedence`):

```bash
ADAPTIVE_DIR_RAW="${REPO_PATH}/$(yq '.adaptive_scripts.directory // ".forgedock/scripts"' forge.yaml 2>/dev/null || echo '.forgedock/scripts')"
ADAPTIVE_DIR=$(realpath -m "$ADAPTIVE_DIR_RAW" 2>/dev/null || echo "$ADAPTIVE_DIR_RAW")
ADAPTIVE_ENABLED=$(yq '.adaptive_scripts.enabled // "true"' forge.yaml 2>/dev/null || echo 'true')
# Bounds check: reject adaptive_scripts.directory values that escape the repo root.
# Normalize REPO_PATH the same way ADAPTIVE_DIR is normalized (realpath -m) so a trailing
# slash in paths.root does not inject a '//' into the glob and trigger a false positive.
REPO_PATH_NORM=$(realpath -m "$REPO_PATH" 2>/dev/null || echo "$REPO_PATH")
if [[ "$ADAPTIVE_DIR" != "${REPO_PATH_NORM}/"* ]]; then
  echo "WARNING: adaptive_scripts.directory resolves outside repo root ('$ADAPTIVE_DIR') — adaptive tier disabled" >&2
  ADAPTIVE_ENABLED=false
fi
UNIVERSAL_DIR="${FORGEDOCK_HOME:-$REPO_PATH}/scripts"
# NOTE: never resolve this via `which` or `find` — universal scripts are
# repo-relative, not installed on $PATH, so a PATH lookup always misses.
# REPO_PATH is already resolved from forge.yaml → paths.root earlier in
# Phase 0, so it is the deterministic fallback when FORGEDOCK_HOME is unset.
# Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to
# locate pipeline scripts under any circumstances: if UNIVERSAL_DIR/${operation}.sh
# does not exist, resolve_script() falls through to Tier 4 (prose) below,
# which is always safe and available. A missing script is never a reason
# to search the filesystem. <!-- Added: forge#1984 -->

resolve_script() {
  local operation="$1"
  # Tier 2: per-repo adaptive (skip if disabled)
  if [ "$ADAPTIVE_ENABLED" != "false" ] && [ -f "${ADAPTIVE_DIR}/${operation}.sh" ]; then
    echo "adaptive:${ADAPTIVE_DIR}/${operation}.sh"
    return
  fi
  # Tier 3: universal script
  if [ -f "${UNIVERSAL_DIR}/${operation}.sh" ]; then
    echo "universal:${UNIVERSAL_DIR}/${operation}.sh"
    return
  fi
  # Tier 4: prose fallback
  echo "prose:"
}

# Canonical tier-dispatch usage pattern — inline at every resolve_script() call site:
#
# There is no centralised run_script() function. The pattern below is inlined
# directly at each call site because each operation has a different prose
# fallback. Copy and adapt this block wherever resolve_script() is called.
#
# Usage pattern at each call site:
#   RESOLUTION=$(resolve_script 'op')
#   TIER="${RESOLUTION%%:*}"
#   SCRIPT_PATH="${RESOLUTION#*:}"
#   case "$TIER" in
#     adaptive|universal) bash "$SCRIPT_PATH" ARGS ;;
#     prose)              # inline fallback here ;;
#   esac
#
# The case pattern is inlined at every call site (rather than centralised here)
# because each operation has a different prose fallback — transition-label falls
# back to inline gh issue edit; classify-lane has no valid prose fallback and
# must exit 1; validate-pr-target emits a WARNING and continues (the PR review
# step catches any mismatch before merge). <!-- Added: forge#822 -->
```

When invoking a resolved script, log the tier in the FORGE annotation: `Script tier: {adaptive|universal|prose} ({path})`. This provides full pipeline observability. <!-- Added: forge#670 -->

### 0B.1: Apply learned overrides (MANDATORY — run after 0B, before any routing)

Read `forge.yaml → learned:` and override runtime variables. If the `learned:` key is absent or empty, all steps below are no-ops — continue to 0C.

```bash
# Read learned section — all reads use // "" fallback so absent keys are silent no-ops
LEARNED_STAGING=$(yq '.learned.branch_targets.staging // ""' forge.yaml 2>/dev/null || echo '')
LEARNED_TEST_COMMANDS=$(yq '.learned.test_commands // []' forge.yaml 2>/dev/null || echo '[]')
LEARNED_LABEL_MAP=$(yq '.learned.label_map // {}' forge.yaml 2>/dev/null || echo '{}')
LEARNED_COMMIT_STYLE=$(yq '.learned.commit_style // ""' forge.yaml 2>/dev/null || echo '')
```

**Apply overrides**:

1. **Branch target override** — If `LEARNED_STAGING` is non-empty, replace `STAGING_BRANCH` with its value:
   ```bash
   [ -n "$LEARNED_STAGING" ] && STAGING_BRANCH="$LEARNED_STAGING" && \
     echo "Learned override: STAGING_BRANCH → $STAGING_BRANCH (from learned.branch_targets.staging)"
   ```

2. **Test commands** — Store `LEARNED_TEST_COMMANDS` for use in Phase 3H (validate). These are appended to the `verification.commands` runs, not replaced:
   ```bash
   # Pass LEARNED_TEST_COMMANDS to Phase 3H as additional commands to run after verification.commands
   # (consumed in 3H — store as env var or carry forward in context)
   echo "Learned test commands: $LEARNED_TEST_COMMANDS"
   ```

3. **Label map** — If `LEARNED_LABEL_MAP` is non-empty, export it as `FORGE_LABEL_MAP` so that all subsequent `resolve_script 'transition-label'` invocations (which are child processes) can read it. The script performs the substitution internally: if the canonical label (e.g. `workflow:investigating`) appears as a key in the map, it uses the mapped value instead.
   ```bash
   # Export as FORGE_LABEL_MAP so child processes (resolve_script 'transition-label') can read it.
   # All 8 resolve_script 'transition-label' call sites in this command inherit this env var automatically.
   # The script substitutes the canonical workflow:* label with the mapped value when found.
   export FORGE_LABEL_MAP="$LEARNED_LABEL_MAP"
   [ -n "$LEARNED_LABEL_MAP" ] && [ "$LEARNED_LABEL_MAP" != "{}" ] && \
     echo "Learned override: FORGE_LABEL_MAP active — label_map will be applied by resolve_script 'transition-label'"
   ```

4. **Commit style** — If `LEARNED_COMMIT_STYLE` is non-empty, use it in Phase 3M:
   ```bash
   [ -n "$LEARNED_COMMIT_STYLE" ] && COMMIT_STYLE="$LEARNED_COMMIT_STYLE" && \
     echo "Learned override: COMMIT_STYLE → $COMMIT_STYLE"
   ```

<!-- Added: forge#667 — learned section reader -->

### 0C: Sync to Project board
Add issue to project, set Status=In Progress, Lane, Component, Priority, Workflow=Investigating.

### 0C.5: Resolve minimal spec set (selective spec loading)

Rather than loading the full ~27-command corpus (~1.1 MB / ~276K tokens) into
context, resolve the **minimal spec set** this run actually needs from the spec
knowledge graph. Use the universal-tier `graph-query` script via the canonical
`resolve_script` tier-dispatch pattern:

```bash
# Forward-transitive reachability: work-on + its reachable sub-phases (CONTAINS)
# + required devdocs (REQUIRES), as repo-relative file paths.
RESOLUTION=$(resolve_script 'graph-query')
TIER="${RESOLUTION%%:*}"
SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal)
    SPEC_SET=$(bash "$SCRIPT_PATH" load-set work-on 2>/dev/null || echo '[]')
    echo "$SPEC_SET" | jq -r '.[]'
    ;;
  prose)
    # Prose fallback: graph-query.sh unavailable — read specs on demand as each
    # Skill(...) is invoked. Selective loading is an optimization, not required.
    : ;;
esac
```

**Read ONLY the files returned by `load-set work-on`** when you need full spec
text during this run — these are the work-on orchestrator plus the sub-phases
and devdocs reachable from it (e.g. `commands/work-on/build/*`, `commands/work-on/review.md`,
`commands/review-pr.md`). Do **not** broadly read unrelated command specs
(`pipeline-health.md`, `audit.md`, `geo-audit.md`, …) that are not in the set.
Sub-phases are still invoked normally via their existing `Skill(...)` calls; this
step only narrows what is *pre-read* into context, it does not remove any phase.

This is the inverse of `graph-query.sh impact` (forward instead of reverse
reachability). It is read-only and auto-builds the graph if the gitignored JSON
is absent — no committed graph is required. The prose tier above handles older
installs without the scripts layer: selective loading is an optimization, never
a hard dependency.

---

## Phase 1: Investigation

**Skip if**: `<!-- FORGE:INVESTIGATOR -->` exists with `<!-- INVESTIGATION:COMPLETE -->` OR `<!-- INVESTIGATION:INVALID -->` in the SAME comment. `INVESTIGATION:INVALID` is the terminal sentinel Phase 1C emits for an INVALID verdict (see 1C below) — it signals investigation completion just as much as `INVESTIGATION:COMPLETE` does, not an interrupted state.

**Partial investigation**: If investigator comment exists BUT NEITHER `<!-- INVESTIGATION:COMPLETE -->` NOR `<!-- INVESTIGATION:INVALID -->` is present → investigation was interrupted. Delete the partial comment and restart:
```bash
COMMENT_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .id')
gh api repos/{GH_REPO}/issues/comments/$COMMENT_ID -X DELETE
```

### 1A: Set label
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} investigating ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:investigating" \
      --remove-label "workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
    ;;
esac
```

**Post Phase 1 heartbeat** (skip unless `UNDER_ORCHESTRATION` is `true`; also skip if issue already has a terminal label — `workflow:merged`, `workflow:invalid`, `needs-human`, `workflow:awaiting-merge`):
```bash
PHASE_START_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:HEARTBEAT -->
**Phase**: Phase 1 — Investigation
**Timestamp**: ${PHASE_START_TIMESTAMP}
**Issue**: #{NUMBER}"
```

### 1A.5: Normalize Issue Body (MANDATORY)

Before investigation begins, verify the issue body contains the four mandatory pipeline sections. If any are missing, add placeholder content so the investigator has the correct scaffolding.

**Skip if**: All four sections (`## Problem`, `## Affected Files`, `## Expected Behavior`, `## Acceptance Criteria`) are already present.

**Reuse `ISSUE_BODY` from Phase 0B** — it was already read in full there. Only re-fetch with the command below if `ISSUE_BODY` is not actually present in this session's context (compaction or a fresh resume).

```bash
ISSUE_BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')

MISSING_SECTIONS=""
echo "$ISSUE_BODY" | grep -q "^## Problem" || MISSING_SECTIONS="$MISSING_SECTIONS PROBLEM"
echo "$ISSUE_BODY" | grep -q "^## Affected Files" || MISSING_SECTIONS="$MISSING_SECTIONS AFFECTED_FILES"
echo "$ISSUE_BODY" | grep -q "^## Expected Behavior" || MISSING_SECTIONS="$MISSING_SECTIONS EXPECTED_BEHAVIOR"
echo "$ISSUE_BODY" | grep -q "^## Acceptance Criteria" || MISSING_SECTIONS="$MISSING_SECTIONS ACCEPTANCE_CRITERIA"

if [ -n "$MISSING_SECTIONS" ]; then
  echo "Missing sections:$MISSING_SECTIONS — normalizing issue body before investigation"

  APPEND_TEXT=""
  echo "$MISSING_SECTIONS" | grep -q "PROBLEM" && APPEND_TEXT="$APPEND_TEXT
## Problem

Root cause unknown — investigation needed."

  echo "$MISSING_SECTIONS" | grep -q "AFFECTED_FILES" && APPEND_TEXT="$APPEND_TEXT
## Affected Files

Files to be identified during investigation."

  echo "$MISSING_SECTIONS" | grep -q "EXPECTED_BEHAVIOR" && APPEND_TEXT="$APPEND_TEXT
## Expected Behavior

Expected behavior to be determined during investigation."

  echo "$MISSING_SECTIONS" | grep -q "ACCEPTANCE_CRITERIA" && APPEND_TEXT="$APPEND_TEXT
## Acceptance Criteria

- [ ] Fix confirmed during investigation."

  # Append missing sections to the existing body (never replace — only extend)
  NORMALIZED_BODY="${ISSUE_BODY}${APPEND_TEXT}"
  gh issue edit {NUMBER} {GH_FLAG} --body "$NORMALIZED_BODY"
  echo "Issue body normalized — added:$MISSING_SECTIONS"
else
  echo "Issue body already contains all mandatory sections — skipping normalization"
fi
```

**Continue to Phase 1B unconditionally.** Normalization is a compensation step — it never blocks investigation.

### 1B: Investigate the issue

Mission: Validate whether the issue is real. Assume description is wrong until proven otherwise.

**Resolve target repo and branch**:

Read `forge.yaml → review.tech_stack` and `forge.yaml → review.key_paths` (if present) to identify which files are most relevant for the affected domain. If the `review` section is absent, use the issue labels, title keywords, and the affected files listed in the issue body to determine the domain. Start with the files the issue explicitly names, then expand to callers and related modules.

**Workflow pipeline issues** (repo is a ForgeDock installation):
- Key files: `commands/work-on.md`, `commands/review-pr.md`, `commands/quality-gate.md`, `commands/orchestrate.md`, `forge.yaml`, `bin/forgedock.mjs`

**Application issues** (all other repos):

Use `forge.yaml → review.tech_stack` and the issue domain labels to identify entry points. If `forge.yaml → review.key_paths` lists domain-to-file mappings, use that table directly. Otherwise, infer key files from the issue body's Affected Files section.

**INFRA domain known footguns** (read before writing any `.github/workflows/*.yml` changes):
- **appleboy/ssh-action Go template preprocessing**: Any `{{` in a `script:` block is interpreted as a Go template directive **before the script reaches SSH**. This means `docker ps --format '{{.Names}}'` and `docker inspect --format '{{index .RepoTags 0}}'` will crash the action with exit 1. Both function calls (`{{index .X Y}}`) AND field accessors (`{{.Names}}`, `{{.Status}}`) fail on the action's empty data context. Shell error handlers (`|| fallback`, `set -e`, `2>/dev/null`) are bypassed because the failure is client-side. Always use `docker inspect IMAGE | jq -r '.[0].RepoTags[0]'` and `docker ps --format json | jq -r '.Names'` patterns in `appleboy/ssh-action` scripts. (Ref: forge#226 — 6-day silent deploy failure masked by `continue-on-error: true`)

**Steps**:
1. Check the right branch — read from branch specified in issue body (`**Code branch**: \`{branch}\``) if present
2. Read domain files — start with key files for the affected domain
2.5. **Existing system search (conditional)**: If the issue describes a gap in a functional capability — content not being distributed, notifications not sending, jobs not running, data not being synced — MUST search for an existing automated system before proposing a new one. The issue body may name a specific tool or path (e.g., `reddit-bot/`, `marketing/`) — do NOT anchor on that path alone. Expand the search to all service layers:
   ```bash
   # Check all service layers for the capability (adapt paths to your project structure)
   grep -rn "{capability_keyword}" {REPO_PATH}/services/ --include="*.py" -l | head -20
   # Look for scheduled jobs, automated runners, existing integrations
   grep -rn "scheduler\|celery\|cron\|nightly\|periodic" {REPO_PATH}/services/ --include="*.py" -l | head -10
   ```
   If an existing system is found that already handles the capability: the fix MUST route through the existing system (fix its config, env var, or gate) — NOT create a new parallel tool. Document the existing system in the investigation report and make it the centerpiece of the recommendation. This check is especially critical when the issue references a standalone tool directory (`reddit-bot/`, `scripts/`, `tools/`) — those directories often duplicate functionality that a service already owns. (Ref: forge#279 — investigator anchored on `reddit-bot/` from issue body, never checked `services/herald/app/scheduler/`, built parallel PRAW integration alongside Herald's existing automated crosspost scheduler)
3. Verify claims — does the code actually have the problem described?
3.5. **Type Invariant Verification (MANDATORY)**: Before declaring that a field, key, or parameter has a specific type (e.g. "content is always a dict", "status is always an int"), search for ALL code paths that write to that field across ALL services:
   ```bash
   grep -rn '"field_name"\s*:' services/   # Python dict key assignments
   grep -rn 'result\["field_name"\]\s*=' services/  # Direct assignments
   grep -rn '\.field_name\s*=' services/   # Attribute assignments
   ```
   If the field is written with different types in different code paths (e.g. dict in the standard path, string in the auth-gated path), document ALL variants. The fix must handle every variant — not just the one on the primary investigated code path. A type guard like `or {}` only protects against falsy values; a non-empty string is truthy and bypasses it.
4. Git blame — trace when/why the relevant code was written. Run bounded, local commands (no network round-trip):
   ```bash
   # Introducing commit for each affected file (first commit that added it)
   git log --reverse --format='%h %an %ad %s' --date=short -- {affected_file} | head -1
   # Last-touch commit (most recent change)
   git log -1 --format='%h %an %ad %s' --date=short -- {affected_file}
   # Line-level blame for a specific suspect hunk, if the issue names one
   git blame -L {start},{end} -- {affected_file}
   ```
   Record the introducing commit and last-touch commit for each primary affected file — this feeds the mandatory **History findings** field in Phase 1C.
4.5. **Rogue commit pre-state comparison (conditional)**: If the issue body references a specific commit as rogue, bad, or unintended (e.g., "rogue commit `abc1234`", "bad commit", "this was never intended"), MUST run `git show {commit}^:{file}` to see the file before that commit. Compare the pre-commit state against the current file. Any block present in the current file but absent in the pre-commit state was introduced by that commit chain and is a candidate for full reversion — not just partial editing. Report the delta (pre vs. current) in the investigation report. Do NOT assume surrounding code near a named import/bug is correct simply because the issue only named a specific sub-problem. (Ref: forge#278 — investigator confirmed the broken import but never ran `git show 18a3a2cf3^:batch.py`; the surrounding 50-line feature gate was also rogue and was preserved by the fix PR, causing a P1 access regression for all non-Scale users)
5. Domain context discovery (narrow scope only, 1–5 files):
   ```bash
   git log --oneline --all -30 -- {affected_files} | grep -oE '#[0-9]+' | sort -u
   gh issue list -R {GH_REPO} --state closed --limit 8 --search "{function_name}"
   ```
   Keep only file/function-level overlap. Max 5 related issues.

   **Pickaxe pass (prior fix / regression detection)** — bounded to one pass, capped at 5 hits: search for prior additions/removals of the suspected symbol or literal string named in the issue, independent of whether that fix was ever linked to a filed issue:
   ```bash
   git log -S"{suspected_symbol_or_string}" --oneline -- {affected_files} | head -5
   # Use -G instead of -S when the target is a regex pattern rather than a literal string
   git log -G"{pattern}" --oneline -- {affected_files} | head -5
   ```
   Any hit here is a candidate prior fix or reintroduced defect — read the commit body (`git show {hash}`) to confirm before citing it. Feed confirmed hits into the History findings field and let them inform the verdict (e.g. a defect being reintroduced raises severity).
6. Determine root cause
7. Identify affected files — full list of files that need changes
7.5. **Sibling Pattern Sweep** *(conditional — when the bug is a condition, gated function call, or field presence check)*: After identifying the affected files, grep for the same pattern in sibling files within the same directory. The issue spec may name only the file where the error was first observed — but the same commit or PR that introduced the bug often applied it uniformly across related handlers.
   ```bash
   # Identify the broken condition or gated function call from the issue
   # Then search sibling files in the same router/service directory
   AFFECTED_DIR=$(dirname {PRIMARY_AFFECTED_FILE})
   grep -rn "{broken_pattern}" "$AFFECTED_DIR" --include="*.py" | grep -v "{PRIMARY_AFFECTED_FILE}"
   ```
   **If identical patterns are found in files NOT listed in the issue spec**, output a scope-gap warning:
   > **Scope-Gap Warning**: The issue spec lists `{PRIMARY_FILE}` but the same pattern exists in `{SIBLING_FILE}:{LINE}`. These were likely introduced together. Recommend widening scope to fix all callers in this PR, or creating follow-up issues for the other files before proceeding.

   Do NOT silently exclude sibling matches. The appropriate output when sibling files have the same bug is to flag them explicitly — even if the issue spec's silence appears intentional. The fix-approach validation step (step 8) will confirm whether to widen scope or create follow-ups. <!-- Added: forge#383 -->
8. Fix-approach validation — if issue proposes a fix, don't adopt as spec. Trace through middleware, auth, routing, config. Cross-domain: if fix in domain A interacts with domain B, read domain B's files too.

### 1C: Post investigation comment

The comment MUST include a terminal sentinel at the very end. **The sentinel is conditional on the resolved Verdict — it is NOT always `<!-- INVESTIGATION:COMPLETE -->`:**

- **Verdict is INVALID** → close with `<!-- INVESTIGATION:INVALID -->`. This is a distinct, already-wired-up terminal marker: `bin/engine/phases.mjs`'s `detectOutcome` for the `investigate` phase checks for it explicitly (ahead of `INVESTIGATION:COMPLETE`) and routes to `terminalReason: "invalid"`; `bin/hooks/interactive-engine.mjs`'s `PHASE_MARKERS` table also already treats it as terminal. Emitting `INVESTIGATION:COMPLETE` for an INVALID verdict is what previously caused every completed investigation to read as `{verdict: "CONFIRMED"}` regardless of actual outcome — do NOT regress this (forge#2350).
- **Verdict is CONFIRMED or PARTIAL** → close with `<!-- INVESTIGATION:COMPLETE -->` as before (PARTIAL still routes to `ready-to-build` below — only INVALID gets the distinct terminal sentinel).

```bash
if [ "{VERDICT}" = "INVALID" ]; then
  INVESTIGATION_SENTINEL="<!-- INVESTIGATION:INVALID -->"
else
  INVESTIGATION_SENTINEL="<!-- INVESTIGATION:COMPLETE -->"
fi

gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:INVESTIGATOR -->
## Investigation Report

**Verdict**: {CONFIRMED|PARTIAL|INVALID}
**Confidence**: {HIGH|MEDIUM|LOW}
**Severity**: {CRITICAL|HIGH|MEDIUM|LOW}
**Task Type**: {Bug Fix|Feature|Refactor|Maintenance|Investigation}

### What Was Claimed
{summary of what the issue describes}

### What We Found
{what the code actually shows}

### Root Cause
{specific root cause, with file:line references where applicable}

### Affected Files
{numbered list of files that need changes}

### Evidence
{specific findings — function names, line numbers, behavior observed}

### History Findings
**Introducing commit**: {hash — author — date — subject, per primary affected file}
**Last touched**: {hash — author — date — subject}
**Pickaxe hits (prior fixes / regressions)**: {commit(s) found via \`git log -S\`/\`-G\`, or 'None found' — max 5}
{This field is MANDATORY — populate from the git blame + pickaxe commands in step 4/5. If a file is newly created (no history), write 'New file — no history.'}

### Recommendation
{what to build/fix, concrete and actionable}

### Related Issues
{if any found via domain context discovery, max 5}

### Decomposition Assessment
**{YES|NO}** — {reason}
{if YES: proposed sub-issues with titles and dependencies}

${INVESTIGATION_SENTINEL}"
```

**Do not hardcode `<!-- INVESTIGATION:COMPLETE -->` as the closing line.** The closing line MUST be the `${INVESTIGATION_SENTINEL}` variable computed above — it resolves to `<!-- INVESTIGATION:INVALID -->` for an INVALID verdict and `<!-- INVESTIGATION:COMPLETE -->` otherwise. `INVESTIGATION:COMPLETE` and `INVESTIGATION:INVALID` are mutually exclusive within a single posted comment — never emit both.

### 1D: Correction capture (MANDATORY — run before label update)

Before routing, scan all non-agent comments for correction signals from the repository owner. Correction signals are owner comments that contain phrases like "no, use", "actually use", "use X instead", "not X, use Y", or "wrong branch". If found, write the correction to `forge.yaml → learned:` and emit a `FORGE:LEARNED` annotation.

**Scan for correction signals**:
```bash
# Get repo owner login for filtering.
# Tiered resolution — necessary because project.owner is the GitHub org/user NAME,
# but comment .user.login is always a personal account login. For org-owned repos
# these are structurally different (e.g. org="RapierCraftStudios", commenter="mrdubey"),
# so using project.owner directly silently disables correction capture for all org repos.
#
# Resolution order:
#   1. project.owner_login (explicit override — required for org repos where owner ≠ personal login)
#   2. gh api repos/{GH_REPO} --jq '.owner.login' (auto-resolves correctly for personal repos)
#   3. project.owner (backward-compat fallback — still broken for org repos, but avoids hard failure)
REPO_OWNER=$(yq '.project.owner_login // ""' forge.yaml 2>/dev/null || echo '')
if [ -z "$REPO_OWNER" ]; then
  REPO_OWNER=$(gh api repos/{GH_REPO} --jq '.owner.login' 2>/dev/null || echo '')
fi
if [ -z "$REPO_OWNER" ]; then
  REPO_OWNER=$(yq '.project.owner' forge.yaml 2>/dev/null || echo '')
fi

# Fetch all comments, filter to owner-only, look for correction signals
CORRECTIONS=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  | jq -r --arg owner "$REPO_OWNER" \
  '.[] | select(.user.login == $owner) | select(
    (.body | test("no,? use|actually use|use .+ instead|not .+, use|wrong branch"; "i"))
  ) | .body' 2>/dev/null || echo '')
```

**If correction signals found** — extract and write each correction:

```bash
# Example: extract branch target correction "use develop not staging"
# Adjust regex to the correction pattern detected

if [ -n "$CORRECTIONS" ]; then
  echo "Correction signals detected — writing to forge.yaml → learned:"
  echo "$CORRECTIONS"

  # Write to forge.yaml using yq in-place merge (idempotent — yq merge overwrites existing keys)
  # Always use env variable injection to avoid YAML injection from comment content
  # Example for branch target correction:
  #   BRANCH_VALUE="develop"
  #   yq eval '.learned.branch_targets.staging = env(BRANCH_VALUE)' -i forge.yaml

  # After writing, emit FORGE:LEARNED annotation
  LEARNED_KEYS="branch_targets.staging"  # replace with actual extracted keys
  CAPTURED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

  # Update captured_at and captured_by metadata
  CAPTURED_AT_VAL="$CAPTURED_AT" yq eval '.learned.captured_at = env(CAPTURED_AT_VAL)' -i forge.yaml
  CAPTURED_BY_VAL="work-on/{NUMBER}" yq eval '.learned.captured_by = env(CAPTURED_BY_VAL)' -i forge.yaml

  gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:LEARNED -->
## Learned Pattern Captured

**Source**: Owner correction in comment on issue #{NUMBER}
**Captured at**: $CAPTURED_AT
**Keys written**: \`$LEARNED_KEYS\`

The following project-specific pattern was detected from owner feedback and written to \`forge.yaml → learned:\`. Future sessions will use this override automatically (read in Phase 0B.1).

\`\`\`yaml
# Written to forge.yaml
learned:
  # {key}: {value}
\`\`\`

**Idempotency**: yq merge-write — re-running will not duplicate entries."

  echo "FORGE:LEARNED annotation posted."
fi
```

**Idempotency guarantee**: Use `yq eval '.learned.key = env(VAR)' -i forge.yaml` — yq overwrites existing keys rather than appending. Re-running the capture step on the same comment produces the same forge.yaml state. <!-- Added: forge#667 -->

### 1D: Update labels & route

**CONFIRMED or PARTIAL with decompose: NO**:
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} ready-to-build ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:ready-to-build" \
      --remove-label "workflow:investigating,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
    ;;
esac
```

**Marker gate — Phase 1 exit** (see Marker Gate table in Universal Phase Dispatcher): <!-- forge#1419, forge#1418 -->
```bash
INV_MARKER=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("INVESTIGATION:COMPLETE"))] | length')
if [ "${INV_MARKER:-0}" -eq 0 ]; then
  echo "MARKER GATE FAIL: INVESTIGATION:COMPLETE absent — re-invoking work-on/investigate once"
  Skill(skill="work-on/investigate", args="{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG}")
  INV_MARKER=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
    --jq '[.[] | select(.body | contains("INVESTIGATION:COMPLETE"))] | length')
  if [ "${INV_MARKER:-0}" -eq 0 ]; then
    gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:GATE_FAILURE -->
## Marker Gate Failure — Phase 1 (Investigation)

**Expected marker**: \`INVESTIGATION:COMPLETE\` inside a \`FORGE:INVESTIGATOR\` comment
**Status**: Absent after subcommand re-invocation. Human review required.

The router re-invoked \`work-on/investigate\` once but the marker was still not posted.
Inspect the subcommand output above for errors. <!-- forge#1418 -->"
    gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" \
      --remove-label "workflow:investigating" 2>/dev/null || true
    exit 1
  fi
fi
```

**No separate CHECKPOINT write here** — the `workflow:ready-to-build` label set above already fully disambiguates the resume point ("Investigation exists + ready-to-build → Phase 3" per Phase 0B's prose heuristics); a `next_phase: BUILD` CHECKPOINT comment would duplicate that signal with an extra write. <!-- Removed redundant FORGE:CHECKPOINT write: forge#1826 -->
<!-- FORGE:PHASE_COMPLETE — Investigation routed to build. See Universal Phase Dispatcher: next phase is Phase 3. Not terminal — continue immediately. -->
→ Continue to Phase 3.

**CONFIRMED or PARTIAL with decompose: YES**:
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} decomposed ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:decomposed" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
    ;;
esac
```

**No separate CHECKPOINT write here** — the `workflow:decomposed` label set above is itself a terminal state for this run; nothing needs to resume past it, so a `next_phase: DECOMPOSE` CHECKPOINT comment adds no information. <!-- Removed redundant FORGE:CHECKPOINT write: forge#1826 -->
<!-- FORGE:PHASE_COMPLETE — Investigation routed to decomposition. See Universal Phase Dispatcher: next phase is Phase 2. Not terminal — continue immediately. -->
→ Continue to Phase 2 (Decomposition).

**INVALID**:
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} invalid ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:invalid" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:decomposed" 2>/dev/null || true
    ;;
esac
gh issue close {NUMBER} {GH_FLAG} --comment "Closing as invalid: {reason from investigation}"
```
→ STOP. No checkpoint written — INVALID is terminal.

---

## Phase 2: Decomposition (Conditional)

**Engine coverage** (forge#2379): this phase's dispatch target (`work-on/decompose`) is now a real phase in the headless engine's phase table — `decompose` in `packages/protocol/src/phases.js`/`bin/engine/phases.mjs`, live-wired so `investigate`'s `DECOMPOSE:YES` outcome hands off to it instead of terminating the run in place. This prose Phase 2 remains the interactive/prose dispatch path; the engine path is the headless equivalent, both invoking the same `commands/work-on/decompose.md` subcommand.

**Skip if**: Already decomposed, is a sub-issue, or investigation says decompose: NO.

**Trigger if**: Investigation assessment says YES — 2+ signals match (at least 1 Strong): multiple task types, 3+ service groups, phased requirements, 6+ files across directories.

### 2A: Load state
```bash
gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone
gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body'
```

**MANDATORY — Owner override detection**: After reading the investigation comment, read ALL comments on the issue to check for owner override signals:
```bash
gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR") | not) | {author: .user.login, body: .body}'
```

Scan non-agent comments for override signals — phrases like "do not", "do NOT", "instead", "revert", "remove this", "override", "actually", or explicit disagreement with the investigation's recommendation. If an override comment is found from a repo owner or admin (not a bot):

1. **Document the override**: Note which direction the owner is steering (e.g., "remove the feature" vs. investigation's "keep with warnings")
2. **Re-derive sub-issue scopes**: Derive sub-issue titles, bodies, and file scope from the override direction — NOT from the original investigation recommendation. The investigation's Decomposition Assessment may list sub-issues that are now stale or contradictory with the override.
3. **If override makes a sub-issue obsolete**: Skip creating it. Note the skip reason in the decomposition comment.
4. **If override changes the sequencing dependency**: Revise the execution order so that the override's primary action (e.g., "strip the feature") completes before any downstream doc/SDK sub-issues are built against it.

**Why this matters**: Sub-issues scoped before an override are built against a stale premise. A docs sub-issue scoped as "neutralize liability language" becomes incorrect if the upstream schema sub-issue will fully remove the feature — the docs sub-issue should instead be "remove all references to the deleted feature." Building both in parallel against the pre-override scope produces contradictory staging state.

### 2B: Design sub-issues
From the Decomposition Assessment (adjusted for any owner override detected in 2A), extract sub-issue titles (dependency order — independent first), dependencies, and descriptions.

For each sub-issue:
- **Title**: from investigation report
- **Body**: brief scope + `**Parent**: #{NUMBER}` + dependency note
- **Labels**: inherit priority label from parent; do NOT copy workflow labels
- **Milestone**: same as parent

### 2C: Create sub-issues

Route through the `/issue` create-hook's programmatic invocation contract (see `commands/issue.md` § "Programmatic Invocation Contract") instead of calling the raw issue-creation command directly — this gets dedup (Phase 2D) and body validation (Phase 3F) for free on every decomposition-spawned sub-issue.

```bash
SUB_ISSUE_TITLE_FULL="{fix|feat|refactor}: {SUB_ISSUE_TITLE}"
# Defense-in-depth: /issue's arg tokenizer (commands/issue.md, forge#2094) uses
# an xargs-based tokenizer that never expands backtick/$(...) substitution, so
# this is no longer required for safety — but strip it anyway so the raw title
# stays readable if it round-trips through any other eval-based consumer.
SUB_ISSUE_TITLE_FULL=$(printf '%s' "$SUB_ISSUE_TITLE_FULL" | tr '`' "'" | sed 's/\$(/$ (/g')
SUB_ISSUE_BODY_FILE=$(mktemp)
cat <<'SUB_BODY_EOF' > "$SUB_ISSUE_BODY_FILE"
## Problem

{1-3 sentences: what this sub-issue specifically addresses. What's wrong or what needs to be built.}

## Root Cause (if known)

{Specific root cause for this sub-task. Reference the parent investigation findings where applicable. If unknown: "Root cause unknown — investigation needed."}

## Affected Files

Files that need changes:
1. `{filepath}` — {what needs to change}
2. `{filepath}` — {what needs to change}

## Acceptance Criteria

- [ ] {Specific, testable criterion}
- [ ] {Specific, testable criterion}
- [ ] No regression in {related feature}

## Context

**Parent**: #{NUMBER}
{If depends on another sub-issue: "**Depends on**: #{SUB_ISSUE_N} — {reason}"}
SUB_BODY_EOF

# --milestone is only passed when the parent has one (see Phase 2B — "Milestone: same as parent").
MILESTONE_ARG=""
[ -n "{MILESTONE_TITLE}" ] && MILESTONE_ARG="--milestone \"{MILESTONE_TITLE}\""
Skill(skill="issue", args="--title \"$SUB_ISSUE_TITLE_FULL\" --body-file \"$SUB_ISSUE_BODY_FILE\" --label \"{PRIORITY_LABEL}\" ${MILESTONE_ARG}")
rm -f "$SUB_ISSUE_BODY_FILE"

# /issue has no machine-readable return contract — resolve the created issue's number by
# exact-title search immediately after the call (used by Phase 2D's tracker checklist).
# Retry to absorb GitHub Search API indexing lag.
SUB_ISSUE_NUM=""
for _resolve_attempt in 1 2 3; do
  SUB_ISSUE_NUM=$(gh issue list {GH_FLAG} --search "in:title \"${SUB_ISSUE_TITLE_FULL}\"" --state open --limit 1 --json number --jq '.[0].number // empty')
  [ -n "$SUB_ISSUE_NUM" ] && break
  sleep 2
done
```

### 2D: Update parent issue body
Add tracker checklist with all sub-issues in dependency order.

### 2E: Post decomposition comment
```bash
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:DECOMPOSED -->
## Decomposition Complete

### Sub-Issues Created
- #{SUB_NUMBER}: {TITLE}

### Decomposition Rationale
{brief summary}

<!-- FORGE:DECOMPOSED:COMPLETE -->"
```

### 2F: Update labels
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} decomposed ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:decomposed" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid" 2>/dev/null || true
    ;;
esac
```

→ STOP. Each sub-issue runs its own `/work-on`.

---

## Phase 3: Build

<!-- FORGE:PHASE_COMPLETE — Entering Phase 3 (Build). See Universal Phase Dispatcher: sub-phases 3A–3M execute in sequence. No sub-phase completion is terminal. -->

**Canonical path**: Sub-phases 3A–3M run **inline** in the current context window for STANDARD and fast-lane issues, with one standing exception: 3G (quality gate) always forks to a sub-agent under Row (d). This is the single authoritative build topology. `work-on/build.md` and `work-on-monolithic.md` ([BENCHMARK]) describe the same inline model with different levels of detail; they are not separate competing paths. `Skill()`/`Agent()` sub-agent spawns for build sub-phases are only permitted under a Spawn-Decision Table Row (c) or Row (d) exception — Row (c): ≥20 Skill invocations or ≥10 files changed before the build; Row (d): the sub-phase's own runtime risks exceeding the prompt-cache TTL regardless of build size (3G's standing exception). <!-- Added: forge#1276, updated: forge#1825 -->

**Skip if**: `<!-- FORGE:BUILDER:COMPLETE -->` is present in a BUILDER comment. <!-- Added: forge#1305 — require completion marker, not mere presence of BUILDER annotation -->

**Partial build detection**: If `<!-- FORGE:BUILDER -->` exists BUT `<!-- FORGE:BUILDER:COMPLETE -->` is ABSENT → the build was interrupted after implement.md Phase I6 (comment posted) but before validate.md Phase V5 (commit). Delete the partial comment and restart Phase 3 from the top: <!-- Added: forge#1305 -->
```bash
PARTIAL_ID=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:BUILDER") and (contains("FORGE:BUILDER:COMPLETE") | not))] | last | .id // ""')
if [ -n "$PARTIAL_ID" ]; then
  gh api repos/{GH_REPO}/issues/comments/$PARTIAL_ID -X DELETE
  echo "Deleted partial FORGE:BUILDER comment (no FORGE:BUILDER:COMPLETE) — restarting build"
fi
```

**CRITICAL: You MUST execute ALL sub-phases 3A–3M in order. Sub-phases 3C.5 (context) and 3C.6 (architect) are skipped ONLY for TRIVIAL tasks and Investigation tasks — see Phase 3B for classification. For STANDARD and COMPLEX tasks they post mandatory `FORGE:CONTEXT` and `FORGE:ARCHITECT` comments that Phase 3F reads as its primary input. Skipping them without a TRIVIAL/Investigation classification degrades build quality and causes review findings. After each sub-phase, continue to the next — no sub-phase is terminal.**

### 3A: Confirm state (reuse in-context; re-read only on compaction)

**Post Phase 3 heartbeat** (skip unless `UNDER_ORCHESTRATION` is `true`; also skip if issue already has a terminal label — `workflow:merged`, `workflow:invalid`, `needs-human`, `workflow:awaiting-merge`):
```bash
PHASE_START_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:HEARTBEAT -->
**Phase**: Phase 3 — Build
**Timestamp**: ${PHASE_START_TIMESTAMP}
**Issue**: #{NUMBER}"
```

If this is a continuous run (Phase 1 already executed in this same session), you already have the issue view, the FORGE:INVESTIGATOR body, and know whether FORGE:BUILDER:COMPLETE/FORGE:FAST_PATH exist from having posted or checked for them earlier — reuse those values, do not re-fetch. Only run the block below if one of those values is genuinely missing from this session's context (fresh/resumed session after compaction, or checkpoint routing jumped straight here):

```bash
gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state,milestone

# Read investigation report
gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body'

# Check if build already completed (require FORGE:BUILDER:COMPLETE — not just FORGE:BUILDER)
gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:BUILDER:COMPLETE")) | .body'

# Check for existing COMPLEXITY_BAND from a prior run (resume path)
EXISTING_FAST_PATH=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:FAST_PATH")) | .body' 2>/dev/null | head -1)
```

If no investigation comment with `<!-- INVESTIGATION:COMPLETE -->` → STOP (investigation not complete).

Extract from investigation: affected files, root cause, recommendation, task type.

### 3B: Classify task type and complexity

**Step 1 — Task type classification:**

| Signal | Type | Approach |
|--------|------|----------|
| Title starts with "Investigate:"/"Audit:"/"Research:" | Investigation | Produce issues as deliverables |
| UI/UX, feature + web/ files | UI/UX | `frontend-design` skill |
| Feature + services/ | Backend Feature | Implement directly |
| Feature + both | Full-Stack | Backend first, then frontend-design |
| Bug + web/ | Frontend Fix | Direct |
| Bug + services/ | Backend Fix | Direct |
| Refactor/docs | Maintenance | Direct |

**Investigation tasks — early exit (BEFORE Phase 3C):** If task type = Investigation, skip Phases 3C, 3C.5, and 3C.6 entirely. Post `<!-- FORGE:FAST_PATH -->` comment, then jump directly to Phase 3F (implement → issue creation path). Do NOT run the Builder Contract, Context Gathering, or Architecture Plan for investigation tasks.

```bash
# Post fast-path comment for investigation tasks
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:FAST_PATH -->
## Fast-Path Classification

**COMPLEXITY_BAND**: INVESTIGATION
**Task type**: Investigation
**Rationale**: Title prefix 'Investigate:' (or task type = Investigation from investigator report) — skipping Builder Contract (3C), Context Gathering (3C.5), and Architecture Plan (3C.6). Jumping directly to Phase 3F (issue creation).
**Phases skipped**: 3C, 3C.5, 3C.6"
```

→ Jump to Phase 3F immediately. Do not continue to Phase 3C.

**Step 2 — Complexity classification (for non-Investigation tasks):**

Classify COMPLEXITY_BAND based on affected file count and task nature:

| Condition | COMPLEXITY_BAND |
|-----------|-----------------|
| Single file, doc/config/markdown only, no logic changes expected | TRIVIAL |
| 1–5 files, existing patterns, no cross-service impact | STANDARD |
| 6+ files, new abstractions, cross-service, migration, schema changes | COMPLEX |

Post `<!-- FORGE:FAST_PATH -->` comment immediately after classification:

```bash
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:FAST_PATH -->
## Fast-Path Classification

**COMPLEXITY_BAND**: {TRIVIAL|STANDARD|COMPLEX}
**Task type**: {TASK_TYPE}
**Affected file count**: {N}
**Rationale**: {one-sentence explanation of classification decision}
**Phases skipped**: {list phases skipped, or 'none — full pipeline' for STANDARD/COMPLEX}"
```

**Resume path**: If `EXISTING_FAST_PATH` was read in Phase 3A, extract COMPLEXITY_BAND from it and skip re-classification.

**TRIVIAL tasks**: After posting FORGE:FAST_PATH, skip Phase 3C.5 (Context Gathering) and Phase 3C.6 (Architecture Plan) only. Phase 3C (Builder Contract) is **retained** — it still runs. Continue: 3C (Builder Contract) → 3D → 3E → 3F → 3F.5 → 3G → 3H onward. When filling in **Phases skipped** in the FORGE:FAST_PATH comment, write: `3C.5, 3C.6`.

**STANDARD and COMPLEX tasks**: Run full pipeline — 3C → 3C.5 → 3C.6 → 3D onward. No phases skipped.

### 3C: Builder Contract (MANDATORY)

Post `<!-- FORGE:CONTRACT -->` comment with: task type, proposed approach, deliverables table (file/change/why), acceptance criteria, quality considerations (auth model, new env vars, SQL safety, security surface), out of scope, alternatives.

```bash
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CONTRACT -->
## Builder Contract

**Task type**: {TASK_TYPE}

### Proposed Approach
{BRIEF_APPROACH_DESCRIPTION}

### Deliverables
| File | Change | Why |
|------|--------|-----|
{DELIVERABLES_ROWS}

### Acceptance Criteria
{ACCEPTANCE_CRITERIA_CHECKLIST}

### Quality Considerations
{AUTH_MODEL_NEW_ENV_VARS_SQL_SAFETY_SECURITY_SURFACE}

### Out of Scope
{OUT_OF_SCOPE_ITEMS}"
```

Contract must be grounded in the investigation report. Adversarially validate proposed fixes against adjacent system layers.

### 3C.5: Context Gathering (MANDATORY for STANDARD/COMPLEX — skip for TRIVIAL)

**Skip if COMPLEXITY_BAND: TRIVIAL** (classified in Phase 3B) — post nothing, proceed directly to Phase 3C.6. Trivial single-file changes have no institutional memory value to surface.

**For STANDARD and COMPLEX tasks**: This phase is NOT optional. Run it regardless. Do NOT skip it without a TRIVIAL classification from Phase 3B.

Surface institutional memory before writing code. Extract function names from the contract deliverables table:

```bash
FUNCTION_NAMES=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:CONTRACT")) | .body' \
  | awk '/^### Deliverables/{p=1; next} /^### /{p=0} p' \
  | grep -oE '`[A-Za-z_][A-Za-z0-9_]*`' \
  | tr -d '`' | sort -u | tr '\n' ' ' | xargs)
```

**The ONLY acceptable skip conditions** (all must be true): Issue is a 1-file config/docs edit with no code logic AND affected files have zero git history. In all other cases, run context gathering.

**Batch execution (MANDATORY)**: C1, C2 (including the direct commit-body read and the pickaxe pass), C3, and C4 below are mutually independent — none consumes another's output. Issue ALL of their underlying `gh`/`git`/`grep` calls as a single batch of parallel tool calls in one message, not as four sequential steps. This is the same rule as the "independent tool calls → same message" convention used elsewhere in the pipeline; it turns a ~4x serial round-trip chain (each `gh api` call is 0.5-2s) into one wall-clock round-trip. The per-file loop inside C1 and the per-function loop inside C3 are themselves independent across iterations — include every iteration's call in the same batch rather than looping turn-by-turn. Total budget is still 20s per query / 2 min overall; batching only removes serialization, it does not change the timeout.

Queries to batch (20s timeout each, 2 min total budget):

**C1: Past Review Findings on These Files**
```bash
# {AFFECTED_FILES} is a space-separated argument (see --files contract) — split
# explicitly on IFS=' ' into an array instead of a bare `for file in {AFFECTED_FILES}`,
# which word-splits on the shell's default IFS (space, tab, AND newline) and
# would corrupt any path containing a space.
IFS=' ' read -ra AFFECTED_FILES_ARR <<< "{AFFECTED_FILES}"
for file in "${AFFECTED_FILES_ARR[@]}"; do
  basename=$(basename "$file" .py)
  gh issue list -R {GH_REPO} --state closed --label "review-finding" \
    --search "$basename" --limit 10 \
    --json number,title,body \
    --jq '.[] | {number, title,
      pattern: (.body | capture("\\*\\*Pattern\\*\\*: *(?<p>[^\\n]+)").p // null),
      prevention: (.body | capture("\\*\\*Prevention\\*\\*: *(?<v>[^\\n]+)").v // null),
      root_cause: (.body | capture("\\*\\*Root cause\\*\\*: *(?<rc>[^\\n]+)").rc // (.body | capture("Root Cause[^\\n]*\\n(?<rc>[^\\n]+)").rc // "see body"))
    }'
done
```

**C2: Past Bugs in the Same Module**
```bash
git log --oneline -30 -- {AFFECTED_FILES} | grep -oE '#[0-9]+' | sort -u | head -8
# For each issue: fetch title + root cause, keep only bug/fix/review-finding labeled. Max 5.
```

**Direct commit-body read (bounded, prefer over `gh api` when it already answers "why")**: read the top 5 commit subjects+bodies on the affected files directly — local git is near-free relative to `gh api` round-trips, and commit bodies often explain the "why" without needing to fetch a linked issue at all:
```bash
git log -5 --format='%h %ad %s%n%b' --date=short -- {AFFECTED_FILES}
```
If a commit body fully explains a prior bug/fix (common for squashed or fix-up commits with no `#NNN` reference), use it directly as a "Past Bug in This Module" entry — do not require a linked GitHub issue to exist.

**Pickaxe pass (has this exact area been fixed before?)** — one bounded pass, capped at 5 hits, keyed on the suspected symbol/string from the Builder Contract or investigation report:
```bash
git log -S"{suspected_symbol_or_string}" --oneline -- {AFFECTED_FILES} | head -5
# Use -G instead of -S for regex patterns
git log -G"{pattern}" --oneline -- {AFFECTED_FILES} | head -5
```
Any hit is a candidate prior fix or reintroduced defect for this exact code area — read `git show {hash}` to confirm scope before including it in the output. This catches regressions the issue-number harvest above misses (e.g. a defect fixed via a squashed commit with no `#NNN` reference).

**C3: Related Code Paths** (callers/importers of FUNCTION_NAMES)
```bash
for fn in {FUNCTION_NAMES}; do
  grep -r "$fn" {WORKTREE_PATH} --include="*.py" -l | grep -v __pycache__ | head -5
  grep -r "$fn" {WORKTREE_PATH}/web/src --include="*.ts" --include="*.tsx" -l 2>/dev/null | head -5
done
```

**C4: Successful Similar Implementations**
```bash
gh pr list -R {GH_REPO} --state merged --search "{domain_keywords}" --limit 5 \
  --json number,title,files --jq '.[] | {number, title, file_count: (.files | length)}'
```

Post `<!-- FORGE:CONTEXT -->` comment with findings:
```bash
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CONTEXT -->
## Implementation Context for #{NUMBER}

### Known Pitfalls for This Area
{prevention rules from past review-findings}

### Historical Findings on These Files
{past review-finding issues}

### Past Bugs in This Module
{closed bug issues from git log mining, PLUS pickaxe-derived findings (commits with no linked issue, or commit
 bodies read directly per the C2 direct-commit-body step)}

### Related Code Paths (must stay consistent)
{files that import/call changed functions}

### Patterns That Cause Bugs Here
{recurring bug types synthesized from C1+C2}

### Successful Similar Implementations
{positive patterns from C4}

<!-- FORGE:CONTEXT:COMPLETE -->"
```

If total time exceeds 2 minutes, post partial results with `<!-- FORGE:CONTEXT:PARTIAL -->`.

### 3C.6: Architecture Plan (MANDATORY for STANDARD/COMPLEX — skip for TRIVIAL)

**Skip if COMPLEXITY_BAND: TRIVIAL** (classified in Phase 3B) — post nothing, proceed directly to Phase 3D. Trivial single-file changes have no multi-path consistency risk.

**For STANDARD and COMPLEX tasks**: This phase is NOT optional. Always run it. Even a 1-file STANDARD fix benefits from cross-path consistency checks. Do NOT skip without a TRIVIAL classification from Phase 3B.

Trace ALL affected code paths before writing code.

**Additional skip condition** (STANDARD tasks only): Issue creates ONLY new files with no existing callers AND title starts with "docs:" or "chore:".

**A1: Read Entry Points** — For each affected file: identify the primary function, all callers (grep), and sibling implementations. Read 3–5 most relevant files, max 8 total.

**A1.5: Route-Tree Classification for Shared Components** *(conditional — skip if no files under `components/` are affected, except `components/ui/primitives/`)* — When a change adds a new hook call or context dependency to a shared component, classify ALL call sites found in A1 by route context:
1. **Authenticated routes**: Callers under `app/dashboard/`, `app/(authenticated)/`, or any layout that wraps children with an auth provider (e.g., `UserProvider`, `SessionProvider`).
2. **Public routes**: Callers under `app/(public)/`, `app/playground/`, `app/(marketing)/`, or any layout without the relevant provider.
3. **If both categories have callers AND the new hook throws when its provider is absent**: add an explicit implementation step to either (a) guard the hook call with a null-context check, (b) make the hook return a safe default when called outside its provider, or (c) remove the hook from the shared component and move it to the authenticated-route-only caller. Document the split in the FORGE:ARCHITECT affected paths table. Do NOT leave a shared component that crashes public routes to be discovered by the FE review agent. <!-- Added: forge#381 -->

**A2: Trace Data Flow** — From each entry point: Entry → Transform → Persist/Relay → Exit. Check if the proposed change needs to propagate to each step. **For every field or key read by the changed code, enumerate ALL write paths first** — search across all services for assignments to that field. If multiple code paths write different types to the same field (e.g. dict in the standard path, string in the auth-gated path), the implementation must handle all variants. Do not assume the type you see on the primary code path is the only possible type.

**A2.1: Runtime UID × Volume Ownership Check** *(conditional — skip if no Dockerfile or entrypoint is affected)* — When the PR changes the container's runtime user (Dockerfile `USER` directive, `su-exec`, `gosu`, `setuid`), the architect MUST trace the full write-path chain before writing the implementation plan:
1. **Enumerate volume mounts**: Read all `docker-compose*.yml` files for the affected service. List every named volume and its container mount point (e.g. `storage_shared:/app/storage`). Docker named volumes are created as root-owned by default — any UID change without a corresponding ownership fix will silently break writes.
2. **Grep for filesystem writes**: Search the affected service's codebase for all filesystem write operations (`mkdir`, `Path.mkdir`, `write_bytes`, `open(`, `os.makedirs`, `shutil.copy`, `shutil.move`). For each write operation, identify the target path.
3. **Cross-reference**: For each write path that falls under a named volume mount point, add an explicit implementation step to ensure ownership compatibility before the privilege drop — typically `chown -R <user>:<group> <mount_point>` in the entrypoint script before the `exec su-exec` / `exec gosu` call.
4. **Add to FORGE:ARCHITECT deliverables table**: List the entrypoint or docker-compose change as an explicit deliverable. Do NOT leave volume ownership to be discovered by the builder or reviewer. <!-- Added: forge#323 -->

**A2.2: Gate-Condition Caller Sweep** *(conditional — when the fix changes a gate condition that guards a function call or restricts a field)*: Before finalizing the affected-paths table, grep for all callers of the gated function across sibling files in the same service directory. For each caller, verify that the gate condition applied at the call site is semantically correct.
   ```bash
   # Identify the gated function from the issue/contract
   GATED_FUNCTION="{function_being_called_inside_the_gate}"
   SERVICE_DIR=$(dirname {PRIMARY_AFFECTED_FILE})
   # Find all call sites
   grep -rn "$GATED_FUNCTION" "$SERVICE_DIR" --include="*.py" | grep -v "#"
   ```
   For each call site found: read the surrounding gate condition (±10 lines). If the condition includes fields that do NOT require the gated resource (e.g., `extraction_schema` gated behind an LLM key check when `extraction_schema` is processed without an LLM), add that caller file to the FORGE:ARCHITECT affected-paths table with a note explaining the incorrect gate condition.

   **Do NOT** omit sibling callers from the affected-paths table simply because they were not listed in the issue spec. The architect's scope is determined by code correctness, not by the issue spec's file list. A gate-condition bug that exists identically in 3 router files must be fixed in all 3 — even if the issue only named 1. <!-- Added: forge#383 -->

**A2.5: Pipeline Phase-Dependency Check** *(Forge pipeline changes only — skip if no `commands/*.md` file is affected)* — Identify artefact contracts the changed file produces (comment markers, structured output blocks, Skill() invocation signatures, label transitions). Find downstream pipeline phases that consume those artefacts. Flag any invocation signature change and verify all callers. Emit a checklist of downstream phases that must be updated — appended to the Consistency Checks block in the FORGE:ARCHITECT comment.

**A3: Consistency Rules** — Identify invariants all paths must satisfy: null handling, validation, logging, error response shape, auth checks.

**A4: Sequence Implementation** — Order: schema/type changes first → core logic → secondary paths → tests → config/env. Files imported by others change before importers. Higher risk first.

**A5: Risk Assessment** — Rate each non-obvious interaction: HIGH/MEDIUM/LOW with mitigation.

Post `<!-- FORGE:ARCHITECT -->` comment:
```bash
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
## Implementation Plan for #{NUMBER}

### Affected Paths (ALL must be updated)
| # | File | Function/Class | Change Required | Why |
|---|------|----------------|-----------------|-----|
{rows}

### Implementation Order
1. {FIRST_CHANGE} — {WHY_FIRST}
2. {SECOND_CHANGE} — {WHY_SECOND}

### Consistency Checks
- [ ] {INVARIANT_1}
- [ ] {INVARIANT_2}

### Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
{rows}

### Files to Read Before Coding
- \`{FILE}\` — {WHY_READ_IT}

<!-- FORGE:ARCHITECT:COMPLETE -->"
```

If budget exceeded (3 min), use `<!-- FORGE:ARCHITECT:PARTIAL -->`.

### 3D: Set building label
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} building ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:building" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:in-review,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
    ;;
esac
```

### 3E: Create worktree

Branch slug from title (lowercase, hyphenated, max 40 chars). Prefix: `fix/` (bugs) or `feat/` (features).

**Compute `PR_BASE` before worktree creation** — the source branch for the worktree MUST match the PR target. Compute `PR_BASE` now using `classify-lane.sh` so both worktree creation and Phase 4C use the same deterministic value. <!-- Added: forge#639 -->

```bash
# Compute PR_BASE deterministically from issue milestone — no LLM interpretation
RESOLUTION=$(resolve_script 'classify-lane')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal)
    if ! PR_BASE=$(bash "$SCRIPT_PATH" {NUMBER} -R {GH_REPO}); then
      gh issue comment {NUMBER} {GH_FLAG} --body "BLOCKER: classify-lane.sh failed to compute PR target — see script error above. Adding needs-human."
      gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
      exit 1
    fi
    ;;
  prose)
    # classify-lane has no valid prose fallback — the script output is authoritative.
    # Without it, PR target cannot be determined safely. Add needs-human and stop.
    gh issue comment {NUMBER} {GH_FLAG} --body "BLOCKER: classify-lane.sh not installed (prose tier). Cannot compute PR target deterministically. Adding needs-human."
    gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
    exit 1
    ;;
esac
```

**Determine source branch**:
- Review-finding → parse `**Code branch**: \`{branch}\`` from issue body; branch from `origin/{branch}`
  - **Milestone review-finding hybrid lane** (Code branch matches `milestone/*`): High-risk lane. NEVER use `git merge` to resolve conflicts — use `git rebase` or `git cherry-pick` only. If conflicts can't be resolved without merge, post comment, add `needs-human`, STOP.
  - **Missing ref fallback**: After parsing, verify the Code branch still exists on remote. If not (e.g., the source PR's head branch was deleted post-merge before a corrected stamp took effect), fall back to `PR_BASE` (the lane default) and note the fallback:
    ```bash
    SOURCE_BRANCH="{CODE_BRANCH_FROM_ISSUE_BODY}"
    if ! git ls-remote --exit-code origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
      echo "WARNING: Code branch '$SOURCE_BRANCH' not found on remote — falling back to PR_BASE '$PR_BASE'"
      SOURCE_BRANCH="$PR_BASE"
    fi
    ```
- Feature lane (has milestone) → branch from `origin/{PR_BASE}` (PR_BASE now set above)
- Fast lane (no milestone) → branch from `origin/{PR_BASE}` (PR_BASE = `{STAGING_BRANCH}`)

```bash
cd {REPO_PATH}
git fetch origin
BRANCH="fix/{slug}-{NUMBER}"
WORKTREE_ROOT="{REPO_PATH}/.claude/worktrees"
if [ "${FORGE_RUNTIME:-}" = "opencode" ] ||
   [ -n "${OPENCODE_SESSION_ID:-}" ] ||
   [ -n "${OPENCODE_PID:-}" ] ||
   [ -n "${OPENCODE:-}" ]; then
  WORKTREE_ROOT="{REPO_PATH}/.opencode/worktrees"
elif [ "${FORGE_RUNTIME:-}" = "codex" ]; then
  WORKTREE_ROOT="{REPO_PATH}/.codex/worktrees"
fi
WORKTREE_PATH="${WORKTREE_ROOT}/{BRANCH_SLUG}"
git worktree add {WORKTREE_PATH} -b {BRANCH} origin/{PR_BASE}
```

If worktree already exists: verify correct branch, reuse or remove and recreate.

### 3F: Implement

**Load context chain** — Read these from GitHub BEFORE writing code:
1. `FORGE:ARCHITECT` comment (primary implementation guide — if present, follow its ordered list exactly)
2. `FORGE:INVESTIGATOR` comment (root cause, affected files)
3. `FORGE:CONTRACT` comment (deliverables, acceptance criteria)
4. `FORGE:CONTEXT` comment (pitfalls, related paths, past bugs)

If `FORGE:ARCHITECT` is absent, fall back to investigation report + contract.

**Route by task type**: Bug Fix → implement directly. Feature (backend) → implement directly. Feature (UI/UX) → invoke `frontend-design` skill. Full-Stack → backend first, then frontend-design. Investigation → create issues, skip to Phase 7.

**Implementation rules**:
- Work in `{WORKTREE_PATH}` — all file reads, writes, git ops happen here
- Read the current file before modifying it — never assume its state
- Read related files identified in context briefing before touching changed code
- Follow architect plan's implementation order exactly (when present)
- For each acceptance criterion: implement it, then verify it's met
- Do NOT add unrequested scope — contract out-of-scope stays out
- **Library callback verification**: When writing a lambda/callable passed to a library parameter, MUST verify expected calling convention BEFORE writing it. Check library's default value, documentation, or source code. Wrong arity causes runtime `TypeError` invisible to static analysis. (Ref: PR #14391 — `lambda _: ""` passed where SQLAlchemy expects 0 args)
- **Cross-lane import guard**: Before adding any `import` or `from X import Y` statement for a service-internal module (`app.*`), verify the module exists on the PR's base branch — NOT just on your local disk or a milestone branch. Run `git show origin/{base_branch}:{module_path}.py` (replacing dots with slashes) to confirm. If the module only exists on a milestone branch, do NOT import it — find an alternative implementation or make the import conditional with a `try/except ImportError` fallback. A milestone-only import on a fast-lane PR will crash production on every request with `ModuleNotFoundError`. (Ref: forge#277 — builder imported `app.billing.subscriptions` from `milestone/subscription-model`, which doesn't exist on `staging`/`main`, causing P1 production crash for paying customer)
- **Deliverable-type consistency check**: Before committing, compare the actual output against the CONTRACT's deliverable list. If the CONTRACT explicitly states "no code changes required", "docs only", or "configuration update only" AND the diff introduces new executable files (`.py`, `.js`, `.ts`, `.sh` — not test, config, or documentation files), STOP. Do NOT commit. Re-read the CONTRACT, the investigation recommendation, and the ARCHITECT plan. If all three agree that code is needed, update the CONTRACT comment to reflect the new deliverable type before proceeding. If only the builder decided to add code without contract support, discard the code change and implement the contracted deliverable instead. <!-- Added: forge#279 -->
- **Pipeline check documentation — generalization rule**: When writing or updating pipeline check documentation (in `commands/*.md`), describe the **bug class**, not a specific incident. Do NOT embed: PR numbers, issue numbers, run IDs, timestamps, function names, dollar amounts, or multi-sentence incident timelines in check prose or `**Evidence**:` blocks. One brief HTML comment `<!-- Added: forge#NNN -->` is acceptable per check for traceability. `**Evidence**:` blocks must describe the vulnerability pattern (what the class of bug looks like, why it's dangerous) — not narrate a single historical occurrence. CHANGELOG entries may reference originating issues; command prompt text must not.
- **Endpoint response contract consumer tracing**: When changing an endpoint's response body shape or status field values (e.g., changing `"status": "healthy"` to `"status": "ok"`, renaming response keys, removing fields), grep the full repo for ALL consumers of that response body before committing. Consumers are not limited to the service being changed — they include deploy scripts, CI health checks, monitoring configs, docker-compose healthcheck definitions, Traefik probes, and any script that parses or pattern-matches on the response body. Run: `grep -rn "{old_value}\|{endpoint_path}" scripts/ infra/ .github/ docker-compose*.yml traefik/ 2>/dev/null`. All consumers whose behavior depends on the old response format MUST be updated in the same PR — a response contract change that updates only one consumer while leaving others on the old format is a deploy-time breakage. <!-- Added: forge#321 -->
- If contract is wrong (file doesn't exist, function has different signature): STOP, post comment, add `needs-human`, EXIT

### 3F.5: Env/Config Completeness Check

Run BEFORE committing. Read-only scan of working changes.

**Trigger**: Run whenever diff introduces env vars, touches infra/deploy configs, or adds literal IPs.

**Check 1 — New env var sync**: Scan for `os.getenv`/`process.env.` references. For each, verify present in `.env.example`, `ENV_VARS.md`, `env_validation.py`. Add if missing.

**Check 2 — Deploy/infra restart risk**: If docker-compose/deploy/infra files changed, scan for restart-inducing changes. Annotate commit with `[restart: <service>]`.

**Check 3 — Hardcoded IPs and credentials**: Scan for bare IPv4 literals and credential-like assignments. HARD BLOCKER — replace with env vars before staging.

**Check 4 — SDK/API Literal sync advisory** (trigger: diff contains `Literal[` in a schema file):
```bash
# Detect Literal type changes in API schema files
cd {WORKTREE_PATH}
LITERAL_CHANGES=$(git diff HEAD -- | grep -E '^\+.*Literal\[' | grep -v '^\+\+\+')
if [ -n "$LITERAL_CHANGES" ]; then
    echo "SDK SYNC ADVISORY: Literal type changed in schema."
    echo "Changed Literal lines:"
    echo "$LITERAL_CHANGES"
    echo ""
    echo "ACTION REQUIRED — verify SDK method/type lists match new API schema:"
    echo "  - sdk/python/*/client.py: check _valid_methods or equivalent list"
    echo "  - sdk/node/src/index.ts: check JSDoc @param Literal type annotation"
    echo "  - web/public/openapi*.json: check enum arrays for affected field"
    echo "  - web/public/openapi-versions/*.json: check all versioned specs"
    echo ""
    echo "Inconsistency example: API schema narrows Literal['GET','POST','PUT','PATCH','DELETE']"
    echo "to Literal['GET','POST'] but SDK JSDoc still lists all 5 methods — API returns 422"
    echo "for callers following SDK docs. This produces silent user-facing failures."
fi
```

This advisory is informational — it does NOT block the commit. But the implementer MUST check each listed file and add to the implementation scope if any SDK/spec file still documents the removed/changed Literal values. If SDK files need changes, add them to the current PR rather than leaving the inconsistency for review to catch.

### 3G: Quality Gate

Skip for 1-file config/docs edits.

**Fork the loop unconditionally under Spawn-Decision Table Row (d)** (see Phase 3G Cross-Reference above): quality-gate scans 14+ domains across up to 3 iterations — long enough to idle the parent's already-large accumulated context past the prompt-cache's ~5-minute TTL, forcing an uncached re-hydration on the parent's next turn. This applies regardless of changed-file count; there is no small-build exception. Dispatch with the `Agent` tool, not a nested `Skill()` call — a nested `Skill()` call still executes inside the parent's own context and would not stop the parent from idling past the cache TTL while the loop runs. The sub-agent needs nothing from the parent's context: it works directly against files on disk at `{WORKTREE_PATH}`. <!-- Added: forge#1825 -->

```
Agent(
  subagent_type="general-purpose",
  model="{SUBAGENT_MODEL}",
  description="Quality gate for #{NUMBER}",
  run_in_background=false,
  prompt="Run the quality gate loop for issue #{NUMBER} in worktree {WORKTREE_PATH}.
    NEVER use plan mode (EnterPlanMode).
    Changed files: {CHANGED_FILES}

    iteration = 0
    max_iterations = 3
    while iteration < max_iterations:
        iteration += 1
        Skill('quality-gate', args='{CHANGED_FILES} --worktree {WORKTREE_PATH}')
        if result == 'QUALITY GATE: PASS':
            GATE_PASSED = true
            break
        else:
            Fix each HIGH and MEDIUM finding directly in the files at {WORKTREE_PATH}
            Re-stage the fixes (do not commit, do not push)

    Do not touch GitHub issue state — the caller handles labels/comments.
    Return exactly one line: 'GATE_RESULT: passed={true|false} iterations={N} summary={one-line summary of remaining findings if failed, else \"clean\"}'"
)
```

Parse the returned `GATE_RESULT` line:
- `passed=true` → `GATE_PASSED = true` → continue to sub-phase 3H.
- `passed=false` (loop exhausted 3 iterations) → post "Quality Gate Failed After 3 Iterations" comment using the returned `summary` → add `needs-human` label → STOP.

**Fallback**: if the `Agent` tool is unavailable in the current runtime (partial install), run the loop inline as before —

```
iteration = 0
max_iterations = 3

while iteration < max_iterations:
    iteration += 1
    Skill("quality-gate", args="{CHANGED_FILES} --worktree {WORKTREE_PATH}")
    if result == "QUALITY GATE: PASS":
        GATE_PASSED = true
        break
    else:
        Fix each HIGH and MEDIUM finding in {WORKTREE_PATH}
        Re-stage fixes

if iteration == max_iterations AND not PASS:
    Post "Quality Gate Failed After 3 Iterations" comment
    Add needs-human label → STOP
```

— and note in the builder comment that context isolation was degraded for this run.

# MUST CONTINUE to sub-phase 3H (Format and verify) — quality gate PASS is intermediate, NOT terminal. <!-- Added: forge#220 -->

**After the sub-agent returns `passed=true`: proceed immediately to sub-phase 3H below. Quality gate is an intermediate check — "PASS" means the code is clean, NOT that the build is done. Do NOT stop.**

**After PASS: Do NOT re-read GitHub state, issue body, labels, or any file beyond what the sub-agent already changed on disk. Do NOT run any gh commands. Do NOT check PR status. Proceed directly to Phase 3H (Format and verify) below.** <!-- Added: forge#93 -->

### 3H: Format and verify

All tool commands are read from `forge.yaml → verification.commands`. When a key is absent, the step logs `SKIPPED — not configured in verification.commands` and continues rather than silently passing. Before any test command executes (`learned.test_commands`, or a future `verification.commands.*.test` invocation), it is first checked against `verification.known_slow_tests` — a repo-declared list of patterns for suites known to hang or make live network/LLM calls — and skipped or narrowed to a safe subset on a match. When that config is absent, this gate is a no-op and behavior is unchanged. <!-- Added: forge#1861 -->

**Track skipped checks** — initialize before any check runs:
```bash
VERIFICATION_SKIPPED_CHECKS=""
```

**Python**:
```bash
cd {WORKTREE_PATH}

PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
if [ -n "$PYTHON_FORMAT" ]; then
    eval "$PYTHON_FORMAT" 2>&1
else
    echo "SKIPPED — python.format not configured in verification.commands"
    VERIFICATION_SKIPPED_CHECKS="${VERIFICATION_SKIPPED_CHECKS:+$VERIFICATION_SKIPPED_CHECKS, }python.format"
fi

# Compile check always runs (no config needed — catches syntax errors)
python -m py_compile {PYTHON_FILES}
```
`py_compile` failures are BLOCKING.

**TypeScript**:
```bash
cd {WORKTREE_PATH}

TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')

if [ -n "$TS_FORMAT" ]; then
    eval "$TS_FORMAT" 2>&1
else
    echo "SKIPPED — typescript.format not configured in verification.commands"
    VERIFICATION_SKIPPED_CHECKS="${VERIFICATION_SKIPPED_CHECKS:+$VERIFICATION_SKIPPED_CHECKS, }typescript.format"
fi

if [ -n "$TS_TYPECHECK" ]; then
    eval "$TS_TYPECHECK" 2>&1
    TS_EXIT=$?
elif [ -n "$TS_BUILD" ]; then
    eval "$TS_BUILD" 2>&1 | tail -30
    TS_EXIT=$?
else
    echo "SKIPPED — typescript.typecheck and typescript.build not configured in verification.commands"
    VERIFICATION_SKIPPED_CHECKS="${VERIFICATION_SKIPPED_CHECKS:+$VERIFICATION_SKIPPED_CHECKS, }typescript.typecheck/build"
    TS_EXIT=0
fi
```
Typecheck or build failures are BLOCKING.

**Known-slow test gate** — Before running any test command below, check it against `verification.known_slow_tests` (a repo-declared list of test patterns known to hang or make live network/LLM calls). When absent or empty, this is a no-op and behavior is unchanged. <!-- Added: forge#1861 -->

```bash
# KNOWN_SLOW_TESTS read directly from verification.known_slow_tests (a static,
# operator-declared config — unlike learned.test_commands, it is NOT part of the
# agent-writable `learned:` section, so it is read inline here rather than
# pre-loaded in Phase 0B.1).
KNOWN_SLOW_TESTS=$(yq -o=json -I=0 '.verification.known_slow_tests // []' forge.yaml 2>/dev/null || echo '[]')

# apply_known_slow_filter <cmd> — echoes the command to actually run, or "" to
# skip it entirely. Matching is substring match of `pattern` against the full
# command text. Exactly one of skip/subset is expected per matched entry.
apply_known_slow_filter() {
  local cmd="$1"
  local out="$cmd"
  if [ -n "$KNOWN_SLOW_TESTS" ] && [ "$KNOWN_SLOW_TESTS" != "[]" ] && [ "$KNOWN_SLOW_TESTS" != "null" ]; then
    while IFS= read -r entry; do
      [ -z "$entry" ] && continue
      pattern=$(echo "$entry" | yq '.pattern // ""')
      skip=$(echo "$entry" | yq '.skip // false')
      subset=$(echo "$entry" | yq '.subset // ""')
      reason=$(echo "$entry" | yq '.reason // "no reason given"')
      [ -z "$pattern" ] && continue
      case "$cmd" in
        *"$pattern"*)
          if [ "$skip" = "true" ]; then
            echo "SKIPPED — known-slow test matched pattern '$pattern' ($reason)" >&2
            out=""
          elif [ -n "$subset" ]; then
            echo "SUBSTITUTED — known-slow test matched pattern '$pattern' ($reason); running safe subset instead" >&2
            out="$subset"
          fi
          ;;
      esac
    done < <(echo "$KNOWN_SLOW_TESTS" | yq -o=json -I=0 '.[]' 2>/dev/null)
  fi
  echo "$out"
}
```

**Learned test commands** — After all `verification.commands` steps complete, run any commands from `learned.test_commands` (captured from owner corrections in Phase 1D or set manually in forge.yaml), filtered through the known-slow gate above:

```bash
# LEARNED_TEST_COMMANDS was set in Phase 0B.1 from forge.yaml → learned.test_commands
# If empty/null, this block is a no-op
if [ -n "$LEARNED_TEST_COMMANDS" ] && [ "$LEARNED_TEST_COMMANDS" != "[]" ]; then
  echo "Running learned test commands..."
  # yq outputs each entry on its own line with -r flag
  echo "$LEARNED_TEST_COMMANDS" | yq '.[]' | while IFS= read -r cmd; do
    [ -z "$cmd" ] && continue
    FILTERED_CMD=$(apply_known_slow_filter "$cmd")
    if [ -z "$FILTERED_CMD" ]; then
      continue
    fi
    echo "Running learned command: $FILTERED_CMD"
    eval "$FILTERED_CMD" 2>&1 | tail -30
    CMD_EXIT=$?
    if [ $CMD_EXIT -ne 0 ]; then
      echo "FAILED (exit $CMD_EXIT): $FILTERED_CMD"
      exit $CMD_EXIT
    fi
  done
else
  echo "No learned test commands configured — skipping"
fi
```
Learned test command failures are BLOCKING (same as verification.commands failures). A command matched and skipped by the known-slow gate is never executed and never counted as a failure. <!-- Added: forge#667, forge#1861 -->

### 3I: Frontend proxy wiring check (MANDATORY)

Skip if no TS/TSX files changed.

All client-side `fetch`/`useSWR`/`apiFetch`/`axios` MUST use `/api/...` proxy routes, NEVER `/api/v1/...` or hardcoded host:port. Scan and fix violations.

### 3I.5: Database Configuration Change Advisory

Skip if no changed Python files contain DB engine/session/pool patterns.

```bash
cd {WORKTREE_PATH}
while IFS= read -r f; do
    [ -z "$f" ] && continue
    grep -qE "create_async_engine|AsyncSession|connect_args|pool_size|prepared_statement|engine_from_config|sessionmaker" "$f" 2>/dev/null && \
        echo "DB CONFIG CHANGE DETECTED in: $f"
done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
```

Advisory only — does not block build. Check for lambda/callable in connect_args (the exact bug class from PR #14391).

### 3J: Deployment completeness check (MANDATORY)

Skip if no new env vars introduced.

**Config variables used by this phase** (set in `forge.yaml`):
- `{deploy.secrets_backend}` — secrets delivery method (`sops`, `aws-sm`, `vault`, `ci-env`, `none`). When absent or not `sops`, SOPS-specific checks below are skipped with an explicit log message.
- `{verification.services[name].container}` — container name for post-deploy verification. Resolved by matching the service name; falls back to `{service}` (bare name) when not configured.

For each new env var, verify present in ALL required locations:

| Location | Required for |
|----------|-------------|
| `.env.example` | All new vars |
| Secrets backend (see `deploy.secrets_backend`) | Secret vars — skip if backend is `none` or unset |
| `app/env_validation.py` | API service vars (if project has one) |
| `docker-compose.prod.yml` | Vars needing explicit injection (if project uses Docker Compose) |

**Secrets backend check** *(trigger: `deploy.secrets_backend == "sops"`)*:

If the project uses SOPS, verify the new var is present in all SOPS chain locations:
- `infra/secrets/prod.enc.yaml` — SOPS-encrypted secret store
- `infra/decrypt-secrets.sh` ENV_MAPPING — maps SOPS key to env var name
- Deploy chain: SOPS → `decrypt-secrets.sh` (ENV_MAPPING) → `.env.secrets` → `merge-env-secrets.sh` → `.env.production` → docker-compose `env_file`

If `deploy.secrets_backend` is absent or not `sops`, skip these checks and log:
> `SKIP: SOPS chain check — deploy.secrets_backend is not "sops". Configure deploy.secrets_backend in forge.yaml to enable.`

**Operator-set var classification** *(trigger: new env var is NOT in the configured secrets backend)*: <!-- Added: forge#380 -->

Some env vars are operator-set (non-secret, not sourced from the secrets backend) — they must be manually added to the runtime environment on the production server. When a new env var has no entry in the secrets backend, classify it as operator-set and add a **HARD BLOCKER** item to the Testing Checklist.

Resolve the container name for the verification command:
1. Look up the service in `forge.yaml → verification.services[]` by name — use the `container` field if present.
2. If no matching entry, fall back to the bare service name: `{service}` (no suffix).

```
- [ ] HARD BLOCKER: Add {VAR_NAME} to the runtime environment on the production server.
      This var is operator-set — it does NOT flow through the automated secrets chain.
      It must be added manually before or after deploy.
      Verify with: docker exec {CONTAINER_NAME} env | grep {VAR_NAME}
      (CONTAINER_NAME resolved from verification.services[{service}].container in forge.yaml,
       or bare service name if not configured)
```

**`env_file` re-read warning** *(trigger: any new env var added to `.env.production` path)*:

> **Docker `env_file` re-read behavior**: New entries in `.env.production` are only read when a container is **recreated** (e.g., `docker compose up --force-recreate`). A plain `docker restart` restarts the existing container with its frozen env — new `env_file` entries are silently absent. The standard deploy workflow uses `--force-recreate` and handles this correctly. If any out-of-band restart is used, new env vars will not take effect.

Add this warning to the Testing Checklist whenever a new env var is introduced (whether secret or operator-set).

**Post-deploy in-container verification** *(trigger: any new env var)*:

Add the following to the Testing Checklist so the deployer can confirm delivery after deploy.

Resolve `{CONTAINER_NAME}` from `forge.yaml → verification.services[{service}].container`; use `{service}` (bare) if the field is absent.

```bash
# Verify env var reached the running container (run post-deploy)
docker exec {CONTAINER_NAME} env | grep {VAR_NAME}
# Expected: {VAR_NAME}={value}
# If blank: container was not recreated — run: docker compose up --no-deps --force-recreate {service}
```

### 3K: Commit

Stage all changes and commit:

```bash
cd {WORKTREE_PATH}
git add -u
git commit -s -m "fix({SCOPE}): {description} (#{NUMBER})"
```

Conventional prefix: `fix`/`feat`/`refactor`/`docs`. Reference `#{NUMBER}` in message.

**Post-commit ancestry audit (MANDATORY)**:
```bash
cd {WORKTREE_PATH}
if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
  MERGE_COMMITS=$(git log --merges HEAD ^origin/{PR_BASE} 2>/dev/null)
  if [ -n "$MERGE_COMMITS" ]; then
    # Post ancestry audit failure comment, add needs-human → STOP
  fi
fi
```

### 3L: Update issue body (MANDATORY)
Check off completed items, mark phases complete, add PR references.

### 3M: Post implementation comment
```bash
# Compute verification status from VERIFICATION_SKIPPED_CHECKS (set in Phase 3H)
if [ -z "$VERIFICATION_SKIPPED_CHECKS" ]; then
  VERIFICATION_STATUS="✅ All configured verification commands passed"
else
  VERIFICATION_STATUS="⚠ Verification NOT run: ${VERIFICATION_SKIPPED_CHECKS} — verification.commands not configured for these checks"
fi

gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:BUILDER -->
## Implementation Complete

**Branch**: \`{BRANCH}\`
**Commits**: {COMMIT_SHA(S)}
**Files changed**: {COUNT}
**Verification Status**: ${VERIFICATION_STATUS}
**Cost (build phase)**: ${PHASE_COST_USD:-unavailable} (best-effort — session telemetry; omit if unavailable)

### Approach
{what was built, key decisions}

### Changes
{bulleted list of file changes}

### Acceptance Criteria Status
{checklist from contract, marked pass/fail}

### Testing Checklist
- [ ] {scenario 1} [type:api]
- [ ] {scenario 2} [type:unit]

> **Test-type annotation** (optional): Append `[type:api]`, `[type:unit]`, `[type:e2e]`, or `[type:manual]` to each checklist item. The test gate reads this annotation directly and skips regex inference. Omit it to rely on regex classification fallback.

<!-- FORGE:BUILDER:COMPLETE -->"
```

Write machine-readable phase checkpoint (MUST execute immediately after FORGE:BUILDER comment is posted, before Phase 4):
```bash
CHECKPOINT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CHECKPOINT -->
\`\`\`json
{\"phase\": \"BUILD\", \"status\": \"COMPLETE\", \"next_phase\": \"REVIEW\", \"timestamp\": \"${CHECKPOINT_TIMESTAMP}\"}
\`\`\`"
```

**Marker gate — Phase 3 exit** (see Marker Gate table in Universal Phase Dispatcher): <!-- forge#1419, forge#1418 -->
```bash
BUILD_MARKER=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:BUILDER:COMPLETE"))] | length')
if [ "${BUILD_MARKER:-0}" -eq 0 ]; then
  echo "MARKER GATE FAIL: FORGE:BUILDER:COMPLETE absent — re-invoking work-on/build once"
  Skill(skill="work-on/build", args="{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG} --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}")
  BUILD_MARKER=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
    --jq '[.[] | select(.body | contains("FORGE:BUILDER:COMPLETE"))] | length')
  if [ "${BUILD_MARKER:-0}" -eq 0 ]; then
    gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:GATE_FAILURE -->
## Marker Gate Failure — Phase 3 (Build)

**Expected marker**: \`FORGE:BUILDER:COMPLETE\` inside a \`FORGE:BUILDER\` comment
**Status**: Absent after subcommand re-invocation. Human review required.

The router re-invoked \`work-on/build\` once but the marker was still not posted.
Inspect the subcommand output above for errors. <!-- forge#1418 -->"
    gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human" \
      --remove-label "workflow:building" 2>/dev/null || true
    exit 1
  fi
fi
```

---

## Phase 4: PR Creation

### 4A: Pre-push ancestry guard

```bash
cd {WORKTREE_PATH}
if git ls-remote --exit-code origin {PR_BASE} >/dev/null 2>&1; then
  MERGE_COMMITS=$(git log --merges {BRANCH} ^origin/{PR_BASE} 2>/dev/null)
  if [ -n "$MERGE_COMMITS" ]; then
    # Post ancestry guard failure, add needs-human → STOP
  fi
fi
```

### 4B: Push branch
```bash
cd {WORKTREE_PATH} && git push -u origin {BRANCH}
```
If fails: try `--force-with-lease`. If still fails: post comment, add `needs-human`, STOP.

### 4C: Determine PR target
`PR_BASE` was computed in Phase 3E. If somehow unset (e.g., resumed session after compaction), recompute:
```bash
RESOLUTION=$(resolve_script 'classify-lane')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal)
    if ! PR_BASE=$(bash "$SCRIPT_PATH" {NUMBER} -R {GH_REPO}); then
      gh issue comment {NUMBER} {GH_FLAG} --body "BLOCKER: classify-lane.sh failed to recompute PR target — see script error above. Adding needs-human."
      gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
      exit 1
    fi
    ;;
  prose)
    # No valid prose fallback — see Phase 3E note.
    gh issue comment {NUMBER} {GH_FLAG} --body "BLOCKER: classify-lane.sh not installed (prose tier). Cannot recompute PR target. Adding needs-human."
    gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
    exit 1
    ;;
esac
```
Output is authoritative — no prose fallback. Script exits 1 on error (invalid issue, `gh` auth failure, or milestone branch absent on remote); treat non-zero exit as `needs-human` and STOP. <!-- Added: forge#669, forge#639 -->

### 4C.5: Validate PR target against classified lane
```bash
RESOLUTION=$(resolve_script 'validate-pr-target')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal)
    bash "$SCRIPT_PATH" {PR_BASE} {CLASSIFIED_LANE}
    ;;
  prose)
    # validate-pr-target has no safe prose fallback — silently skipping validation risks
    # merging to the wrong branch. Log a warning but do NOT block the pipeline; the PR
    # review step will catch a mismatched target before merge.
    echo "WARNING: validate-pr-target.sh not installed (prose tier) — skipping lane validation. Confirm PR base manually." >&2
    ;;
esac
```
`{CLASSIFIED_LANE}` is the value returned by `classify-lane.sh` in Phase 4C. `{PR_BASE}` is the branch the PR will target. If exit code is 1 (mismatch):
```bash
gh issue comment {NUMBER} {GH_FLAG} --body "BLOCKING: validate-pr-target.sh — PR base \`{PR_BASE}\` does not match classified lane \`{CLASSIFIED_LANE}\`. Manual intervention required."
gh issue edit {NUMBER} {GH_FLAG} --add-label "needs-human"
```
→ STOP. Do NOT proceed to Phase 4D. <!-- Added: forge#671 -->

### 4D: Create PR
```bash
PR_URL=$(gh pr create {GH_FLAG} --base {PR_BASE} --head {BRANCH} \
  --title "{Fix|Feat|Refactor}: {description}" \
  --body "## Summary
{BRIEF_DESCRIPTION}

## Changes
{CHANGES_LIST}

## Testing
{TESTING_CHECKLIST}

---
Closes #{NUMBER}
**Implementation branch**: \`{BRANCH}\`
**Base**: \`{PR_BASE}\`")
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
```

`Closes #{NUMBER}` documents intent but does NOT auto-close for non-default-branch PRs. Capture `PR_NUMBER` here — Phase 5A reuses it instead of re-querying `gh pr list`.

If PR already exists for this branch, use the existing PR number.

### 4E: Update labels
```bash
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} in-review ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:in-review" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:awaiting-merge,workflow:merged,workflow:invalid,workflow:decomposed" 2>/dev/null || true
    ;;
esac
```

---

## Phase 5: Auto-Review

### 5A: Confirm state (reuse in-context; re-read only on compaction)

**Post Phase 5 heartbeat** (skip unless `UNDER_ORCHESTRATION` is `true`; also skip if issue already has a terminal label — `workflow:merged`, `workflow:invalid`, `needs-human`, `workflow:awaiting-merge`):
```bash
PHASE_START_TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:HEARTBEAT -->
**Phase**: Phase 5 — Review
**Timestamp**: ${PHASE_START_TIMESTAMP}
**Issue**: #{NUMBER}"
```

`PR_NUMBER` was already captured from `gh pr create`'s output in Phase 4D — reuse it. Only run the lookup below if `PR_NUMBER` is genuinely unset (resumed session after compaction, or checkpoint routing jumped straight to Phase 4/5):
```bash
gh issue view {NUMBER} {GH_FLAG} --json number,title,body,labels,state
PR_NUMBER=$(gh pr list {GH_FLAG} --head {BRANCH} --json number --jq '.[0].number')
```

### 5B: Post progress comment
```bash
gh issue comment {NUMBER} {GH_FLAG} --body "## Submitting for Review

PR #${PR_NUMBER} created targeting \`{PR_BASE}\`. Invoking /review-pr with --auto-merge.

<!-- FORGE:REVIEW_STARTED -->"
```

### 5C: Invoke /review-pr with --auto-merge

**Always fork — Row (d) supersedes Row (c) as the controlling reason** (run before invoking review-pr): <!-- Updated: forge#1825 (previously threshold-gated by Row (c) alone — forge#93) -->

`/review-pr` spawns domain review agent(s) (observed at `effort: xhigh`) that run for minutes — long enough to idle the parent's accumulated context past the prompt cache's ~5-minute TTL under **Spawn-Decision Policy Row (d)**, regardless of build size — see the [Spawn-Decision Table](#spawn-decision-table). There is no small-build exception: review ALWAYS forks.

- ALWAYS invoke `work-on/review` as a fresh sub-agent (via `Skill(skill="work-on/review", args="...")`) rather than calling review-pr directly. The sub-agent starts with a clean context window and re-reads all needed state from GitHub (Phase R0) — it does not depend on anything the parent accumulated.
- **Fallback only**: if `work-on/review` is not available (partial install), invoke review-pr directly and add a note in the progress comment that context isolation was degraded for this run.

**Sub-agent invocation** (always, regardless of changed-file count or Skill-invocation count):

Before spawning, build a distilled hot-copy of the key annotations the review sub-agent would otherwise re-fetch from GitHub. This reduces gh round-trips in the child without replacing the durable FORGE annotation record. <!-- Added: forge#1277 -->

```bash
# Hot-copy: extract CONTRACT and ARCHITECT annotation bodies for inline injection
HOT_CONTRACT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("<!-- FORGE:CONTRACT -->"))] | last | .body // ""' 2>/dev/null \
  | head -60)  # Scope: first 60 lines — captures Proposed Approach + Deliverables table

HOT_ARCHITECT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("<!-- FORGE:ARCHITECT -->"))] | last | .body // ""' 2>/dev/null \
  | head -40)  # Scope: first 40 lines — captures Affected Paths + Implementation Order

# Build inline context block (omit sections where annotation was not found)
HOT_COPY_BLOCK=""
if [ -n "$HOT_CONTRACT" ]; then
  HOT_COPY_BLOCK="${HOT_COPY_BLOCK}
**HOT COPY — FORGE:CONTRACT** (do not re-fetch; durable record is on the issue):
${HOT_CONTRACT}"
fi
if [ -n "$HOT_ARCHITECT" ]; then
  HOT_COPY_BLOCK="${HOT_COPY_BLOCK}

**HOT COPY — FORGE:ARCHITECT** (do not re-fetch; durable record is on the issue):
${HOT_ARCHITECT}"
fi
```

```
Skill(skill="work-on/review", args="{NUMBER} --repo {GH_REPO} --gh-flag {GH_FLAG} --worktree {WORKTREE_PATH} --branch {BRANCH} --base {PR_BASE}", context="{HOT_COPY_BLOCK}")
```

The `{HOT_COPY_BLOCK}` is an optimization that avoids the child re-discovering context already held by the parent. The FORGE annotations on GitHub remain the durable, compaction-safe record. If the hot-copy block is empty (annotations not yet posted), the sub-agent falls back to reading them from GitHub as before.

**Fallback invocation** (only when `work-on/review` is unavailable):
```
Skill(skill="review-pr", args="{PR_NUMBER} --auto-merge --issue {NUMBER} --base {PR_BASE} --gh-flag {GH_FLAG}")
```

Review-pr handles: full domain-agent review → post findings as separate issues → merge PR. It does NOT close the issue or clean up the worktree — those run in Phase 6.

### 5D: Verify merge and close (recovery)

```bash
gh pr view {PR_NUMBER} {GH_FLAG} --json state,mergedAt --jq '{state: .state, mergedAt: .mergedAt}'
gh issue view {NUMBER} {GH_FLAG} --json state --jq '.state'
```

- PR MERGED + issue CLOSED → write checkpoint, then proceed to Phase 6
- PR MERGED + issue OPEN → close issue manually, write checkpoint, proceed to Phase 6
- PR NOT MERGED → `gh pr merge {PR_NUMBER} {GH_FLAG} --merge --auto`. If fails → post comment, add `needs-human`, STOP.

**When PR is MERGED — write machine-readable phase checkpoint (MANDATORY)**:
```bash
CHECKPOINT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:CHECKPOINT -->
\`\`\`json
{\"phase\": \"REVIEW\", \"status\": \"COMPLETE\", \"next_phase\": \"CLOSE\", \"timestamp\": \"${CHECKPOINT_TIMESTAMP}\"}
\`\`\`"
```

<!-- FORGE:PHASE_COMPLETE — Review done, PR merged. See Universal Phase Dispatcher: next phase is Phase 6 (Close & Cleanup). Not terminal — continue immediately. -->

**After /review-pr returns and the PR is confirmed merged: immediately proceed to Phase 6 (Close & Cleanup). Do NOT stop here. `REVIEW_RESULT: status: COMPLETE` is an intermediate result — the pipeline is NOT done. Invoke Phase 6 now to close the issue, update labels, post the trajectory log, and clean up the worktree.**

**Do NOT output any text describing this transition. Do NOT write phrases like "returning to work-on", "proceeding to close", "now invoking Phase 6", or any narrative summary of what comes next. Do NOT emit end_turn. Execute Phase 6 code immediately.** <!-- Added: forge#93 -->

---

## Phase 6: Close & Cleanup

### 6A: Final issue body update

**Multi-phase guard**: Detect whether the issue has multiple phases. Only check off items belonging to the current completed phase.

**This read is intentionally NOT covered by the session-state-cache rule** — the body is about to be rewritten below, and Phase 5's `/review-pr` invocation is an external process that can post comments/edits between the last read and here. Writing back a stale cached body would silently revert any concurrent change, so always fetch fresh immediately before a body mutation.

```bash
BODY=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body')
REMAINING_BEFORE=$(printf '%s\n' "$BODY" | grep -cE '^[-*+] \[ \]' || true)

# Structural test: count heading-delimited sections that contain checkbox items.
# Multi-phase == 2+ checkbox-bearing sections. A single checkbox group is
# single-phase regardless of how many prose headings surround it.
#
# - Fenced code blocks are stripped first: issue bodies routinely embed fenced
#   blocks whose lines start with '#' or contain a literal '- [ ]'. An
#   unterminated fence keeps the original body so later work is never hidden.
# - ATX headings and setext underlines both delimit sections. The ATX pattern
#   avoids awk interval-quantifier variance across awk implementations.
# - grep -E / awk only — no PCRE. '^#+ ' needs none.
FENCE_COUNT=$(printf '%s\n' "$BODY" | grep -cE '^(```+|~~~+)' || true)
if [ $(( ${FENCE_COUNT:-0} % 2 )) -ne 0 ]; then
  BODY_STRIPPED="$BODY"
else
  BODY_STRIPPED=$(printf '%s\n' "$BODY" | awk '/^(```+|~~~+)/{f=!f; next} !f')
fi

CHECKBOX_SECTIONS=$(printf '%s\n' "$BODY_STRIPPED" | awk '
  /^#+ / { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
  /^(=+|-+)$/ && previous != "" { if (in_section && has) n++; in_section=1; has=0; previous=""; next }
  { if (in_section && /^[-*+] \[[ xX]\]/) has=1; previous=$0 }
  END { if (in_section && has) n++; print n+0 }
')

# Sub-issue-tracker guard: a decompose parent whose only checkbox group is
# '## Sub-Issue Tracker' counts 1 section. Checking those off would mark open
# sub-issues done and close the tracker, so any unchecked GFM task item for an
# issue forces multi-phase.
SUBISSUE_ITEMS=$(printf '%s\n' "$BODY_STRIPPED" | grep -cE '^[-*+] \[ \] #[0-9]+' || true)
```

**Sync invariant:** Keep the structural computation above and its consuming multi-phase guard in `commands/work-on/close.md` Phase C1 synchronized. The guard is `CHECKBOX_SECTIONS >= 2 OR SUBISSUE_ITEMS > 0`; this Phase 6A path is reached only when `REMAINING_BEFORE > 0`. <!-- Fixed: forge#2840, #2874 -->

If multi-phase (`CHECKBOX_SECTIONS >= 2` OR a `- [ ] #NNN` sub-issue item is present): do NOT check off future phase items. Add PR reference only. A heading count is **not** the test — every templated issue carries `## Problem`/`## Evidence`/`## Context`, so it is `> 0` universally.

If single-phase or final phase: check off all `[ ]` items, add PR reference.

### 6B: Project board update (Status=Done, Workflow=Merged)

Resolve `PROJECT_BOARD_OWNER` and `PROJECT_BOARD_NUMBER` from `forge.yaml → project_board` (fields: `owner`, `project_number`). Fall back to `forge.yaml → project.owner` and project number `1` if `project_board` section is absent.

```bash
ISSUE_URL="https://github.com/{GH_REPO}/issues/{NUMBER}"
ITEM_ID=$(gh project item-list {PROJECT_BOARD_NUMBER} --owner {PROJECT_BOARD_OWNER} --format json --limit 200 \
  --jq ".items[] | select(.content.url == \"$ISSUE_URL\") | .id" 2>/dev/null | head -1)
```

If found: set Status=Done, Workflow=Merged using project field IDs from `forge.yaml → project_board.field_ids`.

### 6C: Ensure issue is closed

**Multi-phase guard**: If `REMAINING_AFTER > 0`, uncompleted phases remain. Post phase-complete comment, leave issue open, EXIT — router picks up next phase on next iteration.

If all phases complete:
```bash
gh issue close {NUMBER} {GH_FLAG} \
  --comment "Closed: PR #{PR_NUMBER} merged to \`{PR_BASE}\`. Closes #{NUMBER}."
RESOLUTION=$(resolve_script 'transition-label')
TIER="${RESOLUTION%%:*}"; SCRIPT_PATH="${RESOLUTION#*:}"
case "$TIER" in
  adaptive|universal) bash "$SCRIPT_PATH" {NUMBER} {GH_FLAG} merged ;;
  prose)
    gh issue edit {NUMBER} {GH_FLAG} --add-label "workflow:merged" \
      --remove-label "workflow:investigating,workflow:ready-to-build,workflow:building,workflow:in-review,workflow:awaiting-merge,workflow:invalid,workflow:decomposed" 2>/dev/null || true
    ;;
esac
```

### 6D: Parent tracker update (sub-issues only)

**Skip if**: Not a sub-issue (no parent reference in body).

Markdown emphasis markers (`**bold**`, `__bold__`, `*italic*`) are stripped before matching, since sub-issue bodies commonly render the label as `**Parent**: #NNN` and the bare label alternation below would otherwise fail to match past the emphasis characters:
```bash
PARENT_REF=$(gh issue view {NUMBER} {GH_FLAG} --json body --jq '.body' \
  | sed -E 's/[*_]+//g' \
  | grep -iE '(part of|spawned from|sub-issue of|parent issue:?|parent:)\s*#[0-9]+' \
  | sed -n 's/.*#\([0-9][0-9]*\).*/\1/p' | head -1)
```

If parent found: check off this sub-issue in parent body. If ALL sub-issues checked off → close parent with `workflow:merged`.

### 6E: Worktree & branch cleanup

```bash
if [ -n "{WORKTREE_PATH}" ] && [ -d "{WORKTREE_PATH}" ]; then
  GIT_COMMON=$(git -C {WORKTREE_PATH} rev-parse --git-common-dir 2>/dev/null)
  REPO_ROOT=$(dirname "$(realpath "$GIT_COMMON" 2>/dev/null || echo "$GIT_COMMON")")
  git -C "$REPO_ROOT" worktree remove {WORKTREE_PATH} --force 2>/dev/null || true
  if [ -n "{BRANCH}" ]; then
    git -C "$REPO_ROOT" branch -D {BRANCH} 2>/dev/null || true
  fi
fi
```

---

## Phase 7: Summary & Trajectory

### 7A: Report + Pipeline Summary Card

Output the terse report, then render the shareable **Pipeline Summary Card** — the shareable
moment a developer screenshots. Gather real stats (commits, additions/deletions, PR target,
review summary, elapsed time) and render exactly as specified in `work-on/close.md` Phase C4.5
(`C4.5a` stats gathering → `C4.5b` box-drawing card to stdout → `C4.5c` machine-readable twin).
This inline path and the delegated `close.md` path MUST produce an identical card.

```
## Done: #{NUMBER} — {TITLE}
- Investigation: {VERDICT} ({CONFIDENCE})
- Lane: {FAST/FEATURE}
- Fix: {BRANCH} → PR #{PR_NUMBER} → merged to `{PR_BASE}`
- Files changed: {COUNT}
```

Then print the card to stdout (inner width 51; truncate long titles with `…`; missing stats
render `—`; pipeline line reflects the actual terminal state — merged / decomposed / invalid /
blocked; draft PRs append `(draft)`):

```
╔═══════════════════════════════════════════════════╗
║  ForgeDock Pipeline Complete                      ║
╠═══════════════════════════════════════════════════╣
║                                                   ║
║  Issue:    #{NUMBER} — {TITLE}                    ║
║  Pipeline: investigate → architect → build →      ║
║            review → merge ✓                       ║
║  Commits:  {COMMITS} ({ADDITIONS} additions, {DELETIONS} deletions) ║
║  PR:       #{PR_NUMBER} (merged to {PR_BASE})     ║
║  Review:   {REVIEW_SUMMARY}                       ║
║  Time:     {ELAPSED}                              ║
║                                                   ║
╚═══════════════════════════════════════════════════╝
```

**Gather real stats** (C4.5a — this block MUST run on the inline path to populate card variables;
do NOT rely on the cross-reference to `close.md` alone):

```bash
PR_STATS=$(gh pr view {PR_NUMBER} {GH_FLAG} --json commits,additions,deletions,baseRefName,isDraft 2>/dev/null)
COMMITS=$(echo "$PR_STATS"   | jq -r '(.commits | length) // empty' 2>/dev/null); COMMITS=${COMMITS:-—}
ADDITIONS=$(echo "$PR_STATS" | jq -r '.additions // empty' 2>/dev/null); ADDITIONS=${ADDITIONS:-—}
DELETIONS=$(echo "$PR_STATS" | jq -r '.deletions // empty' 2>/dev/null); DELETIONS=${DELETIONS:-—}
PR_TARGET=$(echo "$PR_STATS" | jq -r '.baseRefName // empty' 2>/dev/null); PR_TARGET=${PR_TARGET:-{PR_BASE}}
IS_DRAFT=$(echo "$PR_STATS"  | jq -r '.isDraft // false' 2>/dev/null)

REVIEW_BODIES=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
  --jq '[.reviews[].body // ""] + [.comments[].body // ""] | .[]' 2>/dev/null)
# NOTE: `grep -c` already prints `0` on no match (and exits non-zero) — do NOT add
# `|| echo 0`, which would append a second line ("0\n0") and break the arithmetic
# and `--argjson` below. Swallow the non-zero exit with `|| true`, then default.
APPROVED=$(echo "$REVIEW_BODIES" | grep -cE 'APPROVED:' 2>/dev/null || true); APPROVED=${APPROVED:-0}
CHANGES=$(echo  "$REVIEW_BODIES" | grep -cE 'CHANGES REQUESTED:' 2>/dev/null || true); CHANGES=${CHANGES:-0}
TOTAL_AGENTS=$((APPROVED + CHANGES))
BLOCKERS=$(echo "$REVIEW_BODIES" | grep -ciE 'blocker|merge.?block' 2>/dev/null || true); BLOCKERS=${BLOCKERS:-0}
if [ "$TOTAL_AGENTS" -gt 0 ]; then
  REVIEW_SUMMARY="${APPROVED}/${TOTAL_AGENTS} agents passed, ${BLOCKERS} blockers"
else
  REVIEW_SUMMARY="—"   # review data unavailable (e.g. review skipped)
fi

FIRST_TS=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:")) | .created_at] | sort | .[0] // empty' 2>/dev/null)
if [ -n "$FIRST_TS" ]; then
  START_EPOCH=$(date -u -d "$FIRST_TS" +%s 2>/dev/null \
    || python3 -c "import sys,datetime; ts=sys.argv[1].rstrip('Z'); print(int(datetime.datetime.fromisoformat(ts+'+00:00').timestamp()))" "$FIRST_TS" 2>/dev/null \
    || echo "")
  NOW_EPOCH=$(date -u +%s)
  if [ -n "$START_EPOCH" ]; then
    ELAPSED_SECS=$((NOW_EPOCH - START_EPOCH))
    ELAPSED=$(printf '%dm %02ds' $((ELAPSED_SECS / 60)) $((ELAPSED_SECS % 60)))
  else ELAPSED="—"; ELAPSED_SECS=0; fi
else ELAPSED="—"; ELAPSED_SECS=0; fi

case "{TERMINAL_STATE}" in
  decomposed) PIPELINE_LINE="investigate → decompose ⏹"; CARD_STATUS="decomposed" ;;
  invalid)    PIPELINE_LINE="investigate → invalid ✗";   CARD_STATUS="invalid" ;;
  blocked)    PIPELINE_LINE="investigate → build → blocked ⚠"; CARD_STATUS="blocked" ;;
  *)          PIPELINE_LINE="investigate → architect → build → review → merge ✓"; CARD_STATUS="merged" ;;
esac
[ "$IS_DRAFT" = "true" ] && PIPELINE_LINE="${PIPELINE_LINE} (draft)"
```

**Build the machine-readable twin** (C4.5c — MUST run this block to assign `CARD_JSON` before
Phase 7B embeds it; the cross-reference to `close.md` above is insufficient on the inline path): <!-- forge#1178 -->

```bash
CARD_JSON=$(jq -nc \
  --argjson issue {NUMBER} \
  --arg title "{TITLE}" \
  --arg status "$CARD_STATUS" \
  --arg pipeline "$PIPELINE_LINE" \
  --arg pr "{PR_NUMBER}" \
  --arg target "$PR_TARGET" \
  --arg commits "$COMMITS" --arg adds "$ADDITIONS" --arg dels "$DELETIONS" \
  --arg review "$REVIEW_SUMMARY" --argjson blockers "${BLOCKERS:-0}" \
  --argjson elapsed "${ELAPSED_SECS:-0}" \
  '{issue:$issue, title:$title, status:$status, pipeline:$pipeline,
    pr:($pr|tonumber? // null), pr_target:$target,
    commits:($commits|tonumber? // null),
    additions:($adds|tonumber? // null),
    deletions:($dels|tonumber? // null),
    review:$review, blockers:$blockers, elapsed_seconds:$elapsed}')
```

`CARD_JSON` is now set and embedded in the trajectory comment by 7B.

### 7B: Trajectory Log (MANDATORY)

**Review-presence check** (run before filling in Phase 4-5 row): <!-- Added: forge#381 -->
```bash
# Check whether /review-pr was actually invoked — look for review agent comments on the PR
REVIEW_PRESENT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json reviews,comments \
  --jq '([.reviews[].body // ""] + [.comments[].body // ""]) |
        map(select(test("APPROVED:|CHANGES REQUESTED:|FORGE:REVIEWER|review-pr";"i"))) |
        length > 0')
# Set Phase 4-5 row: ✅ Merged if review present, ⚠ Skipped (no review) if not
REVIEW_ROW=$([ "$REVIEW_PRESENT" = "true" ] && echo "✅ Merged" || echo "⚠ Skipped (no review)")
```

This check is **audit-only** — it annotates the trajectory for visibility. It cannot retroactively block a merged PR. If `⚠ Skipped (no review)` is emitted, log it in the Anomalies field so the skip is surfaced during pipeline health review.

Post `<!-- FORGE:TRAJECTORY -->` comment with phase-by-phase results table:

```bash
# Compute verification row from VERIFICATION_SKIPPED_CHECKS (set in Phase 3H)
if [ -z "$VERIFICATION_SKIPPED_CHECKS" ]; then
  VERIFICATION_ROW="✅ Ran"
else
  VERIFICATION_ROW="⚠ Skipped — verification.commands not configured for: ${VERIFICATION_SKIPPED_CHECKS}"
fi

gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:TRAJECTORY -->
## Pipeline Trajectory — #{NUMBER}

| Phase | Result | Notes |
|-------|--------|-------|
| Phase 0: Context Load | ✅ Complete | {lane} → \`{PR_BASE}\` |
| Phase 1: Investigation | ✅ {VERDICT} ({CONFIDENCE}) | Task type: {TASK_TYPE} |
| Phase 2: Decomposition | ⏭ Skipped | {reason} |
| Phase 3: Build | ✅ Complete | Branch: \`{BRANCH}\` |
| Phase 3G: Quality Gate | ✅ Gate passed | {iterations} iterations |
| Phase 3H: Verification | ${VERIFICATION_ROW} | |
| Phase 4–5: Review + PR | {REVIEW_ROW} | PR #{PR_NUMBER} → \`{PR_BASE}\` |
| Phase 6: Close | ✅ Complete | Issue closed |

**Decisions**: {key decisions}
**Anomalies**: {anomalies or None}
**Pipeline completed**: {TIMESTAMP}

<!-- FORGE:CARD ${CARD_JSON} -->"
```

Append the `<!-- FORGE:CARD {...} -->` block (machine-readable twin from 7A / close.md C4.5c)
as the last line of the trajectory comment. It is HTML-comment-wrapped so it stays hidden in
the rendered view but greppable for platform consumption (`/orchestrate` Phase 6 reads it for
per-issue cards). Additive — does not affect existing `FORGE:TRAJECTORY` table consumers.

### 7C: Graph Decision Record (MANDATORY when PR exists)

**Skip if**: `{PR_NUMBER}` is empty (investigation-only tasks with no PR) OR `<!-- FORGE:DECISION_RECORD -->` already posted on the PR.

**Purpose**: Post a single consolidated provenance artifact to the PR that proves the merge was backed by citable evidence. Enables downstream benchmarking queries (repeated-mistake rate, stale-edge hit rate, review escape rate) by making every pipeline run queryable via `gh api`.

**Idempotency check**:
```bash
GDR_EXISTS=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:DECISION_RECORD"))] | length > 0' 2>/dev/null || echo "false")
```

**Extract context edge counts** from FORGE:CONTEXT comment (already posted on issue):
```bash
CONTEXT_COMMENT=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '.[] | select(.body | contains("FORGE:CONTEXT")) | .body' 2>/dev/null | head -1)

# Count historical review-finding issue references (#NNN patterns in Context comment)
REVIEW_FINDING_COUNT=$(echo "$CONTEXT_COMMENT" | grep -oE '#[0-9]+' | wc -l | tr -d ' ')
REVIEW_FINDING_COUNT=${REVIEW_FINDING_COUNT:-0}
```

**Extract review verdict and findings count** from PR review summary (Phase 9 of review-pr):
```bash
REVIEW_SUMMARY=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:REVIEWER") or (.body | test("APPROVED:|CHANGES REQUESTED:"; "i")))] | last | .body // ""' 2>/dev/null || echo '')

REVIEW_VERDICT=$(echo "$REVIEW_SUMMARY" | sed -n 's/.*Verdict: \(APPROVED\|CHANGES REQUESTED\).*/\1/p' | head -1 || echo "APPROVED")
REVIEW_VERDICT="${REVIEW_VERDICT:-APPROVED}"
FINDINGS_COUNT=$(echo "$REVIEW_SUMMARY" | grep -oE '[0-9]+ findings' | grep -oE '[0-9]+' | head -1 || echo "0")
FINDINGS_COUNT="${FINDINGS_COUNT:-0}"
AGENTS_RUN=$(echo "$REVIEW_SUMMARY" | grep -oE '[0-9]+ agents' | grep -oE '[0-9]+' | head -1 || echo "0")
AGENTS_RUN="${AGENTS_RUN:-0}"
```

**Capture best-effort cost signal** from session telemetry before posting GDR. This is best-effort — if the signal is unavailable, the cost block is omitted rather than blocking the pipeline or fabricating a number. Field names align with `bin/runner.mjs` usage accounting from #1295 so downstream tooling shares one schema:
```bash
# Best-effort: read per-stage usage from FORGE:BUILDER/FORGE:CONTEXT/FORGE:ARCHITECT phase annotations
# Source: session telemetry when available (e.g. OTEL_LOG_TOOL_DETAILS, Claude Code usage reporting).
# If unavailable, set COST_BLOCK to null — the field is omitted from the GDR rather than fabricated.
COST_INVESTIGATION=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body] | last // ""' 2>/dev/null \
  | grep -oP '(?<=cost_usd: )\S+' | head -1 || echo "")
COST_BUILD=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:BUILDER")) | .body] | last // ""' 2>/dev/null \
  | grep -oP '(?<=cost_usd: )\S+' | head -1 || echo "")
COST_REVIEW=$(gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments \
  --jq '[.[] | select(.body | contains("FORGE:REVIEWER")) | .body] | last // ""' 2>/dev/null \
  | grep -oP '(?<=cost_usd: )\S+' | head -1 || echo "")

# Build cost block JSON only if at least one stage value is present; otherwise null
if [ -n "$COST_INVESTIGATION" ] || [ -n "$COST_BUILD" ] || [ -n "$COST_REVIEW" ]; then
  COST_INV_JSON="${COST_INVESTIGATION:-null}"
  COST_BUILD_JSON="${COST_BUILD:-null}"
  COST_REVIEW_JSON="${COST_REVIEW:-null}"
  COST_BLOCK="\"cost\": {
    \"stages\": {
      \"investigation\": $COST_INV_JSON,
      \"build\": $COST_BUILD_JSON,
      \"review\": $COST_REVIEW_JSON
    },
    \"total_usd\": null,
    \"source\": \"session-telemetry\"
  },"
else
  COST_BLOCK=""
fi
```

**Post GDR to PR** (not to issue — PR comment survives as permanent artifact on the merged diff):
```bash
if [ "$GDR_EXISTS" != "true" ] && [ -n "{PR_NUMBER}" ]; then
  GDR_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  HEAD_SHA=$(gh pr view {PR_NUMBER} {GH_FLAG} --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
  MERGE_COMMIT=$(gh pr view {PR_NUMBER} {GH_FLAG} --json mergeCommit --jq '.mergeCommit.oid // ""' 2>/dev/null || echo "")

  gh pr comment {PR_NUMBER} {GH_FLAG} --body "<!-- FORGE:DECISION_RECORD -->
## Graph Decision Record — Issue #${NUMBER} / PR #${PR_NUMBER}

\`\`\`json
{
  \"schema_version\": \"1\",
  \"issue\": ${NUMBER},
  \"pr\": ${PR_NUMBER},
  \"repo\": \"{GH_REPO}\",
  \"lane\": \"{lane}\",
  \"pr_base\": \"{PR_BASE}\",
  \"branch\": \"{BRANCH}\",
  \"head_sha\": \"${HEAD_SHA}\",
  \"merge_commit\": \"${MERGE_COMMIT}\",
  \"investigation\": {
    \"verdict\": \"{VERDICT}\",
    \"confidence\": \"{CONFIDENCE}\",
    \"task_type\": \"{TASK_TYPE}\"
  },
  ${COST_BLOCK}
  \"context\": {
    \"historical_edges_referenced\": ${REVIEW_FINDING_COUNT},
    \"forge_annotations_read\": [\"FORGE:INVESTIGATOR\", \"FORGE:CONTRACT\", \"FORGE:CONTEXT\", \"FORGE:ARCHITECT\", \"FORGE:BUILDER\"]
  },
  \"build\": {
    \"files_changed\": {FILES_CHANGED},
    \"quality_gate\": \"{pass|fail}\",
    \"quality_gate_iterations\": {GATE_ITERATIONS}
  },
  \"review\": {
    \"verdict\": \"${REVIEW_VERDICT:-APPROVED}\",
    \"findings_created\": ${FINDINGS_COUNT},
    \"agents_run\": ${AGENTS_RUN}
  },
  \"merge\": {
    \"merged_at\": \"${GDR_TIMESTAMP}\",
    \"justification\": \"Investigation confirmed ({VERDICT}/{CONFIDENCE}), quality gate passed, review ${REVIEW_VERDICT:-approved}\"
  }
}
\`\`\`

**Queryable**: \`gh api repos/{GH_REPO}/issues/{PR_NUMBER}/comments --jq '[.[] | select(.body | contains(\"FORGE:DECISION_RECORD\"))] | .[0].body\`"
fi
```

**Benchmarking**: Query all GDRs for a repo to compute pipeline metrics:
```bash
# Fetch all merged PRs and extract their GDR JSON blocks for metric computation
# (used by /pipeline-health to measure repeated-mistake rate, stale-edge hit rate, etc.)
gh pr list -R {GH_REPO} --state merged --limit 100 --json number \
  --jq '.[].number' | while read pr; do
    gh api repos/{GH_REPO}/issues/$pr/comments \
      --jq '.[] | select(.body | contains("FORGE:DECISION_RECORD")) | .body' 2>/dev/null
  done
```

<!-- Added: forge#776 -->

---

## Error Handling

- Worktree exists: reuse or clean up
- PR creation fails: check if branch pushed, if PR already exists
- Merge conflicts: report to user, do NOT auto-resolve
- gh CLI fails: check `gh auth status`
- Label missing: run `npx forgedock labels setup` (from the project directory, or pass `--repo owner/repo`) to idempotently bootstrap all ForgeDock-managed labels with canonical colors and descriptions. Alternatively: `gh label create "{name}" --color {hex} --description "Managed by ForgeDock." --force -R {GH_REPO}`
