omp-conductor 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
@@ -98,8 +98,15 @@ import {
98
98
  import { repoSlugFor } from "./gitops.ts";
99
99
  import { makeTracker } from "./tracker/github.ts";
100
100
  import { formatDecisionDigest } from "./decisions.ts";
101
+ import { installSurfaceMismatch } from "./status-render.ts";
101
102
  import {
102
103
  ASK_TOOL,
104
+ type AskDeliveryResult,
105
+ MAX_QUESTIONNAIRE_ITEMS,
106
+ QUESTIONNAIRE_TOOL,
107
+ parseQuestionnaireRequest,
108
+ performQuestionnaire,
109
+ questionnaireParameterSchema,
103
110
  askAnswerRowWrite,
104
111
  askParameterSchema,
105
112
  DEFAULT_ASK_TIMEOUT_SECONDS,
@@ -114,18 +121,22 @@ import {
114
121
  type AskResult,
115
122
  } from "./ask.ts";
116
123
  import { deliverOperatorMessage } from "./reports.ts";
124
+ import { effectiveLabels } from "./routing.ts";
117
125
  import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
118
126
  import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
119
127
  import { dbPath, openStore } from "./store.ts";
120
128
  import { digestDue, localDayKey } from "./digest-schedule.ts";
121
129
  import {
122
130
  parseToSpecEvidence,
131
+ parseToSpecFailureEvidence,
123
132
  recordToSpecGrooming,
124
133
  TO_SPEC_MAX_SOURCE_AGE_MS,
125
134
  TO_SPEC_SCHEMA,
135
+ type ToSpecFailure,
136
+ type ToSpecResult,
126
137
  } from "./to-spec.ts";
127
138
  import { heldNoticeId } from "./notices.ts";
128
- import { isActiveArmProof } from "./arm-challenge.ts";
139
+ import { acknowledgeArmReply } from "./arm-challenge.ts";
129
140
 
130
141
  /** The activation file. Absent means "this is not an orchestrator session". */
131
142
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -639,6 +650,21 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
639
650
  return [...counts.entries()].map(([verdict, count]) => `${verdict} ${count}`).join(", ");
640
651
  }
641
652
 
653
+ /**
654
+ * The queue-label inventory read fresh from the tracker on one tick, with the
655
+ * pending label ops applied exactly as the dispatch pass judges eligibility
656
+ * (#848). Queue verdicts in the present tense ("empty", "running low") are
657
+ * only ever drawn from this observation; a dispatch summary alone is dated
658
+ * history and must never be rendered as the live queue.
659
+ */
660
+ export interface QueueObservation {
661
+ /** Open issues carrying the queue label under the *effective* label set —
662
+ * the count dispatch would route against on its next pass. */
663
+ queued: number;
664
+ /** The instant the tracker observation was made, epoch ms. */
665
+ observedAt: number;
666
+ }
667
+
642
668
  /**
643
669
  * One line telling the orchestrator the routable queue is running dry (#181),
644
670
  * or `undefined` when healthy — no dispatch recorded yet, or the routable count
@@ -651,6 +677,12 @@ function groomingVerdictCounts(records: readonly GroomingRecord[]): string {
651
677
  * so it is told apart from the mechanical holds: it says "a batch is running",
652
678
  * not "the lane cannot move", and counts neither as claimable nor as
653
679
  * known-blocked.
680
+ *
681
+ * `queue` is the tracker observation the caller made THIS tick (#848). When
682
+ * present, the queue verdict reads in the present tense from that inventory,
683
+ * and dispatch-derived counts are only ever dated context. When absent the
684
+ * tracker could not be read, and this never asserts that the queue is empty:
685
+ * the dispatch row is rendered explicitly as-of instead.
654
686
  */
655
687
  export function queueDigestLine(
656
688
  summary: DispatchSummary | undefined,
@@ -658,10 +690,36 @@ export function queueDigestLine(
658
690
  labelPrefix: string,
659
691
  groomBelow: number,
660
692
  grooming: readonly GroomingRecord[] = [],
693
+ queue: QueueObservation | undefined = undefined,
694
+ /** Observation time for the durability of the grooming rows this line
695
+ * describes — the same clock the selection is offered against, so the
696
+ * inventory and the batch cannot disagree about what is still groomed
697
+ * (#887). */
698
+ now: number = Date.now(),
661
699
  ): string | undefined {
662
700
  if (summary === undefined) return undefined;
701
+ if (queue !== undefined) {
702
+ return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming, now);
703
+ }
704
+ return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming, now);
705
+ }
706
+
707
+ /** The dated rendering: the last dispatch row is the only evidence, so every
708
+ * count carries its snapshot date and nothing is asserted as current. */
709
+ function datedQueueDigestLine(
710
+ summary: DispatchSummary,
711
+ queueLabel: string,
712
+ labelPrefix: string,
713
+ groomBelow: number,
714
+ grooming: readonly GroomingRecord[],
715
+ now: number,
716
+ ): string | undefined {
717
+ const dated = new Date(summary.completedAt).toISOString();
663
718
  if (summary.ready === 0) {
664
- 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.`;
719
+ if (summary.paused === true) {
720
+ 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.`;
721
+ }
722
+ 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.`;
665
723
  }
666
724
  if (summary.routed === 0) {
667
725
  // `ready` counts claimed (in-flight) issues too; route() drops those with a
@@ -678,7 +736,7 @@ export function queueDigestLine(
678
736
  const staleLifecycle = summary.holds
679
737
  .filter((h) => h.reason === "stale-lifecycle")
680
738
  .reduce((n, h) => n + h.count, 0);
681
- let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
739
+ let line = `As of the last dispatch (${dated}): Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
682
740
  if (unroutable > 0) {
683
741
  line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
684
742
  }
@@ -693,6 +751,98 @@ export function queueDigestLine(
693
751
  return line;
694
752
  }
695
753
  if (summary.routed >= groomBelow) return undefined;
754
+ return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `, now);
755
+ }
756
+
757
+ /** The live rendering: the tracker observation is the queue label inventory,
758
+ * the dispatch summary supplies the claimability reading, and the shared
759
+ * low-queue diagnostics are composed underneath (#848 review 2). The raw
760
+ * carrier count appears only as a present-tense inventory fact — never as
761
+ * the grooming threshold, which must be claimability (`summary.routed`, the
762
+ * same count the to-spec offer gates on), or parked/in-flight/state-labelled
763
+ * carriers would silence a queue that dispatch cannot move. */
764
+ function liveQueueDigestLine(
765
+ summary: DispatchSummary,
766
+ queue: QueueObservation,
767
+ queueLabel: string,
768
+ labelPrefix: string,
769
+ groomBelow: number,
770
+ grooming: readonly GroomingRecord[],
771
+ now: number,
772
+ ): string | undefined {
773
+ const queued = queue.queued;
774
+ const observed = new Date(queue.observedAt).toISOString();
775
+ // The present-tense empty verdict is the tracker's own word: nothing open
776
+ // carries the queue label right now. No dispatch snapshot may produce a
777
+ // live "empty" claim — that is the whole defect (#848).
778
+ if (queued === 0) {
779
+ return (
780
+ `Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
781
+ "Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
782
+ "why there is nothing to do."
783
+ );
784
+ }
785
+ // The grooming threshold is claimability — summary.routed — never raw
786
+ // carrier volume. Four in-flight/parked/unroutable carriers must not
787
+ // suppress the low-claimable signal, because the to-spec offer gates on
788
+ // the very same routed count and the two would split.
789
+ if (summary.routed >= groomBelow) return undefined;
790
+ const dated = new Date(summary.completedAt).toISOString();
791
+ const inventory = `Queue: ${queued} open issue${queued === 1 ? "" : "s"} carry "${queueLabel}" right now (tracker ${observed}); `;
792
+ if (summary.routed === 0) {
793
+ // Dispatch's own claimability read: `ready` counts claimed (in-flight)
794
+ // issues too, so "0 routable" must not blanket-blame missing `repo:`
795
+ // labels (#228), and a residual lifecycle label is Duty 1 reconciliation
796
+ // work, not spare depth (#611).
797
+ if (summary.paused === true) {
798
+ return (
799
+ inventory +
800
+ `the last dispatch (${dated}) was a hold that admitted nothing — the queued issues above are the work that resumes when claiming reopens.`
801
+ );
802
+ }
803
+ const claimed = summary.claimed ?? 0;
804
+ let line = inventory + `at the last dispatch (${dated}) ${summary.ready} ready, 0 spare — ${claimed} in flight`;
805
+ const unroutable = summary.holds
806
+ .filter((h) => h.reason.startsWith("unroutable:"))
807
+ .reduce((n, h) => n + h.count, 0);
808
+ const staleLifecycle = summary.holds
809
+ .filter((h) => h.reason === "stale-lifecycle")
810
+ .reduce((n, h) => n + h.count, 0);
811
+ if (unroutable > 0) {
812
+ line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
813
+ }
814
+ if (staleLifecycle > 0) {
815
+ line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
816
+ }
817
+ if (unroutable === 0 && staleLifecycle === 0) {
818
+ line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
819
+ } else {
820
+ line += ".";
821
+ }
822
+ return line;
823
+ }
824
+ // routed > 0 below the trigger: the live inventory leads, then the same
825
+ // low-queue diagnostics the dated path ships — the claimable/known-blocked
826
+ // split, in-flight to-spec batches, the considered backlog and this pass's
827
+ // holds (#735, #777, #679) — so a tracker read never hides them.
828
+ return inventory + lowQueueTail(summary, groomBelow, grooming, "", now);
829
+ }
830
+
831
+ /** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
832
+ * claimable/known-blocked split, in-flight to-spec batches, the considered
833
+ * backlog and this pass's holds. `lead` is the sentence opener the caller
834
+ * chooses — the dated path opens with the dispatch timestamp and "Queue:",
835
+ * the live path with its tracker inventory (#848 review 2). Sharing one tail
836
+ * means a tracker read can never hide these distinctions. */
837
+ function lowQueueTail(
838
+ summary: DispatchSummary,
839
+ groomBelow: number,
840
+ grooming: readonly GroomingRecord[],
841
+ lead: string,
842
+ /** The clock the durability of each verdict is judged against — the same
843
+ * one the batch offer uses (#887). */
844
+ now: number,
845
+ ): string {
696
846
  // The durable per-issue verdicts, not this pass's one-shot hold groups: a
697
847
  // lane-blocked runway must read as "cannot move" even after a restart, and
698
848
  // the orchestrator's own prior verdicts (#679) must not be re-derived.
@@ -708,24 +858,33 @@ export function queueDigestLine(
708
858
  const claimable = Math.max(0, summary.routed - knownBlocked.length);
709
859
  const inFlightNames = inFlight.map((r) => `#${r.issue}`).join(", ");
710
860
  const busy = inFlight.length === 0 ? "" : `, ${inFlight.length} in a to-spec batch (${inFlightNames})`;
711
- let line: string;
861
+ let tail: string;
712
862
  if (knownBlocked.length > 0 && claimable === 0) {
713
- line =
714
- `Queue: running low — ${summary.routed} routable candidate(s), all known-blocked ` +
863
+ tail =
864
+ `${lead}running low — ${summary.routed} routable candidate(s), all known-blocked ` +
715
865
  `(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
716
866
  `${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
717
867
  } 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}: ` +
868
+ tail =
869
+ `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
720
870
  `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
721
871
  `${busy} — groom only the claimable.`;
722
872
  } else {
723
- line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
873
+ tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
724
874
  }
725
875
  if (considered.length > 0) {
726
- line +=
876
+ // "Already considered" is not "will not be re-groomed": selection only
877
+ // withholds a candidate whose verdict is still durable, so the inventory
878
+ // says which of the two each row is. Claiming "never re-groom these" over
879
+ // rows the mechanical selection was simultaneously offering is the #887
880
+ // defect — the same predicate now answers both.
881
+ const durable = considered.filter((g) => toSpecDurableVerdict(g, now) !== undefined);
882
+ const regroomable = considered.length - durable.length;
883
+ tail +=
727
884
  ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
728
- `promote the promotable or groom new issues, never re-groom these.`;
885
+ `${durable.length} still durable (never re-groom these), ${regroomable} re-groomable ` +
886
+ "(no readable to-spec source, or observed past the freshness ceiling). " +
887
+ "Promote the promotable or groom new issues.";
729
888
  }
730
889
  if (summary.admitted === 0 && summary.holds.length > 0) {
731
890
  const held = summary.holds
@@ -734,9 +893,9 @@ export function queueDigestLine(
734
893
  return `${h.reason} ${h.count}${first === undefined ? "" : ` (${first})`}`;
735
894
  })
736
895
  .join(", ");
737
- line += ` All held: ${held}.`;
896
+ tail += ` All held: ${held}.`;
738
897
  }
739
- return line;
898
+ return tail;
740
899
  }
741
900
 
742
901
  // ================================================================ to-spec
@@ -818,6 +977,51 @@ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
818
977
  */
819
978
  export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
820
979
 
980
+ /**
981
+ * How long a refused pass parks its candidate before another batch may be
982
+ * spent on it. Deliberately the same 24h number as the source-freshness
983
+ * ceiling and the in-flight TTL — one granularity for this whole lifecycle,
984
+ * not a third threshold to keep in sync: within that window neither the
985
+ * authoritative source nor the issue has produced new evidence, so a retry
986
+ * re-runs the identical prompt and refuses the identical way.
987
+ *
988
+ * Without it, a candidate whose delegated pass returns malformed,
989
+ * source-less or stale output is immediately eligible again, so every
990
+ * low-queue tick spends a full delegated batch re-grooming it — measured on
991
+ * this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
992
+ * re-offered on every pass, and as #807 groomed twice seven minutes apart
993
+ * (#887).
994
+ */
995
+ export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
996
+
997
+ /**
998
+ * The one durability rule for a grooming row: the validated to-spec result it
999
+ * carries when that result is still fresh, or `undefined` when the row is not
1000
+ * durable grooming at all (no to-spec payload — a hand-edited or pre-#772
1001
+ * row — or a source observed past the freshness ceiling).
1002
+ *
1003
+ * Every reader of "is this issue already groomed?" MUST go through this:
1004
+ * {@link toSpecCandidateExclusion} (selection and the `tool_call` gate), the
1005
+ * launch block's `already-groomed` list, and the queue digest's
1006
+ * already-considered inventory. Two readers with two predicates is exactly
1007
+ * the #887 defect — the digest told the orchestrator "never re-groom these"
1008
+ * about rows the mechanical selection was simultaneously offering.
1009
+ */
1010
+ export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
1011
+ const result = parseToSpecEvidence(row.evidence);
1012
+ if (result === undefined) return undefined;
1013
+ return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
1014
+ }
1015
+
1016
+ /** The refusal a row records when its pass produced nothing usable, while the
1017
+ * cooldown above still holds it out of a new batch; `undefined` for any
1018
+ * other row, including a refusal whose cooldown has expired. */
1019
+ export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
1020
+ const failure = parseToSpecFailureEvidence(row.evidence);
1021
+ if (failure === undefined) return undefined;
1022
+ return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
1023
+ }
1024
+
821
1025
  /** The first line of every batch item's `task`, in the shape the gate parses:
822
1026
  * `to-spec candidate: <owner/repo>#<issue> — <title>`. */
823
1027
  export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
@@ -906,6 +1110,12 @@ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
906
1110
  * re-running it would recompute a verdict that is still valid. New source
907
1111
  * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
908
1112
  * the row no longer reads as groomed, and a fresh pass overrides it;
1113
+ * - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
1114
+ * delegated batch was already spent and produced nothing usable
1115
+ * (malformed, source-less or stale output). Retrying inside the cooldown
1116
+ * re-runs the identical prompt against the same source and refuses the
1117
+ * same way, which is how one broken candidate consumed a batch on every
1118
+ * low-queue tick (#887);
909
1119
  * - `active`: a run is in flight on the issue right now.
910
1120
  */
911
1121
  export function toSpecCandidateExclusion(
@@ -922,14 +1132,23 @@ export function toSpecCandidateExclusion(
922
1132
  } else if (row.reason === "file-lane" || row.reason === "depends-on") {
923
1133
  return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
924
1134
  } else {
925
- const result = parseToSpecEvidence(row.evidence);
926
- if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1135
+ const durable = toSpecDurableVerdict(row, now);
1136
+ if (durable !== undefined) {
927
1137
  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 ` +
1138
+ `#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
1139
+ `${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
930
1140
  "with new source evidence"
931
1141
  );
932
1142
  }
1143
+ const refusal = toSpecRefusalOnCooldown(row, now);
1144
+ if (refusal !== undefined) {
1145
+ const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
1146
+ return (
1147
+ `#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
1148
+ `(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
1149
+ "issue or its source changes"
1150
+ );
1151
+ }
933
1152
  }
934
1153
  }
935
1154
  if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
@@ -1001,6 +1220,9 @@ export interface ToSpecLaunchBlock {
1001
1220
  export interface ToSpecLaunchExclusions {
1002
1221
  /** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
1003
1222
  groomed: string[];
1223
+ /** Candidates whose last pass was refused and whose retry cooldown still
1224
+ * holds ({@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}). */
1225
+ refused: string[];
1004
1226
  /** Candidates with an active to-spec batch. */
1005
1227
  inFlight: string[];
1006
1228
  /** Candidates under admission's durable lane/dependency holds. */
@@ -1094,21 +1316,28 @@ export async function offerToSpecLaunch(input: {
1094
1316
  active: readonly { issue: number }[];
1095
1317
  project: ProjectConfig;
1096
1318
  trackerSeam: ToSpecTrackerSeam | undefined;
1319
+ /** An open-issue snapshot this tick already read from the tracker. Shared
1320
+ * with the queue digest so one tick cannot describe two queues (#848);
1321
+ * absent when that read failed, in which case the launch retries its own
1322
+ * read and fails closed on the same terms as before. */
1323
+ issues?: readonly ReadyIssue[];
1097
1324
  now: number;
1098
1325
  }): Promise<ToSpecLaunchBlock | undefined> {
1099
1326
  if (input.summary === undefined) return undefined;
1100
1327
  if (input.summary.routed >= input.groomBelow) return undefined;
1101
1328
  const seam = input.trackerSeam;
1102
1329
  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;
1330
+ let issues = input.issues;
1331
+ if (issues === undefined) {
1332
+ try {
1333
+ issues = await seam.listOpenIssues(input.project);
1334
+ } catch {
1335
+ // No authoritative snapshot, no launch: a batch offered without one would
1336
+ // make the model the selector, which is exactly the defect this slice
1337
+ // removes. The queue digest still names the grooming duty; the next tick
1338
+ // retries the read.
1339
+ return undefined;
1340
+ }
1112
1341
  }
1113
1342
  const views = toSpecPoolFromSnapshot(issues, input.project);
1114
1343
  const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
@@ -1184,17 +1413,35 @@ export function toSpecLaunchBlock(input: {
1184
1413
  if (input.summary === undefined) return undefined;
1185
1414
  if (input.summary.routed >= input.groomBelow) return undefined;
1186
1415
  if (input.selected.length === 0) return undefined;
1187
- const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
1416
+ const exclusions: ToSpecLaunchExclusions = {
1417
+ groomed: [],
1418
+ refused: [],
1419
+ inFlight: [],
1420
+ mechanicallyBlocked: [],
1421
+ dispatched: [],
1422
+ };
1188
1423
  for (const row of input.grooming) {
1189
1424
  if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1190
1425
  if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
1191
1426
  } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1192
1427
  exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
1193
1428
  } else {
1194
- const result = parseToSpecEvidence(row.evidence);
1195
- if (result !== undefined && input.now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1429
+ // The same two predicates the selection and the gate apply, so a
1430
+ // candidate this block advertises as excluded is one selection actually
1431
+ // withheld — and one it does not advertise is one selection may offer
1432
+ // (#887).
1433
+ const durable = toSpecDurableVerdict(row, input.now);
1434
+ if (durable !== undefined) {
1196
1435
  exclusions.groomed.push(
1197
- `#${row.issue} (${result.verdict} @ ${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()})`,
1436
+ `#${row.issue} (${durable.verdict} @ ${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()})`,
1437
+ );
1438
+ continue;
1439
+ }
1440
+ const refusal = toSpecRefusalOnCooldown(row, input.now);
1441
+ if (refusal !== undefined) {
1442
+ exclusions.refused.push(
1443
+ `#${row.issue} (${refusal.kind}, refused ${new Date(row.recordedAt).toISOString()}, retry after ` +
1444
+ `${new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString()})`,
1198
1445
  );
1199
1446
  }
1200
1447
  }
@@ -1217,10 +1464,11 @@ export function toSpecLaunchBlock(input: {
1217
1464
  "The conductor selected this batch mechanically from the live open-issue snapshot " +
1218
1465
  `(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
1219
1466
  `\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
1220
- "already-groomed, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
1467
+ "already-groomed, refused-on-cooldown, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
1221
1468
  candidates,
1222
1469
  "Excluded this tick — " +
1223
1470
  `already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
1471
+ `refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
1224
1472
  `in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
1225
1473
  `mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
1226
1474
  `dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
@@ -1247,6 +1495,51 @@ export function toSpecLaunchBlock(input: {
1247
1495
  return { token, block: lines.join("\n"), items };
1248
1496
  }
1249
1497
 
1498
+ /**
1499
+ * The accounting a low-queue tick owes Duty 2 when the mechanical selection
1500
+ * produced no batch at all. Without it a fully-excluded backlog is silence,
1501
+ * and silence is what gets re-derived by hand: the orchestrator cannot tell
1502
+ * "the queue is low and nothing is groomable" from "the launch machinery did
1503
+ * not run". Every count comes from the same predicates the selection and the
1504
+ * `tool_call` gate apply (#887).
1505
+ *
1506
+ * `undefined` when no dispatch row exists or the queue is at/above the
1507
+ * grooming trigger — the same gate the offer itself uses, so this line and a
1508
+ * launch block are mutually exclusive.
1509
+ */
1510
+ export function toSpecNoBatchLine(input: {
1511
+ summary: DispatchSummary | undefined;
1512
+ groomBelow: number;
1513
+ grooming: readonly GroomingRecord[];
1514
+ active: readonly { issue: number }[];
1515
+ now: number;
1516
+ }): string | undefined {
1517
+ if (input.summary === undefined) return undefined;
1518
+ if (input.summary.routed >= input.groomBelow) return undefined;
1519
+ let durable = 0;
1520
+ let refused = 0;
1521
+ let inFlight = 0;
1522
+ let held = 0;
1523
+ for (const row of input.grooming) {
1524
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1525
+ if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) inFlight += 1;
1526
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1527
+ held += 1;
1528
+ } else if (toSpecDurableVerdict(row, input.now) !== undefined) {
1529
+ durable += 1;
1530
+ } else if (toSpecRefusalOnCooldown(row, input.now) !== undefined) {
1531
+ refused += 1;
1532
+ }
1533
+ }
1534
+ return (
1535
+ `No to-spec batch this tick: the mechanical selection found nothing eligible in the open-issue ` +
1536
+ `snapshot — ${durable} durable verdict(s), ${refused} refused inside the retry cooldown, ${inFlight} ` +
1537
+ `in flight, ${held} lane/dependency-blocked, ${input.active.length} dispatched. A \`task\` call carrying ` +
1538
+ `the ${TO_SPEC_BATCH_MARKER} marker is refused this turn; file or promote from what the backlog already ` +
1539
+ "says instead of re-grooming it."
1540
+ );
1541
+ }
1542
+
1250
1543
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
1251
1544
  material: "Report material events per your brief.",
1252
1545
  escalations:
@@ -1451,6 +1744,8 @@ export const TICK_ASK_RULE =
1451
1744
  `On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
1452
1745
  `call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
1453
1746
  `and optionally "timeoutSeconds" — an ask issued without one still gets the default ceiling, capped at the turn budget. ` +
1747
+ `Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, one ceiling, ` +
1748
+ `each item a durable row bound to that issue, resolved independently and in any order. ` +
1454
1749
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
1455
1750
  `takes, and an unanswered question must never hold the loop.`;
1456
1751
 
@@ -2941,24 +3236,33 @@ function resolveAskProject(
2941
3236
  * prior state, and executing it still fails closed when it cannot route.
2942
3237
  */
2943
3238
  async function ensureAskSurface(pi: TickApi, resolvable: boolean): Promise<boolean> {
3239
+ // Both operator-question surfaces flip together (#947): they route through the
3240
+ // same resolution and fail closed the same way, so a beat that can carry one
3241
+ // can carry the other, and a degraded beat must hide both — an ask surface that
3242
+ // is half-present is a model choosing between a working tool and a broken one.
3243
+ const tools = [ASK_TOOL, QUESTIONNAIRE_TOOL];
2944
3244
  const current = pi.getActiveTools();
2945
- const present = current.includes(ASK_TOOL);
2946
- if (resolvable === present) return true;
3245
+ const present = tools.every((tool) => current.includes(tool));
3246
+ const absent = tools.every((tool) => !current.includes(tool));
3247
+ if (resolvable ? present : absent) return true;
2947
3248
  try {
2948
3249
  await pi.setActiveTools(
2949
- resolvable ? [...current, ASK_TOOL] : current.filter((name) => name !== ASK_TOOL),
3250
+ resolvable
3251
+ ? [...current.filter((name) => !tools.includes(name)), ...tools]
3252
+ : current.filter((name) => !tools.includes(name)),
2950
3253
  );
2951
3254
  } catch (err) {
2952
3255
  pi.logger.error(
2953
- `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${ASK_TOOL}: ${
3256
+ `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${tools.join(" / ")}: ${
2954
3257
  err instanceof Error ? err.message : String(err)
2955
3258
  }`,
2956
3259
  );
2957
3260
  return false;
2958
3261
  }
2959
3262
  // An awaited call is not yet confirmation: the live membership read after the
2960
- // reconciliation is what proves the tool is really model-visible.
2961
- return pi.getActiveTools().includes(ASK_TOOL) === resolvable;
3263
+ // reconciliation is what proves the tools are really model-visible.
3264
+ const after = pi.getActiveTools();
3265
+ return tools.every((tool) => after.includes(tool) === resolvable);
2962
3266
  }
2963
3267
 
2964
3268
  /**
@@ -3198,6 +3502,16 @@ async function tick(
3198
3502
  // because the whole failure was a question surviving in context only.
3199
3503
  const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
3200
3504
  if (decisions.length > 0) content = `${content}\n${decisions}`;
3505
+ // A host running two different releases at once, from the row the
3506
+ // dispatch pass recorded (#919). Read only — probing the three surfaces
3507
+ // here spawns three children per tick and took this file's own suite
3508
+ // from 8.4s to 83.4s, which is why the observation is recorded. Only a
3509
+ // proven mismatch appears: an absent surface, a `local:` herdr link and
3510
+ // an unverifiable pin are steady states someone chose, and a warning
3511
+ // repeated every fifteen minutes trains an operator to ignore the line
3512
+ // that matters. `doctor` keeps that nuance.
3513
+ const surfaces = installSurfaceMismatch(frictionStore.installSurfaces());
3514
+ if (surfaces !== undefined) content = `${content}\n${surfaces}`;
3201
3515
  // What the daemon already fixed, so the session stops re-deriving that
3202
3516
  // paragraph on every tick (#132). Two intervals wide rather than one: a
3203
3517
  // tick that ran long must not drop the window it was meant to report.
@@ -3224,12 +3538,63 @@ async function tick(
3224
3538
  // so the resolved name is in hand and an un-named lookup would refuse
3225
3539
  // to guess on a host with a second project.
3226
3540
  const project = findProject(loadConfig(), scope.projectName);
3541
+ const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
3542
+ const dispatch = frictionStore.latestDispatch(scope.projectName);
3543
+ const grooming = store.groomingVerdicts(projectName);
3544
+ // #848: "the queue is empty / running dry" is the tracker's word, not
3545
+ // the last dispatch pass's echo. One open-issue snapshot per tick,
3546
+ // read live through the same authoritative tracker surface the
3547
+ // to-spec launch uses, overlaid with the label_ops projection
3548
+ // exactly as the dispatch pass judges eligibility — so a promotion,
3549
+ // unblock or projection write after the last dispatch pass is
3550
+ // visible on the very next tick even while a drain holds claiming.
3551
+ // The digest consumes the overlay as the live inventory; the to-spec
3552
+ // offer shares the same raw snapshot so one tick cannot describe two
3553
+ // queues. If the tracker read fails, the digest falls back to
3554
+ // explicitly dated wording (never a present-tense empty claim),
3555
+ // and the offer retries its own read exactly as it did before.
3556
+ //
3557
+ // The read is made only for dispatch rows the digest could render:
3558
+ // a healthy row (routed at/above the grooming threshold) emits no
3559
+ // queue line at all, so no freshness check is spent on it and no
3560
+ // stale claim can leak from it. A zero-ready row (or any row below
3561
+ // the trigger) is exactly where the old wording lied, so every such
3562
+ // tick reads the live queue.
3563
+ const needsQueueRead =
3564
+ dispatch !== undefined &&
3565
+ (dispatch.ready === 0 || dispatch.routed === 0 || dispatch.routed < groomBelow);
3566
+ let queueObservation: QueueObservation | undefined;
3567
+ let openSnapshot: readonly ReadyIssue[] | undefined;
3568
+ if (toSpecTrackerSeam !== undefined && needsQueueRead) {
3569
+ try {
3570
+ const open = await toSpecTrackerSeam.listOpenIssues(project);
3571
+ openSnapshot = open;
3572
+ const effective = open.map((issue) => {
3573
+ const pending = store.pendingLabelOpsFor(projectName, issue.number);
3574
+ return pending.length === 0
3575
+ ? issue
3576
+ : { ...issue, labels: effectiveLabels(issue.labels, pending) };
3577
+ });
3578
+ queueObservation = {
3579
+ queued: effective.filter((issue) => issue.labels.includes(project.queueLabel)).length,
3580
+ observedAt: now,
3581
+ };
3582
+ } catch {
3583
+ // No current-queue evidence this tick: the digest below renders
3584
+ // the dispatch row as dated history instead of claiming empty.
3585
+ }
3586
+ }
3227
3587
  const queue = queueDigestLine(
3228
- frictionStore.latestDispatch(scope.projectName),
3588
+ dispatch,
3229
3589
  project.queueLabel,
3230
3590
  project.routing.labelPrefix,
3231
- project.groomBelow ?? DEFAULT_GROOM_BELOW,
3232
- store.groomingVerdicts(projectName),
3591
+ groomBelow,
3592
+ grooming,
3593
+ queueObservation,
3594
+ // One clock for the inventory and the offer below: the digest must
3595
+ // never call a verdict durable that the same tick's selection is
3596
+ // about to re-groom (#887).
3597
+ now,
3233
3598
  );
3234
3599
  if (queue !== undefined) content = `${content}\n${queue}`;
3235
3600
  // #777: the mechanical to-spec launch boundary. The queue digest is
@@ -3243,11 +3608,13 @@ async function tick(
3243
3608
  //
3244
3609
  // Every tick owns its authorization fresh — the clears above ran
3245
3610
  // before the reads, so the offer below can only mint for THIS tick.
3611
+ const toSpecActive = store.activeRuns(projectName);
3246
3612
  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),
3613
+ summary: dispatch,
3614
+ groomBelow,
3615
+ grooming,
3616
+ active: toSpecActive,
3617
+ issues: openSnapshot,
3251
3618
  project,
3252
3619
  trackerSeam: toSpecTrackerSeam,
3253
3620
  now,
@@ -3261,6 +3628,19 @@ async function tick(
3261
3628
  session.launchProject = project.name;
3262
3629
  session.launchToken = launch.token;
3263
3630
  session.launchItems = launch.items;
3631
+ } else if (openSnapshot !== undefined) {
3632
+ // A low queue with nothing eligible is a finding, not silence: the
3633
+ // snapshot read succeeded, so the exclusions — and only they — are
3634
+ // why no batch is offered. Rendered only when the read succeeded,
3635
+ // so a tracker failure never masquerades as "nothing eligible".
3636
+ const noBatch = toSpecNoBatchLine({
3637
+ summary: dispatch,
3638
+ groomBelow,
3639
+ grooming,
3640
+ active: toSpecActive,
3641
+ now,
3642
+ });
3643
+ if (noBatch !== undefined) content = `${content}\n${noBatch}`;
3264
3644
  }
3265
3645
  // Pending intake is the same class of standing block as the friction
3266
3646
  // and decisions read-outs: a store-backed duty the orchestrator must
@@ -4033,22 +4413,25 @@ export default function orchestratorTickExtension(
4033
4413
  }
4034
4414
  return;
4035
4415
  }
4036
- // The reply to an arming challenge lands here as an ordinary user turn. It
4037
- // must reach the transcript exactly as sent the host's transcript scan
4038
- // (`armTicks`) is the backend proof, so it is left untouched — while the
4039
- // model gets a trusted, machine-readable steer about it, so it stops
4040
- // ad-libbing pairing-safety prose at a bare `FLEET-…` token (conductor
4041
- // #415). The classification derives from persisted authenticated
4042
- // pending-challenge state (hash + expiry, keyed by this project), never
4043
- // from the `FLEET-` prefix, so an unsolicited lookalike that matches no
4044
- // active challenge stays inert.
4416
+ // The reply to an arming challenge lands here as an ordinary user turn.
4417
+ // This adapter — not the model, and not any transcript scan is the
4418
+ // sole producer of the arming acknowledgement: it classifies the turn
4419
+ // against persisted authenticated challenge state and, on a match,
4420
+ // atomically records the challenge-id-specific acknowledgement the
4421
+ // host-side `arm` waits on (conductor #614), all before normal model
4422
+ // handling. The classification derives from the pending challenge (hash
4423
+ // + expiry, keyed by this project), never from the `FLEET-` prefix, so
4424
+ // an unsolicited lookalike that matches no active challenge stays inert
4425
+ // and model behaviour cannot determine whether the host becomes armed.
4426
+ // The turn itself still reaches the transcript exactly as sent, with the
4427
+ // same trusted machine-readable steer as before (#415).
4045
4428
  if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
4046
4429
  const replyText =
4047
4430
  message.content
4048
4431
  ?.filter((part) => part.type === "text" && typeof part.text === "string")
4049
4432
  .map((part) => part.text as string)
4050
4433
  .join(" ") ?? "";
4051
- const proof = isActiveArmProof(configuredProject, replyText, Date.now());
4434
+ const proof = acknowledgeArmReply(configuredProject, replyText, Date.now());
4052
4435
  if (session.activeLocalTick !== undefined) {
4053
4436
  session.activeLocalTick.humanWaiting = true;
4054
4437
  if (proof) session.activeLocalTick.armingProof = true;
@@ -4112,6 +4495,71 @@ export default function orchestratorTickExtension(
4112
4495
  // the project to file the decision row against and to resolve the delivery
4113
4496
  // target. An unreadable/ambiguous config makes the tool say so and record
4114
4497
  // nothing, which is the same fail-closed posture the autonomous gate takes.
4498
+ /**
4499
+ * The plumbing both operator-question tools share (#947): which project this
4500
+ * call records against, and the one sanctioned delivery path.
4501
+ *
4502
+ * Extracted rather than copied, because the questionnaire needs exactly the
4503
+ * live-config routing, the fail-closed refusals and the held-notice fallback
4504
+ * the single ask already got right — and a second copy of the fallback is how
4505
+ * one surface quietly starts dropping questions the other one holds.
4506
+ */
4507
+ const resolveOperatorAskContext = (
4508
+ tool: string,
4509
+ ): { ok: true; project: ProjectConfig; config: TickConfig } | { ok: false; text: string } => {
4510
+ const session = askSession;
4511
+ if (session === undefined) {
4512
+ return {
4513
+ ok: false,
4514
+ text: `${tool}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
4515
+ };
4516
+ }
4517
+ const routed = resolveAskProject(session.cwd, session.config);
4518
+ if (routed.kind === "error") {
4519
+ return {
4520
+ ok: false,
4521
+ text:
4522
+ `${tool}: conductor config unreadable (${routed.problem}); ` +
4523
+ "nothing was asked or recorded. Repair the config, do not ask through another path.",
4524
+ };
4525
+ }
4526
+ return { ok: true, project: routed.project, config: session.config };
4527
+ };
4528
+
4529
+ const operatorAskDelivery =
4530
+ (tool: string, projectConfig: ProjectConfig, store: Store) =>
4531
+ async (text: string, category: InterruptCategory): Promise<AskDeliveryResult> => {
4532
+ const at = Date.now();
4533
+ const noticeId = randomUUID();
4534
+ try {
4535
+ const delivered = await deliverOperatorMessage(projectConfig, text, {
4536
+ store,
4537
+ at,
4538
+ noticeId,
4539
+ category,
4540
+ });
4541
+ return delivered.kind === "sent"
4542
+ ? { kind: "sent", category: delivered.category }
4543
+ : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
4544
+ } catch (err) {
4545
+ // A failed immediate send must not drop the question: fall back to the
4546
+ // durable hold exactly like the gate's own hold path, and let the daemon
4547
+ // retry with the digest.
4548
+ store.addHeldNotice({
4549
+ id: noticeId,
4550
+ project: projectConfig.name,
4551
+ category,
4552
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
4553
+ detail: text,
4554
+ createdAt: at,
4555
+ });
4556
+ pi.logger.error(
4557
+ `[omp-conductor] ${tool} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
4558
+ );
4559
+ return { kind: "held", category, noticeId };
4560
+ }
4561
+ };
4562
+
4115
4563
  pi.registerTool({
4116
4564
  name: ASK_TOOL,
4117
4565
  label: ASK_TOOL,
@@ -4140,43 +4588,16 @@ export default function orchestratorTickExtension(
4140
4588
  if (!parsed.ok) {
4141
4589
  return { content: [{ type: "text", text: parsed.problem }], isError: true };
4142
4590
  }
4143
- const session = askSession;
4144
- if (session === undefined) {
4145
- // Not a conductor tick session (subagent, or a session that never
4146
- // composed a tick). Same fail-closed posture as an unresolvable config:
4147
- // say so, record nothing, and never route the question elsewhere.
4148
- return {
4149
- content: [
4150
- {
4151
- type: "text",
4152
- text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
4153
- },
4154
- ],
4155
- isError: true,
4156
- };
4157
- }
4158
- // Routing follows the *live* tick config, not the session-start stamp:
4159
- // a restamp (un-stamped → stamped, or project A → B) must make the next
4160
- // tick's toolbox land on the project the turn actually ticks for, and an
4161
- // abandoned stamp must not keep recording against a project that is no
4162
- // longer this fleet's. Only the ceiling stays startup-only (from
4163
- // `config` below) — routing is re-read every call.
4164
- const { cwd, config } = session;
4165
- const routed = resolveAskProject(cwd, config);
4166
- if (routed.kind === "error") {
4167
- return {
4168
- content: [
4169
- {
4170
- type: "text",
4171
- text:
4172
- `${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
4173
- "nothing was asked or recorded. Repair the config, do not ask through another path.",
4174
- },
4175
- ],
4176
- isError: true,
4177
- };
4591
+ // Routing follows the *live* tick config, not the session-start stamp: a
4592
+ // restamp (un-stamped stamped, or project A → B) must make the next
4593
+ // tick's toolbox land on the project the turn actually ticks for. Only the
4594
+ // ceiling stays startup-only routing is re-read every call.
4595
+ const context = resolveOperatorAskContext(ASK_TOOL);
4596
+ if (!context.ok) {
4597
+ return { content: [{ type: "text", text: context.text }], isError: true };
4178
4598
  }
4179
- const projectConfig = routed.project;
4599
+ const projectConfig = context.project;
4600
+ const config = context.config;
4180
4601
  const store = openStore(dbPath());
4181
4602
  let result: AskResult;
4182
4603
  try {
@@ -4192,37 +4613,7 @@ export default function orchestratorTickExtension(
4192
4613
  // inject a fake through the same seam.
4193
4614
  interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
4194
4615
  ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
4195
- deliver: async (text, category) => {
4196
- const at = Date.now();
4197
- const noticeId = randomUUID();
4198
- try {
4199
- const delivered = await deliverOperatorMessage(projectConfig, text, {
4200
- store,
4201
- at,
4202
- noticeId,
4203
- category,
4204
- });
4205
- return delivered.kind === "sent"
4206
- ? { kind: "sent", category: delivered.category }
4207
- : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
4208
- } catch (err) {
4209
- // A failed immediate send must not drop the question: fall back
4210
- // to the durable hold exactly like the gate's own hold path, and
4211
- // let the daemon retry with the digest.
4212
- store.addHeldNotice({
4213
- id: noticeId,
4214
- project: projectConfig.name,
4215
- category,
4216
- summary: text.split("\n", 1)[0]!.slice(0, 240),
4217
- detail: text,
4218
- createdAt: at,
4219
- });
4220
- pi.logger.error(
4221
- `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
4222
- );
4223
- return { kind: "held", category, noticeId };
4224
- }
4225
- },
4616
+ deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
4226
4617
  });
4227
4618
  } finally {
4228
4619
  store.close();
@@ -4231,6 +4622,58 @@ export default function orchestratorTickExtension(
4231
4622
  },
4232
4623
  });
4233
4624
 
4625
+ // The batched spec-out questionnaire (#947): the same durable contract as the
4626
+ // single ask, asked once for several judgement calls about one issue. Mounted
4627
+ // and routed through exactly the shared helpers above, so the two surfaces
4628
+ // cannot come to disagree about which project a question belongs to or what
4629
+ // happens when delivery fails.
4630
+ pi.registerTool({
4631
+ name: QUESTIONNAIRE_TOOL,
4632
+ label: QUESTIONNAIRE_TOOL,
4633
+ defaultInactive: true,
4634
+ description:
4635
+ `Ask your operator several bounded questions about ONE issue as a single message, and wait up ` +
4636
+ `to one ask ceiling for the answers. Every item is recorded as its own durable decision row ` +
4637
+ `before anything is delivered, all bound to the issue you name in "spec-issue" — so the answers ` +
4638
+ `become that issue's provenance and a later reader sees why a slice is shaped the way it is. ` +
4639
+ `Items resolve independently and in any order: an item the operator answers keeps that answer, ` +
4640
+ `and at the ceiling each unanswered item takes its own declared "on-timeout" ("auto-proceed" ` +
4641
+ `applies its recommendation and records that nobody human chose it; "park" leaves the row open ` +
4642
+ `and pending, and you then park the work it blocks). Ask ONLY the judgement calls that genuinely ` +
4643
+ `belong to your operator — anything a repo read can answer is your own work — and at most ` +
4644
+ `${MAX_QUESTIONNAIRE_ITEMS} items. This path is plain text by design (one message, not ` +
4645
+ `${MAX_QUESTIONNAIRE_ITEMS} button posts), so a prose reply does not itself resolve a row: map ` +
4646
+ `it with \`omp-conductor decision resolve <id> --answer "…"\`. Use conductor_ask for a single ` +
4647
+ `question.`,
4648
+ parameters: questionnaireParameterSchema(),
4649
+ approval: "write",
4650
+ execute: async (_toolCallId, params) => {
4651
+ const parsed = parseQuestionnaireRequest(params);
4652
+ if (!parsed.ok) {
4653
+ return { content: [{ type: "text", text: parsed.problem }], isError: true };
4654
+ }
4655
+ const context = resolveOperatorAskContext(QUESTIONNAIRE_TOOL);
4656
+ if (!context.ok) {
4657
+ return { content: [{ type: "text", text: context.text }], isError: true };
4658
+ }
4659
+ const projectConfig = context.project;
4660
+ const store = openStore(dbPath());
4661
+ try {
4662
+ const result = await performQuestionnaire(parsed.request, {
4663
+ store,
4664
+ project: projectConfig.name,
4665
+ configuredCeilingSeconds: context.config.askTimeoutSeconds,
4666
+ turnBudgetSeconds: context.config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
4667
+ ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
4668
+ deliver: operatorAskDelivery(QUESTIONNAIRE_TOOL, projectConfig, store),
4669
+ });
4670
+ return { content: [{ type: "text", text: result.text }] };
4671
+ } finally {
4672
+ store.close();
4673
+ }
4674
+ },
4675
+ });
4676
+
4234
4677
  // The async half of the to-spec result capture (#777). Registered at
4235
4678
  // extension-factory time like {@link ASK_TOOL}, with the same routing
4236
4679
  // contract: the state it needs (cwd + startup config) is filled by