omp-conductor 0.19.7 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -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, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
+ import { existsSync, 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 {
@@ -102,7 +102,6 @@ import {
102
102
  type ResolvedGrants,
103
103
  type Store,
104
104
  } from "./types.ts";
105
- import { repoSlugFor } from "./gitops.ts";
106
105
  import { makeTracker } from "./tracker/github.ts";
107
106
  import { formatDecisionDigest } from "./decisions.ts";
108
107
  import { installSurfaceMismatch } from "./status-render.ts";
@@ -114,36 +113,25 @@ import {
114
113
  parseQuestionnaireRequest,
115
114
  performQuestionnaire,
116
115
  questionnaireParameterSchema,
117
- askAnswerRowWrite,
118
116
  askParameterSchema,
119
- DEFAULT_ASK_TIMEOUT_SECONDS,
120
- MAX_ASK_TIMEOUT_SECONDS,
121
- MIN_ASK_TIMEOUT_SECONDS,
122
- parseAskAnswerEnvelope,
123
117
  parseAskRequest,
124
118
  performAsk,
125
- renderInteractiveAsk,
126
- type AskAnswerEnvelope,
127
- type AskInteractiveDelivery,
128
119
  type AskResult,
129
120
  } from "./ask.ts";
130
121
  import { deliverOperatorMessage } from "./reports.ts";
131
122
  import { effectiveLabels } from "./routing.ts";
132
- import { readTelegramToken, resolveProjectTopicId, telegramStateDir } from "./escalate.ts";
133
123
  import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
134
124
  import { dbPath, openStore } from "./store.ts";
135
125
  import { digestDue, localDayKey } from "./digest-schedule.ts";
136
126
  import {
137
- parseToSpecEvidence,
138
- parseToSpecFailureEvidence,
139
- recordToSpecGrooming,
140
- TO_SPEC_MAX_SOURCE_AGE_MS,
141
- TO_SPEC_SCHEMA,
142
- type ToSpecFailure,
143
- type ToSpecResult,
144
- } from "./to-spec.ts";
127
+ DEFAULT_GROOM_BELOW,
128
+ groomingDue,
129
+ parseReadyGateRejection,
130
+ TO_SPEC_IN_FLIGHT_REASON,
131
+ toSpecDurableVerdict,
132
+ type ToSpecTrackerSeam,
133
+ } from "./groom.ts";
145
134
  import { heldNoticeId } from "./notices.ts";
146
- import { classifyArmReply } from "./arm-challenge.ts";
147
135
 
148
136
  /** The activation file. Absent means "this is not an orchestrator session". */
149
137
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -151,47 +139,6 @@ export const TICK_CONFIG_FILE = ".conductor-tick.json";
151
139
  /** Namespaced so a renderer or a session-log reader can pick ticks out. */
152
140
  export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
153
141
 
154
- /**
155
- * A deterministic machine-readable steer the availability gate sends when an
156
- * inbound user turn is an active arming proof (conductor #415). It names the
157
- * turn for what it is so the model acknowledges receipt and hands completion to
158
- * the host-side `arm` instead of ad-libbing pairing-safety prose at the bare
159
- * `FLEET-…` token — and it never quotes the code, which has no place outside
160
- * the protected session transcript.
161
- */
162
- export const ARM_PROOF_CUSTOM_TYPE = "omp-conductor.arm-proof";
163
-
164
- /**
165
- * The type carried by an answer to a code that was **not** a proof (#991).
166
- *
167
- * Deliberately not {@link ARM_PROOF_CUSTOM_TYPE}: that type means "an arming
168
- * proof landed", and the availability gate and every reader that filters on it
169
- * would otherwise see a refusal as a proof. A refusal is the opposite fact.
170
- */
171
- export const ARM_REPLY_CUSTOM_TYPE = "omp-conductor.arm-reply";
172
-
173
- /**
174
- * What the operator is told when their code arrived too late (#991).
175
- *
176
- * Never the token, and never a hint about any other project: an expired record
177
- * is the one thing this session can state about a code it could not accept.
178
- */
179
- export const ARM_EXPIRED_REPLY_TEXT =
180
- "That arming code has expired — nothing was armed. Run `omp-conductor arm` again for a fresh code.";
181
-
182
- /** What the operator is told when the code matches nothing here (#991). Worded
183
- * so it cannot be read as a statement about another project's ceremony. */
184
- export const ARM_UNKNOWN_REPLY_TEXT =
185
- "No arming is in progress here, so that code was not accepted. Run `omp-conductor arm` to start one.";
186
-
187
- /** The model-ready wording for {@link ARM_PROOF_CUSTOM_TYPE}. Kept fixed so the
188
- * orchestrator's handling of an arming proof is deterministic, whichever
189
- * challenge it was cut for. */
190
- export const ARM_PROOF_ACK_TEXT =
191
- "The message you just received was an active omp conductor arming confirmation. " +
192
- "Acknowledge that the arming confirmation was received; the host-side `arm` command " +
193
- "owns completion of the arming handshake. No pairing or access change is required.";
194
-
195
142
  /**
196
143
  * A tick costs a whole turn of a frontier model, and the orchestrator's loop is
197
144
  * about minutes of latency, not seconds. Anything under a minute is a
@@ -245,22 +192,6 @@ export const STALL_TICKS = 2;
245
192
  const DEFAULT_TICK_BUDGET_SECONDS = 600;
246
193
  /** Grace before a queued operator message preempts the turn's tool calls. */
247
194
  const PENDING_MESSAGE_GRACE_MS = 60_000;
248
- /** Routable candidates below which the queue digest tells the orchestrator to groom (#181). */
249
- const DEFAULT_GROOM_BELOW = 4;
250
-
251
- /**
252
- * Whether the count-based half of the grooming duty fires for one dispatch
253
- * row's routable count (#988): below a numeric threshold as always, while
254
- * `"always"` leaves this gate open on purpose — the selectors then answer
255
- * "is anything left to groom?" from candidate state (ungroomed, unrefused,
256
- * unparked), and the launch block or no-batch line renders the truthful
257
- * finding either way. Never substitute a large number for `"always"`: a big
258
- * queue must not silence the duty.
259
- */
260
- function groomingDue(routed: number, groomBelow: GroomTrigger): boolean {
261
- return groomBelow === "always" || routed < groomBelow;
262
- }
263
-
264
195
  /**
265
196
  * Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
266
197
  * dispatch daemon when a watched decision condition transitions false→true
@@ -481,15 +412,6 @@ export interface TickConfig {
481
412
  * before the next tick).
482
413
  */
483
414
  budgetSeconds?: number;
484
- /**
485
- * Seconds one {@link ASK_TOOL} call waits for the operator before its
486
- * declared timeout outcome fires (#438). Optional; defaults to
487
- * {@link DEFAULT_ASK_TIMEOUT_SECONDS} (5 minutes) and is always capped at the
488
- * turn budget, so an ask can never outlive the turn it runs in. The ceiling
489
- * is the tool's, not the model's: an ask issued without a `timeoutSeconds`
490
- * gets this value all the same.
491
- */
492
- askTimeoutSeconds?: number;
493
415
  /**
494
416
  * The herdr agent name this fleet's orchestrator pane is registered under, and
495
417
  * the whole of {@link resolveTickOwnership}'s identity test under herdr.
@@ -718,10 +640,10 @@ export interface QueueObservation {
718
640
  * line tells claimable candidates apart from a runway that cannot move —
719
641
  * instead of inviting Duty 2 to groom work whose last pass held it, which is
720
642
  * exactly the re-derivation this store exists to stop. A to-spec launch row
721
- * (#777) carries the same `blocked` verdict as its durable in-flight marker,
722
- * so it is told apart from the mechanical holds: it says "a batch is running",
723
- * not "the lane cannot move", and counts neither as claimable nor as
724
- * known-blocked.
643
+ * (#777, written by the daemon's grooming launcher since #1041) carries the
644
+ * same `blocked` verdict as its durable in-flight marker, so it is told apart
645
+ * from the mechanical holds: it says "a batch is running", not "the lane
646
+ * cannot move", and counts neither as claimable nor as known-blocked.
725
647
  *
726
648
  * `queue` is the tracker observation the caller made THIS tick (#848). When
727
649
  * present, the queue verdict reads in the present tense from that inventory,
@@ -737,9 +659,9 @@ export function queueDigestLine(
737
659
  grooming: readonly GroomingRecord[] = [],
738
660
  queue: QueueObservation | undefined = undefined,
739
661
  /** Observation time for the durability of the grooming rows this line
740
- * describes — the same clock the selection is offered against, so the
741
- * inventory and the batch cannot disagree about what is still groomed
742
- * (#887). */
662
+ * describes — the same clock the daemon's selection judges them against, so
663
+ * the inventory and the next grooming pass cannot disagree about what is
664
+ * still groomed (#887). */
743
665
  now: number = Date.now(),
744
666
  ): string | undefined {
745
667
  if (summary === undefined) return undefined;
@@ -823,8 +745,8 @@ function liveQueueDigestLine(
823
745
  if (queued === 0) {
824
746
  return (
825
747
  `Queue: empty — nothing open carries "${queueLabel}" right now (tracker ${observed}). ` +
826
- "Groom the backlog (Duty 2): promote or file the next issues, or say in this tick's report " +
827
- "why there is nothing to do."
748
+ "Groom the backlog (Duty 2): the daemon runs the to-spec passes and promotes what its ready gate " +
749
+ "passes, so file or sharpen the next issues, or say in this tick's report why there is nothing to do."
828
750
  );
829
751
  }
830
752
  // The grooming threshold is claimability — summary.routed — never raw
@@ -884,8 +806,8 @@ function lowQueueTail(
884
806
  groomBelow: GroomTrigger,
885
807
  grooming: readonly GroomingRecord[],
886
808
  lead: string,
887
- /** The clock the durability of each verdict is judged against — the same
888
- * one the batch offer uses (#887). */
809
+ /** The clock the durability of each verdict is judged against — the same one
810
+ * the daemon's selection uses (#887). */
889
811
  now: number,
890
812
  ): string {
891
813
  // The durable per-issue verdicts, not this pass's one-shot hold groups: a
@@ -910,20 +832,23 @@ function lowQueueTail(
910
832
  `(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
911
833
  `${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
912
834
  } else if (knownBlocked.length > 0 || inFlight.length > 0) {
835
+ // Who grooms is the daemon's business now (#1041): the tail reports which
836
+ // candidates the pipeline can actually move, and stops instructing a
837
+ // session that no longer holds the launch.
913
838
  tail =
914
839
  groomBelow === "always"
915
- ? `${lead}grooming runs every tick ("always") — ${summary.routed} routable candidate(s): ` +
840
+ ? `${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s): ` +
916
841
  `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
917
- `${busy} — groom the claimable while any remain ungroomed.`
842
+ `${busy} — only the claimable are groomable.`
918
843
  : `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
919
844
  `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
920
- `${busy} — groom only the claimable.`;
845
+ `${busy} — only the claimable are groomable.`;
921
846
  } else if (groomBelow === "always") {
922
847
  // No numeric trigger exists to be "below", so the tail states the actual
923
848
  // condition and what remains ungroomed (#988) instead of inventing one.
924
849
  tail =
925
- `${lead}grooming runs every tick ("always") — ${summary.routed} routable candidate(s); ` +
926
- "groom while ungroomed, unrefused, unparked candidates remain.";
850
+ `${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s); ` +
851
+ "the daemon keeps grooming while ungroomed, unrefused, unparked candidates remain.";
927
852
  } else {
928
853
  tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
929
854
  }
@@ -939,7 +864,7 @@ function lowQueueTail(
939
864
  ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
940
865
  `${durable.length} still durable (never re-groom these), ${regroomable} re-groomable ` +
941
866
  "(no readable to-spec source, or observed past the freshness ceiling). " +
942
- "Promote the promotable or groom new issues.";
867
+ "The daemon promotes what its ready gate passes; audit those promotions and sharpen what it rejected.";
943
868
  }
944
869
  if (summary.admitted === 0 && summary.holds.length > 0) {
945
870
  const held = summary.holds
@@ -953,657 +878,115 @@ function lowQueueTail(
953
878
  return tail;
954
879
  }
955
880
 
956
- // ================================================================ to-spec
957
- // launch lifecycle (#777). #772 shipped the strict to-spec contract
958
- // (`TO_SPEC_SCHEMA` + `recordToSpecGrooming`); this section is the launch
959
- // half: the mechanical candidate selection, the bounded prompt block that
960
- // authorizes exactly one native `task` batch per low-queue tick, the
961
- // tool-call gate that stamps the contract and records in-flight rows, and
962
- // the result capture that routes every item's exact output through
963
- // `recordToSpecGrooming` independently.
881
+ // ============================================================ promotion audit
882
+ // (#1041/#1040). #777 gave the tick a per-tick capability token: a launch block
883
+ // naming the mechanically selected batch, a `task` tool-call gate that redeemed
884
+ // exactly one batch, and promotion as an orchestrator act. That made grooming
885
+ // tick-bound one batch per low-queue tick, and an operator message that
886
+ // consumed the tick consumed the batch (#1040) so the whole mechanism is
887
+ // gone. The daemon watches depth, launches its own to-spec sessions, and
888
+ // promotes a PROMOTABLE verdict whose spec passes the pure ready gate.
964
889
  //
965
- // Where each fact lives decides who enforces it and every exclusion is
966
- // enforced mechanically from authoritative data, never by prose:
967
- //
968
- // - Tracker facts the open-issue pool, the park label, the parent/epic
969
- // probe are read ONCE, at launch composition time, through the existing
970
- // Tracker adapter (`listOpenIssues`/`childrenOf`). The tick turns that
971
- // snapshot into the mechanically selected batch: `offerToSpecLaunch`
972
- // streams the snapshot through the store-side exclusions and the parent
973
- // probe, and the returned block names the selected candidates as the ONLY
974
- // batch this tick may launch. If the snapshot cannot be read, no batch is
975
- // offered at all — the launch fails closed rather than trusting the model
976
- // to self-filter.
977
- // - Store facts durable grooming rows (#735), admission's lane/dependency
978
- // holds, active runs, in-flight launches are enforced mechanically at
979
- // composition time (the exclusion lines the block names) and again at
980
- // `tool_call` time (the gate refuses an item the store contradicts).
981
- // - The live source ref for each item cannot be known synchronously without
982
- // a fresh per-repo read, so the model fetches it and declares it in the
983
- // item's second contract line; the strict parser's 24h freshness ceiling
984
- // re-vets the claim at persistence time, which is the boundary #772 set.
985
- // - The gate additionally enforces the allowlist: a marker-bearing `task`
986
- // call is refused unless every item's issue number AND routing repo match
987
- // the batch this tick's token authorized. A parked or parent/epic
988
- // candidate therefore cannot be stamped in-flight even if the model
989
- // invents one it was never on the list, and the list is the only thing
990
- // the gate lets through.
991
- //
992
- // The pure selector (`selectToSpecBatch`) remains the single shared rule set:
993
- // the offer feeds it the tracker-vetted pool and the gate runs the same
994
- // exclusion on every item at call time, so a candidate excluded in the prompt
995
- // is excluded in the gate for the same reason.
996
-
997
- /** The `to-spec` agent every batch item runs under (shipped in
998
- * `omp/agents/to-spec.md`, discovered through OMP's native task-agent
999
- * discovery never a custom process runtime). */
1000
- export const TO_SPEC_AGENT = "to-spec";
1001
-
1002
- /** The maximum number of candidates one tick's batch may carry (#679's
1003
- * "small per-tick candidate limit"; the session-scoped task semaphore
1004
- * bounds concurrency underneath). */
1005
- export const TO_SPEC_BATCH_MAX = 3;
1006
-
1007
- /**
1008
- * The `context` marker that identifies a conductor grooming batch to the
1009
- * `tool_call` gate. The launch block instructs the orchestrator to put
1010
- * `{@link TO_SPEC_BATCH_MARKER}: <token>` as the first line of the batch's
1011
- * shared `context`; the gate matches the marker and the exact token this tick
1012
- * issued, stamps the per-item contract (agent, strict schema), and persists
1013
- * the in-flight rows. A `task` call without this marker is the orchestrator's
1014
- * own and passes untouched.
1015
- */
1016
- export const TO_SPEC_BATCH_MARKER = "conductor-to-spec-batch";
1017
-
1018
- /** The grooming-table verdict row a launched-but-unfinished batch leaves behind
1019
- * (reason, on a `blocked` verdict): the durable in-flight marker that stops
1020
- * the next tick — or a restarted session — from re-launching the same item.
1021
- * `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
1022
- * result lands, and refusing-to-parse output must *not* be swallowed by the
1023
- * kept-prior path that protects prior `promotable`/`considered` verdicts. */
1024
- export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
1025
-
1026
- /**
1027
- * How long a launch row may sit before it is treated as a dead batch and the
1028
- * candidate becomes eligible again. A batch that dies with the process (a
1029
- * daemon stop between `tool_call` and delivery) must not park a candidate
1030
- * forever; the 24h ceiling matches the source-freshness ceiling, so a
1031
- * relaunched pass always reads new source evidence anyway.
1032
- */
1033
- export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
1034
-
1035
- /**
1036
- * How long a refused pass parks its candidate before another batch may be
1037
- * spent on it. Deliberately the same 24h number as the source-freshness
1038
- * ceiling and the in-flight TTL — one granularity for this whole lifecycle,
1039
- * not a third threshold to keep in sync: within that window neither the
1040
- * authoritative source nor the issue has produced new evidence, so a retry
1041
- * re-runs the identical prompt and refuses the identical way.
1042
- *
1043
- * Without it, a candidate whose delegated pass returns malformed,
1044
- * source-less or stale output is immediately eligible again, so every
1045
- * low-queue tick spends a full delegated batch re-grooming it — measured on
1046
- * this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
1047
- * re-offered on every pass, and as #807 groomed twice seven minutes apart
1048
- * (#887).
1049
- */
1050
- export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
1051
-
1052
- /**
1053
- * The one durability rule for a grooming row: the validated to-spec result it
1054
- * carries when that result is still fresh, or `undefined` when the row is not
1055
- * durable grooming at all (no to-spec payload — a hand-edited or pre-#772
1056
- * row — or a source observed past the freshness ceiling).
1057
- *
1058
- * Every reader of "is this issue already groomed?" MUST go through this:
1059
- * {@link toSpecCandidateExclusion} (selection and the `tool_call` gate), the
1060
- * launch block's `already-groomed` list, and the queue digest's
1061
- * already-considered inventory. Two readers with two predicates is exactly
1062
- * the #887 defect — the digest told the orchestrator "never re-groom these"
1063
- * about rows the mechanical selection was simultaneously offering.
1064
- */
1065
- export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
1066
- const result = parseToSpecEvidence(row.evidence);
1067
- if (result === undefined) return undefined;
1068
- return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
1069
- }
1070
-
1071
- /** The refusal a row records when its pass produced nothing usable, while the
1072
- * cooldown above still holds it out of a new batch; `undefined` for any
1073
- * other row, including a refusal whose cooldown has expired. */
1074
- export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
1075
- const failure = parseToSpecFailureEvidence(row.evidence);
1076
- if (failure === undefined) return undefined;
1077
- return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
1078
- }
1079
-
1080
- /** The first line of every batch item's `task`, in the shape the gate parses:
1081
- * `to-spec candidate: <owner/repo>#<issue> — <title>`. */
1082
- export const TO_SPEC_ITEM_PREFIX = "to-spec candidate:";
1083
-
1084
- /** The second line of every batch item's `task`, naming the authoritative
1085
- * source and the exact ref the item was groomed against:
1086
- * `to-spec source: <owner/repo>@<ref>`. `ref` is whatever the launch block
1087
- * told the orchestrator to fetch as the repo's current default-branch head. */
1088
- export const TO_SPEC_ITEM_SOURCE_PREFIX = "to-spec source:";
1089
-
1090
- /** The tool the orchestrator calls to persist one completed item's exact raw
1091
- * output when the batch ran in the background (#777). Registered by this
1092
- * extension; subagents never see it — the `to-spec` agent's tool list is
1093
- * read-only and explicit. */
1094
- export const TO_SPEC_RESULT_TOOL = "conductor_to_spec_result";
1095
-
1096
- /** One backlog candidate the mechanical gate can judge. The tracker facts
1097
- * (labels, epics) are read from the authoritative open-issue snapshot at
1098
- * launch composition time; they travel in this view so the selector stays
1099
- * deterministic and testable. */
1100
- export interface ToSpecCandidateView {
1101
- issue: number;
1102
- title: string;
1103
- /** The routing target — a routed `owner/repo` (from the issue's one
1104
- * `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
1105
- routing: string;
1106
- /** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
1107
- parked?: boolean;
1108
- /** A parent/epic with no independently runnable slice; read from the
1109
- * tracker's sub-issue probe. */
1110
- parent?: boolean;
1111
- }
1112
-
1113
- /** One candidate the tick's token authorizes. The gate admits a batch item
1114
- * only when its issue number AND routing both match an entry here. */
1115
- export interface ToSpecLaunchItem {
1116
- issue: number;
1117
- /** The `owner/repo` the item's first contract line must name. */
1118
- routing: string;
1119
- /** The candidate title, as it appears in the item's first contract line. */
1120
- title: string;
1121
- }
1122
-
1123
- /** The machine-readable item contract a batch item must satisfy. */
1124
- export interface ToSpecBatchItem {
1125
- issue: number;
1126
- /** The `owner/repo` named by the item's first contract line. */
1127
- routing: string;
1128
- /** The `owner/repo@ref` named by the item's second contract line. */
1129
- sourceRef: string;
1130
- /** The item's full task text (the rendered brief plus the contract lines). */
1131
- task: string;
1132
- }
1133
-
1134
- /**
1135
- * Parse the two contract lines a batch item must start with
1136
- * (`to-spec candidate: <repo>#<n> — <title>` / `to-spec source: <name>@<ref>`).
1137
- * Anything else is not a conductor grooming item. Built from the shared
1138
- * {@link TO_SPEC_ITEM_PREFIX}/{@link TO_SPEC_ITEM_SOURCE_PREFIX} constants so
1139
- * the launch block's wording and the gate's parsing cannot drift apart.
1140
- */
1141
- export function parseToSpecItem(task: unknown): ToSpecBatchItem | undefined {
1142
- if (typeof task !== "string") return undefined;
1143
- const lines = task.split("\n");
1144
- const head = new RegExp(`^${TO_SPEC_ITEM_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)#(\\d+)(?:\\s+—\\s+.*)?$`).exec(
1145
- lines[0]?.trim() ?? "",
1146
- );
1147
- const source = new RegExp(`^${TO_SPEC_ITEM_SOURCE_PREFIX}\\s+([\\w.-]+\\/[\\w.-]+)@(\\S+)$`).exec(
1148
- lines[1]?.trim() ?? "",
1149
- );
1150
- if (head === null || source === null) return undefined;
1151
- return { issue: Number(head[2]), routing: head[1]!, sourceRef: source[2]!, task };
1152
- }
1153
-
1154
- /**
1155
- * Why one candidate is not eligible for a batch right now, or undefined when
1156
- * it is. The single rule source for the prompt-time exclusion list, the
1157
- * `tool_call` gate, and the selection helper — one rule, three readers, so a
1158
- * candidate excluded in prose is excluded in the gate for the same reason.
1159
- *
1160
- * - `in-flight`: a launch row recorded within the TTL (a dead batch's row
1161
- * expires and the candidate becomes eligible again);
1162
- * - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
1163
- * - a fresh valid `to-spec` result in the grooming table: the candidate was
1164
- * already groomed at an observed source within the freshness ceiling, so
1165
- * re-running it would recompute a verdict that is still valid. New source
1166
- * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
1167
- * the row no longer reads as groomed, and a fresh pass overrides it;
1168
- * - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
1169
- * delegated batch was already spent and produced nothing usable
1170
- * (malformed, source-less or stale output). Retrying inside the cooldown
1171
- * re-runs the identical prompt against the same source and refuses the
1172
- * same way, which is how one broken candidate consumed a batch on every
1173
- * low-queue tick (#887);
1174
- * - `active`: a run is in flight on the issue right now.
1175
- */
1176
- export function toSpecCandidateExclusion(
1177
- candidate: { issue: number },
1178
- facts: { grooming: GroomingRecord | undefined; active: boolean },
1179
- now: number,
1180
- ): string | undefined {
1181
- const row = facts.grooming;
1182
- if (row !== undefined) {
1183
- if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1184
- if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
1185
- return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
1186
- }
1187
- } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1188
- return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
1189
- } else {
1190
- const durable = toSpecDurableVerdict(row, now);
1191
- if (durable !== undefined) {
1192
- return (
1193
- `#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
1194
- `${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
1195
- "with new source evidence"
1196
- );
1197
- }
1198
- const refusal = toSpecRefusalOnCooldown(row, now);
1199
- if (refusal !== undefined) {
1200
- const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
1201
- return (
1202
- `#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
1203
- `(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
1204
- "issue or its source changes"
1205
- );
1206
- }
1207
- }
1208
- }
1209
- if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
1210
- return undefined;
1211
- }
1212
-
1213
- /**
1214
- * The mechanical half of Duty 2's launch: deterministic, bounded selection of
1215
- * the eligible candidates, smallest issue numbers first, never more than
1216
- * {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above a numeric grooming
1217
- * trigger (`"always"` opens that gate and lets candidate state decide, #988),
1218
- * nothing before the first dispatch summary exists (queue health unknown —
1219
- * the same gate the queue digest uses). Parked and parent views are honored
1220
- * when the caller supplies them.
1221
- *
1222
- * Production reaches this selector through {@link offerToSpecLaunch}, which
1223
- * fills the views from the authoritative tracker snapshot (park label from
1224
- * the open-issue labels, parent/epic from the sub-issue probe) and streams
1225
- * the store-side exclusions before the selector runs — so parked and parent
1226
- * exclusions are enforced on the live path, not only on test inputs.
1227
- */
1228
- export function selectToSpecBatch(input: {
1229
- candidates: readonly ToSpecCandidateView[];
1230
- summary: DispatchSummary | undefined;
1231
- groomBelow: GroomTrigger;
1232
- grooming: readonly GroomingRecord[];
1233
- active: readonly { issue: number }[];
1234
- now: number;
1235
- }): ToSpecCandidateView[] {
1236
- if (input.summary === undefined) return [];
1237
- if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
1238
- const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
1239
- const activeIssues = new Set(input.active.map((run) => run.issue));
1240
- const selected: ToSpecCandidateView[] = [];
1241
- for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
1242
- if (candidate.parked) continue;
1243
- if (candidate.parent) continue;
1244
- if (
1245
- toSpecCandidateExclusion(
1246
- { issue: candidate.issue },
1247
- { grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
1248
- input.now,
1249
- ) !== undefined
1250
- ) {
1251
- continue;
1252
- }
1253
- selected.push(candidate);
1254
- if (selected.length >= TO_SPEC_BATCH_MAX) break;
1255
- }
1256
- return selected;
1257
- }
1258
-
1259
- /**
1260
- * One tick's launch authorization: the token, the block text it carries, and
1261
- * the mechanically selected batch the token authorizes. The gate admits a
1262
- * marker-bearing `task` call only when every item matches an entry of
1263
- * {@link ToSpecLaunchBlock.items} — the list is produced from the tracker
1264
- * snapshot, never from model-supplied fields.
1265
- */
1266
- export interface ToSpecLaunchBlock {
1267
- /** The per-launch token the batch must echo in its `context` first line. */
1268
- token: string;
1269
- block: string;
1270
- /** The mechanically selected candidates — the ONLY batch this token may
1271
- * carry (issue number AND routing must both match). */
1272
- items: ToSpecLaunchItem[];
1273
- }
1274
-
1275
- /** Why candidates sit out, as one compact line each, for the launch block. */
1276
- export interface ToSpecLaunchExclusions {
1277
- /** Candidates with a fresh, valid to-spec verdict already on the grooming table. */
1278
- groomed: string[];
1279
- /** Candidates whose last pass was refused and whose retry cooldown still
1280
- * holds ({@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}). */
1281
- refused: string[];
1282
- /** Candidates with an active to-spec batch. */
1283
- inFlight: string[];
1284
- /** Candidates under admission's durable lane/dependency holds. */
1285
- mechanicallyBlocked: string[];
1286
- /** Candidates with a dispatched run live right now. */
1287
- dispatched: string[];
1288
- }
1289
-
1290
- /**
1291
- * The authoritative tracker surface the launch selection is built from
1292
- * (#777): the two reads through the existing Tracker adapter that answer
1293
- * "which open backlog issues are actually eligible right now". Production
1294
- * implements this with `makeTracker(...).listOpenIssues()` /
1295
- * `.childrenOf(...)`; tests inject deterministic fakes. `listOpenIssues` is
1296
- * the one open-issue snapshot with labels (#203) — it answers the park
1297
- * label and the routing label mechanically — and `childrenOf` answers
1298
- * whether a candidate is a parent/epic (its sub-issues exist, so it has no
1299
- * independently runnable slice of its own). Both fail closed: an unreadable
1300
- * snapshot means no batch is offered, while an unreadable parent probe skips
1301
- * that candidate because eligibility was not mechanically established.
1302
- */
1303
- export interface ToSpecTrackerSeam {
1304
- /** Every open issue in the tracker repo, labels included
1305
- * (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
1306
- listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
1307
- /** Sub-issues of one issue (`Tracker.childrenOf`). */
1308
- childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
1309
- }
1310
-
1311
- /**
1312
- * The tracker half of the candidate views: map one open-issue snapshot onto
1313
- * the pool the selector can judge. Issues still carrying the queue label are
1314
- * already queued — not backlog — and leave the pool; issues with zero or
1315
- * several `routing.labelPrefix` labels (or a label mapping to no configured
1316
- * repo) cannot name an authoritative source to read and leave the pool,
1317
- * exactly like admission's unroutable partition. The park label lands on the
1318
- * view for the selector to drop; the parent/epic probe is separate (one
1319
- * tracker read per candidate) and stays with the offer, which runs it only
1320
- * for candidates the store-side exclusions did not already reject.
1321
- */
1322
- function toSpecPoolFromSnapshot(
1323
- issues: readonly ReadyIssue[],
1324
- project: ProjectConfig,
1325
- ): ToSpecCandidateView[] {
1326
- const queueLabel = project.queueLabel;
1327
- const parkLabel = project.stateLabels.backlog;
1328
- const { labelPrefix, repos } = project.routing;
1329
- const views: ToSpecCandidateView[] = [];
1330
- for (const issue of issues) {
1331
- if (issue.labels.includes(queueLabel)) continue;
1332
- const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
1333
- if (matched.length !== 1) continue;
1334
- const key = matched[0]!.slice(labelPrefix.length);
1335
- const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
1336
- if (target === undefined) continue;
1337
- views.push({
1338
- issue: issue.number,
1339
- title: issue.title,
1340
- routing: repoSlugFor(target),
1341
- parked: issue.labels.includes(parkLabel) ? true : undefined,
1342
- });
1343
- }
1344
- views.sort((a, b) => a.issue - b.issue);
1345
- return views;
1346
- }
1347
-
1348
- /**
1349
- * The production launch offer for one tick: the complete mechanical
1350
- * selection, from the authoritative tracker snapshot through every
1351
- * exclusion, ending in the token + block + allowlist, or `undefined` when no
1352
- * batch may launch. All of the following yield `undefined`:
1353
- *
1354
- * - no dispatch row yet, or the routable queue is at/above a numeric
1355
- * grooming trigger (`"always"` opens that gate; candidate state decides,
1356
- * #988 — the same gate the queue digest uses);
1357
- * - no tracker seam (the snapshot is unavailable);
1358
- * - the snapshot cannot be read — the launch fails closed rather than
1359
- * trusting the model to self-filter parked/parent/epic candidates;
1360
- * - nothing survives the exclusions (parked, parent/epic, already groomed,
1361
- * in-flight, lane/dependency holds, dispatched runs) — there is then no
1362
- * batch to authorize, and a marker-bearing `task` call stays refused.
1363
- *
1364
- * The parent/epic probe runs only for candidates the store-side exclusions
1365
- * have not already rejected, in issue order, and stops as soon as
1366
- * {@link TO_SPEC_BATCH_MAX} candidates are selected — a bounded set of
1367
- * tracker reads per low-queue tick, never one per open issue.
1368
- */
1369
- export async function offerToSpecLaunch(input: {
1370
- summary: DispatchSummary | undefined;
1371
- groomBelow: GroomTrigger;
890
+ // What the tick owes Duty 2 instead is review, and review needs exactly two
891
+ // facts the store already holds: what the daemon queued unattended since the
892
+ // last beat, and which specs its gate refused. Neither is derivable from
893
+ // session memory, and both must arrive with the command that acts on them —
894
+ // an audit line an operator cannot act on is a line that gets skipped.
895
+
896
+ /** How many promotions and gate rejections one audit block names. A tick's
897
+ * prompt is a budget, and an unbounded burst of promotions must not push the
898
+ * duties off the end of it; the count always states the full total. */
899
+ const PROMOTION_AUDIT_LIMIT = 6;
900
+
901
+ /**
902
+ * The Duty-2 promotion audit: what the daemon promoted on its own since the
903
+ * previous tick, each with the one command that undoes it, and the specs its
904
+ * ready gate refused, each with what the gate found missing.
905
+ *
906
+ * `undefined` when there is nothing to say the common state of a fleet whose
907
+ * queue is deep enough that no grooming pass ran. A block that reported "0
908
+ * promotions" every beat would be permanent noise, the same convention
909
+ * {@link queueDigestLine} and {@link formatPendingIntake} keep.
910
+ *
911
+ * The demote command is spelled from the real verb surface rather than
912
+ * invented: `conductor_label` (verbs/protocol.ts) takes the full issue URL,
913
+ * the label, `action`, and a `reason` from {@link LABEL_REASONS} — `needs-human`
914
+ * is the member that means "parked: it needs a decision only a human can
915
+ * make", which is exactly what an overruled auto-promotion is.
916
+ */
917
+ export function formatPromotionAudit(input: {
918
+ /** Grooming rows promoted since {@link since}, as `Store.promotionsSince`
919
+ * returns them (newest or oldest first the block does not reorder, so the
920
+ * store's order is the audit's order). */
921
+ promotions: readonly GroomingRecord[];
922
+ /** Every current grooming row, from which the gate rejections are read. The
923
+ * full set rather than a window: a spec the gate refused three ticks ago is
924
+ * still unqueued work, and dropping it would let a rejected spec go quiet. */
1372
925
  grooming: readonly GroomingRecord[];
1373
- active: readonly { issue: number }[];
1374
- project: ProjectConfig;
1375
- trackerSeam: ToSpecTrackerSeam | undefined;
1376
- /** An open-issue snapshot this tick already read from the tracker. Shared
1377
- * with the queue digest so one tick cannot describe two queues (#848);
1378
- * absent when that read failed, in which case the launch retries its own
1379
- * read and fails closed on the same terms as before. */
1380
- issues?: readonly ReadyIssue[];
1381
- now: number;
1382
- }): Promise<ToSpecLaunchBlock | undefined> {
1383
- if (input.summary === undefined) return undefined;
1384
- if (!groomingDue(input.summary.routed, input.groomBelow)) return undefined;
1385
- const seam = input.trackerSeam;
1386
- if (seam === undefined) return undefined;
1387
- let issues = input.issues;
1388
- if (issues === undefined) {
1389
- try {
1390
- issues = await seam.listOpenIssues(input.project);
1391
- } catch {
1392
- // No authoritative snapshot, no launch: a batch offered without one would
1393
- // make the model the selector, which is exactly the defect this slice
1394
- // removes. The queue digest still names the grooming duty; the next tick
1395
- // retries the read.
1396
- return undefined;
1397
- }
1398
- }
1399
- const views = toSpecPoolFromSnapshot(issues, input.project);
1400
- const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
1401
- const activeIssues = new Set(input.active.map((run) => run.issue));
1402
- const pool: ToSpecCandidateView[] = [];
1403
- for (const view of views) {
1404
- if (view.parked) continue;
1405
- if (
1406
- toSpecCandidateExclusion(
1407
- { issue: view.issue },
1408
- { grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
1409
- input.now,
1410
- ) !== undefined
1411
- ) {
1412
- continue;
1413
- }
1414
- let children: { number: number; state: IssueState }[];
1415
- try {
1416
- children = await seam.childrenOf(input.project, view.issue);
1417
- } catch {
1418
- continue;
1419
- }
1420
- if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
1421
- pool.push(view);
1422
- if (pool.length >= TO_SPEC_BATCH_MAX) break;
1423
- }
1424
- // The one shared rule set re-runs on the tracker-vetted pool, so the pure
1425
- // selector — not a prose instruction — answers what the block authorizes.
1426
- const selected = selectToSpecBatch({
1427
- candidates: pool,
1428
- summary: input.summary,
1429
- groomBelow: input.groomBelow,
1430
- grooming: input.grooming,
1431
- active: input.active,
1432
- now: input.now,
1433
- });
1434
- if (selected.length === 0) return undefined;
1435
- return toSpecLaunchBlock({
1436
- summary: input.summary,
1437
- groomBelow: input.groomBelow,
1438
- grooming: input.grooming,
1439
- active: input.active,
1440
- tracker: input.project.tracker.repo,
1441
- queueLabel: input.project.queueLabel,
1442
- labelPrefix: input.project.routing.labelPrefix,
1443
- parkLabel: input.project.stateLabels.backlog,
1444
- selected,
1445
- now: input.now,
1446
- });
1447
- }
1448
-
1449
- /**
1450
- * The per-tick launch block. Present exactly when a batch may be launched:
1451
- * the grooming duty is due (below a numeric trigger, or `"always"` — #988),
1452
- * a dispatch row exists, and the mechanical selection produced at least one
1453
- * candidate. The block names the selected candidates as the ONLY batch this
1454
- * tick authorizes and lists every store-proven exclusion the selection
1455
- * already applied; the `tool_call` gate refuses any item outside the list.
1456
- */
1457
- export function toSpecLaunchBlock(input: {
1458
- summary: DispatchSummary | undefined;
1459
- groomBelow: GroomTrigger;
1460
- grooming: readonly GroomingRecord[];
1461
- active: readonly { issue: number }[];
926
+ project: string;
1462
927
  tracker: string;
1463
928
  queueLabel: string;
1464
- labelPrefix: string;
1465
- parkLabel: string;
1466
- /** The mechanically selected candidates the block renders and authorizes. */
1467
- selected: readonly ToSpecCandidateView[];
1468
- now: number;
1469
- }): ToSpecLaunchBlock | undefined {
1470
- if (input.summary === undefined) return undefined;
1471
- if (!groomingDue(input.summary.routed, input.groomBelow)) return undefined;
1472
- if (input.selected.length === 0) return undefined;
1473
- const exclusions: ToSpecLaunchExclusions = {
1474
- groomed: [],
1475
- refused: [],
1476
- inFlight: [],
1477
- mechanicallyBlocked: [],
1478
- dispatched: [],
1479
- };
1480
- for (const row of input.grooming) {
1481
- if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1482
- if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) exclusions.inFlight.push(`#${row.issue}`);
1483
- } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1484
- exclusions.mechanicallyBlocked.push(`#${row.issue} (${row.reason})`);
1485
- } else {
1486
- // The same two predicates the selection and the gate apply, so a
1487
- // candidate this block advertises as excluded is one selection actually
1488
- // withheld — and one it does not advertise is one selection may offer
1489
- // (#887).
1490
- const durable = toSpecDurableVerdict(row, input.now);
1491
- if (durable !== undefined) {
1492
- exclusions.groomed.push(
1493
- `#${row.issue} (${durable.verdict} @ ${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()})`,
1494
- );
1495
- continue;
1496
- }
1497
- const refusal = toSpecRefusalOnCooldown(row, input.now);
1498
- if (refusal !== undefined) {
1499
- exclusions.refused.push(
1500
- `#${row.issue} (${refusal.kind}, refused ${new Date(row.recordedAt).toISOString()}, retry after ` +
1501
- `${new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString()})`,
1502
- );
1503
- }
1504
- }
1505
- }
1506
- for (const run of input.active) exclusions.dispatched.push(`#${run.issue}`);
1507
- const token = randomUUID();
1508
- const items: ToSpecLaunchItem[] = input.selected.map((candidate) => ({
1509
- issue: candidate.issue,
1510
- routing: candidate.routing,
1511
- title: candidate.title,
1512
- }));
1513
- const candidates = items.map((item) => `- #${item.issue} (${item.routing}) — ${item.title}`).join("\n");
1514
- // The opening sentence names the condition that authorized this batch:
1515
- // below a numeric trigger as before, or the always-on mode in its own
1516
- // words (#988) — never a threshold the configuration does not have.
1517
- const trigger =
1518
- input.groomBelow === "always"
1519
- ? `Grooming is configured \`groomBelow: "always"\` — Duty 2 grooms every tick while ungroomed candidates remain ` +
1520
- `(${input.summary.routed} routable right now). `
1521
- : `The routable queue is below the grooming trigger of ${input.groomBelow} (${input.summary.routed} routable). `;
1522
- const lines = [
1523
- `## Bounded to-spec grooming batch (${TO_SPEC_BATCH_MARKER}, #777)`,
1524
- "",
1525
- trigger +
1526
- "Duty 2's finding is now a strict contract: launch EXACTLY ONE native `task` batch this turn with the " +
1527
- `\`${TO_SPEC_AGENT}\` agent — never a second batch, never an improvised scout.`,
1528
- "",
1529
- "The conductor selected this batch mechanically from the live open-issue snapshot " +
1530
- `(issues carrying \`${input.queueLabel}\`, the \`${input.parkLabel}\` park label, issues without exactly one ` +
1531
- `\`${input.labelPrefix}<repo>\` routing label, parent/epic issues with sub-issues, ` +
1532
- "already-groomed, refused-on-cooldown, in-flight, lane/dependency-blocked and dispatched candidates were excluded):",
1533
- candidates,
1534
- "Excluded this tick — " +
1535
- `already-groomed: ${exclusions.groomed.length === 0 ? "none" : exclusions.groomed.join(", ")}; ` +
1536
- `refused, cooldown still holding: ${exclusions.refused.length === 0 ? "none" : exclusions.refused.join(", ")}; ` +
1537
- `in-flight batches — ${exclusions.inFlight.length === 0 ? "none" : exclusions.inFlight.join(", ")}; ` +
1538
- `mechanically blocked: ${exclusions.mechanicallyBlocked.length === 0 ? "none" : exclusions.mechanicallyBlocked.join(", ")}; ` +
1539
- `dispatched now: ${exclusions.dispatched.length === 0 ? "none" : exclusions.dispatched.join(", ")}.`,
1540
- "",
1541
- "To launch it:",
1542
- "",
1543
- `1. For each candidate above, render \`omp/src/briefs/to-spec.md\` with its placeholders — {{TRACKER_REPO}}, ` +
1544
- "{{ISSUE_NUMBER}}, {{CANDIDATE_TITLE}}, {{ISSUE_BODY}} (read via `gh issue view <number> --repo " +
1545
- `${input.tracker}\`, never from memory), {{SOURCE}} = the candidate's routed repo, {{SOURCE_REF}} = that repo's \`` +
1546
- "current default-branch head, fetched now (`gh api repos/<repo>/branches/HEAD` or `git ls-remote`). Your item " +
1547
- "`task` MUST start with the two contract lines `to-spec candidate: <owner/repo>#<issue> — <title>` and " +
1548
- "`to-spec source: <owner/repo>@<ref>` — the `owner/repo` must match the routing named above.",
1549
- `2. Call \`task\` once with \`context\` whose first line is exactly \`${TO_SPEC_BATCH_MARKER}: ${token}\`, one ` +
1550
- `item per candidate above, in that order — no substitutes, no extra items (\`agent: "${TO_SPEC_AGENT}"\`; this ` +
1551
- "extension stamps the exact outputSchema/schemaMode on the way in and refuses a second batch this turn).",
1552
- `3. When each item completes, whether in the tool result or later as an async-result message, persist the agent's ` +
1553
- `exact raw output through the \`${TO_SPEC_RESULT_TOOL}\` tool — one call per completed item (\`issue\` + \`input\`), ` +
1554
- "success and failure alike: a malformed, source-less or stale result persists as blocked and must not discard " +
1555
- "successful siblings. Read the full output from its `agent://<id>` artifact when the inline text is truncated.",
1556
- `Never add \`${input.queueLabel}\`, never edit an issue or its labels. This batch produces verdicts only; ` +
1557
- "promotion stays your decision — the tool_call gate and the store own persistence, you own the queue.",
1558
- "",
1559
- ];
1560
- return { token, block: lines.join("\n"), items };
1561
- }
1562
-
1563
- /**
1564
- * The accounting a low-queue tick owes Duty 2 when the mechanical selection
1565
- * produced no batch at all. Without it a fully-excluded backlog is silence,
1566
- * and silence is what gets re-derived by hand: the orchestrator cannot tell
1567
- * "the queue is low and nothing is groomable" from "the launch machinery did
1568
- * not run". Every count comes from the same predicates the selection and the
1569
- * `tool_call` gate apply (#887).
1570
- *
1571
- * `undefined` when no dispatch row exists or the queue is at/above a numeric
1572
- * grooming trigger (`"always"` opens that gate; candidate state decides, #988)
1573
- * — the same gate the offer itself uses, so this line and a launch block are
1574
- * mutually exclusive.
1575
- */
1576
- export function toSpecNoBatchLine(input: {
1577
- summary: DispatchSummary | undefined;
1578
- groomBelow: GroomTrigger;
1579
- grooming: readonly GroomingRecord[];
1580
- active: readonly { issue: number }[];
1581
- now: number;
929
+ /** The lower bound the promotions were read from, named so the orchestrator
930
+ * can tell "nothing happened" from "a narrow window". */
931
+ since: number;
1582
932
  }): string | undefined {
1583
- if (input.summary === undefined) return undefined;
1584
- if (!groomingDue(input.summary.routed, input.groomBelow)) return undefined;
1585
- let durable = 0;
1586
- let refused = 0;
1587
- let inFlight = 0;
1588
- let held = 0;
1589
- for (const row of input.grooming) {
1590
- if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
1591
- if (input.now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) inFlight += 1;
1592
- } else if (row.reason === "file-lane" || row.reason === "depends-on") {
1593
- held += 1;
1594
- } else if (toSpecDurableVerdict(row, input.now) !== undefined) {
1595
- durable += 1;
1596
- } else if (toSpecRefusalOnCooldown(row, input.now) !== undefined) {
1597
- refused += 1;
933
+ const rejected = input.grooming.flatMap((row) => {
934
+ const rejection = parseReadyGateRejection(row.evidence);
935
+ return rejection === undefined ? [] : [{ row, rejection }];
936
+ });
937
+ if (input.promotions.length === 0 && rejected.length === 0) return undefined;
938
+ const lines: string[] = [
939
+ `Promotion audit (Duty 2) ${input.promotions.length} auto-promotion(s) since ` +
940
+ `${new Date(input.since).toISOString()}, ${rejected.length} spec(s) the ready gate refused:`,
941
+ ];
942
+ for (const row of input.promotions.slice(0, PROMOTION_AUDIT_LIMIT)) {
943
+ const at = row.promotedAt === undefined ? "unknown time" : new Date(row.promotedAt).toISOString();
944
+ const by = row.promotedBy ?? "unrecorded";
945
+ // The gate that passed is named from the provenance the promoter stored,
946
+ // never guessed: the audit exists to review what the daemon did
947
+ // unattended, and a guessed attribution would make that review circular.
948
+ const gate =
949
+ by === "daemon"
950
+ ? "ready gate passed"
951
+ : by === "orchestrator"
952
+ ? "promoted by an earlier tick"
953
+ : "promoted by an operator";
954
+ lines.push(
955
+ `- #${row.issue} — verdict ${row.verdict} (${row.reason}); ${gate}; queued ${at} by ${by}. ` +
956
+ `Demote: omp-conductor verb conductor_label --project ${input.project} ` +
957
+ `--arg issueUrl=https://github.com/${input.tracker}/issues/${row.issue} ` +
958
+ `--arg label=${input.queueLabel} --arg action=remove --arg reason=needs-human ` +
959
+ `--arg rationale="<why this spec is not ready>"`,
960
+ );
961
+ }
962
+ if (input.promotions.length > PROMOTION_AUDIT_LIMIT) {
963
+ lines.push(`- … ${input.promotions.length - PROMOTION_AUDIT_LIMIT} further promotion(s) not shown.`);
964
+ }
965
+ if (rejected.length > 0) {
966
+ lines.push(
967
+ "Gate-rejected specs — never queued, and the pass that produced them is already spent, so the " +
968
+ "issue itself is what has to change. These repeat every tick until they are fixed, which is why " +
969
+ "each carries the date its gate ran:",
970
+ );
971
+ // Oldest rejection first: a spec refused days ago and never edited is the
972
+ // one the audit keeps re-printing, so it leads rather than sinking under
973
+ // whatever the last pass produced.
974
+ const ordered = [...rejected].sort((a, b) => a.rejection.checkedAt - b.rejection.checkedAt);
975
+ for (const { row, rejection } of ordered.slice(0, PROMOTION_AUDIT_LIMIT)) {
976
+ const checked =
977
+ rejection.checkedAt === 0 ? "unknown date" : new Date(rejection.checkedAt).toISOString();
978
+ lines.push(`- #${row.issue} (gate ran ${checked}) missing: ${rejection.missing.join("; ")}`);
979
+ }
980
+ if (rejected.length > PROMOTION_AUDIT_LIMIT) {
981
+ lines.push(`- … ${rejected.length - PROMOTION_AUDIT_LIMIT} further rejected spec(s) not shown.`);
1598
982
  }
1599
983
  }
1600
- return (
1601
- `No to-spec batch this tick: the mechanical selection found nothing eligible in the open-issue ` +
1602
- `snapshot ${durable} durable verdict(s), ${refused} refused inside the retry cooldown, ${inFlight} ` +
1603
- `in flight, ${held} lane/dependency-blocked, ${input.active.length} dispatched. A \`task\` call carrying ` +
1604
- `the ${TO_SPEC_BATCH_MARKER} marker is refused this turn; file or promote from what the backlog already ` +
1605
- "says instead of re-grooming it."
984
+ lines.push(
985
+ "Duty 2 is now three things and no launch: propose net-new scope through a decision row, audit the " +
986
+ "promotions above (demote what you disagree with, using the command on its line), and edit the " +
987
+ "gate-rejected issues until the missing fields are there the daemon re-runs the gate by itself.",
1606
988
  );
989
+ return lines.join("\n");
1607
990
  }
1608
991
 
1609
992
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
@@ -1616,6 +999,19 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string
1616
999
  "Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
1617
1000
  };
1618
1001
 
1002
+ /**
1003
+ * Collapses one durable row's prose to a single bounded prompt line.
1004
+ *
1005
+ * Shared by every appended block rather than re-declared inside each: they all
1006
+ * render operator- or model-authored text into a prompt where one pathological
1007
+ * row must not push the duties out of the window, and three private copies of
1008
+ * the same four lines is how one of them ends up unbounded.
1009
+ */
1010
+ function flattened(text: string, limit: number): string {
1011
+ const flat = text.replace(/\s+/g, " ").trim();
1012
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
1013
+ }
1014
+
1619
1015
  /** Bounded, durable source material for one due digest. Row ids are part of the
1620
1016
  * handoff contract: the report and exactly the rows it consumed settle in one
1621
1017
  * SQLite transaction, so a crash cannot lose an outcome between those writes. */
@@ -1624,21 +1020,17 @@ export function formatDigestLedger(
1624
1020
  notices: readonly HeldNotice[],
1625
1021
  backlog: DigestBacklog,
1626
1022
  ): string {
1627
- const oneLine = (text: string, limit: number): string => {
1628
- const flat = text.replace(/\s+/g, " ").trim();
1629
- return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
1630
- };
1631
1023
  const lines = [
1632
1024
  `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held notice(s); oldest first:`,
1633
1025
  ...events.map(
1634
1026
  (event) =>
1635
1027
  ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
1636
- `${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
1028
+ `${flattened(event.summary, 180)} | evidence: ${flattened(event.evidence, 240)}`,
1637
1029
  ),
1638
1030
  ...notices.map(
1639
1031
  (notice) =>
1640
1032
  ` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
1641
- `${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
1033
+ `${flattened(notice.summary, 180)} | detail: ${flattened(notice.detail, 240)}`,
1642
1034
  ),
1643
1035
  ];
1644
1036
  if (backlog.materialCount > events.length) {
@@ -1658,6 +1050,113 @@ export function formatDigestLedger(
1658
1050
  return lines.join("\n");
1659
1051
  }
1660
1052
 
1053
+ /**
1054
+ * Reserved `material_events` category for a mechanical amendment the
1055
+ * orchestrator applied on its own authority (Phase 4 — the floor's
1056
+ * mechanical-vs-judgment split).
1057
+ *
1058
+ * There is no amendments table, and neither other durable candidate can carry
1059
+ * this audit honestly. `POLICY.md`'s own `## Amendments` section is written by
1060
+ * step 4 of the floor's *judgment* protocol only — a mechanical fix skips
1061
+ * straight to applying and reporting, so it never lands there — and that
1062
+ * section's line shape (date, trigger, one-sentence summary) has nowhere to put
1063
+ * a revert command. `friction_rollups` counts admission holds and report
1064
+ * classifications and knows nothing about policy edits. `material_events` is
1065
+ * the append-only outbox the floor already uses for every outcome that is not
1066
+ * sent as an interrupt, its category is a free lowercase slug (`event record`
1067
+ * validates `^[a-z0-9][a-z0-9-]{0,31}$`), and both its text columns are
1068
+ * mandatory — `--summary` 1-240 characters, `--evidence` 1-500 — which makes
1069
+ * `evidence` exactly the right home for a one-line revert. So the audit is a
1070
+ * category convention over the ledger that already exists, and needs no schema
1071
+ * change. Note where the cap bites: a revert longer than 500 characters is
1072
+ * refused by `event record` at write time rather than truncated by the renderer
1073
+ * below, so an over-long one is a revert to shorten, never a silently clipped
1074
+ * command an operator would paste.
1075
+ *
1076
+ * The consequence to keep in view: this surface holds what the orchestrator
1077
+ * wrote to it and nothing else. An amendment applied without the ledger write
1078
+ * is invisible here, which is why the floor's mechanical bullet and the
1079
+ * friction digest both name the exact command rather than leaving the shape to
1080
+ * be guessed.
1081
+ */
1082
+ export const AMENDMENT_EVENT_CATEGORY = "amendment";
1083
+
1084
+ /** Amendment rows rendered in one audit. A window with more self-applied policy
1085
+ * edits than this is a fleet problem the prompt cannot fix by listing them. */
1086
+ const AMENDMENT_AUDIT_LIMIT = 5;
1087
+
1088
+ /**
1089
+ * How deep into the outbox the audit reads.
1090
+ *
1091
+ * Deliberately far past {@link DIGEST_BACKLOG_LIMIT}, which bounds one digest's
1092
+ * worth of source material: an audit inheriting that bound would hide a
1093
+ * self-applied policy edit behind twenty merge events, and that row is the one
1094
+ * that must never go unseen.
1095
+ */
1096
+ export const AMENDMENT_AUDIT_SCAN_LIMIT = 200;
1097
+
1098
+ /**
1099
+ * The one sentence in the tick prompt that states the amendment cap.
1100
+ *
1101
+ * A constant, and used in exactly one rendered block, because the cap is the
1102
+ * clause the floor just re-scoped: it counts *judgment* proposals, and a
1103
+ * mechanical drift fix is not a proposal at all. A second copy of this sentence
1104
+ * anywhere in the prompt is how the old undifferentiated wording comes back —
1105
+ * the audit block below therefore states the duty and never restates the cap.
1106
+ */
1107
+ export const AMENDMENT_THROTTLE_LINE =
1108
+ "The cap is at most one new orchestrator-originated judgment amendment proposal per autonomous tick, and it " +
1109
+ "counts judgment proposals only — duties, boundaries, release policy, caps, reporting scope. A mechanical " +
1110
+ "drift fix is not a proposal and is not throttled: apply it, report it with its revert, and apply the next one.";
1111
+
1112
+ /**
1113
+ * Mechanical amendments the orchestrator applied without asking, read back to
1114
+ * it before they reach its operator (Phase 4).
1115
+ *
1116
+ * The floor's mechanical branch trades an ask for a promise — apply now, then
1117
+ * say in the next report what changed, why, and how to revert it. This block is
1118
+ * what makes that promise auditable by something other than session memory,
1119
+ * which is exactly the memory the whole ledger discipline exists to distrust.
1120
+ *
1121
+ * The window is "recorded and not yet carried into a delivered digest", not
1122
+ * "since the previous tick". That is the honest predicate rather than the
1123
+ * approximate one: the tick has no durable record of its own previous beat, and
1124
+ * an interval-width window silently drops the audit when a turn runs long or a
1125
+ * digest fails — the two occasions where an unreviewed policy edit is most
1126
+ * likely. Undigested is also self-clearing: composing the report retires the
1127
+ * row, so a reported amendment stops appearing without anyone marking it.
1128
+ *
1129
+ * On a due-digest tick this deliberately overlaps {@link formatDigestLedger},
1130
+ * which lists the same row for a different purpose — the ledger hands over row
1131
+ * ids to consume, the audit hands over a policy edit to re-justify or revert.
1132
+ * Suppressing one because the other rendered would mean a self-applied
1133
+ * amendment goes unreviewed precisely on the tick that ships it.
1134
+ */
1135
+ export function formatAmendmentAudit(events: readonly MaterialEvent[]): string | undefined {
1136
+ const amendments = events.filter((event) => event.category === AMENDMENT_EVENT_CATEGORY);
1137
+ if (amendments.length === 0) return undefined;
1138
+ const shown = amendments.slice(0, AMENDMENT_AUDIT_LIMIT);
1139
+ const lines = [
1140
+ `Mechanical amendments you applied without asking — ${amendments.length}, not yet carried into a report. ` +
1141
+ "Each one is a POLICY.md edit made on your own authority, so the audit is yours before it is your operator's:",
1142
+ ...shown.map(
1143
+ (event) =>
1144
+ `- ${new Date(event.occurredAt).toISOString()} | ${flattened(event.summary, 180)} | ` +
1145
+ `revert: ${flattened(event.evidence, 240)}`,
1146
+ ),
1147
+ ];
1148
+ if (amendments.length > shown.length) {
1149
+ lines.push(`- … ${amendments.length - shown.length} further amendment(s) not shown.`);
1150
+ }
1151
+ lines.push(
1152
+ "Re-check each against the source that made it mechanical — the file, config key or ledger row that proves " +
1153
+ "the value. Still true: carry the line into your next report exactly as it reads here, revert command " +
1154
+ "included. No longer true, or a source you can no longer state: run the revert now, and propose the change " +
1155
+ "as a judgment amendment if it is still worth making.",
1156
+ );
1157
+ return lines.join("\n");
1158
+ }
1159
+
1661
1160
  /**
1662
1161
  * The reporting constraint appended to a default tick prompt (#229, #242).
1663
1162
  *
@@ -1779,7 +1278,11 @@ export { TELEGRAM_APPROVAL_TOOL };
1779
1278
  *
1780
1279
  * The last clause is the actual hazard #114 exposed: a turn that knows
1781
1280
  * it must ask, and cannot, is one inference away from recording an approval
1782
- * nobody gave.
1281
+ * nobody gave. Phase 4 narrows its noun to a *judgment* amendment, and only its
1282
+ * noun: a mechanical drift fix is applied without an ask by design, so an
1283
+ * undifferentiated "never apply an amendment without an answer" here would
1284
+ * contradict the floor on the branch that has no ask to be missing. The hazard
1285
+ * this rule guards — recording an unanswered yes/no as approved — is unchanged.
1783
1286
  *
1784
1287
  * Unlike {@link TICK_DELIVERY_RULE} this is appended to a configured `message`
1785
1288
  * too. An operator's prompt owns the reporting contract and is theirs to get
@@ -1788,29 +1291,35 @@ export { TELEGRAM_APPROVAL_TOOL };
1788
1291
  */
1789
1292
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
1790
1293
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
1791
- `If you have an amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
1294
+ `If you have a judgment amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
1792
1295
  `it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
1793
1296
  `same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
1794
1297
  `or the seven-day expiry, never recorded as approved. ` +
1795
1298
  `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
1796
1299
  `A returned answer proves an answer, not Telegram delivery. ` +
1797
- `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
1300
+ `Never apply a judgment amendment, or record one as approved, without an explicit answer you actually received.`;
1798
1301
 
1799
1302
  /**
1800
1303
  * Appended to every tick — the shipped prompt or the operator's own — because
1801
- * the bounded-ask surface is the mechanical contract of a local tick (#438).
1304
+ * the durable ask surface is the mechanical contract of a local tick (#438).
1802
1305
  *
1803
- * Even a *mounted* `telegram_ask` is not the ask surface for a locally injected
1804
- * tick: it would wait for the operator for as long as the answer takes, and on
1805
- * 2026-08-16 that was six stopped hours. The gate refuses it, so the tick names
1806
- * the bounded replacement up front instead of letting the model discover the
1807
- * refusal mid-ask.
1306
+ * No ask waits any more. This session is the headless tick brain: operator
1307
+ * turns land in the 24/7 console session, so an answer physically cannot
1308
+ * arrive here, and a duty cycle that waited would stop the fleet for nothing —
1309
+ * on 2026-08-16 that was six stopped hours. So the rule states the whole
1310
+ * contract up front: the question becomes a durable row, the console resolves
1311
+ * it later, and parking the blocked work is part of the same turn rather than
1312
+ * something the model discovers when a wait expires.
1808
1313
  */
1809
1314
  export const TICK_ASK_RULE =
1810
1315
  `On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
1811
- `call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
1812
- `and optionally "timeoutSeconds" an ask issued without one still gets the default ceiling, capped at the turn budget. ` +
1813
- `Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, one ceiling, ` +
1316
+ `call it with the question, its options and your recommendation. It does NOT wait nothing here does. ` +
1317
+ `The question is filed as a durable decision row and delivered, and the answer arrives later, when your ` +
1318
+ `operator resolves that row from the console session (\`omp-conductor decision resolve <id> --answer "..."\`). ` +
1319
+ `So in the SAME turn you ask, park the work the question blocks: take it out of the claimable queue, record ` +
1320
+ `its state, and name the row id in your report. Never wait for a reply, never re-ask on the next tick, and ` +
1321
+ `never proceed as though your recommendation had been approved. ` +
1322
+ `Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, ` +
1814
1323
  `each item a durable row bound to that issue, resolved independently and in any order. ` +
1815
1324
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
1816
1325
  `takes, and an unanswered question must never hold the loop.`;
@@ -1824,8 +1333,8 @@ export const TICK_ASK_RULE =
1824
1333
  export const ASK_DIVERSION_REASON =
1825
1334
  `Blocked: on this locally injected tick, ${TELEGRAM_APPROVAL_TOOL} would wait for your operator for as long as ` +
1826
1335
  `the answer takes, and an unanswered question must never stop the loop (#438). Nothing was sent or recorded ` +
1827
- `by this call. Ask instead with ${ASK_TOOL}, declaring "on-timeout" ("auto-proceed" or "park") it bounds the ` +
1828
- `wait, records the question durably, and returns one of those outcomes when nobody answers.`;
1336
+ `by this call. Ask instead with ${ASK_TOOL} it records the question as a durable decision row, delivers it, ` +
1337
+ `and returns immediately with the row id and the command your operator's console resolves it with.`;
1829
1338
 
1830
1339
  /**
1831
1340
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -1866,7 +1375,17 @@ function frictionLabel(kind: FrictionSignal["kind"]): string {
1866
1375
  return "tick reports classified as surprising";
1867
1376
  }
1868
1377
 
1869
- /** Bounded evidence for the existing approval protocol — never an automatic edit. */
1378
+ /**
1379
+ * Bounded evidence for the existing approval protocol — never an automatic edit.
1380
+ *
1381
+ * The closing instruction is the one place the tick prompt routes into the
1382
+ * Learning loop, so it is also where the floor's mechanical-vs-judgment split
1383
+ * has to be visible (Phase 4): the gate sentence is unchanged — friction with no
1384
+ * safe POLICY.md remedy still leaves policy alone — and the two branches under
1385
+ * it say which kind of amendment the remedy is, and what a mechanical one owes
1386
+ * the ledger. It carries {@link AMENDMENT_THROTTLE_LINE} and is the only block
1387
+ * that does.
1388
+ */
1870
1389
  export function formatFrictionDigest(signals: readonly FrictionSignal[]): string {
1871
1390
  const shown = signals.slice(0, FRICTION_DIGEST_LIMIT);
1872
1391
  const lines = [
@@ -1884,12 +1403,24 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
1884
1403
  if (signals.length > shown.length) lines.push(`- ${signals.length - shown.length} more signal(s) deferred`);
1885
1404
  lines.push(
1886
1405
  "After the tick duties, investigate at most one signal. Use the existing Learning loop only if the recurring cause has a safe POLICY.md remedy; otherwise leave policy unchanged and report or file the underlying product/infra issue through the existing rules.",
1406
+ "When there is a remedy, route it by kind. A drift fact you can verify against the repo, the config or " +
1407
+ "this fleet's own ledger — a renamed label, a moved path, a changed schedule, a caps figure the config " +
1408
+ "already carries — is a MECHANICAL amendment: edit POLICY.md now, then record it with " +
1409
+ '`omp-conductor event record --category ' +
1410
+ AMENDMENT_EVENT_CATEGORY +
1411
+ ' --summary "<what changed and why>" --evidence "<the one-line revert>"`, which is what puts it in the ' +
1412
+ "next tick's amendment audit and in your next report. A remedy that is a choice about how the fleet " +
1413
+ "should behave is a JUDGMENT amendment and goes through the proposal protocol unchanged. " +
1414
+ AMENDMENT_THROTTLE_LINE,
1887
1415
  );
1888
1416
  return lines.join("\n");
1889
1417
  }
1890
1418
 
1891
1419
  /** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
1892
1420
  const INTAKE_TEXT_LIMIT = 160;
1421
+ /** Bounds a mined item's provenance key beside its text. Shorter than the text
1422
+ * because a source is an identifier, not prose — a longer one is malformed. */
1423
+ const INTAKE_SOURCE_LIMIT = 80;
1893
1424
 
1894
1425
  /**
1895
1426
  * Pending intake items, each waiting to become an issue (#300).
@@ -1903,19 +1434,31 @@ const INTAKE_TEXT_LIMIT = 160;
1903
1434
  * "0 pending" every tick would be permanent noise — the same convention as
1904
1435
  * {@link queueDigestLine}. Project-specific grooming taste (priority scales,
1905
1436
  * template wording) stays in POLICY.md; this block carries only the floor duty.
1437
+ *
1438
+ * Provenance is rendered per item because the two kinds are not the same work
1439
+ * (Phase 4 signal mining). An item with no `source` is an idea a person typed:
1440
+ * it is scope, and grooming it means writing down what they asked for. A mined
1441
+ * item is a machine's inference from settlement flags, failure classes or
1442
+ * friction — evidence to check against the code before anything is filed, and
1443
+ * the one kind that can be honestly dismissed on the orchestrator's own
1444
+ * judgement when the evidence does not hold up. Rendering them
1445
+ * indistinguishably invited exactly one error: filing a mined guess as though
1446
+ * an operator had asked for it.
1906
1447
  */
1907
1448
  export function formatPendingIntake(
1908
1449
  items: readonly IntakeItem[],
1909
1450
  instruction: { tracker: string; queueLabel: string; labelPrefix: string },
1910
1451
  ): string | undefined {
1911
1452
  if (items.length === 0) return undefined;
1912
- const oneLine = (text: string): string => {
1913
- const flat = text.replace(/\s+/g, " ").trim();
1914
- return flat.length <= INTAKE_TEXT_LIMIT ? flat : `${flat.slice(0, INTAKE_TEXT_LIMIT - 1)}…`;
1915
- };
1453
+ // Bounded like the text: a mined source is a key, and a pathological one must
1454
+ // not push the grooming instruction out of the prompt window.
1455
+ const provenance = (item: IntakeItem): string =>
1456
+ item.source === undefined
1457
+ ? "your operator's own idea"
1458
+ : `mined signal ${flattened(item.source, INTAKE_SOURCE_LIMIT)}`;
1916
1459
  const lines = [
1917
1460
  `Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
1918
- ...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
1461
+ ...items.map((item) => `- ${item.id} (${provenance(item)}) — ${flattened(item.text, INTAKE_TEXT_LIMIT)}`),
1919
1462
  "",
1920
1463
  `Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
1921
1464
  `carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
@@ -1927,6 +1470,12 @@ export function formatPendingIntake(
1927
1470
  'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
1928
1471
  "the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
1929
1472
  "<id>` and noted in the digest rather than filed.",
1473
+ "The two provenances are groomed differently. Your operator's own idea is scope: file what they asked " +
1474
+ "for, and never dismiss it on your own judgement — if it looks wrong, that is a question for them. A " +
1475
+ "mined signal is evidence, not scope: open what its source names, confirm from the code that the " +
1476
+ "problem is real, and file the issue against what you actually find — its wording is a machine's " +
1477
+ "inference and yours to correct. A mined signal the evidence does not support is dismissed, with the " +
1478
+ "reason in the digest.",
1930
1479
  ];
1931
1480
  return lines.join("\n");
1932
1481
  }
@@ -2165,18 +1714,11 @@ export function readTickConfig(cwd: string): TickConfigResult {
2165
1714
  ? budgetRaw
2166
1715
  : undefined;
2167
1716
 
2168
- // Same tolerance, same reason. The ask ceiling is bounded on both ends by
2169
- // construction: MIN..MAX here, and the resolved per-ask ceiling is capped at
2170
- // the turn budget by the ask tool itself, so a config cannot smuggle an
2171
- // unbounded wait in through this key.
2172
- const askRaw = raw["askTimeoutSeconds"];
2173
- const askTimeoutSeconds =
2174
- typeof askRaw === "number" &&
2175
- Number.isInteger(askRaw) &&
2176
- askRaw >= MIN_ASK_TIMEOUT_SECONDS &&
2177
- askRaw <= MAX_ASK_TIMEOUT_SECONDS
2178
- ? askRaw
2179
- : undefined;
1717
+ // No ask ceiling is read from the config any more: the ask does not wait at
1718
+ // all (#438 as re-cut for the console split), so a stale `askTimeoutSeconds`
1719
+ // on a deployed fleet's `.conductor-tick.json` is simply an unknown key
1720
+ // ignored like every other one, so an old file on disk can never fail a
1721
+ // restart.
2180
1722
 
2181
1723
  // Relative paths resolve against the session cwd, so the files can sit beside
2182
1724
  // the config that names them (`state/armed`) without hard-coding a deploy path.
@@ -2222,7 +1764,6 @@ export function readTickConfig(cwd: string): TickConfigResult {
2222
1764
  intervalSeconds,
2223
1765
  ...(project === undefined ? {} : { project }),
2224
1766
  ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
2225
- ...(askTimeoutSeconds === undefined ? {} : { askTimeoutSeconds }),
2226
1767
  ...(armedFile === undefined ? {} : { armedFile }),
2227
1768
  ...(accessFile === undefined ? {} : { accessFile }),
2228
1769
  ...(message === undefined ? {} : { message }),
@@ -2856,12 +2397,6 @@ interface PendingLocalTick {
2856
2397
  interface ActiveLocalTick extends PendingLocalTick {
2857
2398
  /** A person who writes during an autonomous run is awake by construction. */
2858
2399
  humanWaiting: boolean;
2859
- /** The most recent inbound turn was an *active arming proof* (conductor #415):
2860
- * a reply matching a persisted pending challenge for this project. Set when
2861
- * `humanWaiting` is, without flipping the human-present exemption — the
2862
- * operator answered a challenge, so they are awake, and the model must not
2863
- * treat the matching token as license to mutate pairing or access. */
2864
- armingProof?: boolean;
2865
2400
  }
2866
2401
 
2867
2402
  interface TelegramInterrupt {
@@ -3231,29 +2766,6 @@ interface TickSession {
3231
2766
  legacyArmLogged: boolean;
3232
2767
  /** The local tick whose agent loop is currently running, if any. */
3233
2768
  activeLocalTick?: ActiveLocalTick;
3234
- /**
3235
- * The to-spec launch token the last low-queue tick authorized (#777). Set
3236
- * when the tick appended a launch block; the `task` tool_call gate accepts
3237
- * exactly one batch echoing it and refuses a marker-bearing call without
3238
- * it (no authorized batch this tick), with a stale token, or after a batch
3239
- * was already accepted. Cleared at the start of every tick — healthy and
3240
- * low-queue alike — so a token minted on a low-queue tick can never be
3241
- * spent on a later healthy-queue tick; the tick that mints the next token
3242
- * sets it fresh with its own {@link TickSession.launchItems}.
3243
- */
3244
- launchToken?: string;
3245
- /** The project the {launchToken} authorization was minted for — the one
3246
- * in-flight rows are recorded against. */
3247
- launchProject?: string;
3248
- /** The mechanically selected batch the current token authorizes: the gate
3249
- * refuses any item whose issue or routing is not on this list, so parked,
3250
- * parent/epic and other excluded candidates cannot be stamped in-flight
3251
- * no matter what the model sends. */
3252
- launchItems?: ToSpecLaunchItem[];
3253
- /** The one batch this session's gate has let through, so a second attempt
3254
- * this tick refuses, and the tool_result capture can match results to
3255
- * items by index. */
3256
- launchedBatch?: { toolCallId: string; items: ToSpecBatchItem[] };
3257
2769
  }
3258
2770
 
3259
2771
  /**
@@ -3343,13 +2855,6 @@ async function tick(
3343
2855
  session: TickSession,
3344
2856
  toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3345
2857
  ): Promise<void> {
3346
- // A launch authorization belongs to one emitted tick only. Revoke it before
3347
- // any gate, config, store or tracker read so a skipped/degraded/healthy next
3348
- // tick cannot reuse a token minted by an earlier low-queue tick.
3349
- session.launchedBatch = undefined;
3350
- session.launchToken = undefined;
3351
- session.launchProject = undefined;
3352
- session.launchItems = undefined;
3353
2858
  const live = currentConfig(ctx.cwd, config);
3354
2859
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
3355
2860
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -3563,6 +3068,17 @@ async function tick(
3563
3068
  now - FRICTION_COOLDOWN_MS,
3564
3069
  );
3565
3070
  if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
3071
+ // Policy edits the session made on its own authority, read back before
3072
+ // they reach the operator (Phase 4). Placed directly after the friction
3073
+ // digest and before the decision rows because that is the order of the
3074
+ // Learning loop itself: the evidence that provokes an amendment, then
3075
+ // the mechanical ones already applied and owed a report, then the
3076
+ // judgment ones still waiting on an answer. The scan reaches past one
3077
+ // digest's worth of backlog on purpose — see AMENDMENT_AUDIT_SCAN_LIMIT.
3078
+ const amendments = formatAmendmentAudit(
3079
+ frictionStore.undigestedMaterialEvents(scope.projectName, AMENDMENT_AUDIT_SCAN_LIMIT),
3080
+ );
3081
+ if (amendments !== undefined) content = `${content}\n${amendments}`;
3566
3082
  // What the session still owes its operator, read from the ledger rather
3567
3083
  // than from what it remembers asking (#136). Appended every tick,
3568
3084
  // because the whole failure was a question surviving in context only.
@@ -3609,16 +3125,13 @@ async function tick(
3609
3125
  const grooming = store.groomingVerdicts(projectName);
3610
3126
  // #848: "the queue is empty / running dry" is the tracker's word, not
3611
3127
  // the last dispatch pass's echo. One open-issue snapshot per tick,
3612
- // read live through the same authoritative tracker surface the
3613
- // to-spec launch uses, overlaid with the label_ops projection
3614
- // exactly as the dispatch pass judges eligibility so a promotion,
3615
- // unblock or projection write after the last dispatch pass is
3616
- // visible on the very next tick even while a drain holds claiming.
3617
- // The digest consumes the overlay as the live inventory; the to-spec
3618
- // offer shares the same raw snapshot so one tick cannot describe two
3619
- // queues. If the tracker read fails, the digest falls back to
3620
- // explicitly dated wording (never a present-tense empty claim),
3621
- // and the offer retries its own read exactly as it did before.
3128
+ // read live through the authoritative tracker surface, overlaid with
3129
+ // the label_ops projection exactly as the dispatch pass judges
3130
+ // eligibility so a promotion, unblock or projection write after the
3131
+ // last dispatch pass is visible on the very next tick even while a
3132
+ // drain holds claiming. If the tracker read fails, the digest falls
3133
+ // back to explicitly dated wording, never a present-tense empty
3134
+ // claim.
3622
3135
  //
3623
3136
  // The read is made only for dispatch rows the digest could render:
3624
3137
  // a healthy row (routed at/above a numeric grooming threshold)
@@ -3627,16 +3140,14 @@ async function tick(
3627
3140
  // below the trigger) is exactly where the old wording lied, so every
3628
3141
  // such tick reads the live queue. Under `groomBelow: "always"`
3629
3142
  // (#988) the duty never clears on volume, so every dispatch row
3630
- // reads the queue and the selectors judge candidate state.
3143
+ // reads the queue.
3631
3144
  const needsQueueRead =
3632
3145
  dispatch !== undefined &&
3633
3146
  (dispatch.ready === 0 || dispatch.routed === 0 || groomingDue(dispatch.routed, groomBelow));
3634
3147
  let queueObservation: QueueObservation | undefined;
3635
- let openSnapshot: readonly ReadyIssue[] | undefined;
3636
3148
  if (toSpecTrackerSeam !== undefined && needsQueueRead) {
3637
3149
  try {
3638
3150
  const open = await toSpecTrackerSeam.listOpenIssues(project);
3639
- openSnapshot = open;
3640
3151
  const effective = open.map((issue) => {
3641
3152
  const pending = store.pendingLabelOpsFor(projectName, issue.number);
3642
3153
  return pending.length === 0
@@ -3659,57 +3170,29 @@ async function tick(
3659
3170
  groomBelow,
3660
3171
  grooming,
3661
3172
  queueObservation,
3662
- // One clock for the inventory and the offer below: the digest must
3663
- // never call a verdict durable that the same tick's selection is
3173
+ // One clock for the inventory and the audit below: the digest must
3174
+ // never call a verdict durable that the daemon's own selection is
3664
3175
  // about to re-groom (#887).
3665
3176
  now,
3666
3177
  );
3667
3178
  if (queue !== undefined) content = `${content}\n${queue}`;
3668
- // #777: the mechanical to-spec launch boundary. The queue digest is
3669
- // the trigger the same below-`groomBelow` signal that already asks
3670
- // the orchestrator to groom so the launch offer is composed beside
3671
- // it, from the same store read, and only when a dispatch row exists
3672
- // to prove the queue is genuinely low. The token it carries is the
3673
- // only authorization the `task` tool_call gate accepts this tick;
3674
- // absent a block (healthy queue, no dispatch, unreadable snapshot,
3675
- // nothing eligible) a task call with the batch marker is refused.
3676
- //
3677
- // Every tick owns its authorization fresh — the clears above ran
3678
- // before the reads, so the offer below can only mint for THIS tick.
3679
- const toSpecActive = store.activeRuns(projectName);
3680
- const launch = await offerToSpecLaunch({
3681
- summary: dispatch,
3682
- groomBelow,
3179
+ // The Duty-2 audit that replaced #777's launch block (#1041/#1040).
3180
+ // The daemon promotes unattended, so what the tick owes is review,
3181
+ // and the window is two intervals wide for the same reason the
3182
+ // recovery digest's is: a tick that ran long must not drop the
3183
+ // promotions it was meant to audit. A tick that reports nothing
3184
+ // renders nothing the store, not this call site, decides whether
3185
+ // there is anything to say.
3186
+ const auditSince = now - 2 * config.intervalSeconds * 1_000;
3187
+ const audit = formatPromotionAudit({
3188
+ promotions: store.promotionsSince(projectName, auditSince),
3683
3189
  grooming,
3684
- active: toSpecActive,
3685
- issues: openSnapshot,
3686
- project,
3687
- trackerSeam: toSpecTrackerSeam,
3688
- now,
3190
+ project: project.name,
3191
+ tracker: project.tracker.repo,
3192
+ queueLabel: project.queueLabel,
3193
+ since: auditSince,
3689
3194
  });
3690
- if (launch !== undefined) {
3691
- content = `${content}\n${launch.block}`;
3692
- // A minted token authorizes exactly the batch this tick's block
3693
- // describes: the project and the allowlist are captured so the
3694
- // tool_call gate records in-flight rows against the same project
3695
- // the prompt named and refuses any item off the list.
3696
- session.launchProject = project.name;
3697
- session.launchToken = launch.token;
3698
- session.launchItems = launch.items;
3699
- } else if (openSnapshot !== undefined) {
3700
- // A low queue with nothing eligible is a finding, not silence: the
3701
- // snapshot read succeeded, so the exclusions — and only they — are
3702
- // why no batch is offered. Rendered only when the read succeeded,
3703
- // so a tracker failure never masquerades as "nothing eligible".
3704
- const noBatch = toSpecNoBatchLine({
3705
- summary: dispatch,
3706
- groomBelow,
3707
- grooming,
3708
- active: toSpecActive,
3709
- now,
3710
- });
3711
- if (noBatch !== undefined) content = `${content}\n${noBatch}`;
3712
- }
3195
+ if (audit !== undefined) content = `${content}\n${audit}`;
3713
3196
  // Pending intake is the same class of standing block as the friction
3714
3197
  // and decisions read-outs: a store-backed duty the orchestrator must
3715
3198
  // not derive from memory. The store answers, the prompt instructs.
@@ -3720,7 +3203,8 @@ async function tick(
3720
3203
  });
3721
3204
  if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
3722
3205
  } catch {
3723
- // unreadable config: no queue digest, launch block, or pending-intake block this tick
3206
+ // unreadable config: no queue digest, promotion audit, or
3207
+ // pending-intake block this tick
3724
3208
  }
3725
3209
  } catch (err) {
3726
3210
  frictionStore?.close();
@@ -3991,324 +3475,25 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
3991
3475
  });
3992
3476
  }
3993
3477
 
3994
- /**
3995
- * The harness surface one to-spec batch travels through (#777): the `task`
3996
- * tool call the orchestrator makes (with the launch marker in `context`) and
3997
- * its result. `details` is the task tool's `TaskToolDetails` — declared here
3998
- * rather than imported, exactly like {@link TickApi}, because the harness is
3999
- * a peer dependency. A settled batch's `details.results` carries one entry
4000
- * per item; a background launch returns an empty `results` array and delivers
4001
- * each final result later as an async-result message, which the orchestrator
4002
- * routes through {@link TO_SPEC_RESULT_TOOL}.
4003
- */
4004
- interface ToSpecTaskToolEvent {
4005
- toolName: string;
4006
- toolCallId: string;
4007
- input: Record<string, unknown>;
4008
- details: unknown;
4009
- }
4010
-
4011
- /** One settled batch item, as `details.results` carries it. */
4012
- interface ToSpecSettledItem {
4013
- /** The item's index inside the batch call. */
4014
- index?: unknown;
4015
- /** The agent's raw output — the exact text that must reach the parser. */
4016
- output?: unknown;
4017
- /** The harness's parsed structured output, when one exists. */
4018
- structuredOutput?: unknown;
4019
- }
4020
-
4021
- /**
4022
- * The launch gate and result capture for one to-spec batch (#777). Armed at
4023
- * extension-factory time; inert until a low-queue tick mints a launch token.
4024
- *
4025
- * `tool_call` on `task`:
4026
- * - a call without the {@link TO_SPEC_BATCH_MARKER} in `context` is the
4027
- * orchestrator's own task use and passes untouched;
4028
- * - a marker-bearing call is the conductor batch and is gated hard: it needs
4029
- * this tick's exact token, the batch shape (1..{@link TO_SPEC_BATCH_MAX}
4030
- * items, each starting with the two contract lines), unique items and
4031
- * names, every item ON the tick's mechanically selected allowlist (issue
4032
- * and routing — the parked/parent/epic exclusions are baked into that
4033
- * list, so a forbidden candidate can never be stamped in-flight), and
4034
- * store-side eligibility — the same {@link toSpecCandidateExclusion} rule
4035
- * the prompt block's exclusion lines came from, so an item excluded in
4036
- * prose is excluded in the gate for the same reason;
4037
- * - on acceptance it stamps every item's `agent`, `outputSchema` and
4038
- * `schemaMode` (the model never carries the schema itself), records the
4039
- * durable in-flight rows through the #735 grooming table, and latches the
4040
- * session so a second batch this tick refuses.
4041
- *
4042
- * `tool_result` on the accepted call captures a *settled* batch: every item
4043
- * is routed through {@link recordToSpecGrooming} independently, so a
4044
- * malformed or failed item persists `blocked` and its siblings survive. A
4045
- * background launch settles with no results here — its completed items arrive
4046
- * as async-result messages and are persisted through
4047
- * {@link TO_SPEC_RESULT_TOOL}.
4048
- */
4049
- function armToSpecGate(pi: TickApi, session: TickSession): void {
4050
- const api = pi as TickApi & {
4051
- on(
4052
- event: "tool_call",
4053
- handler: (
4054
- event: { toolName: string; toolCallId: string; input: Record<string, unknown> },
4055
- ctx: unknown,
4056
- ) => { block: true; reason: string } | { input: Record<string, unknown> } | undefined,
4057
- ): void;
4058
- on(event: "tool_result", handler: (event: ToSpecTaskToolEvent, ctx: unknown) => void): void;
4059
- };
4060
-
4061
- api.on("tool_call", (event) => {
4062
- if (event.toolName !== "task") return undefined;
4063
- const input = event.input;
4064
- const context = input["context"];
4065
- if (typeof context !== "string" || !context.includes(TO_SPEC_BATCH_MARKER)) return undefined;
4066
- // From here on this is a conductor grooming batch — the hard gate. Each
4067
- // refusal names the correction, because a blocked batch costs the tick a
4068
- // retry and the gate is meant to catch model error, not to hide it.
4069
- if (session.launchToken === undefined) {
4070
- return {
4071
- block: true,
4072
- reason:
4073
- `task refused: no to-spec batch is authorized this tick (` +
4074
- `only a low-queue tick's launch block names a ${TO_SPEC_BATCH_MARKER} token). ` +
4075
- "Run no batch this turn.",
4076
- };
4077
- }
4078
- if (session.launchedBatch !== undefined) {
4079
- return {
4080
- block: true,
4081
- reason: "task refused: this tick already launched its one to-spec batch. Wait for the results.",
4082
- };
4083
- }
4084
- const token = new RegExp(`${TO_SPEC_BATCH_MARKER}:\\s*(\\S+)`).exec(context)?.[1];
4085
- if (token !== session.launchToken) {
4086
- return {
4087
- block: true,
4088
- reason:
4089
- "task refused: the context token does not match the batch this tick authorized. " +
4090
- "Relaunch with the token named in this tick's launch block.",
4091
- };
4092
- }
4093
- const tasks = input["tasks"];
4094
- if (!Array.isArray(tasks) || tasks.length === 0 || tasks.length > TO_SPEC_BATCH_MAX) {
4095
- return {
4096
- block: true,
4097
- reason:
4098
- `task refused: a to-spec batch carries 1–${TO_SPEC_BATCH_MAX} items (one \`tasks[]\` call) — ` +
4099
- `got ${Array.isArray(tasks) ? tasks.length : "none"}.`,
4100
- };
4101
- }
4102
- const items: ToSpecBatchItem[] = [];
4103
- const names = new Set<string>();
4104
- for (const raw of tasks) {
4105
- if (typeof raw !== "object" || raw === null) {
4106
- return { block: true, reason: "task refused: every batch item must be an object." };
4107
- }
4108
- const item = parseToSpecItem((raw as Record<string, unknown>)["task"]);
4109
- if (item === undefined) {
4110
- return {
4111
- block: true,
4112
- reason:
4113
- "task refused: every item's `task` must start with the two contract lines " +
4114
- "`to-spec candidate: <owner/repo>#<issue> — <title>` and `to-spec source: <owner/repo>@<ref>`.",
4115
- };
4116
- }
4117
- const name = (raw as Record<string, unknown>)["name"];
4118
- if (typeof name === "string" && name.length > 0) {
4119
- if (names.has(name)) {
4120
- return { block: true, reason: `task refused: duplicate item name \`${name}\`.` };
4121
- }
4122
- names.add(name);
4123
- }
4124
- items.push(item);
4125
- }
4126
- for (let i = 1; i < items.length; i += 1) {
4127
- for (let j = 0; j < i; j += 1) {
4128
- if (items[j]!.issue === items[i]!.issue) {
4129
- return {
4130
- block: true,
4131
- reason: `task refused: #${items[i]!.issue} appears twice in one batch.`,
4132
- };
4133
- }
4134
- }
4135
- }
4136
- // The allowlist is the mechanical selection this tick's token authorized:
4137
- // every item's issue number AND routing must match an entry the launch
4138
- // offer produced from the authoritative tracker snapshot. Parked,
4139
- // parent/epic, already-groomed, in-flight, held and dispatched candidates
4140
- // were never on it, so no item carrying one can be stamped in-flight here
4141
- // — the model cannot self-select past the conductor's selection (#805).
4142
- const allowlist = session.launchItems;
4143
- if (allowlist === undefined) {
4144
- return {
4145
- block: true,
4146
- reason:
4147
- "task refused: this tick's launch did not carry a mechanically selected batch — " +
4148
- "re-tick before launching.",
4149
- };
4150
- }
4151
- for (const item of items) {
4152
- if (!allowlist.some((allowed) => allowed.issue === item.issue && allowed.routing === item.routing)) {
4153
- const listed = allowlist.map((allowed) => `#${allowed.issue} (${allowed.routing})`).join(", ");
4154
- return {
4155
- block: true,
4156
- reason:
4157
- `task refused: #${item.issue} is not on this tick's mechanically selected to-spec batch ` +
4158
- `(${listed}). Launch exactly the selected candidates, no substitutes.`,
4159
- };
4160
- }
4161
- }
4162
- const projectName = session.launchProject;
4163
- if (projectName === undefined) {
4164
- return {
4165
- block: true,
4166
- reason: "task refused: the batch was authorized without a project — re-tick before launching.",
4167
- };
4168
- }
4169
- // Store-side eligibility, the same rule the prompt block's exclusion list
4170
- // came from. Eligible items then become durable in-flight rows, so a
4171
- // crashed batch still suppresses re-launch until the TTL expires.
4172
- let store: Store | undefined;
4173
- try {
4174
- store = openStore(dbPath());
4175
- const byIssue = new Map(
4176
- store.groomingVerdicts(projectName).map((row) => [row.issue, row] as const),
4177
- );
4178
- const active = new Set(store.activeRuns(projectName).map((run) => run.issue));
4179
- const now = Date.now();
4180
- for (const item of items) {
4181
- const reason = toSpecCandidateExclusion(
4182
- { issue: item.issue },
4183
- { grooming: byIssue.get(item.issue), active: active.has(item.issue) },
4184
- now,
4185
- );
4186
- if (reason !== undefined) {
4187
- return {
4188
- block: true,
4189
- reason: `task refused: ${reason}. Drop that item (and every other listed exclusion) from this batch and re-call.`,
4190
- };
4191
- }
4192
- }
4193
- const launchedEvidence = JSON.stringify({
4194
- kind: "to-spec-in-flight",
4195
- launchedAt: now,
4196
- batch: token,
4197
- agent: TO_SPEC_AGENT,
4198
- });
4199
- for (const item of items) {
4200
- store.upsertGrooming({
4201
- project: projectName,
4202
- issue: item.issue,
4203
- verdict: "blocked",
4204
- reason: TO_SPEC_IN_FLIGHT_REASON,
4205
- evidence: launchedEvidence,
4206
- at: now,
4207
- });
4208
- }
4209
- } catch (err) {
4210
- pi.logger.error(
4211
- `[omp-conductor] to-spec launch not recorded: ${err instanceof Error ? err.message : String(err)}`,
4212
- );
4213
- return {
4214
- block: true,
4215
- reason: `task refused: the launch could not be recorded durably (${
4216
- err instanceof Error ? err.message : String(err)
4217
- }); nothing was started.`,
4218
- };
4219
- } finally {
4220
- store?.close();
4221
- }
4222
- const stampedTasks = tasks.map((raw) => ({
4223
- ...(raw as Record<string, unknown>),
4224
- agent: TO_SPEC_AGENT,
4225
- outputSchema: TO_SPEC_SCHEMA,
4226
- schemaMode: "strict",
4227
- }));
4228
- session.launchedBatch = { toolCallId: event.toolCallId, items };
4229
- pi.logger.info(
4230
- `[omp-conductor] to-spec batch launched: ${items.map((item) => `#${item.issue}`).join(", ")} (${token})`,
4231
- );
4232
- return { input: { ...input, tasks: stampedTasks } };
4233
- });
4234
-
4235
- api.on("tool_result", (event) => {
4236
- if (event.toolName !== "task") return;
4237
- const batch = session.launchedBatch;
4238
- if (batch === undefined || event.toolCallId !== batch.toolCallId) return;
4239
- const projectName = session.launchProject;
4240
- if (projectName === undefined) return;
4241
- const details = event.details as { results?: unknown } | undefined;
4242
- const results = details?.results;
4243
- if (!Array.isArray(results) || results.length === 0) {
4244
- // Background launch: the settled items arrive later through
4245
- // TO_SPEC_RESULT_TOOL; the in-flight rows keep them out of any new batch
4246
- // until then.
4247
- return;
4248
- }
4249
- let store: Store | undefined;
4250
- try {
4251
- store = openStore(dbPath());
4252
- for (const entry of results) {
4253
- const settled = entry as ToSpecSettledItem;
4254
- const item = batch.items[typeof settled.index === "number" ? settled.index : -1];
4255
- if (item === undefined) continue;
4256
- // The raw output is the contract input — exactly as returned, even
4257
- // when the item failed: the strict parser turns anything unparseable
4258
- // into a blocked row, and a failed sibling never touches the others.
4259
- const raw = typeof settled.output === "string" ? settled.output : "";
4260
- const structured = settled.structuredOutput;
4261
- const fallback =
4262
- structured !== null &&
4263
- typeof structured === "object" &&
4264
- typeof (structured as { data?: unknown }).data === "object"
4265
- ? JSON.stringify((structured as { data?: unknown }).data)
4266
- : "";
4267
- try {
4268
- recordToSpecGrooming(store, {
4269
- project: projectName,
4270
- issue: item.issue,
4271
- input: raw.trim().length > 0 ? raw : fallback,
4272
- });
4273
- } catch (err) {
4274
- pi.logger.error(
4275
- `[omp-conductor] to-spec result not persisted for #${item.issue}: ${
4276
- err instanceof Error ? err.message : String(err)
4277
- }`,
4278
- );
4279
- }
4280
- }
4281
- } catch (err) {
4282
- pi.logger.error(
4283
- `[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
4284
- );
4285
- } finally {
4286
- store?.close();
4287
- }
4288
- });
4289
- }
4290
-
4291
3478
  /**
4292
3479
  * Factory-time seams for the extension, used by tests. Production runs
4293
3480
  * `orchestratorTickExtension(pi)` with no options and gets the module's own
4294
3481
  * real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
4295
- * injected clock (`wait`/`now`) so the two timeout outcomes can be proven
4296
- * through the real tool deterministically, without real timers (#683).
3482
+ * injected clock (`now`) so the filed row's timestamp is deterministic. There
3483
+ * is no wait seam any more: the ask does not wait (#438 as re-cut for the
3484
+ * console split), so there is no timer for a test to control.
4297
3485
  */
4298
3486
  export interface OrchestratorTickExtensionOptions {
4299
3487
  ask?: {
4300
- wait?: (ms: number) => Promise<void>;
4301
3488
  now?: () => number;
4302
- /** Injected interactive delivery surface (#722); production builds its own. */
4303
- interactive?: AskInteractiveDelivery;
4304
3489
  };
4305
3490
  /**
4306
- * The authoritative tracker surface the low-queue to-spec launch reads
4307
- * (#777). Production omits it and the extension builds the real seam over
4308
- * the existing Tracker adapter for each project; tests inject deterministic
4309
- * fakes so the mechanical selection is provable without a network. A
4310
- * low-queue launch without a readable snapshot is refused entirely — the
4311
- * launch fails closed rather than trusting the model to self-filter.
3491
+ * The authoritative tracker surface the queue digest reads (#848).
3492
+ * Production omits it and the extension builds the real seam over the
3493
+ * existing Tracker adapter for each project; tests inject deterministic
3494
+ * fakes so the digest's live inventory is provable without a network. An
3495
+ * unreadable snapshot leaves the digest dated rather than claiming a
3496
+ * present-tense empty queue.
4312
3497
  */
4313
3498
  toSpec?: { tracker?: ToSpecTrackerSeam };
4314
3499
  }
@@ -4337,17 +3522,13 @@ export default function orchestratorTickExtension(
4337
3522
  let releaseGateArmed = false;
4338
3523
  let availabilityGateArmed = false;
4339
3524
  let guardArmed = false;
4340
- // #777: the to-spec launch gate is factory-time like the tick guard. It acts
4341
- // only when a low-queue tick has minted a launch token, so on any other
4342
- // session or any healthy queue it observes `task` calls without touching
4343
- // them.
4344
- armToSpecGate(pi, session);
4345
- // The authoritative tracker seam for the launch selection. Production runs
4346
- // without options and builds the real seam lazily over the existing Tracker
4347
- // adapter (one tracker per project read, per low-queue tick); tests inject a
4348
- // deterministic fake. The seam is the ONLY tracker surface the launch path
4349
- // touches — the tick never mutates tracker state and never lets the model
4350
- // self-select candidates.
3525
+ // The authoritative tracker seam the queue digest reads (#848): production
3526
+ // runs without options and builds the real seam lazily over the existing
3527
+ // Tracker adapter (one tracker read per low-queue tick); tests inject a
3528
+ // deterministic fake. Read-only — the tick never mutates tracker state.
3529
+ // Grooming itself no longer launches from here: the daemon owns the
3530
+ // selection and the launch (#1041), so there is no launch token to mint and
3531
+ // no `task` gate to arm (#1040).
4351
3532
  const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
4352
3533
  options.toSpec?.tracker ?? {
4353
3534
  listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
@@ -4486,57 +3667,23 @@ export default function orchestratorTickExtension(
4486
3667
  }
4487
3668
  return;
4488
3669
  }
4489
- // The reply to an arming challenge lands here as an ordinary user turn.
4490
- // This adapter not the model, and not any transcript scan — is the
4491
- // sole producer of the arming acknowledgement: it classifies the turn
4492
- // against persisted authenticated challenge state and, on a match,
4493
- // atomically records the challenge-id-specific acknowledgement the
4494
- // host-side `arm` waits on (conductor #614), all before normal model
4495
- // handling. The classification derives from the pending challenge (hash
4496
- // + expiry, keyed by this project), never from the `FLEET-` prefix, so
4497
- // an unsolicited lookalike that matches no active challenge stays inert
4498
- // and model behaviour cannot determine whether the host becomes armed.
4499
- // The turn itself still reaches the transcript exactly as sent, with the
4500
- // same trusted machine-readable steer as before (#415).
4501
- if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
4502
- const replyText =
4503
- message.content
4504
- ?.filter((part) => part.type === "text" && typeof part.text === "string")
4505
- .map((part) => part.text as string)
4506
- .join(" ") ?? "";
4507
- const verdict = classifyArmReply(configuredProject, replyText, Date.now());
4508
- const proof = verdict === "matched";
4509
- if (session.activeLocalTick !== undefined) {
4510
- session.activeLocalTick.humanWaiting = true;
4511
- if (proof) session.activeLocalTick.armingProof = true;
4512
- }
4513
- // Acknowledged whether or not a local tick is running: an inactivity-
4514
- // window challenge reply still deserves deterministic handling, and the
4515
- // host-side `arm` owns completion either way.
4516
- if (proof) {
4517
- pi.sendMessage(
4518
- { customType: ARM_PROOF_CUSTOM_TYPE, content: ARM_PROOF_ACK_TEXT, display: true, attribution: "agent" },
4519
- { triggerTurn: true, deliverAs: "steer" },
4520
- );
4521
- } else if (verdict !== "none") {
4522
- // A code that matched nothing used to be silent: the waiter kept
4523
- // waiting and the operator had no idea whether they had been heard,
4524
- // so the fastest way to be sure of the current code was to scroll
4525
- // (#991). The answer never echoes the token — that would put the
4526
- // plaintext in the chat and in any log that captures outbound
4527
- // messages, defeating the hash-only storage on purpose — and it never
4528
- // says whether some other project has a live challenge, because
4529
- // `classifyArmReply` reads only this session's own records.
4530
- pi.sendMessage(
4531
- {
4532
- customType: ARM_REPLY_CUSTOM_TYPE,
4533
- content: verdict === "expired" ? ARM_EXPIRED_REPLY_TEXT : ARM_UNKNOWN_REPLY_TEXT,
4534
- display: true,
4535
- attribution: "agent",
4536
- },
4537
- { triggerTurn: true, deliverAs: "steer" },
4538
- );
4539
- }
3670
+ // An inbound user turn on THIS session is now an anomaly, not a
3671
+ // conversation: operator DMs belong to the 24/7 console session, so a
3672
+ // turn arriving here is an accidental topic post. It is recorded for one
3673
+ // purpose only {@link tickGuardDecision}'s preemption, which lets a
3674
+ // human waiting on this pane cut a running tick short. Nothing is
3675
+ // classified: the arming ceremony is two CLI steps on the host
3676
+ // (`omp-conductor arm`, then `omp-conductor arm --reply "<message>"`),
3677
+ // because with the console owning DMs an arming reply lands where no tick
3678
+ // extension runs, and a classifier here would only ever have been dead
3679
+ // code waiting to misfire.
3680
+ if (
3681
+ message.role === "user" &&
3682
+ message.synthetic !== true &&
3683
+ message.attribution !== "agent" &&
3684
+ session.activeLocalTick !== undefined
3685
+ ) {
3686
+ session.activeLocalTick.humanWaiting = true;
4540
3687
  }
4541
3688
  });
4542
3689
  pi.on("agent_end", () => {
@@ -4560,7 +3707,7 @@ export default function orchestratorTickExtension(
4560
3707
  );
4561
3708
  };
4562
3709
 
4563
- // Mount the bounded ask surface (#438) at extension-factory time, not in
3710
+ // Mount the durable ask surface (#438) at extension-factory time, not in
4564
3711
  // `session_start`: OMP 17.2.9 snapshots the extension's active tool set
4565
3712
  // before it emits `session_start`, so a tool registered there mutates the
4566
3713
  // registry but never reaches this session's model-visible set. Registering
@@ -4575,13 +3722,10 @@ export default function orchestratorTickExtension(
4575
3722
  // surface is brought into the live set by {@link tick} — and only for as
4576
3723
  // long as the current tick config resolves a project.
4577
3724
  //
4578
- // The ceiling comes from the tick config the heartbeat started with the
4579
- // same startup-only contract `budgetSeconds` already hasand is capped at
4580
- // the turn budget by the tool itself, so no combination of config and
4581
- // arguments produces an unbounded wait. That session state is read lazily
4582
- // from {@link askSession}, filled by `session_start` only once ownership is
4583
- // accepted for a session that goes on to compose a tick; a call on any
4584
- // other session fails closed.
3725
+ // The session state the tools need the cwd and the startup config that
3726
+ // routes a call to a project — is read lazily from {@link askSession},
3727
+ // filled by `session_start` only once ownership is accepted for a session
3728
+ // that goes on to compose a tick; a call on any other session fails closed.
4585
3729
  //
4586
3730
  // The tool is inert unless the config resolves this fleet's project: it needs
4587
3731
  // the project to file the decision row against and to resolve the delivery
@@ -4598,7 +3742,7 @@ export default function orchestratorTickExtension(
4598
3742
  */
4599
3743
  const resolveOperatorAskContext = (
4600
3744
  tool: string,
4601
- ): { ok: true; project: ProjectConfig; config: TickConfig } | { ok: false; text: string } => {
3745
+ ): { ok: true; project: ProjectConfig } | { ok: false; text: string } => {
4602
3746
  const session = askSession;
4603
3747
  if (session === undefined) {
4604
3748
  return {
@@ -4615,7 +3759,7 @@ export default function orchestratorTickExtension(
4615
3759
  "nothing was asked or recorded. Repair the config, do not ask through another path.",
4616
3760
  };
4617
3761
  }
4618
- return { ok: true, project: routed.project, config: session.config };
3762
+ return { ok: true, project: routed.project };
4619
3763
  };
4620
3764
 
4621
3765
  const operatorAskDelivery =
@@ -4657,22 +3801,15 @@ export default function orchestratorTickExtension(
4657
3801
  label: ASK_TOOL,
4658
3802
  defaultInactive: true,
4659
3803
  description:
4660
- `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
4661
- `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
4662
- `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
4663
- `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
4664
- `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s — an ask issued without ` +
4665
- `one still gets the default). When the Telegram surface can, the question posts as selectable ` +
4666
- `buttons and a tap resolves the decision row with the chosen option; when it cannot, the ask ` +
4667
- `degrades to plain text and the row records the degraded delivery. "recommended" is required ` +
4668
- `whenever "on-timeout" is "auto-proceed" (the row must record what was auto-applied) and, when ` +
4669
- `"options" are supplied, must be one of their labels — the label as delivered, never an index. ` +
4670
- `When nobody answers, the declared "on-timeout" decides: ` +
4671
- `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
4672
- `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
4673
- `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
4674
- `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
4675
- `answered yet", never a cancellation, an error, or an operator "no".`,
3804
+ `Ask your operator one question. It does NOT wait for the answer: the question is recorded ` +
3805
+ `durably (a decision row + the same delivery path as \`omp-conductor message\`), delivered per the ` +
3806
+ `reporting policy, and the call returns immediately with the row id and the exact command that ` +
3807
+ `resolves it. Your operator answers in their console session this tick session is headless and no ` +
3808
+ `reply can land hereand an unanswered row stays open and pending, re-surfaced in every tick ` +
3809
+ `prompt, until it is answered, withdrawn, or swept by the seven-day decision expiry. When ` +
3810
+ `"options" are supplied, "recommended" must be one of their labels the label as delivered, never ` +
3811
+ `an index. Park the work the question blocks in the same turn you ask: out of the claimable queue, ` +
3812
+ `state recorded, row id in your report. Never treat your own recommendation as an approval.`,
4676
3813
  parameters: askParameterSchema(),
4677
3814
  approval: "write",
4678
3815
  execute: async (_toolCallId, params) => {
@@ -4682,29 +3819,22 @@ export default function orchestratorTickExtension(
4682
3819
  }
4683
3820
  // Routing follows the *live* tick config, not the session-start stamp: a
4684
3821
  // restamp (un-stamped → stamped, or project A → B) must make the next
4685
- // tick's toolbox land on the project the turn actually ticks for. Only the
4686
- // ceiling stays startup-only — routing is re-read every call.
3822
+ // tick's toolbox land on the project the turn actually ticks for.
4687
3823
  const context = resolveOperatorAskContext(ASK_TOOL);
4688
3824
  if (!context.ok) {
4689
3825
  return { content: [{ type: "text", text: context.text }], isError: true };
4690
3826
  }
4691
3827
  const projectConfig = context.project;
4692
- const config = context.config;
4693
3828
  const store = openStore(dbPath());
4694
3829
  let result: AskResult;
4695
3830
  try {
4696
3831
  result = await performAsk(parsed.request, {
4697
3832
  store,
4698
3833
  project: projectConfig.name,
4699
- configuredCeilingSeconds: config.askTimeoutSeconds,
4700
- turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
4701
- // Test seam (#683): production omits `wait`/`now` and `performAsk`
4702
- // falls back to the module's real-time defaults; a test that wants
4703
- // the timeout outcomes deterministically hands both in. The
4704
- // interactive surface (#722) is production's default; a test may
4705
- // inject a fake through the same seam.
4706
- interactive: options.ask?.interactive ?? interactiveAskSurface({ project: projectConfig, store }),
4707
- ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
3834
+ // Test seam: production omits `now` and `performAsk` falls back to
3835
+ // the module's real clock; a test hands one in so the filed row's
3836
+ // timestamp is deterministic.
3837
+ ...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
4708
3838
  deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
4709
3839
  });
4710
3840
  } finally {
@@ -4724,19 +3854,15 @@ export default function orchestratorTickExtension(
4724
3854
  label: QUESTIONNAIRE_TOOL,
4725
3855
  defaultInactive: true,
4726
3856
  description:
4727
- `Ask your operator several bounded questions about ONE issue as a single message, and wait up ` +
4728
- `to one ask ceiling for the answers. Every item is recorded as its own durable decision row ` +
4729
- `before anything is delivered, all bound to the issue you name in "spec-issue" — so the answers ` +
4730
- `become that issue's provenance and a later reader sees why a slice is shaped the way it is. ` +
4731
- `Items resolve independently and in any order: an item the operator answers keeps that answer, ` +
4732
- `and at the ceiling each unanswered item takes its own declared "on-timeout" ("auto-proceed" ` +
4733
- `applies its recommendation and records that nobody human chose it; "park" leaves the row open ` +
4734
- `and pending, and you then park the work it blocks). Ask ONLY the judgement calls that genuinely ` +
4735
- `belong to your operator anything a repo read can answer is your own work — and at most ` +
4736
- `${MAX_QUESTIONNAIRE_ITEMS} items. This path is plain text by design (one message, not ` +
4737
- `${MAX_QUESTIONNAIRE_ITEMS} button posts), so a prose reply does not itself resolve a row: map ` +
4738
- `it with \`omp-conductor decision resolve <id> --answer "…"\`. Use conductor_ask for a single ` +
4739
- `question.`,
3857
+ `Ask your operator several questions about ONE issue as a single message. Like conductor_ask it ` +
3858
+ `does NOT wait: every item is recorded as its own durable decision row before anything is ` +
3859
+ `delivered, all bound to the issue you name in "spec-issue" — so the answers become that issue's ` +
3860
+ `provenance and a later reader sees why a slice is shaped the way it is — and the call returns the ` +
3861
+ `group id, every row id, and the command that resolves each one. Your operator answers in their ` +
3862
+ `console session, item by item, in any order. Ask ONLY the judgement calls that genuinely belong ` +
3863
+ `to your operator anything a repo read can answer is your own work and at most ` +
3864
+ `${MAX_QUESTIONNAIRE_ITEMS} items. Park the work every item blocks in the same turn you ask, and ` +
3865
+ `name the group id in your report.`,
4740
3866
  parameters: questionnaireParameterSchema(),
4741
3867
  approval: "write",
4742
3868
  execute: async (_toolCallId, params) => {
@@ -4754,9 +3880,7 @@ export default function orchestratorTickExtension(
4754
3880
  const result = await performQuestionnaire(parsed.request, {
4755
3881
  store,
4756
3882
  project: projectConfig.name,
4757
- configuredCeilingSeconds: context.config.askTimeoutSeconds,
4758
- turnBudgetSeconds: context.config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
4759
- ...(options.ask === undefined ? {} : { wait: options.ask.wait, now: options.ask.now }),
3883
+ ...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
4760
3884
  deliver: operatorAskDelivery(QUESTIONNAIRE_TOOL, projectConfig, store),
4761
3885
  });
4762
3886
  return { content: [{ type: "text", text: result.text }] };
@@ -4766,114 +3890,6 @@ export default function orchestratorTickExtension(
4766
3890
  },
4767
3891
  });
4768
3892
 
4769
- // The async half of the to-spec result capture (#777). Registered at
4770
- // extension-factory time like {@link ASK_TOOL}, with the same routing
4771
- // contract: the state it needs (cwd + startup config) is filled by
4772
- // `session_start` for an accepted fleet session, and the live config is
4773
- // re-read at execution so a restamp binds the current project. The tool
4774
- // only ever writes the grooming table — never an issue, a label, or a
4775
- // dispatch row — so the output of a background batch persists while no
4776
- // candidate can become claimable from it.
4777
- pi.registerTool({
4778
- name: TO_SPEC_RESULT_TOOL,
4779
- label: TO_SPEC_RESULT_TOOL,
4780
- description:
4781
- `Persist the exact raw output of one completed to-spec grooming item (#777). ` +
4782
- `Call it once per completed batch item after the batch settles — in the tool ` +
4783
- `result, or when an async-result message delivers the item — passing the issue ` +
4784
- `number and the agent's EXACT raw output as \`input\` (read agent://<id> when the ` +
4785
- `inline text is truncated; never paraphrase). The conductor parses the output ` +
4786
- `against the strict to-spec contract and records the verdict durably: a valid ` +
4787
- `result persists its verdict, anything malformed, source-less or stale persists ` +
4788
- `as blocked, and a failing item never discards its siblings. One call per item, ` +
4789
- `success and failure alike; never edits an issue or a label.`,
4790
- parameters: {
4791
- type: "object",
4792
- properties: {
4793
- issue: { type: "integer", description: "The tracker issue number the item groomed." },
4794
- input: {
4795
- type: "string",
4796
- description: "The to-spec agent's exact raw output for this item.",
4797
- },
4798
- },
4799
- required: ["issue", "input"],
4800
- additionalProperties: false,
4801
- },
4802
- approval: "write",
4803
- execute: async (_toolCallId, params) => {
4804
- const fleet = askSession;
4805
- if (fleet === undefined) {
4806
- return {
4807
- content: [
4808
- {
4809
- type: "text",
4810
- text: `${TO_SPEC_RESULT_TOOL}: not available in this session (no orchestrator tick); nothing was persisted.`,
4811
- },
4812
- ],
4813
- isError: true,
4814
- };
4815
- }
4816
- const issue = params["issue"];
4817
- const input = params["input"];
4818
- if (typeof issue !== "number" || !Number.isInteger(issue) || typeof input !== "string" || input.length === 0) {
4819
- return {
4820
- content: [
4821
- { type: "text", text: `${TO_SPEC_RESULT_TOOL}: expected an integer \`issue\` and a non-empty \`input\` string.` },
4822
- ],
4823
- isError: true,
4824
- };
4825
- }
4826
- const routed = resolveAskProject(fleet.cwd, fleet.config);
4827
- if (routed.kind === "error") {
4828
- return {
4829
- content: [
4830
- {
4831
- type: "text",
4832
- text:
4833
- `${TO_SPEC_RESULT_TOOL}: conductor config unreadable (${routed.problem}); nothing was persisted. ` +
4834
- "Keep the outputs in the transcript and persist after the config is repaired.",
4835
- },
4836
- ],
4837
- isError: true,
4838
- };
4839
- }
4840
- const store = openStore(dbPath());
4841
- try {
4842
- const outcome = recordToSpecGrooming(store, {
4843
- project: routed.project.name,
4844
- issue,
4845
- input,
4846
- });
4847
- const record = outcome.record;
4848
- const kept = outcome.kind === "kept-prior" ? " (kept the prior valid verdict)" : "";
4849
- return {
4850
- content: [
4851
- {
4852
- type: "text",
4853
- text:
4854
- `${TO_SPEC_RESULT_TOOL}: #${issue} persisted as ${record.verdict} (${record.reason})${kept}. ` +
4855
- "The grooming table now decides re-grooming; promotion stays yours.",
4856
- },
4857
- ],
4858
- };
4859
- } catch (err) {
4860
- return {
4861
- content: [
4862
- {
4863
- type: "text",
4864
- text: `${TO_SPEC_RESULT_TOOL}: could not persist #${issue}: ${
4865
- err instanceof Error ? err.message : String(err)
4866
- } — keep the output and retry the call.`,
4867
- },
4868
- ],
4869
- isError: true,
4870
- };
4871
- } finally {
4872
- store.close();
4873
- }
4874
- },
4875
- });
4876
-
4877
3893
  pi.on("session_start", (_event, ctx) => {
4878
3894
  if (decided) return;
4879
3895
 
@@ -5035,272 +4051,3 @@ export default function orchestratorTickExtension(
5035
4051
  );
5036
4052
  });
5037
4053
  }
5038
-
5039
- /** One error's message, for a reason string — never a stack. */
5040
- function errText(err: unknown): string {
5041
- return err instanceof Error ? err.message : String(err);
5042
- }
5043
-
5044
- /**
5045
- * Write one prompt-protocol file the way omp-telegram's `atomicJson` does:
5046
- * temp file in the same directory, then rename. The bridge reads these files
5047
- * on a hot path (every tap), so a half-written request must never be visible.
5048
- */
5049
- function atomicallyWriteJson(path: string, value: unknown): void {
5050
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
5051
- const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
5052
- writeFileSync(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
5053
- renameSync(tmp, path);
5054
- }
5055
-
5056
- /** Remove one prompt file; a missing file is not an error. */
5057
- function removeFile(path: string): void {
5058
- try {
5059
- rmSync(path, { force: true });
5060
- } catch {
5061
- // best effort — a leftover prompt request dies with its owner process
5062
- }
5063
- }
5064
-
5065
- /**
5066
- * The interactive Telegram surface for one bounded ask (#722).
5067
- *
5068
- * `telegram_ask` posts its options as a Bot API inline keyboard whose taps the
5069
- * running omp-telegram bridge acknowledges and answers through a documented
5070
- * cross-process file protocol (guide.md: "prompts/ — Cross-process
5071
- * selectable-question requests (live while their owning session is) and their
5072
- * answers"): the asking process writes `<state>/prompts/<nonce>.json`, the
5073
- * bridge validates the tap against that request (responder, chat, topic,
5074
- * message id, owner-pid liveness) and writes `<nonce>.answer.json`; the asking
5075
- * process reads the envelope and settles. `interactiveAskSurface` is that
5076
- * asking-process half, nothing more: it posts the question with the same
5077
- * `qa:<nonce>:s:<index>` callbacks `prompts.ts` routes, writes the request file
5078
- * so the bridge recognizes the taps, and translates the envelope the bridge
5079
- * writes into the decision row — the resolution is the chosen option's *label*,
5080
- * never an index and never free text.
5081
- *
5082
- * The decision id doubles as the protocol nonce: it fits `[A-Za-z0-9_-]`, the
5083
- * bridge's callback regex, and makes the pending question and its answer
5084
- * addressable by the row that records them.
5085
- */
5086
- export function interactiveAskSurface(deps: {
5087
- project: ProjectConfig;
5088
- store: Store;
5089
- /** Injected Bot API transport (tests); production posts to api.telegram.org. */
5090
- call?: (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>>;
5091
- /** Injected state dir (tests); production resolves it like the token. */
5092
- stateDir?: string;
5093
- /** Injected bot token (tests); production reads the state dir's .env. */
5094
- token?: string;
5095
- now?: () => number;
5096
- }): AskInteractiveDelivery {
5097
- const surfaceStateDir = deps.stateDir ?? telegramStateDir();
5098
- const call = deps.call ?? telegramCall(deps.token ?? readTelegramToken() ?? "");
5099
- const now = deps.now ?? Date.now;
5100
- // Where each pending question physically sits, so collect/close can settle
5101
- // the right message without re-reading the request file.
5102
- const posted = new Map<string, { chatId: string; messageId: number; settled: boolean }>();
5103
-
5104
- const promptsDir = (): string => join(surfaceStateDir, "prompts");
5105
- const requestPath = (nonce: string): string => join(promptsDir(), `${nonce}.json`);
5106
- const answerPath = (nonce: string): string => join(promptsDir(), `${nonce}.answer.json`);
5107
-
5108
- const ownerId = (): string | undefined => {
5109
- let raw: string;
5110
- try {
5111
- raw = readFileSync(join(surfaceStateDir, "access.json"), "utf8");
5112
- } catch {
5113
- return undefined;
5114
- }
5115
- let access: { allowFrom?: unknown };
5116
- try {
5117
- access = JSON.parse(raw) as { allowFrom?: unknown };
5118
- } catch {
5119
- return undefined;
5120
- }
5121
- const allowFrom = access.allowFrom;
5122
- if (!Array.isArray(allowFrom) || allowFrom.length !== 1 || typeof allowFrom[0] !== "string") {
5123
- return undefined;
5124
- }
5125
- return allowFrom[0];
5126
- };
5127
-
5128
- const tokenAvailable = (): boolean =>
5129
- deps.call !== undefined || deps.token !== undefined || readTelegramToken() !== undefined;
5130
-
5131
- return {
5132
- unavailableReason(request) {
5133
- const chat = deps.project.escalation.telegramChatId;
5134
- if (chat === undefined || chat === "") {
5135
- return "no escalation.telegramChatId configured for this project";
5136
- }
5137
- if (!tokenAvailable()) {
5138
- return "no Telegram bot token readable (install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR)";
5139
- }
5140
- if (ownerId() === undefined) {
5141
- return "no paired Telegram owner (omp-telegram access.json must name exactly one allowed user)";
5142
- }
5143
- // The interactive post is still a delivery under the reporting policy: a
5144
- // question the policy would hold for the digest or the availability
5145
- // window must not bypass that hold just because it has buttons.
5146
- const disposition = interruptDisposition(
5147
- deps.project.reporting,
5148
- request.category ?? "decision-needed",
5149
- now(),
5150
- );
5151
- if (disposition !== "interrupt") {
5152
- return `the question defers under the reporting policy (${disposition}) — it must be held, not posted`;
5153
- }
5154
- return undefined;
5155
- },
5156
-
5157
- async post(request, decisionId) {
5158
- const chat = deps.project.escalation.telegramChatId;
5159
- const owner = ownerId();
5160
- if (chat === undefined || owner === undefined) {
5161
- return {
5162
- ok: false,
5163
- reason: "the interactive surface is not configured (no escalation.telegramChatId or no paired owner)",
5164
- };
5165
- }
5166
- const render = renderInteractiveAsk(request, decisionId);
5167
- const threadId = resolveProjectTopicId(deps.project);
5168
- let result: Record<string, unknown>;
5169
- try {
5170
- result = await call("sendMessage", {
5171
- chat_id: chat,
5172
- ...(threadId === undefined ? {} : { message_thread_id: threadId }),
5173
- text: render.text,
5174
- reply_markup: render.markup,
5175
- });
5176
- } catch (err) {
5177
- return { ok: false, reason: `Telegram rejected the interactive question: ${errText(err)}` };
5178
- }
5179
- const messageId = result["message_id"];
5180
- const chatType =
5181
- typeof result["chat"] === "object" && result["chat"] !== null
5182
- ? String((result["chat"] as Record<string, unknown>)["type"] ?? "private")
5183
- : "private";
5184
- if (typeof messageId !== "number" || !Number.isSafeInteger(messageId)) {
5185
- // The question went out, but unaddressable — take it back rather than
5186
- // leaving a button row nothing can settle.
5187
- await call("deleteMessage", { chat_id: chat, message_id: messageId as number }).catch(
5188
- () => undefined,
5189
- );
5190
- return { ok: false, reason: "Telegram posted no usable message id" };
5191
- }
5192
- const recommended =
5193
- request.recommended === undefined
5194
- ? undefined
5195
- : (request.options ?? []).findIndex((option) => option.label === request.recommended);
5196
- const requestFile = {
5197
- version: 1,
5198
- nonce: decisionId,
5199
- responderId: owner,
5200
- chatId: chat,
5201
- chatType,
5202
- threadId,
5203
- page: 0,
5204
- messageId,
5205
- questions: [
5206
- {
5207
- id: "q1",
5208
- question: request.question,
5209
- options: request.options ?? [],
5210
- ...(recommended === undefined || recommended < 0 ? {} : { recommended }),
5211
- },
5212
- ],
5213
- questionIndex: 0,
5214
- answers: [],
5215
- selectedIndices: [],
5216
- awaitingText: (request.options ?? []).length === 0,
5217
- ownerPid: process.pid,
5218
- };
5219
- try {
5220
- atomicallyWriteJson(requestPath(decisionId), requestFile as unknown);
5221
- } catch (err) {
5222
- // The buttons went out but nothing would ever answer them — take the
5223
- // message back rather than leave a dead keyboard in the chat.
5224
- await call("deleteMessage", { chat_id: chat, message_id: messageId }).catch(() => undefined);
5225
- return { ok: false, reason: `could not register the interactive question: ${errText(err)}` };
5226
- }
5227
- posted.set(decisionId, { chatId: chat, messageId, settled: false });
5228
- return { ok: true };
5229
- },
5230
-
5231
- collect(decisionId) {
5232
- const state = posted.get(decisionId);
5233
- if (state === undefined) return;
5234
- let raw: string | undefined;
5235
- try {
5236
- raw = readFileSync(answerPath(decisionId), "utf8");
5237
- } catch {
5238
- return; // no answer yet
5239
- }
5240
- let parsed: AskAnswerEnvelope | undefined;
5241
- try {
5242
- parsed = parseAskAnswerEnvelope(JSON.parse(raw) as unknown);
5243
- } catch {
5244
- parsed = undefined;
5245
- }
5246
- if (parsed === undefined) return; // an unreadable envelope is "no answer yet"
5247
- const write = askAnswerRowWrite(parsed);
5248
- if (write === undefined) return; // expiry/abort: the bounded wait still owns the row
5249
- deps.store.resolveDecision(decisionId, write.state, write.resolution, now());
5250
- removeFile(requestPath(decisionId));
5251
- removeFile(answerPath(decisionId));
5252
- state.settled = true;
5253
- const outcomeText =
5254
- write.state === "withdrawn"
5255
- ? write.resolution
5256
- : `User selected: ${write.resolution}`;
5257
- // The bridge edits its own prompts' messages; this surface owns the edit
5258
- // for its own, so an answered ask reads as answered and a stale tap
5259
- // finds no keyboard.
5260
- call("editMessageText", {
5261
- chat_id: state.chatId,
5262
- message_id: state.messageId,
5263
- text: outcomeText,
5264
- reply_markup: { inline_keyboard: [] },
5265
- }).catch(() => undefined);
5266
- },
5267
-
5268
- close(decisionId) {
5269
- const state = posted.get(decisionId);
5270
- removeFile(requestPath(decisionId));
5271
- removeFile(answerPath(decisionId));
5272
- if (state !== undefined && !state.settled) {
5273
- call("editMessageReplyMarkup", {
5274
- chat_id: state.chatId,
5275
- message_id: state.messageId,
5276
- reply_markup: { inline_keyboard: [] },
5277
- }).catch(() => undefined);
5278
- state.settled = true;
5279
- }
5280
- },
5281
- };
5282
- }
5283
-
5284
- /** The Bot API transport: one JSON POST per call, the `result` back. */
5285
- function telegramCall(
5286
- token: string,
5287
- ): (method: string, payload: Record<string, unknown>) => Promise<Record<string, unknown>> {
5288
- return async (method, payload) => {
5289
- if (token === "") throw new Error("no Telegram bot token");
5290
- const url = `https://api.telegram.org/bot${token}/${method}`;
5291
- const res = await fetch(url, {
5292
- method: "POST",
5293
- headers: { "content-type": "application/json" },
5294
- body: JSON.stringify(payload),
5295
- });
5296
- const raw = await res.text();
5297
- if (!res.ok) {
5298
- throw new Error(`telegram ${method} failed: HTTP ${res.status} ${raw.slice(0, 200)}`);
5299
- }
5300
- const parsed = JSON.parse(raw) as { ok?: unknown; result?: unknown };
5301
- if (parsed.ok !== true) {
5302
- throw new Error(`telegram ${method} rejected: ${raw.slice(0, 200)}`);
5303
- }
5304
- return (parsed.result as Record<string, unknown>) ?? {};
5305
- };
5306
- }