omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { createHash, randomUUID } from "node:crypto";
48
48
  import { spawnSync } from "node:child_process";
49
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
51
  import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
52
  import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
@@ -82,32 +82,51 @@ import {
82
82
  type DispatchSummary,
83
83
  type DigestBacklog,
84
84
  type FrictionSignal,
85
+ type GroomingRecord,
85
86
  type HeldNotice,
86
87
  type InterruptCategory,
87
88
  type IntakeItem,
89
+ type IssueState,
88
90
  type MaterialEvent,
91
+ type ProjectConfig,
92
+ type ReadyIssue,
89
93
  type ReportScopeChoice,
90
94
  type ReportingPolicy,
91
95
  type ResolvedGrants,
92
96
  type Store,
93
97
  } from "./types.ts";
98
+ import { repoSlugFor } from "./gitops.ts";
99
+ import { makeTracker } from "./tracker/github.ts";
94
100
  import { formatDecisionDigest } from "./decisions.ts";
95
101
  import {
96
102
  ASK_TOOL,
103
+ askAnswerRowWrite,
97
104
  askParameterSchema,
98
105
  DEFAULT_ASK_TIMEOUT_SECONDS,
99
106
  MAX_ASK_TIMEOUT_SECONDS,
100
107
  MIN_ASK_TIMEOUT_SECONDS,
108
+ parseAskAnswerEnvelope,
101
109
  parseAskRequest,
102
110
  performAsk,
111
+ renderInteractiveAsk,
112
+ type AskAnswerEnvelope,
113
+ type AskInteractiveDelivery,
103
114
  type AskResult,
104
115
  } from "./ask.ts";
105
116
  import { deliverOperatorMessage } from "./reports.ts";
106
- import type { FailureClass, ProjectConfig, RecoveryAction, RunRecord } from "./types.ts";
117
+ import { effectiveLabels } from "./routing.ts";
118
+ import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
119
+ import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
107
120
  import { dbPath, openStore } from "./store.ts";
108
121
  import { digestDue, localDayKey } from "./digest-schedule.ts";
122
+ import {
123
+ parseToSpecEvidence,
124
+ recordToSpecGrooming,
125
+ TO_SPEC_MAX_SOURCE_AGE_MS,
126
+ TO_SPEC_SCHEMA,
127
+ } from "./to-spec.ts";
109
128
  import { heldNoticeId } from "./notices.ts";
110
- import { isActiveArmProof } from "./arm-challenge.ts";
129
+ import { acknowledgeArmReply } from "./arm-challenge.ts";
111
130
 
112
131
  /** The activation file. Absent means "this is not an orchestrator session". */
113
132
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -603,20 +622,88 @@ export function recoveryDigestLine(
603
622
  return lines.join("\n");
604
623
  }
605
624
 
625
+ /** Per-reason counts for a verdict slice — "file-lane 2, depends-on 1". */
626
+ function groomingGroupCounts(records: readonly GroomingRecord[]): string {
627
+ const counts = new Map<string, number>();
628
+ for (const record of records) {
629
+ counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
630
+ }
631
+ return [...counts.entries()].map(([reason, count]) => `${reason} ${count}`).join(", ");
632
+ }
633
+
634
+ /** Per-verdict counts for a slice — "promotable 1, considered 1". */
635
+ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
636
+ const counts = new Map<string, number>();
637
+ for (const record of records) {
638
+ counts.set(record.verdict, (counts.get(record.verdict) ?? 0) + 1);
639
+ }
640
+ return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
641
+ }
642
+
643
+ /**
644
+ * The queue-label inventory read fresh from the tracker on one tick, with the
645
+ * pending label ops applied exactly as the dispatch pass judges eligibility
646
+ * (#848). Queue verdicts in the present tense ("empty", "running low") are
647
+ * only ever drawn from this observation; a dispatch summary alone is dated
648
+ * history and must never be rendered as the live queue.
649
+ */
650
+ export interface QueueObservation {
651
+ /** Open issues carrying the queue label under the *effective* label set —
652
+ * the count dispatch would route against on its next pass. */
653
+ queued: number;
654
+ /** The instant the tracker observation was made, epoch ms. */
655
+ observedAt: number;
656
+ }
657
+
606
658
  /**
607
659
  * One line telling the orchestrator the routable queue is running dry (#181),
608
660
  * or `undefined` when healthy — no dispatch recorded yet, or the routable count
609
- * is at/above the grooming trigger.
661
+ * is at/above the grooming trigger. `grooming` is the durable per-issue verdict
662
+ * table (#735): `blocked` rows are admission's lane/dependency holds, so the
663
+ * line tells claimable candidates apart from a runway that cannot move —
664
+ * instead of inviting Duty 2 to groom work whose last pass held it, which is
665
+ * exactly the re-derivation this store exists to stop. A to-spec launch row
666
+ * (#777) carries the same `blocked` verdict as its durable in-flight marker,
667
+ * so it is told apart from the mechanical holds: it says "a batch is running",
668
+ * not "the lane cannot move", and counts neither as claimable nor as
669
+ * known-blocked.
670
+ *
671
+ * `queue` is the tracker observation the caller made THIS tick (#848). When
672
+ * present, the queue verdict reads in the present tense from that inventory,
673
+ * and dispatch-derived counts are only ever dated context. When absent the
674
+ * tracker could not be read, and this never asserts that the queue is empty:
675
+ * the dispatch row is rendered explicitly as-of instead.
610
676
  */
611
677
  export function queueDigestLine(
612
678
  summary: DispatchSummary | undefined,
613
679
  queueLabel: string,
614
680
  labelPrefix: string,
615
681
  groomBelow: number,
682
+ grooming: readonly GroomingRecord[] = [],
683
+ queue: QueueObservation | undefined = undefined,
616
684
  ): string | undefined {
617
685
  if (summary === undefined) return undefined;
686
+ if (queue !== undefined) {
687
+ return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming);
688
+ }
689
+ return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming);
690
+ }
691
+
692
+ /** The dated rendering: the last dispatch row is the only evidence, so every
693
+ * count carries its snapshot date and nothing is asserted as current. */
694
+ function datedQueueDigestLine(
695
+ summary: DispatchSummary,
696
+ queueLabel: string,
697
+ labelPrefix: string,
698
+ groomBelow: number,
699
+ grooming: readonly GroomingRecord[],
700
+ ): string | undefined {
701
+ const dated = new Date(summary.completedAt).toISOString();
618
702
  if (summary.ready === 0) {
619
- return `Queue: empty — nothing carries "${queueLabel}". Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report why there is nothing to do.`;
703
+ if (summary.paused === true) {
704
+ return `Queue: unchecked — the last pass (${dated}) was a hold that counted no queue and admitted nothing; the tracker could not be re-read this tick, so the live queue is NOT claimed empty. Groom only after a fresh read.`;
705
+ }
706
+ return `Queue: empty (as of the last dispatch, ${dated}) — nobody carried "${queueLabel}" on that count, and the live tracker was not re-read this tick, so this is a snapshot, not a present-tense claim. Groom the backlog (Duty 2) once a fresh read confirms it.`;
620
707
  }
621
708
  if (summary.routed === 0) {
622
709
  // `ready` counts claimed (in-flight) issues too; route() drops those with a
@@ -633,7 +720,7 @@ export function queueDigestLine(
633
720
  const staleLifecycle = summary.holds
634
721
  .filter((h) => h.reason === "stale-lifecycle")
635
722
  .reduce((n, h) => n + h.count, 0);
636
- let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
723
+ let line = `As of the last dispatch (${dated}): Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
637
724
  if (unroutable > 0) {
638
725
  line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
639
726
  }
@@ -648,7 +735,128 @@ export function queueDigestLine(
648
735
  return line;
649
736
  }
650
737
  if (summary.routed >= groomBelow) return undefined;
651
- let line = `Queue: running low ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
738
+ return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `);
739
+ }
740
+
741
+ /** The live rendering: the tracker observation is the queue label inventory,
742
+ * the dispatch summary supplies the claimability reading, and the shared
743
+ * low-queue diagnostics are composed underneath (#848 review 2). The raw
744
+ * carrier count appears only as a present-tense inventory fact — never as
745
+ * the grooming threshold, which must be claimability (`summary.routed`, the
746
+ * same count the to-spec offer gates on), or parked/in-flight/state-labelled
747
+ * carriers would silence a queue that dispatch cannot move. */
748
+ function liveQueueDigestLine(
749
+ summary: DispatchSummary,
750
+ queue: QueueObservation,
751
+ queueLabel: string,
752
+ labelPrefix: string,
753
+ groomBelow: number,
754
+ grooming: readonly GroomingRecord[],
755
+ ): string | undefined {
756
+ const queued = queue.queued;
757
+ const observed = new Date(queue.observedAt).toISOString();
758
+ // The present-tense empty verdict is the tracker's own word: nothing open
759
+ // carries the queue label right now. No dispatch snapshot may produce a
760
+ // live "empty" claim — that is the whole defect (#848).
761
+ if (queued === 0) {
762
+ return (
763
+ `Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
764
+ "Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
765
+ "why there is nothing to do."
766
+ );
767
+ }
768
+ // The grooming threshold is claimability — summary.routed — never raw
769
+ // carrier volume. Four in-flight/parked/unroutable carriers must not
770
+ // suppress the low-claimable signal, because the to-spec offer gates on
771
+ // the very same routed count and the two would split.
772
+ if (summary.routed >= groomBelow) return undefined;
773
+ const dated = new Date(summary.completedAt).toISOString();
774
+ const inventory = `Queue: ${queued} open issue${queued === 1 ? "" : "s"} carry "${queueLabel}" right now (tracker ${observed}); `;
775
+ if (summary.routed === 0) {
776
+ // Dispatch's own claimability read: `ready` counts claimed (in-flight)
777
+ // issues too, so "0 routable" must not blanket-blame missing `repo:`
778
+ // labels (#228), and a residual lifecycle label is Duty 1 reconciliation
779
+ // work, not spare depth (#611).
780
+ if (summary.paused === true) {
781
+ return (
782
+ inventory +
783
+ `the last dispatch (${dated}) was a hold that admitted nothing — the queued issues above are the work that resumes when claiming reopens.`
784
+ );
785
+ }
786
+ const claimed = summary.claimed ?? 0;
787
+ let line = inventory + `at the last dispatch (${dated}) ${summary.ready} ready, 0 spare — ${claimed} in flight`;
788
+ const unroutable = summary.holds
789
+ .filter((h) => h.reason.startsWith("unroutable:"))
790
+ .reduce((n, h) => n + h.count, 0);
791
+ const staleLifecycle = summary.holds
792
+ .filter((h) => h.reason === "stale-lifecycle")
793
+ .reduce((n, h) => n + h.count, 0);
794
+ if (unroutable > 0) {
795
+ line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
796
+ }
797
+ if (staleLifecycle > 0) {
798
+ line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
799
+ }
800
+ if (unroutable === 0 && staleLifecycle === 0) {
801
+ line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
802
+ } else {
803
+ line += ".";
804
+ }
805
+ return line;
806
+ }
807
+ // routed > 0 below the trigger: the live inventory leads, then the same
808
+ // low-queue diagnostics the dated path ships — the claimable/known-blocked
809
+ // split, in-flight to-spec batches, the considered backlog and this pass's
810
+ // holds (#735, #777, #679) — so a tracker read never hides them.
811
+ return inventory + lowQueueTail(summary, groomBelow, grooming, "");
812
+ }
813
+
814
+ /** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
815
+ * claimable/known-blocked split, in-flight to-spec batches, the considered
816
+ * backlog and this pass's holds. `lead` is the sentence opener the caller
817
+ * chooses — the dated path opens with the dispatch timestamp and "Queue:",
818
+ * the live path with its tracker inventory (#848 review 2). Sharing one tail
819
+ * means a tracker read can never hide these distinctions. */
820
+ function lowQueueTail(
821
+ summary: DispatchSummary,
822
+ groomBelow: number,
823
+ grooming: readonly GroomingRecord[],
824
+ lead: string,
825
+ ): string {
826
+ // The durable per-issue verdicts, not this pass's one-shot hold groups: a
827
+ // lane-blocked runway must read as "cannot move" even after a restart, and
828
+ // the orchestrator's own prior verdicts (#679) must not be re-derived.
829
+ //
830
+ // An in-flight to-spec launch (#777) is backlog inventory, not a routed
831
+ // ready candidate, a durable hold, or a verdict. Name it separately without
832
+ // subtracting it from the routed queue or printing lane/dependency guidance.
833
+ const inFlight = grooming.filter((g) => g.reason === TO_SPEC_IN_FLIGHT_REASON);
834
+ const knownBlocked = grooming.filter(
835
+ (g) => g.verdict === "blocked" && (g.reason === "file-lane" || g.reason === "depends-on"),
836
+ );
837
+ const considered = grooming.filter((g) => g.verdict === "promotable" || g.verdict === "considered");
838
+ const claimable = Math.max(0, summary.routed - knownBlocked.length);
839
+ const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
840
+ const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
841
+ let tail: string;
842
+ if (knownBlocked.length > 0 && claimable === 0) {
843
+ tail =
844
+ `${lead}running low — ${summary.routed} routable candidate(s), all known-blocked ` +
845
+ `(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
846
+ `${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
847
+ } else if (knownBlocked.length > 0 || inFlight.length > 0) {
848
+ tail =
849
+ `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
850
+ `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
851
+ `${busy} — groom only the claimable.`;
852
+ } else {
853
+ tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
854
+ }
855
+ if (considered.length > 0) {
856
+ tail +=
857
+ ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
858
+ `promote the promotable or groom new issues, never re-groom these.`;
859
+ }
652
860
  if (summary.admitted === 0 && summary.holds.length > 0) {
653
861
  const held = summary.holds
654
862
  .map((h) => {
@@ -656,9 +864,524 @@ export function queueDigestLine(
656
864
  return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
657
865
  })
658
866
  .join(", ");
659
- line += ` All held: ${held}.`;
867
+ tail += ` All held: ${held}.`;
660
868
  }
661
- return line;
869
+ return tail;
870
+ }
871
+
872
+ // ================================================================ to-spec
873
+ // launch lifecycle (#777). #772 shipped the strict to-spec contract
874
+ // (`TO_SPEC_SCHEMA` + `recordToSpecGrooming`); this section is the launch
875
+ // half: the mechanical candidate selection, the bounded prompt block that
876
+ // authorizes exactly one native `task` batch per low-queue tick, the
877
+ // tool-call gate that stamps the contract and records in-flight rows, and
878
+ // the result capture that routes every item's exact output through
879
+ // `recordToSpecGrooming` independently.
880
+ //
881
+ // Where each fact lives decides who enforces it — and every exclusion is
882
+ // enforced mechanically from authoritative data, never by prose:
883
+ //
884
+ // - Tracker facts — the open-issue pool, the park label, the parent/epic
885
+ // probe — are read ONCE, at launch composition time, through the existing
886
+ // Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
887
+ // snapshot into the mechanically selected batch: `offerToSpecLaunch`
888
+ // streams the snapshot through the store-side exclusions and the parent
889
+ // probe, and the returned block names the selected candidates as the ONLY
890
+ // batch this tick may launch. If the snapshot cannot be read, no batch is
891
+ // offered at all — the launch fails closed rather than trusting the model
892
+ // to self-filter.
893
+ // - Store facts — durable grooming rows (#735), admission's lane/dependency
894
+ // holds, active runs, in-flight launches — are enforced mechanically at
895
+ // composition time (the exclusion lines the block names) and again at
896
+ // `tool_call` time (the gate refuses an item the store contradicts).
897
+ // - The live source ref for each item cannot be known synchronously without
898
+ // a fresh per-repo read, so the model fetches it and declares it in the
899
+ // item's second contract line; the strict parser's 24h freshness ceiling
900
+ // re-vets the claim at persistence time, which is the boundary #772 set.
901
+ // - The gate additionally enforces the allowlist: a marker-bearing `task`
902
+ // call is refused unless every item's issue number AND routing repo match
903
+ // the batch this tick's token authorized. A parked or parent/epic
904
+ // candidate therefore cannot be stamped in-flight even if the model
905
+ // invents one — it was never on the list, and the list is the only thing
906
+ // the gate lets through.
907
+ //
908
+ // The pure selector (`selectToSpecBatch`) remains the single shared rule set:
909
+ // the offer feeds it the tracker-vetted pool and the gate runs the same
910
+ // exclusion on every item at call time, so a candidate excluded in the prompt
911
+ // is excluded in the gate for the same reason.
912
+
913
+ /** The `to-spec` agent every batch item runs under (shipped in
914
+ * `omp/agents/to-spec.md`, discovered through OMP's native task-agent
915
+ * discovery — never a custom process runtime). */
916
+ export const TO_SPEC_AGENT = "to-spec";
917
+
918
+ /** The maximum number of candidates one tick's batch may carry (#679's
919
+ * "small per-tick candidate limit"; the session-scoped task semaphore
920
+ * bounds concurrency underneath). */
921
+ export const TO_SPEC_BATCH_MAX = 3;
922
+
923
+ /**
924
+ * The `context` marker that identifies a conductor grooming batch to the
925
+ * `tool_call` gate. The launch block instructs the orchestrator to put
926
+ * `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
927
+ * shared `context`; the gate matches the marker and the exact token this tick
928
+ * issued, stamps the per-item contract (agent, strict schema), and persists
929
+ * the in-flight rows. A `task` call without this marker is the orchestrator's
930
+ * own and passes untouched.
931
+ */
932
+ export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
933
+
934
+ /** The grooming-table verdict row a launched-but-unfinished batch leaves behind
935
+ * (reason, on a `blocked` verdict): the durable in-flight marker that stops
936
+ * the next tick — or a restarted session — from re-launching the same item.
937
+ * `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
938
+ * result lands, and refusing-to-parse output must *not* be swallowed by the
939
+ * kept-prior path that protects prior `promotable`/`considered` verdicts. */
940
+ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
941
+
942
+ /**
943
+ * How long a launch row may sit before it is treated as a dead batch and the
944
+ * candidate becomes eligible again. A batch that dies with the process (a
945
+ * daemon stop between `tool_call` and delivery) must not park a candidate
946
+ * forever; the 24h ceiling matches the source-freshness ceiling, so a
947
+ * relaunched pass always reads new source evidence anyway.
948
+ */
949
+ export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
950
+
951
+ /** The first line of every batch item's `task`, in the shape the gate parses:
952
+ * `to-spec candidate: <owner/repo>#<issue> — <title>`. */
953
+ export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
954
+
955
+ /** The second line of every batch item's `task`, naming the authoritative
956
+ * source and the exact ref the item was groomed against:
957
+ * `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
958
+ * told the orchestrator to fetch as the repo's current default-branch head. */
959
+ export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
960
+
961
+ /** The tool the orchestrator calls to persist one completed item's exact raw
962
+ * output when the batch ran in the background (#777). Registered by this
963
+ * extension; subagents never see it — the `to-spec` agent's tool list is
964
+ * read-only and explicit. */
965
+ export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
966
+
967
+ /** One backlog candidate the mechanical gate can judge. The tracker facts
968
+ * (labels, epics) are read from the authoritative open-issue snapshot at
969
+ * launch composition time; they travel in this view so the selector stays
970
+ * deterministic and testable. */
971
+ export interface ToSpecCandidateView {
972
+ issue: number;
973
+ title: string;
974
+ /** The routing target — a routed `owner/repo` (from the issue's one
975
+ * `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
976
+ routing: string;
977
+ /** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
978
+ parked?: boolean;
979
+ /** A parent/epic with no independently runnable slice; read from the
980
+ * tracker's sub-issue probe. */
981
+ parent?: boolean;
982
+ }
983
+
984
+ /** One candidate the tick's token authorizes. The gate admits a batch item
985
+ * only when its issue number AND routing both match an entry here. */
986
+ export interface ToSpecLaunchItem {
987
+ issue: number;
988
+ /** The `owner/repo` the item's first contract line must name. */
989
+ routing: string;
990
+ /** The candidate title, as it appears in the item's first contract line. */
991
+ title: string;
992
+ }
993
+
994
+ /** The machine-readable item contract a batch item must satisfy. */
995
+ export interface ToSpecBatchItem {
996
+ issue: number;
997
+ /** The `owner/repo` named by the item's first contract line. */
998
+ routing: string;
999
+ /** The `owner/repo@ref` named by the item's second contract line. */
1000
+ sourceRef: string;
1001
+ /** The item's full task text (the rendered brief plus the contract lines). */
1002
+ task: string;
1003
+ }
1004
+
1005
+ /**
1006
+ * Parse the two contract lines a batch item must start with
1007
+ * (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
1008
+ * Anything else is not a conductor grooming item. Built from the shared
1009
+ * {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
1010
+ * the launch block's wording and the gate's parsing cannot drift apart.
1011
+ */
1012
+ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
1013
+ if (typeof task !== "string") return undefined;
1014
+ const lines = task.split("\n");
1015
+ const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
1016
+ lines[0]?.trim() ?? "",
1017
+ );
1018
+ const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
1019
+ lines[1]?.trim() ?? "",
1020
+ );
1021
+ if (head === null || source === null) return undefined;
1022
+ return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
1023
+ }
1024
+
1025
+ /**
1026
+ * Why one candidate is not eligible for a batch right now, or undefined when
1027
+ * it is. The single rule source for the prompt-time exclusion list, the
1028
+ * `tool_call` gate, and the selection helper — one rule, three readers, so a
1029
+ * candidate excluded in prose is excluded in the gate for the same reason.
1030
+ *
1031
+ * - `in-flight`: a launch row recorded within the TTL (a dead batch's row
1032
+ * expires and the candidate becomes eligible again);
1033
+ * - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
1034
+ * - a fresh valid `to-spec` result in the grooming table: the candidate was
1035
+ * already groomed at an observed source within the freshness ceiling, so
1036
+ * re-running it would recompute a verdict that is still valid. New source
1037
+ * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
1038
+ * the row no longer reads as groomed, and a fresh pass overrides it;
1039
+ * - `active`: a run is in flight on the issue right now.
1040
+ */
1041
+ export function toSpecCandidateExclusion(
1042
+ candidate: { issue: number },
1043
+ facts: { grooming: GroomingRecord | undefined; active: boolean },
1044
+ now: number,
1045
+ ): string | undefined {
1046
+ const row = facts.grooming;
1047
+ if (row !== undefined) {
1048
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1049
+ if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
1050
+ return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
1051
+ }
1052
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1053
+ return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
1054
+ } else {
1055
+ const result = parseToSpecEvidence(row.evidence);
1056
+ if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1057
+ return (
1058
+ `#${candidate.issue} was already groomed ${result.verdict} (source ${result.source.name}@` +
1059
+ `${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()}) — re-groom only ` +
1060
+ "with new source evidence"
1061
+ );
1062
+ }
1063
+ }
1064
+ }
1065
+ if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
1066
+ return undefined;
1067
+ }
1068
+
1069
+ /**
1070
+ * The mechanical half of Duty 2's launch: deterministic, bounded selection of
1071
+ * the eligible candidates, smallest issue numbers first, never more than
1072
+ * {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above the grooming trigger,
1073
+ * nothing before the first dispatch summary exists (queue health unknown —
1074
+ * the same gate the queue digest uses). Parked and parent views are honored
1075
+ * when the caller supplies them.
1076
+ *
1077
+ * Production reaches this selector through {@link offerToSpecLaunch}, which
1078
+ * fills the views from the authoritative tracker snapshot (park label from
1079
+ * the open-issue labels, parent/epic from the sub-issue probe) and streams
1080
+ * the store-side exclusions before the selector runs — so parked and parent
1081
+ * exclusions are enforced on the live path, not only on test inputs.
1082
+ */
1083
+ export function selectToSpecBatch(input: {
1084
+ candidates: readonly ToSpecCandidateView[];
1085
+ summary: DispatchSummary | undefined;
1086
+ groomBelow: number;
1087
+ grooming: readonly GroomingRecord[];
1088
+ active: readonly { issue: number }[];
1089
+ now: number;
1090
+ }): ToSpecCandidateView[] {
1091
+ if (input.summary === undefined) return [];
1092
+ if (input.summary.routed >= input.groomBelow) return [];
1093
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
1094
+ const activeIssues = new Set(input.active.map((run) => run.issue));
1095
+ const selected: ToSpecCandidateView[] = [];
1096
+ for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
1097
+ if (candidate.parked) continue;
1098
+ if (candidate.parent) continue;
1099
+ if (
1100
+ toSpecCandidateExclusion(
1101
+ { issue: candidate.issue },
1102
+ { grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
1103
+ input.now,
1104
+ ) !== undefined
1105
+ ) {
1106
+ continue;
1107
+ }
1108
+ selected.push(candidate);
1109
+ if (selected.length >= TO_SPEC_BATCH_MAX) break;
1110
+ }
1111
+ return selected;
1112
+ }
1113
+
1114
+ /**
1115
+ * One tick's launch authorization: the token, the block text it carries, and
1116
+ * the mechanically selected batch the token authorizes. The gate admits a
1117
+ * marker-bearing `task` call only when every item matches an entry of
1118
+ * {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
1119
+ * snapshot, never from model-supplied fields.
1120
+ */
1121
+ export interface ToSpecLaunchBlock {
1122
+ /** The per-launch token the batch must echo in its `context` first line. */
1123
+ token: string;
1124
+ block: string;
1125
+ /** The mechanically selected candidates — the ONLY batch this token may
1126
+ * carry (issue number AND routing must both match). */
1127
+ items: ToSpecLaunchItem[];
1128
+ }
1129
+
1130
+ /** Why candidates sit out, as one compact line each, for the launch block. */
1131
+ export interface ToSpecLaunchExclusions {
1132
+ /** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
1133
+ groomed: string[];
1134
+ /** Candidates with an active to-spec batch. */
1135
+ inFlight: string[];
1136
+ /** Candidates under admission's durable lane/dependency holds. */
1137
+ mechanicallyBlocked: string[];
1138
+ /** Candidates with a dispatched run live right now. */
1139
+ dispatched: string[];
1140
+ }
1141
+
1142
+ /**
1143
+ * The authoritative tracker surface the launch selection is built from
1144
+ * (#777): the two reads through the existing Tracker adapter that answer
1145
+ * "which open backlog issues are actually eligible right now". Production
1146
+ * implements this with `makeTracker(...).listOpenIssues()` /
1147
+ * `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
1148
+ * the one open-issue snapshot with labels (#203) — it answers the park
1149
+ * label and the routing label mechanically — and `childrenOf` answers
1150
+ * whether a candidate is a parent/epic (its sub-issues exist, so it has no
1151
+ * independently runnable slice of its own). Both fail closed: an unreadable
1152
+ * snapshot means no batch is offered, while an unreadable parent probe skips
1153
+ * that candidate because eligibility was not mechanically established.
1154
+ */
1155
+ export interface ToSpecTrackerSeam {
1156
+ /** Every open issue in the tracker repo, labels included
1157
+ * (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
1158
+ listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
1159
+ /** Sub-issues of one issue (`Tracker.childrenOf`). */
1160
+ childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
1161
+ }
1162
+
1163
+ /**
1164
+ * The tracker half of the candidate views: map one open-issue snapshot onto
1165
+ * the pool the selector can judge. Issues still carrying the queue label are
1166
+ * already queued — not backlog — and leave the pool; issues with zero or
1167
+ * several `routing.labelPrefix` labels (or a label mapping to no configured
1168
+ * repo) cannot name an authoritative source to read and leave the pool,
1169
+ * exactly like admission's unroutable partition. The park label lands on the
1170
+ * view for the selector to drop; the parent/epic probe is separate (one
1171
+ * tracker read per candidate) and stays with the offer, which runs it only
1172
+ * for candidates the store-side exclusions did not already reject.
1173
+ */
1174
+ function toSpecPoolFromSnapshot(
1175
+ issues: readonly ReadyIssue[],
1176
+ project: ProjectConfig,
1177
+ ): ToSpecCandidateView[] {
1178
+ const queueLabel = project.queueLabel;
1179
+ const parkLabel = project.stateLabels.backlog;
1180
+ const { labelPrefix, repos } = project.routing;
1181
+ const views: ToSpecCandidateView[] = [];
1182
+ for (const issue of issues) {
1183
+ if (issue.labels.includes(queueLabel)) continue;
1184
+ const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
1185
+ if (matched.length !== 1) continue;
1186
+ const key = matched[0]!.slice(labelPrefix.length);
1187
+ const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
1188
+ if (target === undefined) continue;
1189
+ views.push({
1190
+ issue: issue.number,
1191
+ title: issue.title,
1192
+ routing: repoSlugFor(target),
1193
+ parked: issue.labels.includes(parkLabel) ? true : undefined,
1194
+ });
1195
+ }
1196
+ views.sort((a, b) => a.issue - b.issue);
1197
+ return views;
1198
+ }
1199
+
1200
+ /**
1201
+ * The production launch offer for one tick: the complete mechanical
1202
+ * selection, from the authoritative tracker snapshot through every
1203
+ * exclusion, ending in the token + block + allowlist, or `undefined` when no
1204
+ * batch may launch. All of the following yield `undefined`:
1205
+ *
1206
+ * - no dispatch row yet, or the routable queue is at/above the grooming
1207
+ * trigger (the same gate the queue digest uses);
1208
+ * - no tracker seam (the snapshot is unavailable);
1209
+ * - the snapshot cannot be read — the launch fails closed rather than
1210
+ * trusting the model to self-filter parked/parent/epic candidates;
1211
+ * - nothing survives the exclusions (parked, parent/epic, already groomed,
1212
+ * in-flight, lane/dependency holds, dispatched runs) — there is then no
1213
+ * batch to authorize, and a marker-bearing `task` call stays refused.
1214
+ *
1215
+ * The parent/epic probe runs only for candidates the store-side exclusions
1216
+ * have not already rejected, in issue order, and stops as soon as
1217
+ * {@link TO_SPEC_BATCH_MAX} candidates are selected — a bounded set of
1218
+ * tracker reads per low-queue tick, never one per open issue.
1219
+ */
1220
+ export async function offerToSpecLaunch(input: {
1221
+ summary: DispatchSummary | undefined;
1222
+ groomBelow: number;
1223
+ grooming: readonly GroomingRecord[];
1224
+ active: readonly { issue: number }[];
1225
+ project: ProjectConfig;
1226
+ trackerSeam: ToSpecTrackerSeam | undefined;
1227
+ /** An open-issue snapshot this tick already read from the tracker. Shared
1228
+ * with the queue digest so one tick cannot describe two queues (#848);
1229
+ * absent when that read failed, in which case the launch retries its own
1230
+ * read and fails closed on the same terms as before. */
1231
+ issues?: readonly ReadyIssue[];
1232
+ now: number;
1233
+ }): Promise<ToSpecLaunchBlock | undefined> {
1234
+ if (input.summary === undefined) return undefined;
1235
+ if (input.summary.routed >= input.groomBelow) return undefined;
1236
+ const seam = input.trackerSeam;
1237
+ if (seam === undefined) return undefined;
1238
+ let issues = input.issues;
1239
+ if (issues === undefined) {
1240
+ try {
1241
+ issues = await seam.listOpenIssues(input.project);
1242
+ } catch {
1243
+ // No authoritative snapshot, no launch: a batch offered without one would
1244
+ // make the model the selector, which is exactly the defect this slice
1245
+ // removes. The queue digest still names the grooming duty; the next tick
1246
+ // retries the read.
1247
+ return undefined;
1248
+ }
1249
+ }
1250
+ const views = toSpecPoolFromSnapshot(issues, input.project);
1251
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
1252
+ const activeIssues = new Set(input.active.map((run) => run.issue));
1253
+ const pool: ToSpecCandidateView[] = [];
1254
+ for (const view of views) {
1255
+ if (view.parked) continue;
1256
+ if (
1257
+ toSpecCandidateExclusion(
1258
+ { issue: view.issue },
1259
+ { grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
1260
+ input.now,
1261
+ ) !== undefined
1262
+ ) {
1263
+ continue;
1264
+ }
1265
+ let children: { number: number; state: IssueState }[];
1266
+ try {
1267
+ children = await seam.childrenOf(input.project, view.issue);
1268
+ } catch {
1269
+ continue;
1270
+ }
1271
+ if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
1272
+ pool.push(view);
1273
+ if (pool.length >= TO_SPEC_BATCH_MAX) break;
1274
+ }
1275
+ // The one shared rule set re-runs on the tracker-vetted pool, so the pure
1276
+ // selector — not a prose instruction — answers what the block authorizes.
1277
+ const selected = selectToSpecBatch({
1278
+ candidates: pool,
1279
+ summary: input.summary,
1280
+ groomBelow: input.groomBelow,
1281
+ grooming: input.grooming,
1282
+ active: input.active,
1283
+ now: input.now,
1284
+ });
1285
+ if (selected.length === 0) return undefined;
1286
+ return toSpecLaunchBlock({
1287
+ summary: input.summary,
1288
+ groomBelow: input.groomBelow,
1289
+ grooming: input.grooming,
1290
+ active: input.active,
1291
+ tracker: input.project.tracker.repo,
1292
+ queueLabel: input.project.queueLabel,
1293
+ labelPrefix: input.project.routing.labelPrefix,
1294
+ parkLabel: input.project.stateLabels.backlog,
1295
+ selected,
1296
+ now: input.now,
1297
+ });
1298
+ }
1299
+
1300
+ /**
1301
+ * The per-tick launch block. Present exactly when a batch may be launched:
1302
+ * the queue is below the grooming trigger (`summary.routed < groomBelow`),
1303
+ * a dispatch row exists, and the mechanical selection produced at least one
1304
+ * candidate. The block names the selected candidates as the ONLY batch this
1305
+ * tick authorizes and lists every store-proven exclusion the selection
1306
+ * already applied; the `tool_call` gate refuses any item outside the list.
1307
+ */
1308
+ export function toSpecLaunchBlock(input: {
1309
+ summary: DispatchSummary | undefined;
1310
+ groomBelow: number;
1311
+ grooming: readonly GroomingRecord[];
1312
+ active: readonly { issue: number }[];
1313
+ tracker: string;
1314
+ queueLabel: string;
1315
+ labelPrefix: string;
1316
+ parkLabel: string;
1317
+ /** The mechanically selected candidates the block renders and authorizes. */
1318
+ selected: readonly ToSpecCandidateView[];
1319
+ now: number;
1320
+ }): ToSpecLaunchBlock | undefined {
1321
+ if (input.summary === undefined) return undefined;
1322
+ if (input.summary.routed >= input.groomBelow) return undefined;
1323
+ if (input.selected.length === 0) return undefined;
1324
+ const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
1325
+ for (const row of input.grooming) {
1326
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1327
+ if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
1328
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1329
+ exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
1330
+ } else {
1331
+ const result = parseToSpecEvidence(row.evidence);
1332
+ if (result !== undefined && input.now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1333
+ exclusions.groomed.push(
1334
+ `#${row.issue} (${result.verdict} @ ${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()})`,
1335
+ );
1336
+ }
1337
+ }
1338
+ }
1339
+ for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
1340
+ const token = randomUUID();
1341
+ const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
1342
+ issue: candidate.issue,
1343
+ routing: candidate.routing,
1344
+ title: candidate.title,
1345
+ }));
1346
+ const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
1347
+ const lines = [
1348
+ `## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
1349
+ "",
1350
+ `The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). ` +
1351
+ "Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
1352
+ `\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
1353
+ "",
1354
+ "The conductor selected this batch mechanically from the live open-issue snapshot " +
1355
+ `(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
1356
+ `\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
1357
+ "already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
1358
+ candidates,
1359
+ "Excluded this tick — " +
1360
+ `already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
1361
+ `in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
1362
+ `mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
1363
+ `dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
1364
+ "",
1365
+ "To launch it:",
1366
+ "",
1367
+ `1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
1368
+ "{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
1369
+ `${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
1370
+ "current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
1371
+ "`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
1372
+ "`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
1373
+ `2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
1374
+ `item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
1375
+ "extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
1376
+ `3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
1377
+ `exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
1378
+ "success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
1379
+ "successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
1380
+ `Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
1381
+ "promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
1382
+ "",
1383
+ ];
1384
+ return { token, block: lines.join("\n"), items };
662
1385
  }
663
1386
 
664
1387
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
@@ -2284,6 +3007,29 @@ interface TickSession {
2284
3007
  legacyArmLogged: boolean;
2285
3008
  /** The local tick whose agent loop is currently running, if any. */
2286
3009
  activeLocalTick?: ActiveLocalTick;
3010
+ /**
3011
+ * The to-spec launch token the last low-queue tick authorized (#777). Set
3012
+ * when the tick appended a launch block; the `task` tool_call gate accepts
3013
+ * exactly one batch echoing it and refuses a marker-bearing call without
3014
+ * it (no authorized batch this tick), with a stale token, or after a batch
3015
+ * was already accepted. Cleared at the start of every tick — healthy and
3016
+ * low-queue alike — so a token minted on a low-queue tick can never be
3017
+ * spent on a later healthy-queue tick; the tick that mints the next token
3018
+ * sets it fresh with its own {@link TickSession.launchItems}.
3019
+ */
3020
+ launchToken?: string;
3021
+ /** The project the {launchToken} authorization was minted for — the one
3022
+ * in-flight rows are recorded against. */
3023
+ launchProject?: string;
3024
+ /** The mechanically selected batch the current token authorizes: the gate
3025
+ * refuses any item whose issue or routing is not on this list, so parked,
3026
+ * parent/epic and other excluded candidates cannot be stamped in-flight
3027
+ * no matter what the model sends. */
3028
+ launchItems?: ToSpecLaunchItem[];
3029
+ /** The one batch this session's gate has let through, so a second attempt
3030
+ * this tick refuses, and the tool_result capture can match results to
3031
+ * items by index. */
3032
+ launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
2287
3033
  }
2288
3034
 
2289
3035
  /**
@@ -2357,7 +3103,20 @@ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boole
2357
3103
  * way. Skips are deliberately silent in the UI — a disarmed fleet would
2358
3104
  * otherwise emit a notification every interval, forever.
2359
3105
  */
2360
- async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): Promise<void> {
3106
+ async function tick(
3107
+ pi: TickApi,
3108
+ ctx: TickContext,
3109
+ config: TickConfig,
3110
+ session: TickSession,
3111
+ toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3112
+ ): Promise<void> {
3113
+ // A launch authorization belongs to one emitted tick only. Revoke it before
3114
+ // any gate, config, store or tracker read so a skipped/degraded/healthy next
3115
+ // tick cannot reuse a token minted by an earlier low-queue tick.
3116
+ session.launchedBatch = undefined;
3117
+ session.launchToken = undefined;
3118
+ session.launchProject = undefined;
3119
+ session.launchItems = undefined;
2361
3120
  const live = currentConfig(ctx.cwd, config);
2362
3121
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
2363
3122
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -2602,13 +3361,92 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
2602
3361
  // so the resolved name is in hand and an un-named lookup would refuse
2603
3362
  // to guess on a host with a second project.
2604
3363
  const project = findProject(loadConfig(), scope.projectName);
3364
+ const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
3365
+ const dispatch = frictionStore.latestDispatch(scope.projectName);
3366
+ const grooming = store.groomingVerdicts(projectName);
3367
+ // #848: "the queue is empty / running dry" is the tracker's word, not
3368
+ // the last dispatch pass's echo. One open-issue snapshot per tick,
3369
+ // read live through the same authoritative tracker surface the
3370
+ // to-spec launch uses, overlaid with the label_ops projection
3371
+ // exactly as the dispatch pass judges eligibility — so a promotion,
3372
+ // unblock or projection write after the last dispatch pass is
3373
+ // visible on the very next tick even while a drain holds claiming.
3374
+ // The digest consumes the overlay as the live inventory; the to-spec
3375
+ // offer shares the same raw snapshot so one tick cannot describe two
3376
+ // queues. If the tracker read fails, the digest falls back to
3377
+ // explicitly dated wording (never a present-tense empty claim),
3378
+ // and the offer retries its own read exactly as it did before.
3379
+ //
3380
+ // The read is made only for dispatch rows the digest could render:
3381
+ // a healthy row (routed at/above the grooming threshold) emits no
3382
+ // queue line at all, so no freshness check is spent on it and no
3383
+ // stale claim can leak from it. A zero-ready row (or any row below
3384
+ // the trigger) is exactly where the old wording lied, so every such
3385
+ // tick reads the live queue.
3386
+ const needsQueueRead =
3387
+ dispatch !== undefined &&
3388
+ (dispatch.ready === 0 || dispatch.routed === 0 || dispatch.routed < groomBelow);
3389
+ let queueObservation: QueueObservation | undefined;
3390
+ let openSnapshot: readonly ReadyIssue[] | undefined;
3391
+ if (toSpecTrackerSeam !== undefined && needsQueueRead) {
3392
+ try {
3393
+ const open = await toSpecTrackerSeam.listOpenIssues(project);
3394
+ openSnapshot = open;
3395
+ const effective = open.map((issue) => {
3396
+ const pending = store.pendingLabelOpsFor(projectName, issue.number);
3397
+ return pending.length === 0
3398
+ ? issue
3399
+ : { ...issue, labels: effectiveLabels(issue.labels, pending) };
3400
+ });
3401
+ queueObservation = {
3402
+ queued: effective.filter((issue) => issue.labels.includes(project.queueLabel)).length,
3403
+ observedAt: now,
3404
+ };
3405
+ } catch {
3406
+ // No current-queue evidence this tick: the digest below renders
3407
+ // the dispatch row as dated history instead of claiming empty.
3408
+ }
3409
+ }
2605
3410
  const queue = queueDigestLine(
2606
- frictionStore.latestDispatch(scope.projectName),
3411
+ dispatch,
2607
3412
  project.queueLabel,
2608
3413
  project.routing.labelPrefix,
2609
- project.groomBelow ?? DEFAULT_GROOM_BELOW,
3414
+ groomBelow,
3415
+ grooming,
3416
+ queueObservation,
2610
3417
  );
2611
3418
  if (queue !== undefined) content = `${content}\n${queue}`;
3419
+ // #777: the mechanical to-spec launch boundary. The queue digest is
3420
+ // the trigger — the same below-`groomBelow` signal that already asks
3421
+ // the orchestrator to groom — so the launch offer is composed beside
3422
+ // it, from the same store read, and only when a dispatch row exists
3423
+ // to prove the queue is genuinely low. The token it carries is the
3424
+ // only authorization the `task` tool_call gate accepts this tick;
3425
+ // absent a block (healthy queue, no dispatch, unreadable snapshot,
3426
+ // nothing eligible) a task call with the batch marker is refused.
3427
+ //
3428
+ // Every tick owns its authorization fresh — the clears above ran
3429
+ // before the reads, so the offer below can only mint for THIS tick.
3430
+ const launch = await offerToSpecLaunch({
3431
+ summary: dispatch,
3432
+ groomBelow,
3433
+ grooming,
3434
+ active: store.activeRuns(projectName),
3435
+ issues: openSnapshot,
3436
+ project,
3437
+ trackerSeam: toSpecTrackerSeam,
3438
+ now,
3439
+ });
3440
+ if (launch !== undefined) {
3441
+ content = `${content}\n${launch.block}`;
3442
+ // A minted token authorizes exactly the batch this tick's block
3443
+ // describes: the project and the allowlist are captured so the
3444
+ // tool_call gate records in-flight rows against the same project
3445
+ // the prompt named and refuses any item off the list.
3446
+ session.launchProject = project.name;
3447
+ session.launchToken = launch.token;
3448
+ session.launchItems = launch.items;
3449
+ }
2612
3450
  // Pending intake is the same class of standing block as the friction
2613
3451
  // and decisions read-outs: a store-backed duty the orchestrator must
2614
3452
  // not derive from memory. The store answers, the prompt instructs.
@@ -2619,7 +3457,7 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
2619
3457
  });
2620
3458
  if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
2621
3459
  } catch {
2622
- // unreadable config: no queue digest or pending-intake block this tick
3460
+ // unreadable config: no queue digest, launch block, or pending-intake block this tick
2623
3461
  }
2624
3462
  } catch (err) {
2625
3463
  frictionStore?.close();
@@ -2780,11 +3618,17 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
2780
3618
  * heartbeat interval keeps handing its promise to the harness, which routes
2781
3619
  * rejections to the extension error channel on its own.
2782
3620
  */
2783
- function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
3621
+ function armTickHeartbeat(
3622
+ pi: TickApi,
3623
+ ctx: TickContext,
3624
+ config: TickConfig,
3625
+ session: TickSession,
3626
+ toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3627
+ ): void {
2784
3628
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2785
3629
  const runScheduledTick = async (): Promise<void> => {
2786
3630
  try {
2787
- await tick(pi, ctx, config, session);
3631
+ await tick(pi, ctx, config, session, toSpecTrackerSeam);
2788
3632
  } finally {
2789
3633
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2790
3634
  }
@@ -2840,7 +3684,7 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
2840
3684
  // call runs inside the session_start handler dispatch, which cannot see an
2841
3685
  // async rejection — an escaped one reaches the process-level
2842
3686
  // unhandledRejection handler and takes the session down.
2843
- void tick(pi, ctx, config, session).catch((err) => {
3687
+ void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
2844
3688
  pi.logger.error(
2845
3689
  `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
2846
3690
  );
@@ -2884,6 +3728,303 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2884
3728
  });
2885
3729
  }
2886
3730
 
3731
+ /**
3732
+ * The harness surface one to-spec batch travels through (#777): the `task`
3733
+ * tool call the orchestrator makes (with the launch marker in `context`) and
3734
+ * its result. `details` is the task tool's `TaskToolDetails` — declared here
3735
+ * rather than imported, exactly like {@link TickApi}, because the harness is
3736
+ * a peer dependency. A settled batch's `details.results` carries one entry
3737
+ * per item; a background launch returns an empty `results` array and delivers
3738
+ * each final result later as an async-result message, which the orchestrator
3739
+ * routes through {@link TO_SPEC_RESULT_TOOL}.
3740
+ */
3741
+ interface ToSpecTaskToolEvent {
3742
+ toolName: string;
3743
+ toolCallId: string;
3744
+ input: Record<string, unknown>;
3745
+ details: unknown;
3746
+ }
3747
+
3748
+ /** One settled batch item, as `details.results` carries it. */
3749
+ interface ToSpecSettledItem {
3750
+ /** The item's index inside the batch call. */
3751
+ index?: unknown;
3752
+ /** The agent's raw output — the exact text that must reach the parser. */
3753
+ output?: unknown;
3754
+ /** The harness's parsed structured output, when one exists. */
3755
+ structuredOutput?: unknown;
3756
+ }
3757
+
3758
+ /**
3759
+ * The launch gate and result capture for one to-spec batch (#777). Armed at
3760
+ * extension-factory time; inert until a low-queue tick mints a launch token.
3761
+ *
3762
+ * `tool_call` on `task`:
3763
+ * - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
3764
+ * orchestrator's own task use and passes untouched;
3765
+ * - a marker-bearing call is the conductor batch and is gated hard: it needs
3766
+ * this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
3767
+ * items, each starting with the two contract lines), unique items and
3768
+ * names, every item ON the tick's mechanically selected allowlist (issue
3769
+ * and routing — the parked/parent/epic exclusions are baked into that
3770
+ * list, so a forbidden candidate can never be stamped in-flight), and
3771
+ * store-side eligibility — the same {@link toSpecCandidateExclusion} rule
3772
+ * the prompt block's exclusion lines came from, so an item excluded in
3773
+ * prose is excluded in the gate for the same reason;
3774
+ * - on acceptance it stamps every item's `agent`, `outputSchema` and
3775
+ * `schemaMode` (the model never carries the schema itself), records the
3776
+ * durable in-flight rows through the #735 grooming table, and latches the
3777
+ * session so a second batch this tick refuses.
3778
+ *
3779
+ * `tool_result` on the accepted call captures a *settled* batch: every item
3780
+ * is routed through {@link recordToSpecGrooming} independently, so a
3781
+ * malformed or failed item persists `blocked` and its siblings survive. A
3782
+ * background launch settles with no results here — its completed items arrive
3783
+ * as async-result messages and are persisted through
3784
+ * {@link TO_SPEC_RESULT_TOOL}.
3785
+ */
3786
+ function armToSpecGate(pi: TickApi, session: TickSession): void {
3787
+ const api = pi as TickApi & {
3788
+ on(
3789
+ event: "tool_call",
3790
+ handler: (
3791
+ event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
3792
+ ctx: unknown,
3793
+ ) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
3794
+ ): void;
3795
+ on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
3796
+ };
3797
+
3798
+ api.on("tool_call", (event) => {
3799
+ if (event.toolName !== "task") return undefined;
3800
+ const input = event.input;
3801
+ const context = input["context"];
3802
+ if (typeof context !== "string" || !context.includes(TO_SPEC_BATCH_MARKER)) return undefined;
3803
+ // From here on this is a conductor grooming batch — the hard gate. Each
3804
+ // refusal names the correction, because a blocked batch costs the tick a
3805
+ // retry and the gate is meant to catch model error, not to hide it.
3806
+ if (session.launchToken === undefined) {
3807
+ return {
3808
+ block: true,
3809
+ reason:
3810
+ `task refused: no to-spec batch is authorized this tick (` +
3811
+ `only a low-queue tick's launch block names a ${TO_SPEC_BATCH_MARKER} token). ` +
3812
+ "Run no batch this turn.",
3813
+ };
3814
+ }
3815
+ if (session.launchedBatch !== undefined) {
3816
+ return {
3817
+ block: true,
3818
+ reason: "task refused: this tick already launched its one to-spec batch. Wait for the results.",
3819
+ };
3820
+ }
3821
+ const token = new RegExp(`${TO_SPEC_BATCH_MARKER}:\\s*(\\S+)`).exec(context)?.[1];
3822
+ if (token !== session.launchToken) {
3823
+ return {
3824
+ block: true,
3825
+ reason:
3826
+ "task refused: the context token does not match the batch this tick authorized. " +
3827
+ "Relaunch with the token named in this tick's launch block.",
3828
+ };
3829
+ }
3830
+ const tasks = input["tasks"];
3831
+ if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > TO_SPEC_BATCH_MAX) {
3832
+ return {
3833
+ block: true,
3834
+ reason:
3835
+ `task refused: a to-spec batch carries 1–${TO_SPEC_BATCH_MAX} items (one \`tasks[]\` call) — ` +
3836
+ `got ${Array.isArray(tasks) ? tasks.length : "none"}.`,
3837
+ };
3838
+ }
3839
+ const items: ToSpecBatchItem[] = [];
3840
+ const names = new Set<string>();
3841
+ for (const raw of tasks) {
3842
+ if (typeof raw !== "object" || raw === null) {
3843
+ return { block: true, reason: "task refused: every batch item must be an object." };
3844
+ }
3845
+ const item = parseToSpecItem((raw as Record<string, unknown>)["task"]);
3846
+ if (item === undefined) {
3847
+ return {
3848
+ block: true,
3849
+ reason:
3850
+ "task refused: every item's `task` must start with the two contract lines " +
3851
+ "`to-spec candidate: <owner/repo>#<issue> — <title>` and `to-spec source: <owner/repo>@<ref>`.",
3852
+ };
3853
+ }
3854
+ const name = (raw as Record<string, unknown>)["name"];
3855
+ if (typeof name === "string" && name.length > 0) {
3856
+ if (names.has(name)) {
3857
+ return { block: true, reason: `task refused: duplicate item name \`${name}\`.` };
3858
+ }
3859
+ names.add(name);
3860
+ }
3861
+ items.push(item);
3862
+ }
3863
+ for (let i = 1; i < items.length; i += 1) {
3864
+ for (let j = 0; j < i; j += 1) {
3865
+ if (items[j]!.issue === items[i]!.issue) {
3866
+ return {
3867
+ block: true,
3868
+ reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
3869
+ };
3870
+ }
3871
+ }
3872
+ }
3873
+ // The allowlist is the mechanical selection this tick's token authorized:
3874
+ // every item's issue number AND routing must match an entry the launch
3875
+ // offer produced from the authoritative tracker snapshot. Parked,
3876
+ // parent/epic, already-groomed, in-flight, held and dispatched candidates
3877
+ // were never on it, so no item carrying one can be stamped in-flight here
3878
+ // — the model cannot self-select past the conductor's selection (#805).
3879
+ const allowlist = session.launchItems;
3880
+ if (allowlist === undefined) {
3881
+ return {
3882
+ block: true,
3883
+ reason:
3884
+ "task refused: this tick's launch did not carry a mechanically selected batch — " +
3885
+ "re-tick before launching.",
3886
+ };
3887
+ }
3888
+ for (const item of items) {
3889
+ if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
3890
+ const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
3891
+ return {
3892
+ block: true,
3893
+ reason:
3894
+ `task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
3895
+ `(${listed}). Launch exactly the selected candidates, no substitutes.`,
3896
+ };
3897
+ }
3898
+ }
3899
+ const projectName = session.launchProject;
3900
+ if (projectName === undefined) {
3901
+ return {
3902
+ block: true,
3903
+ reason: "task refused: the batch was authorized without a project — re-tick before launching.",
3904
+ };
3905
+ }
3906
+ // Store-side eligibility, the same rule the prompt block's exclusion list
3907
+ // came from. Eligible items then become durable in-flight rows, so a
3908
+ // crashed batch still suppresses re-launch until the TTL expires.
3909
+ let store: Store | undefined;
3910
+ try {
3911
+ store = openStore(dbPath());
3912
+ const byIssue = new Map(
3913
+ store.groomingVerdicts(projectName).map((row) => [row.issue, row] as const),
3914
+ );
3915
+ const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
3916
+ const now = Date.now();
3917
+ for (const item of items) {
3918
+ const reason = toSpecCandidateExclusion(
3919
+ { issue: item.issue },
3920
+ { grooming: byIssue.get(item.issue), active: active.has(item.issue) },
3921
+ now,
3922
+ );
3923
+ if (reason !== undefined) {
3924
+ return {
3925
+ block: true,
3926
+ reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
3927
+ };
3928
+ }
3929
+ }
3930
+ const launchedEvidence = JSON.stringify({
3931
+ kind: "to-spec-in-flight",
3932
+ launchedAt: now,
3933
+ batch: token,
3934
+ agent: TO_SPEC_AGENT,
3935
+ });
3936
+ for (const item of items) {
3937
+ store.upsertGrooming({
3938
+ project: projectName,
3939
+ issue: item.issue,
3940
+ verdict: "blocked",
3941
+ reason: TO_SPEC_IN_FLIGHT_REASON,
3942
+ evidence: launchedEvidence,
3943
+ at: now,
3944
+ });
3945
+ }
3946
+ } catch (err) {
3947
+ pi.logger.error(
3948
+ `[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
3949
+ );
3950
+ return {
3951
+ block: true,
3952
+ reason: `task refused: the launch could not be recorded durably (${
3953
+ err instanceof Error ? err.message : String(err)
3954
+ }); nothing was started.`,
3955
+ };
3956
+ } finally {
3957
+ store?.close();
3958
+ }
3959
+ const stampedTasks = tasks.map((raw) => ({
3960
+ ...(raw as Record<string, unknown>),
3961
+ agent: TO_SPEC_AGENT,
3962
+ outputSchema: TO_SPEC_SCHEMA,
3963
+ schemaMode: "strict",
3964
+ }));
3965
+ session.launchedBatch = { toolCallId: event.toolCallId, items };
3966
+ pi.logger.info(
3967
+ `[omp-conductor] to-spec batch launched: ${items.map((item) => `#${item.issue}`).join(", ")} (${token})`,
3968
+ );
3969
+ return { input: { ...input, tasks: stampedTasks } };
3970
+ });
3971
+
3972
+ api.on("tool_result", (event) => {
3973
+ if (event.toolName !== "task") return;
3974
+ const batch = session.launchedBatch;
3975
+ if (batch === undefined || event.toolCallId !== batch.toolCallId) return;
3976
+ const projectName = session.launchProject;
3977
+ if (projectName === undefined) return;
3978
+ const details = event.details as { results?: unknown } | undefined;
3979
+ const results = details?.results;
3980
+ if (!Array.isArray(results) || results.length === 0) {
3981
+ // Background launch: the settled items arrive later through
3982
+ // TO_SPEC_RESULT_TOOL; the in-flight rows keep them out of any new batch
3983
+ // until then.
3984
+ return;
3985
+ }
3986
+ let store: Store | undefined;
3987
+ try {
3988
+ store = openStore(dbPath());
3989
+ for (const entry of results) {
3990
+ const settled = entry as ToSpecSettledItem;
3991
+ const item = batch.items[typeof settled.index === "number" ? settled.index : -1];
3992
+ if (item === undefined) continue;
3993
+ // The raw output is the contract input — exactly as returned, even
3994
+ // when the item failed: the strict parser turns anything unparseable
3995
+ // into a blocked row, and a failed sibling never touches the others.
3996
+ const raw = typeof settled.output === "string" ? settled.output : "";
3997
+ const structured = settled.structuredOutput;
3998
+ const fallback =
3999
+ structured !== null &&
4000
+ typeof structured === "object" &&
4001
+ typeof (structured as { data?: unknown }).data === "object"
4002
+ ? JSON.stringify((structured as { data?: unknown }).data)
4003
+ : "";
4004
+ try {
4005
+ recordToSpecGrooming(store, {
4006
+ project: projectName,
4007
+ issue: item.issue,
4008
+ input: raw.trim().length > 0 ? raw : fallback,
4009
+ });
4010
+ } catch (err) {
4011
+ pi.logger.error(
4012
+ `[omp-conductor] to-spec result not persisted for #${item.issue}: ${
4013
+ err instanceof Error ? err.message : String(err)
4014
+ }`,
4015
+ );
4016
+ }
4017
+ }
4018
+ } catch (err) {
4019
+ pi.logger.error(
4020
+ `[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
4021
+ );
4022
+ } finally {
4023
+ store?.close();
4024
+ }
4025
+ });
4026
+ }
4027
+
2887
4028
  /**
2888
4029
  * Factory-time seams for the extension, used by tests. Production runs
2889
4030
  * `orchestratorTickExtension(pi)` with no options and gets the module's own
@@ -2892,7 +4033,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2892
4033
  * through the real tool deterministically, without real timers (#683).
2893
4034
  */
2894
4035
  export interface OrchestratorTickExtensionOptions {
2895
- ask?: { wait?: (ms: number) => Promise<void>; now?: () => number };
4036
+ ask?: {
4037
+ wait?: (ms: number) => Promise<void>;
4038
+ now?: () => number;
4039
+ /** Injected interactive delivery surface (#722); production builds its own. */
4040
+ interactive?: AskInteractiveDelivery;
4041
+ };
4042
+ /**
4043
+ * The authoritative tracker surface the low-queue to-spec launch reads
4044
+ * (#777). Production omits it and the extension builds the real seam over
4045
+ * the existing Tracker adapter for each project; tests inject deterministic
4046
+ * fakes so the mechanical selection is provable without a network. A
4047
+ * low-queue launch without a readable snapshot is refused entirely — the
4048
+ * launch fails closed rather than trusting the model to self-filter.
4049
+ */
4050
+ toSpec?: { tracker?: ToSpecTrackerSeam };
2896
4051
  }
2897
4052
 
2898
4053
  export default function orchestratorTickExtension(
@@ -2919,6 +4074,22 @@ export default function orchestratorTickExtension(
2919
4074
  let releaseGateArmed = false;
2920
4075
  let availabilityGateArmed = false;
2921
4076
  let guardArmed = false;
4077
+ // #777: the to-spec launch gate is factory-time like the tick guard. It acts
4078
+ // only when a low-queue tick has minted a launch token, so on any other
4079
+ // session — or any healthy queue — it observes `task` calls without touching
4080
+ // them.
4081
+ armToSpecGate(pi, session);
4082
+ // The authoritative tracker seam for the launch selection. Production runs
4083
+ // without options and builds the real seam lazily over the existing Tracker
4084
+ // adapter (one tracker per project read, per low-queue tick); tests inject a
4085
+ // deterministic fake. The seam is the ONLY tracker surface the launch path
4086
+ // touches — the tick never mutates tracker state and never lets the model
4087
+ // self-select candidates.
4088
+ const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
4089
+ options.toSpec?.tracker ?? {
4090
+ listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
4091
+ childrenOf: (project, issue) => makeTracker(project).childrenOf(issue),
4092
+ };
2922
4093
  // The bounded ask tool's session state. The tool itself is registered at
2923
4094
  // extension-factory time, before any session exists, because OMP snapshots
2924
4095
  // the extension's active tool set before it emits `session_start` — a tool
@@ -3047,22 +4218,25 @@ export default function orchestratorTickExtension(
3047
4218
  }
3048
4219
  return;
3049
4220
  }
3050
- // The reply to an arming challenge lands here as an ordinary user turn. It
3051
- // must reach the transcript exactly as sent the host's transcript scan
3052
- // (`armTicks`) is the backend proof, so it is left untouched — while the
3053
- // model gets a trusted, machine-readable steer about it, so it stops
3054
- // ad-libbing pairing-safety prose at a bare `FLEET-…` token (conductor
3055
- // #415). The classification derives from persisted authenticated
3056
- // pending-challenge state (hash + expiry, keyed by this project), never
3057
- // from the `FLEET-` prefix, so an unsolicited lookalike that matches no
3058
- // active challenge stays inert.
4221
+ // The reply to an arming challenge lands here as an ordinary user turn.
4222
+ // This adapter — not the model, and not any transcript scan is the
4223
+ // sole producer of the arming acknowledgement: it classifies the turn
4224
+ // against persisted authenticated challenge state and, on a match,
4225
+ // atomically records the challenge-id-specific acknowledgement the
4226
+ // host-side `arm` waits on (conductor #614), all before normal model
4227
+ // handling. The classification derives from the pending challenge (hash
4228
+ // + expiry, keyed by this project), never from the `FLEET-` prefix, so
4229
+ // an unsolicited lookalike that matches no active challenge stays inert
4230
+ // and model behaviour cannot determine whether the host becomes armed.
4231
+ // The turn itself still reaches the transcript exactly as sent, with the
4232
+ // same trusted machine-readable steer as before (#415).
3059
4233
  if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
3060
4234
  const replyText =
3061
4235
  message.content
3062
4236
  ?.filter((part) => part.type === "text" && typeof part.text === "string")
3063
4237
  .map((part) => part.text as string)
3064
4238
  .join(" ") ?? "";
3065
- const proof = isActiveArmProof(configuredProject, replyText, Date.now());
4239
+ const proof = acknowledgeArmReply(configuredProject, replyText, Date.now());
3066
4240
  if (session.activeLocalTick !== undefined) {
3067
4241
  session.activeLocalTick.humanWaiting = true;
3068
4242
  if (proof) session.activeLocalTick.armingProof = true;
@@ -3136,7 +4310,12 @@ export default function orchestratorTickExtension(
3136
4310
  `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
3137
4311
  `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
3138
4312
  `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s — an ask issued without ` +
3139
- `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
4313
+ `one still gets the default). When the Telegram surface can, the question posts as selectable ` +
4314
+ `buttons and a tap resolves the decision row with the chosen option; when it cannot, the ask ` +
4315
+ `degrades to plain text and the row records the degraded delivery. "recommended" is required ` +
4316
+ `whenever "on-timeout" is "auto-proceed" (the row must record what was auto-applied) and, when ` +
4317
+ `"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
4318
+ `When nobody answers, the declared "on-timeout" decides: ` +
3140
4319
  `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
3141
4320
  `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
3142
4321
  `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
@@ -3196,8 +4375,11 @@ export default function orchestratorTickExtension(
3196
4375
  turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
3197
4376
  // Test seam (#683): production omits `wait`/`now` and `performAsk`
3198
4377
  // falls back to the module's real-time defaults; a test that wants
3199
- // the timeout outcomes deterministically hands both in.
3200
- ...(options.ask === undefined ? {} : options.ask),
4378
+ // the timeout outcomes deterministically hands both in. The
4379
+ // interactive surface (#722) is production's default; a test may
4380
+ // inject a fake through the same seam.
4381
+ interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
4382
+ ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
3201
4383
  deliver: async (text, category) => {
3202
4384
  const at = Date.now();
3203
4385
  const noticeId = randomUUID();
@@ -3237,6 +4419,114 @@ export default function orchestratorTickExtension(
3237
4419
  },
3238
4420
  });
3239
4421
 
4422
+ // The async half of the to-spec result capture (#777). Registered at
4423
+ // extension-factory time like {@link ASK_TOOL}, with the same routing
4424
+ // contract: the state it needs (cwd + startup config) is filled by
4425
+ // `session_start` for an accepted fleet session, and the live config is
4426
+ // re-read at execution so a restamp binds the current project. The tool
4427
+ // only ever writes the grooming table — never an issue, a label, or a
4428
+ // dispatch row — so the output of a background batch persists while no
4429
+ // candidate can become claimable from it.
4430
+ pi.registerTool({
4431
+ name: TO_SPEC_RESULT_TOOL,
4432
+ label: TO_SPEC_RESULT_TOOL,
4433
+ description:
4434
+ `Persist the exact raw output of one completed to-spec grooming item (#777). ` +
4435
+ `Call it once per completed batch item after the batch settles — in the tool ` +
4436
+ `result, or when an async-result message delivers the item — passing the issue ` +
4437
+ `number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
4438
+ `inline text is truncated; never paraphrase). The conductor parses the output ` +
4439
+ `against the strict to-spec contract and records the verdict durably: a valid ` +
4440
+ `result persists its verdict, anything malformed, source-less or stale persists ` +
4441
+ `as blocked, and a failing item never discards its siblings. One call per item, ` +
4442
+ `success and failure alike; never edits an issue or a label.`,
4443
+ parameters: {
4444
+ type: "object",
4445
+ properties: {
4446
+ issue: { type: "integer", description: "The tracker issue number the item groomed." },
4447
+ input: {
4448
+ type: "string",
4449
+ description: "The to-spec agent's exact raw output for this item.",
4450
+ },
4451
+ },
4452
+ required: ["issue", "input"],
4453
+ additionalProperties: false,
4454
+ },
4455
+ approval: "write",
4456
+ execute: async (_toolCallId, params) => {
4457
+ const fleet = askSession;
4458
+ if (fleet === undefined) {
4459
+ return {
4460
+ content: [
4461
+ {
4462
+ type: "text",
4463
+ text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
4464
+ },
4465
+ ],
4466
+ isError: true,
4467
+ };
4468
+ }
4469
+ const issue = params["issue"];
4470
+ const input = params["input"];
4471
+ if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
4472
+ return {
4473
+ content: [
4474
+ { type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
4475
+ ],
4476
+ isError: true,
4477
+ };
4478
+ }
4479
+ const routed = resolveAskProject(fleet.cwd, fleet.config);
4480
+ if (routed.kind === "error") {
4481
+ return {
4482
+ content: [
4483
+ {
4484
+ type: "text",
4485
+ text:
4486
+ `${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
4487
+ "Keep the outputs in the transcript and persist after the config is repaired.",
4488
+ },
4489
+ ],
4490
+ isError: true,
4491
+ };
4492
+ }
4493
+ const store = openStore(dbPath());
4494
+ try {
4495
+ const outcome = recordToSpecGrooming(store, {
4496
+ project: routed.project.name,
4497
+ issue,
4498
+ input,
4499
+ });
4500
+ const record = outcome.record;
4501
+ const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
4502
+ return {
4503
+ content: [
4504
+ {
4505
+ type: "text",
4506
+ text:
4507
+ `${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
4508
+ "The grooming table now decides re-grooming; promotion stays yours.",
4509
+ },
4510
+ ],
4511
+ };
4512
+ } catch (err) {
4513
+ return {
4514
+ content: [
4515
+ {
4516
+ type: "text",
4517
+ text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
4518
+ err instanceof Error ? err.message : String(err)
4519
+ } — keep the output and retry the call.`,
4520
+ },
4521
+ ],
4522
+ isError: true,
4523
+ };
4524
+ } finally {
4525
+ store.close();
4526
+ }
4527
+ },
4528
+ });
4529
+
3240
4530
  pi.on("session_start", (_event, ctx) => {
3241
4531
  if (decided) return;
3242
4532
 
@@ -3358,7 +4648,7 @@ export default function orchestratorTickExtension(
3358
4648
  // not create or deliver decisions). `cwd` is for re-reading the live
3359
4649
  // tick config at execution, `config` for the startup-only ceiling.
3360
4650
  askSession = { cwd: ctx.cwd, config };
3361
- armTickHeartbeat(pi, ctx, config, session);
4651
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
3362
4652
  if (!guardArmed) {
3363
4653
  guardArmed = true;
3364
4654
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -3380,7 +4670,7 @@ export default function orchestratorTickExtension(
3380
4670
  // why): a declined or unresolved session keeps `askSession` undefined and
3381
4671
  // the tool fails closed.
3382
4672
  askSession = { cwd: ctx.cwd, config };
3383
- armTickHeartbeat(pi, ctx, config, session);
4673
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
3384
4674
  if (!guardArmed) {
3385
4675
  guardArmed = true;
3386
4676
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -3398,3 +4688,272 @@ export default function orchestratorTickExtension(
3398
4688
  );
3399
4689
  });
3400
4690
  }
4691
+
4692
+ /** One error's message, for a reason string — never a stack. */
4693
+ function errText(err: unknown): string {
4694
+ return err instanceof Error ? err.message : String(err);
4695
+ }
4696
+
4697
+ /**
4698
+ * Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
4699
+ * temp file in the same directory, then rename. The bridge reads these files
4700
+ * on a hot path (every tap), so a half-written request must never be visible.
4701
+ */
4702
+ function atomicallyWriteJson(path: string, value: unknown): void {
4703
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
4704
+ const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
4705
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
4706
+ renameSync(tmp, path);
4707
+ }
4708
+
4709
+ /** Remove one prompt file; a missing file is not an error. */
4710
+ function removeFile(path: string): void {
4711
+ try {
4712
+ rmSync(path, { force: true });
4713
+ } catch {
4714
+ // best effort — a leftover prompt request dies with its owner process
4715
+ }
4716
+ }
4717
+
4718
+ /**
4719
+ * The interactive Telegram surface for one bounded ask (#722).
4720
+ *
4721
+ * `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
4722
+ * running omp-telegram bridge acknowledges and answers through a documented
4723
+ * cross-process file protocol (guide.md: "prompts/ — Cross-process
4724
+ * selectable-question requests (live while their owning session is) and their
4725
+ * answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
4726
+ * bridge validates the tap against that request (responder, chat, topic,
4727
+ * message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
4728
+ * process reads the envelope and settles. `interactiveAskSurface` is that
4729
+ * asking-process half, nothing more: it posts the question with the same
4730
+ * `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
4731
+ * so the bridge recognizes the taps, and translates the envelope the bridge
4732
+ * writes into the decision row — the resolution is the chosen option's *label*,
4733
+ * never an index and never free text.
4734
+ *
4735
+ * The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
4736
+ * bridge's callback regex, and makes the pending question and its answer
4737
+ * addressable by the row that records them.
4738
+ */
4739
+ export function interactiveAskSurface(deps: {
4740
+ project: ProjectConfig;
4741
+ store: Store;
4742
+ /** Injected Bot API transport (tests); production posts to api.telegram.org. */
4743
+ call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
4744
+ /** Injected state dir (tests); production resolves it like the token. */
4745
+ stateDir?: string;
4746
+ /** Injected bot token (tests); production reads the state dir's .env. */
4747
+ token?: string;
4748
+ now?: () => number;
4749
+ }): AskInteractiveDelivery {
4750
+ const surfaceStateDir = deps.stateDir ?? telegramStateDir();
4751
+ const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
4752
+ const now = deps.now ?? Date.now;
4753
+ // Where each pending question physically sits, so collect/close can settle
4754
+ // the right message without re-reading the request file.
4755
+ const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
4756
+
4757
+ const promptsDir = (): string => join(surfaceStateDir, "prompts");
4758
+ const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
4759
+ const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
4760
+
4761
+ const ownerId = (): string | undefined => {
4762
+ let raw: string;
4763
+ try {
4764
+ raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
4765
+ } catch {
4766
+ return undefined;
4767
+ }
4768
+ let access: { allowFrom?: unknown };
4769
+ try {
4770
+ access = JSON.parse(raw) as { allowFrom?: unknown };
4771
+ } catch {
4772
+ return undefined;
4773
+ }
4774
+ const allowFrom = access.allowFrom;
4775
+ if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
4776
+ return undefined;
4777
+ }
4778
+ return allowFrom[0];
4779
+ };
4780
+
4781
+ const tokenAvailable = (): boolean =>
4782
+ deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
4783
+
4784
+ return {
4785
+ unavailableReason(request) {
4786
+ const chat = deps.project.escalation.telegramChatId;
4787
+ if (chat === undefined || chat === "") {
4788
+ return "no escalation.telegramChatId configured for this project";
4789
+ }
4790
+ if (!tokenAvailable()) {
4791
+ return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
4792
+ }
4793
+ if (ownerId() === undefined) {
4794
+ return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
4795
+ }
4796
+ // The interactive post is still a delivery under the reporting policy: a
4797
+ // question the policy would hold for the digest or the availability
4798
+ // window must not bypass that hold just because it has buttons.
4799
+ const disposition = interruptDisposition(
4800
+ deps.project.reporting,
4801
+ request.category ?? "decision-needed",
4802
+ now(),
4803
+ );
4804
+ if (disposition !== "interrupt") {
4805
+ return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
4806
+ }
4807
+ return undefined;
4808
+ },
4809
+
4810
+ async post(request, decisionId) {
4811
+ const chat = deps.project.escalation.telegramChatId;
4812
+ const owner = ownerId();
4813
+ if (chat === undefined || owner === undefined) {
4814
+ return {
4815
+ ok: false,
4816
+ reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
4817
+ };
4818
+ }
4819
+ const render = renderInteractiveAsk(request, decisionId);
4820
+ const threadId = resolveProjectTopicId(deps.project);
4821
+ let result: Record<string, unknown>;
4822
+ try {
4823
+ result = await call("sendMessage", {
4824
+ chat_id: chat,
4825
+ ...(threadId === undefined ? {} : { message_thread_id: threadId }),
4826
+ text: render.text,
4827
+ reply_markup: render.markup,
4828
+ });
4829
+ } catch (err) {
4830
+ return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
4831
+ }
4832
+ const messageId = result["message_id"];
4833
+ const chatType =
4834
+ typeof result["chat"] === "object" && result["chat"] !== null
4835
+ ? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
4836
+ : "private";
4837
+ if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
4838
+ // The question went out, but unaddressable — take it back rather than
4839
+ // leaving a button row nothing can settle.
4840
+ await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
4841
+ () => undefined,
4842
+ );
4843
+ return { ok: false, reason: "Telegram posted no usable message id" };
4844
+ }
4845
+ const recommended =
4846
+ request.recommended === undefined
4847
+ ? undefined
4848
+ : (request.options ?? []).findIndex((option) => option.label === request.recommended);
4849
+ const requestFile = {
4850
+ version: 1,
4851
+ nonce: decisionId,
4852
+ responderId: owner,
4853
+ chatId: chat,
4854
+ chatType,
4855
+ threadId,
4856
+ page: 0,
4857
+ messageId,
4858
+ questions: [
4859
+ {
4860
+ id: "q1",
4861
+ question: request.question,
4862
+ options: request.options ?? [],
4863
+ ...(recommended === undefined || recommended < 0 ? {} : { recommended }),
4864
+ },
4865
+ ],
4866
+ questionIndex: 0,
4867
+ answers: [],
4868
+ selectedIndices: [],
4869
+ awaitingText: (request.options ?? []).length === 0,
4870
+ ownerPid: process.pid,
4871
+ };
4872
+ try {
4873
+ atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
4874
+ } catch (err) {
4875
+ // The buttons went out but nothing would ever answer them — take the
4876
+ // message back rather than leave a dead keyboard in the chat.
4877
+ await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
4878
+ return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
4879
+ }
4880
+ posted.set(decisionId, { chatId: chat, messageId, settled: false });
4881
+ return { ok: true };
4882
+ },
4883
+
4884
+ collect(decisionId) {
4885
+ const state = posted.get(decisionId);
4886
+ if (state === undefined) return;
4887
+ let raw: string | undefined;
4888
+ try {
4889
+ raw = readFileSync(answerPath(decisionId), "utf8");
4890
+ } catch {
4891
+ return; // no answer yet
4892
+ }
4893
+ let parsed: AskAnswerEnvelope | undefined;
4894
+ try {
4895
+ parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
4896
+ } catch {
4897
+ parsed = undefined;
4898
+ }
4899
+ if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
4900
+ const write = askAnswerRowWrite(parsed);
4901
+ if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
4902
+ deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
4903
+ removeFile(requestPath(decisionId));
4904
+ removeFile(answerPath(decisionId));
4905
+ state.settled = true;
4906
+ const outcomeText =
4907
+ write.state === "withdrawn"
4908
+ ? write.resolution
4909
+ : `User selected: ${write.resolution}`;
4910
+ // The bridge edits its own prompts' messages; this surface owns the edit
4911
+ // for its own, so an answered ask reads as answered and a stale tap
4912
+ // finds no keyboard.
4913
+ call("editMessageText", {
4914
+ chat_id: state.chatId,
4915
+ message_id: state.messageId,
4916
+ text: outcomeText,
4917
+ reply_markup: { inline_keyboard: [] },
4918
+ }).catch(() => undefined);
4919
+ },
4920
+
4921
+ close(decisionId) {
4922
+ const state = posted.get(decisionId);
4923
+ removeFile(requestPath(decisionId));
4924
+ removeFile(answerPath(decisionId));
4925
+ if (state !== undefined && !state.settled) {
4926
+ call("editMessageReplyMarkup", {
4927
+ chat_id: state.chatId,
4928
+ message_id: state.messageId,
4929
+ reply_markup: { inline_keyboard: [] },
4930
+ }).catch(() => undefined);
4931
+ state.settled = true;
4932
+ }
4933
+ },
4934
+ };
4935
+ }
4936
+
4937
+ /** The Bot API transport: one JSON POST per call, the `result` back. */
4938
+ function telegramCall(
4939
+ token: string,
4940
+ ): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
4941
+ return async (method, payload) => {
4942
+ if (token === "") throw new Error("no Telegram bot token");
4943
+ const url = `https://api.telegram.org/bot${token}/${method}`;
4944
+ const res = await fetch(url, {
4945
+ method: "POST",
4946
+ headers: { "content-type": "application/json" },
4947
+ body: JSON.stringify(payload),
4948
+ });
4949
+ const raw = await res.text();
4950
+ if (!res.ok) {
4951
+ throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
4952
+ }
4953
+ const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
4954
+ if (parsed.ok !== true) {
4955
+ throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
4956
+ }
4957
+ return (parsed.result as Record<string, unknown>) ?? {};
4958
+ };
4959
+ }