omp-conductor 0.19.7 → 0.20.1

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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/types.ts CHANGED
@@ -680,6 +680,18 @@ export const REVIEW_ADJUDICATOR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
680
680
  */
681
681
  export const DEFAULT_REVIEW_ADJUDICATOR_ROLE = "task";
682
682
 
683
+ /**
684
+ * Which OMP model role the daemon's own to-spec grooming scouts run under
685
+ * (#1041) when no project names one. Same value and same reasoning as
686
+ * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE} — the general session role every
687
+ * install can launch — but a separate constant because the two are separate
688
+ * operator choices: grooming judgement and review adjudication are answered
689
+ * independently in setup, and collapsing them would silently move one when the
690
+ * other is retuned. Validated with {@link REVIEW_ADJUDICATOR_RE}: one role-name
691
+ * grammar for every role token conductor stores.
692
+ */
693
+ export const DEFAULT_GROOM_ROLE = "task";
694
+
683
695
  /**
684
696
  * One project's review policy (#678): the strictness its orchestrator applies
685
697
  * when deciding whether a green PR is returned to its worker, the hard
@@ -830,15 +842,89 @@ export type ReviewAdjudicationAdmission =
830
842
  | { kind: "existing"; record: ReviewAdjudicationRecord }
831
843
  | { kind: "refused"; block: "in-flight-different-head"; record: ReviewAdjudicationRecord };
832
844
 
845
+ /**
846
+ * How one review-correction round was launched (#1045, child of #1044).
847
+ *
848
+ * `resume-original` is the pre-#1045 behaviour and stays the healthy default
849
+ * path: the findings are delivered into the *same* implementation transcript,
850
+ * which is why the round costs no attempt and needs no re-orientation.
851
+ * `fresh-correction` is the escape hatch #1035 proved necessary — the original
852
+ * session was exhausted (turn/wall cap, `model-empty-stop`) or the fleet's
853
+ * worker model changed after the rejected run, so replaying that transcript
854
+ * spends the round on a session that cannot take another turn or answers from
855
+ * a model nobody would dispatch today.
856
+ *
857
+ * A closed vocabulary rather than a boolean: the two modes read differently in
858
+ * a digest, and a third mode must be a compile-time change everywhere at once.
859
+ */
860
+ export type ReviewLaunchMode = "resume-original" | "fresh-correction";
861
+
862
+ /**
863
+ * The durable provenance of one review-correction round's launch (#1045): what
864
+ * the daemon decided, which model it asked for, which model the harness
865
+ * actually resolved, and the two session lineages involved.
866
+ *
867
+ * One definition, shared by the stored row ({@link ReviewRevisionRecord}) and
868
+ * the status projection ({@link ReviewCorrectionRound}, #1048), so a renderer
869
+ * and the row it renders cannot drift apart.
870
+ *
871
+ * *Every field is optional on purpose.* Rows written before #1045 recorded no
872
+ * decision at all, and the honest projection of one is "unknown" — never a
873
+ * fabricated `resume-original`, which would put an invented explanation in the
874
+ * very audit these fields exist to make truthful. Absent means nobody wrote it
875
+ * down, and a reader must say so.
876
+ */
877
+ export interface ReviewCorrectionProvenance {
878
+ /** The decision, written once per round at dispatch time. */
879
+ launchMode?: ReviewLaunchMode;
880
+ /** What the daemon asked the harness for (a role alias, or a concrete
881
+ * provider/model), as resolved from escalation/config at dispatch. */
882
+ requestedModel?: string;
883
+ /** What the harness actually resolved and ran — the answer to "which model
884
+ * wrote this correction", which a role alias alone cannot give. */
885
+ resolvedModel?: string;
886
+ /** The implementation session lineage this round was reviewed *from*. Kept
887
+ * even for a fresh correction: it is the transcript the findings came out
888
+ * of, and losing it is losing the thread of the whole PR. */
889
+ originSessionRef?: string;
890
+ /** The fresh correction's own lineage, once the launch reports it. Only a
891
+ * `fresh-correction` round has one — a resumed round *is* its origin. */
892
+ correctionSessionRef?: string;
893
+ }
894
+
895
+ /**
896
+ * One review-correction round exactly as the status snapshot carries it
897
+ * (#1048): the round's position, its lifecycle state, and the #1045 launch
898
+ * provenance the renderer prints beside it.
899
+ *
900
+ * Derived from the same `review_revisions` rows the dispatcher decides on, so
901
+ * status can never describe a round differently from the way it was launched —
902
+ * which is precisely the #1035 failure: the fleet showed `review-revision N`
903
+ * and nothing else while every one of those rounds replayed an exhausted
904
+ * transcript under a model the fleet had already stopped using.
905
+ */
906
+ export interface ReviewCorrectionRound extends ReviewCorrectionProvenance {
907
+ round: number;
908
+ /** `pending` = enqueued, never dispatched; `dispatched` = live or
909
+ * restart-recovered; `settled` = an outcome is recorded. */
910
+ state: "pending" | "dispatched" | "settled";
911
+ outcome?: ReviewRevisionOutcome;
912
+ dispatchedAt?: number;
913
+ }
914
+
833
915
  /**
834
916
  * One durable review-revision request (#677): the orchestrator returned a
835
917
  * green, run-owned pull request to its worker with blocking findings, and
836
- * everything the daemon needs to resume the *same* OMP session is recorded
837
- * here before any worker is woken. The round numbers successive revisions of
838
- * one run (1, 2, …); the findings text is delivered to the resumed session
839
- * verbatim.
918
+ * everything the daemon needs to reach the *same work* is recorded here before
919
+ * any worker is woken. The round numbers successive revisions of one run
920
+ * (1, 2, …); the findings text is delivered to the launched session verbatim.
921
+ *
922
+ * Since #1045 the row also carries how the round was launched — resumed into
923
+ * the recorded transcript, or opened as a fresh correction on the same
924
+ * branch/PR — together with the models and session lineages involved, so a
925
+ * restart re-reads that decision instead of making it a second time.
840
926
  */
841
- export interface ReviewRevisionRecord {
927
+ export interface ReviewRevisionRecord extends ReviewCorrectionProvenance {
842
928
  id: string;
843
929
  project: string;
844
930
  /** The run whose pushed-green row this revises. The row is reused, never
@@ -866,8 +952,29 @@ export interface ReviewRevisionRecord {
866
952
  * `failed` — and this counter is what bounds that, durably, so an outage
867
953
  * loop cannot retry one round forever. Absent means zero. */
868
954
  infraRetries?: number;
955
+ /** When the launch decision was made (#1045). Stamped by the deciding
956
+ * dispatch and never moved afterwards, so a re-affirming retry cannot make
957
+ * an old decision look new. Absent on a round nobody has decided yet, and
958
+ * on every pre-#1045 row. */
959
+ launchDecidedAt?: number;
869
960
  }
870
961
 
962
+ /**
963
+ * What a caller may hand {@link Store.enqueueReviewRevision} (#1045).
964
+ *
965
+ * Deliberately narrower than the record: the launch decision and its
966
+ * provenance belong to *dispatch*, not to the review that requested the round,
967
+ * and the enqueue INSERT does not persist them. Accepting them here would let
968
+ * a caller pass a `launchMode`, read it straight back off the returned record,
969
+ * and never notice the row does not carry it — the exact silent fake #1045
970
+ * names. They are written afterwards, once, by
971
+ * {@link Store.recordReviewRevisionLaunch}.
972
+ */
973
+ export type ReviewRevisionDraft = Omit<
974
+ ReviewRevisionRecord,
975
+ "id" | keyof ReviewCorrectionProvenance | "launchDecidedAt"
976
+ >;
977
+
871
978
  /**
872
979
  * The outcome of recording one `conductor_pr_review` finding against a run's
873
980
  * review-revision outbox (#786). `created` opened a new revision round for the
@@ -1034,6 +1141,15 @@ export interface ProjectConfig {
1034
1141
  * every tick (#988). Optional; defaults to {@link DEFAULT_GROOM_BELOW} in
1035
1142
  * orchestrator-tick.ts. */
1036
1143
  groomBelow?: GroomTrigger;
1144
+ /**
1145
+ * The OMP model role the daemon's own to-spec grooming scouts run under
1146
+ * (#1041): a role token in the same grammar the review adjudicator uses
1147
+ * ({@link REVIEW_ADJUDICATOR_RE}), never a provider/model — OMP owns model
1148
+ * selection. Absent loads as {@link DEFAULT_GROOM_ROLE} (`"task"`), the
1149
+ * general session role every install can launch without pinning a provider,
1150
+ * which is what every project that never answered the question wants.
1151
+ */
1152
+ groomRole?: string;
1037
1153
  /** Labels the dispatcher writes back so the tracker alone shows live state
1038
1154
  * to a human who never opens the daemon's logs. `backlog` is the
1039
1155
  * operator's own park gesture (#507) — not dispatcher-written, but read by
@@ -1681,6 +1797,14 @@ export const FAILURE_CLASSES = [
1681
1797
  "dispatch-infra",
1682
1798
  "merge-conflict",
1683
1799
  "question",
1800
+ /** A run that finished its work and stopped because it is waiting on an
1801
+ * observable condition — its settlement names `blockers:` (a PR whose
1802
+ * checks were still pending, say) and no question (#1068). Distinct from
1803
+ * `question` because the remedy is not a human: the recovery is the later
1804
+ * observation, which the settle sweep performs by re-offering the blocked
1805
+ * row until its PR resolves (this fleet's #1062 sat `blocked`/`question`
1806
+ * after its PR merged and its tick kept listing it for Duty 1 triage). */
1807
+ "awaiting-observation",
1684
1808
  "orphan-clean",
1685
1809
  "orphan-dirty",
1686
1810
  "settlement-stuck",
@@ -1715,21 +1839,27 @@ export type FailureClass = (typeof FAILURE_CLASSES)[number];
1715
1839
  /**
1716
1840
  * What the daemon does about a class.
1717
1841
  *
1718
- * Two of these perform nothing, and they are not the same nothing:
1842
+ * Three of these perform nothing, and they are not the same nothing:
1719
1843
  *
1844
+ * - `observe` — recorded and deliberately left alone, because the recovery IS
1845
+ * the later observation: `awaiting-observation` rows wait on something the
1846
+ * world will resolve (their PR), and the settle sweep re-offers the blocked
1847
+ * row until it does, settling it the way it settles `settlement-stuck` when
1848
+ * the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is
1849
+ * recovered yet, by design (#1068).
1720
1850
  * - `hold` — recorded and deliberately left alone, because acting would destroy
1721
1851
  * something. `orphan-dirty` is the case: the tree is the only copy, so
1722
1852
  * `unblock` refuses rather than re-claiming over it.
1723
1853
  * - `none` — the classifier declining to name a failure at all. Two callers
1724
1854
  * reach it, and neither is "unclassifiable": `classifyRun` returns it for a
1725
- * terminal row whose PR is open and fully green, where `classifyAndRecover`
1855
+ * terminal row whose PR is open and fully green, where `classifyAndRecover`
1726
1856
  * restores `pushed-green` *without* persisting a class (#766); and the settle
1727
1857
  * sweep persists it beside `returned-for-revision` when a reviewer closes
1728
1858
  * pushed work without merging, where the remedy is the queue label the sweep
1729
1859
  * deliberately leaves on.
1730
1860
  *
1731
1861
  * An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry.
1732
- * This comment previously said nothing in the decision table maps to `none`,
1862
+ * This comment previously said nothing in the decision tree maps to `none`,
1733
1863
  * which both callers above contradict; it cost a reading of two modules to
1734
1864
  * discover, which is what a wrong comment costs (#132).
1735
1865
  */
@@ -1739,6 +1869,7 @@ export const RECOVERY_ACTIONS = [
1739
1869
  "rerun-checks",
1740
1870
  "settle",
1741
1871
  "escalate",
1872
+ "observe",
1742
1873
  "hold",
1743
1874
  "none",
1744
1875
  ] as const;
@@ -2036,11 +2167,13 @@ export interface RunRecord {
2036
2167
  */
2037
2168
  outputTokens?: number;
2038
2169
  reasoningTokens?: number;
2039
- /** The run id of the orphan-clean attempt whose session this row resumes
2040
- * (#536, #567). Set only when the dispatch-time resume verdict fired;
2041
- * absent means a fresh dispatch, never "resumed from unknown". Readable
2042
- * without opening the transcript, so "how often did resume fire and did it
2043
- * save turns" is a query, not a file walk. */
2170
+ /** The run id whose session this row resumes: the orphan-clean attempt it
2171
+ * inherited (#536, #567), or since the blocked-continuation resume the
2172
+ * answered-block run whose transcript this continuation carries on. Set only
2173
+ * when a dispatch-time resume verdict fired; absent means a fresh dispatch,
2174
+ * never "resumed from unknown". Readable without opening the transcript, so
2175
+ * "how often did resume fire and did it save turns" is a query, not a file
2176
+ * walk. */
2044
2177
  resumedFromRunId?: string;
2045
2178
  prUrl?: string;
2046
2179
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
@@ -2548,6 +2681,14 @@ export interface IntakeItem {
2548
2681
  issueUrl?: string;
2549
2682
  /** When the item was resolved into an issue (#300) or dismissed. */
2550
2683
  groomedAt?: number;
2684
+ /**
2685
+ * Provenance of a machine-filed item (Phase 4 signal mining) — the stable
2686
+ * key of the signal that produced it, e.g.
2687
+ * `mined:settlement-weakening:omp/src/foo.ts`. Absent means an operator
2688
+ * typed this idea themselves, which surfaces matching on it must render as
2689
+ * their own idea rather than as an unattributed machine signal.
2690
+ */
2691
+ source?: string;
2551
2692
  }
2552
2693
 
2553
2694
  /** What a caller hands over. The store owns the id, the state and the timestamps. */
@@ -2555,6 +2696,72 @@ export interface IntakeDraft {
2555
2696
  project: string;
2556
2697
  text: string;
2557
2698
  at: number;
2699
+ /**
2700
+ * Provenance and dedupe key in one (Phase 4). When present, the store
2701
+ * derives the item's id DETERMINISTICALLY from `project` + `source` and
2702
+ * inserts only if that id is new, so an hourly mining pass re-filing a
2703
+ * signal it already filed is a no-op rather than an hourly duplicate.
2704
+ *
2705
+ * The consequence is intended: an item the operator dismissed stays
2706
+ * dismissed instead of reappearing every hour, because the second filing
2707
+ * cannot resurrect the row it collides with. The trap is the same fact from
2708
+ * the other side — a signal genuinely worth re-raising later needs a
2709
+ * DIFFERENT key, which is what a source carrying a bucket (a week, a run
2710
+ * count) is for. A bare `mined:<kind>:<path>` is a once-ever claim.
2711
+ *
2712
+ * Absent for an operator's own idea, which keeps a random id and is
2713
+ * therefore always a new row: two identical thoughts on two days are two
2714
+ * thoughts.
2715
+ */
2716
+ source?: string;
2717
+ }
2718
+
2719
+ /**
2720
+ * Which daemon-owned session spent this (Phase 4 attribution). Exactly the two
2721
+ * the daemon runs itself: to-spec grooming scouts and review adjudication.
2722
+ *
2723
+ * The orchestrator's own tick turns are deliberately NOT a member. The tick
2724
+ * extension observes no spend or usage events at all — its `message_start`
2725
+ * adapter reads content text — so there is no number to record, and a `0` row
2726
+ * per tick would report the orchestrator as free. Orchestrator spend is
2727
+ * reported as unmetered instead of invented.
2728
+ */
2729
+ export const SESSION_SPEND_ROLES = ["groom", "adjudicator"] as const;
2730
+
2731
+ export type SessionSpendRole = (typeof SESSION_SPEND_ROLES)[number];
2732
+
2733
+ /**
2734
+ * One daemon-owned session's turns and cost (Phase 4 attribution).
2735
+ *
2736
+ * The same shape going in and coming out: the store adds nothing but a rowid,
2737
+ * because every field here is something the caller actually observed.
2738
+ *
2739
+ * `spendUsd` is OPTIONAL, and that is the contract rather than convenience.
2740
+ * In the `runs` table an unmetered request and a genuinely free one are both
2741
+ * `0`, which is why `spend-telemetry.ts` has to infer a subscription verdict
2742
+ * from the share of zeros instead of reading a fact. Here the caller
2743
+ * distinguishes them: omit `spendUsd` when the provider reported no cost for
2744
+ * the session (subscription billing, or a harness that emitted no usage
2745
+ * event), pass it when it reported a number — including a real `0`. A reader
2746
+ * MUST render an absent `spendUsd` as unmetered and never as `$0.00`.
2747
+ */
2748
+ export interface SessionSpendRow {
2749
+ project: string;
2750
+ role: SessionSpendRole;
2751
+ /** The issue the session was about, when it was about one. Absent for a
2752
+ * grooming batch that spanned several. */
2753
+ issue?: number;
2754
+ /** The model the caller asked for. */
2755
+ model?: string;
2756
+ /** The model the harness actually ran, which is the one that was billed and
2757
+ * the one a per-model breakdown groups on. Absent when unobserved — never
2758
+ * filled in from `model`, because a requested model is not evidence of a
2759
+ * resolved one. */
2760
+ resolvedModel?: string;
2761
+ turns: number;
2762
+ /** Absent means the provider reported no cost. See the interface note. */
2763
+ spendUsd?: number;
2764
+ at: number;
2558
2765
  }
2559
2766
 
2560
2767
  /**
@@ -2569,6 +2776,16 @@ export const GROOMING_VERDICTS = ["promotable", "blocked", "considered"] as cons
2569
2776
 
2570
2777
  export type GroomingVerdict = (typeof GROOMING_VERDICTS)[number];
2571
2778
 
2779
+ /**
2780
+ * Who acted on a grooming verdict by queueing the issue (#1041). Three actors,
2781
+ * because the promotion audit's whole question is which of them did it: the
2782
+ * daemon promotes unattended once the ready gate passes, an orchestrator tick
2783
+ * promotes as part of its own reasoning, and an operator promotes by hand. A
2784
+ * fourth spelling would mean a fourth promotion path exists, which is exactly
2785
+ * what this closed union is here to prevent.
2786
+ */
2787
+ export type PromotedBy = "daemon" | "orchestrator" | "operator";
2788
+
2572
2789
  /**
2573
2790
  * One issue's current grooming verdict, durable across restarts and keyed by
2574
2791
  * project + issue — one row per issue, replaced in place by upsert, never a
@@ -2587,6 +2804,24 @@ export interface GroomingRecord {
2587
2804
  * or a scout summary. Free text, bounded at the write site. */
2588
2805
  evidence: string;
2589
2806
  recordedAt: number;
2807
+ /**
2808
+ * When this verdict was acted on by queueing the issue (#1041). Absent on
2809
+ * every verdict nobody promoted, which is the honest reading of a row from
2810
+ * before mechanical promotion existed: nothing recorded who queued it, so
2811
+ * nothing may claim to know.
2812
+ *
2813
+ * Set exactly once per verdict, by {@link Store.markGroomingPromoted}, and
2814
+ * cleared by the next `upsertGrooming` — a re-recorded verdict is a new
2815
+ * judgement about the issue, and provenance that outlived the judgement it
2816
+ * describes would audit the wrong decision.
2817
+ */
2818
+ promotedAt?: number;
2819
+ /** Who queued it: the daemon's own ready gate, an orchestrator tick, or an
2820
+ * operator at the CLI. Stored rather than inferred — the tick's promotion
2821
+ * audit exists precisely to review what the daemon did unattended, and a
2822
+ * guess would make that review circular. Always present when
2823
+ * {@link promotedAt} is, and absent when it is not. */
2824
+ promotedBy?: PromotedBy;
2590
2825
  }
2591
2826
 
2592
2827
  /** What a caller hands over. The store owns the timestamp and the replace-in-
@@ -2600,6 +2835,32 @@ export interface GroomingDraft {
2600
2835
  at: number;
2601
2836
  }
2602
2837
 
2838
+ /**
2839
+ * One operator's standing approval of an epic's scope (#1041).
2840
+ *
2841
+ * Deliberately its own durable fact rather than a resolved decision row, for
2842
+ * two reasons that both bite in production. First, approving an epic is an
2843
+ * explicit, revocable act with exactly one meaning — "the children of this
2844
+ * epic may be queued without asking me again" — while a decision row's
2845
+ * `resolution` is free text a human wrote for a human, and no amount of
2846
+ * parsing turns "yes, but do the migration first" into a machine gate. Second,
2847
+ * decision rows expire ({@link DECISION_TTL_MS}) and an approval must not:
2848
+ * scope consent does not lapse after a week just because nobody looked at it.
2849
+ *
2850
+ * Carries no `project` field because it is always read through a
2851
+ * project-scoped accessor — the scoping is the key, not a payload column an
2852
+ * unwary caller could compare against the wrong project.
2853
+ */
2854
+ export interface EpicApproval {
2855
+ issue: number;
2856
+ approvedAt: number;
2857
+ /** Who approved it, in the same spelling the report-withdrawal path already
2858
+ * uses: the session role when one is set, else `orchestrator` inside a
2859
+ * Herdr pane, else `operator`. Free text on purpose — it is provenance for
2860
+ * a human reading an audit line, never a gate anything branches on. */
2861
+ approvedBy: string;
2862
+ }
2863
+
2603
2864
  /**
2604
2865
  * Where one operator decision stands (#136).
2605
2866
  *
@@ -2781,7 +3042,7 @@ export interface Store {
2781
3042
  * two concurrent same-head findings cannot both create a row or overwrite
2782
3043
  * each other (the run row's state is the other half once the revision is
2783
3044
  * dispatched). */
2784
- enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue;
3045
+ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue;
2785
3046
  /** The pending revision for one run, if any — the header the verb reads to
2786
3047
  * tell "this call folds into the in-flight round" from "this call opens a
2787
3048
  * new round" before the round-ceiling gate applies. */
@@ -2793,6 +3054,51 @@ export interface Store {
2793
3054
  latestReviewRound(project: string, runId: string): number;
2794
3055
  /** Record that a revision was handed to a worker. */
2795
3056
  markReviewRevisionDispatched(id: string, at: number): void;
3057
+ /** Record, once, how this review round is being launched (#1045).
3058
+ *
3059
+ * Write-once and atomic — one guarded UPDATE, never read-then-write, so two
3060
+ * dispatch passes cannot each decide. The FIRST decision for a round wins:
3061
+ *
3062
+ * - no decision yet ⇒ written, `true`;
3063
+ * - the identical decision again (a dispatch retry, a restart re-deciding
3064
+ * the same way) ⇒ no-op, `true`, and the original `launchDecidedAt`
3065
+ * stands;
3066
+ * - a *contradictory* decision (different mode, requested model or origin
3067
+ * lineage) ⇒ nothing written, `false`. The caller must re-read the
3068
+ * recorded decision and honour it rather than launching its own;
3069
+ * - no such round in this project ⇒ `false`.
3070
+ *
3071
+ * Touches the decision columns only. The issue, run, attempt/continuation
3072
+ * charges, round number, branch, PR URL, reviewed head, findings and
3073
+ * settlement history are the round's identity and are never written here. */
3074
+ recordReviewRevisionLaunch(
3075
+ project: string,
3076
+ id: string,
3077
+ decision: {
3078
+ launchMode: ReviewLaunchMode;
3079
+ requestedModel?: string;
3080
+ originSessionRef?: string;
3081
+ at: number;
3082
+ },
3083
+ ): boolean;
3084
+ /** Attach the fresh correction's own session lineage — and the model the
3085
+ * harness actually resolved — to a round already decided
3086
+ * `fresh-correction` (#1045). `false`, writing nothing, on a round decided
3087
+ * `resume-original`, on an undecided round, or on no such round: a resumed
3088
+ * round *is* its origin session, so recording a separate correction
3089
+ * lineage for one would invent a session that never existed.
3090
+ *
3091
+ * Not write-once, and deliberately so: an infra-killed round relaunches on
3092
+ * the same decision, and the newest fresh session is then the truth about
3093
+ * what is running. `resolvedModel` is only ever added, never cleared — a
3094
+ * later call without one leaves a known resolution in place. */
3095
+ recordReviewCorrectionSession(project: string, id: string, ref: string, resolvedModel?: string): boolean;
3096
+ /** The recorded launch decision and provenance for one round, or `undefined`
3097
+ * when no such round exists (#1045). Every field is absent on a round
3098
+ * nobody has decided and on every pre-#1045 row — the explicit unknown a
3099
+ * claim/retry/restart path must read *before* deciding, and the honest
3100
+ * projection a renderer must print instead of guessing. */
3101
+ reviewRevisionLaunch(project: string, id: string): ReviewCorrectionProvenance | undefined;
2796
3102
  /** Record how a revision ended. */
2797
3103
  settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void;
2798
3104
  /** Every revision row not yet settled — queued (`dispatchedAt` unset) and
@@ -3118,15 +3424,37 @@ export interface Store {
3118
3424
  getMaterialEvent(id: string): MaterialEvent | undefined;
3119
3425
  /** Ordinary material outcomes still owed, oldest first and bounded. */
3120
3426
  undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
3121
- /** Persist one raw idea, `pending`, for later grooming (#299). */
3427
+ /**
3428
+ * Persist one raw idea, `pending`, for later grooming (#299).
3429
+ *
3430
+ * Idempotent when {@link IntakeDraft.source} is present (Phase 4): the id is
3431
+ * derived from `project` + `source` and the insert is ignored if that id
3432
+ * already exists, so the hourly mining pass is safe to re-run. A collision
3433
+ * returns the EXISTING row untouched — which is how a caller tells a new
3434
+ * filing from a no-op (`item.createdAt !== draft.at`, or a `state` that is
3435
+ * no longer `pending`) — and in particular does NOT reopen an item the
3436
+ * operator dismissed.
3437
+ *
3438
+ * Without a `source` the id is random, so every call is a new row.
3439
+ */
3122
3440
  recordIntake(draft: IntakeDraft): IntakeItem;
3123
3441
  /** Ideas still `pending`, oldest first — what `intake list` and `status` read. */
3124
3442
  pendingIntake(project: string): IntakeItem[];
3125
3443
  /** Resolve one idea to `groomed` (recording the issue URL #300 chose) or
3126
3444
  * `dismissed`. `false` when the id is unknown. */
3127
3445
  resolveIntake(id: string, state: "groomed" | "dismissed", issueUrl?: string): boolean;
3128
- /** Record the current grooming verdict for one issue, replacing any prior
3129
- * row one row per project + issue, never a history (#735). */
3446
+ /**
3447
+ * Record the current grooming verdict for one issue, replacing any prior
3448
+ * row — one row per project + issue, never a history (#735).
3449
+ *
3450
+ * Clears {@link GroomingRecord.promotedAt}/{@link GroomingRecord.promotedBy}
3451
+ * (#1041). A re-recorded verdict is a fresh judgement about the issue, so
3452
+ * carrying the old promotion forward would let the audit attribute a queueing
3453
+ * to a verdict that no longer exists — and, worse, would make a demote-then-
3454
+ * regroom cycle read as though the demoted promotion still stood. A caller
3455
+ * that re-promotes stamps provenance again through
3456
+ * {@link markGroomingPromoted}, which is the only writer of these columns.
3457
+ */
3130
3458
  upsertGrooming(draft: GroomingDraft): void;
3131
3459
  /** The current grooming verdict for one issue, or undefined when none. */
3132
3460
  grooming(project: string, issue: number): GroomingRecord | undefined;
@@ -3142,6 +3470,15 @@ export interface Store {
3142
3470
  * self-heals instead of lingering as a cache someone must invalidate. Runs
3143
3471
  * at the end of every admission pass. Never touches `promotable` or
3144
3472
  * `considered` rows, which belong to #679's scout loop.
3473
+ *
3474
+ * A promoted row (#1041) is likewise inert here, in both directions: it is
3475
+ * neither overwritten with `blocked` nor swept by the self-clearing delete.
3476
+ * Promotion hands the issue to dispatch, and admission's lane/dependency
3477
+ * holds on a queued issue are flow control of the moment, not a re-grooming.
3478
+ * Without this the fleet would lose the audit line within one 5-minute
3479
+ * admission pass — a promoted issue whose lane overlaps an active run would
3480
+ * be rewritten as `blocked` and then deleted — long before the ~30-minute
3481
+ * tick that is supposed to review the promotion ever read it.
3145
3482
  */
3146
3483
  reconcileGrooming(
3147
3484
  project: string,
@@ -3162,6 +3499,48 @@ export interface Store {
3162
3499
  * snapshot here deletes verdicts nobody re-derived.
3163
3500
  */
3164
3501
  retireGroomingNotOpen(project: string, openIssues: readonly number[]): number[];
3502
+ /**
3503
+ * Stamp promotion provenance on one existing verdict (#1041), returning
3504
+ * whether this call is the one that stamped it.
3505
+ *
3506
+ * `false` on both no-op shapes, and the caller must treat them the same way:
3507
+ * there is no such verdict, or it already carries a promotion. That makes the
3508
+ * write the single-flight latch for promotion itself — the daemon enqueues the
3509
+ * queue label and fires the wake only when it wins this call, so a restart
3510
+ * mid-promotion re-reads a stamped row and does not queue the issue twice.
3511
+ * Provenance is therefore stamped BEFORE the label op, never after.
3512
+ */
3513
+ markGroomingPromoted(project: string, issue: number, at: number, by: PromotedBy): boolean;
3514
+ /**
3515
+ * Verdicts promoted at or after `since`, newest promotion first — the tick's
3516
+ * promotion audit (#1041): what was queued unattended while nobody was
3517
+ * looking, with the verdict and evidence that justified it still attached.
3518
+ *
3519
+ * Bounded by the window rather than by a count, because the audit's contract
3520
+ * is "everything since the last tick" — a truncated list would silently
3521
+ * un-audit a promotion.
3522
+ */
3523
+ promotionsSince(project: string, since: number): GroomingRecord[];
3524
+ /**
3525
+ * Record the operator's standing approval of one epic's scope (#1041).
3526
+ *
3527
+ * Idempotent and first-write-wins: re-approving keeps the original
3528
+ * `approvedAt` and `approvedBy`, because the durable fact is when consent was
3529
+ * given, not when it was last restated. A caller that must tell "approved
3530
+ * now" from "already approved" reads {@link epicApproval} first. Refuses a
3531
+ * non-integer or non-positive issue number rather than storing a fact about
3532
+ * an issue that cannot exist.
3533
+ */
3534
+ approveEpic(project: string, issue: number, at: number, by: string): void;
3535
+ /** Withdraw an epic's approval, `false` when there was none to withdraw.
3536
+ * Approval is revocable by design: scope consent an operator regrets must be
3537
+ * removable without deleting the epic or its children (#1041). */
3538
+ revokeEpicApproval(project: string, issue: number): boolean;
3539
+ /** One epic's approval, or `undefined` when it has none — which every gate
3540
+ * must read as "not approved", never as approval whose row is missing. */
3541
+ epicApproval(project: string, issue: number): EpicApproval | undefined;
3542
+ /** Every approved epic in this project, issue-ascending. */
3543
+ epicApprovals(project: string): EpicApproval[];
3165
3544
  /** Count and age source for status and digest prompt bounds. */
3166
3545
  digestBacklog(project: string): DigestBacklog;
3167
3546
  /** Add one bounded observation to the per-day friction rollup. */
@@ -3211,6 +3590,38 @@ export interface Store {
3211
3590
  bumpGhCalls?(day: string, source: string): void;
3212
3591
  /** The tracked call counts for a UTC day, per source. */
3213
3592
  ghCallsToday?(day: string): { source: string; calls: number }[];
3593
+ /**
3594
+ * Record one auto-restart the daemon fired at a wedged orchestrator
3595
+ * (Phase 4). A bare append, like {@link Store.recordGhRefusal}: the cap is
3596
+ * enforced by counting rows in a window, so there is no counter to reset and
3597
+ * a crash cannot leave a high-water mark that either disables the cap or
3598
+ * spends it twice. Rows older than a week are pruned in the same write.
3599
+ */
3600
+ recordOrchestratorRestart(project: string, at: number, reason: string): void;
3601
+ /**
3602
+ * This project's auto-restarts at or after `since`, NEWEST FIRST — the
3603
+ * bounded-restart gate counts them against its per-window cap, and the page
3604
+ * it sends when the cap is reached names the most recent reasons.
3605
+ */
3606
+ orchestratorRestartsSince(project: string, since: number): { at: number; reason: string }[];
3607
+ /**
3608
+ * Record what one daemon-owned session (grooming, adjudication) cost
3609
+ * (Phase 4 attribution). Append-only; these sessions have no lifecycle to
3610
+ * update, they either ran or they did not.
3611
+ *
3612
+ * Deliberately not a `runs` row: `computeStats` walks runs as issue journeys
3613
+ * and a role row in there would invent a journey, corrupt the settled counts
3614
+ * and misattribute grooming spend to a merge it did not produce.
3615
+ *
3616
+ * Omit `spendUsd` when the provider reported no cost — see
3617
+ * {@link SessionSpendRow}.
3618
+ */
3619
+ recordSessionSpend(row: SessionSpendRow): void;
3620
+ /** This project's daemon-owned session spend at or after `since`, newest
3621
+ * first. Never includes worker runs: those live in `runs` and are already
3622
+ * counted by {@link Store.statsRuns}, so summing both surfaces gives the
3623
+ * fleet's whole bill without double-counting either half. */
3624
+ sessionSpendSince(project: string, since: number): SessionSpendRow[];
3214
3625
  /**
3215
3626
  * Persist a rendered report `pending`, before anything is sent — the whole
3216
3627
  * point of #123 is that an undelivered report is a queryable row rather than
@@ -3665,10 +4076,15 @@ export const VERB_NAMES = [
3665
4076
  * re-enter normal review/merge without a fresh clone. Idempotent: an
3666
4077
  * already-matching PR is returned, not duplicated. */
3667
4078
  "conductor_pr_recover",
3668
- /** The one read verb. It answers with the merge gate's own verdict, so
4079
+ /** The first read verb. It answers with the merge gate's own verdict, so
3669
4080
  * "pushed-green" is the dispatcher's reading of the PR rather than a claim the
3670
4081
  * worker makes about itself from whatever it happened to run. */
3671
4082
  "conductor_pr_status",
4083
+ /** The second read verb (#1043 lane): the decisive log lines of the failing
4084
+ * jobs at one pull request's head, through the tracker's bounded
4085
+ * failed-attempt log read. A worker calls it to diagnose a red check it
4086
+ * must fix, instead of guessing from the check name. */
4087
+ "conductor_ci_logs",
3672
4088
  ] as const;
3673
4089
 
3674
4090
  export type VerbName = (typeof VERB_NAMES)[number];
@@ -484,6 +484,34 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
484
484
  },
485
485
  roleRefusalText: (role) => `conductor_pr_status is not open to a ${role} session.`,
486
486
  },
487
+ conductor_ci_logs: {
488
+ name: "conductor_ci_logs",
489
+ mutating: false,
490
+ allowedRoles: ["worker", "orchestrator"],
491
+ description:
492
+ "Read the failing CI jobs' own log output at one pull request's head. Call this when " +
493
+ "conductor_pr_status reports a red check you have to fix, INSTEAD of guessing from the check name or " +
494
+ "re-running the suite locally to see what CI saw. It answers with the failed steps of every failed job " +
495
+ "across the head's attempts, bounded to the newest evidence and truncated per job when large — so it is " +
496
+ "cheap in turns but not free in context; read it once and fix from it, do not poll it. Read-only: it " +
497
+ "changes nothing and is not ledgered. If the logs cannot be read it says so — an unreadable log is never " +
498
+ "reported as \"no failures\". A worker may omit prUrl and gets its own run's.",
499
+ args: {
500
+ prUrl: {
501
+ type: "string",
502
+ required: false,
503
+ description: "Full pull request URL. Omit as a worker to read your own run's.",
504
+ },
505
+ headSha: {
506
+ type: "string",
507
+ required: false,
508
+ description:
509
+ "Optional: the head you believe you are diagnosing. If the branch has moved since, the call is " +
510
+ "refused naming both shas rather than handing you logs from a superseded commit.",
511
+ },
512
+ },
513
+ roleRefusalText: (role) => `conductor_ci_logs is not open to a ${role} session.`,
514
+ },
487
515
  };
488
516
 
489
517
  export interface VerbRequest {