omp-conductor 0.12.0 → 0.14.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.
@@ -44,9 +44,11 @@
44
44
  * config, believed it held both.
45
45
  */
46
46
 
47
+ import { createHash, randomUUID } from "node:crypto";
47
48
  import { spawnSync } from "node:child_process";
48
49
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
+ import { availabilityPrompt, interruptDisposition } from "./availability.ts";
50
52
  import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
51
53
  import {
52
54
  bridgeTokenBound,
@@ -58,10 +60,12 @@ import {
58
60
  import {
59
61
  briefPathForProject,
60
62
  policyPathForProject,
63
+ reportScopeFromPolicy,
61
64
  refreshComposedBriefForProject,
62
65
  } from "./setup.ts";
63
66
  import {
64
67
  recordReleaseBlock,
68
+ redactReleaseArgs,
65
69
  releaseDriftDigestLine,
66
70
  releaseRefusal,
67
71
  releaseShapeFromTool,
@@ -72,8 +76,11 @@ import {
72
76
  DEFAULT_REPORT_SCOPE,
73
77
  DENIED_RELEASE_GRANTS,
74
78
  type DispatchSummary,
79
+ type DigestBacklog,
75
80
  type FrictionSignal,
76
- type ReportScope,
81
+ type HeldNotice,
82
+ type MaterialEvent,
83
+ type ReportScopeChoice,
77
84
  type ReportingPolicy,
78
85
  type ResolvedGrants,
79
86
  type Store,
@@ -81,7 +88,7 @@ import {
81
88
  import { formatDecisionDigest } from "./decisions.ts";
82
89
  import type { RunRecord } from "./types.ts";
83
90
  import { dbPath, openStore } from "./store.ts";
84
- import { digestDue } from "./digest-schedule.ts";
91
+ import { digestDue, localDayKey } from "./digest-schedule.ts";
85
92
 
86
93
  /** The activation file. Absent means "this is not an orchestrator session". */
87
94
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -110,6 +117,8 @@ const FRICTION_DIGEST_LIMIT = 3;
110
117
  * definitive, either way.
111
118
  */
112
119
  const RETRY_OWNERSHIP_MS = 60_000;
120
+ /** A failed digest turn must release working-hours catch-up ownership promptly. */
121
+ const MIN_AVAILABILITY_DIGEST_LEASE_MS = 30 * 60 * 1_000;
113
122
 
114
123
  /**
115
124
  * The stall marker — written beside the activation file, in the session cwd —
@@ -257,6 +266,23 @@ interface TickApi {
257
266
  on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
258
267
  on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
259
268
  on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
269
+ on(
270
+ event: "message_start",
271
+ handler: (
272
+ event: {
273
+ type: "message_start";
274
+ message: {
275
+ role: string;
276
+ customType?: string;
277
+ synthetic?: boolean;
278
+ attribution?: "user" | "agent";
279
+ timestamp?: number;
280
+ };
281
+ },
282
+ ctx: TickContext,
283
+ ) => void,
284
+ ): void;
285
+ on(event: "agent_end", handler: (event: { type: "agent_end" }, ctx: TickContext) => void): void;
260
286
  /**
261
287
  * `deliverAs: "followUp"` + `triggerTurn: true`, verified against
262
288
  * `AgentSession.sendCustomMessage` rather than assumed:
@@ -409,42 +435,99 @@ export function queueDigestLine(
409
435
  return line;
410
436
  }
411
437
 
412
- export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
438
+ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
413
439
  material: "Report material events per your brief.",
414
440
  escalations:
415
- "Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
441
+ "Interrupt only for: tier2, fleet-stopped; everything else releases included waits for the daily digest.",
416
442
  decisions:
417
- "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
443
+ "Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event waits for the configured digest; nothing between digests.",
444
+ quiet:
445
+ "Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
418
446
  };
419
447
 
448
+ /** Bounded, durable source material for one due digest. Row ids are part of the
449
+ * handoff contract: the report and exactly the rows it consumed settle in one
450
+ * SQLite transaction, so a crash cannot lose an outcome between those writes. */
451
+ export function formatDigestLedger(
452
+ events: readonly MaterialEvent[],
453
+ notices: readonly HeldNotice[],
454
+ backlog: DigestBacklog,
455
+ ): string {
456
+ const oneLine = (text: string, limit: number): string => {
457
+ const flat = text.replace(/\s+/g, " ").trim();
458
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
459
+ };
460
+ const lines = [
461
+ `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
462
+ ...events.map(
463
+ (event) =>
464
+ ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
465
+ `${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
466
+ ),
467
+ ...notices.map(
468
+ (notice) =>
469
+ ` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
470
+ `${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
471
+ ),
472
+ ];
473
+ if (backlog.materialCount > events.length) {
474
+ lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
475
+ }
476
+ if (backlog.heldNoticeCount > notices.length) {
477
+ lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
478
+ }
479
+ const eventIds = events.map((event) => event.id).join(",");
480
+ const noticeIds = notices.map((notice) => notice.id).join(",");
481
+ lines.push(
482
+ "Compose the digest from the relevant rows above. Hand it off with omp-conductor report --kind digest " +
483
+ `${eventIds.length === 0 ? "" : `--events ${eventIds} `}` +
484
+ `${noticeIds.length === 0 ? "" : `--notices ${noticeIds} `}` +
485
+ "--text TEXT. Remove any row id you did not actually include; omitted rows remain owed. Never invent an id.",
486
+ );
487
+ return lines.join("\n");
488
+ }
489
+
420
490
  /**
421
- * The reporting constraint appended to a default tick prompt (#229).
491
+ * The reporting constraint appended to a default tick prompt (#229, #242).
422
492
  *
423
- * A legacy `scopePreset` keeps its exact words those are what the policy
424
- * tests and the fleet already read with a DUE/not-due clause appended only
425
- * when the digest is actually scheduled (`daily` + `at`). An explicit
426
- * non-preset policy derives one sentence naming its allowed interrupt
427
- * categories and stating that everything else accumulates for the digest.
493
+ * Material and decisions keep their legacy preset words. The escalations
494
+ * preset instead names the policy's actual interrupt categories, so the prompt
495
+ * cannot drift from the gate. Every daily cadence gets an explicit due state:
496
+ * model-timed digests need that instruction just as scheduled digests do.
428
497
  */
429
498
  export function tickReportingConstraint(
430
499
  policy: ReportingPolicy | undefined,
431
- digest: { due: boolean; scheduled: boolean; at?: string; timezone?: string },
500
+ digest: {
501
+ due: boolean;
502
+ cadence: ReportingPolicy["digest"]["cadence"];
503
+ at?: string;
504
+ timezone?: string;
505
+ },
432
506
  held: number,
433
507
  ): string {
434
508
  const preset = policy?.scopePreset;
509
+ const allowed = policy?.interruptOn ?? [];
435
510
  let base: string;
436
- if (preset !== undefined) {
511
+ if (preset === "escalations") {
512
+ base = `Interrupt only for: ${allowed.join(", ")}; everything else — releases included — waits for the daily digest.`;
513
+ } else if (preset !== undefined) {
437
514
  base = TICK_SCOPE_CONSTRAINTS[preset];
438
515
  } else {
439
- const allowed = policy?.interruptOn ?? [];
440
516
  base =
441
517
  allowed.length === 0
442
518
  ? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
443
519
  : `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
444
520
  }
445
- if (!digest.scheduled) return base;
446
- return digest.due
447
- ? `${base} The daily digest is DUE now compose it from this tick's accumulated events and the ${held} held notice(s) below, then send via omp-conductor report --kind digest.`
521
+ if (policy !== undefined && !allowed.includes("material")) {
522
+ base +=
523
+ " Record every material outcome that is not sent as an interrupt immediately with omp-conductor event record; the durable ledger, not session memory, feeds the next digest.";
524
+ }
525
+ if (digest.cadence !== "daily") return base;
526
+ if (digest.due) {
527
+ return `${base} The daily digest is DUE now — compose it from the durable accumulated events and ${held} held notice(s), then send via omp-conductor report --kind digest.`;
528
+ }
529
+ return digest.at === undefined
530
+ ? `${base} The daily digest was already sent today; do not send another.`
448
531
  : `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
449
532
  }
450
533
 
@@ -479,13 +562,14 @@ export function tickReportingConstraint(
479
562
  * report is indistinguishable from a quiet tick. #123 moved delivery to the
480
563
  * daemon, so the clause now names the handover instead. `omp-conductor report`
481
564
  * persists the text before anything is sent and the daemon retries it until it
482
- * lands; a handover that returns a report id has therefore *already* survived
483
- * the failure mode this rule exists for, which a watched `telegram_send` never
484
- * could. `telegram_send` remains the right call for an interactive reply to a
485
- * person who is waiting — that is a conversation, not a report.
565
+ * lands; a handover that returns a durable report or held-notice id has
566
+ * therefore *already* survived the failure mode this rule exists for, which a
567
+ * watched `telegram_send` never could. `telegram_send` remains the right call
568
+ * for an interactive reply to a person who is waiting — that is a
569
+ * conversation, not a report.
486
570
  */
487
571
  export const TICK_DELIVERY_RULE =
488
- "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
572
+ "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a durable handoff id (a report id, or a held-notice id during quiet hours); the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
489
573
 
490
574
  /** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
491
575
  * delivers the tick's ending text (#169). That is a narrower class than the
@@ -528,8 +612,9 @@ export { TELEGRAM_APPROVAL_TOOL };
528
612
  */
529
613
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
530
614
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
531
- `If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
532
- `never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
615
+ `If you have an amendment to propose, deliver the question with telegram_send, prefix its text with "QUESTION:", and wait for your operator's reply on a later turn. ` +
616
+ `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
617
+ `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
533
618
 
534
619
  /**
535
620
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -615,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
615
700
  * trade.
616
701
  */
617
702
  export function resolveTickScope(): {
618
- scope: ReportScope;
703
+ scope: ReportScopeChoice;
619
704
  policy?: ReportingPolicy;
620
705
  briefPath?: string;
621
706
  policyPath?: string;
@@ -625,7 +710,7 @@ export function resolveTickScope(): {
625
710
  try {
626
711
  const project = findProject(loadConfig());
627
712
  return {
628
- scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
713
+ scope: reportScopeFromPolicy(project.reporting),
629
714
  policy: project.reporting,
630
715
  briefPath: briefPathForProject(project),
631
716
  policyPath: policyPathForProject(project),
@@ -1272,6 +1357,228 @@ function clearTickRequest(pi: TickApi, cwd: string): void {
1272
1357
  }
1273
1358
  }
1274
1359
 
1360
+ interface PendingLocalTick {
1361
+ id: string;
1362
+ projectName?: string;
1363
+ policy?: ReportingPolicy;
1364
+ }
1365
+
1366
+ interface ActiveLocalTick extends PendingLocalTick {
1367
+ /** A person who writes during an autonomous run is awake by construction. */
1368
+ humanWaiting: boolean;
1369
+ }
1370
+
1371
+ interface TelegramInterrupt {
1372
+ kind: "question" | "message";
1373
+ category: HeldNotice["category"];
1374
+ summary: string;
1375
+ detail: string;
1376
+ hasFiles: boolean;
1377
+ fingerprint: string;
1378
+ }
1379
+
1380
+ function objectRecord(value: unknown): value is Record<string, unknown> {
1381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1382
+ }
1383
+ function canonicalTelegramPayload(value: unknown): unknown {
1384
+ if (Array.isArray(value)) return value.map(canonicalTelegramPayload);
1385
+ if (!objectRecord(value)) return value;
1386
+ return Object.fromEntries(
1387
+ Object.keys(value)
1388
+ .sort()
1389
+ .map((key) => [key, canonicalTelegramPayload(value[key])]),
1390
+ );
1391
+ }
1392
+
1393
+
1394
+ function serialiseTelegramPayload(value: unknown): string {
1395
+ try {
1396
+ return JSON.stringify(canonicalTelegramPayload(value)) ?? String(value);
1397
+ } catch {
1398
+ return String(value);
1399
+ }
1400
+ }
1401
+
1402
+ function telegramInterruptFromTool(
1403
+ toolName: string,
1404
+ input: Record<string, unknown>,
1405
+ ): TelegramInterrupt | undefined {
1406
+ let kind: TelegramInterrupt["kind"];
1407
+ let payload: Record<string, unknown> = input;
1408
+
1409
+ if (toolName === TELEGRAM_APPROVAL_TOOL) {
1410
+ kind = "question";
1411
+ } else if (toolName === "telegram_send" || toolName === "telegram_react") {
1412
+ kind = "message";
1413
+ } else if (toolName === "write") {
1414
+ const path = input["path"];
1415
+ if (
1416
+ path !== "xd://telegram_ask" &&
1417
+ path !== "xd://telegram_send" &&
1418
+ path !== "xd://telegram_react"
1419
+ ) {
1420
+ return undefined;
1421
+ }
1422
+ kind = path === "xd://telegram_ask" ? "question" : "message";
1423
+ const content = input["content"];
1424
+ if (typeof content === "string") {
1425
+ try {
1426
+ const parsed: unknown = JSON.parse(content);
1427
+ payload = objectRecord(parsed) ? parsed : { content };
1428
+ } catch {
1429
+ payload = { content };
1430
+ }
1431
+ }
1432
+ } else {
1433
+ return undefined;
1434
+ }
1435
+ if (
1436
+ kind === "message" &&
1437
+ typeof payload["text"] === "string" &&
1438
+ /^\s*QUESTION:\s/i.test(payload["text"])
1439
+ ) {
1440
+ // The package floor uses this explicit fallback when telegram_ask is not
1441
+ // mounted. Without the marker a plain send remains a material update.
1442
+ kind = "question";
1443
+ }
1444
+
1445
+ let detail = "";
1446
+ if (kind === "question" && Array.isArray(payload["questions"])) {
1447
+ detail = payload["questions"]
1448
+ .map((value, index) => {
1449
+ if (!objectRecord(value)) return `Question ${index + 1}: ${serialiseTelegramPayload(value)}`;
1450
+ const question = typeof value["question"] === "string" ? value["question"] : "Question";
1451
+ const options = Array.isArray(value["options"])
1452
+ ? value["options"]
1453
+ .map((option) =>
1454
+ objectRecord(option) && typeof option["label"] === "string"
1455
+ ? `${option["label"]}${
1456
+ typeof option["description"] === "string" && option["description"] !== ""
1457
+ ? ` — ${option["description"]}`
1458
+ : ""
1459
+ }`
1460
+ : serialiseTelegramPayload(option),
1461
+ )
1462
+ .join("; ")
1463
+ : "";
1464
+ return options === "" ? question : `${question}\nOptions: ${options}`;
1465
+ })
1466
+ .join("\n\n");
1467
+ } else if (typeof payload["text"] === "string") {
1468
+ detail = payload["text"];
1469
+ }
1470
+ if (detail.trim() === "") detail = serialiseTelegramPayload(payload);
1471
+
1472
+ const flat = detail.replace(/\s+/g, " ").trim();
1473
+ const fallback = kind === "question" ? "Operator question" : "Telegram message";
1474
+ const summary = flat === "" ? fallback : flat.length <= 180 ? flat : `${flat.slice(0, 179)}…`;
1475
+ const canonical = serialiseTelegramPayload({ kind, payload });
1476
+ return {
1477
+ kind,
1478
+ category: kind === "question" ? "decision-needed" : "material",
1479
+ summary,
1480
+ hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
1481
+ detail,
1482
+ fingerprint: createHash("sha256").update(canonical).digest("hex"),
1483
+ };
1484
+ }
1485
+
1486
+ type TelegramInterruptBlock = { block: true; reason: string };
1487
+
1488
+ /**
1489
+ * A locally injected tick is an autonomous actor. Its direct Telegram calls
1490
+ * therefore pass through the same category and availability decision as daemon
1491
+ * escalations. Human-originated runs, including a message steered into a tick,
1492
+ * are exempt: the operator is already present and waiting for the reply.
1493
+ */
1494
+ function autonomousTelegramInterruptBlock(
1495
+ pi: TickApi,
1496
+ session: TickSession,
1497
+ event: { toolName: string; input: Record<string, unknown> },
1498
+ ): TelegramInterruptBlock | undefined {
1499
+ const active = session.activeLocalTick;
1500
+ if (active === undefined || active.humanWaiting) return undefined;
1501
+ const interrupt = telegramInterruptFromTool(event.toolName, event.input);
1502
+ if (interrupt === undefined) return undefined;
1503
+ if (active.projectName === undefined) {
1504
+ return {
1505
+ block: true,
1506
+ reason:
1507
+ "Operator availability policy is unavailable for this autonomous tick, so direct Telegram is blocked fail-closed. " +
1508
+ "Repair the conductor config; do not retry through another Telegram path.",
1509
+ };
1510
+ }
1511
+
1512
+ let policy = active.policy;
1513
+ let configReadable = true;
1514
+ try {
1515
+ policy = findProject(loadConfig(), active.projectName).reporting;
1516
+ } catch (err) {
1517
+ configReadable = false;
1518
+ pi.logger.error(
1519
+ `[omp-conductor] operator availability config unreadable during Telegram call; blocking fail-closed: ${
1520
+ err instanceof Error ? err.message : String(err)
1521
+ }`,
1522
+ );
1523
+ }
1524
+
1525
+ const at = Date.now();
1526
+ const disposition = configReadable
1527
+ ? interruptDisposition(policy, interrupt.category, at)
1528
+ : "availability";
1529
+ if (disposition === "interrupt") return undefined;
1530
+ const release =
1531
+ disposition === "availability"
1532
+ ? "the availability window opens or the daily digest runs"
1533
+ : "the daily digest runs";
1534
+ if (interrupt.hasFiles) {
1535
+ return {
1536
+ block: true,
1537
+ reason:
1538
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind} until ${release}, ` +
1539
+ "and attachment-bearing Telegram sends cannot be durably replayed. Nothing was sent or held. " +
1540
+ "Do not retry through another Telegram path while the policy forbids delivery.",
1541
+ };
1542
+ }
1543
+
1544
+ const key = `${active.projectName}:autonomous-telegram:${active.id}:${interrupt.fingerprint}`;
1545
+ try {
1546
+ const store = openStore(dbPath());
1547
+ try {
1548
+ if (!store.wasNotified(key)) {
1549
+ store.addHeldNotice({
1550
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
1551
+ project: active.projectName,
1552
+ category: interrupt.category,
1553
+ summary: interrupt.summary,
1554
+ detail: interrupt.detail,
1555
+ createdAt: at,
1556
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
1557
+ });
1558
+ store.markNotified(key);
1559
+ }
1560
+ } finally {
1561
+ store.close();
1562
+ }
1563
+ } catch (err) {
1564
+ const reason = err instanceof Error ? err.message : String(err);
1565
+ pi.logger.error(`[omp-conductor] could not persist held Telegram ${interrupt.kind}: ${reason}`);
1566
+ return {
1567
+ block: true,
1568
+ reason:
1569
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind}, and the durable hold failed: ${reason}. ` +
1570
+ "Do not retry through another Telegram path.",
1571
+ };
1572
+ }
1573
+
1574
+ return {
1575
+ block: true,
1576
+ reason:
1577
+ `Operator policy durably held this autonomous Telegram ${interrupt.kind} until ${release}. ` +
1578
+ "Do not retry through another Telegram path.",
1579
+ };
1580
+ }
1581
+
1275
1582
  /** Everything one tick remembers for the next. */
1276
1583
  interface TickSession {
1277
1584
  /**
@@ -1300,6 +1607,10 @@ interface TickSession {
1300
1607
  bridgeTokenAtStart: boolean;
1301
1608
  /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
1302
1609
  pendingSkips: number;
1610
+ /** Ticks sent as follow-ups but not yet observed by the agent loop. */
1611
+ pendingLocalTicks: PendingLocalTick[];
1612
+ /** The local tick whose agent loop is currently running, if any. */
1613
+ activeLocalTick?: ActiveLocalTick;
1303
1614
  }
1304
1615
 
1305
1616
  /**
@@ -1377,28 +1688,74 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1377
1688
  if (scope.projectName !== undefined) {
1378
1689
  const policy = scope.policy;
1379
1690
  const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
1380
- const scheduled = digestPolicy.cadence === "daily" && digestPolicy.at !== undefined;
1691
+ const cadence = digestPolicy.cadence;
1381
1692
  const at = Date.now();
1382
1693
  const store = openStore(dbPath());
1383
1694
  try {
1384
1695
  const lastKey = store.lastDigestDedupeKey(scope.projectName);
1385
1696
  const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1697
+ const due = digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at);
1698
+ // Reserve availability-held rows before reading them. The same SQLite
1699
+ // transaction releases pending catch-ups; daemon enqueue attempts then
1700
+ // observe the lease, closing both orders of the snapshot-to-handoff race.
1701
+ const availabilityReserved =
1702
+ due &&
1703
+ store.reserveAvailabilityDigest(
1704
+ scope.projectName,
1705
+ localDayKey(at, digestPolicy.timezone ?? policy?.availability?.timezone),
1706
+ at,
1707
+ at +
1708
+ Math.max(
1709
+ MIN_AVAILABILITY_DIGEST_LEASE_MS,
1710
+ (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1_000 + 5 * 60_000,
1711
+ ),
1712
+ );
1713
+ const backlog = store.digestBacklog(scope.projectName);
1714
+ const visibleBacklog: DigestBacklog = availabilityReserved
1715
+ ? backlog
1716
+ : {
1717
+ ...backlog,
1718
+ heldNoticeCount: Math.max(
1719
+ 0,
1720
+ backlog.heldNoticeCount - (backlog.availabilityHeldNoticeCount ?? 0),
1721
+ ),
1722
+ };
1386
1723
  reportingConstraint = tickReportingConstraint(
1387
1724
  policy,
1388
1725
  {
1389
- due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
1390
- scheduled,
1726
+ due,
1727
+ cadence,
1391
1728
  at: digestPolicy.at,
1392
1729
  timezone: digestPolicy.timezone,
1393
1730
  },
1394
- store.undigestedNotices(scope.projectName).length,
1731
+ visibleBacklog.heldNoticeCount,
1395
1732
  );
1733
+ if (
1734
+ due &&
1735
+ (visibleBacklog.materialCount > 0 || visibleBacklog.heldNoticeCount > 0)
1736
+ ) {
1737
+ const notices = store
1738
+ .undigestedNotices(scope.projectName)
1739
+ .filter((notice) => notice.releaseOnAvailable !== true || availabilityReserved);
1740
+ reportingConstraint +=
1741
+ `\n${formatDigestLedger(
1742
+ store.undigestedMaterialEvents(scope.projectName),
1743
+ notices,
1744
+ visibleBacklog,
1745
+ )}`;
1746
+ }
1396
1747
  } finally {
1397
1748
  store.close();
1398
1749
  }
1399
1750
  }
1400
1751
  content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1401
1752
  }
1753
+ if (scope.projectName !== undefined) {
1754
+ // Unlike ordinary reporting prose, this is a mechanical clock reading and
1755
+ // survives a custom heartbeat message. The model never infers whether the
1756
+ // operator can be interrupted.
1757
+ content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
1758
+ }
1402
1759
  let frictionStore: Store | undefined;
1403
1760
  let frictionSignals: FrictionSignal[] = [];
1404
1761
  const now = Date.now();
@@ -1516,11 +1873,23 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1516
1873
  // No `accessFile` means no fleet bridge to judge, exactly as above.
1517
1874
  if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1518
1875
 
1876
+ const pendingLocalTick: PendingLocalTick = {
1877
+ id: randomUUID(),
1878
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
1879
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
1880
+ };
1881
+ session.pendingLocalTicks.push(pendingLocalTick);
1519
1882
  try {
1520
- pi.sendMessage(
1521
- { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1522
- { triggerTurn: true, deliverAs: "followUp" },
1523
- );
1883
+ try {
1884
+ pi.sendMessage(
1885
+ { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1886
+ { triggerTurn: true, deliverAs: "followUp" },
1887
+ );
1888
+ } catch (err) {
1889
+ const pendingIndex = session.pendingLocalTicks.indexOf(pendingLocalTick);
1890
+ if (pendingIndex >= 0) session.pendingLocalTicks.splice(pendingIndex, 1);
1891
+ throw err;
1892
+ }
1524
1893
  if (scope.projectName !== undefined && frictionSignals.length > 0) {
1525
1894
  try {
1526
1895
  frictionStore?.markFrictionSurfaced(
@@ -1635,8 +2004,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1635
2004
  approvalToolMissingLogged: false,
1636
2005
  bridgeTokenAtStart: true,
1637
2006
  pendingSkips: 0,
2007
+ pendingLocalTicks: [],
1638
2008
  };
1639
2009
  let releaseGateArmed = false;
2010
+ let availabilityGateArmed = false;
1640
2011
  let guardArmed = false;
1641
2012
  // An activation file makes this a fleet directory before Herdr can prove
1642
2013
  // which pane owns it. The gate therefore starts closed and only honours a
@@ -1683,9 +2054,17 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1683
2054
  // `createSession`; suppress this second copy only after this session has
1684
2055
  // proved it owns the external heartbeat.
1685
2056
  if (releaseAuthorityAccepted && !external) return undefined;
2057
+ // Before ownership is proved this session holds no grant at all, so a
2058
+ // covered shape still refuses with the deny-all wording.
2059
+ const decision = refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
2060
+ if (decision === undefined) return undefined;
1686
2061
  if (projectName !== undefined) {
1687
2062
  try {
1688
- recordReleaseBlock(projectName, "orchestrator", shape);
2063
+ recordReleaseBlock(projectName, "orchestrator", shape, {
2064
+ tool: event.toolName,
2065
+ reason: decision.reason,
2066
+ args: redactReleaseArgs(event.input),
2067
+ });
1689
2068
  } catch (err) {
1690
2069
  pi.logger.error(
1691
2070
  `[omp-conductor] could not record release-policy block: ${
@@ -1694,13 +2073,65 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1694
2073
  );
1695
2074
  }
1696
2075
  }
1697
- // Before ownership is proved this session holds no grant at all, so a
1698
- // covered shape still refuses — with the wording it would get from a
1699
- // deny-all map rather than a claim about a grant it cannot yet use.
1700
- return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
2076
+ return decision;
1701
2077
  });
1702
2078
  };
1703
2079
 
2080
+ const armAvailabilityGate = (): void => {
2081
+ if (availabilityGateArmed) return;
2082
+ availabilityGateArmed = true;
2083
+
2084
+ // Custom messages sent with `pi.sendMessage` bypass `before_agent_start`.
2085
+ // `message_start` is the first lifecycle event that carries their exact
2086
+ // custom type, so it is the reliable boundary between a local tick and an
2087
+ // inbound human turn.
2088
+ pi.on("message_start", (event) => {
2089
+ const message = event.message;
2090
+ if (message.role === "custom" && message.customType === TICK_CUSTOM_TYPE) {
2091
+ const pending = session.pendingLocalTicks.shift();
2092
+ if (pending !== undefined) {
2093
+ session.activeLocalTick = { ...pending, humanWaiting: false };
2094
+ } else {
2095
+ // A queued custom tick can survive a host restart after the in-memory
2096
+ // enqueue record does not. Reconstruct its scope so recovery cannot
2097
+ // silently turn an autonomous run into an interactive one.
2098
+ const scope = resolveTickScope();
2099
+ const id =
2100
+ typeof message.timestamp === "number"
2101
+ ? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
2102
+ : randomUUID();
2103
+ session.activeLocalTick = {
2104
+ id,
2105
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
2106
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
2107
+ humanWaiting: false,
2108
+ };
2109
+ }
2110
+ return;
2111
+ }
2112
+ if (
2113
+ session.activeLocalTick !== undefined &&
2114
+ message.role === "user" &&
2115
+ message.synthetic !== true &&
2116
+ message.attribution !== "agent"
2117
+ ) {
2118
+ session.activeLocalTick.humanWaiting = true;
2119
+ }
2120
+ });
2121
+ pi.on("agent_end", () => {
2122
+ session.activeLocalTick = undefined;
2123
+ });
2124
+ (pi as TickApi & {
2125
+ on(
2126
+ event: "tool_call",
2127
+ handler: (
2128
+ event: { toolName: string; input: Record<string, unknown> },
2129
+ ctx: unknown,
2130
+ ) => TelegramInterruptBlock | undefined,
2131
+ ): void;
2132
+ }).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
2133
+ };
2134
+
1704
2135
  pi.on("session_start", (_event, ctx) => {
1705
2136
  if (decided) return;
1706
2137
 
@@ -1728,6 +2159,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1728
2159
  // Present but invalid still identifies a fleet directory. Install the
1729
2160
  // fail-closed handler before validation or ownership can return early.
1730
2161
  armReleaseGate();
2162
+ armAvailabilityGate();
1731
2163
 
1732
2164
  if (result.kind === "invalid") {
1733
2165
  const detail = `${result.path}: ${result.problem}`;