@cohortapp/agent-sdk 2.15.0 → 2.16.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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * tier.test.mjs — the reply tier, across the whole classifier matrix.
3
+ *
4
+ * The tier is the ONE decision that says whether a human hears anything before
5
+ * the answer. Getting it wrong in the loud direction is the 3,069 generic acks
6
+ * and 961 clock-derived nags measured in production on 2026-09-12; getting it
7
+ * wrong in the quiet direction is the silence the obligation ledger was built
8
+ * to end. So every cell of the matrix is pinned here rather than sampled.
9
+ *
10
+ * Run: node --test lib/assurance/tier.test.mjs
11
+ */
12
+
13
+ import { test, describe } from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import {
17
+ replyTier,
18
+ TIERS,
19
+ ANSWER_MAX_RUNG,
20
+ PLAN_MIN_RUNG,
21
+ PLAN_ACTIONS,
22
+ PLAN_PRIORITIES,
23
+ } from "./tier.mjs";
24
+
25
+ /** Every value `classifier.mjs` can emit — the real enums, not a sample. */
26
+ const ACTIONS = ["respond", "draft", "research", "queue", "archive", "ignore"];
27
+ const PRIORITIES = ["critical", "high", "normal", "ignore"];
28
+ /** Every rung in `lib/execution/route.RUNGS`, plus "not routed yet". */
29
+ const RUNGS = [0, 1, 2, 3, 4, 5, null];
30
+
31
+ describe("replyTier — the three tiers and nothing else", () => {
32
+ test("every cell of the matrix returns one of exactly three tiers", () => {
33
+ for (const action of ACTIONS) {
34
+ for (const priority of PRIORITIES) {
35
+ for (const rung of RUNGS) {
36
+ for (const answerable of [true, false, null, undefined]) {
37
+ for (const willSpawnSession of [true, false]) {
38
+ const t = replyTier({ answerable, action, priority, rung, willSpawnSession });
39
+ assert.ok(TIERS.includes(t), `{${action}/${priority}/rung:${rung}/answerable:${answerable}/spawn:${willSpawnSession}} → ${t}`);
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ });
46
+
47
+ test("no session spawning ⇒ answer tier, whatever the classifier said", () => {
48
+ // The reply arrives in this turn. An interim in front of an answer the
49
+ // human is about to read is the definition of content-free traffic.
50
+ for (const action of ACTIONS) {
51
+ for (const priority of PRIORITIES) {
52
+ for (const rung of RUNGS) {
53
+ assert.equal(
54
+ replyTier({ answerable: false, action, priority, rung, willSpawnSession: false }),
55
+ "answer",
56
+ `${action}/${priority}/rung:${rung} with no session must be "answer"`,
57
+ );
58
+ }
59
+ }
60
+ }
61
+ });
62
+
63
+ test("answerable at rung 0 or 1 is the answer tier — the reply IS the acknowledgement", () => {
64
+ // `willSpawnSession` UNKNOWN: the caller is asking hypothetically (which is
65
+ // how WP-2's effort router will use it), so the classifier verdict decides.
66
+ for (const rung of [0, ANSWER_MAX_RUNG]) {
67
+ assert.equal(replyTier({ answerable: true, action: "respond", priority: "high", rung }), "answer");
68
+ assert.equal(replyTier({ answerable: true, action: "respond", priority: "high", rung, willSpawnSession: false }), "answer");
69
+ }
70
+ });
71
+
72
+ test("REGRESSION: an ASSERTED session disqualifies the answer tier, however answerable the item looked", () => {
73
+ // THE QUICK-PATH FALL-THROUGH. agent-daemon.mjs tries a quick reply first;
74
+ // when that reply fails transiently or is blocked by validation it falls
75
+ // THROUGH to a full session dispatch and asks for a tier with
76
+ // willSpawnSession:true while classResult.answerable is still true. The
77
+ // answer tier's whole premise — "the reply arrives in this turn" — is
78
+ // exactly what the fall-through has falsified.
79
+ //
80
+ // Latent rather than live only because `rung` is null today (R13). The
81
+ // moment WP-2 routes it, that item would have landed in `answer`,
82
+ // shouldAcknowledge would have returned ack:false, the sweep would have
83
+ // read the durable tier as never-speak, and a 15-45 minute session would
84
+ // have run with the human hearing NOTHING at all. Pinned before WP-2 lands
85
+ // on top of it.
86
+ for (const rung of [0, ANSWER_MAX_RUNG]) {
87
+ assert.equal(
88
+ replyTier({ answerable: true, action: "respond", priority: "high", rung, willSpawnSession: true }),
89
+ "work",
90
+ `rung ${rung}: a spawning session speaks once at most — it is never silent`,
91
+ );
92
+ }
93
+ // …and the plan tier still outranks it, because rung 3+ is a project.
94
+ assert.equal(replyTier({ answerable: true, action: "respond", priority: "high", rung: 4, willSpawnSession: true }), "plan");
95
+ });
96
+
97
+ test("a routed rung of 3 or above is the plan tier, whatever the action", () => {
98
+ for (const rung of RUNGS.filter((r) => r != null && r >= PLAN_MIN_RUNG)) {
99
+ for (const action of ACTIONS) {
100
+ assert.equal(
101
+ replyTier({ answerable: false, action, priority: "normal", rung, willSpawnSession: true }),
102
+ "plan",
103
+ `rung ${rung} / ${action} must be "plan"`,
104
+ );
105
+ }
106
+ }
107
+ });
108
+
109
+ test("research or draft at critical/high priority is the plan tier even with no routed rung", () => {
110
+ for (const action of PLAN_ACTIONS) {
111
+ for (const priority of PLAN_PRIORITIES) {
112
+ assert.equal(replyTier({ answerable: false, action, priority, rung: null, willSpawnSession: true }), "plan");
113
+ }
114
+ }
115
+ });
116
+
117
+ test("…and the same actions at normal priority are only work", () => {
118
+ for (const action of PLAN_ACTIONS) {
119
+ for (const priority of ["normal", "ignore"]) {
120
+ assert.equal(replyTier({ answerable: false, action, priority, rung: null, willSpawnSession: true }), "work");
121
+ }
122
+ }
123
+ });
124
+
125
+ test("the ordinary directed ask — respond/high, unrouted — is work: silence, then at most one line", () => {
126
+ // This is the shape of the overwhelming majority of the 10,667 measured
127
+ // agent messages. It must NOT be plan tier, or the flood returns wearing a
128
+ // better costume.
129
+ assert.equal(replyTier({ answerable: false, action: "respond", priority: "high", rung: null, willSpawnSession: true }), "work");
130
+ assert.equal(replyTier({ answerable: false, action: "queue", priority: "critical", rung: null, willSpawnSession: true }), "work");
131
+ });
132
+
133
+ test("an ABSENT rung is never read as the cheapest rung", () => {
134
+ // `lib/backlog` already learned this: absence is not rung 0. A missing rung
135
+ // may not buy the answer tier's silence-on-the-strength-of-a-quick-path,
136
+ // and may not buy the plan tier's licence to speak either.
137
+ assert.equal(replyTier({ answerable: true, action: "respond", priority: "high", rung: null, willSpawnSession: true }), "work");
138
+ assert.equal(replyTier({ answerable: true, action: "respond", priority: "high", rung: undefined, willSpawnSession: true }), "work");
139
+ });
140
+
141
+ test("a non-numeric or out-of-range rung is treated as unrouted, never as a licence to speak", () => {
142
+ for (const rung of ["3", "team", NaN, Infinity, -1, 6, {}, []]) {
143
+ assert.equal(
144
+ replyTier({ answerable: false, action: "respond", priority: "normal", rung, willSpawnSession: true }),
145
+ "work",
146
+ `rung ${JSON.stringify(rung)} must not be trusted`,
147
+ );
148
+ }
149
+ });
150
+
151
+ test("answerable only counts when it is strictly true", () => {
152
+ // classifier.mjs coerces a missing/odd field to false for the same reason:
153
+ // never guess "answerable".
154
+ for (const answerable of ["true", 1, {}, null, undefined]) {
155
+ assert.equal(replyTier({ answerable, action: "respond", priority: "high", rung: 0, willSpawnSession: true }), "work");
156
+ }
157
+ });
158
+
159
+ test("garbage in gives work out — the tier never throws and never invents silence", () => {
160
+ // Fail-open direction: the tier that can still speak, never the tier that
161
+ // cannot. A classifier failure must not silently mute the agent.
162
+ assert.equal(replyTier(), "work");
163
+ assert.equal(replyTier(null), "work");
164
+ assert.equal(replyTier({}), "work");
165
+ assert.equal(replyTier({ action: 12, priority: [], rung: "x", willSpawnSession: "yes" }), "work");
166
+ });
167
+
168
+ test("the tier is a pure function — same input, same answer, no ambient reads", () => {
169
+ const args = { answerable: false, action: "research", priority: "critical", rung: null, willSpawnSession: true };
170
+ const first = replyTier(args);
171
+ for (let i = 0; i < 50; i++) assert.equal(replyTier(args), first);
172
+ assert.deepEqual(args, { answerable: false, action: "research", priority: "critical", rung: null, willSpawnSession: true }, "the argument is not mutated");
173
+ });
174
+ });
@@ -211,12 +211,28 @@ export function spokeSince(q = {}) {
211
211
  * @param {string} [q.agentRoot]
212
212
  * @returns {{heard:boolean, basis:string}}
213
213
  */
214
+ /**
215
+ * Receipt kinds that are COURTESIES, not answers.
216
+ *
217
+ * A receipt of one of these proves the agent said something in the room; it
218
+ * does not prove the ask was answered, and discharging a debt on one closes an
219
+ * unanswered question as answered.
220
+ *
221
+ * `progress` is retained although nothing emits it any more (deleted
222
+ * 2026-09-12 — see `scripts/daemon/assurance.mjs`): receipts already on disk
223
+ * carry it, and a historical progress ping must not start discharging debts on
224
+ * the day it stops being written. `notice` is the interrupted/orphan apology —
225
+ * information about the MACHINE, not about the ask. `plan` is the plan-tier
226
+ * interim.
227
+ */
228
+ export const NON_ANSWER_KINDS = Object.freeze(["ack", "progress", "notice", "failure", "plan"]);
229
+
214
230
  export function spokeFor(q = {}) {
215
231
  const rec = q.obligation || {};
216
232
  const ch = channelKey(rec.channel);
217
233
  const since = Number.isFinite(rec.openedAt) ? rec.openedAt : q.now;
218
234
  if (!ch || !Number.isFinite(since)) return { heard: false, basis: "no-channel" };
219
- const exclude = Array.isArray(q.excludeKinds) ? q.excludeKinds : ["ack", "progress", "failure"];
235
+ const exclude = Array.isArray(q.excludeKinds) ? q.excludeKinds : NON_ANSWER_KINDS;
220
236
  const recs = readReceiptsSince(since, q).filter(
221
237
  (r) => r.channel === ch && !exclude.includes(r.kind),
222
238
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
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": {
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: inbound-triage
3
- description: Decide, for each inbound Cohort event, whether to answer in this turn, acknowledge and file it on the board and run a workflow, or hand it to a peer session — and always acknowledge in the same turn. Use when a feed `inbound` line arrives, when reading `maestro inbox list`, or when you are unsure whether an ask is a reply or a task.
3
+ description: Decide, for each inbound Cohort event, whether to answer in this turn, file it on the board and run a workflow, or hand it to a peer session — and whether the person hears anything before the answer (usually not). Use when a feed `inbound` line arrives, when reading `maestro inbox list`, or when you are unsure whether an ask is a reply or a task.
4
4
  ---
5
5
 
6
6
  # Inbound triage
7
7
 
8
- Every inbound event is one of three things. Decide in the first turn, and say
9
- something to the person in that same turn — a person who asked gets a reply
10
- or an acknowledgement before you do anything else.
8
+ Every inbound event is one of three things. Decide in the first turn. Whether
9
+ you SAY anything in that turn is a separate question, and the default answer is
10
+ no: see "The one-interim rule" at the foot of this skill.
11
11
 
12
12
  ## The three outcomes
13
13
 
@@ -17,12 +17,10 @@ or an acknowledgement before you do anything else.
17
17
  --text "…"` → `maestro inbox done <id>`. No board row: the ledger on the
18
18
  server already records that you answered.
19
19
 
20
- 2. **Acknowledge, file, work here.** The ask needs real work — reading,
21
- drafting, building, several steps — but you can finish it in this session
22
- inside an hour or two without blocking the front door. In ONE turn:
20
+ 2. **File it, work here.** The ask needs real work — reading, drafting,
21
+ building, several steps — but you can finish it in this session inside an
22
+ hour or two without blocking the front door. In ONE turn:
23
23
  - `maestro inbox claim <id>`
24
- - `maestro inbox reply <id> --text "On it — I'll have <X> to you by <when>."`
25
- (specific deliverable, specific time; never "I'll look into it")
26
24
  - `maestro board track <id> --stage accepted --title "<what you took on>"
27
25
  --why "<one line: why this is more than a reply>"`
28
26
  - then run the work — a `Workflow` when it has distinct steps, plain tool
@@ -34,10 +32,10 @@ or an acknowledgement before you do anything else.
34
32
  (or `messaging_send` to the thread), then `maestro board track <id>
35
33
  --stage done` and `maestro inbox done <id>`.
36
34
 
37
- 3. **Acknowledge, file, hand to a peer.** Same as 2, but the work is long
38
- (hours), heavy (a repo build, a large research pass), or would block you
39
- from answering the next person. After the acknowledgement and the
40
- `--stage accepted` track, `maestro session spawn --name <slug> "<prompt>"`
35
+ 3. **File it, hand to a peer.** Same as 2, but the work is long (hours), heavy
36
+ (a repo build, a large research pass), or would block you from answering the
37
+ next person. After the `--stage accepted` track — and, if the ask is big
38
+ enough to warrant one, the plan (below) — `maestro session spawn --name <slug> "<prompt>"`
41
39
  with a prompt that names the deliverable, the channel and thread to report
42
40
  to, the inbox id, and the instruction to `SendMessage` you a two-line
43
41
  status when done. You stay the one who talks to the human; the peer talks to
@@ -54,14 +52,14 @@ or an acknowledgement before you do anything else.
54
52
  space's board. You do not choose the board.
55
53
  - Will it take longer than the next inbound can wait? → peer.
56
54
  - Is it a question you should not answer alone (a commitment, spend, an
57
- external promise)? → acknowledge, file with `--stage blocked` and `notify`
58
- your principal; do not guess.
55
+ external promise)? → file with `--stage blocked` and `notify` your principal,
56
+ and say in-channel what you need and from whom. That is a message with
57
+ content in it, so it is always worth sending; do not guess.
59
58
 
60
59
  ## Special topics
61
60
 
62
- - **Calls** (`topic: call`): acknowledge in the channel and either join if
63
- you are free now or propose a time; the media stays with the avatar service,
64
- you do not handle audio here.
61
+ - **Calls** (`topic: call`): answer in the channel — join if you are free now,
62
+ or propose a time. Either is a real answer, not an acknowledgement.
65
63
  - **Comments on files/boards**: reply in the thread of the comment, not in a
66
64
  DM; a comment that asks for a change to a document is outcome 2.
67
65
  - **Inbound email** (`surface: email`): the same three outcomes; reply through
@@ -71,10 +69,40 @@ or an acknowledgement before you do anything else.
71
69
  the same message re-delivered after a crash — check the thread before you
72
70
  answer twice.
73
71
 
74
- ## The same-turn rule
72
+ ## The one-interim rule
75
73
 
76
- Whatever the outcome, the person hears from you in the turn the event
77
- arrived. An acknowledgement is one or two sentences with a concrete next step
78
- and time. Do not open with filler and do not describe how you work — say what
79
- they will get and when. The persona rules (`persona-discipline`) apply to the
80
- acknowledgement too.
74
+ ~~"Whatever the outcome, the person hears from you in the turn the event
75
+ arrived."~~ — struck 2026-09-12. Measured over fourteen days in
76
+ `org_default_adaptic`: of 10,667 agent messages, 3,069 (28.8 %) were opening
77
+ acknowledgements and 961 (9.0 %) were progress nags, and 272 of those
78
+ acknowledgements were never followed by a substantive reply inside an hour.
79
+ Agent-to-human volume went from 2:1 to 158:1 in a fortnight, 80 % of it into one
80
+ channel.
81
+
82
+ **Silence is the default. A message before the answer is never a reflex; it is
83
+ either absent or it is a plan.**
84
+
85
+ - **Outcome 1 (reply now):** the reply IS the acknowledgement. Nothing before it.
86
+ - **Outcome 2 or 3, ordinary size:** say nothing. Claim it, file it, do it, and
87
+ let the result be the first thing they read. A person who has been waiting two
88
+ minutes has lost nothing; a person who got "On it" and then nothing for an
89
+ hour has lost their trust in you.
90
+ - **Outcome 2 or 3, large enough that the shape matters** (a workflow, a peer
91
+ team, a multi-day research pass, anything where the approach is itself a
92
+ decision): post the PLAN, once, as the first step of doing the work — what you
93
+ will do, in what order, and what comes back. Three bullets at most, in your own
94
+ words about this specific ask.
95
+ - **Never** a generic opener. `On it`, `Looking into it`, `Checking`, `One
96
+ moment`, `Got it`, `Will do`, `Working on it`, `Taking a look`, `Digging in`:
97
+ the daemon's sanitiser now refuses every one of these outright, and so should
98
+ you.
99
+ - **Never** a promise you have no mechanism to keep. "I'll come back as soon as
100
+ I've got something" is only sayable because the obligation ledger will in fact
101
+ chase it; do not say it about work that has no such ledger behind it.
102
+ - **At most one** such message per channel per fifteen minutes, whatever else is
103
+ in flight there. If a teammate or the daemon has already spoken in that room
104
+ inside the window, you have had your turn.
105
+
106
+ The persona rules (`persona-discipline`) apply to a plan exactly as to a reply.
107
+ The daemon's half of the same policy — tiers, the room budget, the sanitiser —
108
+ is `docs/guides/poller-daemon-setup.md` §2.6a.
@@ -43,8 +43,9 @@ Each line is a JSON object with a `type`. Handle it in the turn it arrives.
43
43
 
44
44
  - `inbound` — `{id, surface, topic, from, channelId, threadId, preview, path}`.
45
45
  `maestro inbox show <id>` for the full item, then follow the
46
- `inbound-triage` skill: reply now, or acknowledge + `maestro board track
47
- <id> --stage accepted` + work it, or spawn a peer. Claim it first
46
+ `inbound-triage` skill: reply now, or `maestro board track <id> --stage
47
+ accepted` + work it, or spawn a peer. Saying something BEFORE the answer is
48
+ the exception, not the rule — see that skill's one-interim rule. Claim it first
48
49
  (`maestro inbox claim <id>`) so the daemon's sweep does not re-deliver it,
49
50
  reply with `maestro inbox reply <id> --text "…"`, and close with
50
51
  `maestro inbox done <id>`. The reply command is the reply lane: it runs the
@@ -67,8 +68,9 @@ Each line is a JSON object with a `type`. Handle it in the turn it arrives.
67
68
  disk.
68
69
 
69
70
  Do not batch: an inbound line that sits while you finish something else is a
70
- person waiting. Acknowledge in the same turn (see `inbound-triage`), then
71
- finish the other thing.
71
+ person waiting. DECIDE it in the turn it arrives (see `inbound-triage`) — claim
72
+ it, file it, start it — then finish the other thing. Deciding in the same turn
73
+ is the rule; speaking in the same turn is not.
72
74
 
73
75
  ## The idle loop
74
76
 
@@ -69,6 +69,7 @@ import { sendQuickResponse, sendHoldingMessage, isQuickReply } from "./responder
69
69
  // imported here because this file is where every one of those moments happens —
70
70
  // the dispatch decision, the session close, and the tick loop.
71
71
  import {
72
+ ACK_AFTER_MS,
72
73
  shouldAcknowledge,
73
74
  openAndAcknowledge,
74
75
  noteSession,
@@ -933,6 +934,13 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
933
934
  // paths; passing only the text to buildPrompt made every failed ack read to
934
935
  // the session as a delivered one.
935
936
  let holdingDelivered = false;
937
+ // An interim reached this human but THIS process does not hold its text —
938
+ // the re-delivery path, where the debt is already open with interimSaid and
939
+ // `openAndAcknowledge` answers {acked:true, ackText:null}. Without carrying
940
+ // the fact separately from the text, the prompt's no-contact block asserted
941
+ // silence at a session whose sender had already had a holding line and a
942
+ // "Hit a problem — … Retrying now".
943
+ let interimAlreadySent = false;
936
944
  let obligationKeyForItem = null;
937
945
  // FAIL-SAFE, the storm's OTHER half: never post a holding "let me look into
938
946
  // it" the seat cannot keep. If the Claude CLI is not even available, the
@@ -942,8 +950,14 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
942
950
  // still opening the durable debt, so the assurance sweep escalates instead of
943
951
  // a human staring at "on it" that never resolves.
944
952
  const ackVerdict = _claudeAvailable()
945
- ? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" })
946
- : { ack: false, reason: "claude-unavailable" };
953
+ // `classResult` is passed so the verdict carries a TIER (answer / work /
954
+ // plan). The tier is what decides whether this ask may make the agent
955
+ // speak AT ALL: an answerable question at a cheap rung never earns an
956
+ // interim, and nothing at all is said at this point on any tier — the
957
+ // sweep owns the single interim, after ACK_AFTER_MS and inside the room's
958
+ // budget. `item.rung` is null today; WP-2 routes it.
959
+ ? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox", classResult, rung: item.rung })
960
+ : { ack: false, reason: "claude-unavailable", tier: "work" };
947
961
  // ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
948
962
  // `emitterClass: true` means the gate read the inbound and recognised output
949
963
  // this seat's own machinery class produces — a peer agent's ack-shaped
@@ -973,17 +987,27 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
973
987
  service,
974
988
  traceId: trace_id,
975
989
  ack: ackVerdict.ack,
990
+ tier: ackVerdict.tier,
991
+ // Retained seam: `openAndAcknowledge` no longer sends anything at open
992
+ // time, so this is never called from the acknowledgement path. It stays
993
+ // wired because `deps.sendHoldingMessage` is a documented injection
994
+ // point for the whole fleet and silently dropping it would break
995
+ // callers that still pass one.
976
996
  deps: { ackSender: _sendHoldingMessage },
977
997
  });
978
998
  obligationKeyForItem = opened.key;
999
+ // SILENCE IS THE DEFAULT. Nothing has been said to this human yet and
1000
+ // nothing may be until ACK_AFTER_MS, so the session's prompt must not be
1001
+ // told a holding message exists — see prompt-builder's two blocks, which
1002
+ // now render only when one genuinely did go out.
979
1003
  holdingText = opened.ackText;
980
1004
  holdingDelivered = Boolean(opened.acked);
1005
+ interimAlreadySent = Boolean(opened.acked) && !opened.ackText;
981
1006
  if (opened.acked) {
982
1007
  updateLock(itemId, { holdingSent: true });
983
1008
  } else if (ackVerdict.ack) {
984
- // NOT fatal, NOT silent, NOT forgotten. Loud here; retried by the sweep.
985
- console.warn(`[daemon] acknowledgement not delivered for ${itemId} (${opened.error}) — obligation ${opened.key} left open for the assurance sweep`);
986
- counters.bump("assurance.ack_deferred", { service });
1009
+ console.log(`[daemon] no interim yet for ${itemId} (${opened.reason}) — the sweep owns it from ${Math.round(ACK_AFTER_MS / 1000)}s, inside the room's budget`);
1010
+ counters.bump("assurance.interim_deferred", { service, tier: ackVerdict.tier || "work" });
987
1011
  } else {
988
1012
  console.log(`[daemon] no acknowledgement for ${itemId} (${ackVerdict.reason}) — debt ${opened.key} still tracked`);
989
1013
  }
@@ -1035,6 +1059,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
1035
1059
  type: "inbox",
1036
1060
  holdingMessage: holdingText,
1037
1061
  holdingSent: holdingDelivered,
1062
+ interimAlreadySent,
1038
1063
  });
1039
1064
  // F1/H2: record a DURABLE in-flight admission and DEFER markProcessed() to
1040
1065
  // the dispatch onClose SUCCESS path. The previous code marked the item
@@ -2528,8 +2553,11 @@ async function main() {
2528
2553
  // sweep is the thing that speaks to humans.
2529
2554
  Promise.resolve(sweepObligations())
2530
2555
  .then((s) => {
2531
- if (s.acked || s.progressed || s.staled || s.interrupted) {
2532
- console.log(`[daemon] assurance sweep — acked:${s.acked} progress:${s.progressed} stale:${s.staled} interrupted:${s.interrupted} closed:${s.closed} (open:${s.swept})`);
2556
+ if (s.acked || s.suppressed || s.staled || s.interrupted) {
2557
+ // `suppressed` is the room budget doing its job — it is logged
2558
+ // BECAUSE it is the quiet half: a sweep that says nothing must still
2559
+ // be able to prove it decided to say nothing.
2560
+ console.log(`[daemon] assurance sweep — interim:${s.acked} suppressed:${s.suppressed} stale:${s.staled} interrupted:${s.interrupted} closed:${s.closed} (open:${s.swept})`);
2533
2561
  }
2534
2562
  })
2535
2563
  .catch((err) => console.error("[daemon] assurance sweep error:", err.message));
@@ -683,13 +683,22 @@ test("ELECTION: an @mention/named-address responds WITHOUT calling electResponde
683
683
  cls._resetAgentRegistry();
684
684
  });
685
685
 
686
- test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (claude unavailable)", async () => {
686
+ test("FAIL-SAFE: an ask the reply session cannot answer is never promised one (claude unavailable)", async () => {
687
687
  resetState();
688
688
  // A slack DM — always directed, so this isolates the ack gate from the election.
689
689
  // is_dm is normally set by enrichItem; answerItem is called directly here, so
690
690
  // set it on the item as the poller/enrichment would.
691
691
  // Distinct channel + subject per run so the request-claim from one run does not
692
692
  // deny the other (the claim key is recipient + subject + action_type).
693
+ //
694
+ // WHAT THIS PINS SINCE 2026-09-12. Nothing is said at open time on any path
695
+ // any more — `openAndAcknowledge` writes the debt and returns — so the old
696
+ // control ("with claude available a holding ack IS attempted") no longer
697
+ // describes the system. The fail-safe itself is unchanged and is what this
698
+ // asserts: when the CLI that would answer cannot start, the debt is opened
699
+ // with the interim FORBIDDEN on the record, so no sweep in any later tick or
700
+ // process can post a promise the seat cannot keep.
701
+ const assurance = await import("./assurance.mjs");
693
702
  const mkItem = (tag) => ({ id: `MSG-ACK-${tag}`, raw_ref: `slack:DACK${tag}:1`, service: "slack", channel: "dm/ceo", channel_id: `DACK${tag}0001`, is_dm: true, sender: "ceo", content: "please draft the memo" });
694
703
  const baseDeps = (spy, claudeUp, tag) => ({
695
704
  classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: `draft memo ${tag}`, category: "action_required", directed_at_agent: true }),
@@ -699,19 +708,27 @@ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (cla
699
708
  claudeAvailable: () => claudeUp,
700
709
  });
701
710
 
702
- // CONTROL: claude available ⇒ the ack path runs (holding message attempted).
711
+ // CONTROL: claude available ⇒ the debt is opened and an interim is PERMITTED
712
+ // later, by the sweep, after ACK_AFTER_MS and inside the room's budget.
703
713
  const up = { calls: 0, dispatched: false };
704
714
  await daemon.answerItem(mkItem("UP"), "slack", "slack:DACKUP:1", "trace-ack-up", baseDeps(up, true, "UP"));
705
- assert.equal(up.calls, 1, "with claude available, a holding ack IS attempted");
715
+ assert.equal(up.calls, 0, "nothing is said at open time on ANY path — the interim belongs to the sweep");
706
716
  assert.equal(up.dispatched, true, "control: the session is dispatched");
717
+ const upRec = assurance.readObligation("slack:DACKUP:1");
718
+ assert.ok(upRec, "the debt exists");
719
+ assert.notEqual(upRec.interimForbidden, true, "and an interim is permitted for it");
707
720
 
708
- // TREATMENT: claude unavailable ⇒ NO ack is emitted, but the session still runs
709
- // (the assurance sweep owns the outcome; the human just isn't promised a reply).
721
+ // TREATMENT: claude unavailable ⇒ the interim is forbidden on the record, but
722
+ // the session still runs (the assurance sweep owns the outcome; the human
723
+ // just isn't promised a reply the seat cannot produce).
710
724
  const down = { calls: 0, dispatched: false };
711
725
  const res = await daemon.answerItem(mkItem("DN"), "slack", "slack:DACKDN:1", "trace-ack-down", baseDeps(down, false, "DN"));
712
- assert.equal(down.calls, 0, "with claude unavailable, NO holding ack is posted before the working session");
726
+ assert.equal(down.calls, 0, "with claude unavailable, no interim is ever posted for this ask");
713
727
  assert.equal(down.dispatched, true, "the session is still dispatched");
714
728
  assert.equal(res.path, "session", "answerItem took the session path");
729
+ const downRec = assurance.readObligation("slack:DACKDN:1");
730
+ assert.ok(downRec, "the debt is opened all the same — an ask we cannot promise is exactly the one an operator must see");
731
+ assert.equal(downRec.interimForbidden, true, "durable on the record, because the sweep runs in another tick");
715
732
  });
716
733
 
717
734
  // ===========================================================================
@@ -207,10 +207,42 @@ test("SCENARIO 1 — a fast ask gets a direct answer, with no pointless holding
207
207
  });
208
208
 
209
209
  // ===========================================================================
210
- // SCENARIO 2 — A SLOW ASK. Acknowledged in milliseconds, updated, then answered.
210
+ // SCENARIO 2 — A SLOW ASK. Silent for ninety seconds, one line, then answered.
211
+ //
212
+ // WHY THIS SCENARIO CHANGED ON 2026-09-12, AND WHY THE PROGRESS PING IS GONE
213
+ //
214
+ // It used to assert an acknowledgement at t≈0 and EXACTLY ONE progress ping at
215
+ // six minutes. Both assertions were faithful to the code and both encoded the
216
+ // defect that code had become.
217
+ //
218
+ // Measured over fourteen days in org_default_adaptic: 10,667 agent messages, of
219
+ // which 3,069 (28.8%) were opening acknowledgements and 961 (9.0%) were progress
220
+ // nags — 37.8% of everything this fleet said carried no content at all. 272 of
221
+ // those acknowledgements were never followed by a substantive reply inside an
222
+ // hour, i.e. the promise in them ("I'll come back as soon as I've got
223
+ // something") was simply not kept. Agent-to-human volume went from 2:1 to 158:1
224
+ // in a fortnight, and 9,187 of ~11,400 messages in 21 days landed in ONE
225
+ // channel from eight agents, ~45% of them exact duplicates. Sampled live at
226
+ // 06:32Z: "Still on this — 5 minutes in" beside "Still going — 15 minutes in"
227
+ // at the SAME timestamp, two independent per-obligation timers narrating one
228
+ // piece of overlapping work.
229
+ //
230
+ // The progress ping could not have been better written. `composeProgress` was a
231
+ // pure function of `now - openedAt`; nothing read the session's stdout, its
232
+ // tool calls or its partial findings, so the message could not contain anything
233
+ // a reader could not already see on the clock. A message that says only what
234
+ // the timestamp says is not an update. It is deleted, and this scenario now
235
+ // asserts that NONE is emitted — not because the assertion was wrong about the
236
+ // code, but because the behaviour it pinned was the bug.
237
+ //
238
+ // What replaces it: silence below ACK_AFTER_MS (the typing indicator is the
239
+ // acknowledgement), then AT MOST ONE bespoke line, and only if the room has not
240
+ // already heard one inside its budget window. Beyond that the agent either says
241
+ // something with content in it — the plan the session itself stated — or it says
242
+ // nothing and lets the answer be the answer.
211
243
  // ===========================================================================
212
244
 
213
- test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs, then answered", async () => {
245
+ test("SCENARIO 2 — a slow ask is silent, then gets ONE line, then the answer — and no progress nag", async () => {
214
246
  const ask = "with that in mind, fix these issues you just noted please and push their fixes to git";
215
247
  beginScenario("SCENARIO 2 — SLOW ASK (the owner's actual message)", ask);
216
248
  const item = makeItem("SLOW-1", ask);
@@ -224,23 +256,45 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
224
256
  dispatch: (_p, _it, _cr, _s, opts) => { onCloseHook = opts.onClose; },
225
257
  });
226
258
 
227
- const ack = transcript.find((m) => m.kind === "ack");
228
- assert.ok(ack, "the acknowledgement must go out");
229
- assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms`);
230
- assert.match(ack.text, /git fixes/i, "the ack is bespoke to the actual ask, not a rotation pick");
231
- assert.doesNotMatch(ack.text, /^(on it|looking now)[.!—]?\s*$/i, "never the retired canned lines");
232
- assert.ok(ack.text.length <= 120, "the ack is one short human line, not a templated paragraph");
233
-
259
+ assert.equal(transcript.length, 0, "NOTHING at t=0 — the reflex ack is what 3,069 content-free messages were");
234
260
  const key = assurance.openObligations()[0].key;
235
- note("obligation opened and acknowledged; session running");
261
+ assert.equal(assurance.readObligation(key).state, "open", "the DEBT is opened all the same: silence about timing is not silence about outcome");
262
+ note("obligation opened, nothing said; session running");
236
263
 
237
- // ── 6 minutes later: still working. The sweep speaks rather than let the
238
- // human sit behind a typing indicator wondering.
239
- let virtual = 6 * 60_000;
264
+ // ── Thirty seconds in: still nothing. Below ACK_AFTER_MS the typing
265
+ // indicator is the acknowledgement.
266
+ let virtual = 30_000;
240
267
  const clock = () => virtual;
241
- const deps = { deliverImpl: transport(clock), spokeSinceImpl: () => false };
242
- let stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps });
243
- assert.equal(stats.progressed, 1, "long work must produce an interim update");
268
+ const deps = () => ({
269
+ deliverImpl: transport(clock),
270
+ spokeSinceImpl: () => false,
271
+ generateAckImpl: async () => "Taking the git fixes now — push coming.",
272
+ });
273
+ let stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
274
+ assert.equal(stats.acked, 0);
275
+ assert.equal(transcript.length, 0, "under ninety seconds, silence is the right answer");
276
+
277
+ // ── Two minutes in: ONE line, specific to the ask.
278
+ virtual = 2 * 60_000;
279
+ stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
280
+ assert.equal(stats.acked, 1, "past ACK_AFTER_MS the human gets exactly one line");
281
+ const ack = transcript.find((m) => m.kind === "ack");
282
+ assert.ok(ack, "…and it is that line");
283
+ assert.match(ack.text, /git fixes/i, "bespoke to the actual ask, not a rotation pick");
284
+ assert.doesNotMatch(ack.text, /^(on it|looking now|checking|one moment|got it|will do)\b/i, "never a generic opener — the sanitiser now enforces the prompt");
285
+ assert.ok(ack.text.length <= 120, "one short human line, not a templated paragraph");
286
+
287
+ // ── Six minutes in — where the progress ping used to land. NOTHING.
288
+ virtual = 6 * 60_000;
289
+ stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
290
+ assert.equal(stats.acked, 0);
291
+ const progressish = transcript.filter((m) => /still (on this|going)/i.test(m.text));
292
+ assert.equal(progressish.length, 0, "a message that is a pure function of elapsed minutes cannot carry progress");
293
+
294
+ // ── Sixteen minutes: and still nothing, however long it runs.
295
+ virtual = 16 * 60_000;
296
+ await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
297
+ assert.equal(transcript.length, 1, `the whole wait costs the human ONE message, got ${transcript.length}`);
244
298
 
245
299
  // ── 17 minutes: the session finally answers the human itself.
246
300
  virtual = 17 * 60_000;
@@ -251,9 +305,8 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
251
305
 
252
306
  const rec = assurance.readObligation(key);
253
307
  assert.equal(rec.state, "answered", "the debt is discharged by evidence of a delivered reply");
254
- const progress = transcript.filter((m) => m.kind === "progress");
255
- assert.equal(progress.length, 1);
256
308
  assert.equal(transcript.filter((m) => m.kind === "ack").length, 1, "acknowledge once, never twice");
309
+ assert.equal(transcript.filter((m) => m.kind === "progress").length, 0, "and never a progress ping — the branch that emitted them is deleted");
257
310
  note("obligation discharged by receipt — the daemon added nothing on top of the session's own answer", virtual);
258
311
  });
259
312
 
@@ -275,7 +328,10 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
275
328
  dispatch: (_p, _it, _cr, _s, opts) => { onCloseHook = opts.onClose; },
276
329
  });
277
330
  const key = assurance.openObligations().find((o) => o.key === item.raw_ref).key;
278
- assert.ok(transcript.find((m) => m.kind === "ack"));
331
+ // Nothing has been said yet — the ask is 45 minutes old in VIRTUAL time only,
332
+ // and the sweep has not run. What matters for this scenario is that the debt
333
+ // exists, so the failure has somewhere to be reported from.
334
+ assert.equal(transcript.length, 0, "silence is the default; the failure notice below is the first thing this human hears");
279
335
 
280
336
  // 45 minutes in, the dispatcher's SIGTERM lands and the session closes 143.
281
337
  const virtual = { v: 45 * 60_000 };