omp-conductor 0.20.0 → 0.20.2

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.
@@ -52,7 +52,10 @@ export type CodeGraphHealth =
52
52
  active: "active" | "inactive" | "unknown";
53
53
  };
54
54
  refresh: {
55
- result: "success" | "failed" | "unknown";
55
+ /** "running": the oneshot is executing right now (#1090). systemd has
56
+ * already cleared the previous exit timestamp mid-run, so this is a
57
+ * progress report — never a claim about staleness or absence. */
58
+ result: "success" | "failed" | "running" | "unknown";
56
59
  fresh: boolean | null;
57
60
  lastSuccessAt?: string;
58
61
  ageMs?: number;
@@ -208,6 +211,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
208
211
  const serviceResult = fields.get("Result");
209
212
  const exitStatus = fields.get("ExecMainStatus");
210
213
  const completedAt = fields.get("ExecMainExitTimestamp");
214
+ // #1090: an empty ExecMainExitTimestamp says "no completed run yet", which
215
+ // is equally true of a unit that has never run and of one executing right
216
+ // now — systemd clears it when the next run starts. ActiveState comes back
217
+ // from the same single `systemctl show` and separates the two; evidence
218
+ // captured without the property keeps the conservative pre-#1090 reading.
219
+ const activeState = fields.get("ActiveState");
211
220
  if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
212
221
  return unknown("refresh service returned an unrecognized result", true);
213
222
  }
@@ -219,6 +228,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
219
228
  };
220
229
  }
221
230
  if (completedAt === "" || completedAt === "n/a") {
231
+ if (activeState === "activating" || activeState === "active") {
232
+ // There is no completed timestamp to measure staleness against yet, but
233
+ // this is progress, not absence: the reindex is keeping the graph fresh
234
+ // at this moment, so neither the row nor the previous message degrades.
235
+ return { health: { result: "running", fresh: null }, uncertain: false };
236
+ }
222
237
  return unknown("no successful refresh has been recorded", false);
223
238
  }
224
239
  const completedMs = Date.parse(completedAt);
@@ -290,6 +305,7 @@ export async function probeCodeGraph(
290
305
  "--property=Result",
291
306
  "--property=ExecMainStatus",
292
307
  "--property=ExecMainExitTimestamp",
308
+ "--property=ActiveState",
293
309
  ]),
294
310
  ]);
295
311
 
package/src/groom.ts CHANGED
@@ -114,6 +114,17 @@ export interface ReadyGateRejection {
114
114
  * {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
115
115
  * not re-groomed — the spec is rejected, not ungroomed.
116
116
  *
117
+ * That durability is bounded by the verdict itself (#1060). A rejection
118
+ * beside a verdict the contract still accepts is not re-groomable, because
119
+ * the fault lies in the issue — missing criteria, a missing lane — and only
120
+ * an edit to the issue can fix it. A rejection beside a verdict the contract
121
+ * refuses — veltro#731's prose lane entry, persisted before parse-time lane
122
+ * validation — reads as not-durable through {@link toSpecDurableVerdict},
123
+ * and the candidate becomes re-groomable: no issue edit can ever satisfy a
124
+ * gate whose refusal is baked into the verdict, and complying with the
125
+ * refusal's remedy would write the unusable entry into the issue itself, so
126
+ * a fresh pass is the only repair.
127
+ *
117
128
  * Fails closed on anything it cannot read: an empty `missing` array is not a
118
129
  * rejection (a gate that found nothing missing passed), and a non-string entry
119
130
  * would render as `undefined` in a digest line an operator is asked to act on.
@@ -46,7 +46,7 @@
46
46
 
47
47
  import { createHash, randomUUID } from "node:crypto";
48
48
  import { spawnSync } from "node:child_process";
49
- import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
51
  import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
52
  import {
@@ -132,6 +132,20 @@ import {
132
132
  type ToSpecTrackerSeam,
133
133
  } from "./groom.ts";
134
134
  import { heldNoticeId } from "./notices.ts";
135
+ import {
136
+ clearArmTransaction,
137
+ expiredArmChallenges,
138
+ FLEET_ARM_KEY,
139
+ resolveArmReply,
140
+ type ArmChallengeSighting,
141
+ type ArmReplyMatch,
142
+ } from "./arm-challenge.ts";
143
+ import {
144
+ readTelegramChannel,
145
+ readTelegramToken,
146
+ resolveProjectTopicId,
147
+ sendTelegram,
148
+ } from "./escalate.ts";
135
149
 
136
150
  /** The activation file. Absent means "this is not an orchestrator session". */
137
151
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -489,6 +503,10 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
489
503
  "rerun-checks": true,
490
504
  settle: true,
491
505
  escalate: false,
506
+ // An observation row never stamps `recoveredAt` (the recovery IS the later
507
+ // sweep pass), so it never lands in this digest; `false` is the honest value
508
+ // if one ever did — it is not "already handled", it is waiting (#1068).
509
+ observe: false,
492
510
  hold: false,
493
511
  none: false,
494
512
  };
@@ -1638,6 +1656,20 @@ export function resolveArmState(armedFile: string, projectName?: string): ArmSta
1638
1656
  return shared ? { armed: true } : { armed: true, legacy: "honoured" };
1639
1657
  }
1640
1658
 
1659
+ /**
1660
+ * The one armed-marker write both settlement paths share — the CLI's
1661
+ * `arm --reply` half and the orchestrator session's mechanical reply consumer
1662
+ * (#1061): same content, same mode, and the same restamp of the pre-per-project
1663
+ * shared marker the heartbeat still honours (#316). It lives here, beside
1664
+ * {@link resolveArmState}, so the session consumer can write the gate it reads
1665
+ * — the reverse import (into `fleet.ts`) would cycle.
1666
+ */
1667
+ export function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
1668
+ mkdirSync(dirname(path), { recursive: true });
1669
+ writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
1670
+ if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
1671
+ }
1672
+
1641
1673
  /**
1642
1674
  * Best-effort recompose of `ORCHESTRATOR.md` from the installed package floor +
1643
1675
  * live `POLICY.md`.
@@ -2854,6 +2886,7 @@ async function tick(
2854
2886
  config: TickConfig,
2855
2887
  session: TickSession,
2856
2888
  toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
2889
+ armSeam: ArmConsumerSeam | undefined,
2857
2890
  ): Promise<void> {
2858
2891
  const live = currentConfig(ctx.cwd, config);
2859
2892
  const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
@@ -2863,6 +2896,29 @@ async function tick(
2863
2896
  `[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
2864
2897
  );
2865
2898
  }
2899
+ // An unanswered ceremony that passed its window is a dead ceremony, not a
2900
+ // silent one (#1061): the tick that notices tells the operator why and how
2901
+ // to re-run it, on the surface the challenge was sent to. Deliberately
2902
+ // before the tick gate — a disarmed fleet is exactly the state an
2903
+ // unanswered challenge leaves, and its death notice must not wait for an
2904
+ // arm to be heard. The scan is synchronous so an idle host (no expired
2905
+ // records) costs the tick nothing; only a send is awaited.
2906
+ const armNow = (armSeam?.now ?? Date.now)();
2907
+ for (const expired of expiredArmChallenges(live.project, armNow)) {
2908
+ const surface: ArmNoticeSurface = { accessFile: live.accessFile, project: live.project };
2909
+ const send = armSeam?.notify ?? sendArmNotice;
2910
+ try {
2911
+ await send(armExpiryNotice(expired, live.project), surface);
2912
+ } catch (err) {
2913
+ // Keep the record: the next heartbeat retries, and `doctor` still
2914
+ // reports it.
2915
+ pi.logger.error(
2916
+ `[omp-conductor] arm expiry notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
2917
+ );
2918
+ continue;
2919
+ }
2920
+ clearArmTransaction(expired.key, expired.id);
2921
+ }
2866
2922
  const decision = tickDecision({
2867
2923
  armed: arm === undefined || arm.armed,
2868
2924
  channelOk: live.accessFile === undefined || channelIsUp(live.accessFile),
@@ -3371,11 +3427,12 @@ function armTickHeartbeat(
3371
3427
  config: TickConfig,
3372
3428
  session: TickSession,
3373
3429
  toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
3430
+ armSeam: ArmConsumerSeam | undefined,
3374
3431
  ): void {
3375
3432
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
3376
3433
  const runScheduledTick = async (): Promise<void> => {
3377
3434
  try {
3378
- await tick(pi, ctx, config, session, toSpecTrackerSeam);
3435
+ await tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam);
3379
3436
  } finally {
3380
3437
  writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
3381
3438
  }
@@ -3431,7 +3488,7 @@ function armTickHeartbeat(
3431
3488
  // call runs inside the session_start handler dispatch, which cannot see an
3432
3489
  // async rejection — an escaped one reaches the process-level
3433
3490
  // unhandledRejection handler and takes the session down.
3434
- void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
3491
+ void tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam).catch((err) => {
3435
3492
  pi.logger.error(
3436
3493
  `[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
3437
3494
  );
@@ -3496,6 +3553,215 @@ export interface OrchestratorTickExtensionOptions {
3496
3553
  * present-tense empty queue.
3497
3554
  */
3498
3555
  toSpec?: { tracker?: ToSpecTrackerSeam };
3556
+ /**
3557
+ * The arm ceremony's mechanical consumer seams (#1061). Production omits
3558
+ * them and the extension classifies inbound turns against the pending
3559
+ * challenge records itself, sending confirmations and expiry notices over
3560
+ * the same bridge transport `armTicks` sends the challenge on. Tests inject
3561
+ * a recorder (`notify`) and a fixed clock (`now`) so the settle and the
3562
+ * notices are provable without Telegram or timers.
3563
+ */
3564
+ arm?: {
3565
+ now?: () => number;
3566
+ notify?: ArmNoticeFn;
3567
+ };
3568
+ }
3569
+
3570
+ /** The send surface an arm notice rides: the live tick config's channel and project. */
3571
+ export interface ArmNoticeSurface {
3572
+ accessFile?: string;
3573
+ project?: string;
3574
+ }
3575
+
3576
+ /**
3577
+ * One mechanical Telegram send the arm consumer raises: the confirmation when
3578
+ * a reply settles the ceremony, or the expiry notice when a challenge dies
3579
+ * unanswered. The default is {@link sendArmNotice}; tests record instead.
3580
+ */
3581
+ export type ArmNoticeFn = (text: string, surface: ArmNoticeSurface) => Promise<void> | void;
3582
+
3583
+ /** The consumer's injectable clock and transport, carried into the tick's helpers. */
3584
+ export interface ArmConsumerSeam {
3585
+ now?: () => number;
3586
+ notify?: ArmNoticeFn;
3587
+ }
3588
+
3589
+ /**
3590
+ * The plain text of an inbound message, for the arm classifier. Content blocks
3591
+ * join with newlines so a code split across block boundaries still tokenises
3592
+ * whole; non-text blocks contribute nothing. `undefined` for an absent or
3593
+ * empty message, which is ordinary chat either way.
3594
+ */
3595
+ function inboundMessageText(content: readonly { type?: string; text?: string }[] | undefined): string | undefined {
3596
+ if (content === undefined || content.length === 0) return undefined;
3597
+ const text = content.map((block) => block.text ?? "").join("\n");
3598
+ return text.length === 0 ? undefined : text;
3599
+ }
3600
+
3601
+ /**
3602
+ * The ready-to-send expiry notice: the reason — never answered, or answered
3603
+ * but never settled — and the exact command that re-runs the ceremony. The
3604
+ * code is the proof and is never named in a notice; only the window is.
3605
+ */
3606
+ function armExpiryNotice(expired: { key: string; sighting: ArmChallengeSighting }, project: string | undefined): string {
3607
+ const reissue =
3608
+ expired.key === FLEET_ARM_KEY || project === undefined || project === ""
3609
+ ? "omp-conductor arm"
3610
+ : `omp-conductor arm --project ${project}`;
3611
+ const sentAt =
3612
+ expired.sighting.sentAt === undefined
3613
+ ? ""
3614
+ : ` sent ${new Date(expired.sighting.sentAt).toISOString()}`;
3615
+ const reason =
3616
+ expired.sighting.acknowledgedAt === undefined
3617
+ ? `The arming challenge${sentAt} expired without being answered — nothing is armed.`
3618
+ : `The arming challenge${sentAt} was answered but never settled before its window closed — nothing is armed.`;
3619
+ return `${reason} Send a fresh one: ${reissue}`;
3620
+ }
3621
+
3622
+ /**
3623
+ * The default transport for the arm consumer's confirmation and expiry
3624
+ * notices: the same bot token, paired channel and project topic `armTicks`
3625
+ * sends the challenge on, so the notice lands on the surface the operator was
3626
+ * asked in. Deliberately not availability-gated — the ceremony is the
3627
+ * operator's own interaction, exactly like the challenge send itself, and a
3628
+ * notice held for quiet hours would be the silent failure #1061 removes. Any
3629
+ * missing or down fact throws; the caller logs and keeps the challenge record
3630
+ * so the next heartbeat retries and `doctor` still reports it.
3631
+ */
3632
+ export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Promise<void> {
3633
+ if (surface.accessFile === undefined) {
3634
+ throw new Error("no accessFile — the arm notice has no channel");
3635
+ }
3636
+ const channel = readTelegramChannel(surface.accessFile);
3637
+ if (channel.kind === "down") {
3638
+ throw new Error(`escalation channel is not up (${surface.accessFile}): ${channel.reason}`);
3639
+ }
3640
+ const token = readTelegramToken();
3641
+ if (token === undefined) {
3642
+ throw new Error("no Telegram bot token readable — the arm notice cannot send");
3643
+ }
3644
+ let topicId: number | undefined;
3645
+ let project: { name: string; workspaceRoot?: string } | undefined;
3646
+ if (surface.project !== undefined) {
3647
+ try {
3648
+ const p = findProject(loadConfig(), surface.project);
3649
+ topicId = resolveProjectTopicId(p);
3650
+ project = { name: p.name, workspaceRoot: p.workspaceRoot };
3651
+ } catch {
3652
+ /* no project config — flat chat, exactly like armTicks */
3653
+ }
3654
+ }
3655
+ await sendTelegram(token, channel.owner, text, {
3656
+ topicId,
3657
+ ...(project === undefined ? {} : { project }),
3658
+ });
3659
+ }
3660
+
3661
+ /**
3662
+ * The settle half of the mechanical consumer: write the markers the matched
3663
+ * challenge recorded and clear its transaction, then confirm on the
3664
+ * challenge's surface. The recorded targets are the contract — the exact rule
3665
+ * `armReply` applies — so nothing is ever armed that the challenge did not
3666
+ * name. A pre-targets record (one release of overlap) is not settled here:
3667
+ * nothing in-session may guess a marker the challenge never recorded; the CLI
3668
+ * half still settles it, and the operator is told so.
3669
+ */
3670
+ function settleInboundArmMatch(
3671
+ pi: TickApi,
3672
+ match: ArmReplyMatch,
3673
+ notify: (text: string) => void,
3674
+ ): void {
3675
+ if (match.targets === undefined || match.owner === undefined) {
3676
+ pi.logger.error(
3677
+ "[omp-conductor] an inbound arming code matched a pre-targets challenge — nothing armed in-session; " +
3678
+ 'run `omp-conductor arm --reply "<the reply>"` to settle it',
3679
+ );
3680
+ return;
3681
+ }
3682
+ for (const target of match.targets) {
3683
+ const state = resolveArmState(target.armedFile, target.project);
3684
+ writeArmedMarker(target.armedFile, match.owner, state);
3685
+ }
3686
+ clearArmTransaction(match.key, match.id);
3687
+ const label =
3688
+ match.targets.length === 1 && match.targets[0]!.project !== undefined
3689
+ ? `project ${match.targets[0]!.project}`
3690
+ : `${match.targets.length} project(s)`;
3691
+ notify(`Arming verified — ${label} armed. The reply was consumed; nothing else to do.`);
3692
+ }
3693
+
3694
+ /**
3695
+ * The arm ceremony's mechanical consumer (#1061): classify one inbound user
3696
+ * turn against this host's pending challenges and, on a match, settle the
3697
+ * ceremony exactly as `omp-conductor arm --reply` would — markers for the
3698
+ * recorded targets, transaction cleared, a confirmation on the surface the
3699
+ * challenge was sent to. This is the real owner the ceremony needed on a host
3700
+ * with no console session: the challenge goes to the project topic, which is
3701
+ * this pane's own claimed surface, so the reply lands here and the ceremony
3702
+ * completes with no second human command. A code past its window is answered
3703
+ * with the re-run command and the dead record is cleared; a lookalike matching
3704
+ * nothing stays inert (#415's non-disclosure bound is unchanged).
3705
+ *
3706
+ * Never throws: an inbound message must not take the session down. Failures
3707
+ * are logged and the CLI half of the ceremony stays available.
3708
+ */
3709
+ function consumeInboundArmReply(
3710
+ pi: TickApi,
3711
+ cwd: string,
3712
+ startupProject: string | undefined,
3713
+ text: string,
3714
+ seam: ArmConsumerSeam | undefined,
3715
+ ): void {
3716
+ // The state key the send half derived from the same file, read fresh so a
3717
+ // re-stamp between the send and the reply cannot split the ceremony. An
3718
+ // invalid config cannot be trusted to name either the key or a marker —
3719
+ // fail closed exactly like `armReply`, but without throwing at a message.
3720
+ const reread = readTickConfig(cwd);
3721
+ if (reread.kind === "invalid") {
3722
+ pi.logger.error(
3723
+ `[omp-conductor] inbound arm reply not classified — tick config invalid at ${reread.path}: ` +
3724
+ `${reread.problem}; use \`omp-conductor arm --reply\` on the host`,
3725
+ );
3726
+ return;
3727
+ }
3728
+ const project = reread.kind === "ok" ? reread.config.project : startupProject;
3729
+ const surface: ArmNoticeSurface = {
3730
+ ...(reread.kind === "ok" ? { accessFile: reread.config.accessFile } : {}),
3731
+ project,
3732
+ };
3733
+ const now = (seam?.now ?? Date.now)();
3734
+ const resolved = resolveArmReply(project, text, now);
3735
+ const notify = (notice: string): void => {
3736
+ const send = seam?.notify ?? sendArmNotice;
3737
+ try {
3738
+ // The send is async on the production path; message_start handlers are
3739
+ // not, so delivery rides the microtask queue and its failure lands in
3740
+ // the log, never on the session.
3741
+ void Promise.resolve(send(notice, surface)).catch((err: unknown) => {
3742
+ pi.logger.error(
3743
+ `[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
3744
+ );
3745
+ });
3746
+ } catch (err) {
3747
+ pi.logger.error(
3748
+ `[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
3749
+ );
3750
+ }
3751
+ };
3752
+ if (resolved.verdict === "matched") {
3753
+ settleInboundArmMatch(pi, resolved.match, notify);
3754
+ return;
3755
+ }
3756
+ // A code past its window: the ceremony is dead, so answer the person who is
3757
+ // clearly trying to complete it — and clear the expired record so neither
3758
+ // this path nor the heartbeat ever notifies twice.
3759
+ if (resolved.verdict === "expired") {
3760
+ for (const expired of expiredArmChallenges(project, now)) {
3761
+ notify(armExpiryNotice(expired, project));
3762
+ clearArmTransaction(expired.key, expired.id);
3763
+ }
3764
+ }
3499
3765
  }
3500
3766
 
3501
3767
  export default function orchestratorTickExtension(
@@ -3634,8 +3900,10 @@ export default function orchestratorTickExtension(
3634
3900
 
3635
3901
  /** `configuredProject` carries {@link TickConfig.project} for the same reason
3636
3902
  * {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
3637
- * fleet's availability policy, not refuse to guess between two projects. */
3638
- const armAvailabilityGate = (configuredProject?: string): void => {
3903
+ * fleet's availability policy, not refuse to guess between two projects.
3904
+ * `cwd` is the fleet directory itself, which the arm consumer re-reads the
3905
+ * tick config from at reply time (#1061). */
3906
+ const armAvailabilityGate = (configuredProject?: string, cwd?: string): void => {
3639
3907
  if (availabilityGateArmed) return;
3640
3908
  availabilityGateArmed = true;
3641
3909
 
@@ -3667,23 +3935,33 @@ export default function orchestratorTickExtension(
3667
3935
  }
3668
3936
  return;
3669
3937
  }
3670
- // An inbound user turn on THIS session is now an anomaly, not a
3938
+ // An inbound user turn on THIS session is an anomaly, not a
3671
3939
  // conversation: operator DMs belong to the 24/7 console session, so a
3672
- // turn arriving here is an accidental topic post. It is recorded for one
3673
- // purpose only{@link tickGuardDecision}'s preemption, which lets a
3674
- // human waiting on this pane cut a running tick short. Nothing is
3675
- // classified: the arming ceremony is two CLI steps on the host
3676
- // (`omp-conductor arm`, then `omp-conductor arm --reply "<message>"`),
3677
- // because with the console owning DMs an arming reply lands where no tick
3678
- // extension runs, and a classifier here would only ever have been dead
3679
- // code waiting to misfire.
3680
- if (
3681
- message.role === "user" &&
3682
- message.synthetic !== true &&
3683
- message.attribution !== "agent" &&
3684
- session.activeLocalTick !== undefined
3685
- ) {
3686
- session.activeLocalTick.humanWaiting = true;
3940
+ // turn arriving here is an accidental topic post. Two things happen,
3941
+ // and both are mechanisms nothing here is left to model behaviour:
3942
+ //
3943
+ // 1. The arm ceremony's reply is classified against the pending
3944
+ // challenge records (#1061). The pre-#415 tombstone is retired: it
3945
+ // reasoned the reply lands "where no tick extension runs", but the
3946
+ // send half delivers the challenge to the project topic this
3947
+ // pane's own claimed surface — so the reply lands exactly here, and
3948
+ // on a host with no console session the CLI's `arm --reply` step had
3949
+ // no owner at all. A matching code settles the ceremony
3950
+ // (markers written, transaction cleared) and answers with a
3951
+ // confirmation; a code past its window is answered with the re-run
3952
+ // command; a lookalike matching nothing stays inert, exactly as
3953
+ // #415 required.
3954
+ // 2. {@link tickGuardDecision}'s preemption, unchanged: a person who
3955
+ // posts into the fleet topic by accident is still a person waiting,
3956
+ // and the autonomous-interrupt refusal must not fire at them.
3957
+ if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
3958
+ if (cwd !== undefined) {
3959
+ const text = inboundMessageText(message.content);
3960
+ if (text !== undefined) consumeInboundArmReply(pi, cwd, configuredProject, text, options.arm);
3961
+ }
3962
+ if (session.activeLocalTick !== undefined) {
3963
+ session.activeLocalTick.humanWaiting = true;
3964
+ }
3687
3965
  }
3688
3966
  });
3689
3967
  pi.on("agent_end", () => {
@@ -3921,7 +4199,7 @@ export default function orchestratorTickExtension(
3921
4199
  // invalid config names none — it cannot be trusted to.
3922
4200
  const configuredProject = result.kind === "ok" ? result.config.project : undefined;
3923
4201
  armReleaseGate(configuredProject);
3924
- armAvailabilityGate(configuredProject);
4202
+ armAvailabilityGate(configuredProject, ctx.cwd);
3925
4203
  // The tool is already registered (extension-factory time); only the session
3926
4204
  // state it acts on is filled, and only once ownership is accepted below.
3927
4205
  // `configuredProject` isn't closed over at all — routing is re-read from
@@ -4011,7 +4289,7 @@ export default function orchestratorTickExtension(
4011
4289
  // not create or deliver decisions). `cwd` is for re-reading the live
4012
4290
  // tick config at execution, `config` for the startup-only ceiling.
4013
4291
  askSession = { cwd: ctx.cwd, config };
4014
- armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
4292
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
4015
4293
  if (!guardArmed) {
4016
4294
  guardArmed = true;
4017
4295
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
@@ -4033,7 +4311,7 @@ export default function orchestratorTickExtension(
4033
4311
  // why): a declined or unresolved session keeps `askSession` undefined and
4034
4312
  // the tool fails closed.
4035
4313
  askSession = { cwd: ctx.cwd, config };
4036
- armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
4314
+ armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
4037
4315
  if (!guardArmed) {
4038
4316
  guardArmed = true;
4039
4317
  armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
package/src/reports.ts CHANGED
@@ -361,7 +361,10 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
361
361
  // limit would still be delivered whole (never truncated) — the seam would
362
362
  // simply under-report the extra ids, which is why the split lives at the
363
363
  // caller, not here.
364
- const ids = await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
364
+ const ids = await sendTelegram(token, chatId, text, {
365
+ topicId: resolveProjectTopicId(p),
366
+ project: { name: p.name, workspaceRoot: p.workspaceRoot },
367
+ });
365
368
  return ids[0];
366
369
  };
367
370
  }