{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/agent-state.schema.json",
  "title": "Multi-Agent Pipeline  -  agent-state.json",
  "description": "Canonical state file for a single pipeline run. Written to $HOME/.claude/logs/multi-agent/{project}/{task-id}/agent-state.json and read by every phase. Versioned so older runs can be migrated or ignored.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schemaVersion",
    "taskId",
    "shortId",
    "project",
    "branch",
    "baseBranch",
    "currentPhase",
    "status",
    "startedAt",
    "phases",
    "identity"
  ],
  "properties": {
    "schemaVersion": {
      "type": "string",
      "enum": ["2.0.0", "2.1.0", "2.2.0"],
      "description": "v2.0.0: single-repo only (scalar project/projectRoot/worktreePath). v2.1.0: adds optional projects[] array for multi-repo tasks  -  single-repo fields remain for backward compat; when projects[] has >1 entries, multi-repo mode is active."
    },
    "taskId": {
      "type": "string",
      "description": "Canonical task id  -  Jira key (PROJ-12345), GitHub issue number (#316), or free-text slug.",
      "minLength": 1
    },
    "shortId": {
      "type": "integer",
      "minimum": 1,
      "description": "Auto-incremented local short id from .worktrees/.multi-agent-counter  -  unique per machine, not per project."
    },
    "sessionId": {
      "type": ["string", "null"],
      "description": "The Claude Code session id this run was launched under, copied by Phase 0 from MULTI_AGENT_SESSION_ID when the launcher set it. Continuous mode sets it to the id it passed as --session-id, and matches this field to find ITS run's state rather than another run in the same repo. Absent or null on an attended run: nothing sets the variable there."
    },
    "title": {
      "type": ["string", "null"],
      "description": "The issue title Phase 0 fetched (GitHub title, Jira summary). runs-index.mjs carries it as the run's title; absent when the input was free text or nothing was fetched."
    },
    "jiraId": {
      "type": ["string", "null"],
      "description": "Jira issue key (PROJ-12345) if the task originated from Jira, else null."
    },
    "githubIssue": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "properties": {
        "owner": {
          "type": "string"
        },
        "repo": {
          "type": "string"
        },
        "number": {
          "type": "integer",
          "minimum": 1
        }
      },
      "required": ["owner", "repo", "number"],
      "description": "GitHub issue coordinates if the task originated from GitHub, else null."
    },
    "project": {
      "type": "string",
      "description": "Project slug  -  used to partition logs and knowledge base."
    },
    "projectRoot": {
      "type": "string",
      "description": "Absolute path to the main checkout."
    },
    "worktreePath": {
      "type": ["string", "null"],
      "description": "Absolute path to the task worktree. Null when the Step 5b picker answered local."
    },
    "branch": {
      "type": "string",
      "description": "Working branch for this task."
    },
    "baseBranch": {
      "type": "string",
      "description": "PR target branch (e.g. develop, main)."
    },
    "baseFetchStatus": {
      "type": "string",
      "enum": ["fresh", "cached-stale", "local-branch", "aborted"],
      "description": "What the base ref is worth. fresh = git fetch origin succeeded; cached-stale = the fetch failed and the user chose the remote-tracking cache; local-branch = the fetch failed and the user chose the local branch; aborted = the user stopped the run at the fetch-fail picker. phase0-exit-gate.mjs has required this field since v17.0, while this schema forbade it under additionalProperties: false - so a state that satisfied the gate failed validation and vice versa. Declared here as of v17.5.0."
    },
    "baseBranchSource": {
      "type": "string",
      "enum": ["asked", "input", "remembered", "default", "derived"],
      "description": "How baseBranch was decided. asked = the user answered the Step 3 picker; input = it arrived with the task reference; remembered = autopilot took the most recent entry in prefs.global.recentBranches still inside the TTL and still on the remote; default = autopilot fell back to the develop/release/main sort order; derived = autopilot took the top base-branch-candidates.mjs candidate, which carried issue-version or linked-release evidence and tied with nothing. The last three are autopilot resolutions: an autopilot run cannot be asked anything, so recording which rule fired is what keeps it readable afterwards, and an interactive run that records one has skipped its picker. A derived branch that a human then confirmed is still asked - the derivation is recorded in baseBranchEvidence, not in this field."
    },
    "baseBranchEvidence": {
      "type": "object",
      "additionalProperties": false,
      "description": "v17.5.0+ - what Step 3 knew when it chose the base branch (refs/features/base-branch-evidence.md). Required when baseBranchSource is derived, and whenever baseFetchStatus is cached-stale or local-branch: a run may degrade to local refs, it may not report a local-only list as the remote's answer.",
      "required": ["refProvenance"],
      "properties": {
        "refProvenance": {
          "type": "string",
          "enum": ["remote", "local"],
          "description": "Where the candidate ref list came from. local means the fetch failed and the list is the local cache plus local heads - possibly stale, possibly incomplete."
        },
        "chosen": {
          "type": "string"
        },
        "ambiguous": {
          "type": "boolean",
          "description": "Two or more candidates tied at the top score. Autopilot may not record derived when this is true."
        },
        "convention": {
          "type": ["object", "null"],
          "additionalProperties": false,
          "description": "The release-branch template inferred from the refs that exist, never from a built-in table.",
          "properties": {
            "template": {
              "type": "string"
            },
            "members": {
              "type": "integer",
              "minimum": 0
            }
          }
        },
        "candidates": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["branch"],
            "properties": {
              "branch": {
                "type": "string"
              },
              "score": {
                "type": "number"
              },
              "refs": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "evidence": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": ["kind"],
                  "properties": {
                    "kind": {
                      "type": "string",
                      "enum": [
                        "issue-version",
                        "linked-release",
                        "version-convention",
                        "recent",
                        "repo-default",
                        "sort-order",
                        "ref-provenance"
                      ]
                    },
                    "detail": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "notes": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "askedOnIssue": {
          "type": ["object", "null"],
          "additionalProperties": false,
          "description": "The one comment autopilot is allowed to post when the derivation is ambiguous, gated by prefs.global.baseBranchEvidence.autopilotAsksOnIssue (default false). A question, never a state change: no transition, no close, no assignee. Posting it trips circuit-breaker trigger 6 and the run waits for resume.",
          "properties": {
            "target": {
              "type": "string"
            },
            "url": {
              "type": "string"
            },
            "at": {
              "type": "string",
              "format": "date-time"
            }
          }
        }
      }
    },
    "maturity": {
      "type": "object",
      "additionalProperties": false,
      "description": "How ready the fetched item was to be developed. Produced by lib/issue-fetcher.sh, which owns the gap codes and their localized wording. Read by Phase 0, Phase 3 and the clarifier.",
      "properties": {
        "score": {
          "type": ["integer", "null"],
          "minimum": 0,
          "maximum": 100,
          "description": "100 minus 40 per blocker and 10 per warning. Null for free-text input: there is no item to be immature."
        },
        "blockers": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Stable gap codes that stop a run: status_closed, already_resolved, description_empty."
        },
        "warnings": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Stable gap codes that do not stop a run on their own."
        },
        "summary": {
          "type": "string",
          "description": "The blockers and warnings rendered in outputLanguage. The one place this wording exists; maturity-followup.mjs quotes it rather than re-deriving it."
        },
        "accepted": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Gap codes a human explicitly waved through at the interactive maturity step. Recording WHICH gap was accepted is what separates an informed continue from a skipped check."
        }
      }
    },
    "maturityFollowup": {
      "type": "object",
      "additionalProperties": false,
      "required": ["gaps", "askedAt"],
      "description": "v17.6.0+ - the record of an autopilot run having asked about an immature item (refs/features/maturity-followup.md). Absent means never asked, which is what makes the first pass distinguishable from every pass after it.",
      "properties": {
        "gaps": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Sorted and deduplicated, so the next pass's comparison is stable rather than order-dependent."
        },
        "askedAt": {
          "type": "string",
          "format": "date-time"
        },
        "target": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "kind": {
              "type": "string",
              "enum": ["jira", "github", "confluence", "analysis", "unknown"]
            },
            "key": {
              "type": ["string", "null"]
            },
            "url": {
              "type": ["string", "null"]
            }
          }
        },
        "commentUrl": {
          "type": ["string", "null"]
        }
      }
    },
    "waitingFor": {
      "type": "string",
      "enum": ["maturity", "user-channels-choice", "question"],
      "description": "The step a paused run must RE-ENTER, as opposed to the phase it should continue past. resume reads this first and falls back to currentPhase + 1 when it is absent. maturity: Phase 0's maturity step, with the item re-fetched (refs/features/maturity-followup.md). user-channels-choice: Phase 5's channels menu, with the stored channelsInput. question: the step named by pendingQuestion.stepId, answered by `resume <id> --answer <optionId>`; open-questions-gate.mjs writes it as the last step of Phase 1 with stepId phase-1/open-questions."
    },
    "workspaceSource": {
      "type": "string",
      "enum": ["asked", "command", "autopilot", "request"],
      "description": "Who decided where the branch lives. asked = the user answered the Step 5b workspace picker; command = a flow that only ever builds worktrees stated it up front; autopilot = resolved to a worktree without asking, because an unattended commit in the user's own checkout is what worktrees prevent; request = the launch request (MA_LAUNCH_REQUEST) answered the picker before the run started. localMode alone cannot say: false is both a chosen worktree and one nothing asked about."
    },
    "launchRequest": {
      "type": "object",
      "additionalProperties": false,
      "required": ["contractVersion", "mode", "answered", "resolvedAt"],
      "description": "Present only when Phase 0 Step 0.1 applied a launch request (launch-request.mjs resolve). Records which pickers the request answered, so a reader can tell a pre-filled answer from one the user gave in the session. Absent on every run started without MA_LAUNCH_REQUEST.",
      "properties": {
        "contractVersion": { "type": "string" },
        "mode": { "enum": ["interactive", "autopilot", "background"] },
        "kind": { "enum": ["analysis", "development"] },
        "input": {
          "type": "string",
          "description": "The request's input, kept so the runs index can title a run launched from free text."
        },
        "answered": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Question ids from run-questions.json."
        },
        "resolvedAt": { "type": "string", "format": "date-time" }
      }
    },
    "pendingQuestion": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "required": ["id", "stepId", "text", "kind", "options"],
      "description": "A question a run stopped on, with waitingFor = question. Written by the step that needed an answer; answered by `/multi-agent:resume <id> --answer <optionId>` through answer-question.mjs, which accepts only one of options[].id and clears this record in the same write. The answer selects an option; it is never read as an instruction.",
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Stable question id, a run-questions.json id when the step has one."
        },
        "stepId": {
          "type": "string",
          "minLength": 1,
          "description": "The step to re-enter, e.g. phase-0/maturity or phase-3/user-test."
        },
        "text": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000,
          "description": "The question as the step would have asked it, in outputLanguage."
        },
        "kind": { "enum": ["single-select", "multi-select"] },
        "options": {
          "type": "array",
          "minItems": 2,
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["id", "label"],
            "properties": {
              "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9+_-]*$" },
              "label": { "type": "string", "minLength": 1 }
            }
          }
        },
        "askedAt": { "type": "string", "format": "date-time" }
      }
    },
    "research": {
      "type": "object",
      "additionalProperties": false,
      "required": ["round", "kind", "decision", "dir", "json", "md", "closed", "at"],
      "description": "The latest research pass over this run's gaps (refs/features/research.md), written by research-gate.mjs and by nothing else: the gate reads research/research.json, verifies every cited source, re-runs the check and records the result here. The files stay in the run's state directory; these are pointers plus the verdict. decision proceed: the check passed with the verified findings applied, and the run re-enters the step it was parked on (the maturity step, or Phase 2 for open questions). decision ask: the run is parked on a pendingQuestion that lists the gaps still open and the unverified candidates.",
      "properties": {
        "round": {
          "type": "integer",
          "minimum": 1,
          "description": "Passes applied to this run so far. The runner's own ceiling across runs of one item is autopilot config maxAskRounds, counted from attempted.jsonl."
        },
        "kind": { "enum": ["maturity", "open-questions"] },
        "decision": { "enum": ["proceed", "ask"] },
        "reason": { "type": "string" },
        "dir": { "type": "string", "minLength": 1 },
        "json": {
          "type": "string",
          "minLength": 1,
          "description": "research.json, schemas/research-output.schema.json."
        },
        "context": {
          "type": "string",
          "description": "context.json: the fetched descriptor and documents the evidence was checked against."
        },
        "md": {
          "type": "string",
          "minLength": 1,
          "description": "research.md, the self-contained story the dev run reads."
        },
        "gaps": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["id", "category", "status"],
            "properties": {
              "id": { "type": "string" },
              "category": { "type": "string" },
              "status": { "enum": ["closed", "candidate", "open"] },
              "sources": { "type": "array", "items": { "type": "object" } }
            }
          }
        },
        "closed": { "type": "array", "items": { "type": "string" } },
        "candidates": { "type": "array", "items": { "type": "string" } },
        "open": { "type": "array", "items": { "type": "string" } },
        "ignored": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Finding gap ids the run does not have."
        },
        "resolved": {
          "type": "array",
          "description": "Open questions closed by a verified source, for Phase 1 Step 5 to write into the analysis document with their source labels.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["id", "question", "answer", "sources"],
            "properties": {
              "id": { "type": "string" },
              "question": { "type": "string" },
              "answer": { "type": "string" },
              "sources": { "type": "array", "items": { "type": "object" } }
            }
          }
        },
        "maturityBefore": { "type": ["object", "null"] },
        "maturityAfter": { "type": ["object", "null"] },
        "at": { "type": "string", "format": "date-time" }
      }
    },
    "lastAnswer": {
      "type": "object",
      "additionalProperties": false,
      "required": ["questionId", "stepId", "optionIds", "answeredAt"],
      "description": "The answer answer-question.mjs recorded when it cleared pendingQuestion. The re-entered step reads it instead of asking again, then leaves it as a trace; the next answer overwrites it.",
      "properties": {
        "questionId": { "type": "string" },
        "stepId": { "type": "string" },
        "optionIds": { "type": "array", "minItems": 1, "items": { "type": "string" } },
        "answeredAt": { "type": "string", "format": "date-time" }
      }
    },
    "remoteType": {
      "type": "string",
      "enum": ["github", "bitbucket", "gitlab", "generic-git", "local"],
      "description": "Determines which PR API and reviewer strategy is used. generic-git has no PR API; local means the run has no remote at all."
    },
    "inputType": {
      "type": "string",
      "enum": ["github-issue-url", "github-issue-number", "jira-url", "jira-id", "free-text"],
      "description": "The form the task input arrived in, as Phase 0 classified it. Commands that apply to one tracker only (the issue/Jira triad) branch on it."
    },
    "offlineOnly": {
      "type": "boolean",
      "default": false,
      "description": "True when every entry in projects[] is local. Phases 4 and 5 read it to skip the PR prompt and remote reporting."
    },
    "figmaUrl": {
      "type": ["string", "null"],
      "description": "The Figma URL found in the task body at intake, or null. Component dispatch passes it to the stack plugin's create-component skill."
    },
    "instructionFiles": {
      "type": "object",
      "additionalProperties": { "type": "string" },
      "description": "Instruction files steering an instruction-driven run, keyed by the step they replace (start, validate, dev, commit). Empty when instructionDriven is false."
    },
    "contextLinks": {
      "type": "array",
      "description": "Typed external links extracted from the task text by lib/context-link-extractor.sh at Phase 0 Step 1b. Phase 1 dispatches each entry to its fetcher by type.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["type", "url", "metadata"],
        "properties": {
          "type": {
            "enum": [
              "swagger",
              "confluence",
              "crashlytics",
              "fortify",
              "document",
              "graylog",
              "figma",
              "generic-doc"
            ]
          },
          "url": { "type": ["string", "null"] },
          "metadata": { "type": "object" }
        }
      }
    },
    "crashContext": {
      "type": ["object", "null"],
      "description": "Crashlytics issue detail fetched at Phase 0 Step 1b.1 (features/url-enrichment.md). Phase 1 treats it as ground truth for the crash."
    },
    "fortifyFinding": {
      "type": ["object", "null"],
      "description": "Fortify SSC finding fetched at Phase 0 Step 1b.2 (features/url-enrichment.md), or {skipped: <reason>} when the fetch was skipped."
    },
    "graylogContext": {
      "type": ["object", "array", "null"],
      "description": "Graylog messages fetched at Phase 0 Step 1b.3 (features/url-enrichment.md): one object, an array when several ids were extracted, or {skipped: <reason>}."
    },
    "currentPhase": {
      "type": "integer",
      "minimum": 0,
      "maximum": 5,
      "description": "Last phase the orchestrator entered. Resume re-enters at currentPhase + 1. Six phases (0..5) since v19.0.0; a state written by an older version is migrated by schemas/migrations/state-2.1.0-to-2.2.0.mjs."
    },
    "status": {
      "type": "string",
      "enum": ["in_progress", "awaiting_input", "paused", "complete", "failed", "abandoned"],
      "description": "Overall task status. `abandoned` is written through the locked state writer by the autopilot runner, for a run its session launched that ended with neither a PR nor a parked question, and by a kill; `abandonedBy` names the writer. `awaiting_input` is deliberately distinct from both `in_progress` and `paused`: the run is not broken and not resumable by a retry - it is finished with what it can do alone and is holding for a human answer, which is what Phase 5 does by design after the PR is open. Without a word for it, a run whose work had landed read exactly like one that crashed mid-development, and a state file in the wild had already invented `awaiting-user-test-main-checkout` to say it. `paused` stays what it was: stopped deliberately, resumable, nothing expected from anyone."
    },
    "abandonedBy": {
      "type": "string",
      "enum": ["autopilot-runner", "kill", "autopilot-off"],
      "description": "Who marked the run `abandoned`: the autopilot runner, only for a run whose state carries the session id it launched the run with, `run-kill.mjs apply` (/multi-agent:kill, POST /v1/runs/{id}/kill), or `autopilot-control.mjs off --now` for an item it stopped in flight."
    },
    "startedAt": {
      "type": "string",
      "format": "date-time"
    },
    "finishedAt": {
      "type": ["string", "null"],
      "format": "date-time"
    },
    "haltReason": {
      "type": ["string", "null"],
      "description": "Set when a phase halts on a hard error (validator failed twice, no subagent returned, dispatch error past fallback, lock irrecoverable). Format '<phase>:<cause>'. Surfaced to the user and cleared on successful resume. See operations.md 'Halt visibility'."
    },
    "relatedIssues": {
      "type": "array",
      "maxItems": 20,
      "description": "Jira issues fetched alongside the task at intake: the other sub-tasks under this issue's parent, where a board keeps the analysis and the test scope. NOT the same field as siblings[] below, which is read-only sibling REPOS. Written by the picker bridge from descriptor.relatedIssues; read by Phase 1 as ground truth and by its own parent-story scope-drift check. Durable on purpose: /multi-agent:resume rebuilds context from artefacts, never from the conversation, so intake-only enrichment would vanish on the first resume. The task's own description and maturity are still NOT persisted here, which is a known asymmetry, not an oversight.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["key", "relation"],
        "properties": {
          "key": {
            "type": "string"
          },
          "relation": {
            "type": "string",
            "enum": ["sibling"]
          },
          "type": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "summary": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "truncated": {
            "type": "boolean",
            "default": false
          }
        }
      }
    },
    "siblings": {
      "type": "array",
      "maxItems": 10,
      "description": "Repos the dev-context picker offered that this run does not modify: read-only siblings, plus any extra the user selected. Persisted at Phase 0 because the phases that consume them run much later - Phase 3's platform-parity cross-check reads this as the fourth of its four counterpart sources (after --with, prefs.projects[<slug>].counterpartRoots[] and the primary checkout's sibling directories; see multi-agent-refs/platform-parity.md), so a picker result that is not written here is a candidate the check can never see.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["name", "root", "stack"],
        "properties": {
          "name": {
            "type": "string",
            "description": "Repo name as the picker showed it."
          },
          "root": {
            "type": ["string", "null"],
            "description": "Absolute path to the local checkout, or null when the repo is known but not checked out. A null root is skipped by every consumer: nothing clones a repo to review a different one."
          },
          "stack": {
            "type": "string",
            "enum": ["ios", "android", "node", "python", "go", "unknown"],
            "description": "Resolved from the checkout's marker files by the same table Phase 1 Step 2 uses (.xcodeproj/Package.swift -> ios, build.gradle(.kts) -> android, ...). 'unknown' when no marker matched or there is no checkout - never guessed from the repo name."
          },
          "canPush": {
            "type": "boolean",
            "default": false,
            "description": "Carried from the picker. Informational here: a sibling is read-only to Phase 3 regardless."
          }
        }
      }
    },
    "rev": {
      "type": "integer",
      "minimum": 0,
      "description": "Monotonic revision, bumped by write-state.mjs on every successful write. A reader keeps the rev it read and compares before writing back; a changed rev means the record moved underneath it. Optional, so a record written by an earlier version stays valid: treat a missing rev as 0. No schemaVersion bump - the field is additive and nothing needs migrating."
    },
    "pendingSteer": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "required": ["text", "at"],
      "description": "A mid-run instruction from the user, waiting for the next phase boundary to consume it. Written by /multi-agent:steer; a phase consumes it at entry by setting appliedAt, which is what stops it being read again. The record is kept as a trace and overwritten wholesale by the next steer. Never applied mid-phase: changing the target of work already in flight throws that work away.",
      "properties": {
        "text": {
          "type": "string",
          "minLength": 1,
          "maxLength": 4000,
          "description": "The instruction, verbatim as the user typed it."
        },
        "at": {
          "type": "string",
          "format": "date-time",
          "description": "When it was queued."
        },
        "appliedAt": {
          "type": ["string", "null"],
          "format": "date-time",
          "description": "When a phase consumed it. Set alongside clearing the record, so the log keeps the trace."
        },
        "appliedPhase": {
          "type": ["integer", "null"],
          "minimum": 0,
          "maximum": 5,
          "description": "The phase that consumed it."
        }
      }
    },
    "telemetry": {
      "type": "object",
      "additionalProperties": true,
      "description": "Run telemetry. mcpCalls[] records every mcp__* invocation tagged with the phase it ran in; smoke-no-mcp-in-dev-phases.sh audits it for Figma calls outside analysis as a maintainer regression check. skillCalls[] is Phase 3's self-report of which skills, plugin skills and guides it consulted; Phase 3 Step 1.78 reads it as CORROBORATING evidence only and never computes coverage from it.",
      "properties": {
        "mcpCalls": {
          "type": "array",
          "description": "One entry per MCP tool invocation during the run. A Figma tool entry with phase >= 2 is a violation of the analysis-only design source.",
          "items": {
            "type": "object",
            "required": ["tool", "phase"],
            "properties": {
              "tool": {
                "type": "string",
                "description": "MCP tool name, e.g. mcp__claude_ai_Figma__get_design_context."
              },
              "phase": {
                "type": "integer",
                "minimum": 0,
                "maximum": 5,
                "description": "Pipeline phase the call ran in. Figma tools are permitted only in 0 (init) and 1 (analysis)."
              },
              "timestamp": {
                "type": "string",
                "description": "ISO-8601 time of the call."
              }
            }
          }
        },
        "skillRouting": {
          "type": "object",
          "additionalProperties": true,
          "description": "Phase 2 stack skill routing outcome, as printed by scripts/skill-candidates.mjs resolve. Written once before the index call so the Phase 5 report can show what was kept, what was excluded and why, the enable hints, and any marketplace fallback. Optional: runs that predate it have none.",
          "properties": {
            "mode": {
              "type": "string",
              "enum": ["attended", "unattended", "autopilot"]
            },
            "toolkits": {
              "type": "array",
              "items": { "type": "object" },
              "description": "Kept toolkits with name, version, source and reason."
            },
            "excluded": {
              "type": "array",
              "items": { "type": "object" },
              "description": "Inherited toolkits dropped for contradicting the detected stack, each with its reason."
            },
            "hints": {
              "type": "array",
              "items": { "type": "object" },
              "description": "Detected stacks with no enabled toolkit, each with the /multi-agent:stack line to enable one."
            },
            "fallbacks": {
              "type": "array",
              "items": { "type": "object" },
              "description": "Unattended and autopilot runs only: toolkits read read-only from a marketplace clone (with reportLine), or the recorded gap when no clone carries one."
            }
          }
        },
        "skillCalls": {
          "type": "array",
          "description": "One entry per skill / plugin skill / stack guide consulted while writing code. Append at the moment of consultation, not retrospectively. This is a self-report and shares the known weakness of mcpCalls[]: an unrecorded consultation and no consultation are byte-identical here, so Step 1.78 treats the deterministic resolver as primary, defaults ledger.source to 'derived', and flags a declared skill it cannot bind to a changed file. Recording it still earns its keep - it is the only signal that distinguishes 'the dev phase applied X to the wrong files' from 'X was never opened'.",
          "items": {
            "type": "object",
            "required": ["skill", "phase", "targetFiles"],
            "additionalProperties": true,
            "properties": {
              "skill": {
                "type": "string",
                "minLength": 1,
                "description": "Skill name as invoked, e.g. ios-coding-standard, ai-ios-toolkit:create-component, or a guide path for a stack guide."
              },
              "phase": {
                "type": "integer",
                "minimum": 0,
                "maximum": 5,
                "description": "Phase the consultation happened in. Normally 2 (Dev)."
              },
              "targetFiles": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Files the skill was actually applied to. REQUIRED: without it coverage cannot be attributed, because a skill applied to the wrong files still reads as 'applied'."
              },
              "timestamp": {
                "type": "string",
                "description": "ISO-8601 time of the consultation."
              },
              "routedBy": {
                "type": "string",
                "description": "Set when a stack toolkit's own index skill chose this skill, as '<toolkit>:index@<version>'. Recorded so a finding can be traced to the index version that selected it  -  the skill set differs between plugin versions. Phase 3 Step 1.78 surfaces these separately in the manifest ledger; it does NOT grant them extra trust, because the resolver stays primary either way."
              },
              "source": {
                "type": "string",
                "enum": ["repo", "toolkit", "marketplace-fallback", "pipeline", "host"],
                "description": "Where the loaded skill came from. 'repo' = the repo's own .claude/skills/<name>/SKILL.md (routedBy 'repo:.claude/skills'), which takes precedence over a toolkit skill of the same name; 'toolkit' = an enabled toolkit plugin; 'marketplace-fallback' = an unattended or autopilot run read a detected stack's toolkit read-only from a local marketplace clone because the repo does not enable it (toolkit and version are then required in practice); 'pipeline' = a skill the pipeline ships; 'host' = picked by the host's own description matching. Optional: entries written before this field existed carry no source."
              },
              "toolkit": {
                "type": "string",
                "description": "Toolkit plugin name the skill came from, set with source 'marketplace-fallback'."
              },
              "version": {
                "type": "string",
                "description": "That toolkit's version, from its plugin.json in the marketplace clone."
              }
            }
          }
        }
      }
    },
    "autopilot": {
      "type": "boolean",
      "default": false
    },
    "localMode": {
      "type": "boolean",
      "default": false,
      "description": "Step 5b answered local  -  no worktree, direct branch in projectRoot."
    },
    "instructionDriven": {
      "type": "boolean",
      "default": false,
      "description": "A Figma / skill instruction file is steering phases 2-4."
    },
    "mode": {
      "type": ["string", "null"],
      "description": "Pipeline mode for this run (e.g. 'autopilot', 'analysis', 'design-check'). Absent = full pipeline."
    },
    "taskType": {
      "type": "string",
      "enum": ["component", "bugfix", "feature", "refactor", "chore"],
      "description": "Phase 0 Step 7 classification. Phase 2 branches on it, and phase0-exit-gate.mjs fails a run that reaches Phase 1 without it. A Figma reference forces component."
    },
    "plan": {
      "type": ["object", "null"],
      "description": "The Phase 1 plan as a Todo list, written by plan-todos.sh when prefs.global.planTodos.enabled is true. Shape: plan-todos.schema.json, which plan-todos.sh validates on write; absent when the opt-in is off."
    },
    "analysis": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "Phase 1 analysis-document outcome. Phase 1 runs in every mode, so a document is always produced; what varies is how many of its sections the evidence supported.",
      "properties": {
        "docStatus": {
          "type": "string",
          "enum": ["produced", "reused", "not-applicable"],
          "description": "Set by Phase 1 Step 4. Phase 2 and Phase 3 pre-flight on it."
        },
        "docPath": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "One emitted path per selected platform."
        },
        "frontMatter": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "Parsed YAML header of the active platform's document."
        },
        "openQuestions": {
          "type": "array",
          "description": "Section 20 rows Phase 1 Step 5 deferred. open-questions-gate.mjs parks a gated run on a non-empty list and passes on an empty one; absent means no analysis recorded any.",
          "items": { "type": ["string", "object"] }
        },
        "assumptions": {
          "type": "array",
          "description": "Open questions the run continues on as stated assumptions, moved here by open-questions-gate.mjs when the answer to its parked question is `assume`.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["question", "source", "assumedAt"],
            "properties": {
              "question": { "type": "string" },
              "source": { "type": "string" },
              "assumedAt": { "type": "string", "format": "date-time" }
            }
          }
        }
      }
    },
    "run": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "Cross-phase run bookkeeping that is not a phase record.",
      "properties": {
        "lastAnalysisDigest": {
          "type": ["string", "null"],
          "description": "The evidence_digest the analysis documents were written from, persisted by Phase 1. Phase 2 compares it against the document front-matter; absent means the freshness check is not-verifiable, never fresh."
        },
        "analysisBaseCommit": {
          "type": ["string", "null"],
          "description": "git rev-parse HEAD at analysis emit time. Phase 2 diffs it against HEAD to detect repo drift under a reused spec, which the digest alone cannot see."
        }
      }
    },
    "figmaAccess": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "Resolved Figma ground-truth tier for this run, established in Phase 0 and read by every phase that consumes or verifies a design reference (see the Figma Access Tier rule in multi-agent-refs/rules.md). Absent when the task references no Figma frame.",
      "properties": {
        "tier": {
          "type": "integer",
          "minimum": 1,
          "maximum": 3,
          "description": "1 = Figma MCP, 2 = Figma REST with the `figma` PAT, 3 = user-attached screenshot."
        },
        "tier1Unavailable": {
          "type": ["string", "null"],
          "enum": ["host", "auth", null],
          "description": "Why Tier 1 was not used, when it was not. 'host' = this CLI does not serve the mcp__claude_ai_Figma__* tools (the normal case on Copilot CLI and Codex CLI, since the installer registers only multi-agent-toolkit) and no re-auth was attempted. 'auth' = the tools were served but authentication failed after one retry. The distinction matters downstream: Tier 2 on Codex is routine, Tier 2 on Claude Code points at a dead figma_mcp token worth surfacing."
        },
        "reviewBlocking": {
          "type": "boolean",
          "default": false,
          "description": "Set with tier 3. Phase 3 holds the run at review_blocking until a human signs off on the component choice."
        }
      }
    },
    "repoProfile": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "description": "Outcome of `repo-profile.mjs ensure` at Phase 1: where the per-repo profile lives and how this run treated it. The profile itself stays at the path, outside the repo (multi-agent-refs/features/repo-profile.md).",
      "properties": {
        "path": { "type": "string" },
        "action": { "type": "string", "enum": ["loaded", "derived", "rederived"] },
        "mode": { "type": "string", "enum": ["attended", "unattended"] },
        "confirmed": {
          "type": "boolean",
          "description": "The profile carried confirmedAt when the run used it."
        },
        "staleReasons": { "type": "array", "items": { "type": "string" } },
        "ignored": {
          "type": "array",
          "description": "Fields the run did not act on, with the confidence policy's reason.",
          "items": { "type": "object", "additionalProperties": true }
        }
      }
    },
    "designCheck": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "State for a /multi-agent:design-check run. Populated in Phase 0 (mock feasibility, scenario inventory, scope), Phase 1 (Figma variants) and Phase 2 (captures, skips, findings).",
      "properties": {
        "module": {
          "type": "string",
          "description": "Module / submodule path being audited."
        },
        "platform": {
          "type": "string",
          "enum": ["ios", "android"]
        },
        "mock": {
          "type": "object",
          "additionalProperties": true,
          "description": "design_mock_detect result.",
          "properties": {
            "supported": {
              "description": "true | false | 'debug-only'"
            },
            "mechanism": {
              "type": ["string", "null"]
            },
            "activation": {
              "type": ["object", "null"]
            },
            "variantsHint": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        },
        "inventory": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "design_scenario_inventory result, persisted verbatim. THIS is the audit's target set  -  Phase 2 iterates it and Phase 3 gates on it. Never re-derive it by reading the repo.",
          "properties": {
            "targetCount": {
              "type": "integer"
            },
            "targets": {
              "type": "array",
              "items": {
                "type": "object",
                "additionalProperties": true
              },
              "description": "{ id, kind, label, screen, driver, evidence } per state driver (launch-arg / scenario-case / code-scenario / fixture / deep-link)."
            },
            "groups": {
              "type": "array",
              "items": {
                "type": "object",
                "additionalProperties": true
              }
            },
            "byKind": {
              "type": "object",
              "additionalProperties": true
            },
            "truncated": {
              "type": "boolean"
            }
          }
        },
        "scope": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "Resolved run scope: the inventory targets this run must audit. The coverage gate applies to this set, not to the whole inventory  -  a scoped run is not penalised for out-of-scope targets.",
          "properties": {
            "argument": {
              "type": ["string", "null"],
              "description": "Raw $ARGUMENTS as given (empty = whole module, '--resume' = remainder of the last run)."
            },
            "targetIds": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "resumedFrom": {
              "type": ["string", "null"],
              "description": "Report dir of the run this scope resumes, when --resume was used."
            }
          }
        },
        "figmaUrl": {
          "type": ["string", "null"]
        },
        "variants": {
          "type": "array",
          "description": "Mapped variants with their Figma spec, capture, and findings.",
          "items": {
            "type": "object",
            "additionalProperties": true
          }
        },
        "captured": {
          "type": "array",
          "description": "Every capture taken, appended and persisted as it happens so a dying run is resumable. One entry per screenshot, keyed to the target that produced it ('<targetId>', or '<targetId>#<sub-label>' for tap-reachable sub-states).",
          "items": {
            "type": "object",
            "additionalProperties": true
          }
        },
        "skipped": {
          "type": "array",
          "description": "Targets that could not be audited, each with a CONCRETE reason. 'Requires a scenario / prefix / launch-arg' is not a reason  -  that is the work Phase 2 exists to do. A skip without a reason fails the coverage gate.",
          "items": {
            "type": "object",
            "additionalProperties": true,
            "required": ["id", "reason"],
            "properties": {
              "id": {
                "type": "string"
              },
              "group": {
                "type": ["string", "null"]
              },
              "reason": {
                "type": "string",
                "minLength": 1
              }
            }
          }
        },
        "coverage": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "design_report coverage verdict for this run. gate='fail' means the run is INCOMPLETE and must be reported as such, with the unaccounted ids and the --resume command.",
          "properties": {
            "gate": {
              "type": "string",
              "enum": ["pass", "fail"]
            },
            "audited": {
              "type": "integer"
            },
            "target": {
              "type": "integer"
            },
            "pct": {
              "type": "number"
            },
            "skippedWithReason": {
              "type": "integer"
            },
            "unaccounted": {
              "type": "integer"
            },
            "unaccountedIds": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        },
        "reportDir": {
          "type": ["string", "null"],
          "description": "Output dir under ~/DesignChecks."
        }
      }
    },
    "identity": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name", "email"],
      "properties": {
        "name": {
          "type": "string"
        },
        "email": {
          "type": "string",
          "format": "email"
        },
        "username": {
          "type": "string",
          "description": "SCM username (GitHub/Bitbucket). Used to filter out PR author from reviewer list."
        }
      }
    },
    "phases": {
      "type": "object",
      "description": "Per-phase status + outputs. Keys are phase numbers as strings (\"0\"..\"5\").",
      "patternProperties": {
        "^[0-5]$": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "status": {
              "type": "string",
              "enum": ["pending", "in_progress", "done", "skipped", "failed"]
            },
            "startedAt": {
              "type": "string",
              "format": "date-time"
            },
            "finishedAt": {
              "type": "string",
              "format": "date-time"
            },
            "model": {
              "type": "string"
            },
            "files": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Files produced or modified by this phase. Used for semantic revert."
            },
            "retryCount": {
              "type": "integer",
              "minimum": 0,
              "maximum": 3
            },
            "subStep": {
              "type": "string",
              "description": "Sub-step checkpoint for long phases (2, 5) so resume re-does only the unfinished tail instead of the whole phase. Short token, e.g. 'red'|'green'|'build'|'pr-opened'|'confluence-synced'. See operations.md 'Sub-step checkpoints'."
            },
            "notes": {
              "type": "string"
            },
            "clarificationRounds": {
              "type": "integer",
              "minimum": 0,
              "maximum": 2,
              "description": "v5.3.0 Phase 1 Plan Approval Gate  -  number of clarification question/answer rounds the orchestrator ran before producing the first plan. Cap 2; hitting the cap produces a 'best-effort' plan instead of asking more questions. Only emitted on an interactive run (autopilot may not ask)."
            },
            "clarificationQuestions": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "v5.3.0 Phase 1  -  ordered log of questions the orchestrator asked the user when the issue description was ambiguous (missing acceptance criteria, vague language, missing Figma/endpoint links, unclear scope vs. parent story)."
            },
            "clarificationAnswers": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "v5.3.0 Phase 1  -  ordered log of user answers aligned with clarificationQuestions (same length)."
            },
            "planIterations": {
              "type": "integer",
              "minimum": 1,
              "description": "v5.3.0 Phase 1 Plan Approval Gate  -  how many plan revisions the user requested before approving. 1 = approved on first show; N = user sent N-1 free-text edit requests and the plan was re-rendered each time."
            },
            "planApprovedAt": {
              "type": ["string", "null"],
              "format": "date-time",
              "description": "v5.3.0 Phase 1 Plan Approval Gate  -  timestamp when the user approved the plan. Null when the gate is not applicable (autopilot) or the task was aborted at the gate."
            },
            "planEditRequests": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "v5.3.0 Phase 1  -  free-text edit instructions the user typed between plan renders. Preserved verbatim for audit; the planning model (Fable top tier) parses them conversationally to revise the plan."
            }
          }
        }
      }
    },
    "baseline": {
      "type": "object",
      "additionalProperties": true,
      "description": "Pre-work state of the repo, captured in Phase 0. Exists so Phase 3 can tell an inherited failure from one this run caused.",
      "properties": {
        "tests": {
          "type": "object",
          "additionalProperties": true,
          "description": "Outcome of the Phase 0 baseline test run (gated by prefs.global.testBaseline.enabled). status is three-valued on purpose: collapsing unknown into green would let a skipped baseline read as a clean tree, which is the failure this whole record exists to prevent.",
          "properties": {
            "status": {
              "type": "string",
              "enum": ["green", "red", "unknown"],
              "description": "green = the suite passed before any change. red = it did not. unknown = no test command, the time cap was hit, or the baseline was disabled."
            },
            "failing": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Identifiers of tests already failing before this run. Empty on a red status means the output could not be parsed into names: the evidence is then the logPath alone, and Phase 3 reports 'inherited red, not attributable' rather than inventing a set."
            },
            "logPath": {
              "type": "string",
              "description": "Path to the tee'd baseline log. The evidence behind the status; cited when failing[] could not be parsed."
            },
            "capturedAt": {
              "type": "string",
              "format": "date-time"
            },
            "command": {
              "type": "string",
              "description": "The exact test command that produced this baseline. Phase 3 compares against its own Gate 3 command and treats a mismatch as unknown."
            }
          }
        },
        "sha": {
          "type": "string",
          "description": "The base commit the baseline was captured at. Pre-existing claims in unattended runs are checked against it (verify-citations.mjs)."
        }
      }
    },
    "threatModel": {
      "type": "object",
      "additionalProperties": true,
      "description": "The run-scoped threat model produced at Phase 3 Step 2.7 (or by /multi-agent:security-review), mirrored here so a resume reuses it instead of re-deriving it. Contract: multi-agent-refs/threat-model.md.",
      "properties": {
        "path": {
          "type": "string",
          "description": "Path to .pipeline/threat-model.md in the worktree."
        },
        "sha": {
          "type": "string",
          "description": "Content hash, so a later step can tell whether the model changed."
        },
        "producedAt": {
          "type": "string",
          "format": "date-time"
        },
        "producedBy": {
          "type": "string",
          "description": "Which step or command wrote it (e.g. 'phase-3-step-2.7', 'security-review')."
        }
      }
    },
    "reviewIterations": {
      "type": "array",
      "items": {
        "type": "object",
        "description": "Per-iteration review record. It accretes fields across phases - summary counts in Phase 4, plus the merged reviewers[] and triage{} detail and validatorResult that run-metrics.mjs and Phase 5 read - so extra keys are allowed.",
        "additionalProperties": true,
        "properties": {
          "iteration": {
            "type": "integer",
            "minimum": 1
          },
          "blocking": {
            "type": "integer",
            "minimum": 0
          },
          "important": {
            "type": "integer",
            "minimum": 0
          },
          "suggestion": {
            "type": "integer",
            "minimum": 0
          },
          "decision": {
            "type": "string",
            "enum": ["fix", "accept", "escalate"]
          },
          "delta": {
            "type": "object",
            "additionalProperties": true,
            "description": "Written by Phase 3 Step 3.8 from review-delta.mjs: how this round's accepted findings relate to the previous round's rework mandate. Absent on iteration 1 and on runs from before the delta existed.",
            "properties": {
              "previousIteration": {
                "type": "integer",
                "minimum": 1
              },
              "new": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "fingerprint": {
                      "type": "string"
                    },
                    "severity": {
                      "type": ["string", "null"]
                    },
                    "file": {
                      "type": ["string", "null"]
                    },
                    "issue": {
                      "type": ["string", "null"]
                    }
                  }
                }
              },
              "stillPresent": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "fingerprint": {
                      "type": "string"
                    },
                    "severity": {
                      "type": ["string", "null"]
                    },
                    "file": {
                      "type": ["string", "null"]
                    },
                    "issue": {
                      "type": ["string", "null"]
                    }
                  }
                }
              },
              "resolved": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "fingerprint": {
                      "type": "string"
                    },
                    "severity": {
                      "type": ["string", "null"]
                    },
                    "file": {
                      "type": ["string", "null"]
                    },
                    "issue": {
                      "type": ["string", "null"]
                    }
                  }
                }
              },
              "downgraded": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "fingerprint": {
                      "type": "string"
                    },
                    "severity": {
                      "type": ["string", "null"]
                    },
                    "file": {
                      "type": ["string", "null"]
                    },
                    "issue": {
                      "type": ["string", "null"]
                    }
                  }
                }
              },
              "stillPresentBlocking": {
                "type": "array",
                "items": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "fingerprint": {
                      "type": "string"
                    },
                    "severity": {
                      "type": ["string", "null"]
                    },
                    "file": {
                      "type": ["string", "null"]
                    },
                    "issue": {
                      "type": ["string", "null"]
                    }
                  }
                }
              },
              "recurrence": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer",
                  "minimum": 1
                },
                "description": "fingerprint -> consecutive rework cycles the finding has survived."
              },
              "plateau": {
                "type": "boolean",
                "description": "The still-present set is unchanged from the previous delta and non-empty."
              },
              "tripped": {
                "type": "boolean"
              },
              "computedAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "reviewers": {
            "type": "array",
            "description": "One entry per reviewer dispatch that RETURNED. Typed because two consumers depend on the shape: anonymize-findings.mjs needs model+findings to build the label map, and run-metrics.mjs reports acceptedRatio per reviewer. Extra keys are allowed; nothing is required, so a run written before this shape existed still validates and surfaces as model \"unknown\" rather than failing.",
            "items": {
              "type": "object",
              "additionalProperties": true,
              "properties": {
                "model": {
                  "type": "string",
                  "description": "The model that produced this review (fable | sonnet | opus | gpt-*). Absent is not the same as unknown-by-name: an entry with no model is reported as \"unknown\" in the per-reviewer metric instead of being folded into another reviewer's count."
                },
                "findings": {
                  "type": "array",
                  "description": "Raw findings from this reviewer, before triage."
                },
                "roundCount": {
                  "type": "integer",
                  "minimum": 1,
                  "description": "1 normally, 2 when the Step 2.5 rebuttal round replaced this reviewer's output."
                }
              }
            }
          },
          "anonymizationMap": {
            "type": "object",
            "additionalProperties": true,
            "description": "Written by Phase 3 Step 3.0 from anonymize-findings.mjs --map, read by run-metrics.mjs to attribute accepted findings back to a reviewer. Declared because the phase doc names it and a consumer reads it; absent on runs from before anonymization existed, which run-metrics reports as perReviewerAttribution \"unavailable\" rather than as a zero. Never goes into a prompt: it is the mapping the anonymization exists to withhold.",
            "properties": {
              "seed": {
                "type": "string",
                "description": "taskId:iteration - the seed that produced the finding order, so a resume reproduces it."
              },
              "labelToModel": {
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Source A|B|C -> model name. \"unknown\" for a reviewer entry that declared no model."
              }
            }
          },
          "triage": {
            "type": "object"
          },
          "accepted": {
            "type": "array"
          },
          "validatorResult": {},
          "reviewDecision": {
            "type": "object",
            "additionalProperties": true,
            "description": "review-decision-gate.mjs --json output for this iteration, merged by Phase 3 after Step 3.7 while the quality gates are active (features/review-decision.md). kept[] lists the blocking findings that stayed blocking with their basis (corroborated | failing-test | test-integrity | owned-path); downgraded[] lists each blocking finding lowered to important with its fingerprint and reason. Absent on an attended run.",
            "properties": {
              "verdict": {
                "type": "string",
                "enum": ["pass", "rebuttal-round", "unreadable"]
              },
              "approved": {
                "type": "boolean"
              },
              "kept": {
                "type": "array"
              },
              "downgraded": {
                "type": "array"
              }
            }
          }
        }
      }
    },
    "circuitBreaker": {
      "type": "object",
      "additionalProperties": false,
      "description": "Autopilot circuit-breaker record (refs/features/autopilot-circuit-breaker.md). Written only when a trigger trips: trigger 2 by Phase 3 Step 3.8 (a mandate finding survived identicalFindingCycles rework cycles), trigger 3 by the Phase 3 re-entry hard-kill, trigger 6 by Phase 0 Step 3 when autopilot posted the base-branch question on the issue and must not answer it itself. /multi-agent:resume clears tripped and keeps counters.",
      "required": ["tripped"],
      "properties": {
        "tripped": {
          "type": "boolean"
        },
        "trigger": {
          "type": ["integer", "null"],
          "minimum": 1,
          "maximum": 6
        },
        "detail": {
          "type": "string"
        },
        "checkpoint": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "phase": {
              "type": "integer",
              "minimum": 0,
              "maximum": 5
            },
            "step": {
              "type": "string"
            },
            "iteration": {
              "type": "integer",
              "minimum": 1
            }
          }
        },
        "trippedAt": {
          "type": "string",
          "format": "date-time"
        },
        "counters": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "identicalFindingCycles": {
              "type": "integer",
              "minimum": 0
            },
            "reworkCycles": {
              "type": "integer",
              "minimum": 0
            }
          }
        }
      }
    },
    "valven": {
      "type": "object",
      "additionalProperties": true,
      "description": "valven-rules delivery gate. Phase 2 Gate 6 sets splitRequired when a size or scope rule fails; Phase 4 Step 2.95 records the stacked PRs it opened.",
      "properties": {
        "splitRequired": { "type": "boolean" },
        "split": {
          "type": "object",
          "properties": {
            "sliceCount": { "type": "integer", "minimum": 1 },
            "branches": { "type": "array", "items": { "type": "string" } },
            "prs": { "type": "array", "items": { "type": "string" } }
          }
        }
      }
    },
    "diffRisk": {
      "type": "object",
      "additionalProperties": true,
      "description": "Totals from diff-risk-score.mjs, persisted by Phase 3 Step 1.75 so Phase 4 (PR risk section), Phase 5 and run-metrics.mjs read the same numbers the review scope decision used.",
      "properties": {
        "files": {
          "type": "integer",
          "minimum": 0
        },
        "loc_added": {
          "type": "integer",
          "minimum": 0
        },
        "loc_removed": {
          "type": "integer",
          "minimum": 0
        },
        "max_score": {
          "type": "number"
        },
        "signals": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Distinct signal names seen across files (security_path, migration, public_api, no_test_change, test_lines_removed, ...)."
        }
      }
    },
    "confluenceSpace": {
      "type": ["string", "null"],
      "description": "Cached Confluence space key for the project  -  avoids re-asking on every run."
    },
    "confluenceParentId": {
      "type": ["string", "null"],
      "description": "Cached Confluence parent page id."
    },
    "pr": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "properties": {
        "number": {
          "type": "integer",
          "minimum": 1
        },
        "url": {
          "type": "string",
          "format": "uri"
        },
        "version": {
          "type": "integer",
          "minimum": 0,
          "description": "Bitbucket PR version  -  must increment by 1 per PUT."
        },
        "draft": {
          "type": "boolean"
        },
        "reviewers": {
          "type": "array",
          "items": {
            "type": "string"
          }
        }
      }
    },
    "commit": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "properties": {
        "sha": {
          "type": "string",
          "minLength": 7
        },
        "message": {
          "type": "string"
        }
      }
    },
    "projects": {
      "type": "array",
      "description": "v2.1.0+. Per-repo state for multi-repo tasks. When present and len > 1, multi-repo mode is active; the scalar project/projectRoot/worktreePath fields at top level refer to the primary (first) repo. Each entry mirrors the top-level single-repo fields plus its own identity and commit.",
      "minItems": 1,
      "maxItems": 10,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["project", "projectRoot", "branch", "baseBranch", "identity"],
        "properties": {
          "project": {
            "type": "string"
          },
          "projectRoot": {
            "type": "string"
          },
          "worktreePath": {
            "type": ["string", "null"]
          },
          "branch": {
            "type": "string"
          },
          "baseBranch": {
            "type": "string"
          },
          "remoteType": {
            "type": "string",
            "enum": ["github", "bitbucket", "gitlab", "generic-git", "local"]
          },
          "identity": {
            "type": "object",
            "additionalProperties": false,
            "required": ["name", "email"],
            "properties": {
              "name": {
                "type": "string"
              },
              "email": {
                "type": "string",
                "format": "email"
              },
              "username": {
                "type": "string"
              }
            }
          },
          "platform": {
            "type": "string",
            "enum": ["bitbucket", "github", "gitlab", "generic-git"],
            "description": "Host platform for this repo (derived from origin URL)."
          },
          "commit": {
            "type": ["object", "null"],
            "additionalProperties": false,
            "properties": {
              "sha": {
                "type": "string",
                "minLength": 7
              },
              "message": {
                "type": "string"
              }
            }
          },
          "pr": {
            "type": ["object", "null"],
            "additionalProperties": false,
            "properties": {
              "number": {
                "type": "integer",
                "minimum": 1
              },
              "url": {
                "type": "string",
                "format": "uri"
              },
              "draft": {
                "type": "boolean"
              }
            }
          },
          "pushAttempts": {
            "type": "integer",
            "minimum": 0,
            "description": "v2.1.0+ push-must-succeed retry counter. Increments per rebase-retry."
          },
          "buildStatus": {
            "description": "Latest Phase 3 build outcome for this repo. A string in early phases ('pending'/'green'/'passed'/'failed') or null before any attempt; once Phase 3 runs a build it becomes a {ok, attempts, lastError} object (see phase-2-dev.md). run-metrics.mjs reads both the string and object forms.",
            "anyOf": [
              {
                "type": "string",
                "enum": ["pending", "passed", "failed", "green"]
              },
              {
                "type": "null"
              },
              {
                "type": "object",
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "attempts": {
                    "type": "integer",
                    "minimum": 0
                  },
                  "lastError": {
                    "type": ["string", "null"]
                  }
                }
              }
            ]
          }
        }
      }
    },
    "worktreeRemovedAt": {
      "type": ["string", "null"],
      "format": "date-time",
      "description": "Set when Phase 4 removed the worktree after opening the PR. Its presence is what tells :resume, :status and :log that a worktree-less task is finished-and-tidied rather than broken  -  without it a missing worktree is indistinguishable from a killed run."
    },
    "artifactsPath": {
      "type": ["string", "null"],
      "description": "Directory the worktree's artefacts were salvaged into before removal (agent-state, phase-tracker, triage-output, .pipeline/, build+test logs, review diff). Phase 5 and :resume read from here when worktreePath is gone."
    },
    "testDepth": {
      "type": ["string", "null"],
      "enum": ["unit", "unit+ui", "unit+mcp", null],
      "description": "How far the run tests, answered at intake because the user test is absent from four of the six modes and a question asked where it cannot be reached is a question nobody answers. `unit+ui` runs the repo's own UI test and records the screen around it; `unit+mcp` drives the flow through the toolkit MCP instead. The options offered are built from evidenceCapability, never from the model's reading of the repo."
    },
    "testDepthSource": {
      "type": ["string", "null"],
      "enum": ["user", "autopilot", "default", "forced", null],
      "description": "Who chose. `forced` means only one option was open, so nothing was asked - recorded rather than passed off as the user's answer."
    },
    "stacks": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": ["ios", "android", "web", "backend"]
      },
      "uniqueItems": true,
      "description": "What the repo is BUILT WITH, from pipeline/lib/stack-detect.sh (Phase 1 Step 2), always in the order ios, android, web, backend. Routes marketplace toolkits through pluginsForStacks() in scripts/_stack-routing.mjs. An empty array is a real answer meaning no marker matched; stackWhy separates that from a root that could not be read. Distinct from detectedStack, which is the LANGUAGE axis."
    },
    "stackWhy": {
      "type": "string",
      "description": "Why stacks[] says what it says, e.g. 'ios<-Package.swift web<-package.json', or 'no marker matched', or 'unreadable: <path>'. A caller that cannot tell no-marker from unreadable treats an unreadable repo as a language-free one."
    },
    "detectedStack": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "The LANGUAGE census (ios, python, node, go, docker, monorepo), written by Phase 1 Step 2 and read by graph-build.mjs --stack, the dev-critic checklist selector and the Phase 3 reviewer skill tables. Deliberately not narrowed to the stacks[] vocabulary: graph-build accepts ios|android|node|python|go and has no notion of web or backend. Phase docs have instructed this write since before the schema existed, and additionalProperties is false, so it is declared here rather than silently invalid."
    },
    "evidenceCapability": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "What this machine and this repo can actually produce, measured by probe-evidence-capability.sh BEFORE the test-depth question. Every absent value carries its reason, so a closed option can say why instead of vanishing from the menu; a value that could not be measured is null with a reason, never false, because a probe that did not look and a probe that found nothing are different facts. Contract: multi-agent-refs/features/visual-evidence.md.",
      "properties": {
        "platform": {
          "type": "string",
          "enum": ["ios", "android", "web", "other"]
        },
        "uiTestTarget": {
          "type": ["string", "null"],
          "description": "The single chosen target, empty while several candidates exist and no match picks one."
        },
        "uiTestTargets": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Every candidate. A real app has many: the reference iOS app has one XCUITest bundle among 477 files that merely sit under a *UITests path, and the reference Android app has eight instrumentation source sets."
        },
        "uiTestTargetReason": {
          "type": ["string", "null"]
        },
        "matchingTests": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Tests that mention a changed file's name. A heuristic, and treated as one: an empty set falls to the next tier rather than concluding the screen is untested."
        },
        "matchingTestsReason": {
          "type": ["string", "null"]
        },
        "device": {
          "type": ["string", "null"]
        },
        "deviceReason": {
          "type": ["string", "null"],
          "description": "'no booted simulator, but one is available to boot' and 'no iOS simulator available on this machine' are different problems with different fixes, and the user can act on only one of them."
        },
        "recorder": {
          "type": ["boolean", "null"]
        },
        "recorderReason": {
          "type": ["string", "null"]
        },
        "mcp": {
          "type": ["boolean", "null"]
        },
        "mcpReason": {
          "type": ["string", "null"]
        },
        "tier1": {
          "type": "string",
          "enum": ["open", "closed", "unknown"],
          "description": "Whether the depth menu may offer tier 1. `unknown` means the target was not probed (a --only device re-check), which is not the same as closed and must not be rendered as one."
        },
        "tier2": {
          "type": "string",
          "enum": ["open", "closed", "unknown"]
        }
      }
    },
    "uiTest": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "The UI test run that produced the tier 1 recording. Subject to the same default-FAIL rule as the build: a zero exit code alone is not a pass, the log is the evidence, and evidence-gate.mjs reads it.",
      "properties": {
        "ran": {
          "type": "boolean"
        },
        "target": {
          "type": ["string", "null"]
        },
        "selected": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "status": {
          "type": ["string", "null"],
          "enum": ["passed", "failed", "not-run", null]
        },
        "notRunReason": {
          "type": ["string", "null"],
          "description": "Why it did not run: no target, no matching test, no device. Each is a reason to fall to the next video tier, never a phase failure."
        },
        "logPath": {
          "type": ["string", "null"]
        }
      }
    },
    "visualEvidence": {
      "type": ["object", "null"],
      "additionalProperties": false,
      "description": "Before/after screenshots and the UI flow video for a UI change, attached to the Jira issue and rendered in the Phase 5 comment and the PR body. Required decided mechanically from taskType plus the changed-file list. Contract: multi-agent-refs/features/visual-evidence.md.",
      "properties": {
        "required": {
          "type": "boolean"
        },
        "requiredBy": {
          "type": "string",
          "description": "Which rule made it required, e.g. 'bugfix + ui-file-changed'."
        },
        "platform": {
          "type": "string",
          "enum": ["ios", "android", "web", "other"]
        },
        "before": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": true,
            "properties": {
              "file": {
                "type": "string",
                "description": "Local path under $WORKTREE/.pipeline/evidence/."
              },
              "source": {
                "type": "string",
                "enum": ["ticket", "capture"]
              },
              "capturedAt": {
                "type": "string",
                "description": "Phase that produced it, e.g. 'phase-3'."
              },
              "jiraFilename": {
                "type": "string",
                "description": "Attachment filename the Jira comment references."
              },
              "url": {
                "type": "string",
                "description": "Jira attachment content URL."
              }
            }
          },
          "description": "Images taken from the issue's own attachments. The pipeline never rebuilds the pre-fix state to photograph it; no ticket image means no before, recorded as a gap."
        },
        "after": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": true,
            "properties": {
              "file": {
                "type": "string",
                "description": "Local path under $WORKTREE/.pipeline/evidence/."
              },
              "source": {
                "type": "string",
                "enum": ["ticket", "capture"]
              },
              "capturedAt": {
                "type": "string",
                "description": "Phase that produced it, e.g. 'phase-3'."
              },
              "jiraFilename": {
                "type": "string",
                "description": "Attachment filename the Jira comment references."
              },
              "url": {
                "type": "string",
                "description": "Jira attachment content URL."
              }
            }
          },
          "description": "Captured in Phase 2 after the build+test gate, because the user test is dropped by every autopilot entry and by any run whose workspace is local."
        },
        "host": {
          "type": ["string", "null"],
          "enum": ["jira", "github-public", "github-private", "none", null],
          "description": "Where the artefacts are published, resolved in Phase 4. `jira` attaches both stills and video. `github-public` pushes the stills to the evidence branch and embeds them in the PR body. `github-private` pushes the same stills but the PR carries a blob permalink instead of an inline image, because GitHub's image proxy cannot fetch a private repo's raw URL and an embedded one renders broken for every reader. `none` publishes nothing and records the gap. Video is Jira-only by decision: without an attachment host there is nothing a recording can be attached to."
        },
        "hostReason": {
          "type": ["string", "null"],
          "description": "Why this host and not the one above it in the order. A host of `none` with no reason is the silence the Phase 4 blocker exists to catch."
        },
        "videoTier": {
          "type": ["integer", "null"],
          "enum": [1, 2, 3, null],
          "description": "1 = the repo's own UI test target drove the flow, 2 = MCP-driven flow, 3 = no recording. Resolved from the capability probe, then RE-CHECKED at capture time: a device booted at intake can be gone by Phase 2, and a tier recorded from a stale measurement is a promise the run cannot keep."
        },
        "videoTierReason": {
          "type": ["string", "null"],
          "description": "Which rule produced the tier, and the tier it came down from when it was downgraded at capture time, e.g. 'tier 1 -> 2: simulator no longer booted'."
        },
        "video": {
          "type": "object",
          "additionalProperties": true,
          "properties": {
            "file": {
              "type": "string",
              "description": "Local path under $WORKTREE/.pipeline/evidence/."
            },
            "source": {
              "type": "string",
              "enum": ["ticket", "capture"]
            },
            "capturedAt": {
              "type": "string",
              "description": "Phase that produced it, e.g. 'phase-3'."
            },
            "jiraFilename": {
              "type": "string",
              "description": "Attachment filename the Jira comment references."
            },
            "url": {
              "type": "string",
              "description": "Jira attachment content URL."
            },
            "seconds": {
              "type": "number",
              "description": "Recorded duration; capped at 60."
            }
          }
        },
        "gaps": {
          "type": "array",
          "description": "Every artefact that is required and absent, with its reason. A recorded reason satisfies the Phase 4 blocker; silence does not.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "what": {
                "type": "string",
                "enum": ["before", "after", "video"]
              },
              "reason": {
                "type": "string"
              }
            },
            "required": ["what", "reason"]
          }
        }
      }
    },
    "gates": {
      "type": "array",
      "description": "Gate ledger, written while the quality gates are active (lib/unattended.mjs gatesActive: MULTI_AGENT_UNATTENDED=1, or state.autopilot true): one entry per verdict, appended by gate-ledger.mjs under the write-state lock. With MULTI_AGENT_UNATTENDED=1 in its environment, pre-commit-check.sh blocks a commit unless the latest entry of every mandatory gate for HEAD is pass or not-applicable. Absent in a run with neither.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["gate", "verdict", "at", "sha", "detail"],
        "properties": {
          "gate": {
            "type": "string",
            "minLength": 1,
            "description": "Gate id, e.g. evidence-gate/test, test-summary, symbol-existence."
          },
          "verdict": {
            "type": "string",
            "enum": ["pass", "fail", "not-applicable"]
          },
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "sha": {
            "type": ["string", "null"],
            "description": "Repository HEAD the gate ran against."
          },
          "detail": {
            "type": "object",
            "additionalProperties": true
          }
        }
      }
    },
    "planCritique": {
      "type": "object",
      "additionalProperties": false,
      "required": ["round", "digest", "verdict", "at"],
      "description": "The one plan-critique round of this run (refs/features/plan-critic.md), written by plan-critique-gate.mjs and by nothing else while the quality gates are active. digest is the sha256 of the judged critique with its replies; a later invocation with a different digest is refused as a second round. advisory is what Phase 1 carries into the plan and Phase 4 into the PR summary.",
      "properties": {
        "round": { "type": "integer", "const": 1 },
        "path": { "type": "string" },
        "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
        "objectionsDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
        "verdict": { "enum": ["pass", "fail"] },
        "objections": { "type": "integer", "minimum": 0 },
        "blocking": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["objection", "rule", "reason"],
            "properties": {
              "objection": { "type": "string" },
              "rule": { "type": "string" },
              "reason": { "type": "string" }
            }
          }
        },
        "advisory": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["objection", "lens", "claim", "label", "reply"],
            "properties": {
              "objection": { "type": "string" },
              "lens": { "enum": ["scope", "feasibility", "security", "alternative"] },
              "claim": { "type": "string" },
              "label": { "enum": ["anchored", "inference"] },
              "rule": { "type": "string" },
              "ruleStatus": { "enum": ["binding", "proposed", "unknown", "unchecked"] },
              "reply": { "enum": ["accept", "rebut", null] }
            }
          }
        },
        "at": { "type": "string", "format": "date-time" }
      }
    },
    "publish": {
      "type": "object",
      "additionalProperties": false,
      "required": ["outcome", "at"],
      "description": "Written by the autopilot runner's publish step (autopilot-publish.mjs) when it pushed a verified branch to a host with no draft PR. The runner reports it as pushed-awaiting-pr. A GitHub publish writes `pr` instead; a refused one writes `verificationFailed` with a `publish/<gate>` gate.",
      "properties": {
        "outcome": { "const": "pushed-awaiting-pr" },
        "at": { "type": "string", "format": "date-time" },
        "host": { "type": "string" },
        "branch": { "type": "string" },
        "base": { "type": "string" },
        "sha": { "type": "string" }
      }
    },
    "verificationFailed": {
      "type": "object",
      "additionalProperties": false,
      "required": ["gate", "reason", "at"],
      "description": "Set by gate-ledger.mjs park when an unattended run's own gate rejected its work. The runner reports the attempt as verification-failed (parked, not retried).",
      "properties": {
        "gate": {
          "type": "string",
          "minLength": 1
        },
        "reason": {
          "type": "string"
        },
        "at": {
          "type": "string",
          "format": "date-time"
        }
      }
    },
    "review": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "State for a standalone /multi-agent:review run. post-pr-review.sh reads input, findings, iterationNumber and headCommitSha to post the verdict.",
      "properties": {
        "input": {
          "type": "object",
          "additionalProperties": false,
          "required": ["kind", "provider", "raw"],
          "description": "Parsed review target.",
          "properties": {
            "kind": {
              "type": "string",
              "enum": ["pr", "branch", "pick"]
            },
            "provider": {
              "type": "string",
              "enum": ["github", "bitbucket-server", "local"]
            },
            "ref": {
              "type": "string"
            },
            "orgRepo": {
              "type": "string"
            },
            "host": {
              "type": "string"
            },
            "bbServerHost": {
              "type": "string"
            },
            "bbProjectKey": {
              "type": "string"
            },
            "bbRepoSlug": {
              "type": "string"
            },
            "prNumber": {
              "type": ["integer", "string"]
            },
            "raw": {
              "type": "string"
            }
          }
        },
        "headCommitSha": {
          "type": "string",
          "description": "PR head commit; GitHub inline comments require it as commit_id."
        },
        "moduleGuides": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Repo-relative module guide paths injected into every reviewer prompt."
        },
        "findings": {
          "type": "array",
          "items": {
            "type": "object",
            "additionalProperties": true,
            "properties": {
              "severity": {
                "type": "string"
              },
              "status": {
                "type": "string"
              },
              "file": {
                "type": "string"
              },
              "line": {
                "type": ["integer", "null"]
              },
              "issue": {
                "type": "string"
              },
              "fix": {
                "type": "string"
              },
              "ruleId": {
                "type": "string"
              }
            }
          }
        },
        "iterationNumber": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "complaintSpec": {
      "type": ["object", "null"],
      "additionalProperties": true,
      "description": "Answers and progress of a /multi-agent:complaint-analysis run. Shape: complaint-analysis-spec.schema.json."
    }
  }
}
