/** * Shared contract for omp-conductor. * * Every later slice (tracker adapters, the store, the dispatcher loop, the * worker driver, the plugin and the CLI) imports from here and nothing else, * so this file stays free of imports and runtime code — the single exception * is `DEFAULT_CAPS`, which is data, not behaviour. */ /** * A threshold on one provider-reported allowance window. * * Both fields exist because of what the real `omp usage --json` payload looks * like. `limits` is a *list* — one Anthropic account reports `anthropic:5h`, * `anthropic:7d` and `anthropic:7d:fable` at once — so the window is named * rather than positional. And `unit` differs per provider (`percent` for * Anthropic, `unknown` with raw counts for `xai-oauth`), so the threshold is a * fraction of the allowance and never a raw count. */ export interface PlanUsageCap { /** * Which allowance this threshold is about: the fully-qualified limit id * (`"anthropic:7d"`), or a bare window key (`"7d"`) when exactly one * reported allowance carries it. An id nothing reports holds dispatch and * says so — it is never quietly treated as unmetered. */ windowId: string; /** * Consumed share, `0`–`1`, at which new claims stop. `0.85` holds at 85% of * the allowance. A fraction rather than a percentage so the config cannot * read `85` and mean "never". */ maxUsedFraction: number; } /** * Hard limits enforced in code, never by the model. A worker that is asked to * respect a budget will eventually talk itself out of it, so the dispatcher * counts turns, wall clock and dollars itself and kills anything over the line. */ export interface Caps { /** Parallel omp sessions. Two by default: on a small self-hosted runner pool * a third worker would starve its own PR checks. */ maxConcurrentWorkers: number; /** Max live workers per repository. 1 by default: the mirror, branch-protection * staleness and shared CI egress are all per-repo collision domains (#186), so * extra slots should land on other repos. */ maxConcurrentWorkersPerRepo: number; /** * Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock * still apply). `0` is a hard stop — deliberate, not "unset". */ dailySpendUsd: number | null; /** * Ceiling on what ONE run may spend, and the size of the reservation * admission takes out of the daily budget before it launches anything * (#851). * * The daily cap alone is a post-spend stop: it compares spend-to-date * against the ceiling at admission time and pauses the fleet on the *next* * pass, so one expensive run can cross it by its whole cost before anything * notices. On 2026-08-21 a single `@slow` run spent an estimated $25.16 of a * $25.00 daily cap unopposed, and the fleet paused only once it had settled. * * `null` means "derive it": `dailySpendUsd / maxConcurrentWorkers`, the * largest per-run allowance that still lets the fleet run at its configured * concurrency. Reserving the whole daily cap per run would bound overshoot * equally well and silently serialise every fleet on defaults, turning a * spend guard into a concurrency change nobody asked for; N reservations of * cap/N are still the cap, so nothing is given away. Set it explicitly to * bound one run harder. It may never exceed `dailySpendUsd` — a per-run * allowance larger than the day's budget is a reservation that can never be * satisfied. With `dailySpendUsd: null` the fleet is unmetered and an * explicit value still bounds one run. */ maxRunSpendUsd: number | null; /** * Subscription/plan allowance guard, independent of `dailySpendUsd` and * enforced from the provider's own reported usage rather than from a dollar * figure conductor made up (#110). `null` means unmetered: the fleet has no * plan allowance worth guarding, or none this host can read. */ planUsage: PlanUsageCap | null; /** Turn ceiling for one worker — catches loops that are burning tokens * without converging. */ workerMaxTurns: number; /** Maximum turn budget an operator may assign to one issue's next attempt. * Defaults to twice that issue's effective `workerMaxTurns`. */ workerMaxTurnsCeiling: number; /** Wall-clock ceiling for one worker (90 min): a session that is merely * stuck spends no turns, so turns alone cannot detect it. */ workerWallClockMs: number; /** How long a live worker's transcript may stay unwritten before the * daemon settles it as a progress stall (#1086). `null` derives it from the * wall-clock ceiling — one third, so a hung run is caught long before its * clock runs out. A single turn can legitimately run for minutes on a slow * provider, so this reads *transcript writes*, never the turn counter. */ workerStallSilenceMs: number | null; /** Failed implementation/CI attempts allowed before escalation. Operational * continuations do not consume this budget. */ maxAttemptsPerIssue: number; /** Cap-kill, daemon-orphan and answered-block continuations allowed before a * crash/resume loop escalates independently of implementation failures. */ maxContinuationsPerIssue: number; } /** * One module checkout a routed issue can target. A project is usually several * repos (module images, shared packages), and the dispatcher must know how to * clone and validate each one without asking the model. */ export interface RepoTarget { /** Short routing key that appears in the issue label, e.g. "api". */ name: string; cloneUrl: string; /** Branch worktrees are cut from and PRs target. */ defaultBranch: string; /** * Exact cheap pre-push commands CI also runs, with the cwd each runs from. * Running the real gate locally is what makes an unattended push safe — a * subset lets lint errors outside the source dir reach the runners. */ gates: { cmd: string; cwd: string }[]; /** * Absolute path of the **conductor-owned, index-only clone** of this repo * whose code-graph index workers query. Optional: absent means this repo has * no graph, and the worker brief says so explicitly rather than staying * silent — a worker that knows there is no graph stops looking for one. * * Two things it deliberately is not, and both were paid for: * * - **Not a worker's worktree.** A code-graph index is keyed by the realpath * of the directory it was built from, with no git-worktree awareness, so a * run's throwaway `worktrees/` path is always an empty project. A * worker that queried its own cwd would find nothing, conclude there is no * graph, and go back to grepping — which is the entire cost this field * exists to remove. * - **Not a human's checkout.** Refreshing an index means hard-resetting the * clone to its default branch. Doing that where somebody works destroys * their uncommitted edits; making it safe instead (a fast-forward pull) * means the graph reflects whatever feature branch they left checked out. * So this names a disposable clone nothing human ever edits, which is what * makes the reset both safe and deterministic. * * `omp-conductor setup graph` creates and refreshes it. Nothing in * this package reads an index itself: the daemon only passes this path into * the worker brief. */ graphProject?: string; /** * Ordered-migration-chain guard (#227): repo-relative directory whose *.py * files declare `revision`/`down_revision`. When set, `conductor_pr_merge` * refuses a merge that would collide with the base branch's chain (id reuse, * a deleted migration, or a combined graph with more than one head). Absent * = no chain check. */ migrations?: { dir: string }; /** * Repository-native release preparation. `versionFile` is a repo-relative * JSON file with a top-level string `version` (for example * `omp/package.json`). A delegated tag is refused until that live value * matches the requested tag; `version-bump-pr` is the mediated review path. */ release?: { versionFile: string }; } /** * How much the orchestrator says out loud without being asked. Declared as data * so the validator, the wizard and the brief all enumerate the same three * values: a fourth scope cannot be added while one of them still knows only * three. * * `escalations` interrupts only for a tier-2 decision. `material` reports every * material event as it happens. `decisions` sits between them: a decision the * session needs, or a condition that stops the fleet, interrupts immediately; * every other material event accumulates and ships with the configured digest * instead of as a ping per merge (#138). */ export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const; export type ReportScope = (typeof REPORT_SCOPES)[number]; /** * What a project that never answered the question gets. `material` rather than * `escalations` because a config written before this key existed was serviced by * a brief that reported material events, and quietly muting an existing fleet is * the kind of change nobody notices until the week it mattered. */ export const DEFAULT_REPORT_SCOPE: ReportScope = "material"; /** * The four answers the wizard offers for "how loud should the orchestrator be". * Three are legacy presets; `quiet` is the explicit form: interrupt only for * escalations, fleet stops and confirmed failures, with one daily rollup * (#229). */ export type ReportScopeChoice = ReportScope | "quiet"; /** * Which categories may interrupt the operator's phone. Each maps to a tier-2 * escalation channel: `tier2` (a plain decision), `decision-needed`, a * fleet-stopping condition, a confirmed failure, or any material event. What an * `interruptOn` list omits is not dropped — it accumulates for the digest. * Data rather than booleans so the config validator, the escalator and the * orchestrator prompt all enumerate the same vocabulary (#229). */ export const INTERRUPT_CATEGORIES = [ "tier2", "decision-needed", "fleet-stopped", "confirmed-failure", "material", ] as const; export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number]; /** Operator-local weekdays used by a weekly availability window. */ export const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; export type Weekday = (typeof WEEKDAYS)[number]; /** * One operator-local interruption window. A day names the day the window * opens; when `start` is later than `end`, it closes on the following day. */ export interface WeeklyAvailability { timezone: string; days: Weekday[]; start: string; end: string; /** Categories that may still interrupt outside the window. */ bypass: InterruptCategory[]; } /** When the daily/report rollup is due. */ export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const; export type DigestCadence = (typeof DIGEST_CADENCES)[number]; /** * Explicit reporting policy: which categories interrupt, and when the daily * rollup happens. `scopePreset` is set when the config came from a legacy * `scope:` preset, and drives the preset tick prompt text. */ export interface ReportingPolicy { interruptOn: InterruptCategory[]; digest: { cadence: DigestCadence; at?: string; timezone?: string }; /** Absent preserves the legacy 24-hour interruption behaviour. */ availability?: WeeklyAvailability; /** Set when the policy came from a legacy `scope` preset. */ scopePreset?: ReportScope; } /** * The default policy: the legacy `material` preset, spelled out so an explicit * config can name it and the wizard can show it. */ export const DEFAULT_REPORT_POLICY: ReportingPolicy = { interruptOn: [...INTERRUPT_CATEGORIES], digest: { cadence: "per-tick" }, scopePreset: DEFAULT_REPORT_SCOPE, }; /** * The irreversible tool-call shapes this package recognises and gates. Data * here rather than in `release-policy.ts` because the config validator, the * setup wizard and the status renderer now all enumerate them (#122): a sixth * shape must fail to compile in each of those places rather than silently * resolve to "not granted" in one of them. * * Deliberately generic. Fleet-specific release topology — a suite pin, a * registry, an IaC stack — belongs in the operator's POLICY.md, not here: a * conductor installed with no npm and no Kubernetes must neither carry those as * dead config nor be asked about them by the wizard. */ export const RELEASE_SHAPES = [ "version-bump-pr", "git-tag", "git-push-tags", "package-publish", "github-release", "deploy", /** * Replace the running conductor itself: the discovered CLI package, the omp * plugin and the Herdr plugin, pinned to one published release and executed * detached. This is the "nobody patches the running conductor" boundary * moved deliberately, not worked around — a session that may cut a release * still cannot install one until an operator grants this shape too. */ "install", ] as const; export type ReleaseShape = (typeof RELEASE_SHAPES)[number]; /** * Mechanical permission per release shape. `authority.release` says who owns * the *decision*; this key is the enforcement gate that decides whether an * autonomous session may invoke the tools at all. * * A map rather than the binary it replaced: under the old `"operator-brief"` * one grant covered cutting a tag and mutating a running environment, and on * 2026-08-09 a stale grant was enough for an orchestrator session to invoke * Komodo `DeployStack` (#122). Producing an artifact and changing what is * running are different acts with different blast radii and different rollback * owners, so they are granted separately or not at all. * * Legacy string forms still parse and are migrated at load; absent keys deny. */ export type ReleasePolicy = "none" | Partial>; /** After normalisation: every shape present, absent keys denied as `"human"`. */ export type ResolvedGrants = Record; /** * The on-disk spellings from before the grant map. Read and migrated, never * written again — data so the validator's error message and the migration table * cannot come to name different sets. */ export const LEGACY_RELEASE_POLICIES = ["none", "operator-brief"] as const; /** * Fail-closed baseline: nothing granted to any session. Written out rather than * derived from {@link RELEASE_SHAPES} so `Record` makes a new shape a compile * error here — a shape this table forgot would read as `undefined` at the gate, * and `undefined !== role` is only accidentally safe. */ export const DENIED_RELEASE_GRANTS: ResolvedGrants = { "version-bump-pr": "human", "git-tag": "human", "git-push-tags": "human", "package-publish": "human", "github-release": "human", deploy: "human", install: "human", }; /** * What the legacy `"operator-brief"` string migrates to: the safe reading of * what operators believed they were enabling when they opened the binary gate. * `deploy` stays with the human, because that is the grant nobody knowingly * gave — see #122. */ export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = { "version-bump-pr": "orchestrator", "git-tag": "orchestrator", "git-push-tags": "orchestrator", "package-publish": "orchestrator", "github-release": "orchestrator", deploy: "human", // Nobody expected a legacy "operator-brief" grant to cover patching the // running conductor; like `deploy` it stays with the human. install: "human", }; /** * What kind of session is asking. Explicit at every gate rather than inferred * from confinement or from which module made the call: a worker's refusal must * not depend on a boolean some future call site forgets to pass. * * Note the two sets: a grant is an {@link AuthorityHolder}, never a * `SessionRole`, so `"worker"` can never equal a grant and a worker is refused * every shape whatever the config says. The type does that enforcing, not a * check some path can miss. */ export const SESSION_ROLES = ["worker", "orchestrator"] as const; export type SessionRole = (typeof SESSION_ROLES)[number]; /** * Env var the daemon sets on a spawned session so the reporting CLI can tell a * worker session from the operator's shell. The daemon stamps it on the * session-host child (and thus on every tool process it spawns); a direct CLI * run — the operator's shell or a systemd unit — has it absent and is treated * as the orchestrator, preserving the historical surface. {@link SESSION_ROLES} is * the closed vocabulary: a value outside it must be refused, never assumed. */ export const SESSION_ROLE_ENV = "OMP_CONDUCTOR_SESSION_ROLE"; /** * Who holds an authority the daemon itself never exercises. Declared as data * for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and * the brief renderer all enumerate the same two holders, so a third one cannot * be added while any of them still knows only two. */ export const AUTHORITY_HOLDERS = ["human", "orchestrator"] as const; export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number]; /** * What a project that never answered the question gets: humans keep all three. * A default that delegated merging would hand a fresh fleet write access to * its own main branch on the strength of an unread config file, and a default * that delegated promotion would let the orchestrator start spend unasked. */ export const DEFAULT_AUTHORITY: ProjectConfig["authority"] & { promotion: AuthorityHolder } = { merge: "human", release: "human", promotion: "human", }; /** * Whether a merge candidate must be up to date with the branch it targets. * * Declared as data for the reason {@link AUTHORITY_HOLDERS} is, and extracted * from POLICY.md prose for a sharper one (#129): until this key existed the * answer was a sentence an operator wrote and a session re-read every tick, so * two ticks could reach two answers and neither left a trace of having decided. * A set-membership check leaves the same trace every time. */ export const BASE_FRESHNESS = ["up-to-date", "any"] as const; export type BaseFreshness = (typeof BASE_FRESHNESS)[number]; /** * What a draft pull request is to the merge gate. `block` by default: a draft is * the author's own statement that the work is not finished, and an autonomous * merge that overrides that has read the diff rather than the intent. */ export const DRAFT_POLICIES = ["block", "allow"] as const; export type DraftPolicy = (typeof DRAFT_POLICIES)[number]; /** * What to do with an otherwise-mergeable pull request that has fallen behind its * base branch. * * `update-branch` is the package floor's one sanctioned write into a worker's * branch — `gh pr update-branch`, then a wait for the fresh run — and it is the * default because merging promptly is itself what stops the next PR going stale. * `hold` leaves it for whoever refreshes it; `escalate` pages rather than * guessing. Closing it and an admin bypass are absent on purpose: neither was * ever an answer, and a vocabulary that cannot spell them cannot be argued into * one. */ export const BEHIND_BASE_ACTIONS = ["update-branch", "hold", "escalate"] as const; export type BehindBaseAction = (typeof BEHIND_BASE_ACTIONS)[number]; /** * What must already have landed before a release may be cut. Each member is * something the daemon can settle against its own store or the tracker — that is * the entry price for being in this list rather than in POLICY.md. */ export const RELEASE_REQUIREMENTS = [ /** Every active run of the released repo reached `merged`, not merely `pushed-green`. */ "runs-settled", /** Every active run in the project reached `merged` — the suite-wide form of * `runs-settled`, for shapes that consume several repos at once. */ "fleet-runs-settled", /** No pull request is still open against the branch being released. */ "no-open-prs", /** Nothing still carries the queue label: the batch is finished, not paused. */ "queue-drained", /** The newest observed post-merge check for the released repo's base branch is green. */ "base-branch-green", /** The epic this release closes has no open children left. */ "epic-children-closed", ] as const; export type ReleaseRequirement = (typeof RELEASE_REQUIREMENTS)[number]; /** * What must be true before a pull request may be merged. * * These four were sentences in the shipped POLICY.md until #129, which meant a * verb could honour them only by asking a model to read prose and agree with it. * Typed here, the decision is a pure function of config: the same PR gets the * same answer on Friday as on Tuesday, and a refusal can name the field that * produced it. */ export interface MergePreconditions { /** * Named checks that must have concluded successfully. Empty means *every* * check the pull request reports — the strict reading, and the one a project * that never answered the question wants. */ requiredChecks: string[]; /** Whether the head must be up to date with its base. See {@link BASE_FRESHNESS}. */ baseFreshness: BaseFreshness; /** What a draft pull request is. See {@link DRAFT_POLICIES}. */ drafts: DraftPolicy; /** * What to do when `baseFreshness` is `"up-to-date"` and the head is not. * Consulted only then: under `"any"` there is no behind-base case to answer. */ whenBehindBase: BehindBaseAction; } /** * What must be true before a release may be cut: what has landed, what it * produces, and where it may be pushed. * * `artefacts` and `environments` are empty by default, and empty denies. A * project that never named an artefact has not authorised releasing one, and a * project that named no environment has authorised no deploy target — the same * posture as an absent release grant (#122). The config says yes out loud, or it * has not said yes. */ export interface ReleasePreconditions { /** What must already have landed. See {@link RELEASE_REQUIREMENTS}. */ requires: ReleaseRequirement[]; /** * Named checks that must be green on the branch being released. Empty means * every check that branch reports. */ requiredChecks: string[]; /** Packages or images this project releases. Empty denies every artefact. */ artefacts: string[]; /** Environments a deploy may target. Empty denies every environment. */ environments: string[]; } /** * The gating conditions #126's verbs read instead of POLICY.md. Optional on disk * and complete after the loader has seen it, so no consumer has to know which of * these fields the operator happened to spell. */ export interface ProjectPolicy { merge: MergePreconditions; release: ReleasePreconditions; } /** * What a project that never answered the question gets: the strictest reading of * the prose these fields replaced. Every check green, the base fresh, drafts * refused, a behind-base PR refreshed rather than merged, and a release only of * work that actually landed — with no artefact and no environment declared, so a * release verb has nothing to act on until an operator says what this project * ships. */ export const DEFAULT_PROJECT_POLICY: ProjectPolicy = { merge: { requiredChecks: [], baseFreshness: "up-to-date", drafts: "block", whenBehindBase: "update-branch", }, release: { requires: ["runs-settled"], requiredChecks: [], artefacts: [], environments: [], }, }; /** * Why a merge is being asked for. A closed set rather than free text, for the * reason every other vocabulary here is closed: a `reason` the daemon matches on * is a field that decides, and a decision made out of a model's own wording is * one no two ticks spell the same way. */ export const MERGE_REASONS = [ /** Every condition in {@link MergePreconditions} is satisfied as it stands. */ "preconditions-met", /** It was behind its base, was refreshed, and came back green. */ "behind-base-refreshed", /** A human in the loop asked for this one specifically. */ "operator-instructed", /** It is what a release otherwise ready to cut is waiting on. */ "release-blocking", ] as const; export type MergeReason = (typeof MERGE_REASONS)[number]; /** * A one-PR provenance exception an operator placed in config for recovery. * * It replaces broad standing merge authority for this exact request and lets * an orchestrator present an otherwise-unrecorded PR to the ordinary merge * gate, including while dispatch is held. It does not relax the normal merge * checks. Exact URL, head and reason binding keeps the exception narrower * than the action it authorizes (#382). */ export interface RecoveryMergeAuthorization { prUrl: string; headSha: string; reason: Extract; } /** Why a release is being cut now. Closed for the reason {@link MERGE_REASONS} is. */ export const RELEASE_REASONS = [ /** The batching unit the operator described in POLICY.md has been reached. */ "batch-complete", /** The epic this release closes has no open children left. */ "epic-closed", /** A fix that cannot wait for the batch. */ "hotfix", /** A human in the loop asked for this one specifically. */ "operator-instructed", ] as const; export type ReleaseReason = (typeof RELEASE_REASONS)[number]; /** * Why a tracker label is being changed. Closed for the reason * {@link MERGE_REASONS} is, and load-bearing past bookkeeping: the queue label is * the claim gate, so "why did this issue become claimable" has to be answerable * from the ledger rather than from a session transcript nobody kept. */ export const LABEL_REASONS = [ /** Groomed and promoted onto the queue. */ "promoted-to-queue", /** Re-briefed after a failed attempt, and put back on the queue. */ "re-briefed", /** Delivered: the PR merged and the issue closed, and the queue label comes * off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */ "completed", /** Parked: it needs a decision only a human can make. */ "needs-human", "duplicate", "superseded", "out-of-scope", ] as const; export type LabelReason = (typeof LABEL_REASONS)[number]; /** * Why a green pull request is being returned to its worker for revision. * Closed for the reason {@link MERGE_REASONS} is: a revision request is a * decision the ledger has to be able to name, and the same request repeated * must read the same both times. */ export const REVIEW_REASONS = [ /** The orchestrator read the green PR and found issues that block merging. */ "blocking-findings", ] as const; export type ReviewReason = (typeof REVIEW_REASONS)[number]; /** * How a queued review revision ended, written by the daemon side of the * transport. `revised` is a revision worker that again reached a verified * green head; `pending` is a still-unverified push; `failed` is a revision * worker that settled terminally; `skipped` is a queued round the daemon could * not dispatch (its run moved, its transcript vanished, or the store already * claimed the slot). */ export const REVIEW_REVISION_OUTCOMES = ["revised", "pending", "failed", "skipped"] as const; export type ReviewRevisionOutcome = (typeof REVIEW_REVISION_OUTCOMES)[number]; /** * How strictly this project's green PRs are reviewed before they merge * (#678). One of three typed levels; each level's bar is documented once in * {@link REVIEW_STRICTNESS_CHOICES} (setup.ts), which is the same single text * the rendered orchestrator brief carries, so the level a config declares and * the threshold a session actually enforces cannot disagree. * * `low` blocks only correctness, security, data-loss or explicit * acceptance-criteria failures. `medium` (the recommended default for a new * project) adds material maintainability or reliability defects likely to * become incidents within six months. `high` adds concrete quality defects — * never subjective style churn or unbounded refactoring. */ export const REVIEW_STRICTNESS = ["low", "medium", "high"] as const; export type ReviewStrictness = (typeof REVIEW_STRICTNESS)[number]; /** The recommended strictness for a project that has never answered. */ export const DEFAULT_REVIEW_STRICTNESS: ReviewStrictness = "medium"; /** The small validated range a project's own review-round ceiling may sit in. */ export const REVIEW_MAX_ROUNDS_MIN = 1; export const REVIEW_MAX_ROUNDS_MAX = 6; /** The typed default ceiling: three corrected heads per PR lifecycle — enough * for real correction cycles, and a hard stop against endless polishing. */ export const DEFAULT_REVIEW_MAX_ROUNDS = 3; /** * The one spelling a review policy may ask the adjudicator for: a token naming * an OMP model role (`"task"`, `"worker"`, a project's own `modelRoles` key). * A role name is never a provider/model — OMP owns provider selection and * named model roles, and conductor stores only the role. Used by the config * grammar and by the setup dialog's availability check; they must read the * same shape or one of them accepts an answer the other rejects. */ export const REVIEW_ADJUDICATOR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** * Which OMP model role carries a PR's terminal review-ceiling adjudication * (#875) when no project policy names one. The deterministic migration default * every existing project loads until its operator answers setup: the general * OMP session role each install can launch without a provider or model being * pinned here. */ export const DEFAULT_REVIEW_ADJUDICATOR_ROLE = "task"; /** * Which OMP model role the daemon's own to-spec grooming scouts run under * (#1041) when no project names one. Same value and same reasoning as * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE} — the general session role every * install can launch — but a separate constant because the two are separate * operator choices: grooming judgement and review adjudication are answered * independently in setup, and collapsing them would silently move one when the * other is retuned. Validated with {@link REVIEW_ADJUDICATOR_RE}: one role-name * grammar for every role token conductor stores. */ export const DEFAULT_GROOM_ROLE = "task"; /** * One project's review policy (#678): the strictness its orchestrator applies * when deciding whether a green PR is returned to its worker, the hard * ceiling on how many such rounds one PR lifecycle may consume, and which OMP * model role carries the final adjudication once that ceiling is reached * (#875). The ceiling is enforced by the dispatch side, never left to the * session's reading of the brief: at the ceiling `conductor_pr_review` * refuses, the orchestrator leaves the PR open, records the unresolved * findings and escalates once. */ export interface ReviewPolicy { strictness: ReviewStrictness; maxRounds: number; /** * The OMP model role that runs a PR's terminal review-ceiling adjudication: * a named role from OMP's configured model roles (`"task"`, `"worker"`, or a * project's own `modelRoles` key), in omp's model-role syntax — never a * provider/model, which OMP owns. Absent in config loads as * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE}, the deterministic default every * existing install upgrades to. */ adjudicator: string; } /** The documented migration default: what a config written before the fields * existed loads as, deterministically, until the operator answers setup. */ export const DEFAULT_REVIEW_POLICY: ReviewPolicy = { strictness: DEFAULT_REVIEW_STRICTNESS, maxRounds: DEFAULT_REVIEW_MAX_ROUNDS, adjudicator: DEFAULT_REVIEW_ADJUDICATOR_ROLE, }; /** * The resolved launch provenance of one review-ceiling adjudication (#875): * what the policy named and what the role actually resolved to when the * adjudicator session launched. OMP owns named roles and exact provider * selection, so this record is where a launch's real model/provider lands — * the typed fields later status/ledger code reads to show what genuinely * adjudicated, not what a config said it should. */ export interface ReviewAdjudicationProvenance { /** The policy's adjudicator role, verbatim from {@link ReviewPolicy.adjudicator}. */ readonly role: string; /** The model pattern the role resolved to at launch, in omp's model syntax. */ readonly model: string; /** The provider OMP selected, when the harness could name one at launch. */ readonly provider?: string; /** Epoch ms when this launch resolution was recorded. */ readonly resolvedAt: number; } /** * The lifecycle of one PR's terminal review-ceiling adjudication (#874). * * The terminal states ARE the verdict — there is deliberately no second * `verdict` column beside this one. A state plus an independently-stored verdict * is two sources of truth for one fact, and the copy nobody updates is the one a * renderer reads. What is stored alongside is what the state cannot say: * the evidence that produced it and the disposition applied because of it. * * - `pending` — recorded, one-shot claimed, no session launched yet. * - `running` — an adjudicator session is live on it. * - `cleared` — the PR may take the ordinary orchestrator-owned merge path. * - `rejected` — it may not; #876 owns the autonomous disposition. * - `unavailable-model` — the configured role could not be resolved or * launched. Distinct from `failed` because the remedy is configuration, not * a retry. * - `stale-head` — the PR moved after this adjudication was recorded, so its * verdict can no longer describe what would merge. * - `failed` — it ran and could not produce a verdict. */ export type ReviewAdjudicationState = | "pending" | "running" | "cleared" | "rejected" | "unavailable-model" | "stale-head" | "failed"; /** The terminal states: a row in one of these is finished forever, and is the * verdict. Exported so the store's transition guard and every reader agree on * the same set rather than each spelling its own list. */ export const TERMINAL_REVIEW_ADJUDICATION_STATES: readonly ReviewAdjudicationState[] = [ "cleared", "rejected", "unavailable-model", "stale-head", "failed", ]; /** * One durable review-ceiling adjudication (#874): the record that makes the * final adjudication a lifecycle rather than an in-memory fourth review round. * * Its identity is the exact reviewed head, and that is the one-shot guarantee: * a repeated tick, a racing dispatcher or a daemon restart re-reads this row * instead of launching a second adjudicator. A round in `review_revisions` is * deliberately NOT reused for this — that would spend a worker review round the * ceiling has already exhausted, and would let the same session and model * produce a nominal fourth opinion, which is exactly what #873 forbids. */ export interface ReviewAdjudicationRecord { id: string; project: string; issue: number; prUrl: string; /** The exact head this adjudication is about. The one-shot key, with * `project` and `prUrl`: a moved head is a different adjudication, and a * verdict never travels to a head it did not read. */ headSha: string; /** The policy's adjudicator role at admission, verbatim — what was asked for, * which stays readable even when the role later resolves differently. */ role: string; /** What the role actually resolved to at launch (#875). Absent while * `pending`: nothing has resolved a model yet, and recording an intention as * provenance would make status claim a launch that never happened. */ provenance?: ReviewAdjudicationProvenance; state: ReviewAdjudicationState; /** Why the terminal state is what it is: the adjudicator's verdict text, or * the diagnostic for a fail-closed outcome (the two heads of a `stale-head`, * the unresolvable role, the failure). */ evidence?: string; /** What was done about the verdict (#876 writes it), kept separate from the * verdict itself so "rejected" and "rejected, and here is what happened" * are never conflated. */ disposition?: string; requestedAt: number; /** Set when an adjudicator session was launched for this row. */ dispatchedAt?: number; settledAt?: number; /** The findings that provoked the escalation (#932): what the orchestrator was * submitting when `conductor_pr_review` hit the ceiling. Evidence for the * adjudicator, deliberately not a `review_revisions` round — a round would * spend a ceiling that is already exhausted and would wake an implementer. */ findings?: string; } /** * What admitting an adjudication did (#874). Idempotent by construction: the * second caller for one head is told the row already exists rather than being * allowed to create a twin, and a non-terminal adjudication at a DIFFERENT head * on the same PR refuses — that PR moved, and adjudicating two heads at once is * the duplicate this lifecycle exists to prevent. */ export type ReviewAdjudicationAdmission = | { kind: "created"; record: ReviewAdjudicationRecord } | { kind: "existing"; record: ReviewAdjudicationRecord } | { kind: "refused"; block: "in-flight-different-head"; record: ReviewAdjudicationRecord }; /** * How one review-correction round was launched (#1045, child of #1044). * * `resume-original` is the pre-#1045 behaviour and stays the healthy default * path: the findings are delivered into the *same* implementation transcript, * which is why the round costs no attempt and needs no re-orientation. * `fresh-correction` is the escape hatch #1035 proved necessary — the original * session was exhausted (turn/wall cap, `model-empty-stop`) or the fleet's * worker model changed after the rejected run, so replaying that transcript * spends the round on a session that cannot take another turn or answers from * a model nobody would dispatch today. * * A closed vocabulary rather than a boolean: the two modes read differently in * a digest, and a third mode must be a compile-time change everywhere at once. */ export type ReviewLaunchMode = "resume-original" | "fresh-correction"; /** * The durable provenance of one review-correction round's launch (#1045): what * the daemon decided, which model it asked for, which model the harness * actually resolved, and the two session lineages involved. * * One definition, shared by the stored row ({@link ReviewRevisionRecord}) and * the status projection ({@link ReviewCorrectionRound}, #1048), so a renderer * and the row it renders cannot drift apart. * * *Every field is optional on purpose.* Rows written before #1045 recorded no * decision at all, and the honest projection of one is "unknown" — never a * fabricated `resume-original`, which would put an invented explanation in the * very audit these fields exist to make truthful. Absent means nobody wrote it * down, and a reader must say so. */ export interface ReviewCorrectionProvenance { /** The decision, written once per round at dispatch time. */ launchMode?: ReviewLaunchMode; /** What the daemon asked the harness for (a role alias, or a concrete * provider/model), as resolved from escalation/config at dispatch. */ requestedModel?: string; /** What the harness actually resolved and ran — the answer to "which model * wrote this correction", which a role alias alone cannot give. */ resolvedModel?: string; /** The implementation session lineage this round was reviewed *from*. Kept * even for a fresh correction: it is the transcript the findings came out * of, and losing it is losing the thread of the whole PR. */ originSessionRef?: string; /** The fresh correction's own lineage, once the launch reports it. Only a * `fresh-correction` round has one — a resumed round *is* its origin. */ correctionSessionRef?: string; } /** * One review-correction round exactly as the status snapshot carries it * (#1048): the round's position, its lifecycle state, and the #1045 launch * provenance the renderer prints beside it. * * Derived from the same `review_revisions` rows the dispatcher decides on, so * status can never describe a round differently from the way it was launched — * which is precisely the #1035 failure: the fleet showed `review-revision N` * and nothing else while every one of those rounds replayed an exhausted * transcript under a model the fleet had already stopped using. */ export interface ReviewCorrectionRound extends ReviewCorrectionProvenance { round: number; /** `pending` = enqueued, never dispatched; `dispatched` = live or * restart-recovered; `settled` = an outcome is recorded. */ state: "pending" | "dispatched" | "settled"; outcome?: ReviewRevisionOutcome; dispatchedAt?: number; } /** * One durable review-revision request (#677): the orchestrator returned a * green, run-owned pull request to its worker with blocking findings, and * everything the daemon needs to reach the *same work* is recorded here before * any worker is woken. The round numbers successive revisions of one run * (1, 2, …); the findings text is delivered to the launched session verbatim. * * Since #1045 the row also carries how the round was launched — resumed into * the recorded transcript, or opened as a fresh correction on the same * branch/PR — together with the models and session lineages involved, so a * restart re-reads that decision instead of making it a second time. */ export interface ReviewRevisionRecord extends ReviewCorrectionProvenance { id: string; project: string; /** The run whose pushed-green row this revises. The row is reused, never * cloned — no new attempt, and no failed-attempt or continuation charge. */ runId: string; issue: number; prUrl: string; /** The exact reviewed head; re-verified against the live PR before persisting. */ headSha: string; /** Structured blocking findings, delivered verbatim to the resumed session. */ findings: string; round: number; reason: ReviewReason; /** The transcript the revision must resume, as the run recorded it — the * durable "same OMP session" identity, recorded before any wake. */ sessionFile?: string; requestedAt: number; /** Set when the daemon handed the revision to a worker. */ dispatchedAt?: number; settledAt?: number; outcome?: ReviewRevisionOutcome; /** How many times an infrastructure kill has returned this round to the * pending set (#903). A dispatch that died before the resumed session took * a turn spent no review round, so the row is re-queued rather than settled * `failed` — and this counter is what bounds that, durably, so an outage * loop cannot retry one round forever. Absent means zero. */ infraRetries?: number; /** When the launch decision was made (#1045). Stamped by the deciding * dispatch and never moved afterwards, so a re-affirming retry cannot make * an old decision look new. Absent on a round nobody has decided yet, and * on every pre-#1045 row. */ launchDecidedAt?: number; } /** * What a caller may hand {@link Store.enqueueReviewRevision} (#1045). * * Deliberately narrower than the record: the launch decision and its * provenance belong to *dispatch*, not to the review that requested the round, * and the enqueue INSERT does not persist them. Accepting them here would let * a caller pass a `launchMode`, read it straight back off the returned record, * and never notice the row does not carry it — the exact silent fake #1045 * names. They are written afterwards, once, by * {@link Store.recordReviewRevisionLaunch}. */ export type ReviewRevisionDraft = Omit< ReviewRevisionRecord, "id" | keyof ReviewCorrectionProvenance | "launchDecidedAt" >; /** * The outcome of recording one `conductor_pr_review` finding against a run's * review-revision outbox (#786). `created` opened a new revision round for the * run; `appended` folded the finding into the revision already pending * undelivered on the same reviewed head — one round, one row, one worker * attempt either way, and the resumed session reads both findings. `refused` * names the one in-flight shape that cannot be amended: a pending revision at * a different head, which is a review of a moved PR and must dispatch and * settle before another finding for the same run is recorded. */ export type ReviewRevisionEnqueue = | { kind: "created"; record: ReviewRevisionRecord } | { kind: "appended"; record: ReviewRevisionRecord } | { kind: "refused"; block: "pending-different-head" }; /** * How many times one review round may be returned to the pending set after an * infrastructure kill before the daemon stops retrying it (#903). * * The same bound, and the same reasoning, as the run-level infra strike caps: * three dispatches killed before the resumed session took a turn mean the host * or the harness is broken rather than unlucky, and a fourth retry per tick * would loop forever against it. Below the bound the round is free — it spent * no worker turn, so charging it against the review-round ceiling would let an * outage close a PR's last route to a merge; at the bound it settles `failed` * exactly as it does today and escalates once. */ export const REVIEW_ROUND_INFRA_MAX_RETRIES = 3; /** * What the store did with a review round whose dispatch was killed by * infrastructure (#903). `requeued` cleared the dispatch marker so the next * pass resumes the same session on the same round; `exhausted` means the bound * is spent and the caller must settle the round the ordinary way. */ export type ReviewRevisionRetry = | { kind: "requeued"; retries: number } | { kind: "exhausted"; retries: number }; /** * One recorded review clearance (#913): an explicit orchestrator disposition * that the durable review findings standing at one exact head are settled, so * `conductor_pr_merge` may proceed at that head. * * It exists because the #888 merge gate had no reachable clearance at an * unchanged head: a re-review at the same head *adds* a blocking round rather * than superseding one, and nothing an orchestrator can call settles a round — * so a PR at the review-round ceiling was permanently unmergeable. Rows are * append-only audit records, and they clear only **backwards**: a clearance * suppresses a revision row whose own last activity is at or before the * clearance's `at`, never a finding recorded afterwards. It bypasses nothing * but the review gate — authority, pause, base-red-freeze, exact-head, * green-checks, release composition and single-flight all still apply. */ export interface ReviewClearance { id: string; project: string; prUrl: string; /** The exact head the findings were cleared at. Matched case-insensitively. */ headSha: string; at: number; /** The caller's role, as the verb channel resolved it. */ by: string; reason: string; } /** * The durable review evidence standing between one pushed-green PR and a * merge at its exact recorded head (#888). Derived from the same * `review_revisions` rows `conductor_pr_merge` refuses on, so status can * never present a green PR as merge-ready while the privileged verb will * (correctly) refuse it. */ export interface ReviewHeadBlocker { /** The review round whose findings still stand at this head. */ round: number; /** Why the round still blocks: queued and not yet addressed, dispatched but * never finished (crashed mid-review), or settled `failed` at this head. */ state: "pending" | "crashed" | "failed"; } /** * A model-supplied justification: one value out of a closed set, plus prose that * is written down and read by nothing that decides. * * The split is the whole point. `reason` is matched, so it is an enum the daemon * enumerates. `rationale` is logged verbatim for the human who later asks why, * and nothing branches on it — the moment something does, the vocabulary stops * being the enum above and becomes whatever a model felt like typing. */ export interface VerbJustification { reason: R; /** Free-form. Logged, never parsed, never matched. */ rationale?: string; } export type MergeJustification = VerbJustification; export type ReleaseJustification = VerbJustification; export type LabelJustification = VerbJustification; /** * Where the session that triages escalations lives. `embedded` is the daemon's * own child session; `external` means an operator already runs one — a visible * TUI session, say — and the daemon must not start a second brain that would * re-triage the same issues from a different transcript. */ export const ORCHESTRATOR_MODES = ["embedded", "external"] as const; export type OrchestratorMode = (typeof ORCHESTRATOR_MODES)[number]; /** * What `arm` accepts as proof that a human just approved arming (conductor * #613). Declared as data for the reason {@link BASE_FRESHNESS} is: the schema, * the setup wizard and the arm gate must all read one vocabulary, and a new * proof must be a compile-time change everywhere at once. * * `challenge` is the pre-#613 behaviour, byte for byte: a Telegram challenge * goes out and the armed marker is written only after the reply appears in the * orchestrator session. `claim-only` arms on the shared live-plumbing verdict * alone — no Telegram send and no wait — which is what makes an unattended * recovery re-arm possible, at the cost spelled out in the wizard question: * anything that can invoke the already-privileged `omp-conductor arm` command * can start dispatch once the live claim and poller pass. */ export const ARM_PROOFS = ["challenge", "claim-only"] as const; export type ArmProof = (typeof ARM_PROOFS)[number]; /** * The proof a config that never answered the question gets: today's * authenticated round-trip, so no existing install changes behaviour on * upgrade. Read through `resolveArmProof` (config.ts), never off a raw * `ProjectConfig` — the loader materialises `arm` complete, but a hand-built * config may omit the key. */ export const DEFAULT_ARM_PROOF: ArmProof = "challenge"; /** * The grooming trigger (#988): a numeric threshold keeps today's behaviour — * the tick grooms when the routable count is below it — and the literal * `"always"` grooms on demand every tick while any ungroomed, unrefused, * unparked candidate exists, judged from candidate state rather than queue * volume. Never spell "always" as a large number: a big queue must not * silence the duty. */ export type GroomTrigger = number | "always"; /** * Everything the dispatcher needs to service one product: where work comes * from, where code goes, and what it may spend doing it. Config is per project * so unrelated products cannot consume each other's budget. */ export interface ProjectConfig { name: string; /** Issue source. Kind is pinned to "github" today but kept explicit so a * second tracker is a config change rather than a schema change. */ tracker: { kind: "github"; repo: string }; /** The one label that means "a human has signed this off as agent-ready". */ queueLabel: string; /** Routable-candidate count below which the tick prompt tells the orchestrator * to groom the queue, or {@link GroomTrigger} `"always"` to groom on demand * every tick (#988). Optional; defaults to {@link DEFAULT_GROOM_BELOW} in * orchestrator-tick.ts. */ groomBelow?: GroomTrigger; /** * The OMP model role the daemon's own to-spec grooming scouts run under * (#1041): a role token in the same grammar the review adjudicator uses * ({@link REVIEW_ADJUDICATOR_RE}), never a provider/model — OMP owns model * selection. Absent loads as {@link DEFAULT_GROOM_ROLE} (`"task"`), the * general session role every install can launch without pinning a provider, * which is what every project that never answered the question wants. */ groomRole?: string; /** Labels the dispatcher writes back so the tracker alone shows live state * to a human who never opens the daemon's logs. `backlog` is the * operator's own park gesture (#507) — not dispatcher-written, but read by * eligibility so a parked issue can never be claimed while its queue label * is still physically present. */ stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string }; /** Maps a `${labelPrefix}${name}` label on an issue to the checkout it * belongs in, so routing is declared by humans, not guessed. */ routing: { labelPrefix: string; repos: Record }; /** Per-project overrides layered on the global defaults. */ caps: Partial; /** * Model pattern for worker sessions, in omp's model/role syntax. Omitted * leaves the harness default in place, which is what a project that never * answered the question wants. */ workerModel?: string; /** * Ordered fallback models for this project, tried after {@link workerModel} * once a run chain has suffered enough consecutive provider-class failures * (stream stalls and credit refusals). Each new issue still starts on the * primary; recovery is sticky to the chain, never global (#286). Empty or * absent preserves the pre-failover dispatch exactly: every attempt stays on * the primary until the existing escalation path takes over. */ modelFallbacks?: string[]; /** * Consecutive provider-class failures on one run chain after which the next * dispatch moves to the next model in {@link modelFallbacks}. Defaults to 2 * when absent or unusable. */ modelFallbackThreshold?: number; /** * An opaque omp selector for one stronger worker tier, used exactly once per * issue chain when a run spun to a cap with nothing to show (#807). * * Conductor never resolves it: `@slow`, a role name or a bare model pattern * are all the same string here, handed to the harness at dispatch the way * {@link workerModel} is. It is deliberately NOT part of * {@link modelFallbacks} — that chain answers provider faults and must stay * provider-only — and absent or blank preserves today's settle/escalate * behaviour byte for byte. */ workerEscalationModel?: string; /** * Providers whose routes must bill to a subscription (OAuth) credential, and * never to an API key (#852). * * Declared per **provider**, not per model or per run, because that is the only * truthful unit: a credential belongs to a provider — a model has none of its * own — and a run's provider is not knowable in advance, since * {@link workerModel} is an opaque selector that may be a role alias and the * provider-fault fallback chain (#286) can move a live run onto another * provider mid-flight. So the declaration reads "this provider must bill to its * subscription", and every candidate is held while that is untrue. * * Enforced by conductor rather than omp because omp cannot express it: * `AuthStorage.getApiKey` cascades from a disabled OAuth credential into the * same provider's API key, and no setting, selector suffix or request option * stops it. Absent or empty dispatches byte-for-byte as it always has. */ requireOauthProviders?: readonly string[]; /** * An opaque omp settings map layered into every worker session this project * dispatches (#537). At dispatch it is materialised verbatim to a fleet-owned * YAML overlay under the run's session directory — never inside the worktree, * whose diff is a PR — and passed to the session as an omp settings overlay * (`Settings.init({ configFiles: [] })`), so it layers on top of both * the daemon account's global config and any project `/.omp/config.yml` * without erasing either. * * Conductor validates YAML shape only: a non-mapping is dropped at load, and * everything inside the map is omp's schema to own — an unknown key is omp's * to reject, never conductor's to understand. A config edit takes effect on * the next dispatch (the overlay is rewritten on every attempt), and a * project without the field dispatches byte-for-byte as it always has. * * The retry keys derived from {@link modelFallbacks} are merged into the * effective overlay (unless the map already names a `retry` mapping), which * is where #539's staging lives. */ ompSettings?: Record; /** * How a stuck run reaches a human, what to do when it cannot, and who runs * the session that triages it. See {@link ORCHESTRATOR_MODES}. */ escalation: { telegramChatId?: string; telegramTopicId?: number; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode }; /** * How `arm` proves a human just approved arming (conductor #613). Optional * only for configs written before the key existed and for hand-built test * configs: the loader always materialises a complete value, so read it * through `resolveArmProof` rather than reaching for `.proof` on a project * some test hand-built. Absent resolves to {@link DEFAULT_ARM_PROOF} * (`challenge`), preserving today's authenticated round-trip — the one value * that changes nothing on upgrade. */ arm?: { proof: ArmProof }; /** * Who lands green PRs, who cuts releases and who promotes. The daemon never * acts on this itself — it words the orchestrator's standing orders and the * rendered brief scaffold with it, so config and prompt can never disagree * about which of them is holding the merge button. * * `promotion` is the queue-label sign-off: adding the label to an issue lets * a worker claim it and starts spend, so POLICY.md must state its owner and * a config that never answered defaults to the human — a gate whose owner is * unstated must never resolve to the orchestrator. */ authority: { merge: AuthorityHolder; release: AuthorityHolder; promotion?: AuthorityHolder }; /** * Per-shape tool-call enforcement for releases and deploys. Optional only for * configs written before the tripwire existed; both omission and any shape key * this map leaves out resolve fail-closed to `"human"`. * * Never read directly — go through `resolveReleaseGrants`, which is where the * legacy `"none"` / `"operator-brief"` strings are migrated and where the * absent-key denial is spelled once. */ releasePolicy?: ReleasePolicy; /** * The gating conditions a merge or a release must satisfy (#129), extracted * from POLICY.md so a verb decides from typed config rather than from prose it * re-interprets every tick. * * Optional only for configs written before the key existed. The loader always * produces a complete value, so read it through `resolvePolicy` rather than * reaching for `.merge` on a project some test hand-built. */ policy?: ProjectPolicy; /** * How green PRs are reviewed and returned (#678): the strictness level and * the hard ceiling on review rounds per PR lifecycle. Optional only for * configs written before the key existed — the loader always materialises a * complete value, so read it through `resolveReview` rather than reaching * for `.review` on a project some test hand-built. A config that never * answered migrates to {@link DEFAULT_REVIEW_POLICY} deterministically, and * the schema's bounds agree with the runtime ones. */ review?: ReviewPolicy; /** * Exact operator-authored exceptions for recovery PRs that no run recorded. * Optional and hand-edited: setup preserves entries but never invents them. * Each entry still passes routing, live-head, checks, migration-chain, and * single-flight gates. */ recoveryMerges?: RecoveryMergeAuthorization[]; /** * How loud the orchestrator is, and when the daily rollup happens. Optional * on disk — a config written before this key existed loads as * {@link DEFAULT_REPORT_POLICY} — so read it through `resolveReportScope` * rather than reaching for `.scope` directly. A legacy `scope:` value is * accepted and materialised as a preset (`.scopePreset`). */ reporting?: ReportingPolicy; /** Parent directory for per-run worktrees. */ workspaceRoot: string; /** Cache of bare clones, so N runs share one fetch instead of N. */ mirrorRoot: string; /** * Git treeish (commit SHAs or refs) on the base branch that every preserved * continuation branch must contain before the dispatcher may reattach it * (#428). A base safety fix protects only branches forked after it landed; a * continuation forked before it still carries the dangerous code and, on a * shared host, re-running the lifecycle suite it retains is what SIGTERMed * the production daemon. With a marker configured, the dispatcher refuses to * reattach any preserved branch missing it, holds the issue as `stale-base` * (distinct from capacity or dependency holds), and never silently merges * base into the user's work. A branch the marker cannot be verified against * at all — a failed mirror fetch, an unresolvable marker, or no probe wired * — fails closed too, but as `critical-base-verify-error`: a verification * failure, never a claim that the branch is stale, re-admitted automatically * once a later probe succeeds. Absent or empty, ordinary stale continuations * keep today's behaviour. Optional and hand-edited, like * {@link recoveryMerges}. */ criticalBase?: string[]; } /** * The config format this conductor writes. Bumped from 1 when a daily-volume cap * was retired: a v1 file may still carry cap keys this version no longer * enforces, so it is read leniently and normalised up, while a v2 file is held * to the current key set exactly. */ export const CONFIG_VERSION = 2; /** * Versions this conductor can read. A v1 file loads, drops the caps that no * longer exist, and is rewritten as v2 the next time anything saves. */ export const READABLE_CONFIG_VERSIONS = [1, CONFIG_VERSION] as const; /** * Operator-authored host facts rendered into every worker brief (#721) — the * typed replacement for host prose hand-written into an untracked agent * context file. Only what cannot be derived lives here: cores and RAM are * read from the host by `host.ts` at render time, and the guarded shell * suites stay `SHARED_HOST_SCRIPTS`'s single source — nothing in this object * may re-name a refused command. * * Every key is optional; a fleet that never fills the object loads a config * with no `host` field at all and dispatches briefs byte-for-byte as before. */ export interface HostConstraints { /** * What this host is and what else it runs, in the operator's words ("a * shared 4-core VPS that also runs Langfuse and the fleet"). The renderer * folds the derived core/RAM count into this line. */ description?: string; /** * The non-interactive PATH a script or `ssh host ''` invocation must * export (`/root/.bun/bin:/root/.local/bin:$PATH` on this fleet) — commands * run from an ssh session do not see the interactive shell's PATH. */ path?: string; /** * Per-repo command conventions, keyed by the repo slug the brief renders * as its `{{REPO}}` (`owner/repo`). A repo without an entry renders nothing * for it. */ conventions?: Record; } /** * On-disk root config. `version` is present from day one so a format change * can be migrated instead of silently misread by an older daemon. */ export interface ConductorConfig { version: typeof CONFIG_VERSION; defaults: Caps; projects: ProjectConfig[]; /** Absolute directory for restorable `conductor.db` snapshots; defaults to * `/backups/db` when omitted. A snapshot that cannot land there * is `doctor`'s `db-backup` failure. */ dbBackupDir?: string; /** Host facts every worker brief renders (#721). Absent or empty renders * nothing — no section — and the brief stays exactly what it always was. */ host?: HostConstraints; } /** * The tracker-agnostic view of a queued issue: only the fields the dispatcher * actually reads, so adapters never have to fabricate provider metadata. */ export interface ReadyIssue { number: number; title: string; body: string; labels: string[]; url: string; /** Used to detect an issue edited mid-run, which invalidates the claim. */ updatedAt: string; } /** * One issue comment as the worker brief renders it: author login and verbatim * body, in the tracker's own order (oldest first), so a later correction * visibly supersedes an earlier note. */ export interface IssueComment { author: string; body: string; } /** * How a pull request ended, in tracker-agnostic terms. Lowercase because the * loop's vocabulary is lowercase; mapping GitHub's `MERGED`/`CLOSED`/`OPEN` onto * it is the adapter's job. * * `closed` is closed *without* merging — a human looked at the work and said no. */ export type PrState = "merged" | "closed" | "open"; /** Result of independently checking a worker's claimed green pull request. */ export interface PrVerification { status: "green" | "pending" | "failed"; /** Full live head SHA read in the same tracker request as the verdict. */ headSha: string; reason: string; } /** * One file in a pull request's diff, in git's own terms rather than any * tracker's. The settlement audit (#128) has to tell a deleted test file from a * renamed one, and only the rename header says which — so `status` and * `previousPath` are load-bearing, not decoration. */ export interface PrDiffFile { path: string; /** Pre-image path, set only for a rename. */ previousPath?: string; status: "added" | "modified" | "removed" | "renamed"; /** The file's unified-diff hunks verbatim, absent for a binary, mode-only or * pure-rename change that has none. */ hunks?: string; } /** A pull request's diff, as the settlement audit reads it. */ export interface PrDiff { files: PrDiffFile[]; /** True when the adapter had to cut the diff short. The audit's silence is * then only silence about the part it saw, and it says so. */ truncated: boolean; } /** * What the settlement audit can say about a worker's own account of its work. * * Declared as data for the same reason `REPORT_SCOPES` is: the analyser, the * settlement report and the orchestrator brief all enumerate these, and a * vocabulary that lives in three places drifts in three directions. */ export const SETTLEMENT_FLAG_KINDS = [ /** The settlement could not read the PR's diff, so no `changed:` file list * could be derived — the one disclosure fault that survives, because a * tree no one can read has nothing to disclose. */ "changed-line-missing", /** A test file left the tree and no rename in the PR accounts for it. */ "test-file-deleted", /** A skip/only/focus marker appears on a line the PR added. */ "test-disabled", /** Assertions were commented out, or more left the file than entered it. */ "assertions-removed", /** A named timeout in a test file went up. */ "test-timeout-raised", /** The PR diff touches files outside the effective declared file lane — * the declaration admission enforced and the brief rendered, so a widened * lane is named on evidence rather than found by reading the file list by * hand, and the queued work it blocks is explainable (#739). */ "lane-escape", /** A command the PR body claims as proof, with no matching command in the * run's transcript — the claim has no attempt behind it at all. */ "claimed-proof-missing", /** A claimed proof command was attempted, but the shared-host guard refused * it — the honest blocked shape, told apart from a claim with no attempt. */ "claimed-proof-blocked", /** A terminal run recovered an OPEN PR by exact issue, repo and branch identity. */ "pr-adopted", /** A workflow on the merge commit's base branch failed after settlement. */ "base-branch-red", ] as const; export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number]; /** * One advisory finding about a settled run. Advisory is the whole design: a * flag never changes a run's state, because the judgement it invites — "was * deleting that test correct?" — needs context the daemon does not have. */ export interface SettlementFlag { kind: SettlementFlagKind; /** Repo-relative path, `(report)` for report evidence, `(lane)` for a * lane-escape finding, or `(base branch)` for a post-merge workflow result. */ file: string; /** 1-based line in the side of the diff the evidence came from: the * post-image for an added line, the pre-image for a removed one. */ line?: number; detail: string; /** True when the dispatching issue never names this file — the "tests you * didn't write" case, which is what makes a flag loud rather than routine. */ unattributed?: boolean; } /** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */ export type IssueState = "open" | "closed"; /** Read-after-write issue facts used to validate an eventually-consistent queue candidate. */ export interface IssueSnapshot { readonly state: IssueState; readonly labels: readonly string[]; } /** * An OPEN pull request that already closes an issue, as admission sees it. * * The repository travels with the URL because recovery has to prove three * independent identities before adopting a PR onto a terminal run: closing * issue, retained branch, and routed repository (#245). Admission needs only * the URL/branch pair, but weakening the shared answer would make recovery * parse provenance back out of a presentation URL. * * The head branch recognises a continuation the store never saw a PR for: on * 2026-08-09T00:47Z veltro#324 attempt 1 was killed at the turns cap before its * worker opened one, and the PR that later appeared on that exact branch * (chad#438) could never be matched by URL equality against a `prUrl` the * terminal run never recorded (#50). */ export interface OpenCloser { url: string; /** Head branch of the open PR, for retained-continuation identity. */ headRefName: string; /** Canonical `owner/repo` identity supplied by the tracker. */ repo: string; } /** The merge identity needed to observe the exact base-branch push it produced. */ export interface MergedPrInfo { mergeSha: string; baseRef: string; } /** One GitHub Actions workflow run, normalised before it crosses the tracker port. */ export interface WorkflowRun { id: number; workflowId: number; name: string; status: string; conclusion?: string; url: string; createdAt: string; } /** * Deliberately narrow so a Gitea or local-file tracker can drop in later. * Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely. */ export interface Tracker { listReady(): Promise; /** * Every open issue in the tracker repo (never PRs), labels included: one * read that covers all lifecycle-label queries of a pass, so consumers * needing several label sets pay one fetch instead of one per label, and * openness itself is answerable without a per-issue lookup (#203). * Complete — follows pagination to the end. */ listOpenIssues(): Promise; /** * The issue's comments, oldest first. The dispatcher renders them into the * worker brief, so grooming the orchestrator posted as a comment reaches the * worker's opening prompt without any runtime read. * * Throws when the tracker could not be read: the caller must never mistake * "could not read" for "no comments", which is exactly the failure mode this * read exists to prevent (#517). */ listComments(issue: number): Promise; /** * One issue's body, or undefined when the tracker could not produce it. * The promotion echo (#724) renders the parsed file lane exactly where the * author asserts the claim; the queue label is being added to an issue the * `listReady` filter would have excluded by definition, so the verb needs * this read rather than a list the issue is not on. */ getIssue(issue: number): Promise; addLabel(issue: number, label: string): Promise; removeLabel(issue: number, label: string): Promise; comment(issue: number, body: string): Promise; close(issue: number): Promise; /** Records that a worker split its issue, so follow-up work stays traceable * to the request that spawned it. */ linkParent(child: number, parent: number): Promise; /** * The parent epic/issue number when `issue` is a linked sub-issue, or * undefined when it has no parent. * * Admission serializes siblings under one parent (at most one in flight). A * missing parent means "not epic-serialized" and keeps today's concurrent * admission. A thrown error is fail-closed for that candidate — same posture * as {@link Tracker.openCloserFor}. */ parentOf(issue: number): Promise; /** * The first OPEN pull request that already closes `issue`, or undefined when * none does. * * Admission asks this any-open question because the store only knows work * recorded in this database. A migrated daemon, restored host, or younger * database can otherwise dispatch duplicate work onto an issue whose PR is * already open. */ openCloserFor(issue: number): Promise; /** * Every OPEN pull request that closes `issue`. * * Salvage recovery must inspect the full answer: an unrelated closer may be * listed before the PR whose branch and repository exactly match the * recovered run. An empty array means no OPEN closer; a thrown lookup remains * fail-closed. */ openClosersFor(issue: number): Promise; /** * Whether an issue is still open, or undefined when tracker/network state is * ambiguous. Cleanup must never interpret undefined as permission to delete. */ issueState(issue: number): Promise; /** * Direct REST state and labels for final admission validation, or undefined * when either fact cannot be read safely. */ issueSnapshot(issue: number): Promise; /** * An issue's raw body, or undefined when it cannot be read. Used only by the * dependency-graph cycle pass (#421) to follow a reachable prerequisite's * own `Depends-on:` declarations; never a "no body" claim. */ issueBody(issue: number): Promise; /** * The state of one specific pull request, or undefined when this adapter * could not tell — a network failure, a revoked token, a URL it cannot * parse, or a 404 the adapter could not corroborate. Undefined never means * "no". The one exception is a definitively missing PR: a REST 404 on the * individual read that a same-repository pulls-list read corroborates (a * deleted or never-created number in a repository the credential can still * read) is reported by throwing a classified `GhPrMissingError`. A * repository hidden from the credential answers the same 404 bytes, so a * bare 404 — token scope/SSO loss included — is never "missing": it stays * undefined and the row stays retryable. Every other tracker implementation * keeps the "never throws, undefined is could-not-tell" contract, and every * caller not acting on a missing PR keeps its existing fail-closed handling * by catching the throw exactly as it already catches any other tracker * failure. * * Deliberately separate from {@link Tracker.openCloserFor}, which asks * whether any OPEN closing PR exists and answers undefined for merged, * closed-unmerged, no PR at all and a deleted PR alike. This one asks "did * *this* run's PR land", where those answers are opposites. Collapsing them * is how a PR a human rejected gets recorded as merged. */ prState(url: string): Promise; /** The live head commit SHA of one pull request, or undefined when the * tracker could not tell. `pr-checks-green` binds its green verdict to the * head it was read at, so a head read that cannot answer is "not yet * satisfied" for that pass, never a green verdict (#808). */ prHead(url: string): Promise; /** The merge commit and base ref for a merged pull request. */ mergedPrInfo(url: string): Promise; /** Workflow runs GitHub associated with one exact commit SHA. */ workflowRunsAt( repo: string, sha: string, opts?: { event?: string; branch?: string }, ): Promise; /** Full failed-job logs of one workflow run across its attempts, oldest * attempt first, one chunk per failed job — never one aggregate per attempt, * so a mixed failure (setup 429 + sibling compile error) stays visible to * the caller's all-infra guard. `undefined` when the evidence is undecided: * the run register, any attempt's job register, or any failed job's log * could not be read, or the run has more attempts than `maxAttempts` (a * prefix is never presented as the complete attempt history); a no-mutation * refusal the next pass asks again. `[]` when the run is determinately * green across its attempts. Logs are full and untruncated — a truncated * last-400-lines view can discard contrary failure evidence. Read through * the tracker's own guarded runner — call/refusal accounting and the * rate-limit breaker apply exactly as they do to a live tick's reads * (#638). */ runFailedAttemptLogs(repo: string, runUrl: string, maxAttempts: number): Promise; /** The live commit at one repository branch, or undefined when unreadable. */ branchHead(repo: string, branch: string): Promise; /** The newest run of this workflow on the base branch before `before`. * `null` means no earlier run; `undefined` means the history could not be read. */ previousWorkflowRun( repo: string, workflowId: number, branch: string, before: string, ): Promise; /** * Verify that a pull request is open, ready, and has a non-empty * terminal-success check rollup. When `expectedHead` is supplied, also * require the live head to match it. * * `opts.read` switches from the merge gate's "may this merge" to the read * surface's "what is this PR's state": a merged or closed PR is then a * reported fact rather than an `expected OPEN` refusal. Mutating verbs never * pass `read`, so their open-PR gate is unchanged. */ verifyPr(url: string, expectedHead?: string, opts?: { read?: boolean }): Promise; /** * The pull request's diff, or undefined when this adapter could not produce * one — an unparseable URL, a deleted PR, a flaky network. * * Undefined never means "nothing changed". Its only consumer is the * settlement audit (#128), which is advisory, so a failure here costs one * unaudited settlement and never a run's state. That is also why it does not * retry: unlike {@link Tracker.verifyPr}, nothing downstream is waiting for a * better answer on a later tick. */ prDiff(url: string): Promise; /** * The pull request's body, or undefined when this adapter could not produce * one — the same advisory contract as {@link Tracker.prDiff}. Its only * consumer is the settlement audit's claimed-proof check (#582): a claim * extracted from a body this port could not read is no claim at all. */ prBody(url: string): Promise; /** * Every check on a PR with the state the tracker reports for it (#132). * * Deliberately separate from {@link Tracker.verifyPr}, which answers "may this * merge" and collapses every non-success into one verdict. Classification asks * a different question — *why* is it not green — and the answer is the * difference between re-running a cancelled runner for free and charging an * implementation attempt for a real test failure. * * An empty array means "could not tell", the same posture as the rest of this * port: nothing downstream may read it as "no checks failed". */ checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]>; /** The tail of a failed workflow run's log, ANSI stripped, or `undefined` * when it cannot be fetched. Best-effort, never throws: the classifier uses * it to tell an infrastructure outage from a deterministic test failure * (#177), and "could not read the log" must fall through to the existing * conservative classification rather than error out of fact-gathering. */ checkLog(detailsUrl: string): Promise; /** Whether the PR can merge into its base. `unknown` on any doubt, so a * mergeability nobody could read never becomes a conflict recovery. */ mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown">; /** Re-run the failed jobs behind a PR's non-success checks. Best effort per * workflow run: one un-rerunnable run must not stop the others. */ rerunFailedChecks(prUrl: string): Promise; /** Issues carrying `label`, open or closed, bounded. Empty on any failure — * a reconcile that cannot list must remove no labels. */ listLabeled(label: string, limit?: number): Promise<{ number: number; state: IssueState }[]>; /** Sub-issues of `issue`, with their states. Propagates lookup failures so * callers can choose the safe polarity: settlement catches and treats an * unread probe as no positive evidence, while launch selection fails closed. */ childrenOf(issue: number): Promise<{ number: number; state: IssueState }[]>; } /** * Why a terminal run ended badly (#132). * * Declared as data here for the same reason `REPORT_SCOPES` is: the classifier, * the store's budget counters, `status` and the board all enumerate these, so a * new class must fail to compile in each of those places rather than silently * resolve to "unclassified" in one of them. The decision table that maps signals * onto them lives in `failure-class.ts`. * * The issue's `question-answerable` / `question-decision` split is deliberately * one `question`: telling them apart needs semantic judgement about the worker's * question, which is the orchestrator's job rather than a decision table's. The * escalation carries the worker's own blocking report, so the orchestrator * answers instead of re-investigating. */ export const FAILURE_CLASSES = [ "env-start-failure", "turn-cap-progress", "turn-cap-spinning", "wall-clock-cap-progress", "wall-clock-cap-spinning", "admin-kill", "ci-infra", "ci-deterministic", "dispatch-infra", "merge-conflict", "question", /** A run that finished its work and stopped because it is waiting on an * observable condition — its settlement names `blockers:` (a PR whose * checks were still pending, say) and no question (#1068). Distinct from * `question` because the remedy is not a human: the recovery is the later * observation, which the settle sweep performs by re-offering the blocked * row until its PR resolves (this fleet's #1062 sat `blocked`/`question` * after its PR merged and its tick kept listing it for Duty 1 triage). */ "awaiting-observation", "orphan-clean", "orphan-dirty", "settlement-stuck", "provider-credit", "provider-transient", /** A run drowned in in-session provider rate limits (repeated HTTP 429s) and * never got clear of the throttle before the cap swallowed it. Distinct from * `provider-credit` (402) and `provider-transient` (a single stream fault) * because its remedy is different (#573). */ "provider-capacity", /** A reviewer closed pushed-green or pushed-pending work without merging it: * a review decision, not a worker failure. */ "returned-for-revision", /** The session ended without delivering any settlement verdict: no yield, no * green claim, no error — the row's last words are mid-delivery narration * (#986). Distinct from `unknown` on purpose: the evidence proves the run * reached its worker and died between turns, so the gap it names is "the * model stopped before handing over" rather than "nothing is known". It * charges the attempt exactly as `unknown` did; only the name is specific. */ "no-verdict", /** The provider answered with empty assistant turns until the harness's own * retry cap gave up and ended the session (#986): the terminal line reads * `empty-stop-retry-cap`. Provider flake, not worker logic — both of the * 2026-08-23 failures were this, at 100/180 and 54/180 turns, while a third * run on the same model finished green in the same window. */ "model-empty-stop", /** A live session stopped writing its transcript for longer than the * configured silence window, and the daemon settled it (#1086). Not a size * verdict: the work did not run out of budget, the session simply never * came back — the evidence names how long it was silent and at which turn. * When the attempt had already pushed a green PR, the settle path lands the * row `pushed-green` instead, so landed work is never charged anything. */ "progress-stall", "unknown", ] as const; export type FailureClass = (typeof FAILURE_CLASSES)[number]; /** * What the daemon does about a class. * * Three of these perform nothing, and they are not the same nothing: * * - `observe` — recorded and deliberately left alone, because the recovery IS * the later observation: `awaiting-observation` rows wait on something the * world will resolve (their PR), and the settle sweep re-offers the blocked * row until it does, settling it the way it settles `settlement-stuck` when * the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is * recovered yet, by design (#1068). * - `hold` — recorded and deliberately left alone, because acting would destroy * something. `orphan-dirty` is the case: the tree is the only copy, so * `unblock` refuses rather than re-claiming over it. * - `none` — the classifier declining to name a failure at all. Two callers * reach it, and neither is "unclassifiable": `classifyRun` returns it for a * terminal row whose PR is open and fully green, where `classifyAndRecover` * restores `pushed-green` *without* persisting a class (#766); and the settle * sweep persists it beside `returned-for-revision` when a reviewer closes * pushed work without merging, where the remedy is the queue label the sweep * deliberately leaves on. * * An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry. * This comment previously said nothing in the decision tree maps to `none`, * which both callers above contradict; it cost a reading of two modules to * discover, which is what a wrong comment costs (#132). */ export const RECOVERY_ACTIONS = [ "requeue", "continue", "rerun-checks", "settle", "escalate", "observe", "hold", "none", ] as const; export type RecoveryAction = (typeof RECOVERY_ACTIONS)[number]; /** What the daemon learned about workflows on a mediated merge's base commit. */ export const BASE_CHECK_STATES = [ "pending", "green", "red", "red-preexisting", "unknown", ] as const; export type BaseCheckState = (typeof BASE_CHECK_STATES)[number]; /** Current push-workflow health at a routed repository's live base head. */ export interface BaseHealth { repo: string; branch: string; headSha: string; verdict: "green" | "red" | "pending" | "unknown"; runsCount: number; detail?: string; checkedAt: number; } /** * A per-repo merge freeze, set when a watched merge (or live base observation) * turns the base red and lifted mechanically on green or by the operator's * `unfreeze` verb. While `clearedAt` is absent the repo is frozen: `prMergeVerb` * refuses further merges to it with the `base-red-freeze` refusal naming the * suspected culprit. It is repo-scoped, never global, so sibling repos keep * merging while one base is broken. */ export interface BaseFreeze { repo: string; /** The merged SHA suspected of breaking the base. */ culpritSha: string; /** Failing workflow/run evidence naming the break. */ detail?: string; /** When the freeze was set (or last refreshed). */ setAt: number; /** When the freeze was lifted — absent means the repo is actively frozen. */ clearedAt?: number; /** Who lifted it: `daemon` for mechanical recovery, else the operator. */ clearedBy?: string; /** Why it was lifted: `base-green` or the operator's override reason. */ clearedReason?: string; } /** * The active release composition for one project (#850): the campaign whose * release is being assembled, and the exact PRs allowed into it. While * `closedAt` is absent the composition is active and `prMergeVerb` refuses * every `conductor_pr_merge` whose PR is neither named here nor covered by a * recorded operator override, with the `outside-active-release` refusal — the * mechanical form of "a patch contains no unrelated merges". The row is * SQLite-backed, so the guard survives daemon and orchestrator restarts, and * it retires only through an explicit completion or cancellation; each * transition also writes an immutable material event as its audit trail. */ export interface ReleaseComposition { /** The campaign identifier, e.g. `v0.18.1-reliability`. */ campaign: string; /** Full PR URLs explicitly allowed into this release. */ allowedPrUrls: string[]; declaredAt: number; declaredBy?: string; /** When the composition retired — absent means it is active. */ closedAt?: number; /** `operator` via the CLI on every path that retires a composition. */ closedBy?: string; closedReason?: string; } /** * One operator override admitting exactly one PR into one release (#850). * Rows are append-only audit records: an override applies to its campaign * only, never outlives it, and bypasses nothing but the composition check — * authority, pause, base-red-freeze, exact-head, green-checks and * single-flight all still apply to the overridden merge. */ export interface ReleaseCompositionOverride { id: string; project: string; campaign: string; prUrl: string; at: number; by: string; reason: string; } /** * Execution state is separate from the tracker's own labels on purpose: labels * are coarse and human-editable, while the loop needs to distinguish "pushed * and green, waiting on merge" from "merged" to decide what to do on restart. */ export type RunState = | "claimed" | "running" | "pushed-green" /** Worker finished, but GitHub has not yet produced a terminal check verdict. */ | "pushed-pending" /** Its PR landed. Written by the tick's settle sweep, never by a worker: only * the tracker knows, and it knows minutes to days after the run ended. */ | "merged" | "blocked" | "failed" | "killed" /** Terminal operator action. The worker cannot resume and the attempt does * not consume implementation-failure or continuation budgets. */ | "stopped" /** In flight when its daemon process died; reconciled at the next startup. */ | "orphaned"; /** One parsed `File lane:` declaration: the paths and the verbatim source * line, so a renderer can reproduce the declaration itself rather than a * summary of it. Exported for the brief's guarantee that the gate's effective * lane is always visible to the worker (#608). */ export interface LaneDeclaration { files: string[]; /** The declaration line verbatim, as written on the surface it came from. */ source: string; } /** * The effective file lane as both admission and the worker brief must read it * (#608): the latest `File lane:` declaration among the issue body and every * comment, in the tracker's oldest-first order. This is the "later correction * visibly supersedes" contract applied across both surfaces at once, and it is * the single source of truth the gate enforces and the brief renders — so a * declaration can never control admission while staying invisible to the * worker. `at` records which surface won (`"body"`, or the 0-based comment * index), letting the brief reproduce the declaration verbatim when the * winning comment sits beyond its rendered discussion budget. */ export interface FileLane extends LaneDeclaration { at: "body" | number; } /** One parsed `Model:` declaration (#535): the selector and the verbatim * source line, so a renderer can reproduce the declaration itself rather * than a summary of it. */ export interface ModelDeclaration { /** The selector in omp's own model syntax — a role alias (`@slow`) or a * concrete `provider/model`, either optionally `:thinking`-suffixed — * passed straight through as the session's `modelPattern`, for omp's * resolver to interpret. */ model: string; /** The declaration line verbatim, as written on the surface it came from. */ source: string; } /** * The effective model as both dispatch and the worker brief must read it * (#535): the latest `Model:` declaration among the issue body and every * comment, in the tracker's oldest-first order — the same "later correction * visibly supersedes" contract applied across both surfaces at once as * {@link FileLane}, so a pre-dispatch comment can change the model a * promotion brief named in the body. `at` records which surface won * (`"body"`, or the 0-based comment index). */ export interface EffectiveModel extends ModelDeclaration { at: "body" | number; } /** * One dispatched run's observation of the code-graph MCP tools (#726): a * runtime fact read off the session's own registry at session start, never a * config guess. `present: true` and `present: false` are both observations — * an absent field (no `graphTools` on the row at all) means no observation was * recorded, which is deliberately distinct from "tools absent": the whole * point of the boolean is that "the model ignored a tool it had" and "the tool * was missing" no longer look identical from outside the session. */ export interface GraphToolsObservation { present: boolean; /** Session-start wall clock (ms epoch), the moment the registry was read. */ at: number; } /** * One attempt at one issue. Persisted so a daemon restart can reconcile * orphaned worktrees and branches instead of leaking them. */ export interface RunRecord { id: string; project: string; issue: number; /** `RepoTarget.name` this attempt was routed to. */ repo: string; branch: string; worktree: string; state: RunState; /** 1-based attempt number, checked against `Caps.maxAttemptsPerIssue`. */ attempt: number; /** * The file-lane declaration admission resolved for this run, persisted at * dispatch from the carried `Admission.lane` (#744). It is the exact * snapshot the overlap gate enforced — the same value the brief renders — * and it is durable on purpose: the next dispatch pass builds lane occupancy * from it, so a run admitted with a declared lane it has not yet written * still holds those files across passes, until the row reaches a terminal * state and leaves the active set. Absent means the run predates the column * or was admitted with no declaration (fail open) — never "empty lane". */ lane?: FileLane; turns: number; /** Effective turn ceiling for this run; operators may only raise it. */ maxTurns: number; spendUsd: number; /** * The per-run spend allowance admission reserved out of the daily budget * before this run launched (#851). Held for as long as the row is live, and * released the moment it is not — reservations are summed over live rows, so * a settle releases the unspent remainder with no bookkeeping to forget and * a daemon restart re-reads the same reservations off the same rows. * * Absent means the row predates the reservation, or was admitted while the * fleet had no spend cap at all. Never "reserved nothing": a run admitted * under a cap always records what it reserved. */ spendReservedUsd?: number; /** How many in-session HTTP 429 responses the harness retried before the run * ended, counted by the worker from the session's `message_end` events while * the run was live (#573). Absent means the column predates the count or no * worker reported one — never "no rate limit happened": a healthy run * records 0. The classifier only reads it as "sustained" above a threshold, * so a single retried 429 is noise. */ provider429Count?: number; /** * The model that actually wrote this run's messages, read from the newest * assistant `message_end`'s `AssistantMessage.model` (#539, #535 slice 1). * Distinct from {@link RunRecord.model} (what the daemon dispatched on): * this is what the harness resolved and actually used, present even for a run * that never failed over, so "which model wrote this" is answerable without * reading a transcript. Absent means the run predates the column or recorded * no message carrying a model. */ resolvedModel?: string; /** The provider that wrote them, from the same message. */ resolvedProvider?: string; /** * Every within-run model fallback the harness applied (`retry_fallback_applied`), * newest first. The `to` target is what a settlement report names when a run * swapped providers mid-run instead of dying on a throttled primary (#539). */ retryFallbacks?: { from: string; to: string }[]; /** `retry_fallback_succeeded` events: within-run fallbacks the harness recovered on. */ retryFallbackSucceeded?: number; /** Assistant messages whose `retryRecovery.recovery === "model"` (#539). */ modelRecoveries?: number; /** `auto_retry_start` events: in-session provider retries the harness ran. */ autoRetryCount?: number; /** `auto_compaction_start` events: in-session context compactions. */ autoCompactionCount?: number; /** omp session transcript, so a human can read what the worker actually did. */ sessionFile?: string; /** * When the run's transcript was last observed to grow, as epoch ms (#1086). * Recorded by the daemon's progress watch from the file's own mtime — the * write instant, not the observation instant — and read by `status` to show * a silence interval while the row is live. Absent means the pass has not * looked at this run (or it has no transcript yet), never "no progress". */ lastProgressAt?: number; /** * The exact `session-host` pid this run's Herdr representation was reported * for, and the pane Herdr knows it as (#842). * * Durable so a restart can find the pane its dead worker left behind, rather * than leaving a workspace showing a live worker that no longer exists. All * three are absent together — a run with a pane always has a pid, because the * pid is what the pane was opened from. * * A matching pid is deliberately NOT treated as proof of life: pids are reused, * and a session-host child dies with the daemon that owns its socket, so a live * row after a restart is an orphan whatever pid it recorded. */ workerPid?: number; paneId?: string; paneLabel?: string; /** * Why this run has no workspace representation right now (#841). * * Present only beside a live run with no pane, and cleared the moment one is * established. This is what makes Herdr unavailability a *named* degraded state * rather than an absence: `status` reads it, so a fleet running blind says so * instead of looking identical to a fleet with no workers. */ paneUnavailable?: string; /** * Cumulative output and reasoning tokens for this run (#518). * * Both are read off the same `usage` block the spend already comes from, so * they cost no extra provider call. Their ratio is the fact that explains a run * reading `24/180 turns $0.05` after 43 minutes: 96% of its output was * reasoning. Absent rather than zero when a provider reports no token block — * a share computed from a fabricated zero is worse than no share. */ outputTokens?: number; reasoningTokens?: number; /** The run id whose session this row resumes: the orphan-clean attempt it * inherited (#536, #567), or — since the blocked-continuation resume — the * answered-block run whose transcript this continuation carries on. Set only * when a dispatch-time resume verdict fired; absent means a fresh dispatch, * never "resumed from unknown". Readable without opening the transcript, so * "how often did resume fire and did it save turns" is a query, not a file * walk. */ resumedFromRunId?: string; prUrl?: string; /** Pull request head the worker observed after its deterministic CI watcher exited. */ headSha?: string; /** Merge commit and actual base ref read from the merged pull request. */ mergeSha?: string; baseRef?: string; /** Post-merge workflow verdict. Pending is revisited by the tick sweep. */ baseCheck?: BaseCheckState; baseCheckAt?: number; /** Commit this run's uncommitted work was preserved as before its worktree * was removed, on the run's own branch. Absent means the daemon found * nothing to save, or never looked — see {@link RunRecord.salvageError}. */ salvageSha?: string; /** Why the salvage failed. Present means the worktree still holds the only * copy of real work, so the tree was kept and the issue is held out of * dispatch until an operator acknowledges it. */ salvageError?: string; /** * The model this attempt actually dispatched on, written at dispatch when the * project configures a failover chain (`modelFallbacks`). Absent means the * run predates the column or the project has no chain — never "the harness * downgraded"; the harness's own downgrade is carried as * `modelFallbackMessage` on the worker result instead, because that is * evidence about this run while this column is attribution for the chain * (#286). */ model?: string; /** * The code-graph session observation (#726): whether the graph MCP tools were * in this run's own session registry when the session started. Read off the * session itself, once, at dispatch — never derived from `mcp.json`, which * only says what a session *should* mount. It is the difference between "the * model ignored a tool it had" and "the tool was missing", which look * identical from outside a session. Absent means the run predates the column * or no worker session recorded an observation — never "graph tools were * absent": `present: false` is the only absence claim, and it is a runtime * fact, not an inference. */ graphTools?: GraphToolsObservation; /** When an operator accepted the loss or recovered the tree by hand * (`unblock --force`). Clears the hold without erasing what happened. */ salvageAckAt?: number; /** Why the retained tree is under quarantine (#737): the cleanup pass could * not make its object store sound (`repairAlternates`), so fetching into it * was refused and the tree's commits cannot be verified against any remote. * Present means the tree is quarantined right now; the pass clears it the * moment it reaps the tree or re-verifies the store on a later pass. * Absent means never quarantined, or no longer — never "quarantine not * checked", because every retained-tree pass checks. */ quarantineDetail?: string; /** Failed-attempt budget charges this row has accumulated through review * claims (#795 review round 2). Every chargeable terminal event that was * claimed into a review round adds one here, so `failuresFor` keeps * counting each event even after the round leaves the row `pushed-green`; * repeated failed rounds therefore keep consuming the failed-attempt * budget. Absent means zero charges were ever preserved on this row. */ failureCharges?: number; /** Continuation budget charges this row has accumulated through review * claims, same semantics as {@link RunRecord.failureCharges}: each * chargeable cap or returned-for-revision event claimed into a round adds * one, and `continuationsFor` sums the column. */ continuationCharges?: number; startedAt: number; endedAt?: number; /** Last failure text, surfaced verbatim in escalations. */ lastError?: string; /** * The terminal evidence the settlement sweep recorded for this attempt * (#986): the exit path the row took, the first line of whatever error was * recoverable from the row or its transcript, and any settlement-audit * flags — one bounded line, readable in `status` beside a surviving * `unknown`. Absent means the row predates the column, or the sweep has not * looked at it yet; it never means "nothing was known". */ terminalEvidence?: string; /** Advisory settlement-audit findings for this attempt (#128). Deliberately * never consulted by anything that decides `state`: a flagged run settles * exactly as an unflagged one does, and the flags are evidence for whoever * reviews the PR. Absent means the audit found nothing, or never ran. */ settlementFlags?: SettlementFlag[]; /** The worker's settlement report text for this attempt: the worker's own * narrative, with the `changed:` file list rewritten at settlement from * the PR's actual diff (#488). Absent means the run predates the column, or * was killed before the worker returned a report. */ report?: string; /** * Why this run ended badly, and what the daemon did about it (#132). Absent * means the sweep has not looked at the row yet — never "nothing was wrong": * every budget counter treats an unclassified terminal row exactly as it did * before classification existed. */ failureClass?: FailureClass; recoveryAction?: RecoveryAction; /** When the recovery for that class ran. Absent means still unrecovered, which * is what `status` counts and what the board's card suffix reports. */ recoveredAt?: number; } /** * A partial patch for {@link updateRun}. Every key is optional; an absent key * and an explicitly-`undefined` value both leave the column alone, while an * explicit `null` clears it (#468). `null` exists only for the columns a * caller may legitimately empty — a settle can never widen into a column it * did not mean to write. */ export type RunPatch = { [K in keyof RunRecord]?: RunRecord[K] | null }; /** A one-shot turn budget waiting for one issue's next claimed attempt. */ export interface TurnOverride { project: string; issue: number; maxTurns: number; setAt: number; } /** * The one-shot model escalation a run chain has already spent (#807). * * Written before the continuation it authorises is queued, and never removed: * its presence is both "this chain dispatches on the stronger tier" and "this * chain may never escalate again", so a restart mid-recovery cannot hand the * same chain a second tier. */ export interface ModelEscalation { /** The opaque selector the chain escalated to — the project's * `workerEscalationModel` as configured when the marker was claimed. */ model: string; /** The spinning-cap class that bought the escalation. */ failureClass: FailureClass; /** The run whose settlement claimed it. */ runId: string; at: number; } export type AdmissionHoldReason = | "capacity" | "issue-active" | "failed-attempts" | "continuations" | "issue-closed" | "issue-state-lookup-error" | "issue-dequeued" /** An operator-parked issue: the park label beat the queue label at claim * time (#734). Unlike `issue-dequeued` it is an operator gesture, not a * queue edit — the queue label may still be physically present. */ | "issue-parked" | "parent-lookup-error" | "sibling-active" | "repo-active" | "open-pr-lookup-error" | "open-pr" | "unsalvaged-wip" | "daily-spend-cap" /** The candidate's own per-run spend reservation does not fit in what is * left of the daily budget once spend-to-date and every live run's * reservation are counted (#851). Distinct from `daily-spend-cap`, which is * the fleet-stopping "today is already spent" verdict: this one holds a * candidate while admitted work is still running, and clears by itself as * those runs settle for less than they reserved. */ | "spend-reservation" | "plan-usage-cap" /** A provider this project requires to bill to its subscription is currently * resolving to something else, or could not be verified (#852). Fleet-wide * like `plan-usage-cap`, and self-clearing: the operator re-authenticates and * the next pass admits. The hold's detail carries which provider, what it * resolves to, and the remediation. */ | "credential-class" | "shutting-down" | "stale-base" /** A ready issue whose lifecycle state label is residual: its newest run is * terminal (or absent), so nobody owns it — Duty 1 reconciliation work, not * occupied capacity (#611). */ | "stale-lifecycle" | "critical-base-verify-error" | "file-lane" | "depends-on" | "dependency-cycle" | "unroutable:no-repo-label" | "unroutable:multiple-repo-labels" | "unroutable:unknown-repo"; /** One bounded reason group from the latest admission pass. */ export interface AdmissionHoldSummary { reason: AdmissionHoldReason; count: number; /** Queue-order sample, capped before persistence and rendering. */ issues: number[]; /** * Per-issue detail, aligned by index with {@link AdmissionHoldSummary.issues} * and capped the same way. The file-lane interlock (#555) fills it so an * operator reading status or the digest can see *which file* blocked the * held issue and *which running issue* holds it — the same question the * plain `reason` group answers only with a count. */ details?: string[]; } /** Persisted outcome of the latest completed dispatch tick. */ export interface DispatchSummary { completedAt: number; ready: number; routed: number; admitted: number; /** Ready issues carrying a state label — claimed by a live or settling run. * Optional: persisted old rows lack it, so readers use `?? 0` (#228). */ claimed?: number; /** True only for system/API failures, never ordinary policy holds. */ degraded: boolean; holds: AdmissionHoldSummary[]; /** Runs the pass settled while it ran — merged PRs terminalised by the settle * sweep and by classification recovery. Optional: persisted old rows lack * it, so readers use `?? 0`. (#497) */ settled?: number; /** * Queue-labelled candidates the operator's park label disqualified — the * same eligibility read the claim gate uses, so the status number cannot * disagree with what admission would hold (#507). Distinguishes "0 claimable, * 12 parked" from "0 claimable, nothing to do". Optional: persisted old rows * lack it, and a pass with nothing parked omits it, so readers use `?? 0`. */ parked?: number; /** True for a held pass's own record (#497): the queue was never routed, so * ready/routed/claimed are absent queue facts, not an empty queue. */ paused?: boolean; } /** Admission holds that indicate repairable friction rather than normal flow control. */ export type FrictionAdmissionReason = | "failed-attempts" | "continuations" | "parent-lookup-error" | "issue-state-lookup-error" | "open-pr-lookup-error" | "stale-base" | "critical-base-verify-error" | "unroutable:no-repo-label" | "unroutable:multiple-repo-labels" | "unroutable:unknown-repo"; /** A mechanically observed or explicitly classified source of repeated friction. */ export type FrictionKind = | `admission:${FrictionAdmissionReason}` | "feedback:escalation-should-digest" | "feedback:report-noise" | "feedback:report-surprise"; /** One observation persisted into the daily friction rollup. */ export interface FrictionObservation { kind: FrictionKind; /** Number of affected candidates/events represented by this observation. */ occurrences: number; issues?: number[]; issue?: number; /** Bounded human-readable evidence, never an unbounded error or transcript. */ sample?: string; at: number; } /** A repeated signal eligible for the orchestrator's Learning loop. */ export interface FrictionSignal { kind: FrictionKind; observations: number; occurrences: number; issues: number[]; samples: string[]; latestAt: number; } /** * What a report is *for*. Both reporting scopes promise exactly one `digest` a * day; a `material` report is one event as it happens. The daemon puts both on * the wire identically — the kind exists so the *ledger* can answer "has * today's digest already been handed over?" without asking the model to * remember, which is the memory #123 found unreliable. * * The remaining values are the interrupt categories themselves. Handing a * report over as `fleet-stopped` or `tier2` (or `decision-needed`, * `confirmed-failure`) is the escalation handoff: the project's reporting * policy decides between sending now and holding, exactly as it does for a * daemon escalation of that category, and an identical retry is refused only * while the earlier handoff is still undelivered (#453). * * Declared as data for the same reason as {@link REPORT_SCOPES}: the CLI verb * that accepts a kind and the outbox that renders one enumerate the same list, * so a third kind cannot be added while either still knows only two. */ export const REPORT_KINDS = [ "material", "digest", ...INTERRUPT_CATEGORIES.filter((category) => category !== "material"), ] as const; export type ReportKind = (typeof REPORT_KINDS)[number]; /** * Where one report is in the outbox. The two open states are deliberately * distinct because they need different responses from an operator: * * - `pending` — nothing is in flight. Either nothing was ever attempted, or the * last attempt failed in a way Telegram told us about ({@link * ReportRecord.lastError} says which). Nobody has this report. * - `sending` — a request left this process and its outcome was never learned: * the daemon died, or the fetch never came back. It may well have been * delivered. The Bot API accepts no client-supplied idempotency key, so there * is nothing to replay against and no bot-readable record to reconcile with — * delivery is therefore **at-least-once** and a retry may duplicate. A * duplicate page is acceptable; a silently dropped one is the whole of #123. * * `failed` is the bounded retry budget running out, and is itself news: a * report nobody can deliver escalates as tier 2 in its own right. */ export const REPORT_DELIVERY_STATES = ["pending", "sending", "delivered", "failed", "withdrawn"] as const; export type ReportDeliveryState = (typeof REPORT_DELIVERY_STATES)[number]; /** One rendered report, and everything the daemon knows about delivering it. */ export interface ReportRecord { /** Short, stable, and printed inside the message itself. This transport * offers no reconciliation, so an id an operator can compare by eye is the * only way to recognise a duplicate as one. */ id: string; project: string; kind: ReportKind; /** The model's rendered text, stored verbatim — authorship stays theirs. */ body: string; state: ReportDeliveryState; /** Attempts *started*, including one currently in flight. */ attempts: number; /** Names the in-flight attempt. Every terminal transition quotes it, so a * request that returns after its row was reclaimed cannot overwrite a newer * attempt's outcome — this is what keeps one report from being concurrently * in flight twice. Absent whenever nothing is in flight. */ attemptId?: string; /** True once an attempt's outcome was lost. From then on every message for * this report says it may be a repeat, because it may be. */ ambiguous: boolean; /** At-most-one guard, e.g. `digest:2026-08-09`. Absent for material reports. */ dedupeKey?: string; createdAt: number; updatedAt: number; /** Backoff gate: a `pending` row is due only once this has passed. */ nextAttemptAt: number; /** Telegram's own id for the delivered message, read out of the response * body rather than inferred from the HTTP status. A report split into * several messages records the first part's id here; {@link messageIds} * carries every part. */ messageId?: number; /** Every Telegram message id a split delivery accepted, in order. Absent * for rows delivered before multi-part sends existed (#566), and whenever * Telegram answered without an id — `ok: true` is the verdict, the id is a * courtesy. */ messageIds?: number[]; /** * Parts of the current delivery contract already accepted, persisted as the * retry's resume point. The split is a pure function of the message text, so * a count is the whole state: a retry whose message is byte-identical to the * attempt that wrote this watermark (see {@link sentPartsHash}) starts at * part `sentParts` instead of re-sending confirmed parts (#566). */ sentParts?: number; /** SHA-256 of the message text the `sentParts` watermark was written under. * Progress is only valid while the text is unchanged — a report flagged * ambiguous re-renders with a possible-repeat banner, which reshapes the * split, so the watermark must not carry across it. */ sentPartsHash?: string; deliveredAt?: number; /** Bounded text of the last known failure, surfaced verbatim by `status`. */ lastError?: string; withdrawnAt?: number; withdrawnBy?: string; withdrawReason?: string; } /** What a caller hands over. The store owns identity, state and every timestamp. */ export interface ReportDraft { project: string; kind: ReportKind; body: string; dedupeKey?: string; at: number; } /** Result of handing a report over. `deduped` is how the daily digest is * suppressed — decided from the ledger, never from the model's memory of the * last tick — and the caller is told which happened rather than left to guess * from a row it cannot tell apart from its own. */ export interface ReportEnqueue { report: ReportRecord; deduped: boolean; } export interface HandoffWithdrawal { id: string; project: string; target: "report" | "notice"; actor: string; reason: string; at: number; summary: string; detail: string; } export type HandoffWithdrawalResult = | { kind: "withdrawn"; withdrawal: HandoffWithdrawal } | { kind: "not-found" } | { kind: "refused"; target: "report" | "notice"; state: string }; /** Maximum ledger rows of each kind placed in one digest prompt. Older rows * stay first, so a busy day drains deterministically over later digests. */ export const DIGEST_BACKLOG_LIMIT = 20; /** * The three install identities one host carried at one moment (#919). * * Recorded by the daemon's dispatch pass rather than probed at render time: * reading them costs three subprocesses (`omp-conductor --version`, `omp plugin * list --json`, `herdr … plugin list`), and calling that from the tick took the * tick suite from 8.4s to 83.4s — three spawns every fifteen minutes, forever, * to answer a question whose answer changes only when someone installs * something. So the surfaces that an operator and an agent actually read * consult this row and spawn nothing. * * A missing `ompVersion` or `herdrSource` is "this host does not carry that * surface", which `doctor` reports with full nuance. Only a proven * disagreement — two different releases live at once — is a fault the cheap * surfaces name. */ export interface InstallSurfaceObservation { /** When the daemon read the surfaces. */ at: number; cliVersion: string; ompVersion?: string; herdrSource?: string; /** * The `omp-telegram` peer's three versions (#961), each absent when that * surface did not answer. * * Absent is not agreement. A pass with no registry access records the two it * could read and leaves `telegramPublished` off, and every renderer must show * that as unverified — the whole point being that a stale plugin silently * broke a contract the brief mandates, and "no finding" is what let it. */ telegramInstalled?: string; telegramDaemon?: string; telegramPublished?: string; } /** One ordinary material outcome waiting for a rendered digest (#274). */ export interface MaterialEvent { id: string; project: string; category: string; summary: string; evidence: string; occurredAt: number; recordedAt: number; /** The accepted outbox row that took responsibility for this event. */ digestReportId?: string; } /** What the orchestrator records when an outcome happens. */ export interface MaterialEventDraft { project: string; category: string; summary: string; evidence: string; occurredAt: number; recordedAt: number; } /** Counts and oldest timestamps for the two distinct digest sources. */ export interface DigestBacklog { materialCount: number; materialOldestAt?: number; heldNoticeCount: number; /** Subset held only for the next availability window. */ availabilityHeldNoticeCount?: number; /** Oldest held notice across both held groups. */ heldNoticeOldestAt?: number; /** Oldest held notice that is digest-bound (`releaseOnAvailable` false). */ digestHeldNoticeOldestAt?: number; /** Oldest held notice waiting only for the availability window. */ availabilityHeldNoticeOldestAt?: number; } /** Where one captured raw idea is in its lifecycle (#298). `pending` is a raw * idea awaiting grooming; `groomed` became an issue (#300, which records the * issue URL); `dismissed` was dropped without becoming anything. */ export const INTAKE_STATES = ["pending", "groomed", "dismissed"] as const; export type IntakeState = (typeof INTAKE_STATES)[number]; /** One raw idea captured by `omp-conductor intake`, durable in the store so an * idea from 02:00 is still there after a restart (#299). */ export interface IntakeItem { id: string; project: string; /** The idea text verbatim, as captured. */ text: string; createdAt: number; state: IntakeState; /** The issue this idea became, once #300 grooms it. */ issueUrl?: string; /** When the item was resolved into an issue (#300) or dismissed. */ groomedAt?: number; /** * Provenance of a machine-filed item (Phase 4 signal mining) — the stable * key of the signal that produced it, e.g. * `mined:settlement-weakening:omp/src/foo.ts`. Absent means an operator * typed this idea themselves, which surfaces matching on it must render as * their own idea rather than as an unattributed machine signal. */ source?: string; } /** What a caller hands over. The store owns the id, the state and the timestamps. */ export interface IntakeDraft { project: string; text: string; at: number; /** * Provenance and dedupe key in one (Phase 4). When present, the store * derives the item's id DETERMINISTICALLY from `project` + `source` and * inserts only if that id is new, so an hourly mining pass re-filing a * signal it already filed is a no-op rather than an hourly duplicate. * * The consequence is intended: an item the operator dismissed stays * dismissed instead of reappearing every hour, because the second filing * cannot resurrect the row it collides with. The trap is the same fact from * the other side — a signal genuinely worth re-raising later needs a * DIFFERENT key, which is what a source carrying a bucket (a week, a run * count) is for. A bare `mined::` is a once-ever claim. * * Absent for an operator's own idea, which keeps a random id and is * therefore always a new row: two identical thoughts on two days are two * thoughts. */ source?: string; } /** * Which daemon-owned session spent this (Phase 4 attribution). Exactly the two * the daemon runs itself: to-spec grooming scouts and review adjudication. * * The orchestrator's own tick turns are deliberately NOT a member. The tick * extension observes no spend or usage events at all — its `message_start` * adapter reads content text — so there is no number to record, and a `0` row * per tick would report the orchestrator as free. Orchestrator spend is * reported as unmetered instead of invented. */ export const SESSION_SPEND_ROLES = ["groom", "adjudicator"] as const; export type SessionSpendRole = (typeof SESSION_SPEND_ROLES)[number]; /** * One daemon-owned session's turns and cost (Phase 4 attribution). * * The same shape going in and coming out: the store adds nothing but a rowid, * because every field here is something the caller actually observed. * * `spendUsd` is OPTIONAL, and that is the contract rather than convenience. * In the `runs` table an unmetered request and a genuinely free one are both * `0`, which is why `spend-telemetry.ts` has to infer a subscription verdict * from the share of zeros instead of reading a fact. Here the caller * distinguishes them: omit `spendUsd` when the provider reported no cost for * the session (subscription billing, or a harness that emitted no usage * event), pass it when it reported a number — including a real `0`. A reader * MUST render an absent `spendUsd` as unmetered and never as `$0.00`. */ export interface SessionSpendRow { project: string; role: SessionSpendRole; /** The issue the session was about, when it was about one. Absent for a * grooming batch that spanned several. */ issue?: number; /** The model the caller asked for. */ model?: string; /** The model the harness actually ran, which is the one that was billed and * the one a per-model breakdown groups on. Absent when unobserved — never * filled in from `model`, because a requested model is not evidence of a * resolved one. */ resolvedModel?: string; turns: number; /** Absent means the provider reported no cost. See the interface note. */ spendUsd?: number; at: number; } /** * The verdict surface of per-issue grooming state (#735). Admission writes * `blocked` when it holds a candidate for a file-lane or dependency reason — * the durable half of the misleading "groom the backlog" fix, because the tick * reads these instead of re-deriving why the runway cannot move. `promotable` * and `considered` are the outcomes #679's scout loop writes into the same * table, so grooming knowledge lives in one store rather than a second memory. */ export const GROOMING_VERDICTS = ["promotable", "blocked", "considered"] as const; export type GroomingVerdict = (typeof GROOMING_VERDICTS)[number]; /** * Who acted on a grooming verdict by queueing the issue (#1041). Three actors, * because the promotion audit's whole question is which of them did it: the * daemon promotes unattended once the ready gate passes, an orchestrator tick * promotes as part of its own reasoning, and an operator promotes by hand. A * fourth spelling would mean a fourth promotion path exists, which is exactly * what this closed union is here to prevent. */ export type PromotedBy = "daemon" | "orchestrator" | "operator"; /** * One issue's current grooming verdict, durable across restarts and keyed by * project + issue — one row per issue, replaced in place by upsert, never a * history. Deliberately NOT a decision row: a verdict is neither owed to the * operator nor TTL-exempt, so it sits outside `decisions` (7-day expiry, * operator-facing) and `material_events` (append-only digest outbox) alike. */ export interface GroomingRecord { project: string; issue: number; verdict: GroomingVerdict; /** Why this verdict — admission's hold reason for `blocked` (`file-lane`, * `depends-on`), or the groomer's own label for #679's verdicts. */ reason: string; /** What proved it: the hold detail naming the overlapping file/holder run, * or a scout summary. Free text, bounded at the write site. */ evidence: string; recordedAt: number; /** * When this verdict was acted on by queueing the issue (#1041). Absent on * every verdict nobody promoted, which is the honest reading of a row from * before mechanical promotion existed: nothing recorded who queued it, so * nothing may claim to know. * * Set exactly once per verdict, by {@link Store.markGroomingPromoted}, and * cleared by the next `upsertGrooming` — a re-recorded verdict is a new * judgement about the issue, and provenance that outlived the judgement it * describes would audit the wrong decision. */ promotedAt?: number; /** Who queued it: the daemon's own ready gate, an orchestrator tick, or an * operator at the CLI. Stored rather than inferred — the tick's promotion * audit exists precisely to review what the daemon did unattended, and a * guess would make that review circular. Always present when * {@link promotedAt} is, and absent when it is not. */ promotedBy?: PromotedBy; } /** What a caller hands over. The store owns the timestamp and the replace-in- * place semantics, nothing else. */ export interface GroomingDraft { project: string; issue: number; verdict: GroomingVerdict; reason: string; evidence: string; at: number; } /** * One operator's standing approval of an epic's scope (#1041). * * Deliberately its own durable fact rather than a resolved decision row, for * two reasons that both bite in production. First, approving an epic is an * explicit, revocable act with exactly one meaning — "the children of this * epic may be queued without asking me again" — while a decision row's * `resolution` is free text a human wrote for a human, and no amount of * parsing turns "yes, but do the migration first" into a machine gate. Second, * decision rows expire ({@link DECISION_TTL_MS}) and an approval must not: * scope consent does not lapse after a week just because nobody looked at it. * * Carries no `project` field because it is always read through a * project-scoped accessor — the scoping is the key, not a payload column an * unwary caller could compare against the wrong project. */ export interface EpicApproval { issue: number; approvedAt: number; /** Who approved it, in the same spelling the report-withdrawal path already * uses: the session role when one is set, else `orchestrator` inside a * Herdr pane, else `operator`. Free text on purpose — it is provenance for * a human reading an audit line, never a gate anything branches on. */ approvedBy: string; } /** * Where one operator decision stands (#136). * * `open` is the only state that owes anybody anything, which is why the tick * digest reads exactly that set. The three terminal states are distinct because * they describe different histories: `answered` carries what the operator * decided, `withdrawn` records that the session stopped needing it (and why), * and `expired` is the ledger closing a question nobody answered inside * {@link DECISION_TTL_MS} rather than letting it accumulate forever. */ export const DECISION_STATES = ["open", "answered", "withdrawn", "expired"] as const; export type DecisionState = (typeof DECISION_STATES)[number]; /** * How long an unanswered question stays open — the seven days the floor's * parked-amendment protocol already promised, now enforced instead of * remembered. */ export const DECISION_TTL_MS = 7 * 24 * 60 * 60_000; /** What a pending decision row is owed — answered by a human, or a condition * the orchestrator set for itself with no human in the loop. The split is a * stored column, never inferred from the wording or from whether a condition * is attached: a real question may legitimately carry a condition ("ask me * once this PR merges"), and a watch is defined by having no human, not by * having one. */ export type DecisionKind = "question" | "watch"; /** One question put to the operator, and its answer if it has one. */ export interface DecisionRecord { id: string; project: string; /** Whether this row is a question a human must answer or a watch the * orchestrator set for itself. A watch turns up under its own heading and * is never offered to the operator to resolve. */ kind: DecisionKind; /** The question verbatim, as it was sent. Re-asking must not reword it. */ question: string; /** What is waiting on the answer — an issue, a release, a PR. Free text, * because the point is that a human reads it in a digest. */ blocks?: string; askedAt: number; expiresAt: number; /** * The questionnaire this row belongs to (#947): N rows written in one * transaction, delivered as one message, resolved independently. Absent on * every ordinary single ask, which is why the column is nullable rather than * defaulted — a historical row was never part of a group and must not read as * a group of one. */ groupId?: string; /** * The issue being specced when this question was asked (#947). A * questionnaire's whole point is that its answers are the spec's provenance, * so a later reader can see why a slice is shaped the way it is instead of * re-litigating it; a group without one is refused at the store boundary. */ specIssue?: number; /** Raw machine-checkable precondition, e.g. `pr-merged:`. Stored as * written and parsed on read, so a grammar this build does not know is a row * that still lists rather than a row that fails to load. */ condition?: string; /** When that precondition was first observed true. Set once; the digest * promotes the row from "parked" to "act on this now". */ conditionMetAt?: number; /** For `pr-checks-green` / `pr-review-ready`: the exact PR head whose checks * satisfied the condition, read in the same pass as the green verdict. A * head change invalidates the verdict, so a met row whose PR head has moved * returns to pending instead of rendering `[CONDITION MET]` for a stale * head (#808). */ conditionHead?: string; state: DecisionState; resolvedAt?: number; /** The answer, the withdrawal reason, or the expiry note. */ resolution?: string; } /** What a caller hands over. The store owns the id, the state and the expiry. */ export interface DecisionDraft { project: string; question: string; /** `"watch"` marks a row the orchestrator set for itself. Absent means a * question a human must answer — the default, so every existing caller * (`message` with a non-material category, `decision open`) stays a * question without remembering to say so. */ kind?: DecisionKind; blocks?: string; condition?: string; /** Set by {@link Store.createDecisionGroup}; never by a single ask (#947). */ groupId?: string; /** The issue this question specs out (#947). Required inside a group. */ specIssue?: number; at: number; } /** One decided GitHub label change waiting to be projected (#201). */ export interface LabelOp { id: number; project: string; issue: number; op: "add" | "remove"; label: string; createdAt: number; attempts: number; nextAttemptAt: number; lastError?: string; } /** * Dispatch state is store-authoritative; GitHub labels are a retried * write-behind projection of it (#201). `isEligible` still consults the label * set a candidate *effectively* carries — physical labels overlaid with the * outbox's pending ops — so a label GitHub refuses to move cannot strand an * issue the daemon has already decided to move on. A store loss no longer * rebuilds the world: it also forgets what labels the tracker was told to * carry, so the operator reconciles by hand, which is the only safe move once * the two disagree. */ /** * A settled `ci-deterministic` row offered to the historical infrastructure * reconciliation, with the internal `rowid` that breaks start-time ties so the * persisted review cursor can resume exactly below the last decided row (#638). */ export type HistoricalInfraCandidate = RunRecord & { rowid: number }; export interface Store { /** A claimed run atomically consumes and applies its issue's pending turn override. */ createRun(r: Omit): RunRecord; /** Partial patch; an explicit `null` clears a column, `undefined`/absence leaves it alone (#468). */ updateRun(id: string, patch: RunPatch): void; getRun(id: string): RunRecord | undefined; /** * Counts of this project's runs that recorded a graph-tools session * observation (#726), split by the observed truth value. The doctor reads * these to report what dispatched sessions actually saw, and reports nothing * as "observed" when no run has recorded one. */ graphToolsObservationCounts(project: string): { recorded: number; present: number; absent: number; }; /** Runs whose issue is occupied: a live worker, or a green PR awaiting merge. */ activeRuns(project: string): RunRecord[]; /** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */ liveRuns(project: string): RunRecord[]; /** * Runs holding an **active mutation lease** (#899, #925): something is * writing through them right now — a live worker, or a review revision the * daemon has dispatched and that has not settled. Narrower than * {@link Store.activeRuns}, whose worker-free `pushed-*` rows own an issue * and an artifact but no longer own their files. * * This is the one answer to "may this file be written", and it is a single * query because two independent readers ask it — admission's file-lane gate * and `conductor_pr_recover`'s fail-closed lane check — and a lease rule that * disagreed between them would be the exact bug both exist to prevent. */ leasedRuns(project: string): RunRecord[]; /** Failed/killed/orphaned rows whose retained tree has not been reaped. */ retainedRuns(project: string): RunRecord[]; /** Newest attempt per issue for the live board. Non-merged work remains * visible; merged rows are bounded by the supplied recent-history cutoff. */ recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[]; /** Every attempt that recorded one PR URL, newest first. Mediated PR verbs * resolve ownership through this, not through `recentRuns`' newest-attempt- * per-issue view — a requeued continuation must never hide the PR its * predecessor opened. Merged rows keep the same recent-history bound. */ runsForProjectPr(project: string, prUrl: string, mergedSinceEpochMs: number): RunRecord[]; /** Persist one review-revision request (#677), durably, before anything is * woken. A same-run, same-head finding folds into the revision already * pending undelivered (`appended` — one round, one row, one worker * attempt, and the resumed session gets both findings); otherwise it opens * a new round (`created`). A revision already pending at a different head * cannot be amended (`refused`) — that round must dispatch and settle * first. The duplicate in-flight guard is the same single transaction, so * two concurrent same-head findings cannot both create a row or overwrite * each other (the run row's state is the other half once the revision is * dispatched). */ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue; /** The pending revision for one run, if any — the header the verb reads to * tell "this call folds into the in-flight round" from "this call opens a * new round" before the round-ceiling gate applies. */ pendingReviewForRun(project: string, runId: string): ReviewRevisionRecord | undefined; /** Review revisions not yet handed to a worker, oldest first — what the * daemon's dispatch pass wakes on its next tick. */ pendingReviewRevisions(project: string): ReviewRevisionRecord[]; /** Round number of the newest revision ever requested for a run (0 = never). */ latestReviewRound(project: string, runId: string): number; /** Record that a revision was handed to a worker. */ markReviewRevisionDispatched(id: string, at: number): void; /** Record, once, how this review round is being launched (#1045). * * Write-once and atomic — one guarded UPDATE, never read-then-write, so two * dispatch passes cannot each decide. The FIRST decision for a round wins: * * - no decision yet ⇒ written, `true`; * - the identical decision again (a dispatch retry, a restart re-deciding * the same way) ⇒ no-op, `true`, and the original `launchDecidedAt` * stands; * - a *contradictory* decision (different mode, requested model or origin * lineage) ⇒ nothing written, `false`. The caller must re-read the * recorded decision and honour it rather than launching its own; * - no such round in this project ⇒ `false`. * * Touches the decision columns only. The issue, run, attempt/continuation * charges, round number, branch, PR URL, reviewed head, findings and * settlement history are the round's identity and are never written here. */ recordReviewRevisionLaunch( project: string, id: string, decision: { launchMode: ReviewLaunchMode; requestedModel?: string; originSessionRef?: string; at: number; }, ): boolean; /** Attach the fresh correction's own session lineage — and the model the * harness actually resolved — to a round already decided * `fresh-correction` (#1045). `false`, writing nothing, on a round decided * `resume-original`, on an undecided round, or on no such round: a resumed * round *is* its origin session, so recording a separate correction * lineage for one would invent a session that never existed. * * Not write-once, and deliberately so: an infra-killed round relaunches on * the same decision, and the newest fresh session is then the truth about * what is running. `resolvedModel` is only ever added, never cleared — a * later call without one leaves a known resolution in place. */ recordReviewCorrectionSession(project: string, id: string, ref: string, resolvedModel?: string): boolean; /** The recorded launch decision and provenance for one round, or `undefined` * when no such round exists (#1045). Every field is absent on a round * nobody has decided and on every pre-#1045 row — the explicit unknown a * claim/retry/restart path must read *before* deciding, and the honest * projection a renderer must print instead of guessing. */ reviewRevisionLaunch(project: string, id: string): ReviewCorrectionProvenance | undefined; /** Record how a revision ended. */ settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void; /** Every revision row not yet settled — queued (`dispatchedAt` unset) and * dispatched-but-unfinished — oldest first. This is what a restart reads to * find review rounds a previous daemon left in flight (#692): the row is * the durable home of the findings, reviewed head, round and target * session, so re-queuing the row is how the exact session resumes. */ unsettledReviewRevisions(project: string): ReviewRevisionRecord[]; /** Return a dispatched revision to the pending set (clear its dispatched * marker) so the next dispatch pass wakes it again — the reopen half of * restart recovery, paired with restoring its run to `pushed-green`. */ requeueReviewRevision(id: string): void; /** Return one round to the pending set after an infrastructure kill, and * count it (#903), or report the bound spent. One transaction: the count is * read and written with the requeue, so two daemons cannot both spend the * same retry. `max` is {@link REVIEW_ROUND_INFRA_MAX_RETRIES}, passed in so * the bound stays a caller's policy rather than a number buried in SQL. A * settled row is never revived — `exhausted` is the honest answer for one. */ retryReviewRevisionAfterInfra(id: string, max: number): ReviewRevisionRetry; /** Atomically take a settled green run out of reviewable state: * `pushed-green` → `running`, and only from `pushed-green`. False when the * row already moved (a prior revision was dispatched, the PR settled), so * two concurrent dispatchers cannot both wake one run. */ claimRunForReview(runId: string): boolean; /** Every review-revision row that blocks merging one PR at one exact head * (#888): rows not yet settled (queued, or dispatched and crashed * mid-review) and rows settled `failed` at that head, oldest first. * Keyed by project + PR + head — never by a label or eventually consistent * issue search — so a finding tied only to an older head never poisons a * corrected new head. A row is excluded when a {@link ReviewClearance} for * the same project + PR + head was recorded at or after that row's own last * activity (#913) — the clearance is the one reachable disposition at an * unchanged head, and the timestamp bound keeps it from blessing findings * recorded after it. */ mergeBlockingReviews(project: string, prUrl: string, headSha: string): ReviewRevisionRecord[]; /** Append one review clearance for an exact head (#913). Append-only: a * second clearance at the same head is another audit row, never an update. */ recordReviewClearance(clearance: { project: string; prUrl: string; headSha: string; by: string; reason: string; at?: number; }): ReviewClearance; /** Clearances recorded for one PR at one exact head, newest first (#913). * Head comparison is case-insensitive, exactly as the merge gate's. */ reviewClearances(project: string, prUrl: string, headSha: string): ReviewClearance[]; /** * Admit one terminal review-ceiling adjudication for an exact PR head (#874), * idempotently. The second caller for the same head is handed the existing * row (`existing`), and a non-terminal adjudication on the same PR at a * DIFFERENT head refuses (`in-flight-different-head`) — the PR moved, and two * live adjudications on one PR is the duplicate this lifecycle prevents. * * Never consumes a review round: this is a separate table from * `review_revisions` precisely so a ceiling that is already exhausted is not * charged again. */ openReviewAdjudication(draft: { project: string; issue: number; prUrl: string; headSha: string; role: string; requestedAt?: number; /** The findings that provoked the escalation, when a caller has them. */ findings?: string; }): ReviewAdjudicationAdmission; /** `pending` → `running`, recording what the role actually resolved to at * launch. The transition is the WHERE clause, so two dispatchers racing one * row cannot both launch: the loser changes nothing and reads `false`. */ markReviewAdjudicationRunning( id: string, provenance: ReviewAdjudicationProvenance, at?: number, ): boolean; /** Settle into a terminal state, once, with the evidence that produced it. * A non-terminal target state or an already-settled row changes nothing and * reads `false`, so an invalid transition preserves the diagnostic the row * already carried instead of overwriting it. */ settleReviewAdjudication( id: string, state: ReviewAdjudicationState, evidence?: string, at?: number, ): boolean; /** Record what was done about a verdict (#876's half), kept separate from the * verdict so the two are never conflated. */ recordReviewAdjudicationDisposition(id: string, disposition: string): boolean; /** One adjudication by id. */ reviewAdjudication(id: string): ReviewAdjudicationRecord | undefined; /** The adjudication for one exact PR head, if any. Case-insensitive on the * SHA, exactly as the merge gate and the clearance lookup are. */ reviewAdjudicationForHead( project: string, prUrl: string, headSha: string, ): ReviewAdjudicationRecord | undefined; /** Every non-terminal adjudication for a project, oldest first: what a daemon * restart re-reads instead of launching a second adjudicator, and what status * projects as pending/running. */ openReviewAdjudications(project: string): ReviewAdjudicationRecord[]; /** Every adjudication recorded for one PR, newest first. */ reviewAdjudicationsForPr(project: string, prUrl: string): ReviewAdjudicationRecord[]; /** * Every review round recorded for one PR, oldest first (#932): the history an * adjudication is shown. Keyed by PR rather than by run so a continuation that * inherited the PR cannot hide its predecessor's findings — and a finding * without its settled outcome reads as an outstanding complaint even when the * worker fixed it, so both travel together. */ reviewRevisionsForPr(project: string, prUrl: string): ReviewRevisionRecord[]; /** Merged rows whose post-merge workflow verdict is still pending, oldest first. */ runsNeedingBaseCheck(project: string, limit?: number): RunRecord[]; /** Replace the current live-head health row for one routed repository. */ upsertBaseHealth(project: string, row: BaseHealth): void; /** Current live-head health, ordered by routed repository name. */ baseHealth(project: string): BaseHealth[]; /** Recently merged routed repository/branch pairs that establish health scope. */ mergedRepoBranches( project: string, sinceEpochMs: number, ): { repo: string; baseRef?: string }[]; /** The per-repo base-red freeze row, active or cleared, for one routed repo. */ baseFreeze(project: string, repo: string): BaseFreeze | undefined; /** Every base-red freeze row for a project, active first, for `status`. */ freezes(project: string): BaseFreeze[]; /** * Set or refresh an *active* base-red freeze for one repo, naming the * suspected culprit merge and the failing evidence. Refreshing an * already-active freeze keeps the repo frozen while the same head is still * red. Returns true only when it *newly* froze the repo — a transition worth * surfacing as a material event — and false for a pure refresh so the queue * digest is not flooded by per-tick re-observation of a still-red base. */ setBaseFreeze( project: string, freeze: { repo: string; culpritSha: string; detail?: string; setAt?: number }, ): boolean; /** * Lift an active freeze — the mechanical green recovery or the operator's * `unfreeze`. Records who lifted it and the ledger reason. Returns true only * when an active freeze was actually lifted (so a recovery/override event is * emitted once, never on an already-clear row). */ clearBaseFreeze( project: string, repo: string, by: string, reason: string, at?: number, ): boolean; /** The project's active release composition, if one is declared (#850). */ activeReleaseComposition(project: string): ReleaseComposition | undefined; /** * Declare the active release composition for a project (#850). One at a * time per project: returns the still-active conflicting composition * unchanged when one exists (the caller refuses), `undefined` when the * declaration took. Replaces only already-closed rows. */ declareReleaseComposition( project: string, composition: { campaign: string; allowedPrUrls: string[]; declaredBy?: string; declaredAt?: number; }, ): ReleaseComposition | undefined; /** * Retire the active composition as completed (#850). Records who closed it * and why on the row itself. Returns true only when an active composition * was actually retired, so the CLI event fires once. */ completeReleaseComposition(project: string, by: string, reason: string, at?: number): boolean; /** Retire the active composition as cancelled (#850), same contract as completion. */ cancelReleaseComposition(project: string, by: string, reason: string, at?: number): boolean; /** Append one operator override admitting `prUrl` into `campaign` (#850). */ grantReleaseCompositionOverride( project: string, override: { campaign: string; prUrl: string; by: string; reason: string; at?: number }, ): ReleaseCompositionOverride; /** The newest live override for one PR in one campaign, if recorded (#850). */ releaseCompositionOverride( project: string, campaign: string, prUrl: string, ): ReleaseCompositionOverride | undefined; /** Every override recorded for one campaign, newest first (#850). */ releaseCompositionOverrides(project: string, campaign: string): ReleaseCompositionOverride[]; /** Newest attempt per issue that preserved work or failed to, so `status` * can name every WIP tip a re-claim would build on and every tree that is * still the only copy. */ salvagedRuns(project: string): RunRecord[]; /** Newest attempt per issue whose retained tree is under quarantine: the * object store could not be made sound, so the tree's commits cannot be * verified against any remote and the daemon refused to fetch into it * (#737). Newest-attempt-only for the same reason the board is. */ quarantinedRuns(project: string): RunRecord[]; /** Total run segments, used only for the monotonically increasing run number. */ attemptsFor(project: string, issue: number): number; /** Terminal implementation failures that consume `maxAttemptsPerIssue`. */ failuresFor(project: string, issue: number): number; /** Operational stops that require a bounded continuation resume. */ continuationsFor(project: string, issue: number): number; /** How many times one issue has reached a given failure class. A recovery * that keeps landing on the same class (e.g. a persistently broken mirror) * uses this to escalate instead of retrying forever. */ classCountFor(project: string, issue: number, cls: FailureClass): number; /** Newest attempt for one issue, whatever state it reached. `omp-conductor * tail` resolves an issue number to a transcript through this; the number is * what an operator has, the run id is not. */ latestRun(project: string, issue: number): RunRecord | undefined; /** Every attempt for one issue, oldest first, so an escalation can group the * continuation budget by failure class (#439). */ runsForIssue(project: string, issue: number): RunRecord[]; /** * Spends the single model escalation an issue chain may buy (#807): the * one-shot marker, the `swapFrom` → `queueLabel` hand-back that the * escalation authorises, and the settling run's recovery stamp, all in one * transaction. `true` when this call spent it, `false` when the chain had * already escalated and nothing was written — the second-cap decomposition * verdict. Atomic on purpose: a restart must never find a chain that reads * as escalated with the continuation it promised never queued. Throws when * `runId` names no row. */ escalateModel(escalation: { project: string; issue: number; model: string; failureClass: FailureClass; runId: string; swapFrom: string; queueLabel: string; at?: number; }): boolean; /** The escalation this chain has already spent; `undefined` for every chain * that never bought one, which is what dispatch reads for a fresh issue. */ modelEscalation(project: string, issue: number): ModelEscalation | undefined; /** Persist a one-shot ceiling and append the operator action to its audit ledger. */ setTurnOverride(project: string, issue: number, maxTurns: number, setAt?: number): void; turnOverride(project: string, issue: number): number | undefined; /** Administrative deletion; claimed runs consume inside `createRun`'s transaction. */ clearTurnOverride(project: string, issue: number): void; listTurnOverrides(project: string): TurnOverride[]; /** Durable override actions, newest first; consumption never removes these rows. */ turnOverrideLedger( project: string, opts?: { issue?: number; limit?: number }, ): TurnOverride[]; runsStartedSince(project: string, sinceEpochMs: number): number; spendSince(project: string, sinceEpochMs: number): number; /** Total per-run allowance currently reserved by LIVE runs (#851). What * admission must subtract from the daily budget on top of spend-to-date: * a run that has spent little has still committed its whole allowance, and * reading the sum off the live rows means a settle releases the remainder * and a restart re-reads it rather than losing it. */ reservedSpendUsd(project: string): number; /** The rows one stats window needs (#282): every run settling at or after * `sinceEpochMs`, plus the full attempt chain of each issue whose merge * settled there (so continuation chains collapse into one journey), ordered * by issue then startedAt. Read-only. */ statsRuns(project: string, sinceEpochMs: number): RunRecord[]; /** * The newest terminal runs, as `{ turns, spendUsd }`, for judging whether the * spend cap's input is still arriving (#970). * * Bounded by `limit` rows, not by `limit` *working* runs: the judgement * discards 0-turn rows, so the caller over-samples and lets the shared * predicate pick the window. Read-only. */ recentSpendSamples(project: string, limit: number): { turns: number; spendUsd: number }[]; /** The newest runs carrying a usable per-turn latency (#1063): terminal * rows with turns and elapsed time recorded, newest first. Bounded by * `limit` rows like `recentSpendSamples` — the caller over-samples and lets * its own model-attribution filter pick the window. Read-only. */ recentLatencySamples(project: string, limit: number): RunRecord[]; /** Total tracked `gh` calls between two UTC day keys, inclusive (#198). */ ghCallsBetween(sinceDay: string, untilDay: string): number; /** Idempotence guard so a retry loop cannot page a human repeatedly for the * same event. */ wasNotified(key: string): boolean; /** Whether an escalation handoff with this project, category and folded text * is still owed: a same-text report still in the outbox, or a same-text * held notice not yet digested. The CLI report surface refuses an identical * retry only while this is true (#453) — once the first handoff lands, the * same text is a new occurrence, not a double-send. */ hasOpenEscalationHandoff(project: string, category: InterruptCategory, body: string): boolean; recordDispatch(project: string, summary: DispatchSummary): void; latestDispatch(project: string): DispatchSummary | undefined; /** The newest `digest:` dedupe key a project has run toward without ending in * failure — what the off-schedule-digest refusal compares against (#229). */ lastDigestDedupeKey(project: string): string | undefined; /** Hold a tier-2 escalation the interrupt policy or availability window deferred. */ addHeldNotice(notice: HeldNoticeDraft): void; /** * Held notices still owed, oldest first and bounded. `availabilityOnly` * selects notices that may be released at the next configured opening. */ undigestedNotices( project: string, limit?: number, availabilityOnly?: boolean, categories?: readonly InterruptCategory[], urgent?: boolean, ): HeldNotice[]; /** Withdraw one unsent report or unassigned held notice, preserving an audit row. */ withdrawHandoff( id: string, project: string, actor: string, reason: string, at: number, ): HandoffWithdrawalResult; /** Recent successful withdrawals, newest first, for status/audit. */ handoffWithdrawals(project: string, limit?: number): HandoffWithdrawal[]; /** Record the install identities this host carried on this pass (#919). * One row, replaced: the question is "what is installed now", and a history * of installs is what the upgrade journal already keeps. */ recordInstallSurfaces(observation: InstallSurfaceObservation): void; /** The newest recorded install-surface observation, or `undefined` when no * pass has read them yet — which the surfaces must render as "not observed", * never as agreement (#919). */ installSurfaces(): InstallSurfaceObservation | undefined; /** Persist one ordinary material outcome without sending it. */ recordMaterialEvent(event: MaterialEventDraft): MaterialEvent; getMaterialEvent(id: string): MaterialEvent | undefined; /** Ordinary material outcomes still owed, oldest first and bounded. */ undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[]; /** * Persist one raw idea, `pending`, for later grooming (#299). * * Idempotent when {@link IntakeDraft.source} is present (Phase 4): the id is * derived from `project` + `source` and the insert is ignored if that id * already exists, so the hourly mining pass is safe to re-run. A collision * returns the EXISTING row untouched — which is how a caller tells a new * filing from a no-op (`item.createdAt !== draft.at`, or a `state` that is * no longer `pending`) — and in particular does NOT reopen an item the * operator dismissed. * * Without a `source` the id is random, so every call is a new row. */ recordIntake(draft: IntakeDraft): IntakeItem; /** Ideas still `pending`, oldest first — what `intake list` and `status` read. */ pendingIntake(project: string): IntakeItem[]; /** Resolve one idea to `groomed` (recording the issue URL #300 chose) or * `dismissed`. `false` when the id is unknown. */ resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean; /** * Record the current grooming verdict for one issue, replacing any prior * row — one row per project + issue, never a history (#735). * * Clears {@link GroomingRecord.promotedAt}/{@link GroomingRecord.promotedBy} * (#1041). A re-recorded verdict is a fresh judgement about the issue, so * carrying the old promotion forward would let the audit attribute a queueing * to a verdict that no longer exists — and, worse, would make a demote-then- * regroom cycle read as though the demoted promotion still stood. A caller * that re-promotes stamps provenance again through * {@link markGroomingPromoted}, which is the only writer of these columns. */ upsertGrooming(draft: GroomingDraft): void; /** The current grooming verdict for one issue, or undefined when none. */ grooming(project: string, issue: number): GroomingRecord | undefined; /** Current grooming verdicts for a project, issue-ascending; `verdict` * narrows to one verdict — the tick's known-blocked read is * `groomingVerdicts(project, "blocked")`. */ groomingVerdicts(project: string, verdict?: GroomingVerdict): GroomingRecord[]; /** * Reconcile admission's lane/dependency holds against the persisted blocked * verdicts: each currently held issue is recorded as `blocked`, and in the * same transaction every `blocked` row whose lane/dependency hold this pass * does not name is deleted — the hold no longer applies, so the state * self-heals instead of lingering as a cache someone must invalidate. Runs * at the end of every admission pass. Never touches `promotable` or * `considered` rows, which belong to #679's scout loop. * * A promoted row (#1041) is likewise inert here, in both directions: it is * neither overwritten with `blocked` nor swept by the self-clearing delete. * Promotion hands the issue to dispatch, and admission's lane/dependency * holds on a queued issue are flow control of the moment, not a re-grooming. * Without this the fleet would lose the audit line within one 5-minute * admission pass — a promoted issue whose lane overlaps an active run would * be rewritten as `blocked` and then deleted — long before the ~30-minute * tick that is supposed to review the promotion ever read it. */ reconcileGrooming( project: string, holds: readonly { issue: number; reason: AdmissionHoldReason; detail?: string }[], ): void; /** * Delete every grooming row whose issue is not in a **complete** open-issue * snapshot, returning the issues retired (#964). * * A verdict describes an open issue: "promotable" means "promote this", and a * closed issue cannot be promoted. Nothing retired one before, so the rows * accumulated and `status` reported a groomed backlog that did not exist — * measured at 64 of 65 rows, including all 26 promotable ones. * * The completeness of the snapshot is the caller's contract, not this * method's: pass the result of `Tracker.listOpenIssues` (which throws rather * than returning a short list), never a label-filtered read. A partial * snapshot here deletes verdicts nobody re-derived. */ retireGroomingNotOpen(project: string, openIssues: readonly number[]): number[]; /** * Stamp promotion provenance on one existing verdict (#1041), returning * whether this call is the one that stamped it. * * `false` on both no-op shapes, and the caller must treat them the same way: * there is no such verdict, or it already carries a promotion. That makes the * write the single-flight latch for promotion itself — the daemon enqueues the * queue label and fires the wake only when it wins this call, so a restart * mid-promotion re-reads a stamped row and does not queue the issue twice. * Provenance is therefore stamped BEFORE the label op, never after. */ markGroomingPromoted(project: string, issue: number, at: number, by: PromotedBy): boolean; /** * Verdicts promoted at or after `since`, newest promotion first — the tick's * promotion audit (#1041): what was queued unattended while nobody was * looking, with the verdict and evidence that justified it still attached. * * Bounded by the window rather than by a count, because the audit's contract * is "everything since the last tick" — a truncated list would silently * un-audit a promotion. */ promotionsSince(project: string, since: number): GroomingRecord[]; /** * Record the operator's standing approval of one epic's scope (#1041). * * Idempotent and first-write-wins: re-approving keeps the original * `approvedAt` and `approvedBy`, because the durable fact is when consent was * given, not when it was last restated. A caller that must tell "approved * now" from "already approved" reads {@link epicApproval} first. Refuses a * non-integer or non-positive issue number rather than storing a fact about * an issue that cannot exist. */ approveEpic(project: string, issue: number, at: number, by: string): void; /** Withdraw an epic's approval, `false` when there was none to withdraw. * Approval is revocable by design: scope consent an operator regrets must be * removable without deleting the epic or its children (#1041). */ revokeEpicApproval(project: string, issue: number): boolean; /** One epic's approval, or `undefined` when it has none — which every gate * must read as "not approved", never as approval whose row is missing. */ epicApproval(project: string, issue: number): EpicApproval | undefined; /** Every approved epic in this project, issue-ascending. */ epicApprovals(project: string): EpicApproval[]; /** Count and age source for status and digest prompt bounds. */ digestBacklog(project: string): DigestBacklog; /** Add one bounded observation to the per-day friction rollup. */ recordFriction(project: string, observation: FrictionObservation): void; /** Repeated signals not surfaced within the supplied cooldown window. */ pendingFriction( project: string, sinceEpochMs: number, minimumObservations: number, surfacedBeforeEpochMs: number, ): FrictionSignal[]; /** Start the cooldown only after a tick carrying these signals was sent. */ markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void; markNotified(key: string): void; /** The live embedded-orchestrator-down incident for a project, if any. */ orchestratorIncident(project: string): OrchestratorIncident | undefined; /** Record a new incident. `false` when one is already open for the project — * dedupe so a flapping orchestrator opens (and pages) once per incident. */ openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean; /** Count tier-1 escalations diverted to an issue comment. No-op when no * incident is open, so a healthy or external orchestrator never accumulates. */ bumpOrchestratorDiverted(project: string, by?: number): void; /** Close the open incident and hand back what it accumulated, for the * recovery page's downtime and diverted count. `undefined` when none. */ closeOrchestratorIncident(project: string, at: number): OrchestratorIncident | undefined; /** The workspace ids conductor recorded as its own worker surface for a * project, oldest first (#1035 review). Durable across daemon AND Herdr * restarts: Herdr restores workspaces without their metadata tokens, so * this record is the discovery leg that survives. */ workerWorkspaceIds(project: string): string[]; rememberWorkerWorkspace(project: string, workspaceId: string): void; forgetWorkerWorkspace(project: string, workspaceId: string): void; /** Append one daemon stop/restart provenance line (#378). Host-wide — never * partitioned by project, so any project's status reads the same records. */ recordDaemonStop(draft: DaemonStopDraft): DaemonStop; /** The newest stop/restart provenance line, or `undefined` when none was * ever recorded. Read by `status` while the daemon is down and after the * next start — the debrief line an operator gets instead of a bare * "daemon not running". */ latestDaemonStop(): DaemonStop | undefined; /** Record one observed GitHub rate-limit refusal (the tracker's hook). Rows * older than 24h are pruned in the same write (#198). */ recordGhRefusal?(at: number): void; /** Refusals observed at or after `sinceMs` — `status` reads the last 5m. */ ghRefusalsSince?(sinceMs: number): { count: number; latestAt?: number }; /** Count one daemon-held `gh` call for the UTC day (`YYYY-MM-DD`). */ bumpGhCalls?(day: string, source: string): void; /** The tracked call counts for a UTC day, per source. */ ghCallsToday?(day: string): { source: string; calls: number }[]; /** * Record one auto-restart the daemon fired at a wedged orchestrator * (Phase 4). A bare append, like {@link Store.recordGhRefusal}: the cap is * enforced by counting rows in a window, so there is no counter to reset and * a crash cannot leave a high-water mark that either disables the cap or * spends it twice. Rows older than a week are pruned in the same write. */ recordOrchestratorRestart(project: string, at: number, reason: string): void; /** * This project's auto-restarts at or after `since`, NEWEST FIRST — the * bounded-restart gate counts them against its per-window cap, and the page * it sends when the cap is reached names the most recent reasons. */ orchestratorRestartsSince(project: string, since: number): { at: number; reason: string }[]; /** * Record what one daemon-owned session (grooming, adjudication) cost * (Phase 4 attribution). Append-only; these sessions have no lifecycle to * update, they either ran or they did not. * * Deliberately not a `runs` row: `computeStats` walks runs as issue journeys * and a role row in there would invent a journey, corrupt the settled counts * and misattribute grooming spend to a merge it did not produce. * * Omit `spendUsd` when the provider reported no cost — see * {@link SessionSpendRow}. */ recordSessionSpend(row: SessionSpendRow): void; /** This project's daemon-owned session spend at or after `since`, newest * first. Never includes worker runs: those live in `runs` and are already * counted by {@link Store.statsRuns}, so summing both surfaces gives the * fleet's whole bill without double-counting either half. */ sessionSpendSince(project: string, since: number): SessionSpendRow[]; /** * Persist a rendered report `pending`, before anything is sent — the whole * point of #123 is that an undelivered report is a queryable row rather than * an absence. A draft whose `dedupeKey` is already in the ledger returns that * row untouched and `deduped: true`, which is how the daily digest is * suppressed without asking the model what it sent yesterday. */ enqueueReport(draft: ReportDraft): ReportEnqueue; /** Atomically hand a digest to the outbox and associate only the named * material events and deferred escalations. A handoff deduplicated by its * optional daily key consumes nothing new. */ enqueueDigestReport( draft: ReportDraft, materialEventIds: readonly string[], heldNoticeIds: readonly string[], ): ReportEnqueue; /** Atomically enqueue a working-hours catch-up unless a due digest currently * owns the handoff window. `undefined` leaves every notice unowned. */ enqueueAvailabilityReport( draft: ReportDraft, heldNoticeIds: readonly string[], ): ReportEnqueue | undefined; getReport(id: string): ReportRecord | undefined; /** Atomically remove an unsent material report from the outbox and preserve * its body as a held notice after policy or availability changes. */ deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean; /** Remove a still-pending working-hours catch-up and make its associated * notices digest-eligible again after a live policy change. */ releasePendingAvailabilityReport(id: string, project: string, possibleRepeat?: boolean): boolean; /** Give one due digest a bounded, once-per-day lease on availability-held * notices. Pending catch-ups are released inside the same transaction. */ reserveAvailabilityDigest( project: string, cycleKey: string, at: number, expiresAt: number, ): boolean; /** `pending` rows whose backoff has elapsed, oldest first. */ dueReports(project: string, now: number, limit: number): ReportRecord[]; /** Atomically take a due `pending` row into `sending` under `attemptId`, so * the row says a request is about to leave *before* one does. `undefined` * means somebody else claimed it first — the guard against the same report * being concurrently in flight twice. */ claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined; /** Record the message ids Telegram returned for every accepted part, and * the moment the last one landed. `messageId` (the first part's id) is * derived here, so the two can never disagree. */ markReportDelivered(id: string, attemptId: string, messageIds: readonly number[], at: number): boolean; /** Record progress mid-split: the first `sentParts` parts of the delivery * contract fingerprinted by `sentPartsHash` are confirmed accepted. Written * before the next part ships, so a crash in the window can only ever * re-send the part that was in flight, never confirmed ones. */ markReportPartsSent( id: string, attemptId: string, sentParts: number, sentPartsHash: string, at: number, ): boolean; /** The attempt ended without an answer: Telegram may hold the message. The * row *stays* `sending`, because that state means exactly "outcome unknown", * and is flagged {@link ReportRecord.ambiguous} so the retry that stale * recovery eventually hands back says it may be a repeat. Deliberately does * not touch `updatedAt`: the staleness clock started when the request left, * and restarting it here would make the daemon wait out a second window for * an attempt already known to be over. */ markReportUncertain(id: string, attemptId: string, error: string): boolean; /** Known-failed: back to `pending` behind `nextAttemptAt`, attempt counted. */ markReportPending(id: string, attemptId: string, nextAttemptAt: number, error: string, at: number): boolean; /** Retry budget exhausted. Terminal, and worth escalating in its own right. */ markReportFailed(id: string, attemptId: string, error: string, at: number): boolean; /** `sending` rows untouched since `staleAt` — what a dead process left behind. * Returns them to `pending` flagged {@link ReportRecord.ambiguous}, so the * retry says it may be a repeat instead of pretending it is the first try. */ recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[]; /** Everything an operator still has to care about: pending, sending, failed. */ openReports(project: string): ReportRecord[]; /** * Terminal rows the classification sweep still owes something to, newest first * and bounded (#132): never classified, or classified with a retryable recovery * that has not run. The second half is what makes "retrying next tick" true * when a tracker write fails mid-recovery. * * Bounded because each row costs tracker calls to gather facts for: a fleet * with a long unclassified history works through it over several ticks rather * than spending one tick on all of it. */ runsNeedingClassification(project: string, limit?: number): RunRecord[]; /** * Settled rows currently reading `ci-deterministic` — the only rows the * historical infrastructure reconciliation may repair (#638): the verdict * the forward classifier no longer gives, and nothing else. Newest first * and bounded, because each candidate costs tracker calls to re-fetch the * evidence its log carries. * * `after` is the persisted review cursor (the last row a prior pass decided): * candidates are then only rows strictly *older* than it, so a bounded pass * progresses through a long history instead of rescanning the newest * non-matches forever. `rowid` on each candidate breaks the start-time tie, * giving the cursor an exact resume boundary. */ historicalInfraCandidates( project: string, limit?: number, after?: { startedAt: number; rowid: number }, ): HistoricalInfraCandidate[]; /** The persisted historical-review cursor for a project, or undefined before * any pass decided a row. The `classifierVersion` stamp tells the pass * whether the cursor is still current against the signature list. */ historicalInfraCursor( project: string, ): { startedAt: number; rowid: number; classifierVersion: string } | undefined; /** Persist (or move) the historical-review cursor after a pass decided rows. */ setHistoricalInfraCursor( project: string, startedAt: number, rowid: number, classifierVersion: string, ): void; /** Reclassify one settled `ci-deterministic` row to `ci-infra` (budget * exempt) after its re-fetched check log matched the closed infrastructure * signature. Changes `failureClass` only — `recoveryAction`/`recoveredAt` * stay byte-for-byte unchanged, so a months-old run is never re-animated * into recovery. False when the row was no longer `ci-deterministic`: a * no-op, exactly what a second repair pass must be. */ reclassifyInfra(id: string): boolean; /** * Rows still **awaiting a recovery the sweep will take**, per class (#132). * * Excludes the recorded-only actions (`none`, `hold`), which never stamp * `recoveredAt` by design — see {@link Store.recordedOnlyClassCounts}. Empty * when nothing is waiting, which is the healthy state and reads as one. */ failureClassCounts(project: string): { cls: FailureClass; n: number }[]; /** * Rows classified but deliberately not acted on, per class (#132): the * `none`/`hold` actions. * * A separate reader because these are a *signal*, not a backlog — 44 * `returned-for-revision` rows say something real about review outcomes, and * nothing about work that is stuck. Counting them as unrecovered is what hid * every actionable class behind a monotonically growing tally. */ recordedOnlyClassCounts(project: string): { cls: FailureClass; n: number }[]; /** The surviving `unknown` rows, newest first, each with whatever terminal * evidence the sweep recorded on it (#986). Bounded per call — a status * render must never walk a whole backlog. */ unknownRuns(project: string, limit?: number): RunRecord[]; /** Rows whose recovery ran at or after `since`, newest first — the tick's * "auto-recovered since last tick" line, and its count. */ recoveredSince(project: string, since: number): RunRecord[]; /** * Record a question put to the operator (#136). Expiry is the store's, not the * caller's: a session that computed its own deadline would be back to * remembering things. */ createDecision(draft: DecisionDraft): DecisionRecord; /** * Record one spec-out questionnaire (#947): every item durable in a single * transaction, before its single delivery, sharing a minted group id and the * issue being specced. Refuses an empty batch and a non-issue spec binding — * a questionnaire with nothing waiting on it, or with no spec, is a caller * bug rather than a degenerate case worth supporting. */ createDecisionGroup(drafts: readonly DecisionDraft[], specIssue: number): DecisionRecord[]; /** One questionnaire's rows in asked order, whatever their states — the * per-item view a digest renders and a timeout pass resolves against. */ decisionGroup(groupId: string): DecisionRecord[]; /** Everything still owed an answer, oldest first — what a tick digest reads. */ openDecisions(project: string): DecisionRecord[]; /** One row by id, whatever its state — what a bounded ask polls while waiting. */ decision(id: string): DecisionRecord | undefined; /** Answer or withdraw one. `false` when the id is unknown or already closed, * so a double-resolve cannot overwrite the first answer. */ resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean; /** * First observation that a row's condition came true. Idempotent per head: * a second call changes nothing, but a `pr-checks-green` / `pr-review-ready` * row whose PR head moved is cleared first and then re-marks against the new * head. * `head` binds the verdict to the PR head it was observed at (#808). */ markDecisionConditionMet(id: string, at: number, head?: string): boolean; /** * Drop a condition's met state — timestamp and any head binding — when the * observation it was set from stopped holding. The head-bound conditions * call this when the PR head changed, so the row returns to pending instead * of rendering `[CONDITION MET]` for checks that are still running (#808). */ clearDecisionConditionMet(id: string): boolean; /** Close every open row past its deadline and return them, so the caller can * say what it just closed rather than reporting a count. */ expireDueDecisions(project: string, now: number): DecisionRecord[]; /** * Append one decided verb call to the run-scoped action ledger (#126). * Written for refusals as well as approvals, and written *before* the * privileged half runs is deliberately NOT the contract: the entry carries * the resulting sha, so it is written once the outcome is known. */ appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry; /** Newest first. `runId` narrows to one run's own record. */ verbLedger(project: string, opts?: { runId?: string; issue?: number; limit?: number }): VerbLedgerEntry[]; /** * Claim the project's single merge slot, or `undefined` when another holder * has it. Locks older than `staleAfterMs` are broken first: a daemon killed * mid-merge must not wedge the project forever, and the exact-head recheck * makes a stolen lock safe — the second merge re-reads the live head anyway. */ acquireMergeLock( project: string, holder: string, prUrl: string, now: number, staleAfterMs: number, ): MergeLock | undefined; /** Release a lock this holder owns. A lock it does not own is left alone. */ releaseMergeLock(project: string, holder: string): void; /** The live lock, for `status` and for tests. */ mergeLock(project: string): MergeLock | undefined; /** * Append decided GitHub label changes to the projection outbox (#201). One * transaction: either every op lands as a pending row or none do, so the * daemon's record of its own intent cannot disagree with itself. Ops for one * issue apply in enqueue (id) order, which is what makes a swap atomic — the * remove lands before the add exactly as enqueued. */ enqueueLabelOps( project: string, ops: readonly { issue: number; op: "add" | "remove"; label: string }[], ): void; /** Unapplied ops whose retry backoff has elapsed, oldest first (#201). */ pendingLabelOps(project: string, now: number): LabelOp[]; /** Every unapplied op for one issue, enqueue order — the eligibility overlay * reads this so a stale GitHub label cannot outlive the daemon's intent * (#201). */ pendingLabelOpsFor(project: string, issue: number): LabelOp[]; /** The op applied; forget it. */ settleLabelOp(id: number): void; /** The op failed; count the attempt and park it behind `nextAttemptAt`. Pass * `countAttempt: false` for a refusal that is not the op's fault (a shared * rate limit), so it does not burn the backoff escalation (#208). */ deferLabelOp(id: number, error: string, nextAttemptAt: number, countAttempt?: boolean): void; /** Unapplied ops still owed — `status` shows the projection lag (#201). */ countPendingLabelOps(project: string): number; /** `createdAt` of the oldest unapplied op, or `undefined` for none. */ oldestPendingLabelOpAt(project: string): number | undefined; close(): void; } /** * How loudly to interrupt: tier 1 is "answer when you can" (the run is parked * and safe), tier 2 is "the fleet is stopped until you look". */ export type EscalationTier = 1 | 2; /** * A message to a human, carrying enough identity to find the run without * pasting logs into a chat window. */ export interface Escalation { tier: EscalationTier; project: string; issue: number; summary: string; detail?: string; runId?: string; /** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the * escalation pages now or is held for the digest under the project's * `reporting.interruptOn` policy (#229). */ category?: InterruptCategory; /** Deliver now even when policy would defer this category to the digest. * Used only when the digest's own orchestrator loop is unavailable. */ urgent?: true; } export interface HeldNotice { id: string; category: InterruptCategory; summary: string; detail: string; createdAt: number; /** * This notice was otherwise interruptible and was held only by the * availability window. It may be surfaced when the current policy opens. */ releaseOnAvailable?: true; /** Urgent recovery may bypass category batching, but never availability. */ urgent?: true; } export interface HeldNoticeDraft { /** Caller-supplied identity when the handoff must print or deduplicate it. */ id?: string; project: string; category: InterruptCategory; summary: string; detail: string; createdAt: number; releaseOnAvailable?: true; urgent?: true; } /** * Why the embedded orchestrator is down. `start-failed` covers a session that * never came up (the daemon's `startOrchestrator` threw); `crashed` covers a * session that died after a healthy start (the terminal event reached the * handle and it reported not alive). */ export type OrchestratorDownMode = "start-failed" | "crashed"; /** * The durable orchestrator-down incident, one per project, re-derived across * daemon restarts so a restart while still down cannot forget it. * * Rows persist while the incident is open and are removed on recovery. The * escalate path writes it from the store and the reconcile closes it against * the same store, so a daemon killed mid-incident leaves the row for the next * one — the records ledger advances only on an finished outcome, never on a * process that is gone. */ export interface OrchestratorIncident { project: string; mode: OrchestratorDownMode; /** Bounded, human-readable cause. Normally the start error or a "session * child exited N" note. */ cause?: string; /** Epoch ms when this incident began. Stable for the life of the row, and * the dedupe anchor: the down page and its recovery page both key on it. */ since: number; /** Tier-1 escalations diverted to an issue comment while it was down. */ diverted: number; } /** What {@link Store.openOrchestratorIncident} is handed. */ export interface OrchestratorIncidentDraft { project: string; mode: OrchestratorDownMode; cause?: string; since: number; } /** * One durable stop/restart provenance line for the shared daemon (#378). * * Host-wide on purpose: the daemon serves every configured project, so this * table is NOT partitioned by project — a record written by one project's CLI * must be readable from another project's `status` the moment the daemon is * down, exactly the situation the incidents in #378 left unattributable. * * Explicit fields only, and never credentials or environment values: the audit * incident was a stop nobody could attribute, and a "just serialise the * command line" implementation would leak tokens. Caller pid/uid and session * role are captured when a conductor process carried the request; the * external-signal fallback records `unattributed` because Linux exposes no * sender identity for a signal, and saying which daemon and which live runs * were affected is the honest capture of what the receiving process knew. */ export interface DaemonStop { id: string; /** Epoch ms the stop/restart was requested (mediated) or observed (fallback). */ at: number; /** * The operator-visible control path: "cli stop", "cli restart", * "cli stop via systemctl" (the mediated request, as actually delivered), * or "external signal" (the unattributed fallback). */ controlPath: string; /** Caller pid, when a conductor process carried the request. */ callerPid?: number; /** Caller uid, when knowable. */ callerUid?: number; /** The requesting session's role, when knowable (see {@link SESSION_ROLE_ENV}). */ role?: SessionRole; /** "project" when the request originated from one project; "global" otherwise. */ scope: "global" | "project"; /** The originating project, when project-scoped. */ project?: string; /** The daemon pid the stop/restart acted on, when one was known. */ daemonPid?: number; /** The daemon runtime directory, when the record is the receiving daemon's * own fallback. Host paths only — the field never carries secrets. */ runtimeDir?: string; /** Every configured project with its live-run count at request time. A * project-scoped stop of the shared daemon names the siblings here. */ affected: { project: string; live: number }[]; /** Non-secret reason; never credentials or environment values. */ reason: string; /** True only when no mediated request existed — the external-signal fallback. */ unattributed: boolean; } /** What a caller hands {@link Store.recordDaemonStop}. The store owns `id` and `at`. */ export type DaemonStopDraft = Omit; /** * Baseline limits used when a project omits `caps`. Data, not behaviour: kept * beside the type so the defaults cannot drift out of shape with it. */ export const DEFAULT_CAPS: Caps = { maxConcurrentWorkers: 2, maxConcurrentWorkersPerRepo: 1, dailySpendUsd: 25, // Derived as `dailySpendUsd / maxConcurrentWorkers` unless an operator names // a per-run allowance: bounded overshoot without changing how many workers // the fleet runs (#851). maxRunSpendUsd: null, // Off unless an operator names a window. A default threshold would need a // default window id, and guessing which allowance a fleet lives on is how a // guard silently watches the wrong meter (#110). planUsage: null, workerMaxTurns: 120, workerMaxTurnsCeiling: 240, workerWallClockMs: 90 * 60 * 1000, // Derived as a third of the wall-clock ceiling unless an operator names the // window: a hung run is caught with most of its budget unspent, while a // slow-but-writing session (measured fleet latency has reached // 1.5 min/turn) never trips it (#1086). workerStallSilenceMs: null, maxAttemptsPerIssue: 2, maxContinuationsPerIssue: 2, }; /** * The conductor-owned mutation verbs (#126). Data, for the reason every other * vocabulary here is data: the daemon's dispatch table, the ledger renderer and * the table-driven "every verb declares an allowed-caller set" test all * enumerate this list, so a seventh verb cannot be added to one of them alone. */ export const VERB_NAMES = [ "conductor_push", "conductor_pr_create", "conductor_pr_update_branch", "conductor_pr_update", "conductor_pr_merge", "conductor_label", "conductor_release", /** Request the fleet upgrade itself to a published conductor release. The * install shape gates it like a release shape; execution detaches from this * session and the daemon, and the first tick after the restart verifies. */ "conductor_install", /** Return one green, run-owned pull request to its worker with blocking * findings: durably record the revision, and the daemon resumes the SAME * OMP session on the existing branch and PR. No close, no reopen, no * redispatch. */ "conductor_pr_review", /** Record that the durable review findings standing at one exact head are * settled, so the #888 merge gate stops refusing that head (#913). A * separate verb, never an argument on the merge: the disposition is its own * recorded act, and it clears only rows older than itself. */ "conductor_pr_review_clear", /** The orchestrator-only, settled-run recovery operation (#806): open (or * adopt) the missing pull request for a terminal run whose stored branch * exists at its exact recorded head. The recovery proves everything a * worker's own `conductor_pr_create` took for granted from a live channel — * no worker is live, the routed repo matches, the remote branch is at the * exact recorded head, the issue is still open, and no PR already * represents that head — so a stranded `pushed-pending` / lost-PR run can * re-enter normal review/merge without a fresh clone. Idempotent: an * already-matching PR is returned, not duplicated. */ "conductor_pr_recover", /** The first read verb. It answers with the merge gate's own verdict, so * "pushed-green" is the dispatcher's reading of the PR rather than a claim the * worker makes about itself from whatever it happened to run. */ "conductor_pr_status", /** The second read verb (#1043 lane): the decisive log lines of the failing * jobs at one pull request's head, through the tracker's bounded * failed-attempt log read. A worker calls it to diagnose a red check it * must fix, instead of guessing from the check name. */ "conductor_ci_logs", ] as const; export type VerbName = (typeof VERB_NAMES)[number]; /** * Field names a verb request may never carry. * * Identity is derived from *which socket the connection arrived on* plus the * verified peer uid, so a payload that spells any of these is either a buggy * client or one probing for a daemon that trusts them — `turnLimitResponse` * trusting a body-supplied `project` on the unauthenticated HTTP port is the * failure mode this list exists to keep off the socket. Refused outright and * named in the refusal rather than ignored, because a client that is silently * stripped keeps sending them and nobody ever learns. */ export const RESERVED_VERB_FIELDS = [ "project", "run", "runId", "issue", "role", "uid", "caller", ] as const; export type ReservedVerbField = (typeof RESERVED_VERB_FIELDS)[number]; /** * Why a verb call ended the way it did. Closed, and matched by the ledger * renderer and by tests: a refusal an operator cannot name is a refusal nobody * can audit, and free-text reasons drift one wording per call site. */ export const VERB_REFUSALS = [ /** The socket's role is not in the verb's allowed-caller set. */ "role-not-allowed", /** The caller's role is not the holder configured in `authority`. */ "authority-holder", /** Claiming, ticks, or the fleet as a whole is stopped. */ "fleet-paused", /** The run row this socket belongs to is no longer live. */ "run-not-live", /** The request carried a {@link RESERVED_VERB_FIELDS} field. */ "identity-in-payload", /** An argument this verb does not declare — closed by default, never ignored. */ "unknown-argument", /** A declared argument was missing or the wrong type. */ "malformed-argument", /** `reason` was not a member of the verb's closed enum (#129). */ "reason-not-in-enum", /** A ref other than the run's own branch. */ "ref-not-run-branch", /** The base branch is not the repo's configured `defaultBranch`. */ "base-not-default-branch", /** An open pull request already closes this issue (#25's guard). */ "open-pr-exists", /** The tracker could not say whether one does. Fail closed. */ "open-pr-lookup-error", /** The named pull request is not this run's, or not this project's. */ "pr-not-this-run", /** An unrecorded recovery PR was authorized, but not for these exact inputs. */ "recovery-authorization-mismatch", /** The recovery target resolved to a run that does not exist in the store. */ "recovery-no-run", /** The run the recovery was pointed at still has a live worker on it. */ "recovery-run-live", /** The run is terminal but not in a state the settlement sweep owns, so a * recovered PR would never re-enter verification/review/merge. */ "recovery-unsettled-state", /** The tracker could not say whether the issue is open. Recovery fails * closed rather than opening a PR beside (or for) a possibly-closed * issue. */ "issue-state-unreadable", /** The recovered work has already landed or been declined: the issue is * closed, the run settled merged, or its recorded PR merged. Nothing to * recover, and recovery must not reopen it. */ "recovery-issue-resolved", /** The run records no exact 40-hex head to compare its stored branch * against, so there is nothing durable to verify a recovered PR would * publish. */ "recovery-unrecorded-head", /** The branch exists but not at the run's recorded head (replaced, * force-pushed or superseded by a live PR), so a created PR would publish * work the daemon never verified. */ "recovery-head-mismatch", /** The routed repository a terminal run recorded no longer has an entry in * this project's routing, so the daemon cannot create a PR for it. */ "recovery-repo-unrouted", /** A live mutation lease already holds a file the preserved branch carries * (#925): publishing it now would put a frozen diff into review beside a * worker still rewriting the same file. Retryable — the lease releases when * that worker settles. */ "recovery-lane-occupied", /** The lane a recovery would publish could not be read (#925). Unlike * admission's fail-open interlock, a single deliberate recovery call refuses * on "could not tell" rather than publishing on an unproven lane. */ "recovery-lane-unprovable", /** The run's durable `prUrl` is a non-URL value the tracker cannot address * (other than the normalized `pending` settlement sentinel, #866). There is * no PR to read and none to guess at — a corrupt record, not an unreadable * one, so it refuses by name instead of reading as a never-clearing * `head-unresolvable`. */ "recorded-pr-corrupt", /** The run has no pull request to act on. */ "pr-missing", /** * The pull request is closed, merged, or its state could not be read. * Distinct from {@link VERB_REFUSALS} `checks-not-green` on purpose: "you * cannot update a merged PR" and "this PR is red" invite different next * moves, and a ledger that spelled both the same way would lose that. */ "pr-not-open", /** `headSha` does not equal the live head at execution time. */ "head-stale", /** The live head could not be read at all. Fail closed. */ "head-unresolvable", /** Required checks are pending or red at the live head, or the PR is not open. */ "checks-not-green", /** Another merge is in flight for this project. */ "merge-in-flight", /** * The run backing a PR is not in a revisable settled-green state: a review * revision is already in flight (queued or dispatched), a worker is still * running on it, or the run settled otherwise. The detail names the state. */ "review-in-flight", /** * The PR has already been through the configured review-round ceiling * (#678): the policy's hard bound against endless polishing. Refused rather * than recorded — the orchestrator leaves the PR open, records the * unresolved findings and escalates once instead of spending a worker round. */ "review-round-ceiling", /** * The routed repository's base branch is frozen because a watched merge (or * live base observation) turned it red — a base-red-freeze. Further merges to * that repo stay refused until the base is observed green again or the * operator lifts the freeze with `omp-conductor unfreeze `; sibling * repos are unaffected. */ "base-red-freeze", /** * The requested merge head carries durable, unresolved review evidence * (#888): a review revision for this PR that is queued or crashed at * exactly that head, or one settled `failed` there. Green checks do not * clear it — the head must be re-reviewed with `conductor_pr_review` or * superseded by a corrected push to a different head. */ "merge-blocked-by-review", /** * A review clearance was asked for at a head where nothing blocks (#913). * A clearance is a disposition of specific evidence, so recording one "just * in case" — before a round exists, or after a corrected push moved the * head — is refused rather than stored as a standing pass. */ "review-clearance-vacuous", /** * The project has an active release composition (#850) — a declared * campaign plus the exact PRs allowed into it — and this PR is neither, * with no operator override recorded for it. Merging would smuggle * unrelated work into an assembling release. Refused before the merge lock * and any tracker call; the guard clears only by completing or cancelling * the release, or recording an explicit per-PR override through * `omp-conductor release-composition`, never through the merge reason. */ "outside-active-release", /** The label is not in this project's own vocabulary. */ "label-not-in-vocabulary", /** The label is a lifecycle label; those transitions stay the daemon's (#26). */ "label-is-lifecycle", /** The label is the operator's park gesture; only the operator may set or * clear it, orchestrator and worker alike (#507). */ "label-is-operator-owned", /** Adding the queue label was refused because the issue carries a clearly * delimited write-lane section heading that parsed no path-like files * (#825): promoting it would fail open beside overlapping work, so the * verb refuses with an actionable syntax error instead of echoing a * fail-open the heading contradicts. */ "file-lane-unparseable", /** * Adding the queue label was refused because the issue carries a durable * `promotable` grooming verdict whose dispatch brief no longer provably * matches the issue (#1036): the current write lane differs from the * verdict's `fileLane`, the evidence does not recover as a strict * PROMOTABLE result, or the issue could not be read to compare at all. The * durable verdict is the admission contract, so promotion fails closed * before any tracker mutation or daemon wake; the remediation is applying * the verdict's `proposedBrief` to the issue explicitly, never a silent * rewrite, and then adding the label again. */ "promotion-brief-mismatch", /** The release grant does not permit this shape for this caller. */ "release-not-granted", /** The artefact or environment is not one this project declared (#129). */ "release-target-not-declared", /** A shape the daemon holds no credential for and will not pretend to cut. */ "release-shape-not-executable", /** Config unreadable, project unknown, repo unrouted. Fail closed. */ "config-unreadable", /** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */ "chain-conflict", /** The PR diff or the base-branch chain could not be read — fail closed rather * than merge blind into a chain that might be about to fork (#227). */ "chain-unreadable", /** The privileged half ran and the underlying command failed. */ "action-failed", ] as const; export type VerbRefusal = (typeof VERB_REFUSALS)[number]; /** How a verb call was decided. `allowed` means the privileged half then ran. */ export const VERB_DECISIONS = ["allowed", "refused"] as const; export type VerbDecision = (typeof VERB_DECISIONS)[number]; /** * One line of the run-scoped action ledger (#126): what was asked, by whom, * what the daemon decided, and what came back. * * Written for refusals as well as approvals — a ledger that records only what * happened cannot answer "what did it try", which is the question an escalation * actually asks. */ export interface VerbLedgerEntry { id: string; project: string; /** The run whose socket carried the call. Absent for the orchestrator's. */ runId?: string; /** The issue that run is servicing, or the one a verb targeted. */ issue?: number; verb: VerbName; /** Resolved from the channel, never from the payload. */ role: SessionRole; /** The arguments as received, redacted of nothing: this is the audit record. */ args: Record; decision: VerbDecision; /** Present exactly when `decision` is `"refused"`. */ refusal?: VerbRefusal; /** What the caller was told, verbatim. */ detail: string; /** The commit, tag or merge sha the approved call produced, if any. */ sha?: string; at: number; } /** What a caller hands the ledger. The store owns `id` and `at`. */ export type VerbLedgerDraft = Omit; /** * The single-flight merge claim for one project, held in the store because the * CLI, the daemon and every session are separate processes: an in-memory mutex * would serialise one process against itself and nothing else (#126). */ export interface MergeLock { project: string; /** Opaque holder id — the run id, or the orchestrator channel's id. */ holder: string; prUrl: string; at: number; }