omp-conductor 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/ask.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * decision — nothing here reopens that clock.
28
28
  */
29
29
 
30
- import type { DecisionRecord, InterruptCategory, Store } from "./types.ts";
30
+ import type { DecisionRecord, DecisionState, InterruptCategory, Store } from "./types.ts";
31
31
  import { INTERRUPT_CATEGORIES } from "./types.ts";
32
32
 
33
33
  /** The bounded ask tool this package registers on an orchestrator session. */
@@ -373,6 +373,34 @@ export function askParameterSchema(): Record<string, unknown> {
373
373
  };
374
374
  }
375
375
 
376
+ /**
377
+ * The questionnaire tool's arguments (#947): the spec it belongs to, plus items
378
+ * that are each exactly one {@link askParameterSchema}. Composed rather than
379
+ * restated, so an item can never accept something a single ask refuses.
380
+ */
381
+ export function questionnaireParameterSchema(): Record<string, unknown> {
382
+ return {
383
+ type: "object",
384
+ properties: {
385
+ "spec-issue": {
386
+ type: "number",
387
+ description:
388
+ "The issue these questions spec out. The answers become that issue's provenance, so a later reader sees why a slice is shaped the way it is instead of re-litigating it.",
389
+ },
390
+ items: {
391
+ type: "array",
392
+ minItems: 1,
393
+ maxItems: MAX_QUESTIONNAIRE_ITEMS,
394
+ description:
395
+ "The judgement calls only — anything a repo read can answer is your own work, not an operator question. Delivered as one message, answerable in any order, each item resolving independently.",
396
+ items: askParameterSchema(),
397
+ },
398
+ },
399
+ required: ["spec-issue", "items"],
400
+ additionalProperties: false,
401
+ };
402
+ }
403
+
376
404
  /**
377
405
  * The ceiling for one ask, in whole seconds. The model may name one, but the
378
406
  * enforcement never depends on it: an ask without a `timeoutSeconds` gets the
@@ -411,6 +439,149 @@ export function askMessageFor(request: AskRequest): string {
411
439
  return lines.join("\n");
412
440
  }
413
441
 
442
+ /** The tool name a spec-out questionnaire arrives on (#947). */
443
+ export const QUESTIONNAIRE_TOOL = "conductor_questionnaire";
444
+
445
+ /** How many items one delivery may carry. A questionnaire longer than this is
446
+ * not a questionnaire, it is an interview the operator will abandon halfway —
447
+ * and the ceiling that bounds the whole group is one ask's, not one per item. */
448
+ export const MAX_QUESTIONNAIRE_ITEMS = 6;
449
+
450
+ /**
451
+ * One spec-out questionnaire (#947): several questions about ONE issue,
452
+ * delivered together and answerable in any order.
453
+ *
454
+ * It is deliberately N {@link AskRequest}s plus a spec binding rather than a
455
+ * new question shape. Every per-item semantic a spec-out needs — options, a
456
+ * recommendation, `auto-proceed` versus `park`, the shape ceiling, the
457
+ * auto-applied marker — already exists and is already tested here; what did not
458
+ * exist is asking six of them once. Re-deriving those semantics for a
459
+ * "questionnaire item" would be the same contract in two places, and the second
460
+ * copy is the one that drifts.
461
+ */
462
+ export interface QuestionnaireRequest {
463
+ /** The issue these questions spec out; their answers are its provenance. */
464
+ specIssue: number;
465
+ /** The items, in delivery order. Each is an ordinary bounded ask. */
466
+ items: AskRequest[];
467
+ }
468
+
469
+ type QuestionnaireParse =
470
+ | { ok: true; request: QuestionnaireRequest }
471
+ | { ok: false; problem: string };
472
+
473
+ /**
474
+ * Validate one questionnaire call. Strict exactly like {@link parseAskRequest},
475
+ * and every item is validated through it — including the shape ceiling, which
476
+ * refuses the **whole group** rather than truncating or dropping one item. A
477
+ * partially accepted questionnaire would deliver a question set that does not
478
+ * match the rows behind it, which is the one failure this whole feature exists
479
+ * to prevent.
480
+ */
481
+ export function parseQuestionnaireRequest(raw: unknown): QuestionnaireParse {
482
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
483
+ return { ok: false, problem: `${QUESTIONNAIRE_TOOL} arguments must be an object` };
484
+ }
485
+ const record = raw as Record<string, unknown>;
486
+ const known = new Set(["spec-issue", "items"]);
487
+ for (const key of Object.keys(record)) {
488
+ if (!known.has(key)) {
489
+ return { ok: false, problem: `unknown argument "${key}" — expected spec-issue and items` };
490
+ }
491
+ }
492
+ const specIssue = record["spec-issue"];
493
+ if (typeof specIssue !== "number" || !Number.isInteger(specIssue) || specIssue <= 0) {
494
+ return {
495
+ ok: false,
496
+ problem: 'spec-issue must be the issue number these questions spec out — a questionnaire whose answers belong to nothing is chat history, not provenance',
497
+ };
498
+ }
499
+ const items = record["items"];
500
+ if (!Array.isArray(items) || items.length === 0) {
501
+ return { ok: false, problem: "items must be a non-empty array of questions" };
502
+ }
503
+ if (items.length > MAX_QUESTIONNAIRE_ITEMS) {
504
+ return {
505
+ ok: false,
506
+ problem: `${items.length} items — over the ${MAX_QUESTIONNAIRE_ITEMS}-item ceiling. Ask the judgement calls only; anything a repo read answers is not an operator question`,
507
+ };
508
+ }
509
+ const parsed: AskRequest[] = [];
510
+ for (const [index, item] of items.entries()) {
511
+ const one = parseAskRequest(item);
512
+ if (!one.ok) return { ok: false, problem: `item ${index + 1}: ${one.problem}` };
513
+ const shape = validateQuestionShape(one.request.question);
514
+ if (!shape.ok) return { ok: false, problem: `item ${index + 1}: ${shape.problem}` };
515
+ parsed.push(one.request);
516
+ }
517
+ return { ok: true, request: { specIssue, items: parsed } };
518
+ }
519
+
520
+ /**
521
+ * The questionnaire exactly as it is delivered: **one** message, its items
522
+ * numbered so an operator can answer them in any order by naming the number.
523
+ *
524
+ * One delivery is the point. N deliveries would be N interrupts for one
525
+ * decision, which is what the serial workaround already did and why nothing was
526
+ * ever answered as a set.
527
+ */
528
+ export function questionnaireMessageFor(request: QuestionnaireRequest): string {
529
+ const lines = [`QUESTION: ${request.items.length} calls to spec out #${request.specIssue} — answer by number, in any order.`];
530
+ for (const [index, item] of request.items.entries()) {
531
+ const parts = [`${index + 1}. ${item.question}`];
532
+ if (item.options !== undefined && item.options.length > 0) {
533
+ parts.push(
534
+ ` options: ${item.options
535
+ .map((option) => (option.description === undefined ? option.label : `${option.label} — ${option.description}`))
536
+ .join("; ")}`,
537
+ );
538
+ }
539
+ if (item.recommended !== undefined) parts.push(` rec: ${item.recommended}`);
540
+ // Silence is a real outcome, so it is stated per item rather than once for
541
+ // the group: a questionnaire may legitimately mix "proceed on my
542
+ // recommendation" with "this one waits for you".
543
+ parts.push(
544
+ ` on silence: ${
545
+ item.onTimeout === "auto-proceed" ? `applies ${item.recommended ?? "the recommendation"}` : "stays open"
546
+ }`,
547
+ );
548
+ lines.push(parts.join("\n"));
549
+ }
550
+ return lines.join("\n");
551
+ }
552
+
553
+ /** What the ceiling did to one still-open item. */
554
+ export type QuestionnaireItemTimeout =
555
+ | { kind: "auto-applied"; id: string; resolution: string }
556
+ | { kind: "parked"; id: string };
557
+
558
+ /**
559
+ * Apply the ceiling to a questionnaire, per item (#947).
560
+ *
561
+ * Pure, and per item on purpose. The group is one delivery but N decisions: an
562
+ * item the operator answered keeps that answer, an `auto-proceed` item resolves
563
+ * with the existing auto-applied marker so the record never reads as a human
564
+ * choice, and a `park` item stays open. Resolving the group atomically — all or
565
+ * nothing — would discard the answers the operator did give, which is the
566
+ * second silent fake this feature has to avoid.
567
+ */
568
+ export function resolveQuestionnaireTimeouts(
569
+ items: readonly { id: string; request: AskRequest; answered: boolean }[],
570
+ ): QuestionnaireItemTimeout[] {
571
+ const outcomes: QuestionnaireItemTimeout[] = [];
572
+ for (const item of items) {
573
+ if (item.answered) continue;
574
+ if (item.request.onTimeout === "auto-proceed" && item.request.recommended !== undefined) {
575
+ outcomes.push({ kind: "auto-applied", id: item.id, resolution: autoApplyResolution(item.request.recommended) });
576
+ continue;
577
+ }
578
+ // `park` — and the impossible-by-parse `auto-proceed` with no
579
+ // recommendation, which parks rather than inventing one.
580
+ outcomes.push({ kind: "parked", id: item.id });
581
+ }
582
+ return outcomes;
583
+ }
584
+
414
585
  /**
415
586
  * The interactive surface a bounded ask posts through when one is available
416
587
  * (#722): the same Bot API `reply_markup` `telegram_ask` posts, whose taps are
@@ -776,3 +947,119 @@ export async function performAsk(request: AskRequest, deps: AskDeps): Promise<As
776
947
 
777
948
  return { outcome, decisionId: row.id, text: `${delivered}\n${text}` };
778
949
  }
950
+
951
+ /** What one {@link QUESTIONNAIRE_TOOL} call produced, per item and as text. */
952
+ export interface QuestionnaireResult {
953
+ /** The minted group id — the handle for `omp-conductor decision` on any item. */
954
+ groupId: string;
955
+ items: { id: string; question: string; state: DecisionState; resolution?: string }[];
956
+ text: string;
957
+ }
958
+
959
+ /**
960
+ * Ask one bounded spec-out questionnaire (#947).
961
+ *
962
+ * The shape mirrors {@link performAsk} deliberately — rows before delivery, one
963
+ * bounded wait, per-item timeout policy applied at the ceiling — with two
964
+ * differences that are the whole feature:
965
+ *
966
+ * 1. **One delivery.** The items go out as a single numbered message, so a
967
+ * questionnaire costs one interrupt rather than N.
968
+ * 2. **N independent resolutions.** The wait ends when every item is closed or
969
+ * the ceiling elapses, and the ceiling then applies each item's own policy.
970
+ * An item the operator answered keeps that answer.
971
+ *
972
+ * It uses the durable text path rather than posting N interactive keyboards,
973
+ * because N posts is exactly the thing it exists not to do. That means a prose
974
+ * reply is not itself a row resolution — the same contract a degraded single ask
975
+ * already has — so the result text names the ids and the command that resolves
976
+ * them.
977
+ */
978
+ export async function performQuestionnaire(
979
+ request: QuestionnaireRequest,
980
+ deps: AskDeps,
981
+ ): Promise<QuestionnaireResult> {
982
+ const now = deps.now ?? Date.now;
983
+ const wait = deps.wait ?? sleep;
984
+
985
+ // Every row durable, in one transaction, BEFORE the delivery: an operator must
986
+ // never be shown a question that no row is waiting on.
987
+ const rows = deps.store.createDecisionGroup(
988
+ request.items.map((item) => ({
989
+ project: deps.project,
990
+ question: item.question,
991
+ ...(item.blocks === undefined ? {} : { blocks: item.blocks }),
992
+ at: now(),
993
+ })),
994
+ request.specIssue,
995
+ );
996
+ const groupId = rows[0]!.groupId!;
997
+
998
+ const category = request.items[0]!.category ?? "decision-needed";
999
+ const delivery = await deps.deliver(questionnaireMessageFor(request), category);
1000
+
1001
+ const ceilingSeconds = resolveAskCeilingSeconds(
1002
+ // One ceiling for the group, taken from the shortest item that named one:
1003
+ // the whole point is that a questionnaire cannot wait longer than an ask.
1004
+ request.items.reduce<number | undefined>(
1005
+ (shortest, item) =>
1006
+ item.timeoutSeconds === undefined ? shortest : Math.min(shortest ?? item.timeoutSeconds, item.timeoutSeconds),
1007
+ undefined,
1008
+ ),
1009
+ deps.configuredCeilingSeconds,
1010
+ deps.turnBudgetSeconds ?? DEFAULT_ASK_TIMEOUT_SECONDS,
1011
+ );
1012
+ const deadline = now() + ceilingSeconds * 1_000;
1013
+ for (;;) {
1014
+ const live = deps.store.decisionGroup(groupId);
1015
+ if (live.every((row) => row.state !== "open")) break;
1016
+ if (now() >= deadline) break;
1017
+ await wait(ASK_POLL_MS);
1018
+ }
1019
+
1020
+ // The ceiling, per item. `resolveDecision` refuses a second resolution, so an
1021
+ // answer that landed in the same instant as the deadline is never overwritten.
1022
+ const atCeiling = deps.store.decisionGroup(groupId);
1023
+ for (const outcome of resolveQuestionnaireTimeouts(
1024
+ atCeiling.map((row, index) => ({
1025
+ id: row.id,
1026
+ request: request.items[index]!,
1027
+ answered: row.state !== "open",
1028
+ })),
1029
+ )) {
1030
+ if (outcome.kind === "auto-applied") {
1031
+ deps.store.resolveDecision(outcome.id, "answered", outcome.resolution, now());
1032
+ }
1033
+ }
1034
+
1035
+ const final = deps.store.decisionGroup(groupId);
1036
+ const items = final.map((row) => ({
1037
+ id: row.id,
1038
+ question: row.question,
1039
+ state: row.state,
1040
+ ...(row.resolution === undefined ? {} : { resolution: row.resolution }),
1041
+ }));
1042
+ const open = items.filter((item) => item.state === "open");
1043
+ const deliveryLine =
1044
+ delivery.kind === "sent"
1045
+ ? `The questionnaire went to the operator as one ${delivery.category} message.`
1046
+ : `The questionnaire is durably held (notice ${delivery.noticeId ?? "?"}, ${delivery.category}); the ` +
1047
+ "daemon releases it with the next digest or working-hours catch-up.";
1048
+ const perItem = items
1049
+ .map(
1050
+ (item, index) =>
1051
+ `${index + 1}. ${item.state}${item.resolution === undefined ? "" : ` — ${item.resolution}`} (${item.id})`,
1052
+ )
1053
+ .join("\n");
1054
+ const closing =
1055
+ open.length === 0
1056
+ ? "Every item is resolved. Apply the answers to the spec now."
1057
+ : `${open.length} item(s) stayed open as declared: park the work each one blocks, and note them in your ` +
1058
+ "report. A prose reply does NOT resolve these rows — map it yourself with " +
1059
+ "`omp-conductor decision resolve <id> --answer \"…\"`.";
1060
+ return {
1061
+ groupId,
1062
+ items,
1063
+ text: `${deliveryLine} Ceiling ${ceilingSeconds}s; #${request.specIssue}.\n${perItem}\n${closing}`,
1064
+ };
1065
+ }
@@ -238,11 +238,12 @@ Keep the queue worth draining.
238
238
  When the queue is below the grooming trigger, the tick's **bounded to-spec
239
239
  batch** block (#777) carries the mechanical contract: it names the launch
240
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
241
+ open-issue snapshot (parked, parent/epic, already-groomed, refused inside
242
+ their retry cooldown, in-flight, lane/dependency-blocked and dispatched
243
+ issues were excluded by code, never by your judgement), the exclusions that
244
+ selection applied, and the item shape. Do exactly what it says — those
245
+ candidates are the only batch this tick authorizes: render
246
+ `omp/src/briefs/to-spec.md` for each of them with
246
247
  the current source head, launch exactly one `task` batch through the
247
248
  `to-spec` agent with the listed items and no substitutes, and persist every
248
249
  returned result with the `conductor_to_spec_result` tool. The `tool_call`
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ import { intakeCommand } from "./commands/intake.ts";
28
28
  import { ledgerCommand } from "./commands/ledger.ts";
29
29
  import { messageCommand } from "./commands/message.ts";
30
30
  import { reportCommand } from "./commands/report.ts";
31
+ import { releaseCompositionCommand } from "./commands/release-composition.ts";
31
32
  import { restartCommand } from "./commands/restart.ts";
32
33
  import { restoreDbCommand } from "./commands/restore-db.ts";
33
34
  import { resumeCommand } from "./commands/resume.ts";
@@ -40,6 +41,7 @@ import { tailCommand } from "./commands/tail.ts";
40
41
  import { unblockCommand } from "./commands/unblock.ts";
41
42
  import { unfreezeCommand } from "./commands/unfreeze.ts";
42
43
  import { upgradeInstallCommand } from "./commands/upgrade-install.ts";
44
+ import { reconcileUnitsCommand } from "./commands/reconcile-units.ts";
43
45
  import { upgradeRollbackCommand } from "./commands/upgrade-rollback.ts";
44
46
  import { upgradeCommand } from "./commands/upgrade.ts";
45
47
  import { verbCommand } from "./commands/verb.ts";
@@ -265,6 +267,7 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
265
267
  setup: () => setupCommand(ctx),
266
268
  upgrade: () => upgradeCommand(ctx),
267
269
  "upgrade-install": () => upgradeInstallCommand(ctx),
270
+ "reconcile-units": () => reconcileUnitsCommand(ctx),
268
271
  "upgrade-rollback": () => upgradeRollbackCommand(ctx),
269
272
  daemon: () => daemonCommand(ctx),
270
273
  start: () => startCommand(ctx),
@@ -284,8 +287,9 @@ export function commandHandlers(ctx: CommandContext): Record<string, CommandHand
284
287
  extend: () => extendCommand(ctx),
285
288
  worker: () => workerCommand(ctx),
286
289
  unblock: () => unblockCommand(ctx),
287
- unfreeze: () => unfreezeCommand(ctx),
290
+ "release-composition": () => releaseCompositionCommand(ctx),
288
291
  verb: () => verbCommand(ctx),
292
+ unfreeze: () => unfreezeCommand(ctx),
289
293
  event: () => eventCommand(ctx),
290
294
  report: () => reportCommand(ctx),
291
295
  message: () => messageCommand(ctx),
@@ -10,6 +10,11 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
10
10
  brief as one pinned release. Pauses only new claims, drains live
11
11
  workers, reloads, verifies twice, and restores the prior dispatch
12
12
  state. Run it from a shell outside the target Herdr session.
13
+ --bootstrap SHA --source PATH installs an exact commit instead of a
14
+ published version, for when the installed conductor is what is
15
+ broken: the source must be a checkout AT that sha, the package's own
16
+ checks run there first, and a check failure refuses rather than
17
+ installing a partly verified tree.
13
18
  upgrade-install
14
19
  the detached executor half of the fleet-installs-itself request:
15
20
  the same transaction, journaled per surface, leaving verification
@@ -207,7 +212,10 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
207
212
  re-stage the systemd unit and run the install behind one confirm.
208
213
  The units are host-global; NAME (or --project NAME) says which
209
214
  project's per-project tail (tick config, brief link) to write, and
210
- is required on a host with several configured projects.
215
+ is required on a host with several configured projects. On a Herdr
216
+ host it restarts the session unit, so it must be run from a shell
217
+ outside that session: from a pane inside it, it refuses before
218
+ staging or installing anything.
211
219
  setup graph
212
220
  set up the code-graph indexes workers query instead of grepping, end
213
221
  to end: check prerequisites, clone any missing index-only clone as
@@ -59,12 +59,15 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
59
59
  description: "interview, configure the fleet, or install host and graph surfaces",
60
60
  scope: "project",
61
61
  usage: [
62
- "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
62
+ "setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]",
63
63
  "setup host [NAME] [--project NAME]",
64
64
  "setup graph [--no-seed] [--print] [--project NAME]",
65
65
  ],
66
66
  subcommands: [
67
- { name: "host", description: "stage and install the host services" },
67
+ {
68
+ name: "host",
69
+ description: "stage and install the host services (run it from a shell outside the Herdr session it restarts)",
70
+ },
68
71
  { name: "graph", description: "install and seed code-graph indexes" },
69
72
  ],
70
73
  flags: [
@@ -117,7 +120,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
117
120
  name: "upgrade",
118
121
  description: "upgrade all installed conductor surfaces as one transaction",
119
122
  scope: "host",
120
- usage: ["upgrade [--to VERSION] [--project NAME]"],
123
+ usage: ["upgrade [--to VERSION] [--bootstrap SHA --source PATH] [--project NAME]"],
121
124
  flags: [value("--to", "package version to install"), project()],
122
125
  },
123
126
  {
@@ -134,6 +137,18 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
134
137
  usage: ["upgrade-rollback"],
135
138
  flags: [],
136
139
  },
140
+ {
141
+ name: "reconcile-units",
142
+ description: "install the host unit files this version renders (units only)",
143
+ scope: "host",
144
+ usage: ["reconcile-units [--yes] [--dry-run] [--protect PATH]... [--project NAME]"],
145
+ flags: [
146
+ toggle("--yes", "skip the confirm (the upgrade transaction's own consent)"),
147
+ toggle("--dry-run", "print what would be installed and run nothing"),
148
+ value("--protect", "a destination to leave alone (repeatable)"),
149
+ project(),
150
+ ],
151
+ },
137
152
  {
138
153
  name: "board",
139
154
  description: "open the live keyboard-driven fleet board",
@@ -292,6 +307,24 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
292
307
  flags: [value("--reason", "reason for clearing the freeze"), project()],
293
308
  positionals: [{ name: "repo" }],
294
309
  },
310
+ {
311
+ name: "release-composition",
312
+ description: "declare, override, complete or cancel the active release",
313
+ scope: "project",
314
+ usage: [
315
+ "release-composition declare --campaign ID [--pr URL ...] [--reason TEXT] [--project NAME]",
316
+ "release-composition override --pr URL [--reason TEXT] [--project NAME]",
317
+ "release-composition complete [--reason TEXT] [--project NAME]",
318
+ "release-composition cancel [--reason TEXT] [--project NAME]",
319
+ "release-composition status [--project NAME]",
320
+ ],
321
+ flags: [
322
+ value("--campaign", "campaign identifier for the release being assembled"),
323
+ value("--pr", "full PR URL allowed into (or overridden into) the release"),
324
+ value("--reason", "reason recorded with the transition"),
325
+ project(),
326
+ ],
327
+ },
295
328
  {
296
329
  name: "verb",
297
330
  description: "run a gated conductor_* verb as the orchestrator",
@@ -15,10 +15,14 @@ for (const project of ctx.targetProjects()) {
15
15
  // Proof-neutral wording: `claim-only` performs no Telegram send, so the
16
16
  // progress line cannot promise a challenge that never goes out (#613). The
17
17
  // result line names the proof that actually armed it.
18
+ // The pending-proof heartbeat goes straight to stdout (#861): a challenge
19
+ // waits on a human for up to five minutes, and `withProgress`'s single step
20
+ // line cannot say how much of that window is left. Written unconditionally —
21
+ // a piped or plain-UI run needs it more than a TTY does, not less.
18
22
  const r = await withProgress(
19
23
  "arm: verifying the arming proof…",
20
24
  "Arming proof verified",
21
- () => armTicks(project.name),
25
+ () => armTicks(project.name, { progress: (line) => process.stdout.write(`${line}\n`) }),
22
26
  { plainMessage: true },
23
27
  );
24
28
  process.stdout.write(
@@ -89,6 +89,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
89
89
  intake: "project",
90
90
  ledger: "project",
91
91
  message: "project",
92
+ "release-composition": "project",
92
93
  report: "project",
93
94
  setup: "project", // the wizard and `setup graph` configure one project
94
95
  stats: "project",
@@ -106,6 +107,7 @@ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
106
107
  upgrade: "host",
107
108
  "upgrade-install": "host",
108
109
  "upgrade-rollback": "host",
110
+ "reconcile-units": "host",
109
111
  // fleet — every project by default, --project narrows
110
112
  arm: "fleet",
111
113
  board: "fleet",
@@ -101,6 +101,28 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
101
101
  );
102
102
  }
103
103
 
104
+ /**
105
+ * A hold reaches nobody now, so it is the wrong path for an answer somebody is
106
+ * waiting for (#882).
107
+ *
108
+ * 2026-08-21T20:48Z: a tick was interrupted by an operator message, the mandated
109
+ * targetless `telegram_send` failed, and the session fell back to this command.
110
+ * It behaved correctly — the category was non-bypass, so the answer was held for
111
+ * the 23:30 digest — and the operator sat waiting in the topic they had written
112
+ * in. "nothing was sent" was already printed and was already true; what was
113
+ * missing is that the caller is *mid-conversation* and a live path exists.
114
+ *
115
+ * So every hold names it. Conditionally, because this process cannot know
116
+ * whether an inbound message is waiting: it is a separate process from the
117
+ * session, and inventing a guess here would either nag every legitimate
118
+ * digest-only note or, worse, quietly bypass availability policy on a hunch.
119
+ * The caller knows, and one sentence is enough to make holding a choice rather
120
+ * than a default.
121
+ */
122
+ const LIVE_REPLY_HINT =
123
+ "if you are answering an operator who is waiting now, this is not the path: " +
124
+ "reply in their own topic with telegram_send (telegram_ask for a choice) — a held notice reaches nobody until then\n";
125
+
104
126
  /** The held-notice line names the future that releases it, in words an
105
127
  * operator can act on: the working-hours window opening, or the daily digest.
106
128
  * The earlier spelling — "the next digest or working-hours catch-up" — read
@@ -118,7 +140,8 @@ function heldLine(
118
140
  return (
119
141
  `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
120
142
  `held until the working-hours window opens${opening})\n` +
121
- "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
143
+ "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n" +
144
+ LIVE_REPLY_HINT
122
145
  );
123
146
  }
124
147
  const digestAt = project.reporting?.digest.at;
@@ -126,6 +149,7 @@ function heldLine(
126
149
  return (
127
150
  `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
128
151
  `digest-only — held until ${when})\n` +
129
- `nothing was sent; the daemon releases it with ${when}\n`
152
+ `nothing was sent; the daemon releases it with ${when}\n` +
153
+ LIVE_REPLY_HINT
130
154
  );
131
155
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `reconcile-units` — install the host unit files this version renders, and
3
+ * nothing else (#905).
4
+ *
5
+ * Scope: `"host"`. The units are host-global (one daemon serves every
6
+ * configured project), so this verb takes no project and reconciles the shared
7
+ * destinations. The per-project tail — tick config, brief link — stays
8
+ * `setup host`'s.
9
+ *
10
+ * It exists as its own verb because the render is code, not data: the upgrade
11
+ * transaction replaces the package underneath itself, so only a freshly
12
+ * spawned process can render what the new release ships. `upgrade` runs this
13
+ * verb with `--yes` after its install phase; an operator can run it by hand
14
+ * any time `doctor` reports `systemd-unit` drift.
15
+ */
16
+
17
+ import type { CommandContext } from "./context.ts";
18
+ import { findProject, loadConfig, resolveCaps } from "../config.ts";
19
+ import { telegramStateDir } from "../fleet.ts";
20
+ import {
21
+ planHostRuntime,
22
+ RECOVER_SCRIPT_INSTALL_PATH,
23
+ SYSTEMD_UNIT_DIR,
24
+ totalConfiguredWorkers,
25
+ } from "../setup-host.ts";
26
+ import {
27
+ formatUnitReconcile,
28
+ planUnitReconcile,
29
+ reconcileUnits,
30
+ UNIT_RECONCILE_ANSWER_KEY,
31
+ } from "../unit-reconcile.ts";
32
+ import { answersUi } from "../setup-answers.ts";
33
+ import { terminalUi } from "../wizard-ui.ts";
34
+
35
+ /** Repeatable `--protect PATH`: destinations this run must not overwrite. */
36
+ function protectedPaths(argv: readonly string[]): string[] {
37
+ const paths: string[] = [];
38
+ for (const [index, arg] of argv.entries()) {
39
+ if (arg === "--protect") {
40
+ const value = argv[index + 1];
41
+ if (value !== undefined && !value.startsWith("--")) paths.push(value);
42
+ } else if (arg.startsWith("--protect=")) {
43
+ paths.push(arg.slice("--protect=".length));
44
+ }
45
+ }
46
+ return paths;
47
+ }
48
+
49
+ export async function reconcileUnitsCommand(ctx: CommandContext): Promise<void> {
50
+ const cfg = loadConfig();
51
+ // The units are host-global. A `--project` narrows only the per-project tail
52
+ // this verb deliberately does not write, so it is accepted (the recovery
53
+ // unit encodes one project's name on a single-project host) and ignored
54
+ // otherwise.
55
+ const project = ctx.projectFlag === undefined && cfg.projects.length !== 1
56
+ ? undefined
57
+ : findProject(cfg, ctx.projectFlag);
58
+ const plan = planHostRuntime(
59
+ project,
60
+ project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
61
+ telegramStateDir(),
62
+ undefined,
63
+ totalConfiguredWorkers(cfg),
64
+ SYSTEMD_UNIT_DIR,
65
+ RECOVER_SCRIPT_INSTALL_PATH,
66
+ cfg.projects.length > 1,
67
+ );
68
+ const protect = protectedPaths(ctx.argv);
69
+ // `--no-refresh` retires without refreshing (#895). The upgrade transaction
70
+ // uses it when it could not read a pre-install drift baseline: there a
71
+ // refresh cannot tell the operator's own edits from the upgrade's, but the
72
+ // retirement is not a comparison against any render and must still happen —
73
+ // otherwise a host that cannot be baselined keeps its obsolete mount forever.
74
+ const refresh = ctx.argv.includes("--no-refresh") === false;
75
+
76
+ if (ctx.argv.includes("--dry-run")) {
77
+ const preview = planUnitReconcile(plan, protect, refresh);
78
+ process.stdout.write(
79
+ preview.steps.length === 0
80
+ ? `${formatUnitReconcile({ kind: "current", protectedDrift: preview.protectedDrift, outOfScope: preview.outOfScope })}\n`
81
+ : `${[
82
+ "would reconcile:",
83
+ ...preview.retire.map((path) => ` retire ${path}`),
84
+ ...preview.refresh.map((d) => ` ${d.installed} (from ${d.source})`),
85
+ ...preview.steps.map((s) => ` $ ${s.argv.join(" ")}`),
86
+ ].join("\n")}\n`,
87
+ );
88
+ return;
89
+ }
90
+
91
+ // `--yes` is the non-interactive consent the upgrade transaction carries:
92
+ // its operator already authorised the upgrade, and the detached installer
93
+ // has no terminal to prompt on. Interactive runs get the real confirm.
94
+ const ui = ctx.argv.includes("--yes")
95
+ ? answersUi({ [UNIT_RECONCILE_ANSWER_KEY]: true })
96
+ : terminalUi();
97
+ try {
98
+ const outcome = await reconcileUnits(plan, ui, { protect, refresh });
99
+ process.stdout.write(`${formatUnitReconcile(outcome)}\n`);
100
+ if (outcome.kind === "failed") process.exitCode = 1;
101
+ } finally {
102
+ ui.close();
103
+ }
104
+ }