omp-conductor 0.19.7 → 0.20.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 (69) 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/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -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,
@@ -283,19 +285,53 @@ export function disarmTicks(projectName?: string): { path: string; wasArmed: boo
283
285
  return { path, wasArmed };
284
286
  }
285
287
 
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;
288
+ /** One project whose arm marker was written. */
289
+ export interface ArmedProject {
292
290
  /**
293
- * Which proof armed the fleet (#613): `challenge` for the authenticated
294
- * round-trip, `claim-only` for the live-plumbing verdict with no send.
291
+ * The configured project name, absent only for a legacy unstamped
292
+ * single-project fleet.
295
293
  */
294
+ project?: string;
295
+ path: string;
296
+ alreadyArmed: boolean;
297
+ }
298
+
299
+ /** Markers written without any reply: the `claim-only` policy proof (#613). */
300
+ export interface ArmMarkersWritten {
301
+ outcome: "armed";
296
302
  proof: ArmProof;
303
+ owner: string;
304
+ armed: ArmedProject[];
297
305
  }
298
306
 
307
+ /**
308
+ * A challenge filed and sent, with nothing armed yet.
309
+ *
310
+ * Arming used to block here for up to five minutes on an in-session
311
+ * acknowledgement. With the console owning the operator DM, the reply lands in
312
+ * a session that runs no tick extension, so that wait could never be satisfied
313
+ * — it is now two mechanical steps, and this is the first one's receipt.
314
+ */
315
+ export interface ArmChallengeSent {
316
+ outcome: "challenge-sent";
317
+ /** Always `challenge`: `claim-only` never sends, so it never reaches here. */
318
+ proof: "challenge";
319
+ /** The chat the challenge went to, recorded with the transaction. */
320
+ owner: string;
321
+ /** The pending transaction the reply must prove. */
322
+ challengeId: string;
323
+ /** Unix ms after which the code stops being a proof. */
324
+ expiresAt: number;
325
+ /** mm:ss the code stays good for — the same clock the message quotes. */
326
+ validFor: string;
327
+ /** Exactly what a matching reply will arm, as recorded with the challenge. */
328
+ targets: ArmTarget[];
329
+ /** The verbatim command that completes the ceremony, copy-pasteable as-is. */
330
+ followUp: string;
331
+ }
332
+
333
+ export type ArmResult = ArmMarkersWritten | ArmChallengeSent;
334
+
299
335
  export interface ArmDeps {
300
336
  sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
301
337
  /**
@@ -307,7 +343,7 @@ export interface ArmDeps {
307
343
  */
308
344
  claimedSessionFile?: () => string | undefined;
309
345
  now?: () => number;
310
- sleep?: (ms: number) => Promise<void>;
346
+ /** How long a sent code stays a proof. */
311
347
  timeoutMs?: number;
312
348
  /**
313
349
  * Liveness seams for the claim-only verdict (#613), with omp-telegram's own
@@ -321,11 +357,10 @@ export interface ArmDeps {
321
357
  lockPidAlive?: (pid: number) => boolean;
322
358
  lockFresh?: (mtimeMs: number) => boolean;
323
359
  /**
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.
360
+ * Where the ceremony's one-line narration goes. Nothing waits any more, so
361
+ * this is no longer a liveness heartbeat (#861) it is the send receipt and
362
+ * the follow-up instruction, for a surface that prints as it goes. Absent
363
+ * means no reporting.
329
364
  */
330
365
  progress?: (line: string) => void;
331
366
  }
@@ -367,30 +402,28 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
367
402
  // one pane that can ask. The challenge names which one, or the operator is
368
403
  // answering a question they cannot attribute.
369
404
  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.
405
+ // The handshake state key must be exactly what the reply step recomputes
406
+ // from this same tick config: TickConfig.project, undefined for a legacy
407
+ // unstamped config. `named` may fall back to the CLI argument for the
408
+ // challenge text and config lookups; the state key must not, or the reply
409
+ // would look for the challenge under a key nothing recorded.
376
410
  const stateKey = tick.config.project;
377
411
 
378
412
  // 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
413
+ // cannot name the project fails safe to `challenge` — the authenticated
380
414
  // round-trip — so a missing or unreadable config never silently weakens the
381
415
  // gate.
382
416
  let proof: ArmProof = DEFAULT_ARM_PROOF;
383
417
  try {
384
418
  proof = resolveArmProof(findProject(loadConfig(), named));
385
419
  } catch {
386
- /* no project config — keep today's challenge behaviour */
420
+ /* no project config — keep the challenge behaviour */
387
421
  }
388
422
 
389
423
  // The orchestrator's live session file per omp-telegram's claim (#600) —
390
424
  // 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).
425
+ // challenge proof never reads it: its proof is conductor state, so where (or
426
+ // whether) a transcript lives is no longer part of arming (#614).
394
427
  const claimed =
395
428
  deps.claimedSessionFile !== undefined
396
429
  ? deps.claimedSessionFile()
@@ -409,17 +442,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
409
442
  }
410
443
 
411
444
  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
445
 
418
446
  if (proof === "claim-only") {
419
447
  // The human-intent gate is declared satisfied by policy, so #612's shared
420
448
  // verdict is the whole proof: the same state reads and the same liveness
421
449
  // rules the doctor's "telegram-plumbing" finding applies, on the route a
422
- // challenge would have ridden. No Telegram send, no transcript wait, no
450
+ // challenge would have ridden. No Telegram send, no reply step, no
423
451
  // pending-challenge record. A failed fact refuses arming by name — never
424
452
  // a silent pass from file existence, and never a marker.
425
453
  const scan = armVerdictScanDirs(tick.cwd, claimed);
@@ -442,13 +470,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
442
470
  `NOT armed; no marker was written`,
443
471
  );
444
472
  }
473
+ // The gate as the heartbeat reads it, so "replaced previous marker" is not
474
+ // a lie about a fleet the shared marker was arming, and so the write knows
475
+ // whether it is superseding that marker.
476
+ const arm = resolveArmState(path, named);
445
477
  writeArmedMarker(path, channel.owner, arm);
446
- return { path, alreadyArmed, owner: channel.owner, proof };
478
+ return {
479
+ outcome: "armed",
480
+ proof,
481
+ owner: channel.owner,
482
+ armed: [{ ...(named === undefined ? {} : { project: named }), path, alreadyArmed: arm.armed }],
483
+ };
447
484
  }
448
485
 
449
486
  const send = deps.sendChallenge ?? sendTelegramMessage;
450
487
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
451
488
  const code = makeChallengeCode();
489
+ const followUp = armReplyCommand(projectName);
452
490
  // Self-describing (#991): with two live challenges in one chat the operator
453
491
  // was working out which was which from message order, and nothing said how
454
492
  // long a code stayed good — so the safe move was to scroll for the newest,
@@ -457,15 +495,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
457
495
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
458
496
  `Reply to this chat with exactly:\n${code}\n` +
459
497
  `Valid for ${armClock(timeoutMs)}. ` +
460
- `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
498
+ `Nothing is dispatched until that reply is verified.`;
461
499
  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);
500
+ // Recorded BEFORE the send, so a reply that beats this process's own return
501
+ // still finds an active challenge and so a send that fails has a
502
+ // transaction to settle rather than a code loose in a chat (#415). The record
503
+ // carries what the reply will arm: the config could change before the
504
+ // operator answers, and arming anything but what the challenge named would
505
+ // arm a fleet nobody was asked about.
506
+ const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs, {
507
+ targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
508
+ owner: channel.owner,
509
+ });
469
510
  try {
470
511
  await send(token, channel.owner, text, sendTopic);
471
512
  } catch (err) {
@@ -476,50 +517,33 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
476
517
  `arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
477
518
  );
478
519
  }
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
520
  deps.progress?.(
484
521
  `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.`,
522
+ `valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
486
523
  );
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 };
524
+ return {
525
+ outcome: "challenge-sent",
526
+ proof: "challenge",
527
+ owner: channel.owner,
528
+ challengeId,
529
+ expiresAt: sentAt + timeoutMs,
530
+ validFor: armClock(timeoutMs),
531
+ targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
532
+ followUp,
533
+ };
515
534
  }
516
535
 
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;
536
+ /**
537
+ * The second half of the ceremony, verbatim. Printed by every surface that
538
+ * issues a challenge, and quoted in the refusals, because an agent reading a
539
+ * console pane has to be able to paste it without composing anything: the
540
+ * operator's message is the only variable.
541
+ */
542
+ function armReplyCommand(projectName?: string): string {
543
+ return (
544
+ `omp-conductor arm --reply "<the operator's reply, verbatim>"` +
545
+ `${projectName === undefined ? "" : ` --project ${projectName}`}`
546
+ );
523
547
  }
524
548
 
525
549
  /**
@@ -527,10 +551,9 @@ export interface FleetArmResult {
527
551
  *
528
552
  * Arming was per project: a two-project fleet meant two sequential handshakes
529
553
  * 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.
554
+ * between them. This sends **one** challenge whose single matching reply arms
555
+ * every configured projectone fleet-wide pending record, not a loop that
556
+ * sends N challenges and collects N replies.
534
557
  *
535
558
  * Every project is validated before anything is sent, so a fleet whose second
536
559
  * project has no `armedFile` refuses the ceremony instead of arming the first
@@ -541,11 +564,14 @@ export interface FleetArmResult {
541
564
  * authenticated round-trip is strictly stronger than the plumbing verdict that
542
565
  * policy would have accepted, so satisfying the weaker gate with the stronger
543
566
  * proof cannot weaken it.
567
+ *
568
+ * Like {@link armTicks}, this returns as soon as the challenge is filed and
569
+ * sent: `omp-conductor arm --reply` writes the markers.
544
570
  */
545
571
  export async function armFleet(
546
572
  projectNames: readonly string[],
547
573
  deps: ArmDeps = {},
548
- ): Promise<FleetArmResult> {
574
+ ): Promise<ArmChallengeSent> {
549
575
  if (projectNames.length === 0) throw new Error("arm: no projects are configured");
550
576
 
551
577
  // Resolve and validate every project first. Nothing is sent and no marker is
@@ -596,8 +622,8 @@ export async function armFleet(
596
622
 
597
623
  // The first project that resolves a live topic carries the ceremony, and the
598
624
  // 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.
625
+ // operator is reading, and the reply step reaches the record from any project
626
+ // because it is fleet-wide rather than topic-scoped.
601
627
  let sendTopic: number | undefined;
602
628
  for (const target of targets) {
603
629
  try {
@@ -612,14 +638,28 @@ export async function armFleet(
612
638
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
613
639
  const code = makeChallengeCode();
614
640
  const names = targets.map((t) => t.project).join(", ");
641
+ // No `--project`: the reply resolves the fleet record, which is the one this
642
+ // ceremony wrote. Naming a project here would be a narrower command than the
643
+ // challenge the operator answered.
644
+ const followUp = armReplyCommand();
615
645
  const text =
616
646
  `Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
617
647
  `Reply to this chat with exactly:\n${code}\n` +
618
648
  `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.`;
649
+ `Nothing is dispatched until that reply is verified.`;
620
650
 
621
651
  const sentAt = (deps.now ?? Date.now)();
622
- const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs);
652
+ // The record carries every project the message named, so the reply arms
653
+ // exactly the fleet the operator was asked about — not whatever the config
654
+ // says minutes later.
655
+ const armTargets: ArmTarget[] = targets.map((target) => ({
656
+ project: target.project,
657
+ armedFile: target.armedFile,
658
+ }));
659
+ const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs, {
660
+ targets: armTargets,
661
+ owner: channel.owner,
662
+ });
623
663
  try {
624
664
  await send(token, channel.owner, text, sendTopic);
625
665
  } catch (err) {
@@ -630,31 +670,169 @@ export async function armFleet(
630
670
  }
631
671
  deps.progress?.(
632
672
  `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.`,
673
+ `valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
634
674
  );
675
+ return {
676
+ outcome: "challenge-sent",
677
+ proof: "challenge",
678
+ owner: channel.owner,
679
+ challengeId,
680
+ expiresAt: sentAt + timeoutMs,
681
+ validFor: armClock(timeoutMs),
682
+ targets: armTargets,
683
+ followUp,
684
+ };
685
+ }
635
686
 
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
- }
687
+ /** What a reply that armed nothing was, and what to do about it. */
688
+ export interface ArmReplyRefused {
689
+ outcome: "refused";
690
+ /** Never `matched`: a match is the armed outcome. */
691
+ verdict: Exclude<ArmReplyVerdict, "matched">;
692
+ /** One operator-facing sentence naming the verdict and the next move. */
693
+ message: string;
694
+ }
648
695
 
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.
696
+ export interface ArmReplyAccepted {
697
+ outcome: "armed";
698
+ /** The chat the proved challenge was sent to — the marker's `owner=`. */
699
+ owner: string;
700
+ /** The transaction the reply proved and this call settled. */
701
+ challengeId: string;
702
+ /** Every project the challenge recorded, now armed. */
703
+ armed: ArmedProject[];
704
+ }
705
+
706
+ export type ArmReplyResult = ArmReplyAccepted | ArmReplyRefused;
707
+
708
+ /**
709
+ * The verification half of the ceremony: classify the operator's verbatim
710
+ * message and, on a match, arm exactly the projects the challenge recorded.
711
+ *
712
+ * This runs in the console session, which is where the operator's reply lands
713
+ * now that it owns the Telegram DM. No session waits for anything: the
714
+ * challenge is durable state, so the two halves are ordinary commands that can
715
+ * run minutes apart in different processes.
716
+ *
717
+ * The security properties are the send half's, unchanged. Only the project's
718
+ * own record and the fleet record are read (no other project's ceremony can be
719
+ * revealed or consumed), an expired record is never a proof, and a marker is
720
+ * written only for a `matched` verdict — every other verdict returns a refusal
721
+ * having written nothing.
722
+ */
723
+ export function armReply(
724
+ replyText: string,
725
+ projectName?: string,
726
+ deps: Pick<ArmDeps, "now"> = {},
727
+ ): ArmReplyResult {
728
+ const tick = resolveTickConfig(projectName);
729
+ if (tick.kind === "invalid") {
730
+ // Every derivation below would come from this file. A config that does not
731
+ // parse cannot be trusted to name a state key or a marker.
732
+ throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
733
+ }
734
+ // An absent config is not fatal here, which is the difference between the two
735
+ // halves: a fleet ceremony's record carries its own targets, and on a
736
+ // multi-project host the search roots for an unnamed project resolve no
737
+ // config at all (the fleet cwds each carry their own). Refusing here would
738
+ // make `arm --reply` unusable for exactly the ceremony that needs it most.
739
+ const config = tick.kind === "ok" ? tick.config : undefined;
740
+ // The same derivation the send half used, from the same file: the state key
741
+ // is TickConfig.project (undefined for a legacy unstamped config), never the
742
+ // CLI argument, or the reply would look under a key nothing recorded.
743
+ const stateKey = config?.project;
744
+ const now = (deps.now ?? Date.now)();
745
+ const resolved = resolveArmReply(stateKey, replyText, now);
746
+ if (resolved.verdict !== "matched") {
747
+ return {
748
+ outcome: "refused",
749
+ verdict: resolved.verdict,
750
+ message: armRefusalText(resolved.verdict, projectName),
751
+ };
752
+ }
753
+ const match = resolved.match;
754
+ // Recorded targets are the contract. A record written before targets existed
755
+ // (one release of overlap) can only be read as naming the project it is keyed
756
+ // under — or, for the fleet record, the project this command was given, since
757
+ // re-deriving the fleet from today's config could arm a project the operator
758
+ // was never asked about.
759
+ const targets: ArmTarget[] = match.targets ?? [legacyArmTarget(config, projectName)];
760
+ // The owner the challenge was actually sent to. Only a pre-targets record
761
+ // lacks it, and then the paired channel is the only other honest source.
762
+ const owner = match.owner ?? pairedChannelOwner(config?.accessFile);
651
763
  const armed = targets.map((target) => {
652
764
  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 };
765
+ writeArmedMarker(target.armedFile, owner, state);
766
+ return {
767
+ ...(target.project === undefined ? {} : { project: target.project }),
768
+ path: target.armedFile,
769
+ alreadyArmed: state.armed,
770
+ };
655
771
  });
656
- clearArmTransaction(FLEET_ARM_KEY, challengeId);
657
- return { armed, owner: channel.owner, challengeId };
772
+ // Settled by id against the key that matched, so a replayed reply cannot arm
773
+ // a second time and a newer replacement's record is never removed.
774
+ clearArmTransaction(match.key, match.id);
775
+ return { outcome: "armed", owner, challengeId: match.id, armed };
776
+ }
777
+
778
+ /**
779
+ * What a pre-targets pending record arms. The tick config that resolved the
780
+ * state key also names this project's marker, which is exactly what the send
781
+ * half would have recorded. Without such a config there is nothing to fall back
782
+ * to, and guessing a marker path is not an option — a challenge from before the
783
+ * upgrade is simply re-run.
784
+ */
785
+ function legacyArmTarget(config: TickConfig | undefined, projectName?: string): ArmTarget {
786
+ const named = config?.project ?? projectName;
787
+ if (config?.armedFile === undefined) {
788
+ throw new Error(
789
+ `arm: the pending challenge names no targets and no readable ${TICK_CONFIG_FILE} names an armedFile — ` +
790
+ `nothing to arm; re-run \`omp-conductor arm\` for a challenge that records its own targets`,
791
+ );
792
+ }
793
+ return { ...(named === undefined ? {} : { project: named }), armedFile: config.armedFile };
794
+ }
795
+
796
+ /** The paired owner, for the one record shape that does not carry its own. */
797
+ function pairedChannelOwner(accessFile: string | undefined): string {
798
+ if (accessFile === undefined) {
799
+ throw new Error(`${TICK_CONFIG_FILE} has no accessFile — the challenge's owner cannot be established`);
800
+ }
801
+ const channel = readPairedChannel(accessFile);
802
+ if (channel.kind === "down") {
803
+ throw new Error(
804
+ `escalation channel is not up (${accessFile}): ${channel.reason} — ` +
805
+ `this challenge predates owner recording, so the marker cannot name whom it armed`,
806
+ );
807
+ }
808
+ return channel.owner;
809
+ }
810
+
811
+ /**
812
+ * Each non-matching verdict in the operator's words, with the next move. A
813
+ * silent no-op was the old failure mode: the operator could not tell a wrong
814
+ * code from an expired one from a command that never looked.
815
+ */
816
+ function armRefusalText(verdict: Exclude<ArmReplyVerdict, "matched">, projectName?: string): string {
817
+ const reissue = `omp-conductor arm${projectName === undefined ? "" : ` --project ${projectName}`}`;
818
+ switch (verdict) {
819
+ case "expired":
820
+ return (
821
+ `arm: that code belongs to a challenge whose window has closed — NOT armed, no marker written. ` +
822
+ `An expired code is never a proof: send a fresh challenge with \`${reissue}\` and verify that one.`
823
+ );
824
+ case "unknown":
825
+ return (
826
+ `arm: that message carries an arming code, but no live challenge here matches it — NOT armed, ` +
827
+ `no marker written. Only this project's own challenge and the fleet ceremony are ever consulted. ` +
828
+ `Send a challenge with \`${reissue}\` and reply to that one.`
829
+ );
830
+ case "none":
831
+ return (
832
+ `arm: that message contains no arming code at all — NOT armed, no marker written. ` +
833
+ `Pass the operator's reply verbatim, including the FLEET-… code; if none was sent, run \`${reissue}\` first.`
834
+ );
835
+ }
658
836
  }
659
837
 
660
838
  export interface HoldResult {
@@ -3361,11 +3539,11 @@ function makeChallengeCode(): string {
3361
3539
  }
3362
3540
 
3363
3541
  /**
3364
- * mm:ss for a window whose whole length is five minutes. Deliberately not
3542
+ * mm:ss for the window a challenge code stays good for. Deliberately not
3365
3543
  * `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).
3544
+ * hours-to-days outages: rounded to the minute, a code with 30 seconds left
3545
+ * would read "5m", which is the exact ambiguity these lines exist to remove
3546
+ * (#861).
3369
3547
  */
3370
3548
  function armClock(ms: number): string {
3371
3549
  const total = Math.max(0, Math.round(ms / 1000));
@@ -3374,58 +3552,6 @@ function armClock(ms: number): string {
3374
3552
  return minutes === 0 ? `${seconds}s` : `${minutes}m${String(seconds).padStart(2, "0")}s`;
3375
3553
  }
3376
3554
 
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
3555
  /**
3430
3556
  * The one armed-marker write both proofs share: same content, same mode, and
3431
3557
  * the same restamp of the pre-per-project shared marker the heartbeat still