omp-conductor 0.3.24 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -7,6 +7,32 @@
7
7
  * is `DEFAULT_CAPS`, which is data, not behaviour.
8
8
  */
9
9
 
10
+ /**
11
+ * A threshold on one provider-reported allowance window.
12
+ *
13
+ * Both fields exist because of what the real `omp usage --json` payload looks
14
+ * like. `limits` is a *list* — one Anthropic account reports `anthropic:5h`,
15
+ * `anthropic:7d` and `anthropic:7d:fable` at once — so the window is named
16
+ * rather than positional. And `unit` differs per provider (`percent` for
17
+ * Anthropic, `unknown` with raw counts for `xai-oauth`), so the threshold is a
18
+ * fraction of the allowance and never a raw count.
19
+ */
20
+ export interface PlanUsageCap {
21
+ /**
22
+ * Which allowance this threshold is about: the fully-qualified limit id
23
+ * (`"anthropic:7d"`), or a bare window key (`"7d"`) when exactly one
24
+ * reported allowance carries it. An id nothing reports holds dispatch and
25
+ * says so — it is never quietly treated as unmetered.
26
+ */
27
+ windowId: string;
28
+ /**
29
+ * Consumed share, `0`–`1`, at which new claims stop. `0.85` holds at 85% of
30
+ * the allowance. A fraction rather than a percentage so the config cannot
31
+ * read `85` and mean "never".
32
+ */
33
+ maxUsedFraction: number;
34
+ }
35
+
10
36
  /**
11
37
  * Hard limits enforced in code, never by the model. A worker that is asked to
12
38
  * respect a budget will eventually talk itself out of it, so the dispatcher
@@ -21,6 +47,13 @@ export interface Caps {
21
47
  * still apply). `0` is a hard stop — deliberate, not "unset".
22
48
  */
23
49
  dailySpendUsd: number | null;
50
+ /**
51
+ * Subscription/plan allowance guard, independent of `dailySpendUsd` and
52
+ * enforced from the provider's own reported usage rather than from a dollar
53
+ * figure conductor made up (#110). `null` means unmetered: the fleet has no
54
+ * plan allowance worth guarding, or none this host can read.
55
+ */
56
+ planUsage: PlanUsageCap | null;
24
57
  /** Turn ceiling for one worker — catches loops that are burning tokens
25
58
  * without converging. */
26
59
  workerMaxTurns: number;
@@ -97,16 +130,94 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
97
130
  export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
98
131
 
99
132
  /**
100
- * Mechanical permission for release-shaped tool calls. `authority.release`
101
- * says who owns the decision; this key is the enforcement gate that decides
102
- * whether an autonomous session may invoke the tools at all.
133
+ * The irreversible tool-call shapes this package recognises and gates. Data
134
+ * here rather than in `release-policy.ts` because the config validator, the
135
+ * setup wizard and the status renderer now all enumerate them (#122): a sixth
136
+ * shape must fail to compile in each of those places rather than silently
137
+ * resolve to "not granted" in one of them.
138
+ *
139
+ * Deliberately generic. Fleet-specific release topology — a suite pin, a
140
+ * registry, an IaC stack — belongs in the operator's POLICY.md, not here: a
141
+ * conductor installed with no npm and no Kubernetes must neither carry those as
142
+ * dead config nor be asked about them by the wizard.
143
+ */
144
+ export const RELEASE_SHAPES = [
145
+ "git-tag",
146
+ "git-push-tags",
147
+ "package-publish",
148
+ "github-release",
149
+ "deploy",
150
+ ] as const;
151
+
152
+ export type ReleaseShape = (typeof RELEASE_SHAPES)[number];
153
+
154
+ /**
155
+ * Mechanical permission per release shape. `authority.release` says who owns
156
+ * the *decision*; this key is the enforcement gate that decides whether an
157
+ * autonomous session may invoke the tools at all.
158
+ *
159
+ * A map rather than the binary it replaced: under the old `"operator-brief"`
160
+ * one grant covered cutting a tag and mutating a running environment, and on
161
+ * 2026-08-09 a stale grant was enough for an orchestrator session to invoke
162
+ * Komodo `DeployStack` (#122). Producing an artifact and changing what is
163
+ * running are different acts with different blast radii and different rollback
164
+ * owners, so they are granted separately or not at all.
165
+ *
166
+ * Legacy string forms still parse and are migrated at load; absent keys deny.
167
+ */
168
+ export type ReleasePolicy = "none" | Partial<Record<ReleaseShape, AuthorityHolder>>;
169
+
170
+ /** After normalisation: every shape present, absent keys denied as `"human"`. */
171
+ export type ResolvedGrants = Record<ReleaseShape, AuthorityHolder>;
172
+
173
+ /**
174
+ * The on-disk spellings from before the grant map. Read and migrated, never
175
+ * written again — data so the validator's error message and the migration table
176
+ * cannot come to name different sets.
177
+ */
178
+ export const LEGACY_RELEASE_POLICIES = ["none", "operator-brief"] as const;
179
+
180
+ /**
181
+ * Fail-closed baseline: nothing granted to any session. Written out rather than
182
+ * derived from {@link RELEASE_SHAPES} so `Record` makes a new shape a compile
183
+ * error here — a shape this table forgot would read as `undefined` at the gate,
184
+ * and `undefined !== role` is only accidentally safe.
185
+ */
186
+ export const DENIED_RELEASE_GRANTS: ResolvedGrants = {
187
+ "git-tag": "human",
188
+ "git-push-tags": "human",
189
+ "package-publish": "human",
190
+ "github-release": "human",
191
+ deploy: "human",
192
+ };
193
+
194
+ /**
195
+ * What the legacy `"operator-brief"` string migrates to: the safe reading of
196
+ * what operators believed they were enabling when they opened the binary gate.
197
+ * `deploy` stays with the human, because that is the grant nobody knowingly
198
+ * gave — see #122.
103
199
  */
104
- export const RELEASE_POLICIES = ["none", "operator-brief"] as const;
200
+ export const OPERATOR_BRIEF_GRANTS: ResolvedGrants = {
201
+ "git-tag": "orchestrator",
202
+ "git-push-tags": "orchestrator",
203
+ "package-publish": "orchestrator",
204
+ "github-release": "orchestrator",
205
+ deploy: "human",
206
+ };
105
207
 
106
- export type ReleasePolicy = (typeof RELEASE_POLICIES)[number];
208
+ /**
209
+ * What kind of session is asking. Explicit at every gate rather than inferred
210
+ * from confinement or from which module made the call: a worker's refusal must
211
+ * not depend on a boolean some future call site forgets to pass.
212
+ *
213
+ * Note the two sets: a grant is an {@link AuthorityHolder}, never a
214
+ * `SessionRole`, so `"worker"` can never equal a grant and a worker is refused
215
+ * every shape whatever the config says. The type does that enforcing, not a
216
+ * check some path can miss.
217
+ */
218
+ export const SESSION_ROLES = ["worker", "orchestrator"] as const;
107
219
 
108
- /** Safe for old and partial configs: release/deploy tools stay closed. */
109
- export const DEFAULT_RELEASE_POLICY: ReleasePolicy = "none";
220
+ export type SessionRole = (typeof SESSION_ROLES)[number];
110
221
 
111
222
  /**
112
223
  * Who holds an authority the daemon itself never exercises. Declared as data
@@ -125,6 +236,290 @@ export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
125
236
  */
126
237
  export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
127
238
 
239
+ /**
240
+ * Whether a merge candidate must be up to date with the branch it targets.
241
+ *
242
+ * Declared as data for the reason {@link AUTHORITY_HOLDERS} is, and extracted
243
+ * from POLICY.md prose for a sharper one (#129): until this key existed the
244
+ * answer was a sentence an operator wrote and a session re-read every tick, so
245
+ * two ticks could reach two answers and neither left a trace of having decided.
246
+ * A set-membership check leaves the same trace every time.
247
+ */
248
+ export const BASE_FRESHNESS = ["up-to-date", "any"] as const;
249
+
250
+ export type BaseFreshness = (typeof BASE_FRESHNESS)[number];
251
+
252
+ /**
253
+ * What a draft pull request is to the merge gate. `block` by default: a draft is
254
+ * the author's own statement that the work is not finished, and an autonomous
255
+ * merge that overrides that has read the diff rather than the intent.
256
+ */
257
+ export const DRAFT_POLICIES = ["block", "allow"] as const;
258
+
259
+ export type DraftPolicy = (typeof DRAFT_POLICIES)[number];
260
+
261
+ /**
262
+ * What to do with an otherwise-mergeable pull request that has fallen behind its
263
+ * base branch.
264
+ *
265
+ * `update-branch` is the package floor's one sanctioned write into a worker's
266
+ * branch — `gh pr update-branch`, then a wait for the fresh run — and it is the
267
+ * default because merging promptly is itself what stops the next PR going stale.
268
+ * `hold` leaves it for whoever refreshes it; `escalate` pages rather than
269
+ * guessing. Closing it and an admin bypass are absent on purpose: neither was
270
+ * ever an answer, and a vocabulary that cannot spell them cannot be argued into
271
+ * one.
272
+ */
273
+ export const BEHIND_BASE_ACTIONS = ["update-branch", "hold", "escalate"] as const;
274
+
275
+ export type BehindBaseAction = (typeof BEHIND_BASE_ACTIONS)[number];
276
+
277
+ /**
278
+ * What must already have landed before a release may be cut. Each member is
279
+ * something the daemon can settle against its own store or the tracker — that is
280
+ * the entry price for being in this list rather than in POLICY.md.
281
+ */
282
+ export const RELEASE_REQUIREMENTS = [
283
+ /** Every run this release covers reached `merged`, not merely `pushed-green`. */
284
+ "runs-settled",
285
+ /** No pull request is still open against the branch being released. */
286
+ "no-open-prs",
287
+ /** Nothing still carries the queue label: the batch is finished, not paused. */
288
+ "queue-drained",
289
+ /** The epic this release closes has no open children left. */
290
+ "epic-children-closed",
291
+ ] as const;
292
+
293
+ export type ReleaseRequirement = (typeof RELEASE_REQUIREMENTS)[number];
294
+
295
+ /**
296
+ * What must be true before a pull request may be merged.
297
+ *
298
+ * These four were sentences in the shipped POLICY.md until #129, which meant a
299
+ * verb could honour them only by asking a model to read prose and agree with it.
300
+ * Typed here, the decision is a pure function of config: the same PR gets the
301
+ * same answer on Friday as on Tuesday, and a refusal can name the field that
302
+ * produced it.
303
+ */
304
+ export interface MergePreconditions {
305
+ /**
306
+ * Named checks that must have concluded successfully. Empty means *every*
307
+ * check the pull request reports — the strict reading, and the one a project
308
+ * that never answered the question wants.
309
+ */
310
+ requiredChecks: string[];
311
+ /** Whether the head must be up to date with its base. See {@link BASE_FRESHNESS}. */
312
+ baseFreshness: BaseFreshness;
313
+ /** What a draft pull request is. See {@link DRAFT_POLICIES}. */
314
+ drafts: DraftPolicy;
315
+ /**
316
+ * What to do when `baseFreshness` is `"up-to-date"` and the head is not.
317
+ * Consulted only then: under `"any"` there is no behind-base case to answer.
318
+ */
319
+ whenBehindBase: BehindBaseAction;
320
+ }
321
+
322
+ /**
323
+ * What must be true before a release may be cut: what has landed, what it
324
+ * produces, and where it may be pushed.
325
+ *
326
+ * `artefacts` and `environments` are empty by default, and empty denies. A
327
+ * project that never named an artefact has not authorised releasing one, and a
328
+ * project that named no environment has authorised no deploy target — the same
329
+ * posture as an absent release grant (#122). The config says yes out loud, or it
330
+ * has not said yes.
331
+ */
332
+ export interface ReleasePreconditions {
333
+ /** What must already have landed. See {@link RELEASE_REQUIREMENTS}. */
334
+ requires: ReleaseRequirement[];
335
+ /**
336
+ * Named checks that must be green on the branch being released. Empty means
337
+ * every check that branch reports.
338
+ */
339
+ requiredChecks: string[];
340
+ /** Packages or images this project releases. Empty denies every artefact. */
341
+ artefacts: string[];
342
+ /** Environments a deploy may target. Empty denies every environment. */
343
+ environments: string[];
344
+ }
345
+
346
+ /**
347
+ * The gating conditions #126's verbs read instead of POLICY.md. Optional on disk
348
+ * and complete after the loader has seen it, so no consumer has to know which of
349
+ * these fields the operator happened to spell.
350
+ */
351
+ export interface ProjectPolicy {
352
+ merge: MergePreconditions;
353
+ release: ReleasePreconditions;
354
+ }
355
+
356
+ /**
357
+ * What a project that never answered the question gets: the strictest reading of
358
+ * the prose these fields replaced. Every check green, the base fresh, drafts
359
+ * refused, a behind-base PR refreshed rather than merged, and a release only of
360
+ * work that actually landed — with no artefact and no environment declared, so a
361
+ * release verb has nothing to act on until an operator says what this project
362
+ * ships.
363
+ */
364
+ export const DEFAULT_PROJECT_POLICY: ProjectPolicy = {
365
+ merge: {
366
+ requiredChecks: [],
367
+ baseFreshness: "up-to-date",
368
+ drafts: "block",
369
+ whenBehindBase: "update-branch",
370
+ },
371
+ release: {
372
+ requires: ["runs-settled"],
373
+ requiredChecks: [],
374
+ artefacts: [],
375
+ environments: [],
376
+ },
377
+ };
378
+
379
+ /**
380
+ * Why a merge is being asked for. A closed set rather than free text, for the
381
+ * reason every other vocabulary here is closed: a `reason` the daemon matches on
382
+ * is a field that decides, and a decision made out of a model's own wording is
383
+ * one no two ticks spell the same way.
384
+ */
385
+ export const MERGE_REASONS = [
386
+ /** Every condition in {@link MergePreconditions} is satisfied as it stands. */
387
+ "preconditions-met",
388
+ /** It was behind its base, was refreshed, and came back green. */
389
+ "behind-base-refreshed",
390
+ /** A human in the loop asked for this one specifically. */
391
+ "operator-instructed",
392
+ /** It is what a release otherwise ready to cut is waiting on. */
393
+ "release-blocking",
394
+ ] as const;
395
+
396
+ export type MergeReason = (typeof MERGE_REASONS)[number];
397
+
398
+ /** Why a release is being cut now. Closed for the reason {@link MERGE_REASONS} is. */
399
+ export const RELEASE_REASONS = [
400
+ /** The batching unit the operator described in POLICY.md has been reached. */
401
+ "batch-complete",
402
+ /** The epic this release closes has no open children left. */
403
+ "epic-closed",
404
+ /** A fix that cannot wait for the batch. */
405
+ "hotfix",
406
+ /** A human in the loop asked for this one specifically. */
407
+ "operator-instructed",
408
+ ] as const;
409
+
410
+ export type ReleaseReason = (typeof RELEASE_REASONS)[number];
411
+
412
+ /**
413
+ * Why a tracker label is being changed. Closed for the reason
414
+ * {@link MERGE_REASONS} is, and load-bearing past bookkeeping: the queue label is
415
+ * the claim gate, so "why did this issue become claimable" has to be answerable
416
+ * from the ledger rather than from a session transcript nobody kept.
417
+ */
418
+ export const LABEL_REASONS = [
419
+ /** Groomed and promoted onto the queue. */
420
+ "promoted-to-queue",
421
+ /** Re-briefed after a failed attempt, and put back on the queue. */
422
+ "re-briefed",
423
+ /** Parked: it needs a decision only a human can make. */
424
+ "needs-human",
425
+ "duplicate",
426
+ "superseded",
427
+ "out-of-scope",
428
+ ] as const;
429
+
430
+ export type LabelReason = (typeof LABEL_REASONS)[number];
431
+
432
+ /**
433
+ * A model-supplied justification: one value out of a closed set, plus prose that
434
+ * is written down and read by nothing that decides.
435
+ *
436
+ * The split is the whole point. `reason` is matched, so it is an enum the daemon
437
+ * enumerates. `rationale` is logged verbatim for the human who later asks why,
438
+ * and nothing branches on it — the moment something does, the vocabulary stops
439
+ * being the enum above and becomes whatever a model felt like typing.
440
+ */
441
+ export interface VerbJustification<R extends string> {
442
+ reason: R;
443
+ /** Free-form. Logged, never parsed, never matched. */
444
+ rationale?: string;
445
+ }
446
+
447
+ export type MergeJustification = VerbJustification<MergeReason>;
448
+
449
+ export type ReleaseJustification = VerbJustification<ReleaseReason>;
450
+
451
+ export type LabelJustification = VerbJustification<LabelReason>;
452
+
453
+ /**
454
+ * How hard a boundary separates model-executed code from the operator's GitHub
455
+ * write credential (#125).
456
+ *
457
+ * Three values, ordered strongest first, because the difference between them is
458
+ * a difference in what anyone can honestly claim:
459
+ *
460
+ * - `per-run` — every session runs as its own OS principal that cannot read the
461
+ * daemon's `gh` config, keychain, `~/.ssh` or `~/.npmrc`, and cannot write a
462
+ * sibling run's checkout. Satisfied **only** by the `uid-pool` and
463
+ * `sandbox-exec` mechanisms.
464
+ * - `group-mode` — same uid as the daemon, cross-run separation by group and
465
+ * mode only. It bounds accidents and does **not** contain a determined bash
466
+ * escape. It exists as a config value rather than as a fallback so that an
467
+ * operator who takes it has said the weaker sentence out loud: a `per-run`
468
+ * request that quietly resolved to this would let somebody believe they had
469
+ * configured the full boundary and shipped the lesser one, which is precisely
470
+ * the "never the silent default" failure #125 exists to end.
471
+ * - `none` — the session runs as the daemon's own user and the env scrubbing in
472
+ * `credentials.ts` is *all* that stands between the model and the credential,
473
+ * which same-uid code defeats in one line.
474
+ *
475
+ * A request is never downgraded. If the host cannot build what was asked for,
476
+ * dispatch refuses and names the missing mechanism.
477
+ */
478
+ export const CREDENTIAL_ISOLATIONS = ["per-run", "group-mode", "none"] as const;
479
+
480
+ export type CredentialIsolation = (typeof CREDENTIAL_ISOLATIONS)[number];
481
+
482
+ /**
483
+ * What the host can actually enforce, decided by the startup probe rather than
484
+ * by config. Kept separate from {@link CREDENTIAL_ISOLATIONS} on purpose: the
485
+ * operator asks for a boundary, the host says which one it can build, and
486
+ * `status` reports the difference instead of letting either side guess.
487
+ *
488
+ * - `uid-pool` — one unprivileged account per run slot, entered through a
489
+ * privilege-dropping launcher. The only mechanism that claims to contain a
490
+ * determined bash escape on Linux.
491
+ * - `group-mode` — same uid as the daemon, cross-run separation by group and
492
+ * mode only. Bounds accidents, does **not** contain an escape.
493
+ * - `sandbox-exec` — the macOS equivalent of `uid-pool` for a dev host.
494
+ * - `none` — no boundary at all.
495
+ */
496
+ export const ISOLATION_MECHANISMS = ["uid-pool", "group-mode", "sandbox-exec", "none"] as const;
497
+
498
+ export type IsolationMechanism = (typeof ISOLATION_MECHANISMS)[number];
499
+
500
+ /**
501
+ * The two groups the filesystem model needs, named once so the probe, the
502
+ * provisioning docs and the adversarial tests cannot drift apart (#125).
503
+ *
504
+ * `daemon` holds the daemon account **only** and is what lets it fetch, salvage
505
+ * and reclaim every run repo. `runs` holds every slot principal and grants
506
+ * read-only access to the shared mirror. A slot principal in `daemon` would
507
+ * void the whole cross-run boundary, so the probe asserts the absence.
508
+ */
509
+ export const CONDUCTOR_GROUPS = { daemon: "conductor-daemon", runs: "conductor-runs" } as const;
510
+
511
+ /** Per-project credential boundary settings. See {@link CREDENTIAL_ISOLATIONS}. */
512
+ export interface CredentialConfig {
513
+ isolation: CredentialIsolation;
514
+ /**
515
+ * Optional **read-scoped** token handed to sessions so they can look things
516
+ * up on GitHub. Absent is the default and the safe reading: a worker then
517
+ * works from its dispatch brief and the daemon's mediated verbs, which is
518
+ * why the brief must not tell it to run `gh pr view`.
519
+ */
520
+ readToken?: string;
521
+ }
522
+
128
523
  /**
129
524
  * Where the session that triages escalations lives. `embedded` is the daemon's
130
525
  * own child session; `external` means an operator already runs one — a visible
@@ -174,21 +569,55 @@ export interface ProjectConfig {
174
569
  */
175
570
  authority: { merge: AuthorityHolder; release: AuthorityHolder };
176
571
  /**
177
- * Tool-call enforcement for releases and deploys. Optional only for configs
178
- * written before the tripwire existed; omission resolves fail-closed to
179
- * {@link DEFAULT_RELEASE_POLICY}.
572
+ * Per-shape tool-call enforcement for releases and deploys. Optional only for
573
+ * configs written before the tripwire existed; both omission and any shape key
574
+ * this map leaves out resolve fail-closed to `"human"`.
575
+ *
576
+ * Never read directly — go through `resolveReleaseGrants`, which is where the
577
+ * legacy `"none"` / `"operator-brief"` strings are migrated and where the
578
+ * absent-key denial is spelled once.
180
579
  */
181
580
  releasePolicy?: ReleasePolicy;
581
+ /**
582
+ * The gating conditions a merge or a release must satisfy (#129), extracted
583
+ * from POLICY.md so a verb decides from typed config rather than from prose it
584
+ * re-interprets every tick.
585
+ *
586
+ * Optional only for configs written before the key existed. The loader always
587
+ * produces a complete value, so read it through `resolvePolicy` rather than
588
+ * reaching for `.merge` on a project some test hand-built.
589
+ */
590
+ policy?: ProjectPolicy;
182
591
  /**
183
592
  * How loud the orchestrator is. Optional on disk — a config written before
184
593
  * this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
185
594
  * `resolveReportScope` rather than reaching for `.scope` directly.
186
595
  */
187
596
  reporting?: { scope: ReportScope };
597
+ /**
598
+ * The credential boundary this project's sessions run behind (#125).
599
+ *
600
+ * Optional only on disk, and only for one release: a config written before
601
+ * this key existed is migrated to an explicit `{ isolation: "none" }` and
602
+ * rewritten, so the operator ends up with the answer in the file rather than
603
+ * inheriting a silent default. Never read directly — go through
604
+ * `resolveCredentials`, which is where that migration is spelled once.
605
+ */
606
+ credentials?: CredentialConfig;
188
607
  /** Parent directory for per-run worktrees. */
189
608
  workspaceRoot: string;
190
609
  /** Cache of bare clones, so N runs share one fetch instead of N. */
191
610
  mirrorRoot: string;
611
+ /**
612
+ * Extra roots an orchestrator session may *read*, on top of the state
613
+ * directory and its own briefs. Absolute (or `~`-expandable) paths only,
614
+ * rejected at load rather than resolved against a cwd nobody can name.
615
+ *
616
+ * It cannot widen the gate past the roots #127 exists to close: the denied
617
+ * list — every worker checkout, the mirror cache, this package's own
618
+ * install — outranks anything named here.
619
+ */
620
+ orchestratorReadPaths?: string[];
192
621
  }
193
622
 
194
623
  /**
@@ -244,6 +673,75 @@ export interface PrVerification {
244
673
  reason: string;
245
674
  }
246
675
 
676
+ /**
677
+ * One file in a pull request's diff, in git's own terms rather than any
678
+ * tracker's. The settlement audit (#128) has to tell a deleted test file from a
679
+ * renamed one, and only the rename header says which — so `status` and
680
+ * `previousPath` are load-bearing, not decoration.
681
+ */
682
+ export interface PrDiffFile {
683
+ path: string;
684
+ /** Pre-image path, set only for a rename. */
685
+ previousPath?: string;
686
+ status: "added" | "modified" | "removed" | "renamed";
687
+ /** The file's unified-diff hunks verbatim, absent for a binary, mode-only or
688
+ * pure-rename change that has none. */
689
+ hunks?: string;
690
+ }
691
+
692
+ /** A pull request's diff, as the settlement audit reads it. */
693
+ export interface PrDiff {
694
+ files: PrDiffFile[];
695
+ /** True when the adapter had to cut the diff short. The audit's silence is
696
+ * then only silence about the part it saw, and it says so. */
697
+ truncated: boolean;
698
+ }
699
+
700
+ /**
701
+ * What the settlement audit can say about a worker's own account of its work.
702
+ *
703
+ * Declared as data for the same reason `REPORT_SCOPES` is: the analyser, the
704
+ * settlement report and the orchestrator brief all enumerate these, and a
705
+ * vocabulary that lives in three places drifts in three directions.
706
+ */
707
+ export const SETTLEMENT_FLAG_KINDS = [
708
+ /** The PR touched a file the report's `changed:` line never mentioned — the
709
+ * direction that matters, an undisclosed edit. */
710
+ "undisclosed-file",
711
+ /** The report carried no usable `changed:` line, so nothing was disclosed. */
712
+ "changed-line-missing",
713
+ /** `changed:` named a path the PR never touched. The weaker direction. */
714
+ "unmatched-claim",
715
+ /** A test file left the tree and no rename in the PR accounts for it. */
716
+ "test-file-deleted",
717
+ /** A skip/only/focus marker appears on a line the PR added. */
718
+ "test-disabled",
719
+ /** Assertions were commented out, or more left the file than entered it. */
720
+ "assertions-removed",
721
+ /** A named timeout in a test file went up. */
722
+ "test-timeout-raised",
723
+ ] as const;
724
+
725
+ export type SettlementFlagKind = (typeof SETTLEMENT_FLAG_KINDS)[number];
726
+
727
+ /**
728
+ * One advisory finding about a settled run. Advisory is the whole design: a
729
+ * flag never changes a run's state, because the judgement it invites — "was
730
+ * deleting that test correct?" — needs context the daemon does not have.
731
+ */
732
+ export interface SettlementFlag {
733
+ kind: SettlementFlagKind;
734
+ /** Repo-relative path, or `(report)` for a finding about the report itself. */
735
+ file: string;
736
+ /** 1-based line in the side of the diff the evidence came from: the
737
+ * post-image for an added line, the pre-image for a removed one. */
738
+ line?: number;
739
+ detail: string;
740
+ /** True when the dispatching issue never names this file — the "tests you
741
+ * didn't write" case, which is what makes a flag loud rather than routine. */
742
+ unattributed?: boolean;
743
+ }
744
+
247
745
  /** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
248
746
  export type IssueState = "open" | "closed";
249
747
 
@@ -327,6 +825,17 @@ export interface Tracker {
327
825
  * head, and has a non-empty terminal-success check rollup.
328
826
  */
329
827
  verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
828
+ /**
829
+ * The pull request's diff, or undefined when this adapter could not produce
830
+ * one — an unparseable URL, a deleted PR, a flaky network.
831
+ *
832
+ * Undefined never means "nothing changed". Its only consumer is the
833
+ * settlement audit (#128), which is advisory, so a failure here costs one
834
+ * unaudited settlement and never a run's state. That is also why it does not
835
+ * retry: unlike {@link Tracker.verifyPr}, nothing downstream is waiting for a
836
+ * better answer on a later tick.
837
+ */
838
+ prDiff(url: string): Promise<PrDiff | undefined>;
330
839
  }
331
840
 
332
841
  /**
@@ -373,10 +882,26 @@ export interface RunRecord {
373
882
  prUrl?: string;
374
883
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
375
884
  headSha?: string;
885
+ /** Commit this run's uncommitted work was preserved as before its worktree
886
+ * was removed, on the run's own branch. Absent means the daemon found
887
+ * nothing to save, or never looked — see {@link RunRecord.salvageError}. */
888
+ salvageSha?: string;
889
+ /** Why the salvage failed. Present means the worktree still holds the only
890
+ * copy of real work, so the tree was kept and the issue is held out of
891
+ * dispatch until an operator acknowledges it. */
892
+ salvageError?: string;
893
+ /** When an operator accepted the loss or recovered the tree by hand
894
+ * (`unblock --force`). Clears the hold without erasing what happened. */
895
+ salvageAckAt?: number;
376
896
  startedAt: number;
377
897
  endedAt?: number;
378
898
  /** Last failure text, surfaced verbatim in escalations. */
379
899
  lastError?: string;
900
+ /** Advisory settlement-audit findings for this attempt (#128). Deliberately
901
+ * never consulted by anything that decides `state`: a flagged run settles
902
+ * exactly as an unflagged one does, and the flags are evidence for whoever
903
+ * reviews the PR. Absent means the audit found nothing, or never ran. */
904
+ settlementFlags?: SettlementFlag[];
380
905
  }
381
906
 
382
907
  export type AdmissionHoldReason =
@@ -388,7 +913,14 @@ export type AdmissionHoldReason =
388
913
  | "sibling-active"
389
914
  | "open-pr-lookup-error"
390
915
  | "open-pr"
916
+ | "unsalvaged-wip"
391
917
  | "daily-spend-cap"
918
+ | "plan-usage-cap"
919
+ /** `credentials.isolation: "per-run"` on a host whose probe found no
920
+ * mechanism. The operator asked for a boundary this host cannot build, so
921
+ * dispatch refuses rather than running unprotected under a config that says
922
+ * otherwise (#125). */
923
+ | "credential-boundary"
392
924
  | "unroutable:no-repo-label"
393
925
  | "unroutable:multiple-repo-labels"
394
926
  | "unroutable:unknown-repo";
@@ -452,6 +984,95 @@ export interface FrictionSignal {
452
984
  }
453
985
 
454
986
 
987
+ /**
988
+ * What a report is *for*. Both reporting scopes promise exactly one `digest` a
989
+ * day; a `material` report is one event as it happens. The daemon puts both on
990
+ * the wire identically — the kind exists so the *ledger* can answer "has
991
+ * today's digest already been handed over?" without asking the model to
992
+ * remember, which is the memory #123 found unreliable.
993
+ *
994
+ * Declared as data for the same reason as {@link REPORT_SCOPES}: the CLI verb
995
+ * that accepts a kind and the outbox that renders one enumerate the same list,
996
+ * so a third kind cannot be added while either still knows only two.
997
+ */
998
+ export const REPORT_KINDS = ["material", "digest"] as const;
999
+
1000
+ export type ReportKind = (typeof REPORT_KINDS)[number];
1001
+
1002
+ /**
1003
+ * Where one report is in the outbox. The two open states are deliberately
1004
+ * distinct because they need different responses from an operator:
1005
+ *
1006
+ * - `pending` — nothing is in flight. Either nothing was ever attempted, or the
1007
+ * last attempt failed in a way Telegram told us about ({@link
1008
+ * ReportRecord.lastError} says which). Nobody has this report.
1009
+ * - `sending` — a request left this process and its outcome was never learned:
1010
+ * the daemon died, or the fetch never came back. It may well have been
1011
+ * delivered. The Bot API accepts no client-supplied idempotency key, so there
1012
+ * is nothing to replay against and no bot-readable record to reconcile with —
1013
+ * delivery is therefore **at-least-once** and a retry may duplicate. A
1014
+ * duplicate page is acceptable; a silently dropped one is the whole of #123.
1015
+ *
1016
+ * `failed` is the bounded retry budget running out, and is itself news: a
1017
+ * report nobody can deliver escalates as tier 2 in its own right.
1018
+ */
1019
+ export const REPORT_DELIVERY_STATES = ["pending", "sending", "delivered", "failed"] as const;
1020
+
1021
+ export type ReportDeliveryState = (typeof REPORT_DELIVERY_STATES)[number];
1022
+
1023
+ /** One rendered report, and everything the daemon knows about delivering it. */
1024
+ export interface ReportRecord {
1025
+ /** Short, stable, and printed inside the message itself. This transport
1026
+ * offers no reconciliation, so an id an operator can compare by eye is the
1027
+ * only way to recognise a duplicate as one. */
1028
+ id: string;
1029
+ project: string;
1030
+ kind: ReportKind;
1031
+ /** The model's rendered text, stored verbatim — authorship stays theirs. */
1032
+ body: string;
1033
+ state: ReportDeliveryState;
1034
+ /** Attempts *started*, including one currently in flight. */
1035
+ attempts: number;
1036
+ /** Names the in-flight attempt. Every terminal transition quotes it, so a
1037
+ * request that returns after its row was reclaimed cannot overwrite a newer
1038
+ * attempt's outcome — this is what keeps one report from being concurrently
1039
+ * in flight twice. Absent whenever nothing is in flight. */
1040
+ attemptId?: string;
1041
+ /** True once an attempt's outcome was lost. From then on every message for
1042
+ * this report says it may be a repeat, because it may be. */
1043
+ ambiguous: boolean;
1044
+ /** At-most-one guard, e.g. `digest:2026-08-09`. Absent for material reports. */
1045
+ dedupeKey?: string;
1046
+ createdAt: number;
1047
+ updatedAt: number;
1048
+ /** Backoff gate: a `pending` row is due only once this has passed. */
1049
+ nextAttemptAt: number;
1050
+ /** Telegram's own id for the delivered message, read out of the response
1051
+ * body rather than inferred from the HTTP status. */
1052
+ messageId?: number;
1053
+ deliveredAt?: number;
1054
+ /** Bounded text of the last known failure, surfaced verbatim by `status`. */
1055
+ lastError?: string;
1056
+ }
1057
+
1058
+ /** What a caller hands over. The store owns identity, state and every timestamp. */
1059
+ export interface ReportDraft {
1060
+ project: string;
1061
+ kind: ReportKind;
1062
+ body: string;
1063
+ dedupeKey?: string;
1064
+ at: number;
1065
+ }
1066
+
1067
+ /** Result of handing a report over. `deduped` is how the daily digest is
1068
+ * suppressed — decided from the ledger, never from the model's memory of the
1069
+ * last tick — and the caller is told which happened rather than left to guess
1070
+ * from a row it cannot tell apart from its own. */
1071
+ export interface ReportEnqueue {
1072
+ report: ReportRecord;
1073
+ deduped: boolean;
1074
+ }
1075
+
455
1076
  /**
456
1077
  * Bookkeeping only — GitHub labels remain the source of truth. The store
457
1078
  * exists to answer cap questions cheaply and to survive a restart; if it is
@@ -470,6 +1091,10 @@ export interface Store {
470
1091
  /** Newest attempt per issue for the live board. Non-merged work remains
471
1092
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
472
1093
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
1094
+ /** Newest attempt per issue that preserved work or failed to, so `status`
1095
+ * can name every WIP tip a re-claim would build on and every tree that is
1096
+ * still the only copy. */
1097
+ salvagedRuns(project: string): RunRecord[];
473
1098
  /** Total run segments, used only for the monotonically increasing run number. */
474
1099
  attemptsFor(project: string, issue: number): number;
475
1100
  /** Terminal implementation failures that consume `maxAttemptsPerIssue`. */
@@ -499,6 +1124,68 @@ export interface Store {
499
1124
  /** Start the cooldown only after a tick carrying these signals was sent. */
500
1125
  markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
501
1126
  markNotified(key: string): void;
1127
+ /**
1128
+ * Persist a rendered report `pending`, before anything is sent — the whole
1129
+ * point of #123 is that an undelivered report is a queryable row rather than
1130
+ * an absence. A draft whose `dedupeKey` is already in the ledger returns that
1131
+ * row untouched and `deduped: true`, which is how the daily digest is
1132
+ * suppressed without asking the model what it sent yesterday.
1133
+ */
1134
+ enqueueReport(draft: ReportDraft): ReportEnqueue;
1135
+ getReport(id: string): ReportRecord | undefined;
1136
+ /** `pending` rows whose backoff has elapsed, oldest first. */
1137
+ dueReports(project: string, now: number, limit: number): ReportRecord[];
1138
+ /** Atomically take a due `pending` row into `sending` under `attemptId`, so
1139
+ * the row says a request is about to leave *before* one does. `undefined`
1140
+ * means somebody else claimed it first — the guard against the same report
1141
+ * being concurrently in flight twice. */
1142
+ claimReport(id: string, attemptId: string, at: number): ReportRecord | undefined;
1143
+ /** Record the message id Telegram returned, and the moment it did. */
1144
+ markReportDelivered(id: string, attemptId: string, messageId: number | undefined, at: number): boolean;
1145
+ /** The attempt ended without an answer: Telegram may hold the message. The
1146
+ * row *stays* `sending`, because that state means exactly "outcome unknown",
1147
+ * and is flagged {@link ReportRecord.ambiguous} so the retry that stale
1148
+ * recovery eventually hands back says it may be a repeat. Deliberately does
1149
+ * not touch `updatedAt`: the staleness clock started when the request left,
1150
+ * and restarting it here would make the daemon wait out a second window for
1151
+ * an attempt already known to be over. */
1152
+ markReportUncertain(id: string, attemptId: string, error: string): boolean;
1153
+ /** Known-failed: back to `pending` behind `nextAttemptAt`, attempt counted. */
1154
+ markReportPending(id: string, attemptId: string, nextAttemptAt: number, error: string, at: number): boolean;
1155
+ /** Retry budget exhausted. Terminal, and worth escalating in its own right. */
1156
+ markReportFailed(id: string, attemptId: string, error: string, at: number): boolean;
1157
+ /** `sending` rows untouched since `staleAt` — what a dead process left behind.
1158
+ * Returns them to `pending` flagged {@link ReportRecord.ambiguous}, so the
1159
+ * retry says it may be a repeat instead of pretending it is the first try. */
1160
+ recoverSendingReports(project: string, staleAt: number, at: number): ReportRecord[];
1161
+ /** Everything an operator still has to care about: pending, sending, failed. */
1162
+ openReports(project: string): ReportRecord[];
1163
+ /**
1164
+ * Append one decided verb call to the run-scoped action ledger (#126).
1165
+ * Written for refusals as well as approvals, and written *before* the
1166
+ * privileged half runs is deliberately NOT the contract: the entry carries
1167
+ * the resulting sha, so it is written once the outcome is known.
1168
+ */
1169
+ appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry;
1170
+ /** Newest first. `runId` narrows to one run's own record. */
1171
+ verbLedger(project: string, opts?: { runId?: string; issue?: number; limit?: number }): VerbLedgerEntry[];
1172
+ /**
1173
+ * Claim the project's single merge slot, or `undefined` when another holder
1174
+ * has it. Locks older than `staleAfterMs` are broken first: a daemon killed
1175
+ * mid-merge must not wedge the project forever, and the exact-head recheck
1176
+ * makes a stolen lock safe — the second merge re-reads the live head anyway.
1177
+ */
1178
+ acquireMergeLock(
1179
+ project: string,
1180
+ holder: string,
1181
+ prUrl: string,
1182
+ now: number,
1183
+ staleAfterMs: number,
1184
+ ): MergeLock | undefined;
1185
+ /** Release a lock this holder owns. A lock it does not own is left alone. */
1186
+ releaseMergeLock(project: string, holder: string): void;
1187
+ /** The live lock, for `status` and for tests. */
1188
+ mergeLock(project: string): MergeLock | undefined;
502
1189
  close(): void;
503
1190
  }
504
1191
 
@@ -528,8 +1215,174 @@ export interface Escalation {
528
1215
  export const DEFAULT_CAPS: Caps = {
529
1216
  maxConcurrentWorkers: 2,
530
1217
  dailySpendUsd: 25,
1218
+ // Off unless an operator names a window. A default threshold would need a
1219
+ // default window id, and guessing which allowance a fleet lives on is how a
1220
+ // guard silently watches the wrong meter (#110).
1221
+ planUsage: null,
531
1222
  workerMaxTurns: 120,
532
1223
  workerWallClockMs: 90 * 60 * 1000,
533
1224
  maxAttemptsPerIssue: 2,
534
1225
  maxContinuationsPerIssue: 2,
535
1226
  };
1227
+
1228
+ /**
1229
+ * The conductor-owned mutation verbs (#126). Data, for the reason every other
1230
+ * vocabulary here is data: the daemon's dispatch table, the ledger renderer and
1231
+ * the table-driven "every verb declares an allowed-caller set" test all
1232
+ * enumerate this list, so a seventh verb cannot be added to one of them alone.
1233
+ */
1234
+ export const VERB_NAMES = [
1235
+ "conductor_push",
1236
+ "conductor_pr_create",
1237
+ "conductor_pr_update_branch",
1238
+ "conductor_pr_merge",
1239
+ "conductor_label",
1240
+ "conductor_release",
1241
+ /** The one read verb. A session with no credential cannot otherwise observe
1242
+ * the pull request it just pushed, and "pushed-green" would go back to being
1243
+ * a claim a worker makes about itself. */
1244
+ "conductor_pr_status",
1245
+ ] as const;
1246
+
1247
+ export type VerbName = (typeof VERB_NAMES)[number];
1248
+
1249
+ /**
1250
+ * Field names a verb request may never carry.
1251
+ *
1252
+ * Identity is derived from *which socket the connection arrived on* plus the
1253
+ * verified peer uid, so a payload that spells any of these is either a buggy
1254
+ * client or one probing for a daemon that trusts them — `turnLimitResponse`
1255
+ * trusting a body-supplied `project` on the unauthenticated HTTP port is the
1256
+ * failure mode this list exists to keep off the socket. Refused outright and
1257
+ * named in the refusal rather than ignored, because a client that is silently
1258
+ * stripped keeps sending them and nobody ever learns.
1259
+ */
1260
+ export const RESERVED_VERB_FIELDS = [
1261
+ "project",
1262
+ "run",
1263
+ "runId",
1264
+ "issue",
1265
+ "role",
1266
+ "uid",
1267
+ "caller",
1268
+ ] as const;
1269
+
1270
+ export type ReservedVerbField = (typeof RESERVED_VERB_FIELDS)[number];
1271
+
1272
+ /**
1273
+ * Why a verb call ended the way it did. Closed, and matched by the ledger
1274
+ * renderer and by tests: a refusal an operator cannot name is a refusal nobody
1275
+ * can audit, and free-text reasons drift one wording per call site.
1276
+ */
1277
+ export const VERB_REFUSALS = [
1278
+ /** The socket's role is not in the verb's allowed-caller set. */
1279
+ "role-not-allowed",
1280
+ /** The caller's role is not the holder configured in `authority`. */
1281
+ "authority-holder",
1282
+ /** Claiming, ticks, or the fleet as a whole is stopped. */
1283
+ "fleet-paused",
1284
+ /** The run row this socket belongs to is no longer live. */
1285
+ "run-not-live",
1286
+ /** The request carried a {@link RESERVED_VERB_FIELDS} field. */
1287
+ "identity-in-payload",
1288
+ /** An argument this verb does not declare — closed by default, never ignored. */
1289
+ "unknown-argument",
1290
+ /** A declared argument was missing or the wrong type. */
1291
+ "malformed-argument",
1292
+ /** `reason` was not a member of the verb's closed enum (#129). */
1293
+ "reason-not-in-enum",
1294
+ /** A ref other than the run's own branch. */
1295
+ "ref-not-run-branch",
1296
+ /** The base branch is not the repo's configured `defaultBranch`. */
1297
+ "base-not-default-branch",
1298
+ /** An open pull request already closes this issue (#25's guard). */
1299
+ "open-pr-exists",
1300
+ /** The tracker could not say whether one does. Fail closed. */
1301
+ "open-pr-lookup-error",
1302
+ /** The named pull request is not this run's, or not this project's. */
1303
+ "pr-not-this-run",
1304
+ /** The run has no pull request to act on. */
1305
+ "pr-missing",
1306
+ /**
1307
+ * The pull request is closed, merged, or its state could not be read.
1308
+ * Distinct from {@link VERB_REFUSALS} `checks-not-green` on purpose: "you
1309
+ * cannot update a merged PR" and "this PR is red" invite different next
1310
+ * moves, and a ledger that spelled both the same way would lose that.
1311
+ */
1312
+ "pr-not-open",
1313
+ /** `headSha` does not equal the live head at execution time. */
1314
+ "head-stale",
1315
+ /** The live head could not be read at all. Fail closed. */
1316
+ "head-unresolvable",
1317
+ /** Required checks are pending or red at the live head, or the PR is not open. */
1318
+ "checks-not-green",
1319
+ /** Another merge is in flight for this project. */
1320
+ "merge-in-flight",
1321
+ /** The label is not in this project's own vocabulary. */
1322
+ "label-not-in-vocabulary",
1323
+ /** The label is a lifecycle label; those transitions stay the daemon's (#26). */
1324
+ "label-is-lifecycle",
1325
+ /** The release grant does not permit this shape for this caller. */
1326
+ "release-not-granted",
1327
+ /** The artefact or environment is not one this project declared (#129). */
1328
+ "release-target-not-declared",
1329
+ /** A shape the daemon holds no credential for and will not pretend to cut. */
1330
+ "release-shape-not-executable",
1331
+ /** Config unreadable, project unknown, repo unrouted. Fail closed. */
1332
+ "config-unreadable",
1333
+ /** The privileged half ran and the underlying command failed. */
1334
+ "action-failed",
1335
+ ] as const;
1336
+
1337
+ export type VerbRefusal = (typeof VERB_REFUSALS)[number];
1338
+
1339
+ /** How a verb call was decided. `allowed` means the privileged half then ran. */
1340
+ export const VERB_DECISIONS = ["allowed", "refused"] as const;
1341
+
1342
+ export type VerbDecision = (typeof VERB_DECISIONS)[number];
1343
+
1344
+ /**
1345
+ * One line of the run-scoped action ledger (#126): what was asked, by whom,
1346
+ * what the daemon decided, and what came back.
1347
+ *
1348
+ * Written for refusals as well as approvals — a ledger that records only what
1349
+ * happened cannot answer "what did it try", which is the question an escalation
1350
+ * actually asks.
1351
+ */
1352
+ export interface VerbLedgerEntry {
1353
+ id: string;
1354
+ project: string;
1355
+ /** The run whose socket carried the call. Absent for the orchestrator's. */
1356
+ runId?: string;
1357
+ /** The issue that run is servicing, or the one a verb targeted. */
1358
+ issue?: number;
1359
+ verb: VerbName;
1360
+ /** Resolved from the channel, never from the payload. */
1361
+ role: SessionRole;
1362
+ /** The arguments as received, redacted of nothing: this is the audit record. */
1363
+ args: Record<string, unknown>;
1364
+ decision: VerbDecision;
1365
+ /** Present exactly when `decision` is `"refused"`. */
1366
+ refusal?: VerbRefusal;
1367
+ /** What the caller was told, verbatim. */
1368
+ detail: string;
1369
+ /** The commit, tag or merge sha the approved call produced, if any. */
1370
+ sha?: string;
1371
+ at: number;
1372
+ }
1373
+
1374
+ /** What a caller hands the ledger. The store owns `id` and `at`. */
1375
+ export type VerbLedgerDraft = Omit<VerbLedgerEntry, "id" | "at">;
1376
+
1377
+ /**
1378
+ * The single-flight merge claim for one project, held in the store because the
1379
+ * CLI, the daemon and every session are separate processes: an in-memory mutex
1380
+ * would serialise one process against itself and nothing else (#126).
1381
+ */
1382
+ export interface MergeLock {
1383
+ project: string;
1384
+ /** Opaque holder id — the run id, or the orchestrator channel's id. */
1385
+ holder: string;
1386
+ prUrl: string;
1387
+ at: number;
1388
+ }