omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -63,22 +63,27 @@ import {
63
63
  reportScopeFromPolicy,
64
64
  refreshComposedBriefForProject,
65
65
  } from "./setup.ts";
66
+ import { sharedPolicyPath } from "./brief-upgrade.ts";
66
67
  import {
67
68
  recordReleaseBlock,
68
69
  redactReleaseArgs,
70
+ releaseBlockedInvocation,
69
71
  releaseDriftDigestLine,
70
72
  releaseRefusal,
71
- releaseShapeFromTool,
73
+ releaseToolMatch,
72
74
  type ReleaseDecision,
73
75
  } from "./release-policy.ts";
74
76
  import {
75
77
  DEFAULT_REPORT_POLICY,
76
78
  DEFAULT_REPORT_SCOPE,
77
79
  DENIED_RELEASE_GRANTS,
80
+ INTERRUPT_CATEGORIES,
78
81
  type DispatchSummary,
79
82
  type DigestBacklog,
80
83
  type FrictionSignal,
81
84
  type HeldNotice,
85
+ type InterruptCategory,
86
+ type IntakeItem,
82
87
  type MaterialEvent,
83
88
  type ReportScopeChoice,
84
89
  type ReportingPolicy,
@@ -86,9 +91,22 @@ import {
86
91
  type Store,
87
92
  } from "./types.ts";
88
93
  import { formatDecisionDigest } from "./decisions.ts";
94
+ import {
95
+ ASK_TOOL,
96
+ askParameterSchema,
97
+ DEFAULT_ASK_TIMEOUT_SECONDS,
98
+ MAX_ASK_TIMEOUT_SECONDS,
99
+ MIN_ASK_TIMEOUT_SECONDS,
100
+ parseAskRequest,
101
+ performAsk,
102
+ type AskResult,
103
+ } from "./ask.ts";
104
+ import { deliverOperatorMessage } from "./reports.ts";
89
105
  import type { RunRecord } from "./types.ts";
90
106
  import { dbPath, openStore } from "./store.ts";
91
107
  import { digestDue, localDayKey } from "./digest-schedule.ts";
108
+ import { heldNoticeId } from "./notices.ts";
109
+ import { isActiveArmProof } from "./arm-challenge.ts";
92
110
 
93
111
  /** The activation file. Absent means "this is not an orchestrator session". */
94
112
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -96,6 +114,24 @@ export const TICK_CONFIG_FILE = ".conductor-tick.json";
96
114
  /** Namespaced so a renderer or a session-log reader can pick ticks out. */
97
115
  export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
98
116
 
117
+ /**
118
+ * A deterministic machine-readable steer the availability gate sends when an
119
+ * inbound user turn is an active arming proof (conductor #415). It names the
120
+ * turn for what it is so the model acknowledges receipt and hands completion to
121
+ * the host-side `arm` instead of ad-libbing pairing-safety prose at the bare
122
+ * `FLEET-…` token — and it never quotes the code, which has no place outside
123
+ * the protected session transcript.
124
+ */
125
+ export const ARM_PROOF_CUSTOM_TYPE = "omp-conductor.arm-proof";
126
+
127
+ /** The model-ready wording for {@link ARM_PROOF_CUSTOM_TYPE}. Kept fixed so the
128
+ * orchestrator's handling of an arming proof is deterministic, whichever
129
+ * challenge it was cut for. */
130
+ export const ARM_PROOF_ACK_TEXT =
131
+ "The message you just received was an active omp conductor arming confirmation. " +
132
+ "Acknowledge that the arming confirmation was received; the host-side `arm` command " +
133
+ "owns completion of the arming handshake. No pairing or access change is required.";
134
+
99
135
  /**
100
136
  * A tick costs a whole turn of a frontier model, and the orchestrator's loop is
101
137
  * about minutes of latency, not seconds. Anything under a minute is a
@@ -268,6 +304,23 @@ interface TickApi {
268
304
  * `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:1267-1268`.
269
305
  */
270
306
  getActiveTools(): string[];
307
+ /**
308
+ * Register one tool on this session — the same shape the verb client uses
309
+ * (`verbs/client.ts`). The bounded ask surface ({@link ASK_TOOL}) is mounted
310
+ * with it, so the orchestrator's only waiting ask can never outlive its
311
+ * ceiling.
312
+ */
313
+ registerTool(tool: {
314
+ name: string;
315
+ label: string;
316
+ description: string;
317
+ parameters: unknown;
318
+ approval?: "read" | "write" | "exec";
319
+ execute(
320
+ toolCallId: string,
321
+ params: Record<string, unknown>,
322
+ ): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }>;
323
+ }): void;
271
324
  on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
272
325
  on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
273
326
  on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
@@ -282,6 +335,11 @@ interface TickApi {
282
335
  synthetic?: boolean;
283
336
  attribution?: "user" | "agent";
284
337
  timestamp?: number;
338
+ /** Full-text content parts, as the runtime's `AgentMessage` carries
339
+ * them. The arming-proof classifier hashes the reply's tokens
340
+ * against persisted pending-challenge state, so it needs the text
341
+ * here — content absent means "no reply text", which never matches. */
342
+ content?: readonly { type?: string; text?: string }[];
285
343
  };
286
344
  },
287
345
  ctx: TickContext,
@@ -340,6 +398,15 @@ export interface TickConfig {
340
398
  * before the next tick).
341
399
  */
342
400
  budgetSeconds?: number;
401
+ /**
402
+ * Seconds one {@link ASK_TOOL} call waits for the operator before its
403
+ * declared timeout outcome fires (#438). Optional; defaults to
404
+ * {@link DEFAULT_ASK_TIMEOUT_SECONDS} (5 minutes) and is always capped at the
405
+ * turn budget, so an ask can never outlive the turn it runs in. The ceiling
406
+ * is the tool's, not the model's: an ask issued without a `timeoutSeconds`
407
+ * gets this value all the same.
408
+ */
409
+ askTimeoutSeconds?: number;
343
410
  /**
344
411
  * The herdr agent name this fleet's orchestrator pane is registered under, and
345
412
  * the whole of {@link resolveTickOwnership}'s identity test under herdr.
@@ -380,9 +447,12 @@ export function defaultTickMessage(
380
447
  now: Date,
381
448
  briefPath = "ORCHESTRATOR.md",
382
449
  policyPath = "POLICY.md",
450
+ sharedPath?: string,
383
451
  ): string {
452
+ const sources =
453
+ sharedPath === undefined ? "package floor + policy" : "package floor + shared policy + policy";
384
454
  return (
385
- `Tick ${now.toISOString()}: re-read ${briefPath} (composed package floor + policy) and ` +
455
+ `Tick ${now.toISOString()}: re-read ${briefPath} (composed ${sources}) and ` +
386
456
  `${policyPath} (editable fleet policy) from disk, then run your standing loop from them. ` +
387
457
  `Learning-loop amendments edit only ${policyPath} — never the package floor.`
388
458
  );
@@ -476,7 +546,7 @@ export function formatDigestLedger(
476
546
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
477
547
  };
478
548
  const lines = [
479
- `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
549
+ `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held notice(s); oldest first:`,
480
550
  ...events.map(
481
551
  (event) =>
482
552
  ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
@@ -492,7 +562,7 @@ export function formatDigestLedger(
492
562
  lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
493
563
  }
494
564
  if (backlog.heldNoticeCount > notices.length) {
495
- lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
565
+ lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held notice(s) remain owed.`);
496
566
  }
497
567
  const eventIds = events.map((event) => event.id).join(",");
498
568
  const noticeIds = notices.map((notice) => notice.id).join(",");
@@ -635,10 +705,69 @@ export { TELEGRAM_APPROVAL_TOOL };
635
705
  */
636
706
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
637
707
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
638
- `If you have an amendment to propose, deliver the question with \`omp-conductor message --text "QUESTION: <the question>"\` — it resolves this project's own Telegram chat and topic — and wait for your operator's reply on a later turn. ` +
639
- `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
708
+ `If you have an amendment to propose, deliver the question with \`omp-conductor message --category decision-needed --text "<the question>"\` — ` +
709
+ `it resolves this project's own Telegram chat and topic, records the question as an open decision row, and applies the ` +
710
+ `same availability policy as the tick: an unanswered yes/no stays pending — re-surfaced in every tick until answered ` +
711
+ `or the seven-day expiry, never recorded as approved. ` +
712
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
713
+ `A returned answer proves an answer, not Telegram delivery. ` +
640
714
  `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
641
715
 
716
+ /**
717
+ * Appended to every tick — the shipped prompt or the operator's own — because
718
+ * the bounded-ask surface is the mechanical contract of a local tick (#438).
719
+ *
720
+ * Even a *mounted* `telegram_ask` is not the ask surface for a locally injected
721
+ * tick: it would wait for the operator for as long as the answer takes, and on
722
+ * 2026-08-16 that was six stopped hours. The gate refuses it, so the tick names
723
+ * the bounded replacement up front instead of letting the model discover the
724
+ * refusal mid-ask.
725
+ */
726
+ export const TICK_ASK_RULE =
727
+ `On this locally injected tick, questions to your operator go through ${ASK_TOOL}: ` +
728
+ `call it with "on-timeout": "auto-proceed" or "park" (what to do when nobody answers within the ceiling) ` +
729
+ `and optionally "timeoutSeconds" — an ask issued without one still gets the default ceiling, capped at the turn budget. ` +
730
+ `The ${TELEGRAM_APPROVAL_TOOL} tool is refused here: it would wait for your operator for as long as the answer ` +
731
+ `takes, and an unanswered question must never hold the loop.`;
732
+
733
+ /**
734
+ * What a tick says when the bounded ask surface did not actually mount on this
735
+ * session (#520). {@link TICK_ASK_RULE} is appended only to a tick whose live
736
+ * mounted set carries {@link ASK_TOOL}; this is the other half of that
737
+ * agreement, and it has to carry the ask's semantics rather than just its text:
738
+ * the escalation category is declared from the vocabulary that already exists
739
+ * (the `conductor_ask` category is the same one), and the fallback command
740
+ * records the question as an open decision row, so "nobody answered" is a
741
+ * durable pending row re-surfaced in every tick — never "asked once, no reply,
742
+ * dropped". It also names the #524 consequence of a plain send: delivery
743
+ * follows the fleet's reporting policy, so a category the policy defers waits
744
+ * for the scheduled digest, and a *blocking* question must declare a category
745
+ * the fleet interrupts on.
746
+ */
747
+ export const TICK_ASK_UNAVAILABLE_RULE =
748
+ `The ${ASK_TOOL} tool is NOT mounted on this tick, so the bounded ask surface is unavailable on this session. ` +
749
+ `Ask through the CLI fallback instead: run \`omp-conductor message --category <category> --text "<the question>"\` ` +
750
+ `with the escalation category declared from the policy vocabulary — fleet-stopped, tier2 or decision-needed — ` +
751
+ `never smuggled through a "QUESTION:" text prefix. The command records the question as an open decision row before ` +
752
+ `delivering, parked on silence: nobody answering keeps the row open and pending, re-surfaced in every tick until ` +
753
+ `answered or the seven-day expiry, never an approval. Delivery follows the same reporting policy as the tick — a ` +
754
+ `category the fleet's interruptOn list defers waits for the next digest or working-hours catch-up and prints a ` +
755
+ `held-notice id, so a blocking question must declare a category the fleet interrupts on (tier2 or fleet-stopped). ` +
756
+ `Wait for the operator's reply on a later turn, then resolve the row with \`omp-conductor decision resolve <id> --answer "..."\`. ` +
757
+ `A returned answer proves an answer, not Telegram delivery.`;
758
+
759
+ /**
760
+ * The gate's refusal for a raw {@link TELEGRAM_APPROVAL_TOOL} / `write
761
+ * xd://telegram_ask` call on a locally injected tick (#438). Fixed wording the
762
+ * way {@link TICK_ASK_RULE} is: a model that must act on a refusal reads it the
763
+ * same way every time.
764
+ */
765
+ export const ASK_DIVERSION_REASON =
766
+ `Blocked: on this locally injected tick, ${TELEGRAM_APPROVAL_TOOL} would wait for your operator for as long as ` +
767
+ `the answer takes, and an unanswered question must never stop the loop (#438). Nothing was sent or recorded ` +
768
+ `by this call. Ask instead with ${ASK_TOOL}, declaring "on-timeout" ("auto-proceed" or "park") — it bounds the ` +
769
+ `wait, records the question durably, and returns one of those outcomes when nobody answers.`;
770
+
642
771
  /**
643
772
  * Appended to every tick — the shipped prompt or the operator's own — composed
644
773
  * on a bridge that is not in omp-telegram's `profile: "daemon"`.
@@ -700,6 +829,49 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
700
829
  return lines.join("\n");
701
830
  }
702
831
 
832
+ /** Bounds one pending idea's text in the tick prompt, like the digest ledger. */
833
+ const INTAKE_TEXT_LIMIT = 160;
834
+
835
+ /**
836
+ * Pending intake items, each waiting to become an issue (#300).
837
+ *
838
+ * The one appended block that is a *duty* rather than a read-out: every pending
839
+ * item is listed with its id and text (oldest first, as the store returns
840
+ * them), followed by the standing instruction for grooming one into an issue —
841
+ * file it without the queue label, mark provenance with `intake groomed`, and
842
+ * name the grooming in the digest ledger. Absent when nothing is pending: an
843
+ * empty intake is the common state of a healthy fleet, and a block that said
844
+ * "0 pending" every tick would be permanent noise — the same convention as
845
+ * {@link queueDigestLine}. Project-specific grooming taste (priority scales,
846
+ * template wording) stays in POLICY.md; this block carries only the floor duty.
847
+ */
848
+ export function formatPendingIntake(
849
+ items: readonly IntakeItem[],
850
+ instruction: { tracker: string; queueLabel: string; labelPrefix: string },
851
+ ): string | undefined {
852
+ if (items.length === 0) return undefined;
853
+ const oneLine = (text: string): string => {
854
+ const flat = text.replace(/\s+/g, " ").trim();
855
+ return flat.length <= INTAKE_TEXT_LIMIT ? flat : `${flat.slice(0, INTAKE_TEXT_LIMIT - 1)}…`;
856
+ };
857
+ const lines = [
858
+ `Pending intake — ${items.length} idea(s) captured, waiting to be groomed into issues, oldest first:`,
859
+ ...items.map((item) => `- ${item.id} — ${oneLine(item.text)}`),
860
+ "",
861
+ `Groom each into exactly one issue on ${instruction.tracker}: a title stating the problem; a body ` +
862
+ `carrying the product rationale and the acceptance criteria as a checklist; the routing ` +
863
+ `"${instruction.labelPrefix}<repo>" label and a priority per POLICY.md. File it WITHOUT the ` +
864
+ `"${instruction.queueLabel}" queue label — filing is not promoting, and never add ` +
865
+ `${instruction.queueLabel} to an issue you groomed from intake. Then mark provenance with ` +
866
+ "`omp-conductor intake groomed <id> --issue <url>` — an already-groomed id is a no-op, and an " +
867
+ "idea is filed exactly once — and record the outcome with `omp-conductor event record --category " +
868
+ 'intake --summary "groomed intake <id> → #<issue-number>" --evidence <url>` so the digest names ' +
869
+ "the grooming. An item that is malformed or empty is dismissed with `omp-conductor intake dismiss " +
870
+ "<id>` and noted in the digest rather than filed.",
871
+ ];
872
+ return lines.join("\n");
873
+ }
874
+
703
875
  /**
704
876
  * The scope this tick carries, where the brief actually lives, and — when the
705
877
  * config could not answer — why.
@@ -934,6 +1106,19 @@ export function readTickConfig(cwd: string): TickConfigResult {
934
1106
  ? budgetRaw
935
1107
  : undefined;
936
1108
 
1109
+ // Same tolerance, same reason. The ask ceiling is bounded on both ends by
1110
+ // construction: MIN..MAX here, and the resolved per-ask ceiling is capped at
1111
+ // the turn budget by the ask tool itself, so a config cannot smuggle an
1112
+ // unbounded wait in through this key.
1113
+ const askRaw = raw["askTimeoutSeconds"];
1114
+ const askTimeoutSeconds =
1115
+ typeof askRaw === "number" &&
1116
+ Number.isInteger(askRaw) &&
1117
+ askRaw >= MIN_ASK_TIMEOUT_SECONDS &&
1118
+ askRaw <= MAX_ASK_TIMEOUT_SECONDS
1119
+ ? askRaw
1120
+ : undefined;
1121
+
937
1122
  // Relative paths resolve against the session cwd, so the files can sit beside
938
1123
  // the config that names them (`state/armed`) without hard-coding a deploy path.
939
1124
  const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
@@ -978,6 +1163,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
978
1163
  intervalSeconds,
979
1164
  ...(project === undefined ? {} : { project }),
980
1165
  ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
1166
+ ...(askTimeoutSeconds === undefined ? {} : { askTimeoutSeconds }),
981
1167
  ...(armedFile === undefined ? {} : { armedFile }),
982
1168
  ...(accessFile === undefined ? {} : { accessFile }),
983
1169
  ...(message === undefined ? {} : { message }),
@@ -1611,6 +1797,12 @@ interface PendingLocalTick {
1611
1797
  interface ActiveLocalTick extends PendingLocalTick {
1612
1798
  /** A person who writes during an autonomous run is awake by construction. */
1613
1799
  humanWaiting: boolean;
1800
+ /** The most recent inbound turn was an *active arming proof* (conductor #415):
1801
+ * a reply matching a persisted pending challenge for this project. Set when
1802
+ * `humanWaiting` is, without flipping the human-present exemption — the
1803
+ * operator answered a challenge, so they are awake, and the model must not
1804
+ * treat the matching token as license to mutate pairing or access. */
1805
+ armingProof?: boolean;
1614
1806
  }
1615
1807
 
1616
1808
  interface TelegramInterrupt {
@@ -1718,6 +1910,32 @@ export function telegramTopicRoutingRefusal(payload: Record<string, unknown>): s
1718
1910
  );
1719
1911
  }
1720
1912
 
1913
+ /**
1914
+ * The caller's declared interrupt category for one Telegram call, when it names
1915
+ * a real one (#347). The tick prompt tells an autonomous turn which categories
1916
+ * may interrupt under this scope — a condition that stops the fleet is
1917
+ * `fleet-stopped`, a decision the operator must make is `tier2` — and the
1918
+ * caller tags the call with `category` so the admission gate below runs that
1919
+ * category through the same policy as a daemon escalation. Anything that is
1920
+ * not a known category falls back to the historical mapping in
1921
+ * {@link telegramInterruptFromTool}: a question is `decision-needed`, an
1922
+ * ordinary send is `material`.
1923
+ */
1924
+ function declaredTelegramCategory(payload: Record<string, unknown>): InterruptCategory | undefined {
1925
+ const value = payload["category"];
1926
+ if (typeof value !== "string") return undefined;
1927
+ return (INTERRUPT_CATEGORIES as readonly string[]).includes(value)
1928
+ ? (value as InterruptCategory)
1929
+ : undefined;
1930
+ }
1931
+
1932
+ /**
1933
+ * One Telegram call, whatever surface it arrived on, classified enough for the
1934
+ * availability gate: its kind (a question that wants an answer, or a message)
1935
+ * plus the interrupt category the caller declared — defaulting to
1936
+ * `decision-needed` for questions and `material` for messages when the caller
1937
+ * declares none.
1938
+ */
1721
1939
  function telegramInterruptFromTool(
1722
1940
  toolName: string,
1723
1941
  input: Record<string, unknown>,
@@ -1769,7 +1987,7 @@ function telegramInterruptFromTool(
1769
1987
  const canonical = serialiseTelegramPayload({ kind, payload });
1770
1988
  return {
1771
1989
  kind,
1772
- category: kind === "question" ? "decision-needed" : "material",
1990
+ category: declaredTelegramCategory(payload) ?? (kind === "question" ? "decision-needed" : "material"),
1773
1991
  summary,
1774
1992
  hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
1775
1993
  detail,
@@ -1822,6 +2040,20 @@ function autonomousTelegramInterruptBlock(
1822
2040
  if (active === undefined || active.humanWaiting) return undefined;
1823
2041
  const interrupt = telegramInterruptFromTool(event.toolName, event.input);
1824
2042
  if (interrupt === undefined) return undefined;
2043
+ // The raw ask tool would wait for the operator for as long as the answer
2044
+ // takes; on an autonomous tick that wait must be bounded and its timeout
2045
+ // outcome declared (#438). Divert before the policy read, because the
2046
+ // diversion does not depend on availability — the incident was an ask the
2047
+ // policy *admitted*: the gate let telegram_ask through at 01:01Z and the loop
2048
+ // stopped for six hours. Nothing is persisted here: the bounded surface
2049
+ // records the row and the delivery when the model actually asks, and a
2050
+ // gate-side duplicate would record questions that were never asked.
2051
+ const directAsk =
2052
+ event.toolName === TELEGRAM_APPROVAL_TOOL ||
2053
+ (event.toolName === "write" && event.input["path"] === `xd://${TELEGRAM_APPROVAL_TOOL}`);
2054
+ if (directAsk) {
2055
+ return { block: true, reason: ASK_DIVERSION_REASON };
2056
+ }
1825
2057
  if (active.projectName === undefined) {
1826
2058
  return {
1827
2059
  block: true,
@@ -1869,7 +2101,7 @@ function autonomousTelegramInterruptBlock(
1869
2101
  try {
1870
2102
  if (!store.wasNotified(key)) {
1871
2103
  store.addHeldNotice({
1872
- id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
2104
+ id: heldNoticeId(key),
1873
2105
  project: active.projectName,
1874
2106
  category: interrupt.category,
1875
2107
  summary: interrupt.summary,
@@ -2085,13 +2317,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2085
2317
  store.close();
2086
2318
  }
2087
2319
  }
2088
- content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
2320
+ content = `${defaultTickMessage(
2321
+ new Date(),
2322
+ scope.briefPath,
2323
+ scope.policyPath,
2324
+ existsSync(sharedPolicyPath()) ? sharedPolicyPath() : undefined,
2325
+ )}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
2089
2326
  }
2090
2327
  if (scope.projectName !== undefined) {
2091
2328
  // Unlike ordinary reporting prose, this is a mechanical clock reading and
2092
2329
  // survives a custom heartbeat message. The model never infers whether the
2093
- // operator can be interrupted.
2330
+ // operator can be interrupted. The bounded-ask rule is the same class
2331
+ // (#438): even a mounted telegram_ask is refused on a locally injected
2332
+ // tick — it would wait unbounded — so the tick names the replacement
2333
+ // surface up front, custom message included.
2094
2334
  content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
2335
+ // The ask rule names the surface this session can actually call (#520):
2336
+ // the bounded ask is registered at `session_start`, so the live mounted
2337
+ // set read here — between turns, in the heartbeat timer — is the honest
2338
+ // source for whether the registration took. A tick that mandates a tool
2339
+ // the session does not carry is #114 all over again, so a missing surface
2340
+ // degrades to the CLI path that exists and carries the ask's semantics.
2341
+ content = `${content}\n${pi.getActiveTools().includes(ASK_TOOL) ? TICK_ASK_RULE : TICK_ASK_UNAVAILABLE_RULE}`;
2095
2342
  }
2096
2343
  let frictionStore: Store | undefined;
2097
2344
  let frictionSignals: FrictionSignal[] = [];
@@ -2137,8 +2384,17 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
2137
2384
  project.groomBelow ?? DEFAULT_GROOM_BELOW,
2138
2385
  );
2139
2386
  if (queue !== undefined) content = `${content}\n${queue}`;
2387
+ // Pending intake is the same class of standing block as the friction
2388
+ // and decisions read-outs: a store-backed duty the orchestrator must
2389
+ // not derive from memory. The store answers, the prompt instructs.
2390
+ const pendingIntake = formatPendingIntake(frictionStore.pendingIntake(scope.projectName), {
2391
+ tracker: project.tracker.repo,
2392
+ queueLabel: project.queueLabel,
2393
+ labelPrefix: project.routing.labelPrefix,
2394
+ });
2395
+ if (pendingIntake !== undefined) content = `${content}\n${pendingIntake}`;
2140
2396
  } catch {
2141
- // unreadable config: no queue digest this tick
2397
+ // unreadable config: no queue digest or pending-intake block this tick
2142
2398
  }
2143
2399
  } catch (err) {
2144
2400
  frictionStore?.close();
@@ -2398,6 +2654,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2398
2654
  };
2399
2655
  let releaseGateArmed = false;
2400
2656
  let availabilityGateArmed = false;
2657
+ let askToolArmed = false;
2401
2658
  let guardArmed = false;
2402
2659
  // An activation file makes this a fleet directory before Herdr can prove
2403
2660
  // which pane owns it. The gate therefore starts closed and only honours a
@@ -2424,8 +2681,9 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2424
2681
  }).on("tool_call", (event) => {
2425
2682
  // Most tool calls are ordinary tracker/file work. Classify first so they
2426
2683
  // do not re-read config or emit policy diagnostics.
2427
- const shape = releaseShapeFromTool(event.toolName, event.input);
2428
- if (shape === undefined) return undefined;
2684
+ const match = releaseToolMatch(event.toolName, event.input);
2685
+ if (match === undefined) return undefined;
2686
+ const shape = match.shape;
2429
2687
  let projectName: string | undefined;
2430
2688
  let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
2431
2689
  let external = true;
@@ -2457,9 +2715,12 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2457
2715
  if (decision === undefined) return undefined;
2458
2716
  if (projectName !== undefined) {
2459
2717
  try {
2718
+ const invocation = releaseBlockedInvocation(event.toolName, event.input);
2460
2719
  recordReleaseBlock(projectName, "orchestrator", shape, {
2461
2720
  tool: event.toolName,
2462
2721
  reason: decision.reason,
2722
+ ...(invocation === undefined ? {} : { invocation }),
2723
+ matched: match.matched,
2463
2724
  args: redactReleaseArgs(event.input),
2464
2725
  });
2465
2726
  } catch (err) {
@@ -2509,13 +2770,35 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2509
2770
  }
2510
2771
  return;
2511
2772
  }
2512
- if (
2513
- session.activeLocalTick !== undefined &&
2514
- message.role === "user" &&
2515
- message.synthetic !== true &&
2516
- message.attribution !== "agent"
2517
- ) {
2518
- session.activeLocalTick.humanWaiting = true;
2773
+ // The reply to an arming challenge lands here as an ordinary user turn. It
2774
+ // must reach the transcript exactly as sent — the host's transcript scan
2775
+ // (`armTicks`) is the backend proof, so it is left untouched — while the
2776
+ // model gets a trusted, machine-readable steer about it, so it stops
2777
+ // ad-libbing pairing-safety prose at a bare `FLEET-…` token (conductor
2778
+ // #415). The classification derives from persisted authenticated
2779
+ // pending-challenge state (hash + expiry, keyed by this project), never
2780
+ // from the `FLEET-` prefix, so an unsolicited lookalike that matches no
2781
+ // active challenge stays inert.
2782
+ if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
2783
+ const replyText =
2784
+ message.content
2785
+ ?.filter((part) => part.type === "text" && typeof part.text === "string")
2786
+ .map((part) => part.text as string)
2787
+ .join(" ") ?? "";
2788
+ const proof = isActiveArmProof(configuredProject, replyText, Date.now());
2789
+ if (session.activeLocalTick !== undefined) {
2790
+ session.activeLocalTick.humanWaiting = true;
2791
+ if (proof) session.activeLocalTick.armingProof = true;
2792
+ }
2793
+ // Acknowledged whether or not a local tick is running: an inactivity-
2794
+ // window challenge reply still deserves deterministic handling, and the
2795
+ // host-side `arm` owns completion either way.
2796
+ if (proof) {
2797
+ pi.sendMessage(
2798
+ { customType: ARM_PROOF_CUSTOM_TYPE, content: ARM_PROOF_ACK_TEXT, display: true, attribution: "agent" },
2799
+ { triggerTurn: true, deliverAs: "steer" },
2800
+ );
2801
+ }
2519
2802
  }
2520
2803
  });
2521
2804
  pi.on("agent_end", () => {
@@ -2539,6 +2822,116 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2539
2822
  );
2540
2823
  };
2541
2824
 
2825
+ /**
2826
+ * Mount the bounded ask surface (#438). One registration per session, next to
2827
+ * the gates it complements: raw {@link TELEGRAM_APPROVAL_TOOL} is refused on
2828
+ * autonomous ticks, so the session must own the tool that asks instead. The
2829
+ * ceiling comes from the tick config the heartbeat started with — the same
2830
+ * startup-only contract `budgetSeconds` already has — and is capped at the
2831
+ * turn budget by the tool itself, so no combination of config and arguments
2832
+ * produces an unbounded wait.
2833
+ *
2834
+ * The tool is inert unless the config resolves this fleet's project: it needs
2835
+ * the project to file the decision row against and to resolve the delivery
2836
+ * target. An unreadable/ambiguous config makes the tool say so and record
2837
+ * nothing, which is the same fail-closed posture the autonomous gate takes.
2838
+ */
2839
+ const armAskTool = (config: TickConfig, configuredProject: string | undefined): void => {
2840
+ if (askToolArmed) return;
2841
+ pi.registerTool({
2842
+ name: ASK_TOOL,
2843
+ label: ASK_TOOL,
2844
+ description:
2845
+ `Ask your operator one question and wait up to a bounded ceiling for the answer. ` +
2846
+ `Records the question durably (decision row + the same delivery path as \`omp-conductor message\`), ` +
2847
+ `delivers it to the operator per the reporting policy, and waits at most the ceiling ` +
2848
+ `(default ${DEFAULT_ASK_TIMEOUT_SECONDS}s, capped at the turn budget; pass "timeoutSeconds" to ` +
2849
+ `shorten or extend within ${MIN_ASK_TIMEOUT_SECONDS}–${MAX_ASK_TIMEOUT_SECONDS}s — an ask issued without ` +
2850
+ `one still gets the default). When nobody answers, the declared "on-timeout" decides: ` +
2851
+ `"auto-proceed" applies your recommended option and resolves the decision row naming the ` +
2852
+ `auto-application ("<option> (auto-applied on ask timeout)"); "park" leaves the row open and ` +
2853
+ `pending — re-surfaced in every tick prompt until answered or the seven-day expiry — and you then ` +
2854
+ `take the blocked work out of the claimable queue and record its state. A timeout is "nobody ` +
2855
+ `answered yet", never a cancellation, an error, or an operator "no".`,
2856
+ parameters: askParameterSchema(),
2857
+ approval: "write",
2858
+ execute: async (_toolCallId, params) => {
2859
+ const parsed = parseAskRequest(params);
2860
+ if (!parsed.ok) {
2861
+ return { content: [{ type: "text", text: parsed.problem }], isError: true };
2862
+ }
2863
+ let projectConfig;
2864
+ try {
2865
+ projectConfig = findProject(loadConfig(), configuredProject);
2866
+ } catch (err) {
2867
+ return {
2868
+ content: [
2869
+ {
2870
+ type: "text",
2871
+ text:
2872
+ `${ASK_TOOL}: conductor config unreadable (${err instanceof Error ? err.message : String(err)}); ` +
2873
+ "nothing was asked or recorded. Repair the config, do not ask through another path.",
2874
+ },
2875
+ ],
2876
+ isError: true,
2877
+ };
2878
+ }
2879
+ const store = openStore(dbPath());
2880
+ let result: AskResult;
2881
+ try {
2882
+ result = await performAsk(parsed.request, {
2883
+ store,
2884
+ project: projectConfig.name,
2885
+ configuredCeilingSeconds: config.askTimeoutSeconds,
2886
+ turnBudgetSeconds: config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS,
2887
+ deliver: async (text, category) => {
2888
+ const at = Date.now();
2889
+ const noticeId = randomUUID();
2890
+ try {
2891
+ const delivered = await deliverOperatorMessage(projectConfig, text, {
2892
+ store,
2893
+ at,
2894
+ noticeId,
2895
+ category,
2896
+ });
2897
+ return delivered.kind === "sent"
2898
+ ? { kind: "sent", category: delivered.category }
2899
+ : { kind: "held", category: delivered.category, noticeId: delivered.noticeId };
2900
+ } catch (err) {
2901
+ // A failed immediate send must not drop the question: fall back
2902
+ // to the durable hold exactly like the gate's own hold path, and
2903
+ // let the daemon retry with the digest.
2904
+ store.addHeldNotice({
2905
+ id: noticeId,
2906
+ project: projectConfig.name,
2907
+ category,
2908
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
2909
+ detail: text,
2910
+ createdAt: at,
2911
+ });
2912
+ pi.logger.error(
2913
+ `[omp-conductor] ${ASK_TOOL} could not deliver the ask directly (${err instanceof Error ? err.message : String(err)}); held durably`,
2914
+ );
2915
+ return { kind: "held", category, noticeId };
2916
+ }
2917
+ },
2918
+ });
2919
+ } finally {
2920
+ store.close();
2921
+ }
2922
+ return { content: [{ type: "text", text: result.text }] };
2923
+ },
2924
+ });
2925
+ // Latched after the registration returns, not before: a throw inside
2926
+ // `registerTool` must not leave the surface permanently unmounted — the
2927
+ // latch would otherwise turn a transient registration failure into a
2928
+ // missing ask surface for the whole session (#520). On a real harness
2929
+ // `session_start` fires once, so this is a retry on the next session
2930
+ // rather than a loop, but it is the difference between a recovery and a
2931
+ // permanent divergence between the prompt and the mounted set.
2932
+ askToolArmed = true;
2933
+ };
2934
+
2542
2935
  pi.on("session_start", (_event, ctx) => {
2543
2936
  if (decided) return;
2544
2937
 
@@ -2571,6 +2964,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2571
2964
  const configuredProject = result.kind === "ok" ? result.config.project : undefined;
2572
2965
  armReleaseGate(configuredProject);
2573
2966
  armAvailabilityGate(configuredProject);
2967
+ if (result.kind === "ok") armAskTool(result.config, configuredProject);
2574
2968
 
2575
2969
  if (result.kind === "invalid") {
2576
2970
  const detail = `${result.path}: ${result.problem}`;
@@ -33,8 +33,8 @@ import { stateDir } from "./config.ts";
33
33
  import { formatEscalation } from "./escalate.ts";
34
34
  import { createSession, disposeSession } from "./omp.ts";
35
35
  import type { AgentSessionLike } from "./omp.ts";
36
- import type { ReleaseBlockContext } from "./release-policy.ts";
37
- import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
36
+ import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
37
+ import type { Escalation, ResolvedGrants, SessionRole } from "./types.ts";
38
38
 
39
39
  /**
40
40
  * The session factory {@link startOrchestrator} uses. Named so the test seam
@@ -47,7 +47,7 @@ export type CreateSessionFn = (opts: {
47
47
  resume?: boolean;
48
48
  role: SessionRole;
49
49
  releaseGrants?: ResolvedGrants;
50
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
50
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
51
51
  onSpawn?: (pid: number) => void;
52
52
  socketPath?: string;
53
53
  verbSocketPath?: string;
@@ -85,7 +85,7 @@ export interface OrchestratorOpts {
85
85
  sessionDir?: string;
86
86
  model?: string;
87
87
  releaseGrants?: ResolvedGrants;
88
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
88
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
89
89
  /** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
90
90
  onSpawn?: (pid: number) => void;
91
91
  /** Control socket for the session child, beside its own working directory. */