@cohortapp/agent-sdk 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.env.example +5 -2
  2. package/docs/guides/front-door-session.md +16 -5
  3. package/docs/guides/poller-daemon-setup.md +53 -2
  4. package/lib/assurance/plan-note.mjs +251 -0
  5. package/lib/assurance/plan-note.test.mjs +234 -0
  6. package/lib/assurance/room-budget.mjs +497 -0
  7. package/lib/assurance/room-budget.test.mjs +486 -0
  8. package/lib/assurance/tier.mjs +166 -0
  9. package/lib/assurance/tier.test.mjs +174 -0
  10. package/lib/comms/receipts.mjs +17 -1
  11. package/lib/context/budget.mjs +327 -0
  12. package/lib/context/budget.test.mjs +252 -0
  13. package/lib/context/history-scope.mjs +138 -0
  14. package/lib/context/history-scope.test.mjs +79 -0
  15. package/lib/model-router/economics.mjs +9 -0
  16. package/lib/model-router/resolve.mjs +6 -0
  17. package/lib/org/inbound/facts.mjs +4 -2
  18. package/lib/org/inbound/hydrate.mjs +555 -51
  19. package/lib/org/inbound/hydrate.test.mjs +456 -1
  20. package/package.json +3 -1
  21. package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
  22. package/plugins/maestro-skills/skills/main-session.md +6 -4
  23. package/scripts/daemon/agent-daemon.mjs +35 -7
  24. package/scripts/daemon/agent-daemon.test.mjs +23 -6
  25. package/scripts/daemon/assurance-e2e.test.mjs +75 -19
  26. package/scripts/daemon/assurance.mjs +663 -159
  27. package/scripts/daemon/assurance.test.mjs +820 -140
  28. package/scripts/daemon/context-compiler.mjs +52 -21
  29. package/scripts/daemon/context-compiler.test.mjs +106 -0
  30. package/scripts/daemon/deliver.mjs +7 -4
  31. package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
  32. package/scripts/daemon/dispatcher.mjs +210 -9
  33. package/scripts/daemon/lib/session-router.mjs +310 -42
  34. package/scripts/daemon/lib/session-router.test.mjs +260 -1
  35. package/scripts/daemon/prompt-builder.mjs +160 -16
  36. package/scripts/daemon/prompt-builder.test.mjs +287 -7
  37. package/scripts/daemon/responder-history.test.mjs +37 -1
  38. package/scripts/daemon/responder.mjs +79 -72
@@ -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 };