omp-conductor 0.19.7 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) 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/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
@@ -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,39 @@ 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";
135
+ import {
136
+ clearArmTransaction,
137
+ expiredArmChallenges,
138
+ FLEET_ARM_KEY,
139
+ resolveArmReply,
140
+ type ArmChallengeSighting,
141
+ type ArmReplyMatch,
142
+ } from "./arm-challenge.ts";
143
+ import {
144
+ readTelegramChannel,
145
+ readTelegramToken,
146
+ resolveProjectTopicId,
147
+ sendTelegram,
148
+ } from "./escalate.ts";
147
149
 
148
150
  /** The activation file. Absent means "this is not an orchestrator session". */
149
151
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -151,47 +153,6 @@ export const TICK_CONFIG_FILE = ".conductor-tick.json";
151
153
  /** Namespaced so a renderer or a session-log reader can pick ticks out. */
152
154
  export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
153
155
 
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
156
  /**
196
157
  * A tick costs a whole turn of a frontier model, and the orchestrator's loop is
197
158
  * about minutes of latency, not seconds. Anything under a minute is a
@@ -245,22 +206,6 @@ export const STALL_TICKS = 2;
245
206
  const DEFAULT_TICK_BUDGET_SECONDS = 600;
246
207
  /** Grace before a queued operator message preempts the turn's tool calls. */
247
208
  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
209
  /**
265
210
  * Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
266
211
  * dispatch daemon when a watched decision condition transitions false→true
@@ -481,15 +426,6 @@ export interface TickConfig {
481
426
  * before the next tick).
482
427
  */
483
428
  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
429
  /**
494
430
  * The herdr agent name this fleet's orchestrator pane is registered under, and
495
431
  * the whole of {@link resolveTickOwnership}'s identity test under herdr.
@@ -567,6 +503,10 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
567
503
  "rerun-checks": true,
568
504
  settle: true,
569
505
  escalate: false,
506
+ // An observation row never stamps `recoveredAt` (the recovery IS the later
507
+ // sweep pass), so it never lands in this digest; `false` is the honest value
508
+ // if one ever did — it is not "already handled", it is waiting (#1068).
509
+ observe: false,
570
510
  hold: false,
571
511
  none: false,
572
512
  };
@@ -718,10 +658,10 @@ export interface QueueObservation {
718
658
  * line tells claimable candidates apart from a runway that cannot move —
719
659
  * instead of inviting Duty 2 to groom work whose last pass held it, which is
720
660
  * 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.
661
+ * (#777, written by the daemon's grooming launcher since #1041) carries the
662
+ * same `blocked` verdict as its durable in-flight marker, so it is told apart
663
+ * from the mechanical holds: it says "a batch is running", not "the lane
664
+ * cannot move", and counts neither as claimable nor as known-blocked.
725
665
  *
726
666
  * `queue` is the tracker observation the caller made THIS tick (#848). When
727
667
  * present, the queue verdict reads in the present tense from that inventory,
@@ -737,9 +677,9 @@ export function queueDigestLine(
737
677
  grooming: readonly GroomingRecord[] = [],
738
678
  queue: QueueObservation | undefined = undefined,
739
679
  /** 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). */
680
+ * describes — the same clock the daemon's selection judges them against, so
681
+ * the inventory and the next grooming pass cannot disagree about what is
682
+ * still groomed (#887). */
743
683
  now: number = Date.now(),
744
684
  ): string | undefined {
745
685
  if (summary === undefined) return undefined;
@@ -823,8 +763,8 @@ function liveQueueDigestLine(
823
763
  if (queued === 0) {
824
764
  return (
825
765
  `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."
766
+ "Groom the backlog (Duty 2): the daemon runs the to-spec passes and promotes what its ready gate " +
767
+ "passes, so file or sharpen the next issues, or say in this tick's report why there is nothing to do."
828
768
  );
829
769
  }
830
770
  // The grooming threshold is claimability — summary.routed — never raw
@@ -884,8 +824,8 @@ function lowQueueTail(
884
824
  groomBelow: GroomTrigger,
885
825
  grooming: readonly GroomingRecord[],
886
826
  lead: string,
887
- /** The clock the durability of each verdict is judged against — the same
888
- * one the batch offer uses (#887). */
827
+ /** The clock the durability of each verdict is judged against — the same one
828
+ * the daemon's selection uses (#887). */
889
829
  now: number,
890
830
  ): string {
891
831
  // The durable per-issue verdicts, not this pass's one-shot hold groups: a
@@ -910,20 +850,23 @@ function lowQueueTail(
910
850
  `(${groomingGroupCounts(knownBlocked)})${busy} — no grooming moves them; the holds clear by themselves` +
911
851
  `${inFlight.length === 0 ? "" : " and the to-spec batch's results land when it settles"}.`;
912
852
  } else if (knownBlocked.length > 0 || inFlight.length > 0) {
853
+ // Who grooms is the daemon's business now (#1041): the tail reports which
854
+ // candidates the pipeline can actually move, and stops instructing a
855
+ // session that no longer holds the launch.
913
856
  tail =
914
857
  groomBelow === "always"
915
- ? `${lead}grooming runs every tick ("always") — ${summary.routed} routable candidate(s): ` +
858
+ ? `${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s): ` +
916
859
  `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
917
- `${busy} — groom the claimable while any remain ungroomed.`
860
+ `${busy} — only the claimable are groomable.`
918
861
  : `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}: ` +
919
862
  `${claimable} claimable, ${knownBlocked.length} known-blocked (${groomingGroupCounts(knownBlocked)})` +
920
- `${busy} — groom only the claimable.`;
863
+ `${busy} — only the claimable are groomable.`;
921
864
  } else if (groomBelow === "always") {
922
865
  // No numeric trigger exists to be "below", so the tail states the actual
923
866
  // condition and what remains ungroomed (#988) instead of inventing one.
924
867
  tail =
925
- `${lead}grooming runs every tick ("always") — ${summary.routed} routable candidate(s); ` +
926
- "groom while ungroomed, unrefused, unparked candidates remain.";
868
+ `${lead}grooming runs every pass ("always") — ${summary.routed} routable candidate(s); ` +
869
+ "the daemon keeps grooming while ungroomed, unrefused, unparked candidates remain.";
927
870
  } else {
928
871
  tail = `${lead}running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
929
872
  }
@@ -939,7 +882,7 @@ function lowQueueTail(
939
882
  ` Backlog already-considered: ${considered.length} (${groomingVerdictCounts(considered)}) — ` +
940
883
  `${durable.length} still durable (never re-groom these), ${regroomable} re-groomable ` +
941
884
  "(no readable to-spec source, or observed past the freshness ceiling). " +
942
- "Promote the promotable or groom new issues.";
885
+ "The daemon promotes what its ready gate passes; audit those promotions and sharpen what it rejected.";
943
886
  }
944
887
  if (summary.admitted === 0 && summary.holds.length > 0) {
945
888
  const held = summary.holds
@@ -953,657 +896,115 @@ function lowQueueTail(
953
896
  return tail;
954
897
  }
955
898
 
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.
964
- //
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.
899
+ // ============================================================ promotion audit
900
+ // (#1041/#1040). #777 gave the tick a per-tick capability token: a launch block
901
+ // naming the mechanically selected batch, a `task` tool-call gate that redeemed
902
+ // exactly one batch, and promotion as an orchestrator act. That made grooming
903
+ // tick-bound one batch per low-queue tick, and an operator message that
904
+ // consumed the tick consumed the batch (#1040) so the whole mechanism is
905
+ // gone. The daemon watches depth, launches its own to-spec sessions, and
906
+ // promotes a PROMOTABLE verdict whose spec passes the pure ready gate.
991
907
  //
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
- }
908
+ // What the tick owes Duty 2 instead is review, and review needs exactly two
909
+ // facts the store already holds: what the daemon queued unattended since the
910
+ // last beat, and which specs its gate refused. Neither is derivable from
911
+ // session memory, and both must arrive with the command that acts on them —
912
+ // an audit line an operator cannot act on is a line that gets skipped.
1289
913
 
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
- }
914
+ /** How many promotions and gate rejections one audit block names. A tick's
915
+ * prompt is a budget, and an unbounded burst of promotions must not push the
916
+ * duties off the end of it; the count always states the full total. */
917
+ const PROMOTION_AUDIT_LIMIT = 6;
1310
918
 
1311
919
  /**
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`:
920
+ * The Duty-2 promotion audit: what the daemon promoted on its own since the
921
+ * previous tick, each with the one command that undoes it, and the specs its
922
+ * ready gate refused, each with what the gate found missing.
1353
923
  *
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.
924
+ * `undefined` when there is nothing to say — the common state of a fleet whose
925
+ * queue is deep enough that no grooming pass ran. A block that reported "0
926
+ * promotions" every beat would be permanent noise, the same convention
927
+ * {@link queueDigestLine} and {@link formatPendingIntake} keep.
1363
928
  *
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;
1372
- 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.
929
+ * The demote command is spelled from the real verb surface rather than
930
+ * invented: `conductor_label` (verbs/protocol.ts) takes the full issue URL,
931
+ * the label, `action`, and a `reason` from {@link LABEL_REASONS} — `needs-human`
932
+ * is the member that means "parked: it needs a decision only a human can
933
+ * make", which is exactly what an overruled auto-promotion is.
1456
934
  */
1457
- export function toSpecLaunchBlock(input: {
1458
- summary: DispatchSummary | undefined;
1459
- groomBelow: GroomTrigger;
935
+ export function formatPromotionAudit(input: {
936
+ /** Grooming rows promoted since {@link since}, as `Store.promotionsSince`
937
+ * returns them (newest or oldest first — the block does not reorder, so the
938
+ * store's order is the audit's order). */
939
+ promotions: readonly GroomingRecord[];
940
+ /** Every current grooming row, from which the gate rejections are read. The
941
+ * full set rather than a window: a spec the gate refused three ticks ago is
942
+ * still unqueued work, and dropping it would let a rejected spec go quiet. */
1460
943
  grooming: readonly GroomingRecord[];
1461
- active: readonly { issue: number }[];
944
+ project: string;
1462
945
  tracker: string;
1463
946
  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;
947
+ /** The lower bound the promotions were read from, named so the orchestrator
948
+ * can tell "nothing happened" from "a narrow window". */
949
+ since: number;
1582
950
  }): 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;
951
+ const rejected = input.grooming.flatMap((row) => {
952
+ const rejection = parseReadyGateRejection(row.evidence);
953
+ return rejection === undefined ? [] : [{ row, rejection }];
954
+ });
955
+ if (input.promotions.length === 0 && rejected.length === 0) return undefined;
956
+ const lines: string[] = [
957
+ `Promotion audit (Duty 2) ${input.promotions.length} auto-promotion(s) since ` +
958
+ `${new Date(input.since).toISOString()}, ${rejected.length} spec(s) the ready gate refused:`,
959
+ ];
960
+ for (const row of input.promotions.slice(0, PROMOTION_AUDIT_LIMIT)) {
961
+ const at = row.promotedAt === undefined ? "unknown time" : new Date(row.promotedAt).toISOString();
962
+ const by = row.promotedBy ?? "unrecorded";
963
+ // The gate that passed is named from the provenance the promoter stored,
964
+ // never guessed: the audit exists to review what the daemon did
965
+ // unattended, and a guessed attribution would make that review circular.
966
+ const gate =
967
+ by === "daemon"
968
+ ? "ready gate passed"
969
+ : by === "orchestrator"
970
+ ? "promoted by an earlier tick"
971
+ : "promoted by an operator";
972
+ lines.push(
973
+ `- #${row.issue} — verdict ${row.verdict} (${row.reason}); ${gate}; queued ${at} by ${by}. ` +
974
+ `Demote: omp-conductor verb conductor_label --project ${input.project} ` +
975
+ `--arg issueUrl=https://github.com/${input.tracker}/issues/${row.issue} ` +
976
+ `--arg label=${input.queueLabel} --arg action=remove --arg reason=needs-human ` +
977
+ `--arg rationale="<why this spec is not ready>"`,
978
+ );
979
+ }
980
+ if (input.promotions.length > PROMOTION_AUDIT_LIMIT) {
981
+ lines.push(`- … ${input.promotions.length - PROMOTION_AUDIT_LIMIT} further promotion(s) not shown.`);
982
+ }
983
+ if (rejected.length > 0) {
984
+ lines.push(
985
+ "Gate-rejected specs — never queued, and the pass that produced them is already spent, so the " +
986
+ "issue itself is what has to change. These repeat every tick until they are fixed, which is why " +
987
+ "each carries the date its gate ran:",
988
+ );
989
+ // Oldest rejection first: a spec refused days ago and never edited is the
990
+ // one the audit keeps re-printing, so it leads rather than sinking under
991
+ // whatever the last pass produced.
992
+ const ordered = [...rejected].sort((a, b) => a.rejection.checkedAt - b.rejection.checkedAt);
993
+ for (const { row, rejection } of ordered.slice(0, PROMOTION_AUDIT_LIMIT)) {
994
+ const checked =
995
+ rejection.checkedAt === 0 ? "unknown date" : new Date(rejection.checkedAt).toISOString();
996
+ lines.push(`- #${row.issue} (gate ran ${checked}) missing: ${rejection.missing.join("; ")}`);
997
+ }
998
+ if (rejected.length > PROMOTION_AUDIT_LIMIT) {
999
+ lines.push(`- … ${rejected.length - PROMOTION_AUDIT_LIMIT} further rejected spec(s) not shown.`);
1598
1000
  }
1599
1001
  }
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."
1002
+ lines.push(
1003
+ "Duty 2 is now three things and no launch: propose net-new scope through a decision row, audit the " +
1004
+ "promotions above (demote what you disagree with, using the command on its line), and edit the " +
1005
+ "gate-rejected issues until the missing fields are there the daemon re-runs the gate by itself.",
1606
1006
  );
1007
+ return lines.join("\n");
1607
1008
  }
1608
1009
 
1609
1010
  export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
@@ -1616,6 +1017,19 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string
1616
1017
  "Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
1617
1018
  };
1618
1019
 
1020
+ /**
1021
+ * Collapses one durable row's prose to a single bounded prompt line.
1022
+ *
1023
+ * Shared by every appended block rather than re-declared inside each: they all
1024
+ * render operator- or model-authored text into a prompt where one pathological
1025
+ * row must not push the duties out of the window, and three private copies of
1026
+ * the same four lines is how one of them ends up unbounded.
1027
+ */
1028
+ function flattened(text: string, limit: number): string {
1029
+ const flat = text.replace(/\s+/g, " ").trim();
1030
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
1031
+ }
1032
+
1619
1033
  /** Bounded, durable source material for one due digest. Row ids are part of the
1620
1034
  * handoff contract: the report and exactly the rows it consumed settle in one
1621
1035
  * SQLite transaction, so a crash cannot lose an outcome between those writes. */
@@ -1624,21 +1038,17 @@ export function formatDigestLedger(
1624
1038
  notices: readonly HeldNotice[],
1625
1039
  backlog: DigestBacklog,
1626
1040
  ): 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
1041
  const lines = [
1632
1042
  `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held notice(s); oldest first:`,
1633
1043
  ...events.map(
1634
1044
  (event) =>
1635
1045
  ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
1636
- `${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
1046
+ `${flattened(event.summary, 180)} | evidence: ${flattened(event.evidence, 240)}`,
1637
1047
  ),
1638
1048
  ...notices.map(
1639
1049
  (notice) =>
1640
1050
  ` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
1641
- `${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
1051
+ `${flattened(notice.summary, 180)} | detail: ${flattened(notice.detail, 240)}`,
1642
1052
  ),
1643
1053
  ];
1644
1054
  if (backlog.materialCount > events.length) {
@@ -1658,6 +1068,113 @@ export function formatDigestLedger(
1658
1068
  return lines.join("\n");
1659
1069
  }
1660
1070
 
1071
+ /**
1072
+ * Reserved `material_events` category for a mechanical amendment the
1073
+ * orchestrator applied on its own authority (Phase 4 — the floor's
1074
+ * mechanical-vs-judgment split).
1075
+ *
1076
+ * There is no amendments table, and neither other durable candidate can carry
1077
+ * this audit honestly. `POLICY.md`'s own `## Amendments` section is written by
1078
+ * step 4 of the floor's *judgment* protocol only — a mechanical fix skips
1079
+ * straight to applying and reporting, so it never lands there — and that
1080
+ * section's line shape (date, trigger, one-sentence summary) has nowhere to put
1081
+ * a revert command. `friction_rollups` counts admission holds and report
1082
+ * classifications and knows nothing about policy edits. `material_events` is
1083
+ * the append-only outbox the floor already uses for every outcome that is not
1084
+ * sent as an interrupt, its category is a free lowercase slug (`event record`
1085
+ * validates `^[a-z0-9][a-z0-9-]{0,31}$`), and both its text columns are
1086
+ * mandatory — `--summary` 1-240 characters, `--evidence` 1-500 — which makes
1087
+ * `evidence` exactly the right home for a one-line revert. So the audit is a
1088
+ * category convention over the ledger that already exists, and needs no schema
1089
+ * change. Note where the cap bites: a revert longer than 500 characters is
1090
+ * refused by `event record` at write time rather than truncated by the renderer
1091
+ * below, so an over-long one is a revert to shorten, never a silently clipped
1092
+ * command an operator would paste.
1093
+ *
1094
+ * The consequence to keep in view: this surface holds what the orchestrator
1095
+ * wrote to it and nothing else. An amendment applied without the ledger write
1096
+ * is invisible here, which is why the floor's mechanical bullet and the
1097
+ * friction digest both name the exact command rather than leaving the shape to
1098
+ * be guessed.
1099
+ */
1100
+ export const AMENDMENT_EVENT_CATEGORY = "amendment";
1101
+
1102
+ /** Amendment rows rendered in one audit. A window with more self-applied policy
1103
+ * edits than this is a fleet problem the prompt cannot fix by listing them. */
1104
+ const AMENDMENT_AUDIT_LIMIT = 5;
1105
+
1106
+ /**
1107
+ * How deep into the outbox the audit reads.
1108
+ *
1109
+ * Deliberately far past {@link DIGEST_BACKLOG_LIMIT}, which bounds one digest's
1110
+ * worth of source material: an audit inheriting that bound would hide a
1111
+ * self-applied policy edit behind twenty merge events, and that row is the one
1112
+ * that must never go unseen.
1113
+ */
1114
+ export const AMENDMENT_AUDIT_SCAN_LIMIT = 200;
1115
+
1116
+ /**
1117
+ * The one sentence in the tick prompt that states the amendment cap.
1118
+ *
1119
+ * A constant, and used in exactly one rendered block, because the cap is the
1120
+ * clause the floor just re-scoped: it counts *judgment* proposals, and a
1121
+ * mechanical drift fix is not a proposal at all. A second copy of this sentence
1122
+ * anywhere in the prompt is how the old undifferentiated wording comes back —
1123
+ * the audit block below therefore states the duty and never restates the cap.
1124
+ */
1125
+ export const AMENDMENT_THROTTLE_LINE =
1126
+ "The cap is at most one new orchestrator-originated judgment amendment proposal per autonomous tick, and it " +
1127
+ "counts judgment proposals only — duties, boundaries, release policy, caps, reporting scope. A mechanical " +
1128
+ "drift fix is not a proposal and is not throttled: apply it, report it with its revert, and apply the next one.";
1129
+
1130
+ /**
1131
+ * Mechanical amendments the orchestrator applied without asking, read back to
1132
+ * it before they reach its operator (Phase 4).
1133
+ *
1134
+ * The floor's mechanical branch trades an ask for a promise — apply now, then
1135
+ * say in the next report what changed, why, and how to revert it. This block is
1136
+ * what makes that promise auditable by something other than session memory,
1137
+ * which is exactly the memory the whole ledger discipline exists to distrust.
1138
+ *
1139
+ * The window is "recorded and not yet carried into a delivered digest", not
1140
+ * "since the previous tick". That is the honest predicate rather than the
1141
+ * approximate one: the tick has no durable record of its own previous beat, and
1142
+ * an interval-width window silently drops the audit when a turn runs long or a
1143
+ * digest fails — the two occasions where an unreviewed policy edit is most
1144
+ * likely. Undigested is also self-clearing: composing the report retires the
1145
+ * row, so a reported amendment stops appearing without anyone marking it.
1146
+ *
1147
+ * On a due-digest tick this deliberately overlaps {@link formatDigestLedger},
1148
+ * which lists the same row for a different purpose — the ledger hands over row
1149
+ * ids to consume, the audit hands over a policy edit to re-justify or revert.
1150
+ * Suppressing one because the other rendered would mean a self-applied
1151
+ * amendment goes unreviewed precisely on the tick that ships it.
1152
+ */
1153
+ export function formatAmendmentAudit(events: readonly MaterialEvent[]): string | undefined {
1154
+ const amendments = events.filter((event) => event.category === AMENDMENT_EVENT_CATEGORY);
1155
+ if (amendments.length === 0) return undefined;
1156
+ const shown = amendments.slice(0, AMENDMENT_AUDIT_LIMIT);
1157
+ const lines = [
1158
+ `Mechanical amendments you applied without asking — ${amendments.length}, not yet carried into a report. ` +
1159
+ "Each one is a POLICY.md edit made on your own authority, so the audit is yours before it is your operator's:",
1160
+ ...shown.map(
1161
+ (event) =>
1162
+ `- ${new Date(event.occurredAt).toISOString()} | ${flattened(event.summary, 180)} | ` +
1163
+ `revert: ${flattened(event.evidence, 240)}`,
1164
+ ),
1165
+ ];
1166
+ if (amendments.length > shown.length) {
1167
+ lines.push(`- … ${amendments.length - shown.length} further amendment(s) not shown.`);
1168
+ }
1169
+ lines.push(
1170
+ "Re-check each against the source that made it mechanical — the file, config key or ledger row that proves " +
1171
+ "the value. Still true: carry the line into your next report exactly as it reads here, revert command " +
1172
+ "included. No longer true, or a source you can no longer state: run the revert now, and propose the change " +
1173
+ "as a judgment amendment if it is still worth making.",
1174
+ );
1175
+ return lines.join("\n");
1176
+ }
1177
+
1661
1178
  /**
1662
1179
  * The reporting constraint appended to a default tick prompt (#229, #242).
1663
1180
  *
@@ -1779,7 +1296,11 @@ export { TELEGRAM_APPROVAL_TOOL };
1779
1296
  *
1780
1297
  * The last clause is the actual hazard #114 exposed: a turn that knows
1781
1298
  * it must ask, and cannot, is one inference away from recording an approval
1782
- * nobody gave.
1299
+ * nobody gave. Phase 4 narrows its noun to a *judgment* amendment, and only its
1300
+ * noun: a mechanical drift fix is applied without an ask by design, so an
1301
+ * undifferentiated "never apply an amendment without an answer" here would
1302
+ * contradict the floor on the branch that has no ask to be missing. The hazard
1303
+ * this rule guards — recording an unanswered yes/no as approved — is unchanged.
1783
1304
  *
1784
1305
  * Unlike {@link TICK_DELIVERY_RULE} this is appended to a configured `message`
1785
1306
  * too. An operator's prompt owns the reporting contract and is theirs to get
@@ -1788,29 +1309,35 @@ export { TELEGRAM_APPROVAL_TOOL };
1788
1309
  */
1789
1310
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
1790
1311
  `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>"\` — ` +
1312
+ `If you have a judgment amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
1792
1313
  `it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
1793
1314
  `same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
1794
1315
  `or the seven-day expiry, never recorded as approved. ` +
1795
1316
  `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
1796
1317
  `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.`;
1318
+ `Never apply a judgment amendment, or record one as approved, without an explicit answer you actually received.`;
1798
1319
 
1799
1320
  /**
1800
1321
  * 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).
1322
+ * the durable ask surface is the mechanical contract of a local tick (#438).
1802
1323
  *
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.
1324
+ * No ask waits any more. This session is the headless tick brain: operator
1325
+ * turns land in the 24/7 console session, so an answer physically cannot
1326
+ * arrive here, and a duty cycle that waited would stop the fleet for nothing —
1327
+ * on 2026-08-16 that was six stopped hours. So the rule states the whole
1328
+ * contract up front: the question becomes a durable row, the console resolves
1329
+ * it later, and parking the blocked work is part of the same turn rather than
1330
+ * something the model discovers when a wait expires.
1808
1331
  */
1809
1332
  export const TICK_ASK_RULE =
1810
1333
  `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, ` +
1334
+ `call it with the question, its options and your recommendation. It does NOT wait nothing here does. ` +
1335
+ `The question is filed as a durable decision row and delivered, and the answer arrives later, when your ` +
1336
+ `operator resolves that row from the console session (\`omp-conductor decision resolve <id> --answer "..."\`). ` +
1337
+ `So in the SAME turn you ask, park the work the question blocks: take it out of the claimable queue, record ` +
1338
+ `its state, and name the row id in your report. Never wait for a reply, never re-ask on the next tick, and ` +
1339
+ `never proceed as though your recommendation had been approved. ` +
1340
+ `Several judgement calls about ONE issue go as a single ${QUESTIONNAIRE_TOOL} instead: one delivery, ` +
1814
1341
  `each item a durable row bound to that issue, resolved independently and in any order. ` +
1815
1342
  `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
1816
1343
  `takes, and an unanswered question must never hold the loop.`;
@@ -1824,8 +1351,8 @@ export const TICK_ASK_RULE =
1824
1351
  export const ASK_DIVERSION_REASON =
1825
1352
  `Blocked: on this locally injected tick, ${TELEGRAM_APPROVAL_TOOL} would wait for your operator for as long as ` +
1826
1353
  `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.`;
1354
+ `by this call. Ask instead with ${ASK_TOOL} it records the question as a durable decision row, delivers it, ` +
1355
+ `and returns immediately with the row id and the command your operator's console resolves it with.`;
1829
1356
 
1830
1357
  /**
1831
1358
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -1866,7 +1393,17 @@ function frictionLabel(kind: FrictionSignal["kind"]): string {
1866
1393
  return "tick reports classified as surprising";
1867
1394
  }
1868
1395
 
1869
- /** Bounded evidence for the existing approval protocol — never an automatic edit. */
1396
+ /**
1397
+ * Bounded evidence for the existing approval protocol — never an automatic edit.
1398
+ *
1399
+ * The closing instruction is the one place the tick prompt routes into the
1400
+ * Learning loop, so it is also where the floor's mechanical-vs-judgment split
1401
+ * has to be visible (Phase 4): the gate sentence is unchanged — friction with no
1402
+ * safe POLICY.md remedy still leaves policy alone — and the two branches under
1403
+ * it say which kind of amendment the remedy is, and what a mechanical one owes
1404
+ * the ledger. It carries {@link AMENDMENT_THROTTLE_LINE} and is the only block
1405
+ * that does.
1406
+ */
1870
1407
  export function formatFrictionDigest(signals: readonly FrictionSignal[]): string {
1871
1408
  const shown = signals.slice(0, FRICTION_DIGEST_LIMIT);
1872
1409
  const lines = [
@@ -1884,12 +1421,24 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
1884
1421
  if (signals.length > shown.length) lines.push(`- ${signals.length - shown.length} more signal(s) deferred`);
1885
1422
  lines.push(
1886
1423
  "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.",
1424
+ "When there is a remedy, route it by kind. A drift fact you can verify against the repo, the config or " +
1425
+ "this fleet's own ledger — a renamed label, a moved path, a changed schedule, a caps figure the config " +
1426
+ "already carries — is a MECHANICAL amendment: edit POLICY.md now, then record it with " +
1427
+ '`omp-conductor event record --category ' +
1428
+ AMENDMENT_EVENT_CATEGORY +
1429
+ ' --summary "<what changed and why>" --evidence "<the one-line revert>"`, which is what puts it in the ' +
1430
+ "next tick's amendment audit and in your next report. A remedy that is a choice about how the fleet " +
1431
+ "should behave is a JUDGMENT amendment and goes through the proposal protocol unchanged. " +
1432
+ AMENDMENT_THROTTLE_LINE,
1887
1433
  );
1888
1434
  return lines.join("\n");
1889
1435
  }
1890
1436
 
1891
1437
  /** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
1892
1438
  const INTAKE_TEXT_LIMIT = 160;
1439
+ /** Bounds a mined item's provenance key beside its text. Shorter than the text
1440
+ * because a source is an identifier, not prose — a longer one is malformed. */
1441
+ const INTAKE_SOURCE_LIMIT = 80;
1893
1442
 
1894
1443
  /**
1895
1444
  * Pending intake items, each waiting to become an issue (#300).
@@ -1903,19 +1452,31 @@ const INTAKE_TEXT_LIMIT = 160;
1903
1452
  * "0 pending" every tick would be permanent noise — the same convention as
1904
1453
  * {@link queueDigestLine}. Project-specific grooming taste (priority scales,
1905
1454
  * template wording) stays in POLICY.md; this block carries only the floor duty.
1455
+ *
1456
+ * Provenance is rendered per item because the two kinds are not the same work
1457
+ * (Phase 4 signal mining). An item with no `source` is an idea a person typed:
1458
+ * it is scope, and grooming it means writing down what they asked for. A mined
1459
+ * item is a machine's inference from settlement flags, failure classes or
1460
+ * friction — evidence to check against the code before anything is filed, and
1461
+ * the one kind that can be honestly dismissed on the orchestrator's own
1462
+ * judgement when the evidence does not hold up. Rendering them
1463
+ * indistinguishably invited exactly one error: filing a mined guess as though
1464
+ * an operator had asked for it.
1906
1465
  */
1907
1466
  export function formatPendingIntake(
1908
1467
  items: readonly IntakeItem[],
1909
1468
  instruction: { tracker: string; queueLabel: string; labelPrefix: string },
1910
1469
  ): string | undefined {
1911
1470
  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
- };
1471
+ // Bounded like the text: a mined source is a key, and a pathological one must
1472
+ // not push the grooming instruction out of the prompt window.
1473
+ const provenance = (item: IntakeItem): string =>
1474
+ item.source === undefined
1475
+ ? "your operator's own idea"
1476
+ : `mined signal ${flattened(item.source, INTAKE_SOURCE_LIMIT)}`;
1916
1477
  const lines = [
1917
1478
  `Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
1918
- ...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
1479
+ ...items.map((item) => `- ${item.id} (${provenance(item)}) — ${flattened(item.text, INTAKE_TEXT_LIMIT)}`),
1919
1480
  "",
1920
1481
  `Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
1921
1482
  `carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
@@ -1927,6 +1488,12 @@ export function formatPendingIntake(
1927
1488
  'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
1928
1489
  "the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
1929
1490
  "<id>` and noted in the digest rather than filed.",
1491
+ "The two provenances are groomed differently. Your operator's own idea is scope: file what they asked " +
1492
+ "for, and never dismiss it on your own judgement — if it looks wrong, that is a question for them. A " +
1493
+ "mined signal is evidence, not scope: open what its source names, confirm from the code that the " +
1494
+ "problem is real, and file the issue against what you actually find — its wording is a machine's " +
1495
+ "inference and yours to correct. A mined signal the evidence does not support is dismissed, with the " +
1496
+ "reason in the digest.",
1930
1497
  ];
1931
1498
  return lines.join("\n");
1932
1499
  }
@@ -2089,6 +1656,20 @@ export function resolveArmState(armedFile: string, projectName?: string): ArmSta
2089
1656
  return shared ? { armed: true } : { armed: true, legacy: "honoured" };
2090
1657
  }
2091
1658
 
1659
+ /**
1660
+ * The one armed-marker write both settlement paths share — the CLI's
1661
+ * `arm --reply` half and the orchestrator session's mechanical reply consumer
1662
+ * (#1061): same content, same mode, and the same restamp of the pre-per-project
1663
+ * shared marker the heartbeat still honours (#316). It lives here, beside
1664
+ * {@link resolveArmState}, so the session consumer can write the gate it reads
1665
+ * — the reverse import (into `fleet.ts`) would cycle.
1666
+ */
1667
+ export function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
1668
+ mkdirSync(dirname(path), { recursive: true });
1669
+ writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
1670
+ if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
1671
+ }
1672
+
2092
1673
  /**
2093
1674
  * Best-effort recompose of `ORCHESTRATOR.md` from the installed package floor +
2094
1675
  * live `POLICY.md`.
@@ -2165,18 +1746,11 @@ export function readTickConfig(cwd: string): TickConfigResult {
2165
1746
  ? budgetRaw
2166
1747
  : undefined;
2167
1748
 
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;
1749
+ // No ask ceiling is read from the config any more: the ask does not wait at
1750
+ // all (#438 as re-cut for the console split), so a stale `askTimeoutSeconds`
1751
+ // on a deployed fleet's `.conductor-tick.json` is simply an unknown key
1752
+ // ignored like every other one, so an old file on disk can never fail a
1753
+ // restart.
2180
1754
 
2181
1755
  // Relative paths resolve against the session cwd, so the files can sit beside
2182
1756
  // the config that names them (`state/armed`) without hard-coding a deploy path.
@@ -2222,7 +1796,6 @@ export function readTickConfig(cwd: string): TickConfigResult {
2222
1796
  intervalSeconds,
2223
1797
  ...(project === undefined ? {} : { project }),
2224
1798
  ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
2225
- ...(askTimeoutSeconds === undefined ? {} : { askTimeoutSeconds }),
2226
1799
  ...(armedFile === undefined ? {} : { armedFile }),
2227
1800
  ...(accessFile === undefined ? {} : { accessFile }),
2228
1801
  ...(message === undefined ? {} : { message }),
@@ -2856,12 +2429,6 @@ interface PendingLocalTick {
2856
2429
  interface ActiveLocalTick extends PendingLocalTick {
2857
2430
  /** A person who writes during an autonomous run is awake by construction. */
2858
2431
  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
2432
  }
2866
2433
 
2867
2434
  interface TelegramInterrupt {
@@ -3231,29 +2798,6 @@ interface TickSession {
3231
2798
  legacyArmLogged: boolean;
3232
2799
  /** The local tick whose agent loop is currently running, if any. */
3233
2800
  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
2801
  }
3258
2802
 
3259
2803
  /**
@@ -3342,14 +2886,8 @@ async function tick(
3342
2886
  config: TickConfig,
3343
2887
  session: TickSession,
3344
2888
  toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
2889
+ armSeam: ArmConsumerSeam | undefined,
3345
2890
  ): 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
2891
  const live = currentConfig(ctx.cwd, config);
3354
2892
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
3355
2893
  if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
@@ -3358,6 +2896,29 @@ async function tick(
3358
2896
  `[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
3359
2897
  );
3360
2898
  }
2899
+ // An unanswered ceremony that passed its window is a dead ceremony, not a
2900
+ // silent one (#1061): the tick that notices tells the operator why and how
2901
+ // to re-run it, on the surface the challenge was sent to. Deliberately
2902
+ // before the tick gate — a disarmed fleet is exactly the state an
2903
+ // unanswered challenge leaves, and its death notice must not wait for an
2904
+ // arm to be heard. The scan is synchronous so an idle host (no expired
2905
+ // records) costs the tick nothing; only a send is awaited.
2906
+ const armNow = (armSeam?.now ?? Date.now)();
2907
+ for (const expired of expiredArmChallenges(live.project, armNow)) {
2908
+ const surface: ArmNoticeSurface = { accessFile: live.accessFile, project: live.project };
2909
+ const send = armSeam?.notify ?? sendArmNotice;
2910
+ try {
2911
+ await send(armExpiryNotice(expired, live.project), surface);
2912
+ } catch (err) {
2913
+ // Keep the record: the next heartbeat retries, and `doctor` still
2914
+ // reports it.
2915
+ pi.logger.error(
2916
+ `[omp-conductor] arm expiry notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
2917
+ );
2918
+ continue;
2919
+ }
2920
+ clearArmTransaction(expired.key, expired.id);
2921
+ }
3361
2922
  const decision = tickDecision({
3362
2923
  armed: arm === undefined || arm.armed,
3363
2924
  channelOk: live.accessFile === undefined || channelIsUp(live.accessFile),
@@ -3563,6 +3124,17 @@ async function tick(
3563
3124
  now - FRICTION_COOLDOWN_MS,
3564
3125
  );
3565
3126
  if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
3127
+ // Policy edits the session made on its own authority, read back before
3128
+ // they reach the operator (Phase 4). Placed directly after the friction
3129
+ // digest and before the decision rows because that is the order of the
3130
+ // Learning loop itself: the evidence that provokes an amendment, then
3131
+ // the mechanical ones already applied and owed a report, then the
3132
+ // judgment ones still waiting on an answer. The scan reaches past one
3133
+ // digest's worth of backlog on purpose — see AMENDMENT_AUDIT_SCAN_LIMIT.
3134
+ const amendments = formatAmendmentAudit(
3135
+ frictionStore.undigestedMaterialEvents(scope.projectName, AMENDMENT_AUDIT_SCAN_LIMIT),
3136
+ );
3137
+ if (amendments !== undefined) content = `${content}\n${amendments}`;
3566
3138
  // What the session still owes its operator, read from the ledger rather
3567
3139
  // than from what it remembers asking (#136). Appended every tick,
3568
3140
  // because the whole failure was a question surviving in context only.
@@ -3609,16 +3181,13 @@ async function tick(
3609
3181
  const grooming = store.groomingVerdicts(projectName);
3610
3182
  // #848: "the queue is empty / running dry" is the tracker's word, not
3611
3183
  // 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.
3184
+ // read live through the authoritative tracker surface, overlaid with
3185
+ // the label_ops projection exactly as the dispatch pass judges
3186
+ // eligibility so a promotion, unblock or projection write after the
3187
+ // last dispatch pass is visible on the very next tick even while a
3188
+ // drain holds claiming. If the tracker read fails, the digest falls
3189
+ // back to explicitly dated wording, never a present-tense empty
3190
+ // claim.
3622
3191
  //
3623
3192
  // The read is made only for dispatch rows the digest could render:
3624
3193
  // a healthy row (routed at/above a numeric grooming threshold)
@@ -3627,16 +3196,14 @@ async function tick(
3627
3196
  // below the trigger) is exactly where the old wording lied, so every
3628
3197
  // such tick reads the live queue. Under `groomBelow: "always"`
3629
3198
  // (#988) the duty never clears on volume, so every dispatch row
3630
- // reads the queue and the selectors judge candidate state.
3199
+ // reads the queue.
3631
3200
  const needsQueueRead =
3632
3201
  dispatch !== undefined &&
3633
3202
  (dispatch.ready === 0 || dispatch.routed === 0 || groomingDue(dispatch.routed, groomBelow));
3634
3203
  let queueObservation: QueueObservation | undefined;
3635
- let openSnapshot: readonly ReadyIssue[] | undefined;
3636
3204
  if (toSpecTrackerSeam !== undefined && needsQueueRead) {
3637
3205
  try {
3638
3206
  const open = await toSpecTrackerSeam.listOpenIssues(project);
3639
- openSnapshot = open;
3640
3207
  const effective = open.map((issue) => {
3641
3208
  const pending = store.pendingLabelOpsFor(projectName, issue.number);
3642
3209
  return pending.length === 0
@@ -3659,57 +3226,29 @@ async function tick(
3659
3226
  groomBelow,
3660
3227
  grooming,
3661
3228
  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
3229
+ // One clock for the inventory and the audit below: the digest must
3230
+ // never call a verdict durable that the daemon's own selection is
3664
3231
  // about to re-groom (#887).
3665
3232
  now,
3666
3233
  );
3667
3234
  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,
3235
+ // The Duty-2 audit that replaced #777's launch block (#1041/#1040).
3236
+ // The daemon promotes unattended, so what the tick owes is review,
3237
+ // and the window is two intervals wide for the same reason the
3238
+ // recovery digest's is: a tick that ran long must not drop the
3239
+ // promotions it was meant to audit. A tick that reports nothing
3240
+ // renders nothing the store, not this call site, decides whether
3241
+ // there is anything to say.
3242
+ const auditSince = now - 2 * config.intervalSeconds * 1_000;
3243
+ const audit = formatPromotionAudit({
3244
+ promotions: store.promotionsSince(projectName, auditSince),
3683
3245
  grooming,
3684
- active: toSpecActive,
3685
- issues: openSnapshot,
3686
- project,
3687
- trackerSeam: toSpecTrackerSeam,
3688
- now,
3246
+ project: project.name,
3247
+ tracker: project.tracker.repo,
3248
+ queueLabel: project.queueLabel,
3249
+ since: auditSince,
3689
3250
  });
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
- }
3251
+ if (audit !== undefined) content = `${content}\n${audit}`;
3713
3252
  // Pending intake is the same class of standing block as the friction
3714
3253
  // and decisions read-outs: a store-backed duty the orchestrator must
3715
3254
  // not derive from memory. The store answers, the prompt instructs.
@@ -3720,7 +3259,8 @@ async function tick(
3720
3259
  });
3721
3260
  if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
3722
3261
  } catch {
3723
- // unreadable config: no queue digest, launch block, or pending-intake block this tick
3262
+ // unreadable config: no queue digest, promotion audit, or
3263
+ // pending-intake block this tick
3724
3264
  }
3725
3265
  } catch (err) {
3726
3266
  frictionStore?.close();
@@ -3887,11 +3427,12 @@ function armTickHeartbeat(
3887
3427
  config: TickConfig,
3888
3428
  session: TickSession,
3889
3429
  toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3430
+ armSeam: ArmConsumerSeam | undefined,
3890
3431
  ): void {
3891
3432
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
3892
3433
  const runScheduledTick = async (): Promise<void> => {
3893
3434
  try {
3894
- await tick(pi, ctx, config, session, toSpecTrackerSeam);
3435
+ await tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam);
3895
3436
  } finally {
3896
3437
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
3897
3438
  }
@@ -3947,7 +3488,7 @@ function armTickHeartbeat(
3947
3488
  // call runs inside the session_start handler dispatch, which cannot see an
3948
3489
  // async rejection — an escaped one reaches the process-level
3949
3490
  // unhandledRejection handler and takes the session down.
3950
- void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
3491
+ void tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam).catch((err) => {
3951
3492
  pi.logger.error(
3952
3493
  `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
3953
3494
  );
@@ -3992,325 +3533,229 @@ function armTickGuard(pi: TickApi, ctx: TickContext, budgetMs: number): void {
3992
3533
  }
3993
3534
 
3994
3535
  /**
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}.
3536
+ * Factory-time seams for the extension, used by tests. Production runs
3537
+ * `orchestratorTickExtension(pi)` with no options and gets the module's own
3538
+ * real-time defaults; the `ask` seam hands the registered {@link ASK_TOOL} an
3539
+ * injected clock (`now`) so the filed row's timestamp is deterministic. There
3540
+ * is no wait seam any more: the ask does not wait (#438 as re-cut for the
3541
+ * console split), so there is no timer for a test to control.
4003
3542
  */
4004
- interface ToSpecTaskToolEvent {
4005
- toolName: string;
4006
- toolCallId: string;
4007
- input: Record<string, unknown>;
4008
- details: unknown;
3543
+ export interface OrchestratorTickExtensionOptions {
3544
+ ask?: {
3545
+ now?: () => number;
3546
+ };
3547
+ /**
3548
+ * The authoritative tracker surface the queue digest reads (#848).
3549
+ * Production omits it and the extension builds the real seam over the
3550
+ * existing Tracker adapter for each project; tests inject deterministic
3551
+ * fakes so the digest's live inventory is provable without a network. An
3552
+ * unreadable snapshot leaves the digest dated rather than claiming a
3553
+ * present-tense empty queue.
3554
+ */
3555
+ toSpec?: { tracker?: ToSpecTrackerSeam };
3556
+ /**
3557
+ * The arm ceremony's mechanical consumer seams (#1061). Production omits
3558
+ * them and the extension classifies inbound turns against the pending
3559
+ * challenge records itself, sending confirmations and expiry notices over
3560
+ * the same bridge transport `armTicks` sends the challenge on. Tests inject
3561
+ * a recorder (`notify`) and a fixed clock (`now`) so the settle and the
3562
+ * notices are provable without Telegram or timers.
3563
+ */
3564
+ arm?: {
3565
+ now?: () => number;
3566
+ notify?: ArmNoticeFn;
3567
+ };
4009
3568
  }
4010
3569
 
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;
3570
+ /** The send surface an arm notice rides: the live tick config's channel and project. */
3571
+ export interface ArmNoticeSurface {
3572
+ accessFile?: string;
3573
+ project?: string;
4019
3574
  }
4020
3575
 
4021
3576
  /**
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}.
3577
+ * One mechanical Telegram send the arm consumer raises: the confirmation when
3578
+ * a reply settles the ceremony, or the expiry notice when a challenge dies
3579
+ * unanswered. The default is {@link sendArmNotice}; tests record instead.
4048
3580
  */
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
- };
3581
+ export type ArmNoticeFn = (text: string, surface: ArmNoticeSurface) => Promise<void> | void;
4060
3582
 
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;
3583
+ /** The consumer's injectable clock and transport, carried into the tick's helpers. */
3584
+ export interface ArmConsumerSeam {
3585
+ now?: () => number;
3586
+ notify?: ArmNoticeFn;
3587
+ }
3588
+
3589
+ /**
3590
+ * The plain text of an inbound message, for the arm classifier. Content blocks
3591
+ * join with newlines so a code split across block boundaries still tokenises
3592
+ * whole; non-text blocks contribute nothing. `undefined` for an absent or
3593
+ * empty message, which is ordinary chat either way.
3594
+ */
3595
+ function inboundMessageText(content: readonly { type?: string; text?: string }[] | undefined): string | undefined {
3596
+ if (content === undefined || content.length === 0) return undefined;
3597
+ const text = content.map((block) => block.text ?? "").join("\n");
3598
+ return text.length === 0 ? undefined : text;
3599
+ }
3600
+
3601
+ /**
3602
+ * The ready-to-send expiry notice: the reason — never answered, or answered
3603
+ * but never settled and the exact command that re-runs the ceremony. The
3604
+ * code is the proof and is never named in a notice; only the window is.
3605
+ */
3606
+ function armExpiryNotice(expired: { key: string; sighting: ArmChallengeSighting }, project: string | undefined): string {
3607
+ const reissue =
3608
+ expired.key === FLEET_ARM_KEY || project === undefined || project === ""
3609
+ ? "omp-conductor arm"
3610
+ : `omp-conductor arm --project ${project}`;
3611
+ const sentAt =
3612
+ expired.sighting.sentAt === undefined
3613
+ ? ""
3614
+ : ` sent ${new Date(expired.sighting.sentAt).toISOString()}`;
3615
+ const reason =
3616
+ expired.sighting.acknowledgedAt === undefined
3617
+ ? `The arming challenge${sentAt} expired without being answered — nothing is armed.`
3618
+ : `The arming challenge${sentAt} was answered but never settled before its window closed — nothing is armed.`;
3619
+ return `${reason} Send a fresh one: ${reissue}`;
3620
+ }
3621
+
3622
+ /**
3623
+ * The default transport for the arm consumer's confirmation and expiry
3624
+ * notices: the same bot token, paired channel and project topic `armTicks`
3625
+ * sends the challenge on, so the notice lands on the surface the operator was
3626
+ * asked in. Deliberately not availability-gated — the ceremony is the
3627
+ * operator's own interaction, exactly like the challenge send itself, and a
3628
+ * notice held for quiet hours would be the silent failure #1061 removes. Any
3629
+ * missing or down fact throws; the caller logs and keeps the challenge record
3630
+ * so the next heartbeat retries and `doctor` still reports it.
3631
+ */
3632
+ export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Promise<void> {
3633
+ if (surface.accessFile === undefined) {
3634
+ throw new Error("no accessFile — the arm notice has no channel");
3635
+ }
3636
+ const channel = readTelegramChannel(surface.accessFile);
3637
+ if (channel.kind === "down") {
3638
+ throw new Error(`escalation channel is not up (${surface.accessFile}): ${channel.reason}`);
3639
+ }
3640
+ const token = readTelegramToken();
3641
+ if (token === undefined) {
3642
+ throw new Error("no Telegram bot token readable the arm notice cannot send");
3643
+ }
3644
+ let topicId: number | undefined;
3645
+ if (surface.project !== undefined) {
4173
3646
  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();
3647
+ topicId = resolveProjectTopicId(findProject(loadConfig(), surface.project));
3648
+ } catch {
3649
+ /* no project config — flat chat, exactly like armTicks */
4221
3650
  }
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})`,
3651
+ }
3652
+ await sendTelegram(token, channel.owner, text, { topicId });
3653
+ }
3654
+
3655
+ /**
3656
+ * The settle half of the mechanical consumer: write the markers the matched
3657
+ * challenge recorded and clear its transaction, then confirm on the
3658
+ * challenge's surface. The recorded targets are the contract — the exact rule
3659
+ * `armReply` applies so nothing is ever armed that the challenge did not
3660
+ * name. A pre-targets record (one release of overlap) is not settled here:
3661
+ * nothing in-session may guess a marker the challenge never recorded; the CLI
3662
+ * half still settles it, and the operator is told so.
3663
+ */
3664
+ function settleInboundArmMatch(
3665
+ pi: TickApi,
3666
+ match: ArmReplyMatch,
3667
+ notify: (text: string) => void,
3668
+ ): void {
3669
+ if (match.targets === undefined || match.owner === undefined) {
3670
+ pi.logger.error(
3671
+ "[omp-conductor] an inbound arming code matched a pre-targets challenge — nothing armed in-session; " +
3672
+ 'run `omp-conductor arm --reply "<the reply>"` to settle it',
4231
3673
  );
4232
- return { input: { ...input, tasks: stampedTasks } };
4233
- });
3674
+ return;
3675
+ }
3676
+ for (const target of match.targets) {
3677
+ const state = resolveArmState(target.armedFile, target.project);
3678
+ writeArmedMarker(target.armedFile, match.owner, state);
3679
+ }
3680
+ clearArmTransaction(match.key, match.id);
3681
+ const label =
3682
+ match.targets.length === 1 && match.targets[0]!.project !== undefined
3683
+ ? `project ${match.targets[0]!.project}`
3684
+ : `${match.targets.length} project(s)`;
3685
+ notify(`Arming verified — ${label} armed. The reply was consumed; nothing else to do.`);
3686
+ }
4234
3687
 
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;
3688
+ /**
3689
+ * The arm ceremony's mechanical consumer (#1061): classify one inbound user
3690
+ * turn against this host's pending challenges and, on a match, settle the
3691
+ * ceremony exactly as `omp-conductor arm --reply` would — markers for the
3692
+ * recorded targets, transaction cleared, a confirmation on the surface the
3693
+ * challenge was sent to. This is the real owner the ceremony needed on a host
3694
+ * with no console session: the challenge goes to the project topic, which is
3695
+ * this pane's own claimed surface, so the reply lands here and the ceremony
3696
+ * completes with no second human command. A code past its window is answered
3697
+ * with the re-run command and the dead record is cleared; a lookalike matching
3698
+ * nothing stays inert (#415's non-disclosure bound is unchanged).
3699
+ *
3700
+ * Never throws: an inbound message must not take the session down. Failures
3701
+ * are logged and the CLI half of the ceremony stays available.
3702
+ */
3703
+ function consumeInboundArmReply(
3704
+ pi: TickApi,
3705
+ cwd: string,
3706
+ startupProject: string | undefined,
3707
+ text: string,
3708
+ seam: ArmConsumerSeam | undefined,
3709
+ ): void {
3710
+ // The state key the send half derived from the same file, read fresh so a
3711
+ // re-stamp between the send and the reply cannot split the ceremony. An
3712
+ // invalid config cannot be trusted to name either the key or a marker —
3713
+ // fail closed exactly like `armReply`, but without throwing at a message.
3714
+ const reread = readTickConfig(cwd);
3715
+ if (reread.kind === "invalid") {
3716
+ pi.logger.error(
3717
+ `[omp-conductor] inbound arm reply not classified — tick config invalid at ${reread.path}: ` +
3718
+ `${reread.problem}; use \`omp-conductor arm --reply\` on the host`,
3719
+ );
3720
+ return;
3721
+ }
3722
+ const project = reread.kind === "ok" ? reread.config.project : startupProject;
3723
+ const surface: ArmNoticeSurface = {
3724
+ ...(reread.kind === "ok" ? { accessFile: reread.config.accessFile } : {}),
3725
+ project,
3726
+ };
3727
+ const now = (seam?.now ?? Date.now)();
3728
+ const resolved = resolveArmReply(project, text, now);
3729
+ const notify = (notice: string): void => {
3730
+ const send = seam?.notify ?? sendArmNotice;
4250
3731
  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
- }
3732
+ // The send is async on the production path; message_start handlers are
3733
+ // not, so delivery rides the microtask queue and its failure lands in
3734
+ // the log, never on the session.
3735
+ void Promise.resolve(send(notice, surface)).catch((err: unknown) => {
3736
+ pi.logger.error(
3737
+ `[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
3738
+ );
3739
+ });
4281
3740
  } catch (err) {
4282
3741
  pi.logger.error(
4283
- `[omp-conductor] to-spec results not captured: ${err instanceof Error ? err.message : String(err)}`,
3742
+ `[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
4284
3743
  );
4285
- } finally {
4286
- store?.close();
4287
3744
  }
4288
- });
4289
- }
4290
-
4291
- /**
4292
- * Factory-time seams for the extension, used by tests. Production runs
4293
- * `orchestratorTickExtension(pi)` with no options and gets the module's own
4294
- * 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).
4297
- */
4298
- export interface OrchestratorTickExtensionOptions {
4299
- ask?: {
4300
- wait?: (ms: number) => Promise<void>;
4301
- now?: () => number;
4302
- /** Injected interactive delivery surface (#722); production builds its own. */
4303
- interactive?: AskInteractiveDelivery;
4304
3745
  };
4305
- /**
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.
4312
- */
4313
- toSpec?: { tracker?: ToSpecTrackerSeam };
3746
+ if (resolved.verdict === "matched") {
3747
+ settleInboundArmMatch(pi, resolved.match, notify);
3748
+ return;
3749
+ }
3750
+ // A code past its window: the ceremony is dead, so answer the person who is
3751
+ // clearly trying to complete it and clear the expired record so neither
3752
+ // this path nor the heartbeat ever notifies twice.
3753
+ if (resolved.verdict === "expired") {
3754
+ for (const expired of expiredArmChallenges(project, now)) {
3755
+ notify(armExpiryNotice(expired, project));
3756
+ clearArmTransaction(expired.key, expired.id);
3757
+ }
3758
+ }
4314
3759
  }
4315
3760
 
4316
3761
  export default function orchestratorTickExtension(
@@ -4337,17 +3782,13 @@ export default function orchestratorTickExtension(
4337
3782
  let releaseGateArmed = false;
4338
3783
  let availabilityGateArmed = false;
4339
3784
  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.
3785
+ // The authoritative tracker seam the queue digest reads (#848): production
3786
+ // runs without options and builds the real seam lazily over the existing
3787
+ // Tracker adapter (one tracker read per low-queue tick); tests inject a
3788
+ // deterministic fake. Read-only — the tick never mutates tracker state.
3789
+ // Grooming itself no longer launches from here: the daemon owns the
3790
+ // selection and the launch (#1041), so there is no launch token to mint and
3791
+ // no `task` gate to arm (#1040).
4351
3792
  const toSpecTrackerSeam: ToSpecTrackerSeam | undefined =
4352
3793
  options.toSpec?.tracker ?? {
4353
3794
  listOpenIssues: (project) => makeTracker(project).listOpenIssues(),
@@ -4453,8 +3894,10 @@ export default function orchestratorTickExtension(
4453
3894
 
4454
3895
  /** `configuredProject` carries {@link TickConfig.project} for the same reason
4455
3896
  * {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
4456
- * fleet's availability policy, not refuse to guess between two projects. */
4457
- const armAvailabilityGate = (configuredProject?: string): void => {
3897
+ * fleet's availability policy, not refuse to guess between two projects.
3898
+ * `cwd` is the fleet directory itself, which the arm consumer re-reads the
3899
+ * tick config from at reply time (#1061). */
3900
+ const armAvailabilityGate = (configuredProject?: string, cwd?: string): void => {
4458
3901
  if (availabilityGateArmed) return;
4459
3902
  availabilityGateArmed = true;
4460
3903
 
@@ -4486,56 +3929,32 @@ export default function orchestratorTickExtension(
4486
3929
  }
4487
3930
  return;
4488
3931
  }
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).
3932
+ // An inbound user turn on THIS session is an anomaly, not a
3933
+ // conversation: operator DMs belong to the 24/7 console session, so a
3934
+ // turn arriving here is an accidental topic post. Two things happen,
3935
+ // and both are mechanisms nothing here is left to model behaviour:
3936
+ //
3937
+ // 1. The arm ceremony's reply is classified against the pending
3938
+ // challenge records (#1061). The pre-#415 tombstone is retired: it
3939
+ // reasoned the reply lands "where no tick extension runs", but the
3940
+ // send half delivers the challenge to the project topic this
3941
+ // pane's own claimed surface so the reply lands exactly here, and
3942
+ // on a host with no console session the CLI's `arm --reply` step had
3943
+ // no owner at all. A matching code settles the ceremony
3944
+ // (markers written, transaction cleared) and answers with a
3945
+ // confirmation; a code past its window is answered with the re-run
3946
+ // command; a lookalike matching nothing stays inert, exactly as
3947
+ // #415 required.
3948
+ // 2. {@link tickGuardDecision}'s preemption, unchanged: a person who
3949
+ // posts into the fleet topic by accident is still a person waiting,
3950
+ // and the autonomous-interrupt refusal must not fire at them.
4501
3951
  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";
3952
+ if (cwd !== undefined) {
3953
+ const text = inboundMessageText(message.content);
3954
+ if (text !== undefined) consumeInboundArmReply(pi, cwd, configuredProject, text, options.arm);
3955
+ }
4509
3956
  if (session.activeLocalTick !== undefined) {
4510
3957
  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
3958
  }
4540
3959
  }
4541
3960
  });
@@ -4560,7 +3979,7 @@ export default function orchestratorTickExtension(
4560
3979
  );
4561
3980
  };
4562
3981
 
4563
- // Mount the bounded ask surface (#438) at extension-factory time, not in
3982
+ // Mount the durable ask surface (#438) at extension-factory time, not in
4564
3983
  // `session_start`: OMP 17.2.9 snapshots the extension's active tool set
4565
3984
  // before it emits `session_start`, so a tool registered there mutates the
4566
3985
  // registry but never reaches this session's model-visible set. Registering
@@ -4575,13 +3994,10 @@ export default function orchestratorTickExtension(
4575
3994
  // surface is brought into the live set by {@link tick} — and only for as
4576
3995
  // long as the current tick config resolves a project.
4577
3996
  //
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.
3997
+ // The session state the tools need the cwd and the startup config that
3998
+ // routes a call to a project — is read lazily from {@link askSession},
3999
+ // filled by `session_start` only once ownership is accepted for a session
4000
+ // that goes on to compose a tick; a call on any other session fails closed.
4585
4001
  //
4586
4002
  // The tool is inert unless the config resolves this fleet's project: it needs
4587
4003
  // the project to file the decision row against and to resolve the delivery
@@ -4598,7 +4014,7 @@ export default function orchestratorTickExtension(
4598
4014
  */
4599
4015
  const resolveOperatorAskContext = (
4600
4016
  tool: string,
4601
- ): { ok: true; project: ProjectConfig; config: TickConfig } | { ok: false; text: string } => {
4017
+ ): { ok: true; project: ProjectConfig } | { ok: false; text: string } => {
4602
4018
  const session = askSession;
4603
4019
  if (session === undefined) {
4604
4020
  return {
@@ -4615,7 +4031,7 @@ export default function orchestratorTickExtension(
4615
4031
  "nothing was asked or recorded. Repair the config, do not ask through another path.",
4616
4032
  };
4617
4033
  }
4618
- return { ok: true, project: routed.project, config: session.config };
4034
+ return { ok: true, project: routed.project };
4619
4035
  };
4620
4036
 
4621
4037
  const operatorAskDelivery =
@@ -4657,22 +4073,15 @@ export default function orchestratorTickExtension(
4657
4073
  label: ASK_TOOL,
4658
4074
  defaultInactive: true,
4659
4075
  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".`,
4076
+ `Ask your operator one question. It does NOT wait for the answer: the question is recorded ` +
4077
+ `durably (a decision row + the same delivery path as \`omp-conductor message\`), delivered per the ` +
4078
+ `reporting policy, and the call returns immediately with the row id and the exact command that ` +
4079
+ `resolves it. Your operator answers in their console session this tick session is headless and no ` +
4080
+ `reply can land hereand an unanswered row stays open and pending, re-surfaced in every tick ` +
4081
+ `prompt, until it is answered, withdrawn, or swept by the seven-day decision expiry. When ` +
4082
+ `"options" are supplied, "recommended" must be one of their labels the label as delivered, never ` +
4083
+ `an index. Park the work the question blocks in the same turn you ask: out of the claimable queue, ` +
4084
+ `state recorded, row id in your report. Never treat your own recommendation as an approval.`,
4676
4085
  parameters: askParameterSchema(),
4677
4086
  approval: "write",
4678
4087
  execute: async (_toolCallId, params) => {
@@ -4682,29 +4091,22 @@ export default function orchestratorTickExtension(
4682
4091
  }
4683
4092
  // Routing follows the *live* tick config, not the session-start stamp: a
4684
4093
  // 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.
4094
+ // tick's toolbox land on the project the turn actually ticks for.
4687
4095
  const context = resolveOperatorAskContext(ASK_TOOL);
4688
4096
  if (!context.ok) {
4689
4097
  return { content: [{ type: "text", text: context.text }], isError: true };
4690
4098
  }
4691
4099
  const projectConfig = context.project;
4692
- const config = context.config;
4693
4100
  const store = openStore(dbPath());
4694
4101
  let result: AskResult;
4695
4102
  try {
4696
4103
  result = await performAsk(parsed.request, {
4697
4104
  store,
4698
4105
  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 }),
4106
+ // Test seam: production omits `now` and `performAsk` falls back to
4107
+ // the module's real clock; a test hands one in so the filed row's
4108
+ // timestamp is deterministic.
4109
+ ...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
4708
4110
  deliver: operatorAskDelivery(ASK_TOOL, projectConfig, store),
4709
4111
  });
4710
4112
  } finally {
@@ -4724,19 +4126,15 @@ export default function orchestratorTickExtension(
4724
4126
  label: QUESTIONNAIRE_TOOL,
4725
4127
  defaultInactive: true,
4726
4128
  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.`,
4129
+ `Ask your operator several questions about ONE issue as a single message. Like conductor_ask it ` +
4130
+ `does NOT wait: every item is recorded as its own durable decision row before anything is ` +
4131
+ `delivered, all bound to the issue you name in "spec-issue" — so the answers become that issue's ` +
4132
+ `provenance and a later reader sees why a slice is shaped the way it is — and the call returns the ` +
4133
+ `group id, every row id, and the command that resolves each one. Your operator answers in their ` +
4134
+ `console session, item by item, in any order. Ask ONLY the judgement calls that genuinely belong ` +
4135
+ `to your operator anything a repo read can answer is your own work and at most ` +
4136
+ `${MAX_QUESTIONNAIRE_ITEMS} items. Park the work every item blocks in the same turn you ask, and ` +
4137
+ `name the group id in your report.`,
4740
4138
  parameters: questionnaireParameterSchema(),
4741
4139
  approval: "write",
4742
4140
  execute: async (_toolCallId, params) => {
@@ -4754,9 +4152,7 @@ export default function orchestratorTickExtension(
4754
4152
  const result = await performQuestionnaire(parsed.request, {
4755
4153
  store,
4756
4154
  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 }),
4155
+ ...(options.ask?.now === undefined ? {} : { now: options.ask.now }),
4760
4156
  deliver: operatorAskDelivery(QUESTIONNAIRE_TOOL, projectConfig, store),
4761
4157
  });
4762
4158
  return { content: [{ type: "text", text: result.text }] };
@@ -4766,114 +4162,6 @@ export default function orchestratorTickExtension(
4766
4162
  },
4767
4163
  });
4768
4164
 
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
4165
  pi.on("session_start", (_event, ctx) => {
4878
4166
  if (decided) return;
4879
4167
 
@@ -4905,7 +4193,7 @@ export default function orchestratorTickExtension(
4905
4193
  // invalid config names none — it cannot be trusted to.
4906
4194
  const configuredProject = result.kind === "ok" ? result.config.project : undefined;
4907
4195
  armReleaseGate(configuredProject);
4908
- armAvailabilityGate(configuredProject);
4196
+ armAvailabilityGate(configuredProject, ctx.cwd);
4909
4197
  // The tool is already registered (extension-factory time); only the session
4910
4198
  // state it acts on is filled, and only once ownership is accepted below.
4911
4199
  // `configuredProject` isn't closed over at all — routing is re-read from
@@ -4995,7 +4283,7 @@ export default function orchestratorTickExtension(
4995
4283
  // not create or deliver decisions). `cwd` is for re-reading the live
4996
4284
  // tick config at execution, `config` for the startup-only ceiling.
4997
4285
  askSession = { cwd: ctx.cwd, config };
4998
- armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
4286
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
4999
4287
  if (!guardArmed) {
5000
4288
  guardArmed = true;
5001
4289
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -5017,7 +4305,7 @@ export default function orchestratorTickExtension(
5017
4305
  // why): a declined or unresolved session keeps `askSession` undefined and
5018
4306
  // the tool fails closed.
5019
4307
  askSession = { cwd: ctx.cwd, config };
5020
- armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
4308
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
5021
4309
  if (!guardArmed) {
5022
4310
  guardArmed = true;
5023
4311
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -5035,272 +4323,3 @@ export default function orchestratorTickExtension(
5035
4323
  );
5036
4324
  });
5037
4325
  }
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
- }