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
package/src/groom.ts ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Grooming candidate selection: the one shared rule set that answers "which
3
+ * backlog issues may a to-spec pass be spent on right now".
4
+ *
5
+ * #772 shipped the strict to-spec contract (`TO_SPEC_SCHEMA` +
6
+ * `recordToSpecGrooming`) and #777 shipped the launch half as a per-tick
7
+ * capability token redeemed by a `task` tool-call gate inside the tick
8
+ * extension. That made grooming tick-bound: one batch per low-queue tick, and
9
+ * an operator message that consumed the tick consumed the batch with it
10
+ * (#1040). This module is what remains when the token goes away — the
11
+ * mechanical selection itself, extracted so the dispatch daemon owns launching
12
+ * (#1041) and the tick is free to audit rather than authorize.
13
+ *
14
+ * Where each fact lives decides who enforces it, and every exclusion is
15
+ * enforced mechanically from authoritative data, never by prose:
16
+ *
17
+ * - Tracker facts — the open-issue pool, the park label, the parent/epic
18
+ * probe — are read through the existing Tracker adapter
19
+ * (`listOpenIssues`/`childrenOf`) via {@link ToSpecTrackerSeam}, and turned
20
+ * into the pool by {@link toSpecPoolFromSnapshot}. If the snapshot cannot be
21
+ * read, no batch is selected at all: the selection fails closed rather than
22
+ * trusting a model to self-filter parked/parent/epic candidates.
23
+ * - Store facts — durable grooming rows (#735), admission's lane/dependency
24
+ * holds, active runs, in-flight launches — are enforced by
25
+ * {@link toSpecCandidateExclusion}, the single predicate every reader of
26
+ * "is this issue already groomed?" goes through.
27
+ * - The live source ref for each item cannot be known synchronously without a
28
+ * fresh per-repo read, so the launcher stamps the observation time it
29
+ * launched at and the strict parser's 24h freshness ceiling re-vets it at
30
+ * persistence time — the boundary #772 set, tightened by #1000.
31
+ *
32
+ * The module deliberately imports nothing from the tick extension, the harness
33
+ * or the pi surface: both the daemon and the tick read it, and a selection rule
34
+ * that could only run inside a session is how grooming became tick-bound in the
35
+ * first place.
36
+ */
37
+
38
+ import { repoSlugFor } from "./gitops.ts";
39
+ import {
40
+ parseToSpecEvidence,
41
+ parseToSpecFailureEvidence,
42
+ TO_SPEC_MAX_SOURCE_AGE_MS,
43
+ type ToSpecFailure,
44
+ type ToSpecResult,
45
+ } from "./to-spec.ts";
46
+ import type {
47
+ DispatchSummary,
48
+ GroomingRecord,
49
+ GroomTrigger,
50
+ IssueState,
51
+ ProjectConfig,
52
+ ReadyIssue,
53
+ } from "./types.ts";
54
+
55
+ /** Routable candidates below which the grooming duty fires (#181). */
56
+ export const DEFAULT_GROOM_BELOW = 4;
57
+
58
+ /**
59
+ * Whether the count-based half of the grooming duty fires for one dispatch
60
+ * row's routable count (#988): below a numeric threshold as always, while
61
+ * `"always"` leaves this gate open on purpose — the selectors then answer
62
+ * "is anything left to groom?" from candidate state (ungroomed, unrefused,
63
+ * unparked), and the caller renders the truthful finding either way. Never
64
+ * substitute a large number for `"always"`: a big queue must not silence the
65
+ * duty.
66
+ */
67
+ export function groomingDue(routed: number, groomBelow: GroomTrigger): boolean {
68
+ return groomBelow === "always" || routed < groomBelow;
69
+ }
70
+
71
+ /** The `to-spec` agent every batch item runs under (shipped in
72
+ * `omp/agents/to-spec.md`, discovered through OMP's native task-agent
73
+ * discovery — never a custom process runtime). */
74
+ export const TO_SPEC_AGENT = "to-spec";
75
+
76
+ /** The maximum number of candidates one batch may carry (#679's "small
77
+ * per-tick candidate limit"; the daemon's own concurrency bounds the
78
+ * sessions underneath). */
79
+ export const TO_SPEC_BATCH_MAX = 3;
80
+
81
+ /** The grooming-table verdict row a launched-but-unfinished batch leaves behind
82
+ * (reason, on a `blocked` verdict): the durable in-flight marker that stops
83
+ * the next pass — or a restarted daemon — from re-launching the same item.
84
+ * `blocked` is deliberate: `recordToSpecGrooming` replaces the row when the
85
+ * result lands, and refusing-to-parse output must *not* be swallowed by the
86
+ * kept-prior path that protects prior `promotable`/`considered` verdicts. */
87
+ export const TO_SPEC_IN_FLIGHT_REASON = "in-flight";
88
+
89
+ /**
90
+ * The grooming-row `reason` a `PROMOTABLE` verdict carries when the daemon's
91
+ * ready gate refused to queue it (#1041). The verdict itself stays
92
+ * `promotable` and `promotedAt`/`promotedBy` stay unset: the to-spec pass was
93
+ * valid and must not be re-spent, but the spec is not queueable yet. One
94
+ * constant, two writers-and-readers — the daemon stamps it, the tick's
95
+ * promotion audit renders it — because the last time this fleet hand-copied a
96
+ * reason string into a second file the two drifted apart (`status-render.ts`'s
97
+ * duplicate of {@link TO_SPEC_IN_FLIGHT_REASON}).
98
+ */
99
+ export const TO_SPEC_GATE_REJECTED_REASON = "gate-rejected";
100
+
101
+ /** What the ready gate found missing, as the rejected row records it. */
102
+ export interface ReadyGateRejection {
103
+ /** The gate's own `missing[]`, verbatim and in order. */
104
+ missing: string[];
105
+ /** When the gate ran, so an audit can say how stale its finding is. */
106
+ checkedAt: number;
107
+ }
108
+
109
+ /**
110
+ * Recover a ready-gate rejection from a grooming row's evidence, or
111
+ * `undefined` when the row is not one. The daemon writes the rejection as one
112
+ * added top-level sibling of the untouched to-spec `result`
113
+ * (`{kind:"to-spec", result:{…}, readyGate:{missing, checkedAt}}`), so
114
+ * {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
115
+ * not re-groomed — the spec is rejected, not ungroomed.
116
+ *
117
+ * Fails closed on anything it cannot read: an empty `missing` array is not a
118
+ * rejection (a gate that found nothing missing passed), and a non-string entry
119
+ * would render as `undefined` in a digest line an operator is asked to act on.
120
+ */
121
+ export function parseReadyGateRejection(evidence: string): ReadyGateRejection | undefined {
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(evidence);
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
129
+ const row = parsed as Record<string, unknown>;
130
+ if (row["kind"] !== "to-spec") return undefined;
131
+ const gate = row["readyGate"];
132
+ if (gate === null || typeof gate !== "object" || Array.isArray(gate)) return undefined;
133
+ const { missing, checkedAt } = gate as { missing?: unknown; checkedAt?: unknown };
134
+ if (!Array.isArray(missing) || missing.length === 0) return undefined;
135
+ if (!missing.every((entry) => typeof entry === "string" && entry.length > 0)) return undefined;
136
+ return {
137
+ missing: missing as string[],
138
+ checkedAt: typeof checkedAt === "number" && Number.isFinite(checkedAt) ? checkedAt : 0,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * How long a launch row may sit before it is treated as a dead batch and the
144
+ * candidate becomes eligible again. A batch that dies with the process (a
145
+ * daemon stop between launch and settlement) must not park a candidate
146
+ * forever; the 24h ceiling matches the source-freshness ceiling, so a
147
+ * relaunched pass always reads new source evidence anyway.
148
+ */
149
+ export const TO_SPEC_IN_FLIGHT_TTL_MS = 24 * 60 * 60 * 1_000;
150
+
151
+ /**
152
+ * How long a refused pass parks its candidate before another batch may be
153
+ * spent on it. Deliberately the same 24h number as the source-freshness
154
+ * ceiling and the in-flight TTL — one granularity for this whole lifecycle,
155
+ * not a third threshold to keep in sync: within that window neither the
156
+ * authoritative source nor the issue has produced new evidence, so a retry
157
+ * re-runs the identical prompt and refuses the identical way.
158
+ *
159
+ * Without it, a candidate whose delegated pass returns malformed,
160
+ * source-less or stale output is immediately eligible again, so every
161
+ * low-queue pass spends a full delegated batch re-grooming it — measured on
162
+ * this fleet as five permanently-refused rows (#295, #296, #297, #679, #806)
163
+ * re-offered on every pass, and as #807 groomed twice seven minutes apart
164
+ * (#887).
165
+ */
166
+ export const TO_SPEC_REFUSED_RETRY_COOLDOWN_MS = TO_SPEC_MAX_SOURCE_AGE_MS;
167
+
168
+ /**
169
+ * The one durability rule for a grooming row: the validated to-spec result it
170
+ * carries when that result is still fresh, or `undefined` when the row is not
171
+ * durable grooming at all (no to-spec payload — a hand-edited or pre-#772
172
+ * row — or a source observed past the freshness ceiling).
173
+ *
174
+ * Every reader of "is this issue already groomed?" MUST go through this:
175
+ * {@link toSpecCandidateExclusion} (selection), the daemon's promotion gate,
176
+ * and the queue digest's already-considered inventory. Two readers with two
177
+ * predicates is exactly the #887 defect — the digest told the orchestrator
178
+ * "never re-groom these" about rows the mechanical selection was
179
+ * simultaneously offering.
180
+ */
181
+ export function toSpecDurableVerdict(row: GroomingRecord, now: number): ToSpecResult | undefined {
182
+ const result = parseToSpecEvidence(row.evidence);
183
+ if (result === undefined) return undefined;
184
+ return now - result.source.freshAt <= TO_SPEC_MAX_SOURCE_AGE_MS ? result : undefined;
185
+ }
186
+
187
+ /** The refusal a row records when its pass produced nothing usable, while the
188
+ * cooldown above still holds it out of a new batch; `undefined` for any
189
+ * other row, including a refusal whose cooldown has expired. */
190
+ export function toSpecRefusalOnCooldown(row: GroomingRecord, now: number): ToSpecFailure | undefined {
191
+ const failure = parseToSpecFailureEvidence(row.evidence);
192
+ if (failure === undefined) return undefined;
193
+ return now - row.recordedAt <= TO_SPEC_REFUSED_RETRY_COOLDOWN_MS ? failure : undefined;
194
+ }
195
+
196
+ /** One backlog candidate the mechanical gate can judge. The tracker facts
197
+ * (labels, epics) are read from the authoritative open-issue snapshot at
198
+ * selection time; they travel in this view so the selector stays
199
+ * deterministic and testable. */
200
+ export interface ToSpecCandidateView {
201
+ issue: number;
202
+ title: string;
203
+ /** The routing target — a routed `owner/repo` (from the issue's one
204
+ * `routing.labelPrefix<key>` label, resolved through `routing.repos`). */
205
+ routing: string;
206
+ /** Operator-parked (`project.stateLabels.backlog`); read from tracker labels. */
207
+ parked?: boolean;
208
+ /** A parent/epic with no independently runnable slice; read from the
209
+ * tracker's sub-issue probe. */
210
+ parent?: boolean;
211
+ }
212
+
213
+ /**
214
+ * Why one candidate is not eligible for a batch right now, or undefined when
215
+ * it is. The single rule source for the selection helper and the launcher's
216
+ * own re-vet at dispatch time — one rule, every reader, so a candidate
217
+ * excluded in a digest line is excluded at launch for the same reason.
218
+ *
219
+ * - `in-flight`: a launch row recorded within the TTL (a dead batch's row
220
+ * expires and the candidate becomes eligible again);
221
+ * - `file-lane` / `depends-on`: admission's durable mechanical holds (#735);
222
+ * - a fresh valid `to-spec` result in the grooming table: the candidate was
223
+ * already groomed at an observed source within the freshness ceiling, so
224
+ * re-running it would recompute a verdict that is still valid. New source
225
+ * evidence reconsiders it: once the recorded `freshAt` crosses the ceiling
226
+ * the row no longer reads as groomed, and a fresh pass overrides it;
227
+ * - a refused pass inside {@link TO_SPEC_REFUSED_RETRY_COOLDOWN_MS}: a full
228
+ * delegated batch was already spent and produced nothing usable
229
+ * (malformed, source-less or stale output). Retrying inside the cooldown
230
+ * re-runs the identical prompt against the same source and refuses the
231
+ * same way, which is how one broken candidate consumed a batch on every
232
+ * low-queue tick (#887);
233
+ * - `active`: a run is in flight on the issue right now.
234
+ */
235
+ export function toSpecCandidateExclusion(
236
+ candidate: { issue: number },
237
+ facts: { grooming: GroomingRecord | undefined; active: boolean },
238
+ now: number,
239
+ ): string | undefined {
240
+ const row = facts.grooming;
241
+ if (row !== undefined) {
242
+ if (row.reason === TO_SPEC_IN_FLIGHT_REASON) {
243
+ if (now - row.recordedAt <= TO_SPEC_IN_FLIGHT_TTL_MS) {
244
+ return `#${candidate.issue} is already in a to-spec batch (launched ${new Date(row.recordedAt).toISOString()})`;
245
+ }
246
+ } else if (row.reason === "file-lane" || row.reason === "depends-on") {
247
+ return `#${candidate.issue} is mechanically blocked (${row.reason}) — the hold clears by itself`;
248
+ } else {
249
+ const durable = toSpecDurableVerdict(row, now);
250
+ if (durable !== undefined) {
251
+ return (
252
+ `#${candidate.issue} was already groomed ${durable.verdict} (source ${durable.source.name}@` +
253
+ `${durable.source.ref}, observed ${new Date(durable.source.freshAt).toISOString()}) — re-groom only ` +
254
+ "with new source evidence"
255
+ );
256
+ }
257
+ const refusal = toSpecRefusalOnCooldown(row, now);
258
+ if (refusal !== undefined) {
259
+ const retryAt = new Date(row.recordedAt + TO_SPEC_REFUSED_RETRY_COOLDOWN_MS).toISOString();
260
+ return (
261
+ `#${candidate.issue} already spent a to-spec batch that was refused as ${refusal.kind} ` +
262
+ `(${new Date(row.recordedAt).toISOString()}) — eligible again after ${retryAt}, or once the ` +
263
+ "issue or its source changes"
264
+ );
265
+ }
266
+ }
267
+ }
268
+ if (facts.active) return `#${candidate.issue} has a dispatched run in flight`;
269
+ return undefined;
270
+ }
271
+
272
+ /**
273
+ * The mechanical half of grooming: deterministic, bounded selection of the
274
+ * eligible candidates, smallest issue numbers first, never more than
275
+ * {@link TO_SPEC_BATCH_MAX} per batch, nothing at/above a numeric grooming
276
+ * trigger (`"always"` opens that gate and lets candidate state decide, #988),
277
+ * nothing before the first dispatch summary exists (queue health unknown —
278
+ * the same gate the queue digest uses). Parked and parent views are honored
279
+ * when the caller supplies them.
280
+ *
281
+ * Production reaches this selector through
282
+ * {@link selectToSpecCandidates}, which fills the views from the
283
+ * authoritative tracker snapshot (park label from the open-issue labels,
284
+ * parent/epic from the sub-issue probe) and streams the store-side exclusions
285
+ * before the selector runs — so parked and parent exclusions are enforced on
286
+ * the live path, not only on test inputs.
287
+ */
288
+ export function selectToSpecBatch(input: {
289
+ candidates: readonly ToSpecCandidateView[];
290
+ summary: DispatchSummary | undefined;
291
+ groomBelow: GroomTrigger;
292
+ grooming: readonly GroomingRecord[];
293
+ active: readonly { issue: number }[];
294
+ now: number;
295
+ }): ToSpecCandidateView[] {
296
+ if (input.summary === undefined) return [];
297
+ if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
298
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
299
+ const activeIssues = new Set(input.active.map((run) => run.issue));
300
+ const selected: ToSpecCandidateView[] = [];
301
+ for (const candidate of [...input.candidates].sort((a, b) => a.issue - b.issue)) {
302
+ if (candidate.parked) continue;
303
+ if (candidate.parent) continue;
304
+ if (
305
+ toSpecCandidateExclusion(
306
+ { issue: candidate.issue },
307
+ { grooming: groomingByIssue.get(candidate.issue), active: activeIssues.has(candidate.issue) },
308
+ input.now,
309
+ ) !== undefined
310
+ ) {
311
+ continue;
312
+ }
313
+ selected.push(candidate);
314
+ if (selected.length >= TO_SPEC_BATCH_MAX) break;
315
+ }
316
+ return selected;
317
+ }
318
+
319
+ /**
320
+ * The authoritative tracker surface the selection is built from (#777): the
321
+ * two reads through the existing Tracker adapter that answer "which open
322
+ * backlog issues are actually eligible right now". Production implements this
323
+ * with `makeTracker(...).listOpenIssues()` / `.childrenOf(...)`; tests inject
324
+ * deterministic fakes. `listOpenIssues` is the one open-issue snapshot with
325
+ * labels (#203) — it answers the park label and the routing label
326
+ * mechanically — and `childrenOf` answers whether a candidate is a parent/epic
327
+ * (its sub-issues exist, so it has no independently runnable slice of its
328
+ * own). Both fail closed: an unreadable snapshot means no batch is selected,
329
+ * while an unreadable parent probe skips that candidate because eligibility
330
+ * was not mechanically established.
331
+ */
332
+ export interface ToSpecTrackerSeam {
333
+ /** Every open issue in the tracker repo, labels included
334
+ * (`Tracker.listOpenIssues`). Throws when the tracker cannot be read. */
335
+ listOpenIssues(project: ProjectConfig): Promise<ReadyIssue[]>;
336
+ /** Sub-issues of one issue (`Tracker.childrenOf`). */
337
+ childrenOf(project: ProjectConfig, issue: number): Promise<{ number: number; state: IssueState }[]>;
338
+ }
339
+
340
+ /**
341
+ * The tracker half of the candidate views: map one open-issue snapshot onto
342
+ * the pool the selector can judge. Issues still carrying the queue label are
343
+ * already queued — not backlog — and leave the pool; issues with zero or
344
+ * several `routing.labelPrefix` labels (or a label mapping to no configured
345
+ * repo) cannot name an authoritative source to read and leave the pool,
346
+ * exactly like admission's unroutable partition. The park label lands on the
347
+ * view for the selector to drop; the parent/epic probe is separate (one
348
+ * tracker read per candidate) and stays with {@link selectToSpecCandidates},
349
+ * which runs it only for candidates the store-side exclusions did not already
350
+ * reject.
351
+ */
352
+ export function toSpecPoolFromSnapshot(
353
+ issues: readonly ReadyIssue[],
354
+ project: ProjectConfig,
355
+ ): ToSpecCandidateView[] {
356
+ const queueLabel = project.queueLabel;
357
+ const parkLabel = project.stateLabels.backlog;
358
+ const { labelPrefix, repos } = project.routing;
359
+ const views: ToSpecCandidateView[] = [];
360
+ for (const issue of issues) {
361
+ if (issue.labels.includes(queueLabel)) continue;
362
+ const matched = [...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix)))];
363
+ if (matched.length !== 1) continue;
364
+ const key = matched[0]!.slice(labelPrefix.length);
365
+ const target = Object.hasOwn(repos, key) ? repos[key]! : undefined;
366
+ if (target === undefined) continue;
367
+ views.push({
368
+ issue: issue.number,
369
+ title: issue.title,
370
+ routing: repoSlugFor(target),
371
+ parked: issue.labels.includes(parkLabel) ? true : undefined,
372
+ });
373
+ }
374
+ views.sort((a, b) => a.issue - b.issue);
375
+ return views;
376
+ }
377
+
378
+ /**
379
+ * The production selection for one grooming pass: the complete mechanical
380
+ * selection, from the authoritative tracker snapshot through every exclusion,
381
+ * ending in the candidates a launcher may spend to-spec sessions on. All of
382
+ * the following yield an empty batch:
383
+ *
384
+ * - no dispatch row yet, or the routable queue is at/above a numeric
385
+ * grooming trigger (`"always"` opens that gate; candidate state decides,
386
+ * #988 — the same gate the queue digest uses);
387
+ * - the snapshot cannot be read — the selection fails closed rather than
388
+ * letting an agent self-filter parked/parent/epic candidates;
389
+ * - nothing survives the exclusions (parked, parent/epic, already groomed,
390
+ * in-flight, lane/dependency holds, dispatched runs).
391
+ *
392
+ * The parent/epic probe runs only for candidates the store-side exclusions
393
+ * have not already rejected, in issue order, and stops as soon as
394
+ * {@link TO_SPEC_BATCH_MAX} candidates are collected — a bounded set of
395
+ * tracker reads per pass, never one per open issue.
396
+ */
397
+ export async function selectToSpecCandidates(input: {
398
+ project: ProjectConfig;
399
+ seam: ToSpecTrackerSeam;
400
+ summary: DispatchSummary | undefined;
401
+ groomBelow: GroomTrigger;
402
+ grooming: readonly GroomingRecord[];
403
+ active: readonly { issue: number }[];
404
+ /** An open-issue snapshot the caller already read from the tracker. Shared
405
+ * so one pass cannot describe two queues (#848); absent when that read
406
+ * failed or was never made, in which case the selection reads its own
407
+ * snapshot and fails closed on the same terms. */
408
+ issues?: readonly ReadyIssue[];
409
+ now: number;
410
+ }): Promise<ToSpecCandidateView[]> {
411
+ if (input.summary === undefined) return [];
412
+ if (!groomingDue(input.summary.routed, input.groomBelow)) return [];
413
+ let issues = input.issues;
414
+ if (issues === undefined) {
415
+ try {
416
+ issues = await input.seam.listOpenIssues(input.project);
417
+ } catch {
418
+ // No authoritative snapshot, no batch: launching without one would make
419
+ // the agent the selector, which is exactly the defect this module
420
+ // removes. The next pass retries the read.
421
+ return [];
422
+ }
423
+ }
424
+ const views = toSpecPoolFromSnapshot(issues, input.project);
425
+ const groomingByIssue = new Map(input.grooming.map((row) => [row.issue, row]));
426
+ const activeIssues = new Set(input.active.map((run) => run.issue));
427
+ const pool: ToSpecCandidateView[] = [];
428
+ for (const view of views) {
429
+ if (view.parked) continue;
430
+ if (
431
+ toSpecCandidateExclusion(
432
+ { issue: view.issue },
433
+ { grooming: groomingByIssue.get(view.issue), active: activeIssues.has(view.issue) },
434
+ input.now,
435
+ ) !== undefined
436
+ ) {
437
+ continue;
438
+ }
439
+ let children: { number: number; state: IssueState }[];
440
+ try {
441
+ children = await input.seam.childrenOf(input.project, view.issue);
442
+ } catch {
443
+ // Eligibility was not mechanically established for this candidate, so it
444
+ // sits out this pass rather than being launched on an unproven fact.
445
+ continue;
446
+ }
447
+ if (children.length > 0) continue; // parent/epic with sub-issues: no runnable slice
448
+ pool.push(view);
449
+ if (pool.length >= TO_SPEC_BATCH_MAX) break;
450
+ }
451
+ // The one shared rule set re-runs on the tracker-vetted pool, so the pure
452
+ // selector — not a prose instruction — answers what may be launched.
453
+ return selectToSpecBatch({
454
+ candidates: pool,
455
+ summary: input.summary,
456
+ groomBelow: input.groomBelow,
457
+ grooming: input.grooming,
458
+ active: input.active,
459
+ now: input.now,
460
+ });
461
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The daemon HTTP surface's bearer token (Phase 4, chokepoints).
3
+ *
4
+ * The daemon's loopback port used to be deliberately unauthenticated: the
5
+ * comment above its `Bun.serve` called the surface loopback-only by design and
6
+ * left `POST /wake` and `PUT /runs/:issue/*` open. On a shared host that is a
7
+ * mutating surface any local process — any browser page that can reach
8
+ * 127.0.0.1, any other tenant's job — can drive: stop a live worker, raise a
9
+ * turn ceiling, wake dispatch in a loop. This module is the credential that
10
+ * closes it. `GET /healthz` stays open, because `requireDaemonControl` probes
11
+ * it to find a live unit-owned daemon *before* it has any reason to hold a
12
+ * token, and a health probe mutates nothing.
13
+ *
14
+ * ## Why not the dashboard's token
15
+ *
16
+ * The discipline here is copied verbatim from `dashboard/server.ts`'s
17
+ * `ensureDashboardToken` — 0700 state dir, 0600 temp file, atomic rename,
18
+ * explicit chmod, hash-then-compare — but the *token* is a separate file on
19
+ * purpose:
20
+ *
21
+ * - Different surface. The dashboard token guards a read-only HTTP server in
22
+ * its own process; this one guards the daemon's mutating run control.
23
+ * - Different lifetime. The dashboard mints on dashboard start; this one is
24
+ * minted by the daemon at start. Either can be rotated by deleting its file
25
+ * and restarting only that process.
26
+ * - Different blast radius. This token is handed to every CLI caller
27
+ * (`extend`, `worker`, `board`, `wake`) and to the dashboard process.
28
+ * Handing those the dashboard's token would mean any of them could also
29
+ * drive the dashboard's whole `/api/*` surface, and a leak from a CLI
30
+ * invocation would compromise both. One credential per surface keeps the
31
+ * failure contained.
32
+ *
33
+ * ## Minting is the daemon's job, never a client's
34
+ *
35
+ * {@link ensureHttpToken} is called by the daemon at start. Clients call
36
+ * {@link readHttpToken} and refuse when it is absent: an absent token means the
37
+ * daemon has never run under this `$OMP_CONDUCTOR_HOME`, so a client that
38
+ * minted one would be inventing a credential the daemon does not know and
39
+ * turning a clear "the daemon is not running" into a 401 nobody can explain.
40
+ */
41
+
42
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
43
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+ import { stateDir } from "./config.ts";
46
+
47
+ /** The token's filename under `$OMP_CONDUCTOR_HOME`, beside `dashboard-token`. */
48
+ export const HTTP_TOKEN_FILE = "http-token";
49
+
50
+ /** Where the daemon's bearer token lives, next to config.json. */
51
+ export function httpTokenPath(): string {
52
+ return join(stateDir(), HTTP_TOKEN_FILE);
53
+ }
54
+
55
+ /** The stored token, or undefined when the daemon has never started here. */
56
+ export function readHttpToken(): string | undefined {
57
+ try {
58
+ const raw = readFileSync(httpTokenPath(), "utf8").trim();
59
+ return raw.length > 0 ? raw : undefined;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ function hashToken(value: string): Buffer {
66
+ return createHash("sha256").update(value).digest();
67
+ }
68
+
69
+ /**
70
+ * Mints `<stateDir()>/http-token` on first daemon start; later starts reuse the
71
+ * existing token so every already-configured client keeps working across a
72
+ * restart. Write discipline is `writeConfigRaw`'s: the state dir is created
73
+ * 0700 (chmod only what this call created, so an operator-chosen mode
74
+ * survives), and the token is written 0600 through a same-directory temp file
75
+ * plus an atomic rename, so a crash mid-write leaves the previous token intact
76
+ * rather than a truncated one that authenticates nobody.
77
+ */
78
+ export function ensureHttpToken(): string {
79
+ const existing = readHttpToken();
80
+ if (existing !== undefined) return existing;
81
+
82
+ const token = randomBytes(32).toString("base64url");
83
+ const dir = stateDir();
84
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
85
+ if (created !== undefined) chmodSync(dir, 0o700);
86
+
87
+ const target = httpTokenPath();
88
+ const tmp = join(dir, `.${HTTP_TOKEN_FILE}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
89
+ try {
90
+ writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
91
+ renameSync(tmp, target);
92
+ } catch (err) {
93
+ rmSync(tmp, { force: true });
94
+ throw err;
95
+ }
96
+ // The rename preserves the temp's 0600; state it anyway so the promise never
97
+ // leans on umask moods.
98
+ chmodSync(target, 0o600);
99
+ return token;
100
+ }
101
+
102
+ /**
103
+ * Constant-time `Authorization: Bearer <token>` check.
104
+ *
105
+ * Takes the raw header (or null, which is what `Headers.get` returns when it is
106
+ * absent) so every route checks the same way and no caller has to remember to
107
+ * strip the scheme. Both sides are hashed to a fixed 32 bytes before the
108
+ * compare, so a wrong-length candidate cannot leak the stored token's length
109
+ * through `timingSafeEqual`'s length check — the same reason
110
+ * `verifyDashboardToken` hashes first.
111
+ */
112
+ export function verifyHttpToken(header: string | null, token: string): boolean {
113
+ if (header === null || token.length === 0) return false;
114
+ const match = /^Bearer[ \t]+(\S+)$/.exec(header.trim());
115
+ if (match === null) return false;
116
+ return timingSafeEqual(hashToken(match[1]!), hashToken(token));
117
+ }
118
+
119
+ /**
120
+ * The refusal every client prints when the token file is absent, worded once so
121
+ * `extend`, `worker`, `board`, `wake` and the dashboard proxy cannot drift.
122
+ * Names the file and the cause: the daemon mints it at start, so no file means
123
+ * no daemon has run here.
124
+ */
125
+ export function missingHttpTokenMessage(): string {
126
+ return (
127
+ `no daemon HTTP token at ${httpTokenPath()} — the daemon mints it at start, ` +
128
+ "so this means no daemon has run under this OMP_CONDUCTOR_HOME. " +
129
+ "Start it with `omp-conductor start` and retry."
130
+ );
131
+ }
132
+
133
+ /**
134
+ * The `Authorization` header every mutating client must send, or `undefined`
135
+ * when there is no token to send. Callers turn `undefined` into
136
+ * {@link missingHttpTokenMessage} — never into an unauthenticated request that
137
+ * would come back as a bare 401.
138
+ */
139
+ export function httpAuthHeader(): { authorization: string } | undefined {
140
+ const token = readHttpToken();
141
+ return token === undefined ? undefined : { authorization: `Bearer ${token}` };
142
+ }