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