omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. 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,24 +235,50 @@ 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:
243
- - verdict `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
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 exactly these fields — the generated task
258
+ schema and the persistence parser are that one contract, so an extra or
259
+ missing field is refused as malformed:
260
+ - `verdict` — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
244
261
  `NEEDS PRODUCT DECISION`
245
- - routing — exactly one repo, or `MULTI` with the split
246
- - evidencethe file or symbol proving `ALREADY DONE`, never a title match
247
- - entry points — the 3-6 files to change or read first
248
- - existing tests covering the behaviour, by path
249
- - the one thing most likely to be silently faked
250
- - source — where the code was read: the clone/ref and how fresh it is. A
262
+ - `routing` — exactly one `owner/repo`, or `MULTI`
263
+ - `routingSplit`required iff `routing` is `MULTI`: what each slice goes to
264
+ - `source`where the code was read: the clone/ref and how fresh it is. A
251
265
  scout that cannot reach a source it trusts returns `BLOCKED` and says so;
252
266
  silent fallback to an unnamed source is the failure mode of delegated
253
267
  research — stale evidence reads exactly like good evidence.
268
+ - `evidence` — the files/symbols proving the verdict; required for
269
+ `ALREADY DONE` (the file or symbol that already does the work, never a
270
+ title match)
271
+ - `laterWorkInvalidates` — whether later work retired the premise
272
+ - `laterWorkNote` — what that check searched and found
273
+ - `entryPoints` — the 3-6 files to change or read first
274
+ - `existingTests` — existing tests covering the behaviour, by path
275
+ - `likelySilentFake` — the one thing most likely to be silently faked
276
+ - `proofCommands` — the focused commands that prove the work
277
+ - `fileLane` — the files and directories this slice writes
278
+ - `dependencies` — open prerequisite issue numbers, bare (`875`) or string
279
+ (`"875"`); `[]` when none
280
+ - `proposedBrief` — required iff `verdict` is `PROMOTABLE`
281
+ - `reasonNotToPromote` — required for every other verdict
254
282
  - **Disqualifying an issue is a successful grooming outcome.** Measured on this
255
283
  package's own fleet: four scouts over sixteen backlog issues promoted four and
256
284
  *disqualified six* that looked promotable from their titles — four written
@@ -440,6 +468,13 @@ neither target — then return to the duty you were in the
440
468
  middle of and finish it. Never abandon or restart the tick because a message
441
469
  arrived, and never batch the answer "for the report" — the person is waiting now.
442
470
 
471
+ The answer itself can make previously blocked work actionable immediately: if
472
+ it resolves a parked decision row, finish the newly reachable approved work in
473
+ this same turn — resolve the row, apply what it approved — then return to the
474
+ standing duty and finish it. Never defer that work to a later tick: executing
475
+ an approved answer is not proposing a new amendment, and the
476
+ at-most-one-proposal-per-autonomous-tick throttle does not apply to it.
477
+
443
478
  ## Escalation tiers
444
479
 
445
480
  | Tier | Meaning | Handled by |
@@ -738,6 +773,12 @@ your next report as `pending amendment: <one-liner> — say 'apply it' or 'drop
738
773
  it'`, and never re-open the yes/no dialog.
739
774
 
740
775
  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.
776
+ only in the shipped package floor, never via this loop. And at most one new
777
+ orchestrator-originated amendment proposal per autonomous tick: a fresh proposal
778
+ waits for the three duties to finish, it never interrupts them.
779
+
780
+ The proposal cap throttles originating proposals, never executing approved work.
781
+ Resolving and applying multiple already-approved decision rows, and
782
+ operator-directed work in an interactive terminal session, are not proposal
783
+ creation and are not serialized by it. An amendment still never auto-applies:
784
+ "already approved" means a recorded yes — never anything less.
@@ -0,0 +1,88 @@
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
+ - `evidence` — the files/symbols that prove the verdict. Required for
48
+ `ALREADY DONE`: name the symbol/file that already does the work, never a
49
+ title match. Welcome on every other verdict.
50
+ - `laterWorkInvalidates` — boolean: did later work (an epic or issue committed
51
+ after this candidate was filed) retire its premise?
52
+ - `laterWorkNote` — what you searched for that check and what you found. Even
53
+ when false this must name the search, so "false" cannot be written without
54
+ looking.
55
+ - `entryPoints` — 3–6 files to change or read first, the discovery a worker's
56
+ budget dies on when absent.
57
+ - `existingTests` — tests that already exercise the behaviour, by path; `[]`
58
+ when you found none.
59
+ - `likelySilentFake` — the one thing most likely to be silently faked while
60
+ implementing, and how to prove it is not.
61
+ - `proofCommands` — the focused commands that prove the work, each with its
62
+ `cwd` when it matters.
63
+ - `fileLane` — the files and directories this slice writes.
64
+ - `dependencies` — open prerequisite issue numbers, each a bare number
65
+ (`875`) or a string (`"875"`); `[]` when none.
66
+ - `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
67
+ would be dispatched with, including the silent fake and the proof commands.
68
+ - `reasonNotToPromote` — required for every other verdict: why this must not
69
+ be promoted.
70
+
71
+ ## Three traps, each of which produces a confidently wrong verdict
72
+
73
+ - **Prose is not evidence.** A verdict without the source-backed contract is
74
+ refused as malformed: every field above is required, and `source` must name
75
+ the ref you read and when.
76
+ - **Stale source reads like good source.** Judge the candidate against the
77
+ stated ref as it is now; a verdict drawn from memory of a different clone is
78
+ stale and will be refused.
79
+ - **A later epic retires the premise.** On anything old, check whether later
80
+ open work invalidated the candidate before concluding anything else. That
81
+ check is mechanical, read-only, and exactly what you are cheap at —
82
+ `laterWorkNote` must name what you searched.
83
+
84
+ ## Answer
85
+
86
+ One fenced JSON block, nothing else after it. Anything unparseable or
87
+ off-schema is persisted as `blocked(malformed)` and the candidate counts as
88
+ 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