omp-conductor 0.19.7 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/fleet.ts CHANGED
@@ -27,8 +27,10 @@ import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts"
27
27
  import {
28
28
  clearArmTransaction,
29
29
  FLEET_ARM_KEY,
30
- readArmAcknowledgement,
31
30
  recordArmChallenge,
31
+ resolveArmReply,
32
+ type ArmReplyVerdict,
33
+ type ArmTarget,
32
34
  } from "./arm-challenge.ts";
33
35
  import {
34
36
  claimedTelegramTopics,
@@ -101,6 +103,7 @@ import {
101
103
  resolveArmState,
102
104
  TICK_CONFIG_FILE,
103
105
  tickConfigMatchesProject,
106
+ writeArmedMarker,
104
107
  type ArmState,
105
108
  type TickConfig,
106
109
  type TickConfigResult,
@@ -283,19 +286,54 @@ export function disarmTicks(projectName?: string): { path: string; wasArmed: boo
283
286
  return { path, wasArmed };
284
287
  }
285
288
 
286
- export interface ArmResult {
287
- path: string;
288
- alreadyArmed: boolean;
289
- owner: string;
290
- /** The challenge code that proved arming, present only for `challenge` proof. */
291
- challenge?: string;
289
+ /** One project whose arm marker was written. */
290
+ export interface ArmedProject {
292
291
  /**
293
- * Which proof armed the fleet (#613): `challenge` for the authenticated
294
- * round-trip, `claim-only` for the live-plumbing verdict with no send.
292
+ * The configured project name, absent only for a legacy unstamped
293
+ * single-project fleet.
295
294
  */
295
+ project?: string;
296
+ path: string;
297
+ alreadyArmed: boolean;
298
+ }
299
+
300
+ /** Markers written without any reply: the `claim-only` policy proof (#613). */
301
+ export interface ArmMarkersWritten {
302
+ outcome: "armed";
296
303
  proof: ArmProof;
304
+ owner: string;
305
+ armed: ArmedProject[];
306
+ }
307
+
308
+ /**
309
+ * A challenge filed and sent, with nothing armed yet.
310
+ *
311
+ * Arming used to block here for up to five minutes on an in-session
312
+ * acknowledgement. Nothing waits now: the reply is consumed mechanically by
313
+ * the session whose topic the challenge went to (the orchestrator pane,
314
+ * #1061), or by `omp-conductor arm --reply` on a console host whose DM the
315
+ * operator answered in. This is the send half's receipt.
316
+ */
317
+ export interface ArmChallengeSent {
318
+ outcome: "challenge-sent";
319
+ /** Always `challenge`: `claim-only` never sends, so it never reaches here. */
320
+ proof: "challenge";
321
+ /** The chat the challenge went to, recorded with the transaction. */
322
+ owner: string;
323
+ /** The pending transaction the reply must prove. */
324
+ challengeId: string;
325
+ /** Unix ms after which the code stops being a proof. */
326
+ expiresAt: number;
327
+ /** mm:ss the code stays good for — the same clock the message quotes. */
328
+ validFor: string;
329
+ /** Exactly what a matching reply will arm, as recorded with the challenge. */
330
+ targets: ArmTarget[];
331
+ /** The verbatim command that completes the ceremony, copy-pasteable as-is. */
332
+ followUp: string;
297
333
  }
298
334
 
335
+ export type ArmResult = ArmMarkersWritten | ArmChallengeSent;
336
+
299
337
  export interface ArmDeps {
300
338
  sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
301
339
  /**
@@ -307,7 +345,7 @@ export interface ArmDeps {
307
345
  */
308
346
  claimedSessionFile?: () => string | undefined;
309
347
  now?: () => number;
310
- sleep?: (ms: number) => Promise<void>;
348
+ /** How long a sent code stays a proof. */
311
349
  timeoutMs?: number;
312
350
  /**
313
351
  * Liveness seams for the claim-only verdict (#613), with omp-telegram's own
@@ -321,11 +359,10 @@ export interface ArmDeps {
321
359
  lockPidAlive?: (pid: number) => boolean;
322
360
  lockFresh?: (mtimeMs: number) => boolean;
323
361
  /**
324
- * Where the pending-proof heartbeat is written (#861). The challenge proof
325
- * waits up to five minutes on a human, inside a fence that holds dispatch:
326
- * without this, that wait is silent and a healthy process is indistinguishable
327
- * from a dead one. Absent means no reporting — the callers that have a surface
328
- * (the CLI, the wizard) pass theirs.
362
+ * Where the ceremony's one-line narration goes. Nothing waits any more, so
363
+ * this is no longer a liveness heartbeat (#861) it is the send receipt and
364
+ * the follow-up instruction, for a surface that prints as it goes. Absent
365
+ * means no reporting.
329
366
  */
330
367
  progress?: (line: string) => void;
331
368
  }
@@ -367,30 +404,28 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
367
404
  // one pane that can ask. The challenge names which one, or the operator is
368
405
  // answering a question they cannot attribute.
369
406
  const named = tick.config.project ?? projectName;
370
- // The handshake state key must be exactly what the orchestrator's inbound
371
- // adapter computes: TickConfig.project, undefined for a legacy unstamped
372
- // config. `named` may fall back to the CLI argument for the challenge text
373
- // and config lookups; the state key must not the adapter has no CLI
374
- // argument to fall back to, and a mismatched key would make arming wait on
375
- // an acknowledgement that can never be written.
407
+ // The handshake state key must be exactly what the reply step recomputes
408
+ // from this same tick config: TickConfig.project, undefined for a legacy
409
+ // unstamped config. `named` may fall back to the CLI argument for the
410
+ // challenge text and config lookups; the state key must not, or the reply
411
+ // would look for the challenge under a key nothing recorded.
376
412
  const stateKey = tick.config.project;
377
413
 
378
414
  // The arming proof is a declared per-project policy (#613). A config that
379
- // cannot name the project fails safe to `challenge` — today's authenticated
415
+ // cannot name the project fails safe to `challenge` — the authenticated
380
416
  // round-trip — so a missing or unreadable config never silently weakens the
381
417
  // gate.
382
418
  let proof: ArmProof = DEFAULT_ARM_PROOF;
383
419
  try {
384
420
  proof = resolveArmProof(findProject(loadConfig(), named));
385
421
  } catch {
386
- /* no project config — keep today's challenge behaviour */
422
+ /* no project config — keep the challenge behaviour */
387
423
  }
388
424
 
389
425
  // The orchestrator's live session file per omp-telegram's claim (#600) —
390
426
  // input to the claim-only verdict's session-identity checks below. The
391
- // challenge proof never reads it: its acknowledgement is conductor state,
392
- // so where (or whether) a transcript lives is no longer part of arming
393
- // (#614).
427
+ // challenge proof never reads it: its proof is conductor state, so where (or
428
+ // whether) a transcript lives is no longer part of arming (#614).
394
429
  const claimed =
395
430
  deps.claimedSessionFile !== undefined
396
431
  ? deps.claimedSessionFile()
@@ -409,17 +444,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
409
444
  }
410
445
 
411
446
  const path = tick.config.armedFile;
412
- // The gate as the heartbeat reads it, so "replaced previous marker" is not a
413
- // lie about a fleet the shared marker was arming, and so the write below knows
414
- // whether it is superseding that marker.
415
- const arm = resolveArmState(path, named);
416
- const alreadyArmed = arm.armed;
417
447
 
418
448
  if (proof === "claim-only") {
419
449
  // The human-intent gate is declared satisfied by policy, so #612's shared
420
450
  // verdict is the whole proof: the same state reads and the same liveness
421
451
  // rules the doctor's "telegram-plumbing" finding applies, on the route a
422
- // challenge would have ridden. No Telegram send, no transcript wait, no
452
+ // challenge would have ridden. No Telegram send, no reply step, no
423
453
  // pending-challenge record. A failed fact refuses arming by name — never
424
454
  // a silent pass from file existence, and never a marker.
425
455
  const scan = armVerdictScanDirs(tick.cwd, claimed);
@@ -442,13 +472,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
442
472
  `NOT armed; no marker was written`,
443
473
  );
444
474
  }
475
+ // The gate as the heartbeat reads it, so "replaced previous marker" is not
476
+ // a lie about a fleet the shared marker was arming, and so the write knows
477
+ // whether it is superseding that marker.
478
+ const arm = resolveArmState(path, named);
445
479
  writeArmedMarker(path, channel.owner, arm);
446
- return { path, alreadyArmed, owner: channel.owner, proof };
480
+ return {
481
+ outcome: "armed",
482
+ proof,
483
+ owner: channel.owner,
484
+ armed: [{ ...(named === undefined ? {} : { project: named }), path, alreadyArmed: arm.armed }],
485
+ };
447
486
  }
448
487
 
449
488
  const send = deps.sendChallenge ?? sendTelegramMessage;
450
489
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
451
490
  const code = makeChallengeCode();
491
+ const followUp = armReplyCommand(projectName);
452
492
  // Self-describing (#991): with two live challenges in one chat the operator
453
493
  // was working out which was which from message order, and nothing said how
454
494
  // long a code stayed good — so the safe move was to scroll for the newest,
@@ -457,15 +497,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
457
497
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
458
498
  `Reply to this chat with exactly:\n${code}\n` +
459
499
  `Valid for ${armClock(timeoutMs)}. ` +
460
- `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
500
+ `Nothing is dispatched until that reply is verified.`;
461
501
  const sentAt = (deps.now ?? Date.now)();
462
- // The orchestrator's inbound adapter can only acknowledge an *active*
463
- // challenge, so the authenticated pending record (hash + expiry, never the
464
- // code) is written before the challenge goes out and settled the moment
465
- // this end finishes (#415). The returned id pins the wait below: an
466
- // acknowledgement can only ever name the currently-pending id, so replacing
467
- // a challenge makes every prior acknowledgement inert.
468
- const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs);
502
+ // Recorded BEFORE the send, so a reply that beats this process's own return
503
+ // still finds an active challenge and so a send that fails has a
504
+ // transaction to settle rather than a code loose in a chat (#415). The record
505
+ // carries what the reply will arm: the config could change before the
506
+ // operator answers, and arming anything but what the challenge named would
507
+ // arm a fleet nobody was asked about.
508
+ const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs, {
509
+ targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
510
+ owner: channel.owner,
511
+ });
469
512
  try {
470
513
  await send(token, channel.owner, text, sendTopic);
471
514
  } catch (err) {
@@ -476,50 +519,33 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
476
519
  `arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
477
520
  );
478
521
  }
479
- // What is being waited on, before the wait starts: the window, the chat the
480
- // reply has to land in, and the fact that dispatch is held until it does
481
- // (#861). One line, so a five-minute wait opens with an explanation rather
482
- // than with silence.
483
522
  deps.progress?.(
484
523
  `arm: challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} — ` +
485
- `waiting up to ${armClock(timeoutMs)} for the reply. Dispatch stays held until it arrives.`,
524
+ `valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
486
525
  );
487
-
488
- // Wait for the orchestrator's own acknowledgement — conductor state written
489
- // by the inbound user-turn adapter when the real reply lands (#614). No
490
- // transcript is read: the proof no longer depends on where (or whether) a
491
- // session file lives, which is exactly the discovery that mis-fired on the
492
- // host three times (#614). A wrong-project or lookalike reply writes no
493
- // acknowledgement, so the window simply runs out fail-closed.
494
- if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
495
- clearArmTransaction(stateKey, challengeId);
496
- throw new Error(
497
- `arm: the challenge was never acknowledged in time — NOT armed.\n` +
498
- `The orchestrator's inbound adapter acknowledges the reply when it lands as a user turn; ` +
499
- `no acknowledgement for challenge ${challengeId} arrived.\n` +
500
- `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
501
- ` * is the bridge polling? attach and run: /telegram status\n` +
502
- ` * is another process holding this bot token? Telegram allows exactly one\n` +
503
- ` getUpdates consumer and rejects the second with HTTP 409.\n` +
504
- ` * did you reply in the DM with the bot, not another chat?\n`,
505
- );
506
- }
507
-
508
- writeArmedMarker(path, channel.owner, arm);
509
- // The acknowledgement landed and this project is armed: settling clears this
510
- // transaction's pending record and acknowledgement — never a newer
511
- // replacement's — so a later unsolicited lookalike stays inert past this
512
- // handshake.
513
- clearArmTransaction(stateKey, challengeId);
514
- return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
526
+ return {
527
+ outcome: "challenge-sent",
528
+ proof: "challenge",
529
+ owner: channel.owner,
530
+ challengeId,
531
+ expiresAt: sentAt + timeoutMs,
532
+ validFor: armClock(timeoutMs),
533
+ targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
534
+ followUp,
535
+ };
515
536
  }
516
537
 
517
- export interface FleetArmResult {
518
- /** Every project this ceremony armed, in configuration order. */
519
- armed: { project: string; path: string; alreadyArmed: boolean }[];
520
- owner: string;
521
- /** The transaction the single reply proved. */
522
- challengeId: string;
538
+ /**
539
+ * The second half of the ceremony, verbatim. Printed by every surface that
540
+ * issues a challenge, and quoted in the refusals, because an agent reading a
541
+ * console pane has to be able to paste it without composing anything: the
542
+ * operator's message is the only variable.
543
+ */
544
+ function armReplyCommand(projectName?: string): string {
545
+ return (
546
+ `omp-conductor arm --reply "<the operator's reply, verbatim>"` +
547
+ `${projectName === undefined ? "" : ` --project ${projectName}`}`
548
+ );
523
549
  }
524
550
 
525
551
  /**
@@ -527,10 +553,9 @@ export interface FleetArmResult {
527
553
  *
528
554
  * Arming was per project: a two-project fleet meant two sequential handshakes
529
555
  * with two codes in one chat, though nothing about the fleet's state differed
530
- * between them. This sends **one** challenge and arms every configured project
531
- * from the single matching reply which is a fleet-wide pending record, not a
532
- * loop that sends N challenges and waits for N replies. That loop is the
533
- * current friction with one command wrapped around it.
556
+ * between them. This sends **one** challenge whose single matching reply arms
557
+ * every configured projectone fleet-wide pending record, not a loop that
558
+ * sends N challenges and collects N replies.
534
559
  *
535
560
  * Every project is validated before anything is sent, so a fleet whose second
536
561
  * project has no `armedFile` refuses the ceremony instead of arming the first
@@ -541,11 +566,15 @@ export interface FleetArmResult {
541
566
  * authenticated round-trip is strictly stronger than the plumbing verdict that
542
567
  * policy would have accepted, so satisfying the weaker gate with the stronger
543
568
  * proof cannot weaken it.
569
+ *
570
+ * Like {@link armTicks}, this returns as soon as the challenge is filed and
571
+ * sent: the reply settles it mechanically on the orchestrator pane's own
572
+ * topic, or through `omp-conductor arm --reply` from the console.
544
573
  */
545
574
  export async function armFleet(
546
575
  projectNames: readonly string[],
547
576
  deps: ArmDeps = {},
548
- ): Promise<FleetArmResult> {
577
+ ): Promise<ArmChallengeSent> {
549
578
  if (projectNames.length === 0) throw new Error("arm: no projects are configured");
550
579
 
551
580
  // Resolve and validate every project first. Nothing is sent and no marker is
@@ -596,8 +625,8 @@ export async function armFleet(
596
625
 
597
626
  // The first project that resolves a live topic carries the ceremony, and the
598
627
  // message says so: a fleet-wide question still has to land somewhere an
599
- // operator is reading, and every session's adapter can acknowledge it because
600
- // the pending record is fleet-wide rather than topic-scoped.
628
+ // operator is reading, and the reply step reaches the record from any project
629
+ // because it is fleet-wide rather than topic-scoped.
601
630
  let sendTopic: number | undefined;
602
631
  for (const target of targets) {
603
632
  try {
@@ -612,14 +641,28 @@ export async function armFleet(
612
641
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
613
642
  const code = makeChallengeCode();
614
643
  const names = targets.map((t) => t.project).join(", ");
644
+ // No `--project`: the reply resolves the fleet record, which is the one this
645
+ // ceremony wrote. Naming a project here would be a narrower command than the
646
+ // challenge the operator answered.
647
+ const followUp = armReplyCommand();
615
648
  const text =
616
649
  `Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
617
650
  `Reply to this chat with exactly:\n${code}\n` +
618
651
  `Valid for ${armClock(timeoutMs)}, and one reply arms all of them. ` +
619
- `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
652
+ `Nothing is dispatched until that reply is verified.`;
620
653
 
621
654
  const sentAt = (deps.now ?? Date.now)();
622
- const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs);
655
+ // The record carries every project the message named, so the reply arms
656
+ // exactly the fleet the operator was asked about — not whatever the config
657
+ // says minutes later.
658
+ const armTargets: ArmTarget[] = targets.map((target) => ({
659
+ project: target.project,
660
+ armedFile: target.armedFile,
661
+ }));
662
+ const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs, {
663
+ targets: armTargets,
664
+ owner: channel.owner,
665
+ });
623
666
  try {
624
667
  await send(token, channel.owner, text, sendTopic);
625
668
  } catch (err) {
@@ -630,31 +673,171 @@ export async function armFleet(
630
673
  }
631
674
  deps.progress?.(
632
675
  `arm: one challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} for ${names} — ` +
633
- `waiting up to ${armClock(timeoutMs)} for the reply. Dispatch stays held until it arrives.`,
676
+ `valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
634
677
  );
678
+ return {
679
+ outcome: "challenge-sent",
680
+ proof: "challenge",
681
+ owner: channel.owner,
682
+ challengeId,
683
+ expiresAt: sentAt + timeoutMs,
684
+ validFor: armClock(timeoutMs),
685
+ targets: armTargets,
686
+ followUp,
687
+ };
688
+ }
635
689
 
636
- if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
637
- clearArmTransaction(FLEET_ARM_KEY, challengeId);
638
- throw new Error(
639
- `arm: the fleet challenge was never acknowledged in time — NOTHING armed.\n` +
640
- `No acknowledgement for challenge ${challengeId} arrived, so no project's marker was written.\n` +
641
- `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
642
- ` * is the bridge polling? attach and run: /telegram status\n` +
643
- ` * is another process holding this bot token? Telegram allows exactly one\n` +
644
- ` getUpdates consumer and rejects the second with HTTP 409.\n` +
645
- ` * did you reply in the chat the challenge names, not another one?\n`,
646
- );
647
- }
690
+ /** What a reply that armed nothing was, and what to do about it. */
691
+ export interface ArmReplyRefused {
692
+ outcome: "refused";
693
+ /** Never `matched`: a match is the armed outcome. */
694
+ verdict: Exclude<ArmReplyVerdict, "matched">;
695
+ /** One operator-facing sentence naming the verdict and the next move. */
696
+ message: string;
697
+ }
698
+
699
+ export interface ArmReplyAccepted {
700
+ outcome: "armed";
701
+ /** The chat the proved challenge was sent to — the marker's `owner=`. */
702
+ owner: string;
703
+ /** The transaction the reply proved and this call settled. */
704
+ challengeId: string;
705
+ /** Every project the challenge recorded, now armed. */
706
+ armed: ArmedProject[];
707
+ }
708
+
709
+ export type ArmReplyResult = ArmReplyAccepted | ArmReplyRefused;
648
710
 
649
- // Proven once, applied to every project. Markers are written after the proof,
650
- // so a refused ceremony leaves the fleet exactly as it was.
711
+ /**
712
+ * The verification half of the ceremony: classify the operator's verbatim
713
+ * message and, on a match, arm exactly the projects the challenge recorded.
714
+ *
715
+ * This is the host CLI half, and it is one of two consumers of the same
716
+ * durable record: the orchestrator session's inbound path settles a reply
717
+ * sent to the project topic mechanically (#1061), while this command remains
718
+ * the console's route for a reply that landed in the operator DM. No session
719
+ * waits for anything: the challenge is durable state, so the halves are
720
+ * ordinary commands that can run minutes apart in different processes.
721
+ *
722
+ * The security properties are the send half's, unchanged. Only the project's
723
+ * own record and the fleet record are read (no other project's ceremony can be
724
+ * revealed or consumed), an expired record is never a proof, and a marker is
725
+ * written only for a `matched` verdict — every other verdict returns a refusal
726
+ * having written nothing.
727
+ */
728
+ export function armReply(
729
+ replyText: string,
730
+ projectName?: string,
731
+ deps: Pick<ArmDeps, "now"> = {},
732
+ ): ArmReplyResult {
733
+ const tick = resolveTickConfig(projectName);
734
+ if (tick.kind === "invalid") {
735
+ // Every derivation below would come from this file. A config that does not
736
+ // parse cannot be trusted to name a state key or a marker.
737
+ throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
738
+ }
739
+ // An absent config is not fatal here, which is the difference between the two
740
+ // halves: a fleet ceremony's record carries its own targets, and on a
741
+ // multi-project host the search roots for an unnamed project resolve no
742
+ // config at all (the fleet cwds each carry their own). Refusing here would
743
+ // make `arm --reply` unusable for exactly the ceremony that needs it most.
744
+ const config = tick.kind === "ok" ? tick.config : undefined;
745
+ // The same derivation the send half used, from the same file: the state key
746
+ // is TickConfig.project (undefined for a legacy unstamped config), never the
747
+ // CLI argument, or the reply would look under a key nothing recorded.
748
+ const stateKey = config?.project;
749
+ const now = (deps.now ?? Date.now)();
750
+ const resolved = resolveArmReply(stateKey, replyText, now);
751
+ if (resolved.verdict !== "matched") {
752
+ return {
753
+ outcome: "refused",
754
+ verdict: resolved.verdict,
755
+ message: armRefusalText(resolved.verdict, projectName),
756
+ };
757
+ }
758
+ const match = resolved.match;
759
+ // Recorded targets are the contract. A record written before targets existed
760
+ // (one release of overlap) can only be read as naming the project it is keyed
761
+ // under — or, for the fleet record, the project this command was given, since
762
+ // re-deriving the fleet from today's config could arm a project the operator
763
+ // was never asked about.
764
+ const targets: ArmTarget[] = match.targets ?? [legacyArmTarget(config, projectName)];
765
+ // The owner the challenge was actually sent to. Only a pre-targets record
766
+ // lacks it, and then the paired channel is the only other honest source.
767
+ const owner = match.owner ?? pairedChannelOwner(config?.accessFile);
651
768
  const armed = targets.map((target) => {
652
769
  const state = resolveArmState(target.armedFile, target.project);
653
- writeArmedMarker(target.armedFile, channel.owner, state);
654
- return { project: target.project, path: target.armedFile, alreadyArmed: state.armed };
770
+ writeArmedMarker(target.armedFile, owner, state);
771
+ return {
772
+ ...(target.project === undefined ? {} : { project: target.project }),
773
+ path: target.armedFile,
774
+ alreadyArmed: state.armed,
775
+ };
655
776
  });
656
- clearArmTransaction(FLEET_ARM_KEY, challengeId);
657
- return { armed, owner: channel.owner, challengeId };
777
+ // Settled by id against the key that matched, so a replayed reply cannot arm
778
+ // a second time and a newer replacement's record is never removed.
779
+ clearArmTransaction(match.key, match.id);
780
+ return { outcome: "armed", owner, challengeId: match.id, armed };
781
+ }
782
+
783
+ /**
784
+ * What a pre-targets pending record arms. The tick config that resolved the
785
+ * state key also names this project's marker, which is exactly what the send
786
+ * half would have recorded. Without such a config there is nothing to fall back
787
+ * to, and guessing a marker path is not an option — a challenge from before the
788
+ * upgrade is simply re-run.
789
+ */
790
+ function legacyArmTarget(config: TickConfig | undefined, projectName?: string): ArmTarget {
791
+ const named = config?.project ?? projectName;
792
+ if (config?.armedFile === undefined) {
793
+ throw new Error(
794
+ `arm: the pending challenge names no targets and no readable ${TICK_CONFIG_FILE} names an armedFile — ` +
795
+ `nothing to arm; re-run \`omp-conductor arm\` for a challenge that records its own targets`,
796
+ );
797
+ }
798
+ return { ...(named === undefined ? {} : { project: named }), armedFile: config.armedFile };
799
+ }
800
+
801
+ /** The paired owner, for the one record shape that does not carry its own. */
802
+ function pairedChannelOwner(accessFile: string | undefined): string {
803
+ if (accessFile === undefined) {
804
+ throw new Error(`${TICK_CONFIG_FILE} has no accessFile — the challenge's owner cannot be established`);
805
+ }
806
+ const channel = readPairedChannel(accessFile);
807
+ if (channel.kind === "down") {
808
+ throw new Error(
809
+ `escalation channel is not up (${accessFile}): ${channel.reason} — ` +
810
+ `this challenge predates owner recording, so the marker cannot name whom it armed`,
811
+ );
812
+ }
813
+ return channel.owner;
814
+ }
815
+
816
+ /**
817
+ * Each non-matching verdict in the operator's words, with the next move. A
818
+ * silent no-op was the old failure mode: the operator could not tell a wrong
819
+ * code from an expired one from a command that never looked.
820
+ */
821
+ function armRefusalText(verdict: Exclude<ArmReplyVerdict, "matched">, projectName?: string): string {
822
+ const reissue = `omp-conductor arm${projectName === undefined ? "" : ` --project ${projectName}`}`;
823
+ switch (verdict) {
824
+ case "expired":
825
+ return (
826
+ `arm: that code belongs to a challenge whose window has closed — NOT armed, no marker written. ` +
827
+ `An expired code is never a proof: send a fresh challenge with \`${reissue}\` and verify that one.`
828
+ );
829
+ case "unknown":
830
+ return (
831
+ `arm: that message carries an arming code, but no live challenge here matches it — NOT armed, ` +
832
+ `no marker written. Only this project's own challenge and the fleet ceremony are ever consulted. ` +
833
+ `Send a challenge with \`${reissue}\` and reply to that one.`
834
+ );
835
+ case "none":
836
+ return (
837
+ `arm: that message contains no arming code at all — NOT armed, no marker written. ` +
838
+ `Pass the operator's reply verbatim, including the FLEET-… code; if none was sent, run \`${reissue}\` first.`
839
+ );
840
+ }
658
841
  }
659
842
 
660
843
  export interface HoldResult {
@@ -3361,11 +3544,11 @@ function makeChallengeCode(): string {
3361
3544
  }
3362
3545
 
3363
3546
  /**
3364
- * mm:ss for a window whose whole length is five minutes. Deliberately not
3547
+ * mm:ss for the window a challenge code stays good for. Deliberately not
3365
3548
  * `formatDownDuration`, which rounds to whole minutes because it reports
3366
- * hours-to-days outages: rounded to the minute, the last 30 seconds of this
3367
- * window would read "5m of 5m left" while the wait was nearly over, which is
3368
- * the exact ambiguity the progress lines exist to remove (#861).
3549
+ * hours-to-days outages: rounded to the minute, a code with 30 seconds left
3550
+ * would read "5m", which is the exact ambiguity these lines exist to remove
3551
+ * (#861).
3369
3552
  */
3370
3553
  function armClock(ms: number): string {
3371
3554
  const total = Math.max(0, Math.round(ms / 1000));
@@ -3374,70 +3557,6 @@ function armClock(ms: number): string {
3374
3557
  return minutes === 0 ? `${seconds}s` : `${minutes}m${String(seconds).padStart(2, "0")}s`;
3375
3558
  }
3376
3559
 
3377
- /** How often the wait says it is still waiting. Six lines across the window. */
3378
- const ARM_PROGRESS_INTERVAL_MS = 30_000;
3379
-
3380
- /**
3381
- * Polls the acknowledgement record for one exact challenge id until the
3382
- * orchestrator's inbound adapter writes it or the deadline passes (#614).
3383
- *
3384
- * The state is a small JSON file re-read every pass, never snapshotted: the
3385
- * acknowledgement may land at any point in the window, written by the live
3386
- * orchestrator process. Only a record naming this exact id satisfies the
3387
- * wait — an acknowledgement cut for a replaced challenge is inert here by
3388
- * construction, and no transcript anywhere is opened.
3389
- *
3390
- * It also *says* it is waiting (#861). Measured 2026-08-21: an arm proof sat
3391
- * silent for five minutes and was reported as a hung setup — the process was
3392
- * healthy and the operator had no way to tell. A silent five-minute wait
3393
- * inside a fence that holds dispatch is indistinguishable from a dead one, so
3394
- * the elapsed/remaining line lands every {@link ARM_PROGRESS_INTERVAL_MS}
3395
- * regardless of the (much shorter) poll cadence, and the terminal outcome is
3396
- * always printed.
3397
- */
3398
- async function waitForArmAcknowledgement(
3399
- challengeId: string,
3400
- timeoutMs: number,
3401
- deps: ArmDeps,
3402
- ): Promise<boolean> {
3403
- const now = deps.now ?? Date.now;
3404
- const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
3405
- const report = deps.progress;
3406
- const startedAt = now();
3407
- const deadline = startedAt + timeoutMs;
3408
- let nextReportAt = startedAt + ARM_PROGRESS_INTERVAL_MS;
3409
- for (;;) {
3410
- if (readArmAcknowledgement(challengeId) !== undefined) {
3411
- report?.(`arm: reply acknowledged after ${armClock(now() - startedAt)} — the fleet is armed.`);
3412
- return true;
3413
- }
3414
- const at = now();
3415
- if (at >= deadline) return false;
3416
- if (report !== undefined && at >= nextReportAt) {
3417
- report(
3418
- `arm: still waiting for the reply — ${armClock(at - startedAt)} elapsed, ` +
3419
- `${armClock(deadline - at)} left. Nothing is stuck: reply in the Telegram chat with the code.`,
3420
- );
3421
- // Anchored to the clock, not to this pass, so a slow pass cannot make the
3422
- // cadence drift into silence.
3423
- while (nextReportAt <= at) nextReportAt += ARM_PROGRESS_INTERVAL_MS;
3424
- }
3425
- await sleep(5_000);
3426
- }
3427
- }
3428
-
3429
- /**
3430
- * The one armed-marker write both proofs share: same content, same mode, and
3431
- * the same restamp of the pre-per-project shared marker the heartbeat still
3432
- * honours — a project that just armed must not leave the bare marker around to
3433
- * re-arm future fleets through `disarm` (#316).
3434
- */
3435
- function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
3436
- mkdirSync(dirname(path), { recursive: true });
3437
- writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
3438
- if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
3439
- }
3440
-
3441
3560
  /**
3442
3561
  * The session surface the claim-only verdict judges: the tick-cwd-derived
3443
3562
  * session directory plus, when the live claim's file lives elsewhere in the