omp-conductor 0.13.0 → 0.15.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 (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Beyond those three gates, every tick carries the project's `reporting.scope`
27
27
  * as one explicit constraint line, re-read from the conductor config on each
28
- * tick so a `/conductor setup` change binds the next heartbeat rather than
28
+ * tick so an `omp-conductor setup` change binds the next heartbeat rather than
29
29
  * waiting for a session restart — and one delivery rule
30
30
  * ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
31
31
  * written as end-of-turn text on such a turn reaches nobody. An operator's own
@@ -44,10 +44,12 @@
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";
50
- import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
51
+ import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
+ import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
51
53
  import {
52
54
  bridgeTokenBound,
53
55
  hasBotToken,
@@ -58,6 +60,7 @@ import {
58
60
  import {
59
61
  briefPathForProject,
60
62
  policyPathForProject,
63
+ reportScopeFromPolicy,
61
64
  refreshComposedBriefForProject,
62
65
  } from "./setup.ts";
63
66
  import {
@@ -73,8 +76,11 @@ import {
73
76
  DEFAULT_REPORT_SCOPE,
74
77
  DENIED_RELEASE_GRANTS,
75
78
  type DispatchSummary,
79
+ type DigestBacklog,
76
80
  type FrictionSignal,
77
- type ReportScope,
81
+ type HeldNotice,
82
+ type MaterialEvent,
83
+ type ReportScopeChoice,
78
84
  type ReportingPolicy,
79
85
  type ResolvedGrants,
80
86
  type Store,
@@ -82,7 +88,7 @@ import {
82
88
  import { formatDecisionDigest } from "./decisions.ts";
83
89
  import type { RunRecord } from "./types.ts";
84
90
  import { dbPath, openStore } from "./store.ts";
85
- import { digestDue } from "./digest-schedule.ts";
91
+ import { digestDue, localDayKey } from "./digest-schedule.ts";
86
92
 
87
93
  /** The activation file. Absent means "this is not an orchestrator session". */
88
94
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -111,6 +117,8 @@ const FRICTION_DIGEST_LIMIT = 3;
111
117
  * definitive, either way.
112
118
  */
113
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;
114
122
 
115
123
  /**
116
124
  * The stall marker — written beside the activation file, in the session cwd —
@@ -145,13 +153,18 @@ const PENDING_MESSAGE_GRACE_MS = 60_000;
145
153
  const DEFAULT_GROOM_BELOW = 4;
146
154
 
147
155
  /**
148
- * Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
149
- * fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
150
- * only after a tick is actually sent — a disarmed or channel-down fleet keeps
151
- * the request until gates pass (or a human removes the file).
156
+ * Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
157
+ * dispatch daemon when a watched decision condition transitions false→true
158
+ * (#329), so the orchestrator can reconcile without waiting a full
159
+ * `intervalSeconds`. Cleared only after a tick is actually sent — a disarmed or
160
+ * channel-down fleet keeps the request until gates pass (or a human removes the
161
+ * file). Mid-interval the heartbeat polls for this file on a short cadence.
152
162
  */
153
163
  export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
154
164
 
165
+ /** How often a live heartbeat looks for {@link TICK_REQUESTED_FILE} between ticks. */
166
+ const TICK_REQUEST_POLL_MS = 10_000;
167
+
155
168
  /** Runtime heartbeat schedule consumed by `omp-conductor status`. */
156
169
  export const TICK_STATUS_FILE = ".conductor-tick-status.json";
157
170
 
@@ -205,9 +218,9 @@ const PENDING_REASON = "tick already pending";
205
218
  * The slice of the omp extension API this entry touches, mirroring
206
219
  * `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
207
220
  *
208
- * Declared here rather than imported for the reason ./plugin.ts declares its
209
- * own: the harness is a peer dependency and the package has to type-check
210
- * without it installed. Structural typing means the real objects satisfy these
221
+ * Declared here rather than imported because the harness is a peer dependency and
222
+ * the package has to type-check without it installed — the same reason ./omp.ts
223
+ * loads the SDK dynamically. Structural typing means the real objects satisfy these
211
224
  * on the way in.
212
225
  */
213
226
  interface TickLogger {
@@ -258,6 +271,23 @@ interface TickApi {
258
271
  on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
259
272
  on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
260
273
  on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
274
+ on(
275
+ event: "message_start",
276
+ handler: (
277
+ event: {
278
+ type: "message_start";
279
+ message: {
280
+ role: string;
281
+ customType?: string;
282
+ synthetic?: boolean;
283
+ attribution?: "user" | "agent";
284
+ timestamp?: number;
285
+ };
286
+ },
287
+ ctx: TickContext,
288
+ ) => void,
289
+ ): void;
290
+ on(event: "agent_end", handler: (event: { type: "agent_end" }, ctx: TickContext) => void): void;
261
291
  /**
262
292
  * `deliverAs: "followUp"` + `triggerTurn: true`, verified against
263
293
  * `AgentSession.sendCustomMessage` rather than assumed:
@@ -287,6 +317,19 @@ interface TickApi {
287
317
  * they get here. */
288
318
  export interface TickConfig {
289
319
  intervalSeconds: number;
320
+ /**
321
+ * The conductor project this fleet session ticks for, stamped by `setup host`
322
+ * (one tick config per fleet cwd). It is what lets a host with several
323
+ * configured projects resolve *this* fleet's brief, reporting policy, digest
324
+ * ledger and release grants, instead of asking `findProject` a question it
325
+ * refuses to guess through.
326
+ *
327
+ * Omitted is the pre-multi-project spelling. Resolution then falls back to the
328
+ * un-named `findProject`: exact for a single-project fleet — which must keep
329
+ * behaving exactly as it did — and ambiguous, therefore degrading, for more
330
+ * than one.
331
+ */
332
+ project?: string;
290
333
  armedFile?: string;
291
334
  accessFile?: string;
292
335
  message?: string;
@@ -410,14 +453,58 @@ export function queueDigestLine(
410
453
  return line;
411
454
  }
412
455
 
413
- export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
456
+ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
414
457
  material: "Report material events per your brief.",
415
458
  escalations:
416
459
  "Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
417
460
  decisions:
418
- "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.",
461
+ "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.",
462
+ quiet:
463
+ "Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
419
464
  };
420
465
 
466
+ /** Bounded, durable source material for one due digest. Row ids are part of the
467
+ * handoff contract: the report and exactly the rows it consumed settle in one
468
+ * SQLite transaction, so a crash cannot lose an outcome between those writes. */
469
+ export function formatDigestLedger(
470
+ events: readonly MaterialEvent[],
471
+ notices: readonly HeldNotice[],
472
+ backlog: DigestBacklog,
473
+ ): string {
474
+ const oneLine = (text: string, limit: number): string => {
475
+ const flat = text.replace(/\s+/g, " ").trim();
476
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
477
+ };
478
+ const lines = [
479
+ `Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
480
+ ...events.map(
481
+ (event) =>
482
+ ` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
483
+ `${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
484
+ ),
485
+ ...notices.map(
486
+ (notice) =>
487
+ ` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
488
+ `${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
489
+ ),
490
+ ];
491
+ if (backlog.materialCount > events.length) {
492
+ lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
493
+ }
494
+ if (backlog.heldNoticeCount > notices.length) {
495
+ lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
496
+ }
497
+ const eventIds = events.map((event) => event.id).join(",");
498
+ const noticeIds = notices.map((notice) => notice.id).join(",");
499
+ lines.push(
500
+ "Compose the digest from the relevant rows above. Hand it off with omp-conductor report --kind digest " +
501
+ `${eventIds.length === 0 ? "" : `--events ${eventIds} `}` +
502
+ `${noticeIds.length === 0 ? "" : `--notices ${noticeIds} `}` +
503
+ "--text TEXT. Remove any row id you did not actually include; omitted rows remain owed. Never invent an id.",
504
+ );
505
+ return lines.join("\n");
506
+ }
507
+
421
508
  /**
422
509
  * The reporting constraint appended to a default tick prompt (#229, #242).
423
510
  *
@@ -449,9 +536,13 @@ export function tickReportingConstraint(
449
536
  ? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
450
537
  : `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
451
538
  }
539
+ if (policy !== undefined && !allowed.includes("material")) {
540
+ base +=
541
+ " 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.";
542
+ }
452
543
  if (digest.cadence !== "daily") return base;
453
544
  if (digest.due) {
454
- return `${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.`;
545
+ 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.`;
455
546
  }
456
547
  return digest.at === undefined
457
548
  ? `${base} The daily digest was already sent today; do not send another.`
@@ -489,13 +580,14 @@ export function tickReportingConstraint(
489
580
  * report is indistinguishable from a quiet tick. #123 moved delivery to the
490
581
  * daemon, so the clause now names the handover instead. `omp-conductor report`
491
582
  * persists the text before anything is sent and the daemon retries it until it
492
- * lands; a handover that returns a report id has therefore *already* survived
493
- * the failure mode this rule exists for, which a watched `telegram_send` never
494
- * could. `telegram_send` remains the right call for an interactive reply to a
495
- * person who is waiting — that is a conversation, not a report.
583
+ * lands; a handover that returns a durable report or held-notice id has
584
+ * therefore *already* survived the failure mode this rule exists for, which a
585
+ * watched `telegram_send` never could. `telegram_send` remains the right call
586
+ * for an interactive reply to a person who is waiting — that is a
587
+ * conversation, not a report.
496
588
  */
497
589
  export const TICK_DELIVERY_RULE =
498
- "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.";
590
+ "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.";
499
591
 
500
592
  /** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
501
593
  * delivers the tick's ending text (#169). That is a narrower class than the
@@ -538,8 +630,9 @@ export { TELEGRAM_APPROVAL_TOOL };
538
630
  */
539
631
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
540
632
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
541
- `If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn; ` +
542
- `never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
633
+ `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. ` +
634
+ `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
635
+ `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
543
636
 
544
637
  /**
545
638
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -607,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
607
700
  * config could not answer — why.
608
701
  *
609
702
  * Read on every tick rather than cached at session start, for the reason the
610
- * channel gate is: the operator re-runs `/conductor setup` while this session
703
+ * channel gate is: the operator re-runs `omp-conductor setup` while this session
611
704
  * lives, and a heartbeat holding a startup snapshot would keep injecting the
612
705
  * old contract until somebody restarted it.
613
706
  *
@@ -623,27 +716,141 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
623
716
  * none named — the same ambiguity `findProject` refuses to guess through for
624
717
  * `status`. Stopping the heartbeat over either preference would be the worse
625
718
  * trade.
719
+ *
720
+ * `projectName` is {@link TickConfig.project}, stamped per fleet cwd, and it is
721
+ * the whole difference between a two-project host resolving each fleet's own
722
+ * contract and both of them degrading. Omitted (a pre-multi-project tick config)
723
+ * keeps the un-named lookup, which only a host with more than one project can
724
+ * fail — and that host gets {@link LEGACY_TICK_PROJECT_HINT} naming the one
725
+ * command that fixes it.
626
726
  */
627
- export function resolveTickScope(): {
628
- scope: ReportScope;
727
+ export function resolveTickScope(projectName?: string): {
728
+ scope: ReportScopeChoice;
629
729
  policy?: ReportingPolicy;
630
730
  briefPath?: string;
631
731
  policyPath?: string;
632
732
  projectName?: string;
633
733
  fallback?: string;
634
734
  } {
735
+ // Counted before `findProject` can throw, because "several projects and this
736
+ // tick config names none" is the one fault with a one-command remedy and the
737
+ // only way to tell it apart from "no config at all" is the count.
738
+ let projects = 0;
635
739
  try {
636
- const project = findProject(loadConfig());
740
+ const config = loadConfig();
741
+ projects = config.projects.length;
742
+ const project = findProject(config, projectName);
637
743
  return {
638
- scope: project.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
744
+ scope: reportScopeFromPolicy(project.reporting),
639
745
  policy: project.reporting,
640
746
  briefPath: briefPathForProject(project),
641
747
  policyPath: policyPathForProject(project),
642
748
  projectName: project.name,
643
749
  };
644
750
  } catch (err) {
645
- return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
751
+ const problem = err instanceof Error ? err.message : String(err);
752
+ const unstamped = projectName === undefined && projects > 1;
753
+ return { scope: DEFAULT_REPORT_SCOPE, fallback: unstamped ? `${problem} ${LEGACY_TICK_PROJECT_HINT}` : problem };
754
+ }
755
+ }
756
+
757
+ /**
758
+ * The fix for the one degradation an operator can clear in a single command: a
759
+ * tick config written before {@link TickConfig.project} existed, on a host that
760
+ * has since gained a second project.
761
+ */
762
+ export const LEGACY_TICK_PROJECT_HINT =
763
+ "Re-run `omp-conductor setup host` to stamp the project into .conductor-tick.json.";
764
+
765
+ /**
766
+ * Whether a tick config found on disk belongs to the project being asked about.
767
+ *
768
+ * Search roots overlap — `stateDir()` and the shared parent of two fleet cwds
769
+ * are read for every project — so without this a stray config in a shared root
770
+ * answers for whichever project asked first, and `setup host` would restamp one
771
+ * project's file while planning another. An unstamped config matches anything,
772
+ * which is what keeps a single-project fleet on its existing file.
773
+ */
774
+ export function tickConfigMatchesProject(config: TickConfig, projectName?: string): boolean {
775
+ if (projectName === undefined || config.project === undefined) return true;
776
+ return config.project === projectName;
777
+ }
778
+
779
+ /**
780
+ * Directory that owns this project's {@link TICK_CONFIG_FILE}, if one is
781
+ * present and stamped for the project (or unstamped on a single-project host).
782
+ * Used by out-of-process writers of {@link TICK_REQUESTED_FILE} — the dispatch
783
+ * daemon must not import `fleet.ts` (fleet already imports daemon).
784
+ */
785
+ export function resolveTickConfigCwd(projectName?: string): string | undefined {
786
+ const roots: string[] = [stateDir()];
787
+ try {
788
+ const p = findProject(loadConfig(), projectName);
789
+ const parent = dirname(p.workspaceRoot);
790
+ if (parent !== roots[0]) roots.push(parent);
791
+ if (p.workspaceRoot !== roots[0] && p.workspaceRoot !== parent) roots.push(p.workspaceRoot);
792
+ } catch {
793
+ /* no config */
794
+ }
795
+ for (const cwd of roots) {
796
+ const r = readTickConfig(cwd);
797
+ if (r.kind === "ok") {
798
+ if (!tickConfigMatchesProject(r.config, projectName)) continue;
799
+ return cwd;
800
+ }
801
+ if (r.kind === "invalid") return undefined;
802
+ }
803
+ return undefined;
804
+ }
805
+
806
+ /** The shared, pre-per-project arm marker: `<stateDir>/armed`. */
807
+ export function legacyArmedMarkerPath(): string {
808
+ return join(stateDir(), "armed");
809
+ }
810
+
811
+ /** What `status` says about a bare `armed` marker that no tick will honour. */
812
+ export const LEGACY_ARM_MARKER_DETAIL =
813
+ "legacy global arm marker — re-run setup host, then arm per project";
814
+
815
+ /**
816
+ * Whether the arm gate is open, and whether the shared pre-per-project marker is
817
+ * what decided it.
818
+ *
819
+ * Two migrations meet here. A single-project fleet whose `armedFile` has just
820
+ * been restamped from `armed` to `armed-<name>` must not be silently disarmed by
821
+ * the upgrade, so its bare `armed` marker still counts (`legacy: "honoured"`). A
822
+ * host with more than one project cannot let one marker arm every fleet — that is
823
+ * the collision this issue removes — so there the bare marker arms nothing and
824
+ * says so instead (`legacy: "stranded"`), including for a tick config that still
825
+ * names it directly.
826
+ *
827
+ * The fallback reaches exactly two `armedFile` spellings: the shared path itself,
828
+ * and the per-project path the restamp replaces it with. An operator's own gate
829
+ * — any other value, which `setup host` deliberately preserves — is the gate; its
830
+ * absence means disarmed however many stale markers lie around it.
831
+ *
832
+ * The config is read only when a legacy marker is actually in play, so the
833
+ * healthy path costs one string compare. An unreadable or absent config is
834
+ * treated as single-project: a host with no conductor config cannot be a
835
+ * multi-project host, and 0.14 behaviour is the honest default there.
836
+ */
837
+ export type ArmState = { armed: boolean; legacy?: "honoured" | "stranded" };
838
+
839
+ export function resolveArmState(armedFile: string, projectName?: string): ArmState {
840
+ const legacy = legacyArmedMarkerPath();
841
+ const shared = armedFile === legacy;
842
+ if (!shared && existsSync(armedFile)) return { armed: true };
843
+ const migratable =
844
+ shared || (projectName !== undefined && armedFile === join(stateDir(), `armed-${projectName}`));
845
+ if (!migratable || !existsSync(legacy)) return { armed: false };
846
+ let multiProject = false;
847
+ try {
848
+ multiProject = loadConfig().projects.length > 1;
849
+ } catch {
850
+ // No readable config: not a multi-project host, so keep 0.14 behaviour.
646
851
  }
852
+ if (multiProject) return { armed: false, legacy: "stranded" };
853
+ return shared ? { armed: true } : { armed: true, legacy: "honoured" };
647
854
  }
648
855
 
649
856
  /**
@@ -655,9 +862,9 @@ export function resolveTickScope(): {
655
862
  * the default prompt path. Failures (no config, no `POLICY.md`, unreadable
656
863
  * overlay) are silent: the tick still goes out.
657
864
  */
658
- export function refreshComposedBriefBestEffort(): boolean {
865
+ export function refreshComposedBriefBestEffort(projectName?: string): boolean {
659
866
  try {
660
- return refreshComposedBriefForProject(findProject(loadConfig()));
867
+ return refreshComposedBriefForProject(findProject(loadConfig(), projectName));
661
868
  } catch {
662
869
  return false;
663
870
  }
@@ -747,6 +954,16 @@ export function readTickConfig(cwd: string): TickConfigResult {
747
954
  }
748
955
  }
749
956
 
957
+ const projectRaw = raw["project"];
958
+ let project: string | undefined;
959
+ if (projectRaw !== undefined) {
960
+ if (typeof projectRaw !== "string" || projectRaw.trim().length === 0) {
961
+ problems.push("project must be a non-empty string when present");
962
+ } else {
963
+ project = projectRaw.trim();
964
+ }
965
+ }
966
+
750
967
  if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
751
968
 
752
969
  return {
@@ -754,6 +971,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
754
971
  path,
755
972
  config: {
756
973
  intervalSeconds,
974
+ ...(project === undefined ? {} : { project }),
757
975
  ...(budgetSeconds === undefined ? {} : { budgetSeconds }),
758
976
  ...(armedFile === undefined ? {} : { armedFile }),
759
977
  ...(accessFile === undefined ? {} : { accessFile }),
@@ -916,9 +1134,17 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
916
1134
  if (mine?.name !== undefined) {
917
1135
  // A registered agent, just not this fleet's. herdr can name several omp
918
1136
  // agents in one directory, and requiring merely *a* name would arm each one.
1137
+ //
1138
+ // The rename hint is conditional because this branch has two very different
1139
+ // causes: a scratch pane that is correctly named something else, and the
1140
+ // fleet's own pane still carrying the name it had before its tick config was
1141
+ // restamped for a project. Only the operator knows which.
919
1142
  return {
920
1143
  kind: "declined",
921
- reason: `this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — this session will not tick`,
1144
+ reason:
1145
+ `this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — ` +
1146
+ `if this is the fleet pane under an old name, \`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
1147
+ `this session will not tick`,
922
1148
  };
923
1149
  }
924
1150
 
@@ -933,12 +1159,19 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
933
1159
  // No name here and nobody else holding it: an ad-hoc pane in the fleet's
934
1160
  // directory, which is exactly the session that must stay inert. Fail-closed,
935
1161
  // and the fix is named — an orchestrator that lost its registration is one
936
- // `herdr agent start` from ticking again.
1162
+ // `herdr agent rename` from ticking again.
1163
+ //
1164
+ // Deliberately NOT `herdr agent start`: that submits omp *into* the pane's
1165
+ // existing shell and requires a pane at a shell prompt hosting no agent
1166
+ // (herdr/README.md). This pane is running the omp session reading this line, so
1167
+ // `agent start` would either be refused or start a second orchestrator in it.
1168
+ // `rename` names the agent herdr already detects, and touches no process.
937
1169
  return {
938
1170
  kind: "declined",
939
1171
  reason:
940
1172
  `this pane is not a registered herdr agent, and no pane is running the fleet agent ` +
941
- `"${input.agentName}" — register it with \`herdr agent start ${input.agentName} --kind omp --pane ${input.paneId}\`; ` +
1173
+ `"${input.agentName}" — name its running agent with ` +
1174
+ `\`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
942
1175
  `this session will not tick`,
943
1176
  };
944
1177
  }
@@ -1265,23 +1498,285 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
1265
1498
  }
1266
1499
  }
1267
1500
 
1501
+ /**
1502
+ * Best-effort poke that asks a live orchestrator heartbeat to fire soon.
1503
+ *
1504
+ * Same file shape as herdr `recover.sh`: one line `${iso} ${reason}`. Writers
1505
+ * (recover, the dispatch daemon on a false→true condition) never clear it —
1506
+ * only a successful tick send does, so a disarmed or channel-down fleet keeps
1507
+ * the request until gates pass.
1508
+ */
1509
+ export function requestImmediateTick(cwd: string, reason: string): boolean {
1510
+ const path = join(cwd, TICK_REQUESTED_FILE);
1511
+ const label = reason.trim().length > 0 ? reason.trim() : "wake";
1512
+ try {
1513
+ writeFileSync(path, `${new Date().toISOString()} ${label}\n`);
1514
+ return true;
1515
+ } catch {
1516
+ return false;
1517
+ }
1518
+ }
1519
+
1520
+ /** First non-empty token after the optional ISO stamp on a poke line. */
1521
+ export function readTickRequestReason(cwd: string): string | undefined {
1522
+ const path = join(cwd, TICK_REQUESTED_FILE);
1523
+ if (!existsSync(path)) return undefined;
1524
+ let raw: string;
1525
+ try {
1526
+ raw = readFileSync(path, "utf8");
1527
+ } catch {
1528
+ return undefined;
1529
+ }
1530
+ const line = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
1531
+ if (line.length === 0) return undefined;
1532
+ const parts = line.split(/\s+/);
1533
+ if (parts.length === 0) return undefined;
1534
+ // recover.sh writes `${iso} recover`; a bare reason is also accepted.
1535
+ if (parts.length >= 2 && Number.isFinite(Date.parse(parts[0]!))) {
1536
+ return parts.slice(1).join(" ");
1537
+ }
1538
+ return parts.join(" ");
1539
+ }
1540
+
1268
1541
  /**
1269
1542
  * Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
1270
1543
  * failed unlink means the next successful send retries the clear; that is
1271
- * preferable to treating a recover poke as fire-and-forget when the tick did
1272
- * land.
1544
+ * preferable to treating a recover/condition poke as fire-and-forget when the
1545
+ * tick did land.
1273
1546
  */
1274
1547
  function clearTickRequest(pi: TickApi, cwd: string): void {
1275
1548
  const path = join(cwd, TICK_REQUESTED_FILE);
1276
1549
  if (!existsSync(path)) return;
1277
1550
  try {
1278
1551
  rmSync(path, { force: true });
1279
- pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
1552
+ pi.logger.info("[omp-conductor] tick request cleared: a tick was sent");
1280
1553
  } catch (err) {
1281
1554
  pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
1282
1555
  }
1283
1556
  }
1284
1557
 
1558
+ interface PendingLocalTick {
1559
+ id: string;
1560
+ projectName?: string;
1561
+ policy?: ReportingPolicy;
1562
+ }
1563
+
1564
+ interface ActiveLocalTick extends PendingLocalTick {
1565
+ /** A person who writes during an autonomous run is awake by construction. */
1566
+ humanWaiting: boolean;
1567
+ }
1568
+
1569
+ interface TelegramInterrupt {
1570
+ kind: "question" | "message";
1571
+ category: HeldNotice["category"];
1572
+ summary: string;
1573
+ detail: string;
1574
+ hasFiles: boolean;
1575
+ fingerprint: string;
1576
+ }
1577
+
1578
+ function objectRecord(value: unknown): value is Record<string, unknown> {
1579
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1580
+ }
1581
+ function canonicalTelegramPayload(value: unknown): unknown {
1582
+ if (Array.isArray(value)) return value.map(canonicalTelegramPayload);
1583
+ if (!objectRecord(value)) return value;
1584
+ return Object.fromEntries(
1585
+ Object.keys(value)
1586
+ .sort()
1587
+ .map((key) => [key, canonicalTelegramPayload(value[key])]),
1588
+ );
1589
+ }
1590
+
1591
+
1592
+ function serialiseTelegramPayload(value: unknown): string {
1593
+ try {
1594
+ return JSON.stringify(canonicalTelegramPayload(value)) ?? String(value);
1595
+ } catch {
1596
+ return String(value);
1597
+ }
1598
+ }
1599
+
1600
+ function telegramInterruptFromTool(
1601
+ toolName: string,
1602
+ input: Record<string, unknown>,
1603
+ ): TelegramInterrupt | undefined {
1604
+ let kind: TelegramInterrupt["kind"];
1605
+ let payload: Record<string, unknown> = input;
1606
+
1607
+ if (toolName === TELEGRAM_APPROVAL_TOOL) {
1608
+ kind = "question";
1609
+ } else if (toolName === "telegram_send" || toolName === "telegram_react") {
1610
+ kind = "message";
1611
+ } else if (toolName === "write") {
1612
+ const path = input["path"];
1613
+ if (
1614
+ path !== "xd://telegram_ask" &&
1615
+ path !== "xd://telegram_send" &&
1616
+ path !== "xd://telegram_react"
1617
+ ) {
1618
+ return undefined;
1619
+ }
1620
+ kind = path === "xd://telegram_ask" ? "question" : "message";
1621
+ const content = input["content"];
1622
+ if (typeof content === "string") {
1623
+ try {
1624
+ const parsed: unknown = JSON.parse(content);
1625
+ payload = objectRecord(parsed) ? parsed : { content };
1626
+ } catch {
1627
+ payload = { content };
1628
+ }
1629
+ }
1630
+ } else {
1631
+ return undefined;
1632
+ }
1633
+ if (
1634
+ kind === "message" &&
1635
+ typeof payload["text"] === "string" &&
1636
+ /^\s*QUESTION:\s/i.test(payload["text"])
1637
+ ) {
1638
+ // The package floor uses this explicit fallback when telegram_ask is not
1639
+ // mounted. Without the marker a plain send remains a material update.
1640
+ kind = "question";
1641
+ }
1642
+
1643
+ let detail = "";
1644
+ if (kind === "question" && Array.isArray(payload["questions"])) {
1645
+ detail = payload["questions"]
1646
+ .map((value, index) => {
1647
+ if (!objectRecord(value)) return `Question ${index + 1}: ${serialiseTelegramPayload(value)}`;
1648
+ const question = typeof value["question"] === "string" ? value["question"] : "Question";
1649
+ const options = Array.isArray(value["options"])
1650
+ ? value["options"]
1651
+ .map((option) =>
1652
+ objectRecord(option) && typeof option["label"] === "string"
1653
+ ? `${option["label"]}${
1654
+ typeof option["description"] === "string" && option["description"] !== ""
1655
+ ? ` — ${option["description"]}`
1656
+ : ""
1657
+ }`
1658
+ : serialiseTelegramPayload(option),
1659
+ )
1660
+ .join("; ")
1661
+ : "";
1662
+ return options === "" ? question : `${question}\nOptions: ${options}`;
1663
+ })
1664
+ .join("\n\n");
1665
+ } else if (typeof payload["text"] === "string") {
1666
+ detail = payload["text"];
1667
+ }
1668
+ if (detail.trim() === "") detail = serialiseTelegramPayload(payload);
1669
+
1670
+ const flat = detail.replace(/\s+/g, " ").trim();
1671
+ const fallback = kind === "question" ? "Operator question" : "Telegram message";
1672
+ const summary = flat === "" ? fallback : flat.length <= 180 ? flat : `${flat.slice(0, 179)}…`;
1673
+ const canonical = serialiseTelegramPayload({ kind, payload });
1674
+ return {
1675
+ kind,
1676
+ category: kind === "question" ? "decision-needed" : "material",
1677
+ summary,
1678
+ hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
1679
+ detail,
1680
+ fingerprint: createHash("sha256").update(canonical).digest("hex"),
1681
+ };
1682
+ }
1683
+
1684
+ type TelegramInterruptBlock = { block: true; reason: string };
1685
+
1686
+ /**
1687
+ * A locally injected tick is an autonomous actor. Its direct Telegram calls
1688
+ * therefore pass through the same category and availability decision as daemon
1689
+ * escalations. Human-originated runs, including a message steered into a tick,
1690
+ * are exempt: the operator is already present and waiting for the reply.
1691
+ */
1692
+ function autonomousTelegramInterruptBlock(
1693
+ pi: TickApi,
1694
+ session: TickSession,
1695
+ event: { toolName: string; input: Record<string, unknown> },
1696
+ ): TelegramInterruptBlock | undefined {
1697
+ const active = session.activeLocalTick;
1698
+ if (active === undefined || active.humanWaiting) return undefined;
1699
+ const interrupt = telegramInterruptFromTool(event.toolName, event.input);
1700
+ if (interrupt === undefined) return undefined;
1701
+ if (active.projectName === undefined) {
1702
+ return {
1703
+ block: true,
1704
+ reason:
1705
+ "Operator availability policy is unavailable for this autonomous tick, so direct Telegram is blocked fail-closed. " +
1706
+ "Repair the conductor config; do not retry through another Telegram path.",
1707
+ };
1708
+ }
1709
+
1710
+ let policy = active.policy;
1711
+ let configReadable = true;
1712
+ try {
1713
+ policy = findProject(loadConfig(), active.projectName).reporting;
1714
+ } catch (err) {
1715
+ configReadable = false;
1716
+ pi.logger.error(
1717
+ `[omp-conductor] operator availability config unreadable during Telegram call; blocking fail-closed: ${
1718
+ err instanceof Error ? err.message : String(err)
1719
+ }`,
1720
+ );
1721
+ }
1722
+
1723
+ const at = Date.now();
1724
+ const disposition = configReadable
1725
+ ? interruptDisposition(policy, interrupt.category, at)
1726
+ : "availability";
1727
+ if (disposition === "interrupt") return undefined;
1728
+ const release =
1729
+ disposition === "availability"
1730
+ ? "the availability window opens or the daily digest runs"
1731
+ : "the daily digest runs";
1732
+ if (interrupt.hasFiles) {
1733
+ return {
1734
+ block: true,
1735
+ reason:
1736
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind} until ${release}, ` +
1737
+ "and attachment-bearing Telegram sends cannot be durably replayed. Nothing was sent or held. " +
1738
+ "Do not retry through another Telegram path while the policy forbids delivery.",
1739
+ };
1740
+ }
1741
+
1742
+ const key = `${active.projectName}:autonomous-telegram:${active.id}:${interrupt.fingerprint}`;
1743
+ try {
1744
+ const store = openStore(dbPath());
1745
+ try {
1746
+ if (!store.wasNotified(key)) {
1747
+ store.addHeldNotice({
1748
+ id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
1749
+ project: active.projectName,
1750
+ category: interrupt.category,
1751
+ summary: interrupt.summary,
1752
+ detail: interrupt.detail,
1753
+ createdAt: at,
1754
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
1755
+ });
1756
+ store.markNotified(key);
1757
+ }
1758
+ } finally {
1759
+ store.close();
1760
+ }
1761
+ } catch (err) {
1762
+ const reason = err instanceof Error ? err.message : String(err);
1763
+ pi.logger.error(`[omp-conductor] could not persist held Telegram ${interrupt.kind}: ${reason}`);
1764
+ return {
1765
+ block: true,
1766
+ reason:
1767
+ `Operator policy forbids this autonomous Telegram ${interrupt.kind}, and the durable hold failed: ${reason}. ` +
1768
+ "Do not retry through another Telegram path.",
1769
+ };
1770
+ }
1771
+
1772
+ return {
1773
+ block: true,
1774
+ reason:
1775
+ `Operator policy durably held this autonomous Telegram ${interrupt.kind} until ${release}. ` +
1776
+ "Do not retry through another Telegram path.",
1777
+ };
1778
+ }
1779
+
1285
1780
  /** Everything one tick remembers for the next. */
1286
1781
  interface TickSession {
1287
1782
  /**
@@ -1310,6 +1805,17 @@ interface TickSession {
1310
1805
  bridgeTokenAtStart: boolean;
1311
1806
  /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
1312
1807
  pendingSkips: number;
1808
+ /** Ticks sent as follow-ups but not yet observed by the agent loop. */
1809
+ pendingLocalTicks: PendingLocalTick[];
1810
+ /**
1811
+ * Whether the stranded shared arm marker has been named. Latched for
1812
+ * {@link TickSession.scopeFallbackLogged}'s reason: a host that gained a
1813
+ * second project without re-running setup would otherwise repeat the same
1814
+ * line every interval, forever.
1815
+ */
1816
+ legacyArmLogged: boolean;
1817
+ /** The local tick whose agent loop is currently running, if any. */
1818
+ activeLocalTick?: ActiveLocalTick;
1313
1819
  }
1314
1820
 
1315
1821
  /**
@@ -1318,8 +1824,15 @@ interface TickSession {
1318
1824
  * otherwise emit a notification every interval, forever.
1319
1825
  */
1320
1826
  function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
1827
+ const arm = config.armedFile === undefined ? undefined : resolveArmState(config.armedFile, config.project);
1828
+ if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
1829
+ session.legacyArmLogged = true;
1830
+ pi.logger.error(
1831
+ `[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
1832
+ );
1833
+ }
1321
1834
  const decision = tickDecision({
1322
- armed: config.armedFile === undefined || existsSync(config.armedFile),
1835
+ armed: arm === undefined || arm.armed,
1323
1836
  channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
1324
1837
  hasPending: ctx.hasPendingMessages(),
1325
1838
  });
@@ -1346,9 +1859,9 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1346
1859
 
1347
1860
  // Floor refresh is independent of which prompt we send: a custom message still
1348
1861
  // expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
1349
- refreshComposedBriefBestEffort();
1862
+ refreshComposedBriefBestEffort(config.project);
1350
1863
 
1351
- const scope = resolveTickScope();
1864
+ const scope = resolveTickScope(config.project);
1352
1865
  // The transport contract, read once from the same file at the same moment so
1353
1866
  // the approval line, the delivery rule and the narration line cannot disagree
1354
1867
  // (#169, #179). No access file means no fleet channel to judge; a session
@@ -1393,22 +1906,68 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1393
1906
  try {
1394
1907
  const lastKey = store.lastDigestDedupeKey(scope.projectName);
1395
1908
  const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1909
+ const due = digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at);
1910
+ // Reserve availability-held rows before reading them. The same SQLite
1911
+ // transaction releases pending catch-ups; daemon enqueue attempts then
1912
+ // observe the lease, closing both orders of the snapshot-to-handoff race.
1913
+ const availabilityReserved =
1914
+ due &&
1915
+ store.reserveAvailabilityDigest(
1916
+ scope.projectName,
1917
+ localDayKey(at, digestPolicy.timezone ?? policy?.availability?.timezone),
1918
+ at,
1919
+ at +
1920
+ Math.max(
1921
+ MIN_AVAILABILITY_DIGEST_LEASE_MS,
1922
+ (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1_000 + 5 * 60_000,
1923
+ ),
1924
+ );
1925
+ const backlog = store.digestBacklog(scope.projectName);
1926
+ const visibleBacklog: DigestBacklog = availabilityReserved
1927
+ ? backlog
1928
+ : {
1929
+ ...backlog,
1930
+ heldNoticeCount: Math.max(
1931
+ 0,
1932
+ backlog.heldNoticeCount - (backlog.availabilityHeldNoticeCount ?? 0),
1933
+ ),
1934
+ };
1396
1935
  reportingConstraint = tickReportingConstraint(
1397
1936
  policy,
1398
1937
  {
1399
- due: digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at),
1938
+ due,
1400
1939
  cadence,
1401
1940
  at: digestPolicy.at,
1402
1941
  timezone: digestPolicy.timezone,
1403
1942
  },
1404
- store.undigestedNotices(scope.projectName).length,
1943
+ visibleBacklog.heldNoticeCount,
1405
1944
  );
1945
+ if (
1946
+ due &&
1947
+ (visibleBacklog.materialCount > 0 || visibleBacklog.heldNoticeCount > 0)
1948
+ ) {
1949
+ const notices = store
1950
+ .undigestedNotices(scope.projectName)
1951
+ .filter((notice) => notice.releaseOnAvailable !== true || availabilityReserved);
1952
+ reportingConstraint +=
1953
+ `\n${formatDigestLedger(
1954
+ store.undigestedMaterialEvents(scope.projectName),
1955
+ notices,
1956
+ visibleBacklog,
1957
+ )}`;
1958
+ }
1406
1959
  } finally {
1407
1960
  store.close();
1408
1961
  }
1409
1962
  }
1410
1963
  content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
1411
1964
  }
1965
+ if (scope.projectName !== undefined) {
1966
+ // Unlike ordinary reporting prose, this is a mechanical clock reading and
1967
+ // survives a custom heartbeat message. The model never infers whether the
1968
+ // operator can be interrupted.
1969
+ content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
1970
+ }
1412
1971
  let frictionStore: Store | undefined;
1413
1972
  let frictionSignals: FrictionSignal[] = [];
1414
1973
  const now = Date.now();
@@ -1442,7 +2001,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1442
2001
  // tracker (#181). A failed config read skips the line rather than
1443
2002
  // wedging the tick.
1444
2003
  try {
1445
- const project = findProject(loadConfig());
2004
+ // Named: this block is already inside `scope.projectName !== undefined`,
2005
+ // so the resolved name is in hand and an un-named lookup would refuse
2006
+ // to guess on a host with a second project.
2007
+ const project = findProject(loadConfig(), scope.projectName);
1446
2008
  const queue = queueDigestLine(
1447
2009
  frictionStore.latestDispatch(scope.projectName),
1448
2010
  project.queueLabel,
@@ -1526,11 +2088,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1526
2088
  // No `accessFile` means no fleet bridge to judge, exactly as above.
1527
2089
  if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
1528
2090
 
2091
+ // Recover / condition-met pokes are auditable: the same reason the writer put
2092
+ // on the sentinel line lands in the prompt and the "tick sent" log.
2093
+ const wakeReason = readTickRequestReason(ctx.cwd);
2094
+ if (wakeReason !== undefined) content = `${content}\nWake reason: ${wakeReason}`;
2095
+
2096
+ const pendingLocalTick: PendingLocalTick = {
2097
+ id: randomUUID(),
2098
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
2099
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
2100
+ };
2101
+ session.pendingLocalTicks.push(pendingLocalTick);
1529
2102
  try {
1530
- pi.sendMessage(
1531
- { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1532
- { triggerTurn: true, deliverAs: "followUp" },
1533
- );
2103
+ try {
2104
+ pi.sendMessage(
2105
+ { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
2106
+ { triggerTurn: true, deliverAs: "followUp" },
2107
+ );
2108
+ } catch (err) {
2109
+ const pendingIndex = session.pendingLocalTicks.indexOf(pendingLocalTick);
2110
+ if (pendingIndex >= 0) session.pendingLocalTicks.splice(pendingIndex, 1);
2111
+ throw err;
2112
+ }
1534
2113
  if (scope.projectName !== undefined && frictionSignals.length > 0) {
1535
2114
  try {
1536
2115
  frictionStore?.markFrictionSurfaced(
@@ -1547,13 +2126,19 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1547
2126
  } finally {
1548
2127
  frictionStore?.close();
1549
2128
  }
1550
- pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
2129
+ pi.logger.info(
2130
+ wakeReason === undefined
2131
+ ? `[omp-conductor] tick sent: ${decision.reason}`
2132
+ : `[omp-conductor] tick sent: ${decision.reason} (wake: ${wakeReason})`,
2133
+ { reason: decision.reason, ...(wakeReason === undefined ? {} : { wakeReason }) },
2134
+ );
1551
2135
  // An empty queue at send time is the proof the previous tick was consumed, so
1552
2136
  // this is the only place either the counter or the marker is cleared.
1553
2137
  session.pendingSkips = 0;
1554
2138
  clearStallMarker(pi, ctx.cwd);
1555
- // Recover poke is consumed only on a real send — gates still apply above.
2139
+ // Immediate-tick poke is consumed only on a real send — gates still apply above.
1556
2140
  clearTickRequest(pi, ctx.cwd);
2141
+
1557
2142
  }
1558
2143
 
1559
2144
  function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: number): void {
@@ -1574,22 +2159,40 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
1574
2159
  }
1575
2160
 
1576
2161
  /**
1577
- * Arm the interval heartbeat, then honour a recover poke if one is waiting.
2162
+ * Arm the interval heartbeat, honour a poke already waiting at arm time, and
2163
+ * poll mid-interval for later pokes (recover resume, condition-met #329).
1578
2164
  * Extracted so the ownership-retry path and the immediate-accept path cannot
1579
- * drift: both must fire the same "do not wait a full interval after resume"
1580
- * behaviour.
2165
+ * drift: both must fire the same "do not wait a full interval after a poke"
2166
+ * behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
2167
+ * live turn coalesces rather than stacking concurrent ticks.
1581
2168
  */
1582
2169
  function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
1583
2170
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
1584
- ctx.setInterval(() => {
2171
+ const runScheduledTick = (): void => {
1585
2172
  try {
1586
2173
  tick(pi, ctx, config, session);
1587
2174
  } finally {
1588
2175
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
1589
2176
  }
1590
- }, config.intervalSeconds * 1000);
2177
+ };
2178
+ ctx.setInterval(runScheduledTick, config.intervalSeconds * 1000);
2179
+ // Short mid-interval poll so a condition that flips between heartbeats does
2180
+ // not wait a full cycle. Cap at the heartbeat itself so a misconfigured short
2181
+ // interval cannot arm a second timer faster than the primary.
2182
+ const pollMs = Math.min(TICK_REQUEST_POLL_MS, config.intervalSeconds * 1000);
2183
+ if (pollMs < config.intervalSeconds * 1000) {
2184
+ ctx.setInterval(() => {
2185
+ if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
2186
+ const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2187
+ pi.logger.info(
2188
+ `[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
2189
+ );
2190
+ runScheduledTick();
2191
+ }, pollMs);
2192
+ }
1591
2193
  if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
1592
- pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
2194
+ const reason = readTickRequestReason(ctx.cwd) ?? "wake";
2195
+ pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
1593
2196
  tick(pi, ctx, config, session);
1594
2197
  }
1595
2198
 
@@ -1645,14 +2248,24 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1645
2248
  approvalToolMissingLogged: false,
1646
2249
  bridgeTokenAtStart: true,
1647
2250
  pendingSkips: 0,
2251
+ pendingLocalTicks: [],
2252
+ legacyArmLogged: false,
1648
2253
  };
1649
2254
  let releaseGateArmed = false;
2255
+ let availabilityGateArmed = false;
1650
2256
  let guardArmed = false;
1651
2257
  // An activation file makes this a fleet directory before Herdr can prove
1652
2258
  // which pane owns it. The gate therefore starts closed and only honours a
1653
2259
  // configured grant after ownership is accepted.
1654
2260
  let releaseAuthorityAccepted = false;
1655
- const armReleaseGate = (): void => {
2261
+ /**
2262
+ * `configuredProject` is {@link TickConfig.project}, and it is what stops a
2263
+ * second configured project from collapsing every release grant this fleet
2264
+ * really holds to {@link DENIED_RELEASE_GRANTS}. Undefined for a tick config
2265
+ * that names none — including an invalid one, which arms this gate before the
2266
+ * config can be trusted at all.
2267
+ */
2268
+ const armReleaseGate = (configuredProject?: string): void => {
1656
2269
  if (releaseGateArmed) return;
1657
2270
  releaseGateArmed = true;
1658
2271
  (pi as TickApi & {
@@ -1672,7 +2285,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1672
2285
  let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
1673
2286
  let external = true;
1674
2287
  try {
1675
- const project = findProject(loadConfig());
2288
+ const project = findProject(loadConfig(), configuredProject);
1676
2289
  projectName = project.name;
1677
2290
  grants = resolveReleaseGrants(project);
1678
2291
  external = project.escalation.orchestrator === "external";
@@ -1716,6 +2329,64 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1716
2329
  });
1717
2330
  };
1718
2331
 
2332
+ /** `configuredProject` carries {@link TickConfig.project} for the same reason
2333
+ * {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
2334
+ * fleet's availability policy, not refuse to guess between two projects. */
2335
+ const armAvailabilityGate = (configuredProject?: string): void => {
2336
+ if (availabilityGateArmed) return;
2337
+ availabilityGateArmed = true;
2338
+
2339
+ // Custom messages sent with `pi.sendMessage` bypass `before_agent_start`.
2340
+ // `message_start` is the first lifecycle event that carries their exact
2341
+ // custom type, so it is the reliable boundary between a local tick and an
2342
+ // inbound human turn.
2343
+ pi.on("message_start", (event) => {
2344
+ const message = event.message;
2345
+ if (message.role === "custom" && message.customType === TICK_CUSTOM_TYPE) {
2346
+ const pending = session.pendingLocalTicks.shift();
2347
+ if (pending !== undefined) {
2348
+ session.activeLocalTick = { ...pending, humanWaiting: false };
2349
+ } else {
2350
+ // A queued custom tick can survive a host restart after the in-memory
2351
+ // enqueue record does not. Reconstruct its scope so recovery cannot
2352
+ // silently turn an autonomous run into an interactive one.
2353
+ const scope = resolveTickScope(configuredProject);
2354
+ const id =
2355
+ typeof message.timestamp === "number"
2356
+ ? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
2357
+ : randomUUID();
2358
+ session.activeLocalTick = {
2359
+ id,
2360
+ ...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
2361
+ ...(scope.policy === undefined ? {} : { policy: scope.policy }),
2362
+ humanWaiting: false,
2363
+ };
2364
+ }
2365
+ return;
2366
+ }
2367
+ if (
2368
+ session.activeLocalTick !== undefined &&
2369
+ message.role === "user" &&
2370
+ message.synthetic !== true &&
2371
+ message.attribution !== "agent"
2372
+ ) {
2373
+ session.activeLocalTick.humanWaiting = true;
2374
+ }
2375
+ });
2376
+ pi.on("agent_end", () => {
2377
+ session.activeLocalTick = undefined;
2378
+ });
2379
+ (pi as TickApi & {
2380
+ on(
2381
+ event: "tool_call",
2382
+ handler: (
2383
+ event: { toolName: string; input: Record<string, unknown> },
2384
+ ctx: unknown,
2385
+ ) => TelegramInterruptBlock | undefined,
2386
+ ): void;
2387
+ }).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
2388
+ };
2389
+
1719
2390
  pi.on("session_start", (_event, ctx) => {
1720
2391
  if (decided) return;
1721
2392
 
@@ -1742,7 +2413,12 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1742
2413
 
1743
2414
  // Present but invalid still identifies a fleet directory. Install the
1744
2415
  // fail-closed handler before validation or ownership can return early.
1745
- armReleaseGate();
2416
+ // The project this fleet cwd ticks for, so both gates resolve its own policy
2417
+ // rather than collapsing fail-closed on a host with a second project. An
2418
+ // invalid config names none — it cannot be trusted to.
2419
+ const configuredProject = result.kind === "ok" ? result.config.project : undefined;
2420
+ armReleaseGate(configuredProject);
2421
+ armAvailabilityGate(configuredProject);
1746
2422
 
1747
2423
  if (result.kind === "invalid") {
1748
2424
  const detail = `${result.path}: ${result.problem}`;