omp-conductor 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
package/src/ask.ts CHANGED
@@ -74,6 +74,25 @@ export type QuestionShape =
74
74
  */
75
75
  export const AUTO_APPLIED_SUFFIX = "(auto-applied on ask timeout)";
76
76
 
77
+ /**
78
+ * The fixed sentence a decision row carries when the question could not be
79
+ * posted through a selectable surface and went out as plain text instead
80
+ * (#722). The row is the record a later reader judges "did the operator answer
81
+ * this?" from, so it must say plainly that a prose reply is not a selection:
82
+ * nothing in this pipeline ever maps free text back to an option, and the
83
+ * marker is what stops a reader from treating a chat reply as the row's
84
+ * answer. It rides the row's `blocks` free-text slot — the one place a row
85
+ * carry durable per-row prose that renders in every digest — without
86
+ * rewriting the archived question (which must stay verbatim, #741).
87
+ */
88
+ export const DEGRADED_DELIVERY_MARKER =
89
+ "question delivered as plain text (no selectable surface); a reply does not resolve it — resolve or withdraw the row by hand";
90
+
91
+ /** The `blocks` text for a degraded ask: the block list plus the marker. */
92
+ export function degradedAskBlocks(blocks: string | undefined): string | undefined {
93
+ return blocks === undefined ? DEGRADED_DELIVERY_MARKER : `${blocks}; ${DEGRADED_DELIVERY_MARKER}`;
94
+ }
95
+
77
96
  /** How often the bounded wait re-reads the decision row while waiting. */
78
97
  export const ASK_POLL_MS = 1_000;
79
98
 
@@ -337,6 +356,20 @@ export function askParameterSchema(): Record<string, unknown> {
337
356
  },
338
357
  required: ["question", "on-timeout"],
339
358
  additionalProperties: false,
359
+ // The one conditional requirement the tool has (#722): an ask that
360
+ // declares auto-proceed must name the option it would auto-apply, because
361
+ // the decision row must record what was applied and who applied it. Stated
362
+ // in the schema (not only at call time) so the model sees it next to the
363
+ // "on-timeout" property it conditions on.
364
+ allOf: [
365
+ {
366
+ if: {
367
+ properties: { "on-timeout": { const: "auto-proceed" } },
368
+ required: ["on-timeout"],
369
+ },
370
+ then: { required: ["recommended"] },
371
+ },
372
+ ],
340
373
  };
341
374
  }
342
375
 
@@ -378,6 +411,175 @@ export function askMessageFor(request: AskRequest): string {
378
411
  return lines.join("\n");
379
412
  }
380
413
 
414
+ /**
415
+ * The interactive surface a bounded ask posts through when one is available
416
+ * (#722): the same Bot API `reply_markup` `telegram_ask` posts, whose taps are
417
+ * routed by the running omp-telegram bridge. The bridge acknowledges the tap
418
+ * and writes an answer envelope into the shared prompts directory; this handle
419
+ * exists only to post the question, translate inbound envelopes into a decision
420
+ * row resolution, and clean up. It is optional by design — a caller without
421
+ * one degrades exactly as before, to the durable plain-text path with the
422
+ * degraded marker on the row.
423
+ */
424
+ export interface AskInteractiveDelivery {
425
+ /**
426
+ * Why no interactive surface is available for this ask, or undefined when it
427
+ * can post. Pure: no files, no network, no side effects — the caller decides
428
+ * the row note before it creates the row.
429
+ */
430
+ unavailableReason(request: AskRequest): string | undefined;
431
+ /**
432
+ * Post the selectable question to the operator. Never throws: every failure
433
+ * comes back as `{ ok: false, reason }` so the caller can fall back to the
434
+ * durable text path. `decisionId` is the row id and the protocol nonce, so
435
+ * the pending request and its answer are addressable by the row.
436
+ */
437
+ post(request: AskRequest, decisionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
438
+ /**
439
+ * Translate an inbound answer (a button tap, or a reply to a free-text ask)
440
+ * into the decision row. Called on every poll while the ask waits; no-op
441
+ * until an answer exists, idempotent afterwards (the row's double-resolve
442
+ * guard makes the second write a no-op anyway).
443
+ */
444
+ collect(decisionId: string): void;
445
+ /**
446
+ * Best-effort cleanup once the wait ends, any outcome: remove the prompt
447
+ * request and its answer from the shared dir, and strip the keyboard off the
448
+ * posted message so a stale tap reads as expired rather than live.
449
+ */
450
+ close(decisionId: string): void;
451
+ }
452
+
453
+ /** One inline-keyboard button, as the Bot API renders it. */
454
+ export interface AskKeyboardButton {
455
+ text: string;
456
+ callback_data: string;
457
+ }
458
+
459
+ /** The `reply_markup` of one interactive ask, one option per row plus Cancel. */
460
+ export type AskKeyboardMarkup = { inline_keyboard: AskKeyboardButton[][] };
461
+
462
+ /** The rendered interactive ask: the message text and its keyboard. */
463
+ export interface AskInteractiveRender {
464
+ text: string;
465
+ markup: AskKeyboardMarkup;
466
+ }
467
+
468
+ /** Shorten a button label to Telegram's inline-button limit, like omp-telegram. */
469
+ function clipButton(text: string, max: number): string {
470
+ const clean = text.replace(/[\r\n]+/g, " ").trim();
471
+ return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
472
+ }
473
+
474
+ /**
475
+ * The interactive render of one bounded ask: the question as a normal ask
476
+ * message (numbered options with their consequences when descriptions exist,
477
+ * the same shape `telegram_ask` posts), and a keyboard whose callbacks speak
478
+ * the shared `qa:<nonce>:<action>[:<index>]` protocol (`prompts.ts` in
479
+ * omp-telegram), so the bridge answers the ask with the *label* of the tapped
480
+ * option — never an index and never the model's numbering. Cancel closes the
481
+ * ask as withdrawn, exactly like a `decision withdraw`.
482
+ */
483
+ export function renderInteractiveAsk(request: AskRequest, nonce: string): AskInteractiveRender {
484
+ const options = request.options ?? [];
485
+ const lines = [request.question.trim()];
486
+ if (options.length === 0) {
487
+ lines.push("", "Reply with your answer as the next message, or /cancel.");
488
+ } else if (options.some((option) => option.description !== undefined)) {
489
+ lines.push("");
490
+ options.forEach((option, index) => {
491
+ const recommended = option.label === request.recommended ? " (recommended)" : "";
492
+ const description = option.description === undefined ? "" : ` — ${option.description}`;
493
+ lines.push(`${index + 1}. ${option.label}${recommended}${description}`);
494
+ });
495
+ }
496
+ const rows = options.map((option, index) => [
497
+ { text: clipButton(option.label, 60), callback_data: `qa:${nonce}:s:${index}` },
498
+ ]);
499
+ rows.push([{ text: "Cancel", callback_data: `qa:${nonce}:x` }]);
500
+ return { text: lines.join("\n"), markup: { inline_keyboard: rows } };
501
+ }
502
+
503
+ /** One answer within an answered envelope — the bridge's prompt answer shape. */
504
+ export interface AskAnswer {
505
+ id: string;
506
+ question: string;
507
+ /** The chosen option labels — the tap resolves the row with these, verbatim. */
508
+ selectedOptions: string[];
509
+ /** The reply to a free-text ask, when the question had no options. */
510
+ customInput?: string;
511
+ }
512
+
513
+ /** One answer envelope from the shared prompts dir (`<nonce>.answer.json`). */
514
+ export interface AskAnswerEnvelope {
515
+ outcome:
516
+ | { status: "answered"; answers: AskAnswer[] }
517
+ | { status: "cancelled" | "expired" | "aborted" };
518
+ }
519
+
520
+ /**
521
+ * Parse one prompt answer envelope. Malformed or unreadable input is
522
+ * undefined — "no answer yet" is never a crash.
523
+ */
524
+ export function parseAskAnswerEnvelope(raw: unknown): AskAnswerEnvelope | undefined {
525
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
526
+ const outcome = (raw as Record<string, unknown>)["outcome"];
527
+ if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) return undefined;
528
+ const record = outcome as Record<string, unknown>;
529
+ const status = record["status"];
530
+ if (status === "answered") {
531
+ const answers = record["answers"];
532
+ if (!Array.isArray(answers)) return undefined;
533
+ const parsed: AskAnswer[] = [];
534
+ for (const answer of answers) {
535
+ if (answer === null || typeof answer !== "object" || Array.isArray(answer)) return undefined;
536
+ const a = answer as Record<string, unknown>;
537
+ if (typeof a["id"] !== "string" || typeof a["question"] !== "string") return undefined;
538
+ const selected = a["selectedOptions"];
539
+ if (!Array.isArray(selected) || selected.some((label) => typeof label !== "string")) {
540
+ return undefined;
541
+ }
542
+ const customInput = a["customInput"];
543
+ if (customInput !== undefined && typeof customInput !== "string") return undefined;
544
+ parsed.push({
545
+ id: a["id"],
546
+ question: a["question"],
547
+ selectedOptions: selected as string[],
548
+ ...(customInput === undefined ? {} : { customInput }),
549
+ });
550
+ }
551
+ return { outcome: { status: "answered", answers: parsed } };
552
+ }
553
+ if (status === "cancelled" || status === "expired" || status === "aborted") {
554
+ return { outcome: { status } as AskAnswerEnvelope["outcome"] };
555
+ }
556
+ return undefined;
557
+ }
558
+
559
+ /**
560
+ * The decision-row write one envelope calls for, or none. An answer becomes a
561
+ * row resolution carrying the option *label* (or the free-text reply to a
562
+ * free-text ask) — the identity the operator actually chose; cancel becomes a
563
+ * withdrawal, so a deliberate close is never an answer; expiry/abort mean
564
+ * "nobody answered yet" and leave the row open for the bounded wait to decide.
565
+ */
566
+ export function askAnswerRowWrite(
567
+ envelope: AskAnswerEnvelope,
568
+ ): { state: "answered" | "withdrawn"; resolution: string } | undefined {
569
+ if (envelope.outcome.status === "cancelled") {
570
+ return { state: "withdrawn", resolution: "Question cancelled by the operator" };
571
+ }
572
+ if (envelope.outcome.status !== "answered") return undefined;
573
+ const first = envelope.outcome.answers[0];
574
+ if (first === undefined) return undefined;
575
+ const resolution =
576
+ first.customInput !== undefined && first.customInput.trim() !== ""
577
+ ? first.customInput.trim()
578
+ : first.selectedOptions.join(", ");
579
+ if (resolution.trim() === "") return undefined;
580
+ return { state: "answered", resolution };
581
+ }
582
+
381
583
  /** One read of the decision row; `undefined` means the id is unknown. */
382
584
  export type DecisionReader = (id: string) => DecisionRecord | undefined;
383
585
 
@@ -388,6 +590,14 @@ export interface BoundedAskDeps {
388
590
  /** Advance one poll; injected so tests stay deterministic. */
389
591
  wait: (ms: number) => Promise<void>;
390
592
  now: () => number;
593
+ /**
594
+ * Called once per poll, before the row read: the seam through which an
595
+ * interactive delivery surface (a Telegram button tap, #722) translates an
596
+ * inbound answer into a decision-row write the read then sees. Optional and
597
+ * synchronous on purpose — the wait must never depend on anything slower
598
+ * than the row itself.
599
+ */
600
+ settle?: () => void;
391
601
  }
392
602
 
393
603
  export type BoundedAskOutcome =
@@ -404,6 +614,7 @@ export type BoundedAskOutcome =
404
614
  export async function runBoundedAsk(deps: BoundedAskDeps): Promise<BoundedAskOutcome> {
405
615
  const deadline = deps.now() + deps.ceilingMs;
406
616
  for (;;) {
617
+ deps.settle?.();
407
618
  const row = deps.read(deps.decisionId);
408
619
  if (row !== undefined && row.state !== "open") {
409
620
  return row.state === "answered"
@@ -438,6 +649,13 @@ export interface AskDeps {
438
649
  turnBudgetSeconds?: number;
439
650
  /** Delivers the question through the sanctioned durable path. */
440
651
  deliver(questionText: string, category: InterruptCategory): Promise<AskDeliveryResult>;
652
+ /**
653
+ * The selectable delivery surface (#722): the same Bot API buttons
654
+ * `telegram_ask` uses, whose taps resolve the decision row with the chosen
655
+ * option label. Absent or unavailable → the ask degrades to the durable
656
+ * text path, and the row records the degraded delivery.
657
+ */
658
+ interactive?: AskInteractiveDelivery;
441
659
  wait?: (ms: number) => Promise<void>;
442
660
  now?: () => number;
443
661
  }
@@ -459,14 +677,43 @@ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<As
459
677
  const now = deps.now ?? Date.now;
460
678
  const wait = deps.wait ?? sleep;
461
679
 
680
+ // The delivery mode is decided before the row exists: the degraded marker
681
+ // must ride the row's `blocks` from birth, and the row must exist before any
682
+ // delivery attempt (a crash at any point leaves a recorded question). The
683
+ // interactive preview is pure — no files, no network — so it can run here
684
+ // without violating that ordering.
685
+ const degradedReason =
686
+ deps.interactive === undefined
687
+ ? "no interactive delivery surface is mounted for this session"
688
+ : deps.interactive.unavailableReason(request);
689
+ const degraded = degradedReason !== undefined;
690
+
462
691
  const row = deps.store.createDecision({
463
692
  project: deps.project,
464
693
  question: request.question,
465
- ...(request.blocks === undefined ? {} : { blocks: request.blocks }),
694
+ ...(request.blocks === undefined && !degraded
695
+ ? {}
696
+ : { blocks: degraded ? degradedAskBlocks(request.blocks) : request.blocks }),
466
697
  at: now(),
467
698
  });
468
699
 
469
- const delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
700
+ // Interactive first: posting selectable options is the whole fix (#722).
701
+ // A preview that passed but a post that failed falls back to the durable
702
+ // text path below, and the result names the failure — the row keeps its
703
+ // clean blocks in that rare race, because the note is decided at birth and
704
+ // the row has no update surface by design.
705
+ let interactivePosted = false;
706
+ let postFailure: string | undefined;
707
+ if (!degraded) {
708
+ const posted = await deps.interactive!.post(request, row.id);
709
+ interactivePosted = posted.ok;
710
+ if (!posted.ok) postFailure = posted.reason;
711
+ }
712
+
713
+ let delivery: AskDeliveryResult | undefined;
714
+ if (!interactivePosted) {
715
+ delivery = await deps.deliver(askMessageFor(request), request.category ?? "decision-needed");
716
+ }
470
717
 
471
718
  const ceilingSeconds = resolveAskCeilingSeconds(
472
719
  request.timeoutSeconds,
@@ -479,7 +726,9 @@ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<As
479
726
  read: (id) => deps.store.decision(id),
480
727
  wait,
481
728
  now,
729
+ settle: interactivePosted ? () => deps.interactive!.collect(row.id) : undefined,
482
730
  });
731
+ if (interactivePosted) deps.interactive!.close(row.id);
483
732
 
484
733
  let text: string;
485
734
  if (outcome.kind === "answered") {
@@ -507,11 +756,23 @@ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<As
507
756
  `state (e.g. unlabel it or move it to a parked state), then note it in your report.`;
508
757
  }
509
758
 
510
- const delivered =
511
- delivery.kind === "sent"
512
- ? `The question went to the operator as a ${delivery.category} message.`
513
- : `The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
514
- "daemon releases it with the next digest or working-hours catch-up.";
759
+ const deliveryLine =
760
+ delivery === undefined
761
+ ? ""
762
+ : delivery.kind === "sent"
763
+ ? ` The question went to the operator as a ${delivery.category} message.`
764
+ : ` The question is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
765
+ "daemon releases it with the next digest or working-hours catch-up.";
766
+ const delivered = interactivePosted
767
+ ? "The question went to the operator as selectable options (button taps); a tap resolves " +
768
+ "the decision row with the chosen option, and a free-text ask is answered by replying."
769
+ : degraded
770
+ ? `The interactive surface is unavailable for this call (${degradedReason}), so the decision row ` +
771
+ `records the degraded delivery: the operator's prose reply is NOT an answer to this row and ` +
772
+ `does not resolve it — if they answer in prose, resolve or withdraw the row by hand ` +
773
+ `(omp-conductor decision resolve|withdraw ${row.id}).${deliveryLine}`
774
+ : `The interactive question could not be posted (${postFailure}) and the ask fell back to the ` +
775
+ `durable text path.${deliveryLine}`;
515
776
 
516
777
  return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
517
778
  }
package/src/board.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  workerPhasesFromHealthz,
12
12
  } from "./fleet.ts";
13
13
  import type { FleetLayers, TelegramHealth } from "./status-render.ts";
14
- import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
14
+ import { probeCodeGraph, DEFAULT_DEPS, type CodeGraphHealth } from "./graph-health.ts";
15
15
  import { healthCheck, livingDaemon } from "./lifecycle.ts";
16
16
  import type { WorkerPausePhase } from "./worker.ts";
17
17
  import { dbPath, openStore } from "./store.ts";
@@ -109,7 +109,7 @@ function isLastRun(run: RunRecord, labels: BoardLabels): boolean {
109
109
  return false;
110
110
  }
111
111
 
112
- type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
112
+ type DaemonBoardState = "stopped" | "ok" | "unreachable" | "unresponsive" | "other-project";
113
113
 
114
114
  /**
115
115
  * A card the board can only describe from tracker state, because the store has
@@ -940,12 +940,26 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProb
940
940
  const classified = classifyDaemonProjectHealth(record, health, project.name);
941
941
  const daemon: DaemonBoardState = classified.kind;
942
942
  const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
943
+ const store = openStore(dbPath());
944
+ let codeGraph: CodeGraphHealth;
945
+ try {
946
+ codeGraph =
947
+ cachedGraph ??
948
+ (await probeCodeGraph(project, {
949
+ ...DEFAULT_DEPS,
950
+ // The runtime half of the code-graph finding (#726): grounded in
951
+ // dispatched runs' own observations, never in the daemon's process.
952
+ graphToolsObservations: () => store.graphToolsObservationCounts(project.name),
953
+ }));
954
+ } finally {
955
+ store.close();
956
+ }
943
957
  return {
944
958
  health: {
945
959
  layers,
946
960
  telegram,
947
961
  daemon,
948
- codeGraph: cachedGraph ?? (await probeCodeGraph(project)),
962
+ codeGraph,
949
963
  },
950
964
  pausedPhases:
951
965
  daemon === "ok"
@@ -212,10 +212,12 @@ Keep the queue worth draining.
212
212
  spent proving what the second one already proved.
213
213
  - **A decomposition has a required output.** When an issue is too big for one
214
214
  budget, do not re-file it smaller and hope. Produce an epic plus children, and
215
- give every child, in its body: the files it writes, its acceptance criteria,
216
- the one thing most likely to be silently faked, and the commands that prove
217
- it. A child missing any of those is not a slice, it is the same issue with a
218
- smaller title.
215
+ give every child, in its body: the files it writes under an
216
+ `## Exact write lane` heading, one backticked path per bullet
217
+ (`- \`omp/src/a.ts\` why it changes`), the spelling admission parses
218
+ (#825) — its acceptance criteria, the one thing most likely to be silently
219
+ faked, and the commands that prove it. A child missing any of those is not a
220
+ slice, it is the same issue with a smaller title.
219
221
  - **Record the decomposition on the parent, not in a chat turn.** The parent
220
222
  carries the child list, the order they must land in, and one sentence per
221
223
  child saying why it is a separate slice — a shared file lane, a dependency,
@@ -233,13 +235,26 @@ Keep the queue worth draining.
233
235
  has to produce roughly three well-specced issues in the time one worker takes
234
236
  to finish one. Auditing candidates serially cannot keep up, and the board then
235
237
  reads "0 ready / workers idle" while you are doing exactly what this duty asks.
236
- When the queue is below the grooming trigger, fan out read-only `scout`
237
- subagents over backlog clusters **in one batch** rather than auditing one issue
238
- at a time. Scouts do the finding; you still do the deciding and you still write
239
- the brief. Name the authoritative source in every scout brief and forbid
240
- unnamed fallbacks. The quality bar above does not move.
241
- - **Give every scout the same return contract**, or it comes back with prose
242
- nobody can act on:
238
+ When the queue is below the grooming trigger, the tick's **bounded to-spec
239
+ batch** block (#777) carries the mechanical contract: it names the launch
240
+ token, the candidates the conductor selected mechanically from the live
241
+ open-issue snapshot (parked, parent/epic, already-groomed, in-flight,
242
+ lane/dependency-blocked and dispatched issues were excluded by code, never
243
+ by your judgement), the exclusions that selection applied, and the item
244
+ shape. Do exactly what it says — those candidates are the only batch this
245
+ tick authorizes: render `omp/src/briefs/to-spec.md` for each of them with
246
+ the current source head, launch exactly one `task` batch through the
247
+ `to-spec` agent with the listed items and no substitutes, and persist every
248
+ returned result with the `conductor_to_spec_result` tool. The `tool_call`
249
+ gate stamps the strict schema, records in-flight rows and refuses any item
250
+ that is not on the list; the strict parser and the grooming table decide
251
+ what persists. Agents research and specify; you still decide and you still
252
+ write the brief. Name the authoritative source (the repo and the exact
253
+ ref) in every item and forbid unnamed fallbacks — a verdict without a
254
+ source/ref/freshness is refused as blocked, never groomed. The quality bar
255
+ above does not move.
256
+ - **Every to-spec agent answers the same return contract**, or its output is
257
+ refused: the strict schema carries
243
258
  - verdict — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
244
259
  `NEEDS PRODUCT DECISION`
245
260
  - routing — exactly one repo, or `MULTI` with the split
@@ -440,6 +455,13 @@ neither target — then return to the duty you were in the
440
455
  middle of and finish it. Never abandon or restart the tick because a message
441
456
  arrived, and never batch the answer "for the report" — the person is waiting now.
442
457
 
458
+ The answer itself can make previously blocked work actionable immediately: if
459
+ it resolves a parked decision row, finish the newly reachable approved work in
460
+ this same turn — resolve the row, apply what it approved — then return to the
461
+ standing duty and finish it. Never defer that work to a later tick: executing
462
+ an approved answer is not proposing a new amendment, and the
463
+ at-most-one-proposal-per-autonomous-tick throttle does not apply to it.
464
+
443
465
  ## Escalation tiers
444
466
 
445
467
  | Tier | Meaning | Handled by |
@@ -738,6 +760,12 @@ your next report as `pending amendment: <one-liner> — say 'apply it' or 'drop
738
760
  it'`, and never re-open the yes/no dialog.
739
761
 
740
762
  Two limits. You never propose relaxing **Hard boundaries** — that section changes
741
- only in the shipped package floor, never via this loop. And at most one proposal
742
- per tick: an amendment waits for the three duties to finish, it never interrupts
743
- them.
763
+ only in the shipped package floor, never via this loop. And at most one new
764
+ orchestrator-originated amendment proposal per autonomous tick: a fresh proposal
765
+ waits for the three duties to finish, it never interrupts them.
766
+
767
+ The proposal cap throttles originating proposals, never executing approved work.
768
+ Resolving and applying multiple already-approved decision rows, and
769
+ operator-directed work in an interactive terminal session, are not proposal
770
+ creation and are not serialized by it. An amendment still never auto-applies:
771
+ "already approved" means a recorded yes — never anything less.
@@ -0,0 +1,84 @@
1
+ # to-spec — groom one backlog candidate into a verified, to-spec result
2
+
3
+ You are grooming exactly ONE backlog candidate for a conductor fleet: you read
4
+ candidate and source, and you return one strict JSON verdict. You never change
5
+ anything. You have reading tools only — no shell, no editor, no GitHub verbs,
6
+ no task spawning, no label changes. The whole of your work is the structured
7
+ result below, and the fleet treats anything else as a failed grooming pass.
8
+
9
+ ## The candidate
10
+
11
+ - **Tracker:** {{TRACKER_REPO}} issue **#{{ISSUE_NUMBER}}** — {{CANDIDATE_TITLE}}
12
+ - **Issue body:** {{ISSUE_BODY}}
13
+ - **Authoritative source:** {{SOURCE}} at ref `{{SOURCE_REF}}`. Read the issue's
14
+ premise against the code *in that source, at that ref* — never from memory,
15
+ never from another checkout, never from the issue alone.
16
+
17
+ ## The verdict
18
+
19
+ Exactly one of these five strings, nothing else:
20
+
21
+ - `ALREADY DONE` — the work already exists in the source (a later epic retired
22
+ the issue's premise counts as done; prove it with the symbol/file, never the
23
+ title).
24
+ - `PROMOTABLE` — well-specified, fits one worker budget, and the acceptance
25
+ criteria are checkable; carries the proposed brief.
26
+ - `NEEDS DECOMPOSITION` — the plan is real but too big for one budget; say what
27
+ slices it splits into and why each is a separate slice.
28
+ - `BLOCKED` — a named open prerequisite, lane, or credential gap stands in the
29
+ way. If you could not reach a source you trust, this is the verdict, with the
30
+ reason naming what failed — never an invented fallback source.
31
+ - `NEEDS PRODUCT DECISION` — the issue cannot proceed until a human decides
32
+ product shape, slice order, or scope; state the one question that unblocks it.
33
+
34
+ ## The return contract
35
+
36
+ Answer in **one fenced JSON block, nothing else after it**, matching the
37
+ JSON Schema this invocation validates against exactly — strict means every
38
+ required field and no extra keys. The fields are:
39
+
40
+ - `verdict` — one of the five strings above.
41
+ - `routing` — exactly one `owner/repo`, or `"MULTI"`.
42
+ - `routingSplit` — required iff `routing` is `"MULTI"`: what each slice goes to.
43
+ - `source` — `{ name, ref, freshAt }`: the authoritative source you read,
44
+ the exact ref, and `freshAt` = epoch milliseconds when you actually observed
45
+ it. Conductor refuses results whose source is older than 24 hours or missing
46
+ name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
47
+ - `laterWorkInvalidates` — boolean: did later work (an epic or issue committed
48
+ after this candidate was filed) retire its premise?
49
+ - `laterWorkNote` — what you searched for that check and what you found. Even
50
+ when false this must name the search, so "false" cannot be written without
51
+ looking.
52
+ - `entryPoints` — 3–6 files to change or read first, the discovery a worker's
53
+ budget dies on when absent.
54
+ - `existingTests` — tests that already exercise the behaviour, by path; `[]`
55
+ when you found none.
56
+ - `likelySilentFake` — the one thing most likely to be silently faked while
57
+ implementing, and how to prove it is not.
58
+ - `proofCommands` — the focused commands that prove the work, each with its
59
+ `cwd` when it matters.
60
+ - `fileLane` — the files and directories this slice writes; `dependencies` —
61
+ open prerequisites, `[]` when none.
62
+ - `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
63
+ would be dispatched with, including the silent fake and the proof commands.
64
+ - `reasonNotToPromote` — required for every other verdict: why this must not
65
+ be promoted.
66
+
67
+ ## Three traps, each of which produces a confidently wrong verdict
68
+
69
+ - **Prose is not evidence.** A verdict without the source-backed contract is
70
+ refused as malformed: every field above is required, and `source` must name
71
+ the ref you read and when.
72
+ - **Stale source reads like good source.** Judge the candidate against the
73
+ stated ref as it is now; a verdict drawn from memory of a different clone is
74
+ stale and will be refused.
75
+ - **A later epic retires the premise.** On anything old, check whether later
76
+ open work invalidated the candidate before concluding anything else. That
77
+ check is mechanical, read-only, and exactly what you are cheap at —
78
+ `laterWorkNote` must name what you searched.
79
+
80
+ ## Answer
81
+
82
+ One fenced JSON block, nothing else after it. Anything unparseable or
83
+ off-schema is persisted as `blocked(malformed)` and the candidate counts as
84
+ not groomed — a refusal is a failed grooming, not a free pass to skip it.
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
47
47
 
48
48
  {{ACCEPTANCE_CRITERIA}}
49
49
 
50
- {{ISSUE_COMMENTS}}{{FILE_LANE}}## How to work
50
+ {{ISSUE_COMMENTS}}{{FILE_LANE}}{{MODEL}}## How to work
51
51
 
52
52
  1. **Understand before editing — and ask the graph before you grep.** Your turns
53
53
  are mostly spent finding code, not writing it, and running out of turns
@@ -111,6 +111,7 @@ These are the exact gates for `{{REPO}}`:
111
111
  {{GATES}}
112
112
 
113
113
  {{SHARED_HOST_NOTICE}}
114
+ {{HOST_CONSTRAINTS}}
114
115
 
115
116
  Run every one of them, from the directory listed, over the **whole tree** — not
116
117
  just the directory you edited. Linting only the source dir is how an error in a
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ import { dashboardCommand } from "./commands/dashboard.ts";
18
18
  import { decisionCommand } from "./commands/decision.ts";
19
19
  import { disarmCommand } from "./commands/disarm.ts";
20
20
  import { doctorCommand } from "./commands/doctor.ts";
21
+ import { drainCommand } from "./commands/drain.ts";
21
22
  import { eventCommand } from "./commands/event.ts";
22
23
  import { extendCommand } from "./commands/extend.ts";
23
24
  import { frictionCommand } from "./commands/friction.ts";
@@ -178,6 +179,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
178
179
  board: () => boardCommand(ctx),
179
180
  dashboard: () => dashboardCommand(ctx),
180
181
  hold: () => holdCommand(ctx),
182
+ drain: () => drainCommand(ctx),
181
183
  arm: () => armCommand(ctx),
182
184
  disarm: () => disarmCommand(ctx),
183
185
  tail: () => tailCommand(ctx),
@@ -87,6 +87,17 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
87
87
  hold soft stop: pause claiming AND disarm ticks. Daemon and pane stay up.
88
88
  This is "stop the conductor overnight" without killing processes.
89
89
  Use --all to target every configured project.
90
+ drain start, inspect, or cancel the project's self-expiring admission
91
+ drain — the durable, bounded alternative to queue-label churn
92
+ before a release. New claims pause while active runs settle, and
93
+ admission resumes at the absolute deadline on its own, even if the
94
+ orchestrator dies. start takes --until (an ISO instant or a
95
+ relative duration such as 90s/45m/2h/1d, always bounded and in the
96
+ future) and an optional --reason; status reports the active drain's
97
+ creation time, expiry, reason and remaining active runs; cancel
98
+ removes it and is idempotent. The drain never touches the pause
99
+ sentinel, the arm marker, or any queue label — it is a file record,
100
+ not a timer or a hold.
90
101
  arm proof-gated: send a Telegram challenge and write the arm marker only
91
102
  after your reply appears as a user turn in the orchestrator transcript.
92
103
  Never auto-armed by resume/hold. Use --all for every project.
@@ -205,6 +205,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
205
205
  usage: ["hold [--keep-ticks] [--project NAME | --all]"],
206
206
  flags: [toggle("--keep-ticks", "pause claims without disarming ticks"), project(), all()],
207
207
  },
208
+ {
209
+ name: "drain",
210
+ description: "start, inspect, or cancel the project's self-expiring admission drain",
211
+ scope: "project",
212
+ usage: [
213
+ "drain start --until ISO|DURATION [--reason TEXT] [--project NAME]",
214
+ "drain status [--project NAME]",
215
+ "drain cancel [--project NAME]",
216
+ ],
217
+ subcommands: [
218
+ { name: "start", description: "record a bounded drain intent" },
219
+ { name: "status", description: "report the drain and its remaining active runs" },
220
+ { name: "cancel", description: "remove the project's drain" },
221
+ ],
222
+ flags: [
223
+ value("--until", "absolute ISO instant or relative duration (90s, 45m, 2h, 1d)"),
224
+ value("--reason", "purpose, persisted on the drain record"),
225
+ project(),
226
+ ],
227
+ positionals: [{ name: "action" }],
228
+ },
208
229
  {
209
230
  name: "arm",
210
231
  description: "prove Telegram delivery and arm scheduled ticks",
@@ -475,6 +496,7 @@ export function renderUsage(manifest: readonly CommandManifestEntry[] = COMMAND_
475
496
  ...details,
476
497
  "recipes:",
477
498
  " hold no claims, no tick sends (inspectable)",
499
+ " drain start bounded release window, self-expiring (no hold)",
478
500
  " stop hold + stop dispatch daemon",
479
501
  " stop --pane stop + pin conductor-pane recovery off",
480
502
  " resume clear pause and pane pin (ticks stay disarmed)",
@@ -82,6 +82,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
82
  // project — exactly one project; findProject demands --project when several
83
83
  "brief-upgrade": "project",
84
84
  decision: "project",
85
+ drain: "project",
85
86
  event: "project",
86
87
  extend: "project",
87
88
  friction: "project",