@cohortapp/agent-sdk 2.18.8 → 2.18.11

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.
@@ -140,6 +140,8 @@ import { list as listPresence } from "../collective/presence.mjs";
140
140
  import { billableUsd } from "../cost/ledger-row.mjs";
141
141
  import { liveClaudeStats } from "../resource-governor.mjs";
142
142
  import { agentFirstName } from "../session/identity.mjs";
143
+ import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
144
+ import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
143
145
 
144
146
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
145
147
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1226,6 +1228,7 @@ function instantMs(v) {
1226
1228
  const ATTENTION_WHY = {
1227
1229
  "no-heartbeat": "up, never beaten",
1228
1230
  "stale-heartbeat": "no beat since this launch",
1231
+ "beat-stopped": "beat, then stopped",
1229
1232
  };
1230
1233
 
1231
1234
  /**
@@ -1938,6 +1941,24 @@ export async function collectStatus(o = {}) {
1938
1941
  if (up) machine.upgrade = up;
1939
1942
  } catch { /* no upgrade field */ }
1940
1943
 
1944
+ // 4c-ii. REPLIES THIS SEAT OWED AND DID NOT SEND — `machine.replyDebt`,
1945
+ // absent on a seat that owes nothing today. Three measured paths end the
1946
+ // reply path in nothing (a session-id collision, a scope the seat does not
1947
+ // hold, a CLI stdout that will not parse as the envelope), and all three left
1948
+ // their only trace in a log file and a JSON on this disk. A silence nobody
1949
+ // off the box can count is indistinguishable from a quiet day, which is how
1950
+ // a seat can stop answering people for three days without anything saying so.
1951
+ //
1952
+ // It rides `machine` for the same reason `frontDoor` does: hq's presence.beat
1953
+ // validator keeps `session` strict and `machine` open, so a new seat-level
1954
+ // fact lands without a lock-step hq deploy. Fail-open like every probe here —
1955
+ // an unreadable counters stream drops the field, never the beat.
1956
+ try {
1957
+ const totals = opt.counterTotals !== undefined ? opt.counterTotals : countersSnapshot({ agentRoot: opt.agentRoot });
1958
+ const debt = replyDebtFromCounters(totals || {}, { date: new Date(nowMs).toISOString().slice(0, 10) });
1959
+ if (debt) machine.replyDebt = debt;
1960
+ } catch { /* no replyDebt field */ }
1961
+
1941
1962
  // 4d. WHO is beating — `machine.daemon` {pid, bootAt, uptimeS, sdkVersion,
1942
1963
  // dashboardAt, healthy?, healthReason?}. This is what makes fleet version
1943
1964
  // drift a QUERY instead of an ssh tour: `machine.sdkVersion` is what is
@@ -1968,7 +1989,12 @@ export async function collectStatus(o = {}) {
1968
1989
  if (opt.withAlerts !== false) {
1969
1990
  try {
1970
1991
  const mod = await import("./alerts.mjs");
1971
- status.alerts = mod.deriveAlerts(status, opt.thresholds) || [];
1992
+ // `nowMs` is handed over, not left to the default: one rule (the front
1993
+ // door) is a DURATION measured against `machine.sessionNote.since`, and
1994
+ // it must be measured against the same instant the rest of this snapshot
1995
+ // was, or a slow collection could date the silence differently from the
1996
+ // note that describes it.
1997
+ status.alerts = mod.deriveAlerts(status, opt.thresholds, nowMs) || [];
1972
1998
  } catch { status.alerts = []; }
1973
1999
  }
1974
2000
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.8",
3
+ "version": "2.18.11",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,27 @@ posture:
74
74
  deployment's own policy requires unprompted disclosure.
75
75
  proactive_disclosure: true
76
76
  machine_readable_marking: true
77
+ # WHO THE PROACTIVE DUTY IS OWED TO. Art. 50(1) addresses informing a
78
+ # person that they are interacting with an AI system. A colleague inside
79
+ # the deploying organisation — who provisioned the agent, sees an AI badge
80
+ # beside its name in every surface, and is three hundred messages into a
81
+ # working thread with it — is not that person.
82
+ #
83
+ # Unscoped, this duty made every agent open every post with "Quick note
84
+ # before we get into it: I'm <name>, an AI assistant working with
85
+ # <principal>", including mid-thread corrections to their own numbers. That
86
+ # is not a transparency control; it reads as the agent disclaiming its own
87
+ # work, and it tells people something they already know in a way that makes
88
+ # them feel worse about the colleague they are talking to.
89
+ #
90
+ # The email footer learned this and was scoped (channels.email.footer_scope,
91
+ # first_contact_only + external_recipients_only); the identity line never
92
+ # was. It is the same duty and it takes the same scope.
93
+ #
94
+ # The truthfulness invariant is NOT scoped by this and never will be: a
95
+ # sincere question about the agent's nature is answered plainly on any
96
+ # message, internal or external, first or thousandth.
97
+ internal_recipients_exempt: true
77
98
 
78
99
  # Do not volunteer, but never conceal and always answer truthfully (invariant).
79
100
  disclose-on-ask:
@@ -133,10 +154,36 @@ governance:
133
154
  # own voice (policies/communication-style.md governs tone — prose, no filler).
134
155
  # ───────────────────────────────────────────────────────────────────────────
135
156
  channels:
157
+ # The organisation's own workspace: channels, DMs, threads, boards. Every
158
+ # member here provisioned these agents or works beside them daily, and the
159
+ # product marks them structurally — an AI badge on the member row, the
160
+ # directory, the mailbox list and the composer. That marking is always on and
161
+ # needs no sentence from the agent.
162
+ cohort:
163
+ identity_line: >
164
+ {agent_name} here — I'm an AI assistant working with {principal_name}.
165
+ marking:
166
+ kind: product_badge
167
+ detail: >
168
+ Cohort labels every AI member in the UI (member rows, directory,
169
+ mailbox list, message headers). The marking is structural and
170
+ unconditional; the identity line is not.
171
+ identity_scope:
172
+ first_contact_only: true
173
+ external_recipients_only: true
174
+ notes: >
175
+ In practice this means the line is effectively never used inside the
176
+ workspace, which is correct: it is for someone meeting the agent for the
177
+ first time from outside, and Cohort has no such surface today. If one is
178
+ added, the line is here and already scoped.
179
+
136
180
  slack:
137
181
  identity_line: >
138
182
  Quick note before we get into it — I'm {agent_name}, an AI assistant
139
183
  working with {principal_name}.
184
+ identity_scope:
185
+ first_contact_only: true
186
+ external_recipients_only: true
140
187
  marking:
141
188
  kind: profile_metadata # bot/app badge + display-name suffix
142
189
  detail: "Display name carries an AI marker; profile states the principal."
@@ -17,6 +17,10 @@
17
17
  // =============================================================================
18
18
 
19
19
  import { resolve, join } from "path";
20
+ import { homedir } from "os";
21
+ import { execFile as _execFileCb } from "child_process";
22
+ import { promisify } from "util";
23
+ const execFileAsync = promisify(_execFileCb);
20
24
  import { readdirSync, readFileSync, renameSync, mkdirSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
21
25
  import { createRequire } from "module";
22
26
 
@@ -63,6 +67,9 @@ import { classifyItem, isDirectedAtAgent } from "./classifier.mjs";
63
67
  // gets a deterministic classification (no LLM triage), no holding ack, and a
64
68
  // per-seat jitter so thirteen answers do not land in the same second.
65
69
  import { isRollCallItem, rollCallJitterMs } from "../../lib/org/inbound/broadcast.mjs";
70
+ import { isRoomSurface } from "../../lib/org/inbound/surfaces.mjs";
71
+ import { isMembershipReason } from "../../lib/org/inbound/directedness.mjs";
72
+ import { cohortSurfaceOf, cohortSurfaceLabel, cohortSurfaceIsDeclared } from "./deliver.mjs";
66
73
  import { itemCollectiveIntent } from "../../lib/org/inbound/collective.mjs";
67
74
  import { dispatch, getStatus, availableSlots, canDispatchBacklog, resetActiveSessions, yieldBacklogForReactive, linkActiveSessionBoard } from "./dispatcher.mjs";
68
75
  import { boardItemRefFor } from "../../lib/session/current-work.mjs";
@@ -132,6 +139,9 @@ import { boardPriority } from "./board-mirror.mjs";
132
139
  import { mintTraceId, withTrace } from "../../lib/diagnostics/trace.mjs";
133
140
  import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
134
141
  import * as counters from "../../lib/diagnostics/counters.mjs";
142
+ // Replies this seat owed and did not send, counted where a human can see them
143
+ // (the presence beat) rather than only in a file on this disk.
144
+ import { REPLY_DEBT_COUNTERS, isScopeFault } from "../../lib/daemon/reply-debt.mjs";
135
145
  import { getHookBus } from "../../lib/hooks/bus.mjs";
136
146
  // EXECUTION LADDER (lib/execution/**). The reactive front of the chain:
137
147
  // intake (what surface is this, and is it mine) → match (which compiled
@@ -149,7 +159,8 @@ import { processOne } from "../../lib/execution/pipeline.mjs";
149
159
  // of spawning `claude --print`; not live → today's dispatch, unchanged. The
150
160
  // gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
151
161
  // session claim that was neither replied nor done within 20 min.
152
- import { makeFrontDoorGate } from "../../lib/session/frontdoor.mjs";
162
+ import { shouldReviveFrontDoor, reviveCommand, sessionJobLabelOnDisk } from "../../lib/session/revive.mjs";
163
+ import { makeFrontDoorGate, sessionLiveFromHeartbeat } from "../../lib/session/frontdoor.mjs";
153
164
  import { CADENCE_REGISTRY } from "./cadence-handlers.mjs";
154
165
  import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
155
166
  import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
@@ -263,11 +274,60 @@ async function poll() {
263
274
  * on the 60s full poll. Returns the count of new items processed. Never throws.
264
275
  */
265
276
  const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
277
+
278
+ // ── REVIVING A SHUT FRONT DOOR ───────────────────────────────────────────────
279
+ //
280
+ // The daemon already reads the front-door state every poll to decide who owns
281
+ // the inbox. It is therefore the one process that watches a session it does not
282
+ // live inside — and that is exactly what a wedged front door needs.
283
+ //
284
+ // The supervisor's own watchdog (2.18.7) cannot help when the supervisor is
285
+ // itself parked in its launch probe waiting on a session that will never report
286
+ // ready: James Kirkland sat that way for 3.5 days and Isla Roselli for 15,
287
+ // through several SDK upgrades, because installing new code does not restart a
288
+ // stuck process. The hourly autoupdate job restarts it (2.18.8) but only once
289
+ // an hour and only on the runs that reach that far. This closes it to a minute.
290
+ //
291
+ // State is per-process and deliberately not persisted: a daemon restart is
292
+ // itself a change of circumstances, and a fresh budget after one is the answer
293
+ // we want rather than a stale count carried across it.
294
+ const _revive = { attempts: 0, lastAt: null };
295
+ async function reviveFrontDoorIfShut(state, deps = {}) {
296
+ const now = deps.now ? deps.now() : Date.now();
297
+ const live = sessionLiveFromHeartbeat(state && state.heartbeat, { now });
298
+ const verdict = shouldReviveFrontDoor({
299
+ frontDoor: state && state.frontDoor,
300
+ sessionLive: state && state.sessionLive,
301
+ silentMs: live.ageMs,
302
+ attempts: _revive.attempts,
303
+ lastAttemptAt: _revive.lastAt,
304
+ now,
305
+ reviveAfterMs: Number(process.env.MAESTRO_SESSION_STALE_S || 0) * 1000 || undefined,
306
+ });
307
+ if (!verdict.revive) return verdict;
308
+ const label = sessionJobLabelOnDisk(readdirSync, homedir());
309
+ if (!label) return { revive: false, reason: "no-session-job" };
310
+ _revive.attempts += 1;
311
+ _revive.lastAt = now;
312
+ const cmd = reviveCommand(label, typeof process.getuid === "function" ? process.getuid() : 0);
313
+ console.error(`[daemon] front door shut — ${verdict.reason}; restarting ${label}`);
314
+ try {
315
+ await (deps.execFile || execFileAsync)(cmd.file, cmd.args);
316
+ console.error(`[daemon] ${label} restarted; a fresh session clears whatever the old one was sitting on`);
317
+ } catch (err) {
318
+ console.error(`[daemon] could not restart ${label}: ${err && err.message ? err.message : err} — this seat needs a person`);
319
+ }
320
+ return verdict;
321
+ }
322
+
266
323
  async function pollService(svc) {
267
324
  try {
268
325
  // Front door: a live main session owns this service's inbox — leave it.
269
326
  const fd = _frontDoorGate.check(svc.name);
270
327
  if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
328
+ // A door that is shut is not a door that owns anything. Ask on every poll,
329
+ // cheap and bounded: the common answer is "answering" off a fresh beat.
330
+ if (svc.name === "cohort") await reviveFrontDoorIfShut(fd.state).catch(() => {});
271
331
  if (!fd.dispatch) return 0;
272
332
  const result = await svc.fn();
273
333
  // Dedup by raw_ref first: the same Slack message arrives via multiple paths
@@ -499,18 +559,41 @@ function claudeAvailable() {
499
559
  * `electedMe:false`. A degraded election must never fall back to respond-to-all —
500
560
  * that is the storm this whole change exists to end.
501
561
  *
562
+ * ── WHAT A REASON MEANS, AND WHY `decided` IS ON THE VERDICT ────────────────
563
+ * Every path below that is not an election result ends in silence too, because
564
+ * the fail-safe is inverted — but silence-because-hq-picked-someone-else and
565
+ * silence-because-the-election-could-not-run are different events, and for
566
+ * months the caller printed both as "not elected to respond". An operator
567
+ * reading that log saw a decision where there had been an outage, or a missing
568
+ * id, or an item that was never a message. So the verdict now carries `decided`:
569
+ * TRUE only when hq actually ran the election and named responders. The caller
570
+ * logs the two cases in different words.
571
+ *
502
572
  * @param {object} item the (cohort) inbox item
503
573
  * @param {object} [deps] { electResponder, orgCfg } injectable seams for tests
504
- * @returns {Promise<{electedMe:boolean, reason:string, election?:string, mode?:string}>}
574
+ * @returns {Promise<{electedMe:boolean, decided:boolean, reason:string, election?:string, mode?:string}>}
505
575
  */
506
576
  async function electResponderVerdict(item, deps = {}) {
507
577
  const impl = deps.electResponder || electResponder;
508
578
  try {
509
579
  const cfg = deps.orgCfg !== undefined ? deps.orgCfg : daemonOrgCfg();
510
- if (!orgEnabled(cfg)) return { electedMe: false, reason: "org-disabled" };
580
+ if (!orgEnabled(cfg)) {
581
+ return { electedMe: false, decided: false, reason: "org integration disabled — no roster to elect against" };
582
+ }
511
583
  const channelId = cohortChannelId(item);
512
584
  const messageId = cohortMessageId(item);
513
- if (!channelId || !messageId) return { electedMe: false, reason: "no-message-id" };
585
+ // A ROOM ITEM MISSING ITS IDS IS A DEFECT, AND THE LOG MUST SAY SO. These
586
+ // two used to share the single reason "no-message-id", which was wrong on
587
+ // both counts: it named the message when the CHANNEL was the missing half,
588
+ // and it was the reason 23 doc comments printed in a day — items that have
589
+ // no channel id because they are not room traffic and never should have
590
+ // reached this call at all. That class is now excluded by the caller's
591
+ // surface gate, so anything landing here genuinely is a room item whose
592
+ // projection lost an id.
593
+ if (!channelId || !messageId) {
594
+ const missing = !channelId && !messageId ? "channel id and message id" : (!channelId ? "channel id" : "message id");
595
+ return { electedMe: false, decided: false, reason: `room item carries no ${missing} — the election cannot be asked` };
596
+ }
514
597
  const conn = configFromAgent(cfg);
515
598
  const res = await impl(
516
599
  { messageId, channelId },
@@ -518,9 +601,9 @@ async function electResponderVerdict(item, deps = {}) {
518
601
  );
519
602
  if (!res || res.error) {
520
603
  const reason = res && res.error
521
- ? `${res.error.code || "error"}: ${res.error.message || ""}`
522
- : "no-response";
523
- return { electedMe: false, reason: reason.slice(0, 200) };
604
+ ? `hq refused the election (${res.error.code || "error"}: ${res.error.message || ""})`
605
+ : "hq returned no election frame";
606
+ return { electedMe: false, decided: false, reason: reason.slice(0, 200) };
524
607
  }
525
608
  const out = res.result !== undefined ? res.result : res;
526
609
  const responders = Array.isArray(out && out.responders) ? out.responders : [];
@@ -535,14 +618,40 @@ async function electResponderVerdict(item, deps = {}) {
535
618
  const electedMe =
536
619
  !!mySlug &&
537
620
  responders.some((r) => String((r && r.slug) || "").trim().toLowerCase() === mySlug);
621
+ // NO SLUG ON RECORD is not an election result: hq may well have elected
622
+ // somebody, this seat simply cannot tell whether it was itself. Reporting it
623
+ // as "hq elected another seat" would be an assertion nothing checked.
624
+ if (!mySlug) {
625
+ return {
626
+ electedMe: false,
627
+ decided: false,
628
+ reason: "this seat has no org member slug on record — cannot tell whether it was elected",
629
+ election: out && out.election,
630
+ mode: out && out.mode,
631
+ };
632
+ }
633
+ const others = responders
634
+ .map((r) => String((r && r.slug) || "").trim())
635
+ .filter(Boolean)
636
+ .slice(0, 5)
637
+ .join(", ");
538
638
  return {
539
639
  electedMe,
540
- reason: electedMe ? "elected" : "not-elected",
640
+ decided: true,
641
+ reason: electedMe
642
+ ? "hq elected this seat"
643
+ : others
644
+ ? `hq elected ${others}`
645
+ : "hq elected nobody",
541
646
  election: out && out.election,
542
647
  mode: out && out.mode,
543
648
  };
544
649
  } catch (err) {
545
- return { electedMe: false, reason: `error: ${err && err.message ? err.message : String(err)}`.slice(0, 200) };
650
+ return {
651
+ electedMe: false,
652
+ decided: false,
653
+ reason: `the election call threw (${err && err.message ? err.message : String(err)})`.slice(0, 200),
654
+ };
546
655
  }
547
656
  }
548
657
 
@@ -802,25 +911,136 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
802
911
  // twelve seats silent on a message that named all thirteen. The inbound
803
912
  // join proved the address (human author, my room, collective marker), so
804
913
  // this is not ambient traffic and there is nothing to hold an election over.
914
+ //
915
+ // AND IT NEEDS A ROOM. `messaging.electResponder` takes
916
+ // `{messageId, channelId}`, loads that Message row and that channel's AI
917
+ // roster, and picks one of the seats IN THE ROOM. A document comment, a
918
+ // board comment, an approval or a decision names no channel and has no room
919
+ // roster — there is nobody to elect between. Asking the messaging election
920
+ // about one produced `no-message-id`, and because this gate's failure mode
921
+ // is inverted to fail-SILENT, "this is not a message" came out as "stay
922
+ // quiet": 23 doc comments dropped on this seat in a single day, each one
923
+ // logged as a decision not to answer a message.
924
+ //
925
+ // TWO SEPARATE QUESTIONS, AND THEY MUST NOT BE COLLAPSED INTO ONE. "Is this
926
+ // surface room traffic?" decides whether an election is the RIGHT mechanism;
927
+ // "was the address ambient?" decides whether one is NEEDED; "does the item
928
+ // name a channel?" decides whether one can be ASKED. A personal address on a
929
+ // non-room surface (I own the doc, I am the assignee, the approval is on my
930
+ // desk) skips the election because there is nothing to arbitrate. An AMBIENT
931
+ // address on a non-room surface still wants one — and gets one whenever the
932
+ // item names a room. `isRoomSurface` answers TRUE for an unknown name, so a
933
+ // new ambient channel surface keeps the storm gate by default.
805
934
  const rollCall = isRollCallItem(item);
806
- if (service === "cohort" && !isDm && !rollCall && !isDirectedAtAgent(item)) {
935
+ const surface = service === "cohort" ? cohortSurfaceOf(item) : "";
936
+ // The name a PERSON reads. `cohortSurfaceOf` answers for ROUTING, and its
937
+ // fallback for a legacy `cohort:<channelId>:<messageId>` raw_ref (or a bare
938
+ // `kind:"message"`, which is what `lib/org/messaging.mjs` stamps) is "dm" —
939
+ // correct as a route, a lie as a label, and it printed
940
+ // `Election on dm in channel/roadmap` for an ambient PUBLIC-channel message
941
+ // in this very lane's test output. `cohortSurfaceLabel` never guesses.
942
+ const surfaceLabel = service === "cohort" ? cohortSurfaceLabel(item) : "";
943
+ const surfaceDeclared = service === "cohort" ? cohortSurfaceIsDeclared(item) : false;
944
+ // THE ADDRESS THE SERVER PROVED OUTRANKS THE LOCAL PROSE HEURISTIC. The
945
+ // comment above says an @mention or a named address "skips the election
946
+ // entirely (`isDirectedAtAgent` already decided)" — but `isDirectedAtAgent`
947
+ // is a SLACK-shaped rule: it matches the seat's configured first name and
948
+ // Slack member id against the message prose, and knows nothing about a
949
+ // Cohort mention row. A Cohort @mention whose prose spells the seat
950
+ // differently (a display name, a surname, a slug) therefore read as
951
+ // UNDIRECTED and went to an election — one surface's gate judging another
952
+ // surface's item, which is the same shape as the doc-comment bug above.
953
+ // `priority_signals.mentions_agent` is the ingest layer's real verdict,
954
+ // stamped by `lib/org/inbound/project.mjs` only for a genuine dm / mention /
955
+ // named / direct address, so it is the one to trust here.
956
+ const provenAddress = service === "cohort" && item.priority_signals && item.priority_signals.mentions_agent === true;
957
+ const undirectedCohort =
958
+ service === "cohort" && !isDm && !rollCall && !provenAddress && !isDirectedAtAgent(item);
959
+ // AMBIENT vs PERSONAL, and whether an election can be ASKED at all. Three
960
+ // facts, kept separate because they answer different questions:
961
+ //
962
+ // `nonRoom` — the surface's reply is not a message posted into a room.
963
+ // `ambient` — the address was proved by MEMBERSHIP or VISIBILITY
964
+ // (`channel`, `participant`, `shared`), so every seat that
965
+ // shares the room or the share holds an equal claim. That is
966
+ // what an election is for.
967
+ // `roomToArbitrate` — the item names a real Cohort channel, so
968
+ // `messaging.electResponder` has a roster to elect from.
969
+ //
970
+ // ONLY THE COMBINATION `nonRoom && ambient && !roomToArbitrate` IS A DEAD
971
+ // END. An ambient non-room item that DOES name a room still goes to the
972
+ // election, exactly as it did before this change — a comment on a
973
+ // chat-attached file (`resolveChatFile` → `yes("file_comment","channel")`)
974
+ // carries its channel id, so hq can arbitrate the room's roster and one
975
+ // seat can answer. Excluding it unconditionally would have made that class
976
+ // permanently unanswerable by anybody, which is a behaviour change this
977
+ // lane never claimed and does not want: if hq cannot resolve the comment id
978
+ // as a Message the election errors, the inverted fail-safe fires, and the
979
+ // silence is the same one — so asking costs nothing and can only turn a
980
+ // never into an answer.
981
+ const nonRoom = undirectedCohort && !isRoomSurface(surface);
982
+ const ambient = undirectedCohort && isMembershipReason(item.direct_reason);
983
+ const roomToArbitrate = Boolean(cohortChannelId(item));
984
+ if (nonRoom && ambient && !roomToArbitrate) {
985
+ // AMBIENT, AND NO MECHANISM CAN REACH IT. A doc comment is addressed to
986
+ // this seat only because the file is SHARED with it — a visibility fact a
987
+ // whole channel can hold at once — but the reply is an RPC against a file
988
+ // id, there is no Message row and no channel id, and
989
+ // `messaging.electResponder` has nothing to be asked about. So the seat
990
+ // stays silent, and the log says THAT: a missing mechanism, not a
991
+ // decision. Arbitration for ambient non-room surfaces is a real gap; it
992
+ // needs a server-side election keyed on the entity, not a local guess.
993
+ console.log(`[daemon] No arbitration exists for an ambient ${surfaceLabel} item (addressed by ${item.direct_reason || "membership"}, it names no room, and the responder election is messaging-only) — staying silent (gap, not a decision; from ${item.sender})`);
994
+ logEvent("classifications", {
995
+ item_id: itemId,
996
+ sender: item.sender,
997
+ service,
998
+ surface,
999
+ surface_declared: surfaceDeclared,
1000
+ skipped: true,
1001
+ reason: `no_arbitration_for_ambient_surface: ${surface}`,
1002
+ direct_reason: item.direct_reason || null,
1003
+ summary: classResult.summary,
1004
+ });
1005
+ markProcessed(item, service);
1006
+ return { ok: true, path: "filtered", reason: "no_arbitration_for_ambient_surface" };
1007
+ } else if (nonRoom && !ambient) {
1008
+ console.log(`[daemon] No election on a ${surfaceLabel} item — it is not room traffic and the inbound join already proved it is addressed here (${item.direct_reason || "addressed"}); handling it on its own surface (from ${item.sender})`);
1009
+ } else if (undirectedCohort) {
807
1010
  const verdict = await _electResponderVerdict(item);
808
1011
  if (!verdict.electedMe) {
809
- console.log(`[daemon] Election: not elected to respond (${verdict.reason}) — staying silent for ${item.sender} in ${item.channel}`);
1012
+ // TWO DIFFERENT EVENTS, TWO DIFFERENT SENTENCES. `decided` is true only
1013
+ // when hq ran the election and named responders; everything else is the
1014
+ // fail-safe firing on an outage or a malformed item, and calling that a
1015
+ // decision is how an outage reads as normal operation in the log.
1016
+ console.log(
1017
+ verdict.decided
1018
+ ? `[daemon] Election on ${surfaceLabel} in ${item.channel}: ${verdict.reason} — this seat stays silent (from ${item.sender})`
1019
+ : `[daemon] Election on ${surfaceLabel} in ${item.channel} could not be decided: ${verdict.reason} — staying silent (fail-safe, not a decision; from ${item.sender})`,
1020
+ );
810
1021
  logEvent("classifications", {
811
1022
  item_id: itemId,
812
1023
  sender: item.sender,
813
1024
  service,
1025
+ surface,
1026
+ surface_declared: surfaceDeclared,
814
1027
  skipped: true,
815
- reason: `not_elected: ${verdict.reason}`,
1028
+ reason: verdict.decided
1029
+ ? `election_elected_another: ${verdict.reason}`
1030
+ : `election_undecided: ${verdict.reason}`,
1031
+ election_decided: verdict.decided === true,
816
1032
  election: verdict.election || null,
817
1033
  mode: verdict.mode || null,
818
1034
  summary: classResult.summary,
819
1035
  });
820
1036
  markProcessed(item, service);
821
- return { ok: true, path: "filtered", reason: "not_elected" };
1037
+ return {
1038
+ ok: true,
1039
+ path: "filtered",
1040
+ reason: verdict.decided ? "election_elected_another" : "election_undecided",
1041
+ };
822
1042
  }
823
- console.log(`[daemon] Election: elected to respond (${verdict.election || "elected"}, mode=${verdict.mode || "?"}) for ${item.sender} in ${item.channel}`);
1043
+ console.log(`[daemon] Election on ${surfaceLabel} in ${item.channel}: ${verdict.reason} (${verdict.election || "elected"}, mode=${verdict.mode || "?"}) — answering ${item.sender}`);
824
1044
  }
825
1045
 
826
1046
  // DIRECTED-MESSAGE GATE: In channels and group chats, only respond to
@@ -1046,6 +1266,32 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
1046
1266
  // the forbidden one); the escalation is the trace that a human must act.
1047
1267
  if (result.permanent) {
1048
1268
  const cause = result.code || result.error || "permanent send failure";
1269
+ // A SCOPE REFUSAL IS A CONFIGURATION FAULT AND SOMEBODY ELSE'S TO FIX.
1270
+ // Everything below this line is correct and stays — the ask is not
1271
+ // lost, the obligation is durable, an operator can sweep
1272
+ // state/obligations/needs-attention. But all of it is on THIS DISK. The
1273
+ // measured case (2026-09-22, doc comment on a file this seat may not
1274
+ // comment on) produced three log lines and one JSON file, and nothing
1275
+ // off the box ever said the seat was missing a scope. A person would
1276
+ // have to already suspect this seat to go and look, which is the same
1277
+ // silence in a nicer folder.
1278
+ //
1279
+ // So a scope fault is also COUNTED, and the count rides the presence
1280
+ // beat (lib/telemetry/collect → machine.replyDebt → the
1281
+ // `seat_missing_scope` alert). Counting rather than sending: the only
1282
+ // channel we had was the forbidden one, and the seat cannot know which
1283
+ // other room would reach the right human. What it CAN do is stop being
1284
+ // the only thing that knows.
1285
+ //
1286
+ // Falling through to a session is still refused, for the reason below:
1287
+ // a denied session runs autonomously and improvises unrelated work into
1288
+ // a channel this seat was just told it may not write to.
1289
+ if (isScopeFault(result.code)) {
1290
+ counters.bump(REPLY_DEBT_COUNTERS.scopeRefused, {
1291
+ service, sender: item.sender || "unknown",
1292
+ channel: item.channel_id || item.channel || "none",
1293
+ });
1294
+ }
1049
1295
  console.error(`[daemon] Quick reply PERMANENTLY failed for ${item.sender} (${cause}) — NOT spawning a session; opening obligation + escalating`);
1050
1296
  let obligationKey = itemId;
1051
1297
  try {
@@ -1061,6 +1307,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
1061
1307
  channel: item.channel_id || item.channel || null,
1062
1308
  summary: classResult && classResult.summary, openedAt: Date.now(),
1063
1309
  attempts: 1, sessionId: null, traceId: trace_id, lastError: cause,
1310
+ configFault: isScopeFault(result.code) ? "missing-scope" : null,
1064
1311
  item: { content: item.content } },
1065
1312
  { failure: { label: cause }, told: false },
1066
1313
  );
@@ -1556,6 +1556,11 @@ export function escalate(rec, o = {}) {
1556
1556
  failedAt: new Date(Number.isFinite(o.now) ? o.now : Date.now()).toISOString(),
1557
1557
  cause: o.failure ? o.failure.label : (rec.lastError || "unknown"),
1558
1558
  requesterWasTold: !!o.told,
1559
+ // WHO has to act. A transport failure is this seat's own problem and the
1560
+ // sweep retries it; a scope fault is a configuration the seat cannot
1561
+ // grant itself, so the row says so in a word an operator can grep rather
1562
+ // than leaving them to infer it from `cause`.
1563
+ configFault: rec.configFault || null,
1559
1564
  attempts: rec.attempts || 0,
1560
1565
  sessionId: rec.sessionId || null,
1561
1566
  traceId: rec.traceId || null,
@@ -34,7 +34,7 @@ import { recordOutbound } from "../../lib/comms/receipts.mjs";
34
34
  // Pure data (a frozen table, no imports of its own) — the surface vocabulary the
35
35
  // inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
36
36
  // new surface cannot be added upstream without this file's switch noticing.
37
- import { SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
37
+ import { ROOM_SURFACES, SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
38
38
 
39
39
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
40
40
 
@@ -117,11 +117,17 @@ export function resolveSlackChannel(item) {
117
117
  * correct case for it.
118
118
  */
119
119
 
120
- /** Surfaces that genuinely live in a Cohort room — these reply with a message. */
121
- // `broadcast` is a room message like a mention: the roll-call answer posts
122
- // into the space it was asked in. Without this entry the reply has no
123
- // transport, `canDeliverTo` says no, and the surface is dark at the last step.
124
- const CHANNEL_SURFACES = Object.freeze(["dm", "mention", "broadcast", "thread_reply", "call"]);
120
+ /**
121
+ * Surfaces that genuinely live in a Cohort room — these reply with a message.
122
+ *
123
+ * DERIVED, not re-typed. This was a hand-kept copy of five names, and a
124
+ * hand-kept copy is a list that can be forgotten: `broadcast` had to be added
125
+ * here by hand when the roll-call surface landed, and until it was, the reply
126
+ * had no transport, `canDeliverTo` said no, and the surface was dark at the last
127
+ * step. `surfaces.mjs` now carries `room` as a per-surface fact and this reads
128
+ * it, so a new room surface is routable the moment it is declared.
129
+ */
130
+ const CHANNEL_SURFACES = ROOM_SURFACES;
125
131
 
126
132
  /**
127
133
  * Error frame codes a retry cannot change.
@@ -174,17 +180,77 @@ function entityFromRawRef(item) {
174
180
  */
175
181
  export function cohortSurfaceOf(item) {
176
182
  if (!item) return "";
177
- // Guarded against the LEGACY raw_ref shape, which is `cohort:<channelId>:
178
- // <messageId>` — still on disk today. An all-lowercase channel id would
179
- // otherwise parse as a surface name and route a working DM to nowhere.
180
- const m = /^cohort:([a-z_]+):/.exec(str(item.raw_ref));
181
- if (m && SURFACE_NAMES.includes(m[1])) return m[1];
183
+ const declared = declaredSurface(item);
184
+ if (declared) return declared;
182
185
  const kind = str(item.kind);
183
186
  if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
184
187
  if (!kind || kind === "message") return "dm";
185
188
  return kind;
186
189
  }
187
190
 
191
+ /**
192
+ * The surface name `project.mjs` DECLARED in `raw_ref`, or "" when it declared
193
+ * none.
194
+ *
195
+ * Guarded against the LEGACY raw_ref shape, which is `cohort:<channelId>:
196
+ * <messageId>` — still on disk today, and still what `lib/org/messaging.mjs`
197
+ * stamps. An all-lowercase channel id would otherwise parse as a surface name
198
+ * and route a working DM to nowhere.
199
+ */
200
+ function declaredSurface(item) {
201
+ const m = /^cohort:([a-z_]+):/.exec(str(item && item.raw_ref));
202
+ return m && SURFACE_NAMES.includes(m[1]) ? m[1] : "";
203
+ }
204
+
205
+ /**
206
+ * Did `raw_ref` actually DECLARE this item's surface, or was it inferred?
207
+ *
208
+ * Worth recording next to a logged surface: an inferred one is a guess made
209
+ * from `kind` / `channel_kind`, and an operator reading the row later should be
210
+ * able to tell the two apart without re-deriving it.
211
+ *
212
+ * @param {object} item
213
+ * @returns {boolean}
214
+ */
215
+ export function cohortSurfaceIsDeclared(item) {
216
+ return declaredSurface(item) !== "";
217
+ }
218
+
219
+ /**
220
+ * How to NAME this item's surface in a line a person reads.
221
+ *
222
+ * ── WHY THIS IS NOT `cohortSurfaceOf` ──
223
+ * Because that function's job is ROUTING, and for routing its fallbacks are
224
+ * right: an item with the legacy `cohort:<channelId>:<messageId>` raw_ref, or
225
+ * bare `kind:"message"`, replies with a message posted into `channel_id`, which
226
+ * is precisely what the `dm` branch does. But "dm" is then a ROUTE, not a
227
+ * claim about where the message was written — and a log line that prints it
228
+ * tells an operator "dm" about a message in a PUBLIC channel. The lane that
229
+ * added these lines to make the log truthful hit exactly that in its own test
230
+ * output: `Election on dm in channel/roadmap`.
231
+ *
232
+ * So the label is derived separately and never guesses: a declared surface is
233
+ * used verbatim; otherwise `channel_kind` (which hq stamps on the narrow reader)
234
+ * names the room; otherwise the item's own `is_dm`; otherwise it says plainly
235
+ * that the surface was never declared, rather than inventing one.
236
+ *
237
+ * @param {object} item
238
+ * @returns {string}
239
+ */
240
+ export function cohortSurfaceLabel(item) {
241
+ if (!item) return "unknown surface";
242
+ const declared = declaredSurface(item);
243
+ if (declared) return declared;
244
+ const kind = str(item.kind);
245
+ if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
246
+ if (kind && kind !== "message") return kind;
247
+ const channelKind = str(item.channel_kind).toUpperCase();
248
+ if (channelKind === "DM") return "dm";
249
+ if (channelKind) return `${channelKind.toLowerCase()} channel message`;
250
+ if (item.is_dm === true) return "dm";
251
+ return "message (surface not declared in raw_ref)";
252
+ }
253
+
188
254
  /**
189
255
  * Where a reply to this Cohort item goes — a pure function of the item, so it
190
256
  * can be asked BEFORE anything is promised (see `canDeliverTo`).
@@ -766,5 +832,7 @@ export default {
766
832
  canDeliverTo,
767
833
  cohortReplyRoute,
768
834
  cohortSurfaceOf,
835
+ cohortSurfaceLabel,
836
+ cohortSurfaceIsDeclared,
769
837
  DELIVERABLE_SERVICES,
770
838
  };