omp-conductor 0.18.2 → 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 (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
@@ -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,
@@ -121,9 +128,12 @@ import { dbPath, openStore } from "./store.ts";
121
128
  import { digestDue, localDayKey } from "./digest-schedule.ts";
122
129
  import {
123
130
  parseToSpecEvidence,
131
+ parseToSpecFailureEvidence,
124
132
  recordToSpecGrooming,
125
133
  TO_SPEC_MAX_SOURCE_AGE_MS,
126
134
  TO_SPEC_SCHEMA,
135
+ type ToSpecFailure,
136
+ type ToSpecResult,
127
137
  } from "./to-spec.ts";
128
138
  import { heldNoticeId } from "./notices.ts";
129
139
  import { acknowledgeArmReply } from "./arm-challenge.ts";
@@ -681,12 +691,17 @@ export function queueDigestLine(
681
691
  groomBelow: number,
682
692
  grooming: readonly GroomingRecord[] = [],
683
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(),
684
699
  ): string | undefined {
685
700
  if (summary === undefined) return undefined;
686
701
  if (queue !== undefined) {
687
- return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming);
702
+ return liveQueueDigestLine(summary, queue, queueLabel, labelPrefix, groomBelow, grooming, now);
688
703
  }
689
- return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming);
704
+ return datedQueueDigestLine(summary, queueLabel, labelPrefix, groomBelow, grooming, now);
690
705
  }
691
706
 
692
707
  /** The dated rendering: the last dispatch row is the only evidence, so every
@@ -697,6 +712,7 @@ function datedQueueDigestLine(
697
712
  labelPrefix: string,
698
713
  groomBelow: number,
699
714
  grooming: readonly GroomingRecord[],
715
+ now: number,
700
716
  ): string | undefined {
701
717
  const dated = new Date(summary.completedAt).toISOString();
702
718
  if (summary.ready === 0) {
@@ -735,7 +751,7 @@ function datedQueueDigestLine(
735
751
  return line;
736
752
  }
737
753
  if (summary.routed >= groomBelow) return undefined;
738
- return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `);
754
+ return lowQueueTail(summary, groomBelow, grooming, `As of the last dispatch (${dated}): Queue: `, now);
739
755
  }
740
756
 
741
757
  /** The live rendering: the tracker observation is the queue label inventory,
@@ -752,6 +768,7 @@ function liveQueueDigestLine(
752
768
  labelPrefix: string,
753
769
  groomBelow: number,
754
770
  grooming: readonly GroomingRecord[],
771
+ now: number,
755
772
  ): string | undefined {
756
773
  const queued = queue.queued;
757
774
  const observed = new Date(queue.observedAt).toISOString();
@@ -808,7 +825,7 @@ function liveQueueDigestLine(
808
825
  // low-queue diagnostics the dated path ships — the claimable/known-blocked
809
826
  // split, in-flight to-spec batches, the considered backlog and this pass's
810
827
  // holds (#735, #777, #679) — so a tracker read never hides them.
811
- return inventory + lowQueueTail(summary, groomBelow, grooming, "");
828
+ return inventory + lowQueueTail(summary, groomBelow, grooming, "", now);
812
829
  }
813
830
 
814
831
  /** The shared low-queue diagnostic tail (#735/#777/#679): the routable-count →
@@ -822,6 +839,9 @@ function lowQueueTail(
822
839
  groomBelow: number,
823
840
  grooming: readonly GroomingRecord[],
824
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,
825
845
  ): string {
826
846
  // The durable per-issue verdicts, not this pass's one-shot hold groups: a
827
847
  // lane-blocked runway must read as "cannot move" even after a restart, and
@@ -853,9 +873,18 @@ function lowQueueTail(
853
873
  tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
854
874
  }
855
875
  if (considered.length > 0) {
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;
856
883
  tail +=
857
884
  ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
858
- `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.";
859
888
  }
860
889
  if (summary.admitted === 0 && summary.holds.length > 0) {
861
890
  const held = summary.holds
@@ -948,6 +977,51 @@ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
948
977
  */
949
978
  export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
950
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
+
951
1025
  /** The first line of every batch item's `task`, in the shape the gate parses:
952
1026
  * `to-spec candidate: <owner/repo>#<issue> — <title>`. */
953
1027
  export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
@@ -1036,6 +1110,12 @@ export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
1036
1110
  * re-running it would recompute a verdict that is still valid. New source
1037
1111
  * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
1038
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);
1039
1119
  * - `active`: a run is in flight on the issue right now.
1040
1120
  */
1041
1121
  export function toSpecCandidateExclusion(
@@ -1052,14 +1132,23 @@ export function toSpecCandidateExclusion(
1052
1132
  } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1053
1133
  return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
1054
1134
  } else {
1055
- const result = parseToSpecEvidence(row.evidence);
1056
- if (result !== undefined && now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS) {
1135
+ const durable = toSpecDurableVerdict(row, now);
1136
+ if (durable !== undefined) {
1057
1137
  return (
1058
- `#${candidate.issue} was already groomed ${result.verdict} (source ${result.source.name}@` +
1059
- `${result.source.ref}, observed ${new Date(result.source.freshAt).toISOString()}) — re-groom only ` +
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 ` +
1060
1140
  "with new source evidence"
1061
1141
  );
1062
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
+ }
1063
1152
  }
1064
1153
  }
1065
1154
  if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
@@ -1131,6 +1220,9 @@ export interface ToSpecLaunchBlock {
1131
1220
  export interface ToSpecLaunchExclusions {
1132
1221
  /** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
1133
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[];
1134
1226
  /** Candidates with an active to-spec batch. */
1135
1227
  inFlight: string[];
1136
1228
  /** Candidates under admission's durable lane/dependency holds. */
@@ -1321,17 +1413,35 @@ export function toSpecLaunchBlock(input: {
1321
1413
  if (input.summary === undefined) return undefined;
1322
1414
  if (input.summary.routed >= input.groomBelow) return undefined;
1323
1415
  if (input.selected.length === 0) return undefined;
1324
- const exclusions: ToSpecLaunchExclusions = { groomed: [], inFlight: [], mechanicallyBlocked: [], dispatched: [] };
1416
+ const exclusions: ToSpecLaunchExclusions = {
1417
+ groomed: [],
1418
+ refused: [],
1419
+ inFlight: [],
1420
+ mechanicallyBlocked: [],
1421
+ dispatched: [],
1422
+ };
1325
1423
  for (const row of input.grooming) {
1326
1424
  if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1327
1425
  if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
1328
1426
  } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1329
1427
  exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
1330
1428
  } else {
1331
- const result = parseToSpecEvidence(row.evidence);
1332
- 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) {
1333
1435
  exclusions.groomed.push(
1334
- `#${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()})`,
1335
1445
  );
1336
1446
  }
1337
1447
  }
@@ -1354,10 +1464,11 @@ export function toSpecLaunchBlock(input: {
1354
1464
  "The conductor selected this batch mechanically from the live open-issue snapshot " +
1355
1465
  `(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
1356
1466
  `\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
1357
- "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):",
1358
1468
  candidates,
1359
1469
  "Excluded this tick — " +
1360
1470
  `already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
1471
+ `refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
1361
1472
  `in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
1362
1473
  `mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
1363
1474
  `dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
@@ -1384,6 +1495,51 @@ export function toSpecLaunchBlock(input: {
1384
1495
  return { token, block: lines.join("\n"), items };
1385
1496
  }
1386
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
+
1387
1543
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
1388
1544
  material: "Report material events per your brief.",
1389
1545
  escalations:
@@ -1588,6 +1744,8 @@ export const TICK_ASK_RULE =
1588
1744
  `On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
1589
1745
  `call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
1590
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. ` +
1591
1749
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
1592
1750
  `takes, and an unanswered question must never hold the loop.`;
1593
1751
 
@@ -3078,24 +3236,33 @@ function resolveAskProject(
3078
3236
  * prior state, and executing it still fails closed when it cannot route.
3079
3237
  */
3080
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];
3081
3244
  const current = pi.getActiveTools();
3082
- const present = current.includes(ASK_TOOL);
3083
- 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;
3084
3248
  try {
3085
3249
  await pi.setActiveTools(
3086
- 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)),
3087
3253
  );
3088
3254
  } catch (err) {
3089
3255
  pi.logger.error(
3090
- `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${ASK_TOOL}: ${
3256
+ `[omp-conductor] could not ${resolvable ? "activate" : "deactivate"} ${tools.join(" / ")}: ${
3091
3257
  err instanceof Error ? err.message : String(err)
3092
3258
  }`,
3093
3259
  );
3094
3260
  return false;
3095
3261
  }
3096
3262
  // An awaited call is not yet confirmation: the live membership read after the
3097
- // reconciliation is what proves the tool is really model-visible.
3098
- 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);
3099
3266
  }
3100
3267
 
3101
3268
  /**
@@ -3335,6 +3502,16 @@ async function tick(
3335
3502
  // because the whole failure was a question surviving in context only.
3336
3503
  const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
3337
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}`;
3338
3515
  // What the daemon already fixed, so the session stops re-deriving that
3339
3516
  // paragraph on every tick (#132). Two intervals wide rather than one: a
3340
3517
  // tick that ran long must not drop the window it was meant to report.
@@ -3414,6 +3591,10 @@ async function tick(
3414
3591
  groomBelow,
3415
3592
  grooming,
3416
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,
3417
3598
  );
3418
3599
  if (queue !== undefined) content = `${content}\n${queue}`;
3419
3600
  // #777: the mechanical to-spec launch boundary. The queue digest is
@@ -3427,11 +3608,12 @@ async function tick(
3427
3608
  //
3428
3609
  // Every tick owns its authorization fresh — the clears above ran
3429
3610
  // before the reads, so the offer below can only mint for THIS tick.
3611
+ const toSpecActive = store.activeRuns(projectName);
3430
3612
  const launch = await offerToSpecLaunch({
3431
3613
  summary: dispatch,
3432
3614
  groomBelow,
3433
3615
  grooming,
3434
- active: store.activeRuns(projectName),
3616
+ active: toSpecActive,
3435
3617
  issues: openSnapshot,
3436
3618
  project,
3437
3619
  trackerSeam: toSpecTrackerSeam,
@@ -3446,6 +3628,19 @@ async function tick(
3446
3628
  session.launchProject = project.name;
3447
3629
  session.launchToken = launch.token;
3448
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}`;
3449
3644
  }
3450
3645
  // Pending intake is the same class of standing block as the friction
3451
3646
  // and decisions read-outs: a store-backed duty the orchestrator must
@@ -4300,6 +4495,71 @@ export default function orchestratorTickExtension(
4300
4495
  // the project to file the decision row against and to resolve the delivery
4301
4496
  // target. An unreadable/ambiguous config makes the tool say so and record
4302
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
+
4303
4563
  pi.registerTool({
4304
4564
  name: ASK_TOOL,
4305
4565
  label: ASK_TOOL,
@@ -4328,43 +4588,16 @@ export default function orchestratorTickExtension(
4328
4588
  if (!parsed.ok) {
4329
4589
  return { content: [{ type: "text", text: parsed.problem }], isError: true };
4330
4590
  }
4331
- const session = askSession;
4332
- if (session === undefined) {
4333
- // Not a conductor tick session (subagent, or a session that never
4334
- // composed a tick). Same fail-closed posture as an unresolvable config:
4335
- // say so, record nothing, and never route the question elsewhere.
4336
- return {
4337
- content: [
4338
- {
4339
- type: "text",
4340
- text: `${ASK_TOOL}: not available in this session (no orchestrator tick); nothing was asked or recorded.`,
4341
- },
4342
- ],
4343
- isError: true,
4344
- };
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 };
4345
4598
  }
4346
- // Routing follows the *live* tick config, not the session-start stamp:
4347
- // a restamp (un-stamped → stamped, or project A → B) must make the next
4348
- // tick's toolbox land on the project the turn actually ticks for, and an
4349
- // abandoned stamp must not keep recording against a project that is no
4350
- // longer this fleet's. Only the ceiling stays startup-only (from
4351
- // `config` below) — routing is re-read every call.
4352
- const { cwd, config } = session;
4353
- const routed = resolveAskProject(cwd, config);
4354
- if (routed.kind === "error") {
4355
- return {
4356
- content: [
4357
- {
4358
- type: "text",
4359
- text:
4360
- `${ASK_TOOL}: conductor config unreadable (${routed.problem}); ` +
4361
- "nothing was asked or recorded. Repair the config, do not ask through another path.",
4362
- },
4363
- ],
4364
- isError: true,
4365
- };
4366
- }
4367
- const projectConfig = routed.project;
4599
+ const projectConfig = context.project;
4600
+ const config = context.config;
4368
4601
  const store = openStore(dbPath());
4369
4602
  let result: AskResult;
4370
4603
  try {
@@ -4380,37 +4613,7 @@ export default function orchestratorTickExtension(
4380
4613
  // inject a fake through the same seam.
4381
4614
  interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
4382
4615
  ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
4383
- deliver: async (text, category) => {
4384
- const at = Date.now();
4385
- const noticeId = randomUUID();
4386
- try {
4387
- const delivered = await deliverOperatorMessage(projectConfig, text, {
4388
- store,
4389
- at,
4390
- noticeId,
4391
- category,
4392
- });
4393
- return delivered.kind === "sent"
4394
- ? { kind: "sent", category: delivered.category }
4395
- : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
4396
- } catch (err) {
4397
- // A failed immediate send must not drop the question: fall back
4398
- // to the durable hold exactly like the gate's own hold path, and
4399
- // let the daemon retry with the digest.
4400
- store.addHeldNotice({
4401
- id: noticeId,
4402
- project: projectConfig.name,
4403
- category,
4404
- summary: text.split("\n", 1)[0]!.slice(0, 240),
4405
- detail: text,
4406
- createdAt: at,
4407
- });
4408
- pi.logger.error(
4409
- `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
4410
- );
4411
- return { kind: "held", category, noticeId };
4412
- }
4413
- },
4616
+ deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
4414
4617
  });
4415
4618
  } finally {
4416
4619
  store.close();
@@ -4419,6 +4622,58 @@ export default function orchestratorTickExtension(
4419
4622
  },
4420
4623
  });
4421
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
+
4422
4677
  // The async half of the to-spec result capture (#777). Registered at
4423
4678
  // extension-factory time like {@link ASK_TOOL}, with the same routing
4424
4679
  // contract: the state it needs (cwd + startup config) is filled by