omp-conductor 0.19.7 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The operator drain sentinel: one JSON file under the state root that says a
3
+ * human asked this fleet to stop taking new work and finish what it has.
4
+ *
5
+ * It is a file rather than a store row because the things that read it are not
6
+ * all the daemon — `omp-conductor drain`, the status render and `upgrade`'s
7
+ * pre-flight all answer without opening the database, and an upgrade that has
8
+ * just replaced the schema must still be able to read the drain it set. That
9
+ * also fixes the boundary: everything here is pure file I/O plus a verdict, no
10
+ * `Deps`, so it is the leaf the dispatch pass and the status projection both
11
+ * read rather than a pass either of them has to import.
12
+ *
13
+ * Not to be confused with `DrainSignal` in `deps.ts`: that one is the in-process
14
+ * "SIGTERM has landed" flag a tick re-checks mid-flight. This is the durable
15
+ * operator intent that outlives the process.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { stateDir } from "../config.ts";
20
+
21
+ // (#484 slice 1) A project drain is a durable, self-expiring admission fence:
22
+ // the same boundary as a pause — settlement above it, nothing claimed below —
23
+ // but recorded with an absolute deadline, so an orchestrator crash can never
24
+ // strand admission. The record is a JSON file under the state directory (like
25
+ // the pause sentinel), scoped to exactly one configured project, and replaced
26
+ // atomically on create. Human CLI wording and release-verb coupling are later
27
+ // #484 children.
28
+
29
+ /** The persisted shape of one project drain. Every field is validated when a
30
+ * record is read — a record that cannot be trusted is never a fence. */
31
+ export interface DrainRecord {
32
+ /** The configured project this drain fences; must match the reader. */
33
+ project: string;
34
+ /** ISO instant the drain intent was recorded. */
35
+ createdAt: string;
36
+ /** Absolute ISO deadline: admission resumes automatically at or after it. */
37
+ expiresAt: string;
38
+ /** Purpose recorded at creation (release window, maintenance…). */
39
+ reason?: string;
40
+ }
41
+
42
+ /** Create-time options for {@link createDrain}. */
43
+ export interface CreateDrainOptions {
44
+ /** Absolute epoch-ms deadline — a drain must always expire on its own. */
45
+ expiresAt: number;
46
+ /** Purpose, persisted on the record and shown in structured status. */
47
+ reason?: string;
48
+ }
49
+
50
+ /** Why a drain record could not be trusted, named deterministically. */
51
+ export type DrainProblem =
52
+ | "unparseable-json"
53
+ | "invalid-record"
54
+ | "invalid-project"
55
+ | "invalid-created-at"
56
+ | "invalid-expires-at"
57
+ | "expiry-not-future"
58
+ | "invalid-reason";
59
+
60
+ /** The verdict of one drain read. `error` is the malformed-record case: the
61
+ * caller may fail its pass closed, and the dispatch-side
62
+ * {@link consumeDrain} removes the record in the same transition, so it can
63
+ * never become a permanent drain. */
64
+ export type DrainVerdict =
65
+ | { kind: "active"; drain: DrainRecord }
66
+ | { kind: "inactive" }
67
+ | { kind: "error"; problem: DrainProblem };
68
+
69
+ /** Where this project's drain record lives. Project-scoped by construction:
70
+ * a record present at one project's path never fences another project. */
71
+ export function drainPath(project: string): string {
72
+ return join(stateDir(), `drain-${project}.json`);
73
+ }
74
+
75
+ /**
76
+ * Records a bounded drain intent for `project`, replacing any prior drain of
77
+ * the same project atomically (tmp + rename, exactly like the admission ack).
78
+ * The record is a file, so it survives orchestrator and daemon loss; the
79
+ * absolute `expiresAt` is what stops it from ever stranding admission.
80
+ */
81
+ export function createDrain(
82
+ project: string,
83
+ opts: CreateDrainOptions,
84
+ now = Date.now(),
85
+ ): void {
86
+ if (project === "") {
87
+ throw new Error("drain project must not be empty");
88
+ }
89
+ if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= now) {
90
+ throw new Error("drain expiresAt must be a finite epoch-ms timestamp in the future");
91
+ }
92
+ if (opts.reason !== undefined && typeof opts.reason !== "string") {
93
+ throw new Error("drain reason must be a string");
94
+ }
95
+ const record: DrainRecord = {
96
+ project,
97
+ createdAt: new Date(now).toISOString(),
98
+ expiresAt: new Date(opts.expiresAt).toISOString(),
99
+ ...(opts.reason === undefined ? {} : { reason: opts.reason }),
100
+ };
101
+ const path = drainPath(project);
102
+ mkdirSync(dirname(path), { recursive: true });
103
+ const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
104
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
105
+ renameSync(tmp, path);
106
+ }
107
+
108
+ /**
109
+ * One drained state read with no side effects — this never touches the record
110
+ * on disk. That purity is what lets observational surfaces live off it: a
111
+ * status/dashboard/health read must not consume a malformed record before a
112
+ * dispatch pass fails closed on it, or the pass would read absent and admit.
113
+ * The dispatch side performs the actual cleanup transition through
114
+ * {@link consumeDrain}. Callers that only need to *see* the state (including
115
+ * the claim path, which refuses but must not unbind its own pass) use this.
116
+ */
117
+ export function readDrain(project: string, now = Date.now()): DrainVerdict {
118
+ const path = drainPath(project);
119
+ if (!existsSync(path)) return { kind: "inactive" };
120
+ let raw: unknown;
121
+ try {
122
+ raw = JSON.parse(readFileSync(path, "utf8"));
123
+ } catch {
124
+ return { kind: "error", problem: "unparseable-json" };
125
+ }
126
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
127
+ return { kind: "error", problem: "invalid-record" };
128
+ }
129
+ const rec = raw as Record<string, unknown>;
130
+ if (typeof rec["project"] !== "string" || rec["project"] === "") {
131
+ return { kind: "error", problem: "invalid-project" };
132
+ }
133
+ if (typeof rec["createdAt"] !== "string" || Number.isNaN(Date.parse(rec["createdAt"]))) {
134
+ return { kind: "error", problem: "invalid-created-at" };
135
+ }
136
+ if (typeof rec["expiresAt"] !== "string" || Number.isNaN(Date.parse(rec["expiresAt"]))) {
137
+ return { kind: "error", problem: "invalid-expires-at" };
138
+ }
139
+ if (Date.parse(rec["expiresAt"] as string) <= Date.parse(rec["createdAt"] as string)) {
140
+ return { kind: "error", problem: "expiry-not-future" };
141
+ }
142
+ const reason = rec["reason"];
143
+ if (reason !== undefined && typeof reason !== "string") {
144
+ return { kind: "error", problem: "invalid-reason" };
145
+ }
146
+ if (rec["project"] !== project) {
147
+ // A record persisted at this project's path but naming another project is
148
+ // either a copy or a rename mishap; it fences nobody (that project's drain
149
+ // lives at its own path).
150
+ return { kind: "inactive" };
151
+ }
152
+ if (Date.parse(rec["expiresAt"] as string) <= now) {
153
+ return { kind: "inactive" };
154
+ }
155
+ const drain: DrainRecord = {
156
+ project: rec["project"],
157
+ createdAt: rec["createdAt"],
158
+ expiresAt: rec["expiresAt"],
159
+ ...(reason === undefined ? {} : { reason }),
160
+ };
161
+ return { kind: "active", drain };
162
+ }
163
+
164
+ /**
165
+ * The dispatch-side drain read: the same verdict as {@link readDrain}, and the
166
+ * one place a stale or untrustworthy record is cleared as a side effect —
167
+ * expired and wrong-project records are removed (bounded stale-state cleanup
168
+ * on the next pass), and a malformed record is removed in the very transition
169
+ * that fails the pass closed, so it can never become an unbounded permanent
170
+ * drain. Only dispatch callers use this: an observational read here would let
171
+ * a status/health reader consume the malformed marker before the pass that
172
+ * must fail closed on it ever ran.
173
+ */
174
+ export function consumeDrain(project: string, now = Date.now()): DrainVerdict {
175
+ const verdict = readDrain(project, now);
176
+ if (verdict.kind !== "active") {
177
+ rmSync(drainPath(project), { force: true });
178
+ }
179
+ return verdict;
180
+ }
181
+
182
+ /** Removes this project's drain, idempotently — a second cancel is a no-op. */
183
+ export function cancelDrain(project: string): void {
184
+ rmSync(drainPath(project), { force: true });
185
+ }
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The daemon's half of grooming: launch to-spec scouts when the queue runs low,
3
+ * persist what they answer, and promote what passes the ready gate.
4
+ *
5
+ * Named `groom-pass` against the top-level `groom.ts` because the two halves
6
+ * must stay apart. `groom.ts` is the rule set — selection, exclusions, the
7
+ * in-flight reasons — pure and shared, and the only place a candidate is ever
8
+ * ruled in or out. This file is the pass: sessions, durable rows, label ops and
9
+ * the wake. A rule that drifted in here would be a second answer to "should this
10
+ * issue be groomed", which is precisely the failure the mechanical pipeline was
11
+ * built to remove.
12
+ *
13
+ * `TO_SPEC_BRIEF_PATH` resolves through `PACKAGE_SRC_DIR` for the same reason
14
+ * the worker template does: the prompt stayed in `src/briefs/` when this pass
15
+ * moved a directory deeper.
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import { mkdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { resolveGroomRole, stateDir } from "../config.ts";
21
+ import { DEFAULT_GROOM_BELOW, TO_SPEC_AGENT, TO_SPEC_GATE_REJECTED_REASON, TO_SPEC_IN_FLIGHT_REASON, TO_SPEC_IN_FLIGHT_TTL_MS, groomingDue, selectToSpecCandidates, toSpecDurableVerdict, type ToSpecCandidateView } from "../groom.ts";
22
+ import { errText, log } from "../log.ts";
23
+ import { materializeOmpSettings } from "../omp-settings.ts";
24
+ import { readyGate } from "../ready-gate.ts";
25
+ import { recordToSpecGrooming, type ToSpecEvidence } from "../to-spec.ts";
26
+ import type { GroomingRecord, IssueComment, ProjectConfig, ReadyIssue } from "../types.ts";
27
+ import { wakeDispatch } from "../wake.ts";
28
+ import { TO_SPEC_MAX_TURNS, renderBrief, runToSpecScout } from "../worker.ts";
29
+ import type { WorkerPool } from "./admission-pass.ts";
30
+ import { PACKAGE_SRC_DIR, recordDaemonSessionSpend, repoSlug, type Deps } from "./deps.ts";
31
+
32
+ // ==================================================== daemon-driven grooming
33
+ // (#1041, #1040). The dispatcher watches its own queue depth, launches to-spec
34
+ // scouts when it runs low, and promotes what passes the ready gate — the three
35
+ // steps that used to need an orchestrator turn each, and therefore used to wait
36
+ // up to a whole tick interval apiece while the queue sat empty.
37
+ //
38
+ // The division of labour, and why each half is where it is:
39
+ //
40
+ // - SELECTION is `groom.ts`: pure, shared, and the only rule set. The daemon
41
+ // supplies the authoritative reads (its own routable count, the open-issue
42
+ // snapshot, the durable grooming rows, its live runs) and takes back a
43
+ // bounded candidate list. Nothing here re-derives an exclusion.
44
+ // - LAUNCH is this module: a read-only session per candidate, the
45
+ // `handleReviewAdjudication` shape exactly — per-item session directory
46
+ // under the state root, staged omp settings, a socket, the configured role,
47
+ // and a durable settle on every branch including "no answer".
48
+ // - PERSISTENCE is `recordToSpecGrooming`: still the only validator, and it
49
+ // stamps the daemon's own launch time as the observation window (#1000).
50
+ // - PROMOTION is `readyGate` plus the epic-scope read below it, and it is the
51
+ // one place a queue label is added: provenance first (the latch), then the
52
+ // label op, then the wake.
53
+ //
54
+ // The in-flight grooming row is the single-flight latch for the batch. It is
55
+ // written BEFORE any session starts, so a second pass in the same window and a
56
+ // restart mid-batch both read the claim; a launch that dies with the process
57
+ // leaves the row to expire on {@link TO_SPEC_IN_FLIGHT_TTL_MS} rather than
58
+ // parking its candidate forever.
59
+
60
+ /** The grooming prompt, read per launch so an edit applies to the next batch
61
+ * rather than the next daemon restart — the same contract `buildBrief` has. */
62
+ export const TO_SPEC_BRIEF_PATH = join(PACKAGE_SRC_DIR, "briefs", "to-spec.md");
63
+
64
+ /** One batch's identity, carried into every item of it. */
65
+ export interface ToSpecBatch {
66
+ /** Short opaque id: it names the batch in logs, in the in-flight evidence and
67
+ * in each item's session directory, so a spend line, a durable row and a
68
+ * transcript can be tied together after the fact. */
69
+ id: string;
70
+ /** The daemon's own launch stamp. It — never anything the agent wrote — is
71
+ * the source observation time every verdict in this batch persists with
72
+ * (#1000). */
73
+ launchedAt: number;
74
+ }
75
+
76
+ /**
77
+ * Launch the to-spec grooming batch this project's queue depth calls for
78
+ * (#1041).
79
+ *
80
+ * `routable` is the count this pass just measured, not a stored one: the whole
81
+ * point of moving grooming into the daemon is that the drought and the response
82
+ * happen in the same pass. The durable dispatch row still has to exist, because
83
+ * its absence means no pass has ever completed here and the shared selector
84
+ * refuses to judge a queue nobody has routed.
85
+ *
86
+ * Never blocks the tick: with a worker pool the launches ride it exactly like
87
+ * adjudications, so a batch that takes minutes cannot delay the dispatch that
88
+ * follows it. Without one (a `--once` pass, a test) it awaits, which is what
89
+ * makes the whole pipeline assertable in one call.
90
+ */
91
+ export async function dispatchToSpecGrooming(
92
+ d: Deps,
93
+ routable: number,
94
+ pool?: WorkerPool,
95
+ ): Promise<void> {
96
+ const project = d.project;
97
+ const groomBelow = project.groomBelow ?? DEFAULT_GROOM_BELOW;
98
+ if (!groomingDue(routable, groomBelow)) return;
99
+ // The stop fence (#374), for the same reason every other launch honours it:
100
+ // a batch started into a draining daemon is spend with nowhere to land — the
101
+ // sessions die with the process and their rows expire unanswered.
102
+ if (d.drain?.draining === true) {
103
+ log("grooming held: the daemon is draining");
104
+ return;
105
+ }
106
+ const prior = d.store.latestDispatch(project.name);
107
+ if (prior === undefined) return;
108
+ const now = Date.now();
109
+ const grooming = d.store.groomingVerdicts(project.name);
110
+ // One batch at a time. The per-candidate exclusion inside the selector stops
111
+ // one issue being groomed twice; this stops a second batch existing at all,
112
+ // which is the bound on what an unattended dispatcher may spend per window.
113
+ const inFlight = grooming.filter(
114
+ (row) => row.reason === TO_SPEC_IN_FLIGHT_REASON && now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS,
115
+ );
116
+ if (inFlight.length > 0) {
117
+ log(
118
+ `grooming held: ${inFlight.length} candidate(s) still in a launched batch ` +
119
+ `(${inFlight.map((row) => `#${row.issue}`).join(" ")})`,
120
+ );
121
+ return;
122
+ }
123
+ let candidates: ToSpecCandidateView[];
124
+ try {
125
+ candidates = await selectToSpecCandidates({
126
+ project,
127
+ seam: {
128
+ listOpenIssues: () => d.tracker.listOpenIssues(),
129
+ childrenOf: (_project, issue) => d.tracker.childrenOf(issue),
130
+ },
131
+ // The selector reads two things off this: that a pass has completed here
132
+ // at all, and the routable depth. The depth is this pass's own
133
+ // measurement — the row supplies the rest of the shape and is never
134
+ // written back.
135
+ summary: { ...prior, routed: routable },
136
+ groomBelow,
137
+ grooming,
138
+ active: d.store.liveRuns(project.name),
139
+ now,
140
+ });
141
+ } catch (err) {
142
+ log(`grooming selection failed: ${errText(err)} — retrying next pass`);
143
+ return;
144
+ }
145
+ if (candidates.length === 0) return;
146
+
147
+ const batch: ToSpecBatch = { id: randomUUID().slice(0, 8), launchedAt: now };
148
+ const claim = JSON.stringify({
149
+ kind: "to-spec-in-flight",
150
+ launchedAt: batch.launchedAt,
151
+ batch: batch.id,
152
+ agent: TO_SPEC_AGENT,
153
+ });
154
+ // Every claim before any launch, deliberately: a session that starts while a
155
+ // sibling's claim is still unwritten is a candidate a concurrent pass can
156
+ // select twice.
157
+ for (const candidate of candidates) {
158
+ d.store.upsertGrooming({
159
+ project: project.name,
160
+ issue: candidate.issue,
161
+ verdict: "blocked",
162
+ reason: TO_SPEC_IN_FLIGHT_REASON,
163
+ evidence: claim,
164
+ at: batch.launchedAt,
165
+ });
166
+ }
167
+ log(
168
+ `grooming batch ${batch.id}: routable ${routable} below ${String(groomBelow)} — ` +
169
+ `launching ${candidates.map((c) => `#${c.issue}`).join(" ")} as ${resolveGroomRole(project)}`,
170
+ );
171
+ const launches = candidates.map((candidate) => handleToSpecGrooming(d, candidate, batch));
172
+ if (pool !== undefined) {
173
+ for (const launch of launches) pool.launch(launch);
174
+ return;
175
+ }
176
+ await Promise.allSettled(launches);
177
+ }
178
+
179
+ /**
180
+ * Groom one candidate to a durable verdict, and promote it when it earns that
181
+ * (#1041).
182
+ *
183
+ * Every branch settles the row. That is the whole reason the in-flight marker
184
+ * can be a latch rather than a lease: a scout that answered, one that answered
185
+ * garbage, and one whose session never started all replace their own claim, so
186
+ * the only way a candidate stays parked is the daemon dying mid-flight — which
187
+ * the TTL covers.
188
+ */
189
+ export async function handleToSpecGrooming(
190
+ d: Deps,
191
+ candidate: ToSpecCandidateView,
192
+ batch: ToSpecBatch,
193
+ ): Promise<void> {
194
+ const project = d.project;
195
+ const issue = candidate.issue;
196
+ // The body verbatim, read now rather than carried from the selection
197
+ // snapshot: the brief's whole premise is that the groomer judges the issue as
198
+ // it stands against the source as it stands. An unreadable body is said out
199
+ // loud in the brief — a scout that silently groomed a blank issue would
200
+ // produce a confident verdict about nothing.
201
+ let body: string | undefined;
202
+ try {
203
+ body = (await d.tracker.getIssue(issue))?.body;
204
+ } catch {
205
+ body = undefined;
206
+ }
207
+ const brief = renderBrief(await Bun.file(TO_SPEC_BRIEF_PATH).text(), {
208
+ TRACKER_REPO: project.tracker.repo,
209
+ ISSUE_NUMBER: String(issue),
210
+ CANDIDATE_TITLE: candidate.title,
211
+ ISSUE_BODY:
212
+ body === undefined || body.trim() === ""
213
+ ? "(the issue body could not be read — say so in reasonNotToPromote rather than guessing what it asked for)"
214
+ : body,
215
+ SOURCE: candidate.routing,
216
+ SOURCE_REF: groomSourceRef(project, candidate.routing),
217
+ });
218
+
219
+ const sessionDir = join(stateDir(), "sessions", `to-spec-${batch.id}-${issue}`);
220
+ mkdirSync(sessionDir, { recursive: true });
221
+ const ompSettingsFile = materializeOmpSettings(project, sessionDir);
222
+ const result = await (d.runToSpecScoutImpl ?? runToSpecScout)({
223
+ brief,
224
+ // No worktree: a scout has no branch, and everything about the candidate is
225
+ // in the brief. The session directory doubles as its confined cwd.
226
+ cwd: sessionDir,
227
+ sessionDir,
228
+ model: resolveGroomRole(project),
229
+ ...(ompSettingsFile === undefined ? {} : { ompSettingsFile }),
230
+ socketPath: join(sessionDir, "ipc.sock"),
231
+ maxTurns: TO_SPEC_MAX_TURNS,
232
+ });
233
+
234
+ // Recorded before the verdict is validated: an unparseable answer cost
235
+ // whatever it cost, and a refusal that is invisible in the accounting is how
236
+ // grooming spend became unattributable in the first place.
237
+ const groomRole = resolveGroomRole(project);
238
+ recordDaemonSessionSpend(d, {
239
+ role: "groom",
240
+ issue,
241
+ ...(groomRole === undefined ? {} : { model: groomRole }),
242
+ ...(result.model === undefined ? {} : { resolvedModel: result.model }),
243
+ turns: result.turns,
244
+ spendUsd: result.spendUsd,
245
+ at: Date.now(),
246
+ });
247
+
248
+ // The scout's answer, unjudged, through the one validator. Its report stands
249
+ // in when it produced no answer at all, so the refusal names what went wrong
250
+ // (an unresolvable role, a harness that would not start) instead of recording
251
+ // an empty string nobody can diagnose.
252
+ const outcome = recordToSpecGrooming(d.store, {
253
+ project: project.name,
254
+ issue,
255
+ input: result.raw === "" ? result.report : result.raw,
256
+ launchedAt: batch.launchedAt,
257
+ });
258
+ const ran = result.model === undefined ? "" : ` by ${result.model}`;
259
+ // "$0.00" for a session that took turns and reported nothing is the misleading
260
+ // zero #46 named: say "unmetered" instead, matching what the durable row now
261
+ // stores and what the stats lane renders.
262
+ const cost =
263
+ result.turns > 0 && result.spendUsd === 0 ? "unmetered" : `$${result.spendUsd.toFixed(2)}`;
264
+ log(
265
+ `#${issue} groomed ${outcome.record.verdict}(${outcome.record.reason})${ran} in batch ${batch.id} — ` +
266
+ `${result.turns} turn(s), ${cost}`,
267
+ );
268
+ if (outcome.kind !== "persisted" || outcome.record.verdict !== "promotable") return;
269
+ await promoteGroomedVerdict(d, outcome.record);
270
+ }
271
+
272
+ /**
273
+ * The ref a grooming brief tells its scout to read the source at (#1041).
274
+ *
275
+ * The routed repository's configured default branch — a name, not a sha,
276
+ * because conductor no longer needs the scout to witness freshness: the
277
+ * dispatcher stamps the observation window itself (#1000), so resolving a head
278
+ * here would cost a network read per candidate to produce a fact nothing reads.
279
+ * `HEAD` is the honest fallback for a routing target this config cannot
280
+ * resolve: it names the repository's own default branch without claiming to
281
+ * know which branch that is.
282
+ */
283
+ export function groomSourceRef(project: ProjectConfig, routing: string): string {
284
+ const target = Object.values(project.routing.repos).find((repo) => repoSlug(repo) === routing);
285
+ return target?.defaultBranch ?? "HEAD";
286
+ }
287
+
288
+ /**
289
+ * Promote one fresh `promotable` verdict, or record exactly why not (#1041,
290
+ * #1040).
291
+ *
292
+ * Mechanical promotion is the phase's whole point and also its whole risk, so
293
+ * the shape is: judge with a pure function, refuse with a durable list, and
294
+ * mutate through the outbox only after the provenance stamp has been won.
295
+ *
296
+ * - The judgement is {@link readyGate} over the issue as it stands right now.
297
+ * A tracker read that fails becomes `"unread"`, which the gate refuses
298
+ * rather than treating as an empty issue: nothing here was queued by a
299
+ * human, so every unknown is a refusal.
300
+ * - The epic-scope check is this function's own, because the gate is pure and
301
+ * approval is a store fact. A child of an unapproved epic is not promotable
302
+ * however complete its brief: an orchestrator can file a decomposition, but
303
+ * only an operator agrees to its scope. No decision row is created here —
304
+ * net-new scope stays the orchestrator's proposal to make.
305
+ * - A refusal re-records the SAME verdict with `readyGate.missing` beside it,
306
+ * under {@link TO_SPEC_GATE_REJECTED_REASON}. The verdict was valid; the
307
+ * spec is simply not queueable, and the tick's promotion audit renders the
308
+ * whole list so one pass fixes every problem instead of four passes fixing
309
+ * one each.
310
+ * - A pass stamps provenance FIRST. `markGroomingPromoted` returning false is
311
+ * the latch: another pass already promoted this verdict, so this one adds no
312
+ * label and fires no wake, and a restart mid-promotion cannot queue an issue
313
+ * twice.
314
+ *
315
+ * Returns whether this call promoted, for the caller's own log; the durable
316
+ * facts are the row and the label op.
317
+ */
318
+ export async function promoteGroomedVerdict(d: Deps, row: GroomingRecord): Promise<boolean> {
319
+ const project = d.project;
320
+ const issue = row.issue;
321
+ const now = Date.now();
322
+ const result = toSpecDurableVerdict(row, now);
323
+ if (result === undefined) {
324
+ // Not durable grooming: no recoverable to-spec payload, or a source
325
+ // observed past the freshness ceiling. Either way there is nothing to
326
+ // promote ON, and the candidate is re-groomable by the same rule.
327
+ log(`#${issue} not promoted: its verdict carries no fresh to-spec result`);
328
+ return false;
329
+ }
330
+ let snapshot: ReadyIssue | undefined;
331
+ try {
332
+ snapshot = await d.tracker.getIssue(issue);
333
+ } catch {
334
+ snapshot = undefined;
335
+ }
336
+ let comments: IssueComment[] | "unread" = "unread";
337
+ if (snapshot !== undefined) {
338
+ try {
339
+ comments = await d.tracker.listComments(issue);
340
+ } catch {
341
+ comments = "unread";
342
+ }
343
+ }
344
+ const verdict = readyGate({
345
+ result,
346
+ issueBody: snapshot?.body ?? "",
347
+ comments: snapshot === undefined ? "unread" : comments,
348
+ labels: snapshot?.labels ?? [],
349
+ project,
350
+ });
351
+ const missing = [...(verdict.ok ? [] : verdict.missing), ...(await epicScopeMisses(d, issue))];
352
+ if (missing.length > 0) {
353
+ const evidence: ToSpecEvidence = { kind: "to-spec", result, readyGate: { missing, checkedAt: now } };
354
+ d.store.upsertGrooming({
355
+ project: project.name,
356
+ issue,
357
+ verdict: row.verdict,
358
+ reason: TO_SPEC_GATE_REJECTED_REASON,
359
+ evidence: JSON.stringify(evidence),
360
+ // The verdict's own recording time is preserved: the gate ran now, but
361
+ // the judgement is the one the scout made, and its freshness window is
362
+ // what decides re-grooming.
363
+ at: row.recordedAt,
364
+ });
365
+ log(`#${issue} not promoted (${missing.length} gate refusal(s)): ${missing[0]}`);
366
+ return false;
367
+ }
368
+ if (!d.store.markGroomingPromoted(project.name, issue, now, "daemon")) {
369
+ log(`#${issue} promotion skipped: this verdict already carries a promotion`);
370
+ return false;
371
+ }
372
+ // The outbox owns labels — never a direct tracker write. The projector
373
+ // applies it and retries until GitHub takes it, and the eligibility overlay
374
+ // keeps this pass coherent in the meantime (#201).
375
+ d.store.enqueueLabelOps(project.name, [{ issue, op: "add", label: project.queueLabel }]);
376
+ try {
377
+ log(`#${issue} promoted by the ready gate — ${await (d.wake ?? wakeDispatch)(project.name)}`);
378
+ } catch (err) {
379
+ log(`#${issue} promoted but the dispatch wake failed (${errText(err)}) — the next scheduled pass will claim`);
380
+ }
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * The epic-scope half of promotion: nothing, or the one reason this slice's
386
+ * scope has not been agreed to (#1041).
387
+ *
388
+ * An issue with no parent is not epic-serialized work and needs no approval —
389
+ * it exists in the tracker, which means somebody filed it. A child does: a
390
+ * decomposition can be proposed without its scope being accepted, and
391
+ * mechanically queueing children of an unapproved epic is how an orchestrator's
392
+ * proposal becomes a fleet's week of work without anyone saying yes.
393
+ *
394
+ * Fails closed on an unreadable parent, matching every other epic read in this
395
+ * package: "the tracker could not say" is not evidence of standalone work.
396
+ */
397
+ export async function epicScopeMisses(d: Deps, issue: number): Promise<string[]> {
398
+ let parent: number | undefined;
399
+ try {
400
+ parent = await d.tracker.parentOf(issue);
401
+ } catch (err) {
402
+ return [
403
+ `the parent/epic lookup failed (${errText(err)}), so the scope this slice belongs to could not be established`,
404
+ ];
405
+ }
406
+ if (parent === undefined) return [];
407
+ if (d.store.epicApproval(d.project.name, parent) !== undefined) return [];
408
+ return [
409
+ `it is a child of epic #${parent}, whose scope nobody approved — ` +
410
+ `\`omp-conductor epic approve ${parent}\` once the operator agrees to it`,
411
+ ];
412
+ }