omp-conductor 0.19.7 → 0.20.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.
Files changed (69) 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/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -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
@@ -2036,11 +2152,13 @@ export interface RunRecord {
2036
2152
  */
2037
2153
  outputTokens?: number;
2038
2154
  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. */
2155
+ /** The run id whose session this row resumes: the orphan-clean attempt it
2156
+ * inherited (#536, #567), or since the blocked-continuation resume the
2157
+ * answered-block run whose transcript this continuation carries on. Set only
2158
+ * when a dispatch-time resume verdict fired; absent means a fresh dispatch,
2159
+ * never "resumed from unknown". Readable without opening the transcript, so
2160
+ * "how often did resume fire and did it save turns" is a query, not a file
2161
+ * walk. */
2044
2162
  resumedFromRunId?: string;
2045
2163
  prUrl?: string;
2046
2164
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
@@ -2548,6 +2666,14 @@ export interface IntakeItem {
2548
2666
  issueUrl?: string;
2549
2667
  /** When the item was resolved into an issue (#300) or dismissed. */
2550
2668
  groomedAt?: number;
2669
+ /**
2670
+ * Provenance of a machine-filed item (Phase 4 signal mining) — the stable
2671
+ * key of the signal that produced it, e.g.
2672
+ * `mined:settlement-weakening:omp/src/foo.ts`. Absent means an operator
2673
+ * typed this idea themselves, which surfaces matching on it must render as
2674
+ * their own idea rather than as an unattributed machine signal.
2675
+ */
2676
+ source?: string;
2551
2677
  }
2552
2678
 
2553
2679
  /** What a caller hands over. The store owns the id, the state and the timestamps. */
@@ -2555,6 +2681,72 @@ export interface IntakeDraft {
2555
2681
  project: string;
2556
2682
  text: string;
2557
2683
  at: number;
2684
+ /**
2685
+ * Provenance and dedupe key in one (Phase 4). When present, the store
2686
+ * derives the item's id DETERMINISTICALLY from `project` + `source` and
2687
+ * inserts only if that id is new, so an hourly mining pass re-filing a
2688
+ * signal it already filed is a no-op rather than an hourly duplicate.
2689
+ *
2690
+ * The consequence is intended: an item the operator dismissed stays
2691
+ * dismissed instead of reappearing every hour, because the second filing
2692
+ * cannot resurrect the row it collides with. The trap is the same fact from
2693
+ * the other side — a signal genuinely worth re-raising later needs a
2694
+ * DIFFERENT key, which is what a source carrying a bucket (a week, a run
2695
+ * count) is for. A bare `mined:<kind>:<path>` is a once-ever claim.
2696
+ *
2697
+ * Absent for an operator's own idea, which keeps a random id and is
2698
+ * therefore always a new row: two identical thoughts on two days are two
2699
+ * thoughts.
2700
+ */
2701
+ source?: string;
2702
+ }
2703
+
2704
+ /**
2705
+ * Which daemon-owned session spent this (Phase 4 attribution). Exactly the two
2706
+ * the daemon runs itself: to-spec grooming scouts and review adjudication.
2707
+ *
2708
+ * The orchestrator's own tick turns are deliberately NOT a member. The tick
2709
+ * extension observes no spend or usage events at all — its `message_start`
2710
+ * adapter reads content text — so there is no number to record, and a `0` row
2711
+ * per tick would report the orchestrator as free. Orchestrator spend is
2712
+ * reported as unmetered instead of invented.
2713
+ */
2714
+ export const SESSION_SPEND_ROLES = ["groom", "adjudicator"] as const;
2715
+
2716
+ export type SessionSpendRole = (typeof SESSION_SPEND_ROLES)[number];
2717
+
2718
+ /**
2719
+ * One daemon-owned session's turns and cost (Phase 4 attribution).
2720
+ *
2721
+ * The same shape going in and coming out: the store adds nothing but a rowid,
2722
+ * because every field here is something the caller actually observed.
2723
+ *
2724
+ * `spendUsd` is OPTIONAL, and that is the contract rather than convenience.
2725
+ * In the `runs` table an unmetered request and a genuinely free one are both
2726
+ * `0`, which is why `spend-telemetry.ts` has to infer a subscription verdict
2727
+ * from the share of zeros instead of reading a fact. Here the caller
2728
+ * distinguishes them: omit `spendUsd` when the provider reported no cost for
2729
+ * the session (subscription billing, or a harness that emitted no usage
2730
+ * event), pass it when it reported a number — including a real `0`. A reader
2731
+ * MUST render an absent `spendUsd` as unmetered and never as `$0.00`.
2732
+ */
2733
+ export interface SessionSpendRow {
2734
+ project: string;
2735
+ role: SessionSpendRole;
2736
+ /** The issue the session was about, when it was about one. Absent for a
2737
+ * grooming batch that spanned several. */
2738
+ issue?: number;
2739
+ /** The model the caller asked for. */
2740
+ model?: string;
2741
+ /** The model the harness actually ran, which is the one that was billed and
2742
+ * the one a per-model breakdown groups on. Absent when unobserved — never
2743
+ * filled in from `model`, because a requested model is not evidence of a
2744
+ * resolved one. */
2745
+ resolvedModel?: string;
2746
+ turns: number;
2747
+ /** Absent means the provider reported no cost. See the interface note. */
2748
+ spendUsd?: number;
2749
+ at: number;
2558
2750
  }
2559
2751
 
2560
2752
  /**
@@ -2569,6 +2761,16 @@ export const GROOMING_VERDICTS = ["promotable", "blocked", "considered"] as cons
2569
2761
 
2570
2762
  export type GroomingVerdict = (typeof GROOMING_VERDICTS)[number];
2571
2763
 
2764
+ /**
2765
+ * Who acted on a grooming verdict by queueing the issue (#1041). Three actors,
2766
+ * because the promotion audit's whole question is which of them did it: the
2767
+ * daemon promotes unattended once the ready gate passes, an orchestrator tick
2768
+ * promotes as part of its own reasoning, and an operator promotes by hand. A
2769
+ * fourth spelling would mean a fourth promotion path exists, which is exactly
2770
+ * what this closed union is here to prevent.
2771
+ */
2772
+ export type PromotedBy = "daemon" | "orchestrator" | "operator";
2773
+
2572
2774
  /**
2573
2775
  * One issue's current grooming verdict, durable across restarts and keyed by
2574
2776
  * project + issue — one row per issue, replaced in place by upsert, never a
@@ -2587,6 +2789,24 @@ export interface GroomingRecord {
2587
2789
  * or a scout summary. Free text, bounded at the write site. */
2588
2790
  evidence: string;
2589
2791
  recordedAt: number;
2792
+ /**
2793
+ * When this verdict was acted on by queueing the issue (#1041). Absent on
2794
+ * every verdict nobody promoted, which is the honest reading of a row from
2795
+ * before mechanical promotion existed: nothing recorded who queued it, so
2796
+ * nothing may claim to know.
2797
+ *
2798
+ * Set exactly once per verdict, by {@link Store.markGroomingPromoted}, and
2799
+ * cleared by the next `upsertGrooming` — a re-recorded verdict is a new
2800
+ * judgement about the issue, and provenance that outlived the judgement it
2801
+ * describes would audit the wrong decision.
2802
+ */
2803
+ promotedAt?: number;
2804
+ /** Who queued it: the daemon's own ready gate, an orchestrator tick, or an
2805
+ * operator at the CLI. Stored rather than inferred — the tick's promotion
2806
+ * audit exists precisely to review what the daemon did unattended, and a
2807
+ * guess would make that review circular. Always present when
2808
+ * {@link promotedAt} is, and absent when it is not. */
2809
+ promotedBy?: PromotedBy;
2590
2810
  }
2591
2811
 
2592
2812
  /** What a caller hands over. The store owns the timestamp and the replace-in-
@@ -2600,6 +2820,32 @@ export interface GroomingDraft {
2600
2820
  at: number;
2601
2821
  }
2602
2822
 
2823
+ /**
2824
+ * One operator's standing approval of an epic's scope (#1041).
2825
+ *
2826
+ * Deliberately its own durable fact rather than a resolved decision row, for
2827
+ * two reasons that both bite in production. First, approving an epic is an
2828
+ * explicit, revocable act with exactly one meaning — "the children of this
2829
+ * epic may be queued without asking me again" — while a decision row's
2830
+ * `resolution` is free text a human wrote for a human, and no amount of
2831
+ * parsing turns "yes, but do the migration first" into a machine gate. Second,
2832
+ * decision rows expire ({@link DECISION_TTL_MS}) and an approval must not:
2833
+ * scope consent does not lapse after a week just because nobody looked at it.
2834
+ *
2835
+ * Carries no `project` field because it is always read through a
2836
+ * project-scoped accessor — the scoping is the key, not a payload column an
2837
+ * unwary caller could compare against the wrong project.
2838
+ */
2839
+ export interface EpicApproval {
2840
+ issue: number;
2841
+ approvedAt: number;
2842
+ /** Who approved it, in the same spelling the report-withdrawal path already
2843
+ * uses: the session role when one is set, else `orchestrator` inside a
2844
+ * Herdr pane, else `operator`. Free text on purpose — it is provenance for
2845
+ * a human reading an audit line, never a gate anything branches on. */
2846
+ approvedBy: string;
2847
+ }
2848
+
2603
2849
  /**
2604
2850
  * Where one operator decision stands (#136).
2605
2851
  *
@@ -2781,7 +3027,7 @@ export interface Store {
2781
3027
  * two concurrent same-head findings cannot both create a row or overwrite
2782
3028
  * each other (the run row's state is the other half once the revision is
2783
3029
  * dispatched). */
2784
- enqueueReviewRevision(draft: Omit<ReviewRevisionRecord, "id">): ReviewRevisionEnqueue;
3030
+ enqueueReviewRevision(draft: ReviewRevisionDraft): ReviewRevisionEnqueue;
2785
3031
  /** The pending revision for one run, if any — the header the verb reads to
2786
3032
  * tell "this call folds into the in-flight round" from "this call opens a
2787
3033
  * new round" before the round-ceiling gate applies. */
@@ -2793,6 +3039,51 @@ export interface Store {
2793
3039
  latestReviewRound(project: string, runId: string): number;
2794
3040
  /** Record that a revision was handed to a worker. */
2795
3041
  markReviewRevisionDispatched(id: string, at: number): void;
3042
+ /** Record, once, how this review round is being launched (#1045).
3043
+ *
3044
+ * Write-once and atomic — one guarded UPDATE, never read-then-write, so two
3045
+ * dispatch passes cannot each decide. The FIRST decision for a round wins:
3046
+ *
3047
+ * - no decision yet ⇒ written, `true`;
3048
+ * - the identical decision again (a dispatch retry, a restart re-deciding
3049
+ * the same way) ⇒ no-op, `true`, and the original `launchDecidedAt`
3050
+ * stands;
3051
+ * - a *contradictory* decision (different mode, requested model or origin
3052
+ * lineage) ⇒ nothing written, `false`. The caller must re-read the
3053
+ * recorded decision and honour it rather than launching its own;
3054
+ * - no such round in this project ⇒ `false`.
3055
+ *
3056
+ * Touches the decision columns only. The issue, run, attempt/continuation
3057
+ * charges, round number, branch, PR URL, reviewed head, findings and
3058
+ * settlement history are the round's identity and are never written here. */
3059
+ recordReviewRevisionLaunch(
3060
+ project: string,
3061
+ id: string,
3062
+ decision: {
3063
+ launchMode: ReviewLaunchMode;
3064
+ requestedModel?: string;
3065
+ originSessionRef?: string;
3066
+ at: number;
3067
+ },
3068
+ ): boolean;
3069
+ /** Attach the fresh correction's own session lineage — and the model the
3070
+ * harness actually resolved — to a round already decided
3071
+ * `fresh-correction` (#1045). `false`, writing nothing, on a round decided
3072
+ * `resume-original`, on an undecided round, or on no such round: a resumed
3073
+ * round *is* its origin session, so recording a separate correction
3074
+ * lineage for one would invent a session that never existed.
3075
+ *
3076
+ * Not write-once, and deliberately so: an infra-killed round relaunches on
3077
+ * the same decision, and the newest fresh session is then the truth about
3078
+ * what is running. `resolvedModel` is only ever added, never cleared — a
3079
+ * later call without one leaves a known resolution in place. */
3080
+ recordReviewCorrectionSession(project: string, id: string, ref: string, resolvedModel?: string): boolean;
3081
+ /** The recorded launch decision and provenance for one round, or `undefined`
3082
+ * when no such round exists (#1045). Every field is absent on a round
3083
+ * nobody has decided and on every pre-#1045 row — the explicit unknown a
3084
+ * claim/retry/restart path must read *before* deciding, and the honest
3085
+ * projection a renderer must print instead of guessing. */
3086
+ reviewRevisionLaunch(project: string, id: string): ReviewCorrectionProvenance | undefined;
2796
3087
  /** Record how a revision ended. */
2797
3088
  settleReviewRevision(id: string, outcome: ReviewRevisionOutcome, at: number): void;
2798
3089
  /** Every revision row not yet settled — queued (`dispatchedAt` unset) and
@@ -3118,15 +3409,37 @@ export interface Store {
3118
3409
  getMaterialEvent(id: string): MaterialEvent | undefined;
3119
3410
  /** Ordinary material outcomes still owed, oldest first and bounded. */
3120
3411
  undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
3121
- /** Persist one raw idea, `pending`, for later grooming (#299). */
3412
+ /**
3413
+ * Persist one raw idea, `pending`, for later grooming (#299).
3414
+ *
3415
+ * Idempotent when {@link IntakeDraft.source} is present (Phase 4): the id is
3416
+ * derived from `project` + `source` and the insert is ignored if that id
3417
+ * already exists, so the hourly mining pass is safe to re-run. A collision
3418
+ * returns the EXISTING row untouched — which is how a caller tells a new
3419
+ * filing from a no-op (`item.createdAt !== draft.at`, or a `state` that is
3420
+ * no longer `pending`) — and in particular does NOT reopen an item the
3421
+ * operator dismissed.
3422
+ *
3423
+ * Without a `source` the id is random, so every call is a new row.
3424
+ */
3122
3425
  recordIntake(draft: IntakeDraft): IntakeItem;
3123
3426
  /** Ideas still `pending`, oldest first — what `intake list` and `status` read. */
3124
3427
  pendingIntake(project: string): IntakeItem[];
3125
3428
  /** Resolve one idea to `groomed` (recording the issue URL #300 chose) or
3126
3429
  * `dismissed`. `false` when the id is unknown. */
3127
3430
  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). */
3431
+ /**
3432
+ * Record the current grooming verdict for one issue, replacing any prior
3433
+ * row — one row per project + issue, never a history (#735).
3434
+ *
3435
+ * Clears {@link GroomingRecord.promotedAt}/{@link GroomingRecord.promotedBy}
3436
+ * (#1041). A re-recorded verdict is a fresh judgement about the issue, so
3437
+ * carrying the old promotion forward would let the audit attribute a queueing
3438
+ * to a verdict that no longer exists — and, worse, would make a demote-then-
3439
+ * regroom cycle read as though the demoted promotion still stood. A caller
3440
+ * that re-promotes stamps provenance again through
3441
+ * {@link markGroomingPromoted}, which is the only writer of these columns.
3442
+ */
3130
3443
  upsertGrooming(draft: GroomingDraft): void;
3131
3444
  /** The current grooming verdict for one issue, or undefined when none. */
3132
3445
  grooming(project: string, issue: number): GroomingRecord | undefined;
@@ -3142,6 +3455,15 @@ export interface Store {
3142
3455
  * self-heals instead of lingering as a cache someone must invalidate. Runs
3143
3456
  * at the end of every admission pass. Never touches `promotable` or
3144
3457
  * `considered` rows, which belong to #679's scout loop.
3458
+ *
3459
+ * A promoted row (#1041) is likewise inert here, in both directions: it is
3460
+ * neither overwritten with `blocked` nor swept by the self-clearing delete.
3461
+ * Promotion hands the issue to dispatch, and admission's lane/dependency
3462
+ * holds on a queued issue are flow control of the moment, not a re-grooming.
3463
+ * Without this the fleet would lose the audit line within one 5-minute
3464
+ * admission pass — a promoted issue whose lane overlaps an active run would
3465
+ * be rewritten as `blocked` and then deleted — long before the ~30-minute
3466
+ * tick that is supposed to review the promotion ever read it.
3145
3467
  */
3146
3468
  reconcileGrooming(
3147
3469
  project: string,
@@ -3162,6 +3484,48 @@ export interface Store {
3162
3484
  * snapshot here deletes verdicts nobody re-derived.
3163
3485
  */
3164
3486
  retireGroomingNotOpen(project: string, openIssues: readonly number[]): number[];
3487
+ /**
3488
+ * Stamp promotion provenance on one existing verdict (#1041), returning
3489
+ * whether this call is the one that stamped it.
3490
+ *
3491
+ * `false` on both no-op shapes, and the caller must treat them the same way:
3492
+ * there is no such verdict, or it already carries a promotion. That makes the
3493
+ * write the single-flight latch for promotion itself — the daemon enqueues the
3494
+ * queue label and fires the wake only when it wins this call, so a restart
3495
+ * mid-promotion re-reads a stamped row and does not queue the issue twice.
3496
+ * Provenance is therefore stamped BEFORE the label op, never after.
3497
+ */
3498
+ markGroomingPromoted(project: string, issue: number, at: number, by: PromotedBy): boolean;
3499
+ /**
3500
+ * Verdicts promoted at or after `since`, newest promotion first — the tick's
3501
+ * promotion audit (#1041): what was queued unattended while nobody was
3502
+ * looking, with the verdict and evidence that justified it still attached.
3503
+ *
3504
+ * Bounded by the window rather than by a count, because the audit's contract
3505
+ * is "everything since the last tick" — a truncated list would silently
3506
+ * un-audit a promotion.
3507
+ */
3508
+ promotionsSince(project: string, since: number): GroomingRecord[];
3509
+ /**
3510
+ * Record the operator's standing approval of one epic's scope (#1041).
3511
+ *
3512
+ * Idempotent and first-write-wins: re-approving keeps the original
3513
+ * `approvedAt` and `approvedBy`, because the durable fact is when consent was
3514
+ * given, not when it was last restated. A caller that must tell "approved
3515
+ * now" from "already approved" reads {@link epicApproval} first. Refuses a
3516
+ * non-integer or non-positive issue number rather than storing a fact about
3517
+ * an issue that cannot exist.
3518
+ */
3519
+ approveEpic(project: string, issue: number, at: number, by: string): void;
3520
+ /** Withdraw an epic's approval, `false` when there was none to withdraw.
3521
+ * Approval is revocable by design: scope consent an operator regrets must be
3522
+ * removable without deleting the epic or its children (#1041). */
3523
+ revokeEpicApproval(project: string, issue: number): boolean;
3524
+ /** One epic's approval, or `undefined` when it has none — which every gate
3525
+ * must read as "not approved", never as approval whose row is missing. */
3526
+ epicApproval(project: string, issue: number): EpicApproval | undefined;
3527
+ /** Every approved epic in this project, issue-ascending. */
3528
+ epicApprovals(project: string): EpicApproval[];
3165
3529
  /** Count and age source for status and digest prompt bounds. */
3166
3530
  digestBacklog(project: string): DigestBacklog;
3167
3531
  /** Add one bounded observation to the per-day friction rollup. */
@@ -3211,6 +3575,38 @@ export interface Store {
3211
3575
  bumpGhCalls?(day: string, source: string): void;
3212
3576
  /** The tracked call counts for a UTC day, per source. */
3213
3577
  ghCallsToday?(day: string): { source: string; calls: number }[];
3578
+ /**
3579
+ * Record one auto-restart the daemon fired at a wedged orchestrator
3580
+ * (Phase 4). A bare append, like {@link Store.recordGhRefusal}: the cap is
3581
+ * enforced by counting rows in a window, so there is no counter to reset and
3582
+ * a crash cannot leave a high-water mark that either disables the cap or
3583
+ * spends it twice. Rows older than a week are pruned in the same write.
3584
+ */
3585
+ recordOrchestratorRestart(project: string, at: number, reason: string): void;
3586
+ /**
3587
+ * This project's auto-restarts at or after `since`, NEWEST FIRST — the
3588
+ * bounded-restart gate counts them against its per-window cap, and the page
3589
+ * it sends when the cap is reached names the most recent reasons.
3590
+ */
3591
+ orchestratorRestartsSince(project: string, since: number): { at: number; reason: string }[];
3592
+ /**
3593
+ * Record what one daemon-owned session (grooming, adjudication) cost
3594
+ * (Phase 4 attribution). Append-only; these sessions have no lifecycle to
3595
+ * update, they either ran or they did not.
3596
+ *
3597
+ * Deliberately not a `runs` row: `computeStats` walks runs as issue journeys
3598
+ * and a role row in there would invent a journey, corrupt the settled counts
3599
+ * and misattribute grooming spend to a merge it did not produce.
3600
+ *
3601
+ * Omit `spendUsd` when the provider reported no cost — see
3602
+ * {@link SessionSpendRow}.
3603
+ */
3604
+ recordSessionSpend(row: SessionSpendRow): void;
3605
+ /** This project's daemon-owned session spend at or after `since`, newest
3606
+ * first. Never includes worker runs: those live in `runs` and are already
3607
+ * counted by {@link Store.statsRuns}, so summing both surfaces gives the
3608
+ * fleet's whole bill without double-counting either half. */
3609
+ sessionSpendSince(project: string, since: number): SessionSpendRow[];
3214
3610
  /**
3215
3611
  * Persist a rendered report `pending`, before anything is sent — the whole
3216
3612
  * point of #123 is that an undelivered report is a queryable row rather than
@@ -3665,10 +4061,15 @@ export const VERB_NAMES = [
3665
4061
  * re-enter normal review/merge without a fresh clone. Idempotent: an
3666
4062
  * already-matching PR is returned, not duplicated. */
3667
4063
  "conductor_pr_recover",
3668
- /** The one read verb. It answers with the merge gate's own verdict, so
4064
+ /** The first read verb. It answers with the merge gate's own verdict, so
3669
4065
  * "pushed-green" is the dispatcher's reading of the PR rather than a claim the
3670
4066
  * worker makes about itself from whatever it happened to run. */
3671
4067
  "conductor_pr_status",
4068
+ /** The second read verb (#1043 lane): the decisive log lines of the failing
4069
+ * jobs at one pull request's head, through the tracker's bounded
4070
+ * failed-attempt log read. A worker calls it to diagnose a red check it
4071
+ * must fix, instead of guessing from the check name. */
4072
+ "conductor_ci_logs",
3672
4073
  ] as const;
3673
4074
 
3674
4075
  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 {