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.
- package/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- 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
|
|
694
|
+
...(request.blocks === undefined && !degraded
|
|
695
|
+
? {}
|
|
696
|
+
: { blocks: degraded ? degradedAskBlocks(request.blocks) : request.blocks }),
|
|
466
697
|
at: now(),
|
|
467
698
|
});
|
|
468
699
|
|
|
469
|
-
|
|
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
|
|
511
|
-
delivery
|
|
512
|
-
?
|
|
513
|
-
:
|
|
514
|
-
|
|
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
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
246
|
-
-
|
|
247
|
-
-
|
|
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
|
|
742
|
-
|
|
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.
|
package/src/briefs/worker.md
CHANGED
|
@@ -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
|