omp-conductor 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. 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,30 +82,48 @@ 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 { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
118
+ import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
107
119
  import { dbPath, openStore } from "./store.ts";
108
120
  import { digestDue, localDayKey } from "./digest-schedule.ts";
121
+ import {
122
+ parseToSpecEvidence,
123
+ recordToSpecGrooming,
124
+ TO_SPEC_MAX_SOURCE_AGE_MS,
125
+ TO_SPEC_SCHEMA,
126
+ } from "./to-spec.ts";
109
127
  import { heldNoticeId } from "./notices.ts";
110
128
  import { isActiveArmProof } from "./arm-challenge.ts";
111
129
 
@@ -603,16 +621,43 @@ export function recoveryDigestLine(
603
621
  return lines.join("\n");
604
622
  }
605
623
 
624
+ /** Per-reason counts for a verdict slice — "file-lane 2, depends-on 1". */
625
+ function groomingGroupCounts(records: readonly GroomingRecord[]): string {
626
+ const counts = new Map<string, number>();
627
+ for (const record of records) {
628
+ counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
629
+ }
630
+ return [...counts.entries()].map(([reason, count]) => `${reason} ${count}`).join(", ");
631
+ }
632
+
633
+ /** Per-verdict counts for a slice — "promotable 1, considered 1". */
634
+ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
635
+ const counts = new Map<string, number>();
636
+ for (const record of records) {
637
+ counts.set(record.verdict, (counts.get(record.verdict) ?? 0) + 1);
638
+ }
639
+ return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
640
+ }
641
+
606
642
  /**
607
643
  * One line telling the orchestrator the routable queue is running dry (#181),
608
644
  * or `undefined` when healthy — no dispatch recorded yet, or the routable count
609
- * is at/above the grooming trigger.
645
+ * is at/above the grooming trigger. `grooming` is the durable per-issue verdict
646
+ * table (#735): `blocked` rows are admission's lane/dependency holds, so the
647
+ * line tells claimable candidates apart from a runway that cannot move —
648
+ * instead of inviting Duty 2 to groom work whose last pass held it, which is
649
+ * exactly the re-derivation this store exists to stop. A to-spec launch row
650
+ * (#777) carries the same `blocked` verdict as its durable in-flight marker,
651
+ * so it is told apart from the mechanical holds: it says "a batch is running",
652
+ * not "the lane cannot move", and counts neither as claimable nor as
653
+ * known-blocked.
610
654
  */
611
655
  export function queueDigestLine(
612
656
  summary: DispatchSummary | undefined,
613
657
  queueLabel: string,
614
658
  labelPrefix: string,
615
659
  groomBelow: number,
660
+ grooming: readonly GroomingRecord[] = [],
616
661
  ): string | undefined {
617
662
  if (summary === undefined) return undefined;
618
663
  if (summary.ready === 0) {
@@ -648,7 +693,40 @@ export function queueDigestLine(
648
693
  return line;
649
694
  }
650
695
  if (summary.routed >= groomBelow) return undefined;
651
- let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
696
+ // The durable per-issue verdicts, not this pass's one-shot hold groups: a
697
+ // lane-blocked runway must read as "cannot move" even after a restart, and
698
+ // the orchestrator's own prior verdicts (#679) must not be re-derived.
699
+ //
700
+ // An in-flight to-spec launch (#777) is backlog inventory, not a routed
701
+ // ready candidate, a durable hold, or a verdict. Name it separately without
702
+ // subtracting it from the routed queue or printing lane/dependency guidance.
703
+ const inFlight = grooming.filter((g) => g.reason === TO_SPEC_IN_FLIGHT_REASON);
704
+ const knownBlocked = grooming.filter(
705
+ (g) => g.verdict === "blocked" && (g.reason === "file-lane" || g.reason === "depends-on"),
706
+ );
707
+ const considered = grooming.filter((g) => g.verdict === "promotable" || g.verdict === "considered");
708
+ const claimable = Math.max(0, summary.routed - knownBlocked.length);
709
+ const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
710
+ const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
711
+ let line: string;
712
+ if (knownBlocked.length > 0 && claimable === 0) {
713
+ line =
714
+ `Queue: running low — ${summary.routed} routable candidate(s), all known-blocked ` +
715
+ `(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
716
+ `${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
717
+ } else if (knownBlocked.length > 0 || inFlight.length > 0) {
718
+ line =
719
+ `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
720
+ `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
721
+ `${busy} — groom only the claimable.`;
722
+ } else {
723
+ line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
724
+ }
725
+ if (considered.length > 0) {
726
+ line +=
727
+ ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
728
+ `promote the promotable or groom new issues, never re-groom these.`;
729
+ }
652
730
  if (summary.admitted === 0 && summary.holds.length > 0) {
653
731
  const held = summary.holds
654
732
  .map((h) => {
@@ -661,6 +739,514 @@ export function queueDigestLine(
661
739
  return line;
662
740
  }
663
741
 
742
+ // ================================================================ to-spec
743
+ // launch lifecycle (#777). #772 shipped the strict to-spec contract
744
+ // (`TO_SPEC_SCHEMA` + `recordToSpecGrooming`); this section is the launch
745
+ // half: the mechanical candidate selection, the bounded prompt block that
746
+ // authorizes exactly one native `task` batch per low-queue tick, the
747
+ // tool-call gate that stamps the contract and records in-flight rows, and
748
+ // the result capture that routes every item's exact output through
749
+ // `recordToSpecGrooming` independently.
750
+ //
751
+ // Where each fact lives decides who enforces it — and every exclusion is
752
+ // enforced mechanically from authoritative data, never by prose:
753
+ //
754
+ // - Tracker facts — the open-issue pool, the park label, the parent/epic
755
+ // probe — are read ONCE, at launch composition time, through the existing
756
+ // Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
757
+ // snapshot into the mechanically selected batch: `offerToSpecLaunch`
758
+ // streams the snapshot through the store-side exclusions and the parent
759
+ // probe, and the returned block names the selected candidates as the ONLY
760
+ // batch this tick may launch. If the snapshot cannot be read, no batch is
761
+ // offered at all — the launch fails closed rather than trusting the model
762
+ // to self-filter.
763
+ // - Store facts — durable grooming rows (#735), admission's lane/dependency
764
+ // holds, active runs, in-flight launches — are enforced mechanically at
765
+ // composition time (the exclusion lines the block names) and again at
766
+ // `tool_call` time (the gate refuses an item the store contradicts).
767
+ // - The live source ref for each item cannot be known synchronously without
768
+ // a fresh per-repo read, so the model fetches it and declares it in the
769
+ // item's second contract line; the strict parser's 24h freshness ceiling
770
+ // re-vets the claim at persistence time, which is the boundary #772 set.
771
+ // - The gate additionally enforces the allowlist: a marker-bearing `task`
772
+ // call is refused unless every item's issue number AND routing repo match
773
+ // the batch this tick's token authorized. A parked or parent/epic
774
+ // candidate therefore cannot be stamped in-flight even if the model
775
+ // invents one — it was never on the list, and the list is the only thing
776
+ // the gate lets through.
777
+ //
778
+ // The pure selector (`selectToSpecBatch`) remains the single shared rule set:
779
+ // the offer feeds it the tracker-vetted pool and the gate runs the same
780
+ // exclusion on every item at call time, so a candidate excluded in the prompt
781
+ // is excluded in the gate for the same reason.
782
+
783
+ /** The `to-spec` agent every batch item runs under (shipped in
784
+ * `omp/agents/to-spec.md`, discovered through OMP's native task-agent
785
+ * discovery — never a custom process runtime). */
786
+ export const TO_SPEC_AGENT = "to-spec";
787
+
788
+ /** The maximum number of candidates one tick's batch may carry (#679's
789
+ * "small per-tick candidate limit"; the session-scoped task semaphore
790
+ * bounds concurrency underneath). */
791
+ export const TO_SPEC_BATCH_MAX = 3;
792
+
793
+ /**
794
+ * The `context` marker that identifies a conductor grooming batch to the
795
+ * `tool_call` gate. The launch block instructs the orchestrator to put
796
+ * `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
797
+ * shared `context`; the gate matches the marker and the exact token this tick
798
+ * issued, stamps the per-item contract (agent, strict schema), and persists
799
+ * the in-flight rows. A `task` call without this marker is the orchestrator's
800
+ * own and passes untouched.
801
+ */
802
+ export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
803
+
804
+ /** The grooming-table verdict row a launched-but-unfinished batch leaves behind
805
+ * (reason, on a `blocked` verdict): the durable in-flight marker that stops
806
+ * the next tick — or a restarted session — from re-launching the same item.
807
+ * `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
808
+ * result lands, and refusing-to-parse output must *not* be swallowed by the
809
+ * kept-prior path that protects prior `promotable`/`considered` verdicts. */
810
+ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
811
+
812
+ /**
813
+ * How long a launch row may sit before it is treated as a dead batch and the
814
+ * candidate becomes eligible again. A batch that dies with the process (a
815
+ * daemon stop between `tool_call` and delivery) must not park a candidate
816
+ * forever; the 24h ceiling matches the source-freshness ceiling, so a
817
+ * relaunched pass always reads new source evidence anyway.
818
+ */
819
+ export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
820
+
821
+ /** The first line of every batch item's `task`, in the shape the gate parses:
822
+ * `to-spec candidate: <owner/repo>#<issue> — <title>`. */
823
+ export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
824
+
825
+ /** The second line of every batch item's `task`, naming the authoritative
826
+ * source and the exact ref the item was groomed against:
827
+ * `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
828
+ * told the orchestrator to fetch as the repo's current default-branch head. */
829
+ export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
830
+
831
+ /** The tool the orchestrator calls to persist one completed item's exact raw
832
+ * output when the batch ran in the background (#777). Registered by this
833
+ * extension; subagents never see it — the `to-spec` agent's tool list is
834
+ * read-only and explicit. */
835
+ export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
836
+
837
+ /** One backlog candidate the mechanical gate can judge. The tracker facts
838
+ * (labels, epics) are read from the authoritative open-issue snapshot at
839
+ * launch composition time; they travel in this view so the selector stays
840
+ * deterministic and testable. */
841
+ export interface ToSpecCandidateView {
842
+ issue: number;
843
+ title: string;
844
+ /** The routing target — a routed `owner/repo` (from the issue's one
845
+ * `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
846
+ routing: string;
847
+ /** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
848
+ parked?: boolean;
849
+ /** A parent/epic with no independently runnable slice; read from the
850
+ * tracker's sub-issue probe. */
851
+ parent?: boolean;
852
+ }
853
+
854
+ /** One candidate the tick's token authorizes. The gate admits a batch item
855
+ * only when its issue number AND routing both match an entry here. */
856
+ export interface ToSpecLaunchItem {
857
+ issue: number;
858
+ /** The `owner/repo` the item's first contract line must name. */
859
+ routing: string;
860
+ /** The candidate title, as it appears in the item's first contract line. */
861
+ title: string;
862
+ }
863
+
864
+ /** The machine-readable item contract a batch item must satisfy. */
865
+ export interface ToSpecBatchItem {
866
+ issue: number;
867
+ /** The `owner/repo` named by the item's first contract line. */
868
+ routing: string;
869
+ /** The `owner/repo@ref` named by the item's second contract line. */
870
+ sourceRef: string;
871
+ /** The item's full task text (the rendered brief plus the contract lines). */
872
+ task: string;
873
+ }
874
+
875
+ /**
876
+ * Parse the two contract lines a batch item must start with
877
+ * (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
878
+ * Anything else is not a conductor grooming item. Built from the shared
879
+ * {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
880
+ * the launch block's wording and the gate's parsing cannot drift apart.
881
+ */
882
+ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
883
+ if (typeof task !== "string") return undefined;
884
+ const lines = task.split("\n");
885
+ const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
886
+ lines[0]?.trim() ?? "",
887
+ );
888
+ const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
889
+ lines[1]?.trim() ?? "",
890
+ );
891
+ if (head === null || source === null) return undefined;
892
+ return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
893
+ }
894
+
895
+ /**
896
+ * Why one candidate is not eligible for a batch right now, or undefined when
897
+ * it is. The single rule source for the prompt-time exclusion list, the
898
+ * `tool_call` gate, and the selection helper — one rule, three readers, so a
899
+ * candidate excluded in prose is excluded in the gate for the same reason.
900
+ *
901
+ * - `in-flight`: a launch row recorded within the TTL (a dead batch's row
902
+ * expires and the candidate becomes eligible again);
903
+ * - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
904
+ * - a fresh valid `to-spec` result in the grooming table: the candidate was
905
+ * already groomed at an observed source within the freshness ceiling, so
906
+ * re-running it would recompute a verdict that is still valid. New source
907
+ * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
908
+ * the row no longer reads as groomed, and a fresh pass overrides it;
909
+ * - `active`: a run is in flight on the issue right now.
910
+ */
911
+ export function toSpecCandidateExclusion(
912
+ candidate: { issue: number },
913
+ facts: { grooming: GroomingRecord | undefined; active: boolean },
914
+ now: number,
915
+ ): string | undefined {
916
+ const row = facts.grooming;
917
+ if (row !== undefined) {
918
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
919
+ if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
920
+ return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
921
+ }
922
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
923
+ return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
924
+ } else {
925
+ const result = parseToSpecEvidence(row.evidence);
926
+ if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
927
+ return (
928
+ `#${candidate.issue} was already groomed ${result.verdict} (source ${result.source.name}@` +
929
+ `${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()}) — re-groom only ` +
930
+ "with new source evidence"
931
+ );
932
+ }
933
+ }
934
+ }
935
+ if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
936
+ return undefined;
937
+ }
938
+
939
+ /**
940
+ * The mechanical half of Duty 2's launch: deterministic, bounded selection of
941
+ * the eligible candidates, smallest issue numbers first, never more than
942
+ * {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above the grooming trigger,
943
+ * nothing before the first dispatch summary exists (queue health unknown —
944
+ * the same gate the queue digest uses). Parked and parent views are honored
945
+ * when the caller supplies them.
946
+ *
947
+ * Production reaches this selector through {@link offerToSpecLaunch}, which
948
+ * fills the views from the authoritative tracker snapshot (park label from
949
+ * the open-issue labels, parent/epic from the sub-issue probe) and streams
950
+ * the store-side exclusions before the selector runs — so parked and parent
951
+ * exclusions are enforced on the live path, not only on test inputs.
952
+ */
953
+ export function selectToSpecBatch(input: {
954
+ candidates: readonly ToSpecCandidateView[];
955
+ summary: DispatchSummary | undefined;
956
+ groomBelow: number;
957
+ grooming: readonly GroomingRecord[];
958
+ active: readonly { issue: number }[];
959
+ now: number;
960
+ }): ToSpecCandidateView[] {
961
+ if (input.summary === undefined) return [];
962
+ if (input.summary.routed >= input.groomBelow) return [];
963
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
964
+ const activeIssues = new Set(input.active.map((run) => run.issue));
965
+ const selected: ToSpecCandidateView[] = [];
966
+ for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
967
+ if (candidate.parked) continue;
968
+ if (candidate.parent) continue;
969
+ if (
970
+ toSpecCandidateExclusion(
971
+ { issue: candidate.issue },
972
+ { grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
973
+ input.now,
974
+ ) !== undefined
975
+ ) {
976
+ continue;
977
+ }
978
+ selected.push(candidate);
979
+ if (selected.length >= TO_SPEC_BATCH_MAX) break;
980
+ }
981
+ return selected;
982
+ }
983
+
984
+ /**
985
+ * One tick's launch authorization: the token, the block text it carries, and
986
+ * the mechanically selected batch the token authorizes. The gate admits a
987
+ * marker-bearing `task` call only when every item matches an entry of
988
+ * {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
989
+ * snapshot, never from model-supplied fields.
990
+ */
991
+ export interface ToSpecLaunchBlock {
992
+ /** The per-launch token the batch must echo in its `context` first line. */
993
+ token: string;
994
+ block: string;
995
+ /** The mechanically selected candidates — the ONLY batch this token may
996
+ * carry (issue number AND routing must both match). */
997
+ items: ToSpecLaunchItem[];
998
+ }
999
+
1000
+ /** Why candidates sit out, as one compact line each, for the launch block. */
1001
+ export interface ToSpecLaunchExclusions {
1002
+ /** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
1003
+ groomed: string[];
1004
+ /** Candidates with an active to-spec batch. */
1005
+ inFlight: string[];
1006
+ /** Candidates under admission's durable lane/dependency holds. */
1007
+ mechanicallyBlocked: string[];
1008
+ /** Candidates with a dispatched run live right now. */
1009
+ dispatched: string[];
1010
+ }
1011
+
1012
+ /**
1013
+ * The authoritative tracker surface the launch selection is built from
1014
+ * (#777): the two reads through the existing Tracker adapter that answer
1015
+ * "which open backlog issues are actually eligible right now". Production
1016
+ * implements this with `makeTracker(...).listOpenIssues()` /
1017
+ * `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
1018
+ * the one open-issue snapshot with labels (#203) — it answers the park
1019
+ * label and the routing label mechanically — and `childrenOf` answers
1020
+ * whether a candidate is a parent/epic (its sub-issues exist, so it has no
1021
+ * independently runnable slice of its own). Both fail closed: an unreadable
1022
+ * snapshot means no batch is offered, while an unreadable parent probe skips
1023
+ * that candidate because eligibility was not mechanically established.
1024
+ */
1025
+ export interface ToSpecTrackerSeam {
1026
+ /** Every open issue in the tracker repo, labels included
1027
+ * (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
1028
+ listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
1029
+ /** Sub-issues of one issue (`Tracker.childrenOf`). */
1030
+ childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
1031
+ }
1032
+
1033
+ /**
1034
+ * The tracker half of the candidate views: map one open-issue snapshot onto
1035
+ * the pool the selector can judge. Issues still carrying the queue label are
1036
+ * already queued — not backlog — and leave the pool; issues with zero or
1037
+ * several `routing.labelPrefix` labels (or a label mapping to no configured
1038
+ * repo) cannot name an authoritative source to read and leave the pool,
1039
+ * exactly like admission's unroutable partition. The park label lands on the
1040
+ * view for the selector to drop; the parent/epic probe is separate (one
1041
+ * tracker read per candidate) and stays with the offer, which runs it only
1042
+ * for candidates the store-side exclusions did not already reject.
1043
+ */
1044
+ function toSpecPoolFromSnapshot(
1045
+ issues: readonly ReadyIssue[],
1046
+ project: ProjectConfig,
1047
+ ): ToSpecCandidateView[] {
1048
+ const queueLabel = project.queueLabel;
1049
+ const parkLabel = project.stateLabels.backlog;
1050
+ const { labelPrefix, repos } = project.routing;
1051
+ const views: ToSpecCandidateView[] = [];
1052
+ for (const issue of issues) {
1053
+ if (issue.labels.includes(queueLabel)) continue;
1054
+ const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
1055
+ if (matched.length !== 1) continue;
1056
+ const key = matched[0]!.slice(labelPrefix.length);
1057
+ const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
1058
+ if (target === undefined) continue;
1059
+ views.push({
1060
+ issue: issue.number,
1061
+ title: issue.title,
1062
+ routing: repoSlugFor(target),
1063
+ parked: issue.labels.includes(parkLabel) ? true : undefined,
1064
+ });
1065
+ }
1066
+ views.sort((a, b) => a.issue - b.issue);
1067
+ return views;
1068
+ }
1069
+
1070
+ /**
1071
+ * The production launch offer for one tick: the complete mechanical
1072
+ * selection, from the authoritative tracker snapshot through every
1073
+ * exclusion, ending in the token + block + allowlist, or `undefined` when no
1074
+ * batch may launch. All of the following yield `undefined`:
1075
+ *
1076
+ * - no dispatch row yet, or the routable queue is at/above the grooming
1077
+ * trigger (the same gate the queue digest uses);
1078
+ * - no tracker seam (the snapshot is unavailable);
1079
+ * - the snapshot cannot be read — the launch fails closed rather than
1080
+ * trusting the model to self-filter parked/parent/epic candidates;
1081
+ * - nothing survives the exclusions (parked, parent/epic, already groomed,
1082
+ * in-flight, lane/dependency holds, dispatched runs) — there is then no
1083
+ * batch to authorize, and a marker-bearing `task` call stays refused.
1084
+ *
1085
+ * The parent/epic probe runs only for candidates the store-side exclusions
1086
+ * have not already rejected, in issue order, and stops as soon as
1087
+ * {@link TO_SPEC_BATCH_MAX} candidates are selected — a bounded set of
1088
+ * tracker reads per low-queue tick, never one per open issue.
1089
+ */
1090
+ export async function offerToSpecLaunch(input: {
1091
+ summary: DispatchSummary | undefined;
1092
+ groomBelow: number;
1093
+ grooming: readonly GroomingRecord[];
1094
+ active: readonly { issue: number }[];
1095
+ project: ProjectConfig;
1096
+ trackerSeam: ToSpecTrackerSeam | undefined;
1097
+ now: number;
1098
+ }): Promise<ToSpecLaunchBlock | undefined> {
1099
+ if (input.summary === undefined) return undefined;
1100
+ if (input.summary.routed >= input.groomBelow) return undefined;
1101
+ const seam = input.trackerSeam;
1102
+ if (seam === undefined) return undefined;
1103
+ let issues: ReadyIssue[];
1104
+ try {
1105
+ issues = await seam.listOpenIssues(input.project);
1106
+ } catch {
1107
+ // No authoritative snapshot, no launch: a batch offered without one would
1108
+ // make the model the selector, which is exactly the defect this slice
1109
+ // removes. The queue digest still names the grooming duty; the next tick
1110
+ // retries the read.
1111
+ return undefined;
1112
+ }
1113
+ const views = toSpecPoolFromSnapshot(issues, input.project);
1114
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
1115
+ const activeIssues = new Set(input.active.map((run) => run.issue));
1116
+ const pool: ToSpecCandidateView[] = [];
1117
+ for (const view of views) {
1118
+ if (view.parked) continue;
1119
+ if (
1120
+ toSpecCandidateExclusion(
1121
+ { issue: view.issue },
1122
+ { grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
1123
+ input.now,
1124
+ ) !== undefined
1125
+ ) {
1126
+ continue;
1127
+ }
1128
+ let children: { number: number; state: IssueState }[];
1129
+ try {
1130
+ children = await seam.childrenOf(input.project, view.issue);
1131
+ } catch {
1132
+ continue;
1133
+ }
1134
+ if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
1135
+ pool.push(view);
1136
+ if (pool.length >= TO_SPEC_BATCH_MAX) break;
1137
+ }
1138
+ // The one shared rule set re-runs on the tracker-vetted pool, so the pure
1139
+ // selector — not a prose instruction — answers what the block authorizes.
1140
+ const selected = selectToSpecBatch({
1141
+ candidates: pool,
1142
+ summary: input.summary,
1143
+ groomBelow: input.groomBelow,
1144
+ grooming: input.grooming,
1145
+ active: input.active,
1146
+ now: input.now,
1147
+ });
1148
+ if (selected.length === 0) return undefined;
1149
+ return toSpecLaunchBlock({
1150
+ summary: input.summary,
1151
+ groomBelow: input.groomBelow,
1152
+ grooming: input.grooming,
1153
+ active: input.active,
1154
+ tracker: input.project.tracker.repo,
1155
+ queueLabel: input.project.queueLabel,
1156
+ labelPrefix: input.project.routing.labelPrefix,
1157
+ parkLabel: input.project.stateLabels.backlog,
1158
+ selected,
1159
+ now: input.now,
1160
+ });
1161
+ }
1162
+
1163
+ /**
1164
+ * The per-tick launch block. Present exactly when a batch may be launched:
1165
+ * the queue is below the grooming trigger (`summary.routed < groomBelow`),
1166
+ * a dispatch row exists, and the mechanical selection produced at least one
1167
+ * candidate. The block names the selected candidates as the ONLY batch this
1168
+ * tick authorizes and lists every store-proven exclusion the selection
1169
+ * already applied; the `tool_call` gate refuses any item outside the list.
1170
+ */
1171
+ export function toSpecLaunchBlock(input: {
1172
+ summary: DispatchSummary | undefined;
1173
+ groomBelow: number;
1174
+ grooming: readonly GroomingRecord[];
1175
+ active: readonly { issue: number }[];
1176
+ tracker: string;
1177
+ queueLabel: string;
1178
+ labelPrefix: string;
1179
+ parkLabel: string;
1180
+ /** The mechanically selected candidates the block renders and authorizes. */
1181
+ selected: readonly ToSpecCandidateView[];
1182
+ now: number;
1183
+ }): ToSpecLaunchBlock | undefined {
1184
+ if (input.summary === undefined) return undefined;
1185
+ if (input.summary.routed >= input.groomBelow) return undefined;
1186
+ if (input.selected.length === 0) return undefined;
1187
+ const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
1188
+ for (const row of input.grooming) {
1189
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1190
+ if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
1191
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1192
+ exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
1193
+ } else {
1194
+ const result = parseToSpecEvidence(row.evidence);
1195
+ if (result !== undefined && input.now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1196
+ exclusions.groomed.push(
1197
+ `#${row.issue} (${result.verdict} @ ${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()})`,
1198
+ );
1199
+ }
1200
+ }
1201
+ }
1202
+ for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
1203
+ const token = randomUUID();
1204
+ const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
1205
+ issue: candidate.issue,
1206
+ routing: candidate.routing,
1207
+ title: candidate.title,
1208
+ }));
1209
+ const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
1210
+ const lines = [
1211
+ `## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
1212
+ "",
1213
+ `The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). ` +
1214
+ "Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
1215
+ `\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
1216
+ "",
1217
+ "The conductor selected this batch mechanically from the live open-issue snapshot " +
1218
+ `(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
1219
+ `\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
1220
+ "already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
1221
+ candidates,
1222
+ "Excluded this tick — " +
1223
+ `already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
1224
+ `in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
1225
+ `mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
1226
+ `dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
1227
+ "",
1228
+ "To launch it:",
1229
+ "",
1230
+ `1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
1231
+ "{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
1232
+ `${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
1233
+ "current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
1234
+ "`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
1235
+ "`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
1236
+ `2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
1237
+ `item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
1238
+ "extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
1239
+ `3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
1240
+ `exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
1241
+ "success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
1242
+ "successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
1243
+ `Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
1244
+ "promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
1245
+ "",
1246
+ ];
1247
+ return { token, block: lines.join("\n"), items };
1248
+ }
1249
+
664
1250
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
665
1251
  material: "Report material events per your brief.",
666
1252
  escalations:
@@ -2284,6 +2870,29 @@ interface TickSession {
2284
2870
  legacyArmLogged: boolean;
2285
2871
  /** The local tick whose agent loop is currently running, if any. */
2286
2872
  activeLocalTick?: ActiveLocalTick;
2873
+ /**
2874
+ * The to-spec launch token the last low-queue tick authorized (#777). Set
2875
+ * when the tick appended a launch block; the `task` tool_call gate accepts
2876
+ * exactly one batch echoing it and refuses a marker-bearing call without
2877
+ * it (no authorized batch this tick), with a stale token, or after a batch
2878
+ * was already accepted. Cleared at the start of every tick — healthy and
2879
+ * low-queue alike — so a token minted on a low-queue tick can never be
2880
+ * spent on a later healthy-queue tick; the tick that mints the next token
2881
+ * sets it fresh with its own {@link TickSession.launchItems}.
2882
+ */
2883
+ launchToken?: string;
2884
+ /** The project the {launchToken} authorization was minted for — the one
2885
+ * in-flight rows are recorded against. */
2886
+ launchProject?: string;
2887
+ /** The mechanically selected batch the current token authorizes: the gate
2888
+ * refuses any item whose issue or routing is not on this list, so parked,
2889
+ * parent/epic and other excluded candidates cannot be stamped in-flight
2890
+ * no matter what the model sends. */
2891
+ launchItems?: ToSpecLaunchItem[];
2892
+ /** The one batch this session's gate has let through, so a second attempt
2893
+ * this tick refuses, and the tool_result capture can match results to
2894
+ * items by index. */
2895
+ launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
2287
2896
  }
2288
2897
 
2289
2898
  /**
@@ -2357,7 +2966,20 @@ async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boole
2357
2966
  * way. Skips are deliberately silent in the UI — a disarmed fleet would
2358
2967
  * otherwise emit a notification every interval, forever.
2359
2968
  */
2360
- async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): Promise<void> {
2969
+ async function tick(
2970
+ pi: TickApi,
2971
+ ctx: TickContext,
2972
+ config: TickConfig,
2973
+ session: TickSession,
2974
+ toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
2975
+ ): Promise<void> {
2976
+ // A launch authorization belongs to one emitted tick only. Revoke it before
2977
+ // any gate, config, store or tracker read so a skipped/degraded/healthy next
2978
+ // tick cannot reuse a token minted by an earlier low-queue tick.
2979
+ session.launchedBatch = undefined;
2980
+ session.launchToken = undefined;
2981
+ session.launchProject = undefined;
2982
+ session.launchItems = undefined;
2361
2983
  const live = currentConfig(ctx.cwd, config);
2362
2984
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
2363
2985
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -2607,8 +3229,39 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
2607
3229
  project.queueLabel,
2608
3230
  project.routing.labelPrefix,
2609
3231
  project.groomBelow ?? DEFAULT_GROOM_BELOW,
3232
+ store.groomingVerdicts(projectName),
2610
3233
  );
2611
3234
  if (queue !== undefined) content = `${content}\n${queue}`;
3235
+ // #777: the mechanical to-spec launch boundary. The queue digest is
3236
+ // the trigger — the same below-`groomBelow` signal that already asks
3237
+ // the orchestrator to groom — so the launch offer is composed beside
3238
+ // it, from the same store read, and only when a dispatch row exists
3239
+ // to prove the queue is genuinely low. The token it carries is the
3240
+ // only authorization the `task` tool_call gate accepts this tick;
3241
+ // absent a block (healthy queue, no dispatch, unreadable snapshot,
3242
+ // nothing eligible) a task call with the batch marker is refused.
3243
+ //
3244
+ // Every tick owns its authorization fresh — the clears above ran
3245
+ // before the reads, so the offer below can only mint for THIS tick.
3246
+ const launch = await offerToSpecLaunch({
3247
+ summary: frictionStore.latestDispatch(scope.projectName),
3248
+ groomBelow: project.groomBelow ?? DEFAULT_GROOM_BELOW,
3249
+ grooming: store.groomingVerdicts(projectName),
3250
+ active: store.activeRuns(projectName),
3251
+ project,
3252
+ trackerSeam: toSpecTrackerSeam,
3253
+ now,
3254
+ });
3255
+ if (launch !== undefined) {
3256
+ content = `${content}\n${launch.block}`;
3257
+ // A minted token authorizes exactly the batch this tick's block
3258
+ // describes: the project and the allowlist are captured so the
3259
+ // tool_call gate records in-flight rows against the same project
3260
+ // the prompt named and refuses any item off the list.
3261
+ session.launchProject = project.name;
3262
+ session.launchToken = launch.token;
3263
+ session.launchItems = launch.items;
3264
+ }
2612
3265
  // Pending intake is the same class of standing block as the friction
2613
3266
  // and decisions read-outs: a store-backed duty the orchestrator must
2614
3267
  // not derive from memory. The store answers, the prompt instructs.
@@ -2619,7 +3272,7 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
2619
3272
  });
2620
3273
  if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
2621
3274
  } catch {
2622
- // unreadable config: no queue digest or pending-intake block this tick
3275
+ // unreadable config: no queue digest, launch block, or pending-intake block this tick
2623
3276
  }
2624
3277
  } catch (err) {
2625
3278
  frictionStore?.close();
@@ -2780,11 +3433,17 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
2780
3433
  * heartbeat interval keeps handing its promise to the harness, which routes
2781
3434
  * rejections to the extension error channel on its own.
2782
3435
  */
2783
- function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
3436
+ function armTickHeartbeat(
3437
+ pi: TickApi,
3438
+ ctx: TickContext,
3439
+ config: TickConfig,
3440
+ session: TickSession,
3441
+ toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3442
+ ): void {
2784
3443
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2785
3444
  const runScheduledTick = async (): Promise<void> => {
2786
3445
  try {
2787
- await tick(pi, ctx, config, session);
3446
+ await tick(pi, ctx, config, session, toSpecTrackerSeam);
2788
3447
  } finally {
2789
3448
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
2790
3449
  }
@@ -2840,7 +3499,7 @@ function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, ses
2840
3499
  // call runs inside the session_start handler dispatch, which cannot see an
2841
3500
  // async rejection — an escaped one reaches the process-level
2842
3501
  // unhandledRejection handler and takes the session down.
2843
- void tick(pi, ctx, config, session).catch((err) => {
3502
+ void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
2844
3503
  pi.logger.error(
2845
3504
  `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
2846
3505
  );
@@ -2884,6 +3543,303 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2884
3543
  });
2885
3544
  }
2886
3545
 
3546
+ /**
3547
+ * The harness surface one to-spec batch travels through (#777): the `task`
3548
+ * tool call the orchestrator makes (with the launch marker in `context`) and
3549
+ * its result. `details` is the task tool's `TaskToolDetails` — declared here
3550
+ * rather than imported, exactly like {@link TickApi}, because the harness is
3551
+ * a peer dependency. A settled batch's `details.results` carries one entry
3552
+ * per item; a background launch returns an empty `results` array and delivers
3553
+ * each final result later as an async-result message, which the orchestrator
3554
+ * routes through {@link TO_SPEC_RESULT_TOOL}.
3555
+ */
3556
+ interface ToSpecTaskToolEvent {
3557
+ toolName: string;
3558
+ toolCallId: string;
3559
+ input: Record<string, unknown>;
3560
+ details: unknown;
3561
+ }
3562
+
3563
+ /** One settled batch item, as `details.results` carries it. */
3564
+ interface ToSpecSettledItem {
3565
+ /** The item's index inside the batch call. */
3566
+ index?: unknown;
3567
+ /** The agent's raw output — the exact text that must reach the parser. */
3568
+ output?: unknown;
3569
+ /** The harness's parsed structured output, when one exists. */
3570
+ structuredOutput?: unknown;
3571
+ }
3572
+
3573
+ /**
3574
+ * The launch gate and result capture for one to-spec batch (#777). Armed at
3575
+ * extension-factory time; inert until a low-queue tick mints a launch token.
3576
+ *
3577
+ * `tool_call` on `task`:
3578
+ * - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
3579
+ * orchestrator's own task use and passes untouched;
3580
+ * - a marker-bearing call is the conductor batch and is gated hard: it needs
3581
+ * this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
3582
+ * items, each starting with the two contract lines), unique items and
3583
+ * names, every item ON the tick's mechanically selected allowlist (issue
3584
+ * and routing — the parked/parent/epic exclusions are baked into that
3585
+ * list, so a forbidden candidate can never be stamped in-flight), and
3586
+ * store-side eligibility — the same {@link toSpecCandidateExclusion} rule
3587
+ * the prompt block's exclusion lines came from, so an item excluded in
3588
+ * prose is excluded in the gate for the same reason;
3589
+ * - on acceptance it stamps every item's `agent`, `outputSchema` and
3590
+ * `schemaMode` (the model never carries the schema itself), records the
3591
+ * durable in-flight rows through the #735 grooming table, and latches the
3592
+ * session so a second batch this tick refuses.
3593
+ *
3594
+ * `tool_result` on the accepted call captures a *settled* batch: every item
3595
+ * is routed through {@link recordToSpecGrooming} independently, so a
3596
+ * malformed or failed item persists `blocked` and its siblings survive. A
3597
+ * background launch settles with no results here — its completed items arrive
3598
+ * as async-result messages and are persisted through
3599
+ * {@link TO_SPEC_RESULT_TOOL}.
3600
+ */
3601
+ function armToSpecGate(pi: TickApi, session: TickSession): void {
3602
+ const api = pi as TickApi & {
3603
+ on(
3604
+ event: "tool_call",
3605
+ handler: (
3606
+ event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
3607
+ ctx: unknown,
3608
+ ) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
3609
+ ): void;
3610
+ on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
3611
+ };
3612
+
3613
+ api.on("tool_call", (event) => {
3614
+ if (event.toolName !== "task") return undefined;
3615
+ const input = event.input;
3616
+ const context = input["context"];
3617
+ if (typeof context !== "string" || !context.includes(TO_SPEC_BATCH_MARKER)) return undefined;
3618
+ // From here on this is a conductor grooming batch — the hard gate. Each
3619
+ // refusal names the correction, because a blocked batch costs the tick a
3620
+ // retry and the gate is meant to catch model error, not to hide it.
3621
+ if (session.launchToken === undefined) {
3622
+ return {
3623
+ block: true,
3624
+ reason:
3625
+ `task refused: no to-spec batch is authorized this tick (` +
3626
+ `only a low-queue tick's launch block names a ${TO_SPEC_BATCH_MARKER} token). ` +
3627
+ "Run no batch this turn.",
3628
+ };
3629
+ }
3630
+ if (session.launchedBatch !== undefined) {
3631
+ return {
3632
+ block: true,
3633
+ reason: "task refused: this tick already launched its one to-spec batch. Wait for the results.",
3634
+ };
3635
+ }
3636
+ const token = new RegExp(`${TO_SPEC_BATCH_MARKER}:\\s*(\\S+)`).exec(context)?.[1];
3637
+ if (token !== session.launchToken) {
3638
+ return {
3639
+ block: true,
3640
+ reason:
3641
+ "task refused: the context token does not match the batch this tick authorized. " +
3642
+ "Relaunch with the token named in this tick's launch block.",
3643
+ };
3644
+ }
3645
+ const tasks = input["tasks"];
3646
+ if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > TO_SPEC_BATCH_MAX) {
3647
+ return {
3648
+ block: true,
3649
+ reason:
3650
+ `task refused: a to-spec batch carries 1–${TO_SPEC_BATCH_MAX} items (one \`tasks[]\` call) — ` +
3651
+ `got ${Array.isArray(tasks) ? tasks.length : "none"}.`,
3652
+ };
3653
+ }
3654
+ const items: ToSpecBatchItem[] = [];
3655
+ const names = new Set<string>();
3656
+ for (const raw of tasks) {
3657
+ if (typeof raw !== "object" || raw === null) {
3658
+ return { block: true, reason: "task refused: every batch item must be an object." };
3659
+ }
3660
+ const item = parseToSpecItem((raw as Record<string, unknown>)["task"]);
3661
+ if (item === undefined) {
3662
+ return {
3663
+ block: true,
3664
+ reason:
3665
+ "task refused: every item's `task` must start with the two contract lines " +
3666
+ "`to-spec candidate: <owner/repo>#<issue> — <title>` and `to-spec source: <owner/repo>@<ref>`.",
3667
+ };
3668
+ }
3669
+ const name = (raw as Record<string, unknown>)["name"];
3670
+ if (typeof name === "string" && name.length > 0) {
3671
+ if (names.has(name)) {
3672
+ return { block: true, reason: `task refused: duplicate item name \`${name}\`.` };
3673
+ }
3674
+ names.add(name);
3675
+ }
3676
+ items.push(item);
3677
+ }
3678
+ for (let i = 1; i < items.length; i += 1) {
3679
+ for (let j = 0; j < i; j += 1) {
3680
+ if (items[j]!.issue === items[i]!.issue) {
3681
+ return {
3682
+ block: true,
3683
+ reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
3684
+ };
3685
+ }
3686
+ }
3687
+ }
3688
+ // The allowlist is the mechanical selection this tick's token authorized:
3689
+ // every item's issue number AND routing must match an entry the launch
3690
+ // offer produced from the authoritative tracker snapshot. Parked,
3691
+ // parent/epic, already-groomed, in-flight, held and dispatched candidates
3692
+ // were never on it, so no item carrying one can be stamped in-flight here
3693
+ // — the model cannot self-select past the conductor's selection (#805).
3694
+ const allowlist = session.launchItems;
3695
+ if (allowlist === undefined) {
3696
+ return {
3697
+ block: true,
3698
+ reason:
3699
+ "task refused: this tick's launch did not carry a mechanically selected batch — " +
3700
+ "re-tick before launching.",
3701
+ };
3702
+ }
3703
+ for (const item of items) {
3704
+ if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
3705
+ const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
3706
+ return {
3707
+ block: true,
3708
+ reason:
3709
+ `task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
3710
+ `(${listed}). Launch exactly the selected candidates, no substitutes.`,
3711
+ };
3712
+ }
3713
+ }
3714
+ const projectName = session.launchProject;
3715
+ if (projectName === undefined) {
3716
+ return {
3717
+ block: true,
3718
+ reason: "task refused: the batch was authorized without a project — re-tick before launching.",
3719
+ };
3720
+ }
3721
+ // Store-side eligibility, the same rule the prompt block's exclusion list
3722
+ // came from. Eligible items then become durable in-flight rows, so a
3723
+ // crashed batch still suppresses re-launch until the TTL expires.
3724
+ let store: Store | undefined;
3725
+ try {
3726
+ store = openStore(dbPath());
3727
+ const byIssue = new Map(
3728
+ store.groomingVerdicts(projectName).map((row) => [row.issue, row] as const),
3729
+ );
3730
+ const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
3731
+ const now = Date.now();
3732
+ for (const item of items) {
3733
+ const reason = toSpecCandidateExclusion(
3734
+ { issue: item.issue },
3735
+ { grooming: byIssue.get(item.issue), active: active.has(item.issue) },
3736
+ now,
3737
+ );
3738
+ if (reason !== undefined) {
3739
+ return {
3740
+ block: true,
3741
+ reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
3742
+ };
3743
+ }
3744
+ }
3745
+ const launchedEvidence = JSON.stringify({
3746
+ kind: "to-spec-in-flight",
3747
+ launchedAt: now,
3748
+ batch: token,
3749
+ agent: TO_SPEC_AGENT,
3750
+ });
3751
+ for (const item of items) {
3752
+ store.upsertGrooming({
3753
+ project: projectName,
3754
+ issue: item.issue,
3755
+ verdict: "blocked",
3756
+ reason: TO_SPEC_IN_FLIGHT_REASON,
3757
+ evidence: launchedEvidence,
3758
+ at: now,
3759
+ });
3760
+ }
3761
+ } catch (err) {
3762
+ pi.logger.error(
3763
+ `[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
3764
+ );
3765
+ return {
3766
+ block: true,
3767
+ reason: `task refused: the launch could not be recorded durably (${
3768
+ err instanceof Error ? err.message : String(err)
3769
+ }); nothing was started.`,
3770
+ };
3771
+ } finally {
3772
+ store?.close();
3773
+ }
3774
+ const stampedTasks = tasks.map((raw) => ({
3775
+ ...(raw as Record<string, unknown>),
3776
+ agent: TO_SPEC_AGENT,
3777
+ outputSchema: TO_SPEC_SCHEMA,
3778
+ schemaMode: "strict",
3779
+ }));
3780
+ session.launchedBatch = { toolCallId: event.toolCallId, items };
3781
+ pi.logger.info(
3782
+ `[omp-conductor] to-spec batch launched: ${items.map((item) => `#${item.issue}`).join(", ")} (${token})`,
3783
+ );
3784
+ return { input: { ...input, tasks: stampedTasks } };
3785
+ });
3786
+
3787
+ api.on("tool_result", (event) => {
3788
+ if (event.toolName !== "task") return;
3789
+ const batch = session.launchedBatch;
3790
+ if (batch === undefined || event.toolCallId !== batch.toolCallId) return;
3791
+ const projectName = session.launchProject;
3792
+ if (projectName === undefined) return;
3793
+ const details = event.details as { results?: unknown } | undefined;
3794
+ const results = details?.results;
3795
+ if (!Array.isArray(results) || results.length === 0) {
3796
+ // Background launch: the settled items arrive later through
3797
+ // TO_SPEC_RESULT_TOOL; the in-flight rows keep them out of any new batch
3798
+ // until then.
3799
+ return;
3800
+ }
3801
+ let store: Store | undefined;
3802
+ try {
3803
+ store = openStore(dbPath());
3804
+ for (const entry of results) {
3805
+ const settled = entry as ToSpecSettledItem;
3806
+ const item = batch.items[typeof settled.index === "number" ? settled.index : -1];
3807
+ if (item === undefined) continue;
3808
+ // The raw output is the contract input — exactly as returned, even
3809
+ // when the item failed: the strict parser turns anything unparseable
3810
+ // into a blocked row, and a failed sibling never touches the others.
3811
+ const raw = typeof settled.output === "string" ? settled.output : "";
3812
+ const structured = settled.structuredOutput;
3813
+ const fallback =
3814
+ structured !== null &&
3815
+ typeof structured === "object" &&
3816
+ typeof (structured as { data?: unknown }).data === "object"
3817
+ ? JSON.stringify((structured as { data?: unknown }).data)
3818
+ : "";
3819
+ try {
3820
+ recordToSpecGrooming(store, {
3821
+ project: projectName,
3822
+ issue: item.issue,
3823
+ input: raw.trim().length > 0 ? raw : fallback,
3824
+ });
3825
+ } catch (err) {
3826
+ pi.logger.error(
3827
+ `[omp-conductor] to-spec result not persisted for #${item.issue}: ${
3828
+ err instanceof Error ? err.message : String(err)
3829
+ }`,
3830
+ );
3831
+ }
3832
+ }
3833
+ } catch (err) {
3834
+ pi.logger.error(
3835
+ `[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
3836
+ );
3837
+ } finally {
3838
+ store?.close();
3839
+ }
3840
+ });
3841
+ }
3842
+
2887
3843
  /**
2888
3844
  * Factory-time seams for the extension, used by tests. Production runs
2889
3845
  * `orchestratorTickExtension(pi)` with no options and gets the module's own
@@ -2892,7 +3848,21 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
2892
3848
  * through the real tool deterministically, without real timers (#683).
2893
3849
  */
2894
3850
  export interface OrchestratorTickExtensionOptions {
2895
- ask?: { wait?: (ms: number) => Promise<void>; now?: () => number };
3851
+ ask?: {
3852
+ wait?: (ms: number) => Promise<void>;
3853
+ now?: () => number;
3854
+ /** Injected interactive delivery surface (#722); production builds its own. */
3855
+ interactive?: AskInteractiveDelivery;
3856
+ };
3857
+ /**
3858
+ * The authoritative tracker surface the low-queue to-spec launch reads
3859
+ * (#777). Production omits it and the extension builds the real seam over
3860
+ * the existing Tracker adapter for each project; tests inject deterministic
3861
+ * fakes so the mechanical selection is provable without a network. A
3862
+ * low-queue launch without a readable snapshot is refused entirely — the
3863
+ * launch fails closed rather than trusting the model to self-filter.
3864
+ */
3865
+ toSpec?: { tracker?: ToSpecTrackerSeam };
2896
3866
  }
2897
3867
 
2898
3868
  export default function orchestratorTickExtension(
@@ -2919,6 +3889,22 @@ export default function orchestratorTickExtension(
2919
3889
  let releaseGateArmed = false;
2920
3890
  let availabilityGateArmed = false;
2921
3891
  let guardArmed = false;
3892
+ // #777: the to-spec launch gate is factory-time like the tick guard. It acts
3893
+ // only when a low-queue tick has minted a launch token, so on any other
3894
+ // session — or any healthy queue — it observes `task` calls without touching
3895
+ // them.
3896
+ armToSpecGate(pi, session);
3897
+ // The authoritative tracker seam for the launch selection. Production runs
3898
+ // without options and builds the real seam lazily over the existing Tracker
3899
+ // adapter (one tracker per project read, per low-queue tick); tests inject a
3900
+ // deterministic fake. The seam is the ONLY tracker surface the launch path
3901
+ // touches — the tick never mutates tracker state and never lets the model
3902
+ // self-select candidates.
3903
+ const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
3904
+ options.toSpec?.tracker ?? {
3905
+ listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
3906
+ childrenOf: (project, issue) => makeTracker(project).childrenOf(issue),
3907
+ };
2922
3908
  // The bounded ask tool's session state. The tool itself is registered at
2923
3909
  // extension-factory time, before any session exists, because OMP snapshots
2924
3910
  // the extension's active tool set before it emits `session_start` — a tool
@@ -3136,7 +4122,12 @@ export default function orchestratorTickExtension(
3136
4122
  `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
3137
4123
  `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
3138
4124
  `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: ` +
4125
+ `one still gets the default). When the Telegram surface can, the question posts as selectable ` +
4126
+ `buttons and a tap resolves the decision row with the chosen option; when it cannot, the ask ` +
4127
+ `degrades to plain text and the row records the degraded delivery. "recommended" is required ` +
4128
+ `whenever "on-timeout" is "auto-proceed" (the row must record what was auto-applied) and, when ` +
4129
+ `"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
4130
+ `When nobody answers, the declared "on-timeout" decides: ` +
3140
4131
  `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
3141
4132
  `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
3142
4133
  `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
@@ -3196,8 +4187,11 @@ export default function orchestratorTickExtension(
3196
4187
  turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
3197
4188
  // Test seam (#683): production omits `wait`/`now` and `performAsk`
3198
4189
  // 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),
4190
+ // the timeout outcomes deterministically hands both in. The
4191
+ // interactive surface (#722) is production's default; a test may
4192
+ // inject a fake through the same seam.
4193
+ interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
4194
+ ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
3201
4195
  deliver: async (text, category) => {
3202
4196
  const at = Date.now();
3203
4197
  const noticeId = randomUUID();
@@ -3237,6 +4231,114 @@ export default function orchestratorTickExtension(
3237
4231
  },
3238
4232
  });
3239
4233
 
4234
+ // The async half of the to-spec result capture (#777). Registered at
4235
+ // extension-factory time like {@link ASK_TOOL}, with the same routing
4236
+ // contract: the state it needs (cwd + startup config) is filled by
4237
+ // `session_start` for an accepted fleet session, and the live config is
4238
+ // re-read at execution so a restamp binds the current project. The tool
4239
+ // only ever writes the grooming table — never an issue, a label, or a
4240
+ // dispatch row — so the output of a background batch persists while no
4241
+ // candidate can become claimable from it.
4242
+ pi.registerTool({
4243
+ name: TO_SPEC_RESULT_TOOL,
4244
+ label: TO_SPEC_RESULT_TOOL,
4245
+ description:
4246
+ `Persist the exact raw output of one completed to-spec grooming item (#777). ` +
4247
+ `Call it once per completed batch item after the batch settles — in the tool ` +
4248
+ `result, or when an async-result message delivers the item — passing the issue ` +
4249
+ `number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
4250
+ `inline text is truncated; never paraphrase). The conductor parses the output ` +
4251
+ `against the strict to-spec contract and records the verdict durably: a valid ` +
4252
+ `result persists its verdict, anything malformed, source-less or stale persists ` +
4253
+ `as blocked, and a failing item never discards its siblings. One call per item, ` +
4254
+ `success and failure alike; never edits an issue or a label.`,
4255
+ parameters: {
4256
+ type: "object",
4257
+ properties: {
4258
+ issue: { type: "integer", description: "The tracker issue number the item groomed." },
4259
+ input: {
4260
+ type: "string",
4261
+ description: "The to-spec agent's exact raw output for this item.",
4262
+ },
4263
+ },
4264
+ required: ["issue", "input"],
4265
+ additionalProperties: false,
4266
+ },
4267
+ approval: "write",
4268
+ execute: async (_toolCallId, params) => {
4269
+ const fleet = askSession;
4270
+ if (fleet === undefined) {
4271
+ return {
4272
+ content: [
4273
+ {
4274
+ type: "text",
4275
+ text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
4276
+ },
4277
+ ],
4278
+ isError: true,
4279
+ };
4280
+ }
4281
+ const issue = params["issue"];
4282
+ const input = params["input"];
4283
+ if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
4284
+ return {
4285
+ content: [
4286
+ { type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
4287
+ ],
4288
+ isError: true,
4289
+ };
4290
+ }
4291
+ const routed = resolveAskProject(fleet.cwd, fleet.config);
4292
+ if (routed.kind === "error") {
4293
+ return {
4294
+ content: [
4295
+ {
4296
+ type: "text",
4297
+ text:
4298
+ `${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
4299
+ "Keep the outputs in the transcript and persist after the config is repaired.",
4300
+ },
4301
+ ],
4302
+ isError: true,
4303
+ };
4304
+ }
4305
+ const store = openStore(dbPath());
4306
+ try {
4307
+ const outcome = recordToSpecGrooming(store, {
4308
+ project: routed.project.name,
4309
+ issue,
4310
+ input,
4311
+ });
4312
+ const record = outcome.record;
4313
+ const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
4314
+ return {
4315
+ content: [
4316
+ {
4317
+ type: "text",
4318
+ text:
4319
+ `${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
4320
+ "The grooming table now decides re-grooming; promotion stays yours.",
4321
+ },
4322
+ ],
4323
+ };
4324
+ } catch (err) {
4325
+ return {
4326
+ content: [
4327
+ {
4328
+ type: "text",
4329
+ text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
4330
+ err instanceof Error ? err.message : String(err)
4331
+ } — keep the output and retry the call.`,
4332
+ },
4333
+ ],
4334
+ isError: true,
4335
+ };
4336
+ } finally {
4337
+ store.close();
4338
+ }
4339
+ },
4340
+ });
4341
+
3240
4342
  pi.on("session_start", (_event, ctx) => {
3241
4343
  if (decided) return;
3242
4344
 
@@ -3358,7 +4460,7 @@ export default function orchestratorTickExtension(
3358
4460
  // not create or deliver decisions). `cwd` is for re-reading the live
3359
4461
  // tick config at execution, `config` for the startup-only ceiling.
3360
4462
  askSession = { cwd: ctx.cwd, config };
3361
- armTickHeartbeat(pi, ctx, config, session);
4463
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
3362
4464
  if (!guardArmed) {
3363
4465
  guardArmed = true;
3364
4466
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -3380,7 +4482,7 @@ export default function orchestratorTickExtension(
3380
4482
  // why): a declined or unresolved session keeps `askSession` undefined and
3381
4483
  // the tool fails closed.
3382
4484
  askSession = { cwd: ctx.cwd, config };
3383
- armTickHeartbeat(pi, ctx, config, session);
4485
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
3384
4486
  if (!guardArmed) {
3385
4487
  guardArmed = true;
3386
4488
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -3398,3 +4500,272 @@ export default function orchestratorTickExtension(
3398
4500
  );
3399
4501
  });
3400
4502
  }
4503
+
4504
+ /** One error's message, for a reason string — never a stack. */
4505
+ function errText(err: unknown): string {
4506
+ return err instanceof Error ? err.message : String(err);
4507
+ }
4508
+
4509
+ /**
4510
+ * Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
4511
+ * temp file in the same directory, then rename. The bridge reads these files
4512
+ * on a hot path (every tap), so a half-written request must never be visible.
4513
+ */
4514
+ function atomicallyWriteJson(path: string, value: unknown): void {
4515
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
4516
+ const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
4517
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
4518
+ renameSync(tmp, path);
4519
+ }
4520
+
4521
+ /** Remove one prompt file; a missing file is not an error. */
4522
+ function removeFile(path: string): void {
4523
+ try {
4524
+ rmSync(path, { force: true });
4525
+ } catch {
4526
+ // best effort — a leftover prompt request dies with its owner process
4527
+ }
4528
+ }
4529
+
4530
+ /**
4531
+ * The interactive Telegram surface for one bounded ask (#722).
4532
+ *
4533
+ * `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
4534
+ * running omp-telegram bridge acknowledges and answers through a documented
4535
+ * cross-process file protocol (guide.md: "prompts/ — Cross-process
4536
+ * selectable-question requests (live while their owning session is) and their
4537
+ * answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
4538
+ * bridge validates the tap against that request (responder, chat, topic,
4539
+ * message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
4540
+ * process reads the envelope and settles. `interactiveAskSurface` is that
4541
+ * asking-process half, nothing more: it posts the question with the same
4542
+ * `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
4543
+ * so the bridge recognizes the taps, and translates the envelope the bridge
4544
+ * writes into the decision row — the resolution is the chosen option's *label*,
4545
+ * never an index and never free text.
4546
+ *
4547
+ * The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
4548
+ * bridge's callback regex, and makes the pending question and its answer
4549
+ * addressable by the row that records them.
4550
+ */
4551
+ export function interactiveAskSurface(deps: {
4552
+ project: ProjectConfig;
4553
+ store: Store;
4554
+ /** Injected Bot API transport (tests); production posts to api.telegram.org. */
4555
+ call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
4556
+ /** Injected state dir (tests); production resolves it like the token. */
4557
+ stateDir?: string;
4558
+ /** Injected bot token (tests); production reads the state dir's .env. */
4559
+ token?: string;
4560
+ now?: () => number;
4561
+ }): AskInteractiveDelivery {
4562
+ const surfaceStateDir = deps.stateDir ?? telegramStateDir();
4563
+ const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
4564
+ const now = deps.now ?? Date.now;
4565
+ // Where each pending question physically sits, so collect/close can settle
4566
+ // the right message without re-reading the request file.
4567
+ const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
4568
+
4569
+ const promptsDir = (): string => join(surfaceStateDir, "prompts");
4570
+ const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
4571
+ const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
4572
+
4573
+ const ownerId = (): string | undefined => {
4574
+ let raw: string;
4575
+ try {
4576
+ raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
4577
+ } catch {
4578
+ return undefined;
4579
+ }
4580
+ let access: { allowFrom?: unknown };
4581
+ try {
4582
+ access = JSON.parse(raw) as { allowFrom?: unknown };
4583
+ } catch {
4584
+ return undefined;
4585
+ }
4586
+ const allowFrom = access.allowFrom;
4587
+ if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
4588
+ return undefined;
4589
+ }
4590
+ return allowFrom[0];
4591
+ };
4592
+
4593
+ const tokenAvailable = (): boolean =>
4594
+ deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
4595
+
4596
+ return {
4597
+ unavailableReason(request) {
4598
+ const chat = deps.project.escalation.telegramChatId;
4599
+ if (chat === undefined || chat === "") {
4600
+ return "no escalation.telegramChatId configured for this project";
4601
+ }
4602
+ if (!tokenAvailable()) {
4603
+ return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
4604
+ }
4605
+ if (ownerId() === undefined) {
4606
+ return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
4607
+ }
4608
+ // The interactive post is still a delivery under the reporting policy: a
4609
+ // question the policy would hold for the digest or the availability
4610
+ // window must not bypass that hold just because it has buttons.
4611
+ const disposition = interruptDisposition(
4612
+ deps.project.reporting,
4613
+ request.category ?? "decision-needed",
4614
+ now(),
4615
+ );
4616
+ if (disposition !== "interrupt") {
4617
+ return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
4618
+ }
4619
+ return undefined;
4620
+ },
4621
+
4622
+ async post(request, decisionId) {
4623
+ const chat = deps.project.escalation.telegramChatId;
4624
+ const owner = ownerId();
4625
+ if (chat === undefined || owner === undefined) {
4626
+ return {
4627
+ ok: false,
4628
+ reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
4629
+ };
4630
+ }
4631
+ const render = renderInteractiveAsk(request, decisionId);
4632
+ const threadId = resolveProjectTopicId(deps.project);
4633
+ let result: Record<string, unknown>;
4634
+ try {
4635
+ result = await call("sendMessage", {
4636
+ chat_id: chat,
4637
+ ...(threadId === undefined ? {} : { message_thread_id: threadId }),
4638
+ text: render.text,
4639
+ reply_markup: render.markup,
4640
+ });
4641
+ } catch (err) {
4642
+ return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
4643
+ }
4644
+ const messageId = result["message_id"];
4645
+ const chatType =
4646
+ typeof result["chat"] === "object" && result["chat"] !== null
4647
+ ? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
4648
+ : "private";
4649
+ if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
4650
+ // The question went out, but unaddressable — take it back rather than
4651
+ // leaving a button row nothing can settle.
4652
+ await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
4653
+ () => undefined,
4654
+ );
4655
+ return { ok: false, reason: "Telegram posted no usable message id" };
4656
+ }
4657
+ const recommended =
4658
+ request.recommended === undefined
4659
+ ? undefined
4660
+ : (request.options ?? []).findIndex((option) => option.label === request.recommended);
4661
+ const requestFile = {
4662
+ version: 1,
4663
+ nonce: decisionId,
4664
+ responderId: owner,
4665
+ chatId: chat,
4666
+ chatType,
4667
+ threadId,
4668
+ page: 0,
4669
+ messageId,
4670
+ questions: [
4671
+ {
4672
+ id: "q1",
4673
+ question: request.question,
4674
+ options: request.options ?? [],
4675
+ ...(recommended === undefined || recommended < 0 ? {} : { recommended }),
4676
+ },
4677
+ ],
4678
+ questionIndex: 0,
4679
+ answers: [],
4680
+ selectedIndices: [],
4681
+ awaitingText: (request.options ?? []).length === 0,
4682
+ ownerPid: process.pid,
4683
+ };
4684
+ try {
4685
+ atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
4686
+ } catch (err) {
4687
+ // The buttons went out but nothing would ever answer them — take the
4688
+ // message back rather than leave a dead keyboard in the chat.
4689
+ await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
4690
+ return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
4691
+ }
4692
+ posted.set(decisionId, { chatId: chat, messageId, settled: false });
4693
+ return { ok: true };
4694
+ },
4695
+
4696
+ collect(decisionId) {
4697
+ const state = posted.get(decisionId);
4698
+ if (state === undefined) return;
4699
+ let raw: string | undefined;
4700
+ try {
4701
+ raw = readFileSync(answerPath(decisionId), "utf8");
4702
+ } catch {
4703
+ return; // no answer yet
4704
+ }
4705
+ let parsed: AskAnswerEnvelope | undefined;
4706
+ try {
4707
+ parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
4708
+ } catch {
4709
+ parsed = undefined;
4710
+ }
4711
+ if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
4712
+ const write = askAnswerRowWrite(parsed);
4713
+ if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
4714
+ deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
4715
+ removeFile(requestPath(decisionId));
4716
+ removeFile(answerPath(decisionId));
4717
+ state.settled = true;
4718
+ const outcomeText =
4719
+ write.state === "withdrawn"
4720
+ ? write.resolution
4721
+ : `User selected: ${write.resolution}`;
4722
+ // The bridge edits its own prompts' messages; this surface owns the edit
4723
+ // for its own, so an answered ask reads as answered and a stale tap
4724
+ // finds no keyboard.
4725
+ call("editMessageText", {
4726
+ chat_id: state.chatId,
4727
+ message_id: state.messageId,
4728
+ text: outcomeText,
4729
+ reply_markup: { inline_keyboard: [] },
4730
+ }).catch(() => undefined);
4731
+ },
4732
+
4733
+ close(decisionId) {
4734
+ const state = posted.get(decisionId);
4735
+ removeFile(requestPath(decisionId));
4736
+ removeFile(answerPath(decisionId));
4737
+ if (state !== undefined && !state.settled) {
4738
+ call("editMessageReplyMarkup", {
4739
+ chat_id: state.chatId,
4740
+ message_id: state.messageId,
4741
+ reply_markup: { inline_keyboard: [] },
4742
+ }).catch(() => undefined);
4743
+ state.settled = true;
4744
+ }
4745
+ },
4746
+ };
4747
+ }
4748
+
4749
+ /** The Bot API transport: one JSON POST per call, the `result` back. */
4750
+ function telegramCall(
4751
+ token: string,
4752
+ ): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
4753
+ return async (method, payload) => {
4754
+ if (token === "") throw new Error("no Telegram bot token");
4755
+ const url = `https://api.telegram.org/bot${token}/${method}`;
4756
+ const res = await fetch(url, {
4757
+ method: "POST",
4758
+ headers: { "content-type": "application/json" },
4759
+ body: JSON.stringify(payload),
4760
+ });
4761
+ const raw = await res.text();
4762
+ if (!res.ok) {
4763
+ throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
4764
+ }
4765
+ const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
4766
+ if (parsed.ok !== true) {
4767
+ throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
4768
+ }
4769
+ return (parsed.result as Record<string, unknown>) ?? {};
4770
+ };
4771
+ }