{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/autopilot-config.schema.json",
  "title": "Autopilot configuration",
  "description": "Continuous mode on ONE machine. Lives at ~/.claude/autopilot/config.json and deliberately NOT in multi-agent-preferences.json: that file has additionalProperties false and a migration chain, so a block there would push a key into every local user's preferences forever, including everyone who never turns this on. This file is the repo SELECTION, not the on/off state: autopilot-off keeps it so turning the mode back on does not re-ask which repos. On/off is whether launchd holds the job, because that is the thing that actually makes work happen. A clean install writes neither - there is no `enabled: false` default, because a file written at install time is a file that can be wrong. Every reader validates the file against this schema when it loads it; a value the schema refuses stops the queue with the field named (blocked-config) rather than being read as a missing ceiling.",
  "type": "object",
  "additionalProperties": false,
  "required": ["version", "repos"],
  "properties": {
    "version": {
      "type": "integer",
      "const": 1,
      "description": "Config shape version. Bumped only when a field changes meaning, never for additions."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    },
    "repos": {
      "type": "array",
      "description": "Repos this machine picks work up from. Empty is valid and means the mode is configured but idle - the picker was opened and everything was unchecked. No repo is ever added implicitly.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["nameWithOwner", "localPath"],
        "properties": {
          "nameWithOwner": {
            "type": "string",
            "description": "owner/repo as GitHub reports it."
          },
          "localPath": {
            "type": "string",
            "description": "The checkout on this machine. A repo with write permission but no local checkout is listed in the picker as unavailable WITH that reason, never silently dropped - the measured set is 65 repos with push rights and far fewer checkouts, so silent dropping would look like a permission problem."
          },
          "sources": {
            "type": "array",
            "description": "Where items come from for this repo. Both may be on.",
            "items": {
              "enum": ["github", "jira"]
            },
            "uniqueItems": true,
            "default": ["github"]
          },
          "githubLabel": {
            "type": "string",
            "default": "agent-queue",
            "description": "Only issues carrying this label are picked up. `gh issue list --label` does the filtering server-side."
          },
          "githubAssignee": {
            "type": "string",
            "default": "@me",
            "description": "Only issues assigned to this login are picked up (`gh issue list --assignee`), matching the Jira search's assignee = currentUser(). An issue form can apply the queue label for anyone who opens an issue, and an unattended run acts on the issue's text. \"*\" picks up every labelled issue."
          },
          "jiraJql": {
            "type": "string",
            "description": "Full JQL, so an instance that cannot use labels is a config change rather than a redesign. The label form is sugar for: assignee = currentUser() AND labels = \"<label>\" AND resolution = EMPTY AND status not in (Done, Closed, Cancelled) ORDER BY priority DESC, created ASC. Jira labels are a single global namespace anyone can write to, so on a shared instance prefer a qualified name, and note that the Labels field must be on the edit screen for the issue types in play. An issue that more than one repo's search returns is queued once, for the first repo in config order, so repos sharing a Jira instance should each carry a jiraJql that matches only their own issues."
          },
          "jiraLabel": {
            "type": "string",
            "default": "agent-queue",
            "description": "Sugar for jiraJql. Ignored when jiraJql is set."
          },
          "host": {
            "enum": ["github", "bitbucket"],
            "default": "github",
            "description": "Where the runner publishes this repo's verified branch (autopilot-publish.mjs). github: push, then `gh pr create --draft`. bitbucket: there is no draft PR, so the branch is pushed, a summary is written beside the PR request and the outcome is pushed-awaiting-pr. Any other value is refused at publish time, before anything is pushed."
          },
          "pushUrl": {
            "type": "string",
            "description": "The URL the runner pushes to. Absent on github: `gh repo view <nameWithOwner> --json url`. Required on bitbucket. Never read from the worktree's .git/config, which the run could have changed. Carries no credentials; the operator's git credential helper supplies them, and a URL with credentials in it is refused."
          },
          "baseBranch": {
            "type": "string",
            "description": "The base every PR request for this repo must target. When set, a request naming another base is refused, and this branch is protected like main, master, develop and release/*. When absent, the base is the remote's default branch (the branch its HEAD names); a remote that names none is refused until this is set."
          },
          "allowedBases": {
            "type": "array",
            "items": { "type": "string", "minLength": 1 },
            "uniqueItems": true,
            "default": [],
            "description": "Further bases a PR request for this repo may target, beside baseBranch or the remote's default branch. Each listed branch is protected like baseBranch. Empty means only the one base is accepted."
          },
          "rank": {
            "type": "integer",
            "minimum": 1,
            "maximum": 9,
            "description": "Optional explicit repo precedence. Absent means ordered by the normal rules."
          }
        }
      }
    },
    "slots": {
      "type": "integer",
      "minimum": 1,
      "maximum": 8,
      "default": 1,
      "description": "How many items run at once. Per-repo concurrency is always 1 whatever this says, because two runs in one checkout contend on .git/index.lock - so slots > 1 means that many DIFFERENT repos. Starts at 1 on purpose; autopilot-status prints the ceiling this machine's RAM, cores and free disk would allow so raising it is a measured decision rather than a guess."
    },
    "scanIntervalSeconds": {
      "type": "integer",
      "minimum": 60,
      "maximum": 3600,
      "default": 120,
      "description": "How often a tick looks for new work. 120s means an issue is picked up within two minutes of being labelled. The scan itself is cheap - one `gh issue list` per repo plus one Jira search - so at five repos this is about 150 GitHub calls an hour against a 5000/hour limit. launchd does NOT wake a sleeping Mac, it only fires on the next wake, which is why the awake agent (`awake`) holds sleep off for as long as the mode is on and a tick holds `caffeinate -s -w <pid>` while its session is in flight (multi-agent-refs/features/autopilot-operations.md)."
    },
    "maxParallelAgents": {
      "type": ["integer", "null"],
      "minimum": 1,
      "default": null,
      "description": "Ceiling on runner-launched sessions working at once: the run the runner supervises plus every parked run whose session is busy again after a person answered it. A ceiling, never a raise: absent or null is unlimited and keeps the existing behaviour exactly, which is one supervised session at a time (runner.pid is exclusive) within `slots`, parked runs not counted. When set and the agent list cannot be read, no item is taken that tick."
    },
    "gc": {
      "type": "object",
      "additionalProperties": false,
      "description": "The daily cleanup report. On the first tick of each local day the runner runs gc-abandoned.sh over the worktrees it created (worktrees.jsonl: only runs whose state carries the session id the runner launched them with) and writes gc-report-<date>.json and .md to the autopilot root. A worktree the queue still holds is never on the list.",
      "properties": {
        "autoDelete": {
          "type": "boolean",
          "default": false,
          "description": "Off: the report is a dry run and nothing is removed. On: gc-abandoned.sh runs with --yes, still restricted to runner-created worktrees and under its own rules - a run waiting on you is kept (including one parked with verificationFailed or waitingFor, and any whose item's newest attempt is parked), uncommitted work is stashed and its worktree kept, nothing outside <repo>/.worktrees/ is touched."
        }
      }
    },
    "verify": {
      "type": "object",
      "additionalProperties": false,
      "description": "The publish step's independent verification (multi-agent-refs/features/unattended-security.md). Before anything is pushed, the runner resolves the worktree's stack with _stack-adapter.mjs and runs the adapter's build and test commands itself, with tokens removed from the environment, then judges the output with evidence-gate.mjs --stack and test-summary.mjs. The run's own gate ledger is agent-writable and is only a consistency check.",
      "properties": {
        "allowUnverifiedStack": {
          "type": "boolean",
          "default": false,
          "description": "False: a stack the adapters cannot build and test (the unknown adapter, a package without a build or test script) is refused as publish/verify-stack. True: such a branch is published on the ledger and test-log checks alone, and the verdict records verified: false."
        },
        "timeoutSec": {
          "type": "integer",
          "minimum": 1,
          "default": 1800,
          "description": "Ceiling for each of the build and the test command. At the ceiling the command's whole process group is killed and the publish is refused as publish/verify-build or publish/verify-test."
        }
      }
    },
    "digest": {
      "type": "object",
      "additionalProperties": false,
      "description": "The opt-in daily digest: items attempted, outcomes by class, PRs opened, parked items waiting for an answer, and cost, over the trailing 24 hours. Written to digest-<date>.json and .md on the first tick of each local day. Sent only when `reportChannels` is also true and `channels.mode` is `configured`, through the same outbound gate as the post-PR report.",
      "properties": {
        "enabled": {
          "type": "boolean",
          "default": false,
          "description": "Absent or false: no digest is computed, written or sent."
        },
        "jiraIssue": {
          "type": "string",
          "pattern": "^[A-Z][A-Z0-9_]*-[0-9]+$",
          "description": "The standing Jira issue the digest is posted to as one comment when `channels.targets` has `jira`. A digest belongs to no single item, so it needs an issue of its own; without one the Jira target is reported as skipped."
        }
      }
    },
    "awake": {
      "type": "object",
      "additionalProperties": false,
      "default": { "enabled": true, "display": false },
      "description": "Keeps the Mac awake for as long as autopilot is on, so launchd ticks keep firing between runs: a separate launchd agent (com.multi-agent.autopilot-awake) runs `/usr/bin/caffeinate -s -i`, installed by autopilot-on and removed by autopilot-off. `-s` holds system sleep off on AC power only; `-i` holds idle sleep off on battery as well. The in-flight lock around each run is separate and unchanged (multi-agent-refs/features/autopilot-operations.md).",
      "properties": {
        "enabled": {
          "type": "boolean",
          "default": true,
          "description": "False: no awake agent is installed and only the in-flight lock holds sleep off, so the Mac may sleep between ticks and the queue waits for the next wake."
        },
        "display": {
          "type": "boolean",
          "default": false,
          "description": "True adds `-d`, keeping the display on too. Off by default: it costs energy and screen wear, and autopilot does not need it - builds, simctl screenshots and headless browsers work with the display asleep. Display sleep does not lock the keychain; system sleep can."
        }
      }
    },
    "maxAttempts": {
      "type": "integer",
      "minimum": 1,
      "default": 2,
      "description": "How many attempts that produced nothing - every RETRYABLE outcome in pipeline/scripts/_autopilot-outcomes.mjs, counted once per run - an item gets before it is left alone. Retry is part of intake rather than a separate mechanism; without it one transient failure drops an item silently."
    },
    "maxConsecutivePerRepo": {
      "type": "integer",
      "minimum": 1,
      "default": 5,
      "description": "How many items from one repo may run back to back before another repo gets a turn. Grouping by repo is what lets items 2..N reuse the code graph, repo map and knowledge index that are rebuilt per run today, so the default is deliberately generous - but without a ceiling a repo with forty labelled issues would hold the queue until it was empty, and every other repo would look broken."
    },
    "askOnLowMaturity": {
      "type": "boolean",
      "description": "Reserved, and read by nothing: setting it has no effect. It names a planned switch for posting one question comment on an item that fails the readiness review. Today a maturity blocker parks the run for the research-then-ask flow (maxAskRounds), whatever this says. Declared so a config that carries it still validates under additionalProperties false."
    },
    "maxAskRounds": {
      "type": "integer",
      "minimum": 1,
      "default": 2,
      "description": "Ceiling on research-then-ask rounds for one item, so a permanently vague ticket is not researched or asked about forever. A run that parks on a maturity blocker or on open questions gets one research pass per round (refs/features/research.md); the runner sums researchRounds from every attempted.jsonl row of the item, because each run of the item starts a fresh state file. At the ceiling the item is parked for a person without another pass. The gap fingerprint prevents repeating the SAME question; this prevents an endless series of different ones."
    },
    "costCeilingUsd": {
      "type": "number",
      "minimum": 0,
      "default": 25,
      "description": "Rolling 24-hour spend ceiling. Dispatch refuses past it and says so; running work is never killed mid-item. There is deliberately no cap on the number of PRs - the queue is bounded by this and by machine capacity, not by a daily item count."
    },
    "channels": {
      "type": "object",
      "additionalProperties": false,
      "description": "Phase 5 pauses for channel selection in every attended mode, by design. Unattended there is nobody to ask, so the answer comes from here instead and Phase 5 completes without prompting. Without this the queue would generate its own backlog: every finished item parked waiting for a menu.",
      "properties": {
        "mode": {
          "enum": ["none", "pr-body", "configured"],
          "default": "pr-body",
          "description": "none: no report anywhere. pr-body: the report goes in the PR body only, no external writes. configured: use the list below."
        },
        "targets": {
          "type": "array",
          "items": {
            "enum": ["jira", "confluence", "wiki", "pr"]
          },
          "uniqueItems": true
        }
      }
    },
    "reportChannels": {
      "type": "boolean",
      "default": false,
      "description": "Opt-in for any report beyond the draft PR. An unattended run posts nothing itself (agent-guard blocks every publisher under MULTI_AGENT_UNATTENDED=1). The runner, after it verified pr-request.json, pushed and opened the PR, and only then, reads the `channels` selection above: mode `configured` with target `jira` posts one comment (the PR link and text) through jira-publish.sh; `pr` is the draft PR itself; `confluence` and `wiki` need a page composed with publish rights and are reported as skipped. Absent or false: the draft PR is the only thing published. multi-agent-refs/features/unattended-security.md."
    },
    "reportIssueUpdates": {
      "type": "boolean",
      "default": false,
      "description": "Opt-in for the issue updates an attended Phase 4 makes itself. After the runner opened the PR, and only then: update-issue-progress.sh for the GitHub issue the run recorded, and a PR-link comment on the linked Jira issue when reportChannels did not already post one. Each runs once, with MULTI_AGENT_UNATTENDED unset, and its text passes the outbound gate. Absent or false: nothing is posted."
    },
    "reportStatus": {
      "type": "boolean",
      "description": "Reserved, and read by nothing: no heartbeat is sent, whatever this says. It names a planned counts-only heartbeat to the admin panel. Declared so a config that carries it still validates under additionalProperties false."
    },
    "sendItemKeys": {
      "type": "boolean",
      "description": "Reserved, and read by nothing, like reportStatus: no item identifier leaves the machine through the runner. Declared so a config that carries it still validates under additionalProperties false."
    },
    "depthRouter": {
      "enum": ["off", "auto"],
      "deprecated": true,
      "description": "Accepted and ignored. Every item runs the one autopilot pipeline, and nothing reads this key; it stays declared so a config written by an older version still validates under additionalProperties false. autopilot-on drops it when it rewrites the file."
    }
  }
}
