@cohortapp/agent-sdk 2.14.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.
- package/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +49 -1
- package/lib/assurance/plan-note.mjs +251 -0
- package/lib/assurance/plan-note.test.mjs +234 -0
- package/lib/assurance/room-budget.mjs +497 -0
- package/lib/assurance/room-budget.test.mjs +486 -0
- package/lib/assurance/tier.mjs +166 -0
- package/lib/assurance/tier.test.mjs +174 -0
- package/lib/comms/receipts.mjs +17 -1
- package/lib/telemetry/collect.mjs +21 -1
- package/lib/telemetry/collect.test.mjs +54 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
- package/plugins/maestro-skills/skills/main-session.md +6 -4
- package/scripts/daemon/agent-daemon.mjs +35 -7
- package/scripts/daemon/agent-daemon.test.mjs +23 -6
- package/scripts/daemon/assurance-e2e.test.mjs +75 -19
- package/scripts/daemon/assurance.mjs +663 -159
- package/scripts/daemon/assurance.test.mjs +820 -140
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/prompt-builder.mjs +63 -4
- package/scripts/daemon/prompt-builder.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +8 -3
|
@@ -466,9 +466,9 @@ async function sendGmailResponse(item, text) {
|
|
|
466
466
|
* and hq's server-side dedup swallows every message after the first, which
|
|
467
467
|
* would turn "never silent" into "acknowledged once and then silent forever".
|
|
468
468
|
*
|
|
469
|
-
* It must be distinct per MESSAGE, not per kind: two
|
|
470
|
-
* key on "
|
|
471
|
-
* emit a kind more than once pass an ordinal (`
|
|
469
|
+
* It must be distinct per MESSAGE, not per kind: two failure notices that both
|
|
470
|
+
* key on "failure" are one message as far as hq is concerned. Callers that can
|
|
471
|
+
* emit a kind more than once pass an ordinal (`failure-1`, `failure-2`). A
|
|
472
472
|
* genuine transport RETRY deliberately reuses the same suffix — that is the
|
|
473
473
|
* dedup doing its job.
|
|
474
474
|
*/
|
|
@@ -636,7 +636,10 @@ async function sendCohortEntityComment(route, text, o = {}) {
|
|
|
636
636
|
* @param {object} item daemon inbox item
|
|
637
637
|
* @param {string} text exactly what the human will read
|
|
638
638
|
* @param {object} [o]
|
|
639
|
-
* @param {string} [o.kind] receipt kind: "
|
|
639
|
+
* @param {string} [o.kind] receipt kind: "reply" (an ANSWER) or one of the
|
|
640
|
+
* courtesy kinds "ack" | "plan" | "notice" | "failure". Only "reply"
|
|
641
|
+
* discharges an obligation — see `lib/comms/receipts.NON_ANSWER_KINDS`.
|
|
642
|
+
* ("progress" is retired: nothing emits it since 2026-09-12.)
|
|
640
643
|
* @param {string[]} [o.mentions] org member ids to @-tag (cohort room sends only)
|
|
641
644
|
* @param {function} [o.fetchImpl] test seam
|
|
642
645
|
* @param {function} [o.callImpl] test seam — the org RPC (entity-thread routes)
|
|
@@ -836,11 +836,18 @@ function buildBacklogContext(queueItem) {
|
|
|
836
836
|
*
|
|
837
837
|
* @param {object} item - The inbox item (sender, content, channel, service, subject, thread_context, etc.)
|
|
838
838
|
* @param {object} classResult - Classifier output (priority, action, model, summary, category)
|
|
839
|
-
* @param {object} options - { type
|
|
839
|
+
* @param {object} options - { type, queueItem?, holdingMessage?, holdingSent?, interimAlreadySent? }
|
|
840
|
+
* `holdingMessage` the interim's TEXT, when this process composed it.
|
|
841
|
+
* `holdingSent` false ⇒ it was composed but did not land.
|
|
842
|
+
* `interimAlreadySent` true ⇒ an interim reached the sender but its text is
|
|
843
|
+
* not available here (the re-delivery / retry path,
|
|
844
|
+
* where the debt is already open with interimSaid). It
|
|
845
|
+
* is what stops the no-contact block asserting silence
|
|
846
|
+
* at a session whose human has already been spoken to.
|
|
840
847
|
* @returns {string} Prompt string ready for claude --print
|
|
841
848
|
*/
|
|
842
849
|
export async function buildPrompt(item, classResult, options = {}) {
|
|
843
|
-
const { type = "inbox", queueItem, holdingMessage, holdingSent } = options;
|
|
850
|
+
const { type = "inbox", queueItem, holdingMessage, holdingSent, interimAlreadySent } = options;
|
|
844
851
|
// Only `false` — an explicit "the send failed" from the caller — flips the
|
|
845
852
|
// framing. Callers that pass no flag keep the historical assertion, so this
|
|
846
853
|
// cannot silently downgrade a genuinely delivered acknowledgement.
|
|
@@ -895,16 +902,31 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
895
902
|
// 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
|
|
896
903
|
// This is the most critical instruction in the prompt: prevents double-replies.
|
|
897
904
|
// We repeat it at section 7a as well, immediately before the action block.
|
|
905
|
+
//
|
|
906
|
+
// WHEN EACH BRANCH IS REACHED (2026-09-12). The daemon opens the obligation
|
|
907
|
+
// and says NOTHING, so on the ordinary inbound path `holdingMessage` is null.
|
|
908
|
+
// The first two branches exist for the case where an interim genuinely did go
|
|
909
|
+
// out WITH ITS TEXT IN HAND before the prompt was built: the PLAN tier, whose
|
|
910
|
+
// one message is posted as the first step of the work. Do not delete them as
|
|
911
|
+
// dead; they are the correct rendering of a fact that is about to be common.
|
|
912
|
+
//
|
|
913
|
+
// The third branch is for an interim that went out whose TEXT THIS PROCESS
|
|
914
|
+
// DOES NOT HAVE — a re-delivery of an item whose debt is already open with
|
|
915
|
+
// `interimSaid: true`, which `openAndAcknowledge` answers with
|
|
916
|
+
// `{acked:true, ackText:null}`. Before it existed, that case fell into FIRST
|
|
917
|
+
// CONTACT and told a RETRY session that a human who had already received a
|
|
918
|
+
// holding line AND a "Hit a problem — … Retrying now" had heard nothing.
|
|
898
919
|
if (holdingMessage && holdingDelivered) {
|
|
899
920
|
parts.push("===== STOP — READ THIS FIRST =====");
|
|
900
921
|
parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
|
|
901
922
|
parts.push(` "${holdingMessage}"`);
|
|
902
923
|
parts.push("");
|
|
924
|
+
parts.push("That was the ONE interim this ask gets. The sender will hear nothing else until you deliver something substantive, and nothing in the daemon will fill the gap — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
|
|
903
925
|
parts.push("Your job is to deliver the FULL substantive response or complete the actual work — NOT to acknowledge again.");
|
|
904
926
|
parts.push("- Do NOT start your reply with 'Got it', 'On it', 'Looking into this', 'Will get back to you', 'Thanks for reaching out', or any similar acknowledgment phrase.");
|
|
905
927
|
parts.push("- Do NOT echo what the user asked — they already received the holding note confirming receipt.");
|
|
906
928
|
parts.push("- Open with the actual answer, the actual draft, or the actual finding. Be direct.");
|
|
907
|
-
parts.push("- If
|
|
929
|
+
parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the user. Never 'still looking into it', never a status line whose only content is how long it has been.");
|
|
908
930
|
parts.push("===== END WARNING =====");
|
|
909
931
|
parts.push("");
|
|
910
932
|
} else if (holdingMessage) {
|
|
@@ -926,6 +948,40 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
926
948
|
parts.push("- If you do NOT, do not let the item evaporate. Record the undeliverable ask and escalate it to the operator — silence plus a closed inbox item is how a request disappears.");
|
|
927
949
|
parts.push("===== END WARNING =====");
|
|
928
950
|
parts.push("");
|
|
951
|
+
} else if (interimAlreadySent && type === "inbox" && item) {
|
|
952
|
+
// AN INTERIM WENT OUT AND THIS PROCESS CANNOT QUOTE IT. See the note above:
|
|
953
|
+
// this is the re-delivery / retry path. What it must NOT say is "the sender
|
|
954
|
+
// has heard nothing", which is the specific falsehood the FIRST CONTACT
|
|
955
|
+
// block would assert here.
|
|
956
|
+
parts.push("===== ALREADY IN CONTACT =====");
|
|
957
|
+
parts.push("A short interim about this item has already reached the sender. This process no longer holds its text, so do not quote it or guess at it.");
|
|
958
|
+
parts.push("That was the ONE interim this ask gets. Nothing else will be sent on your behalf — the timed progress updates were deleted on 2026-09-12 because a message derived from elapsed minutes carries no information.");
|
|
959
|
+
parts.push("- Deliver the substantive response. Do NOT acknowledge receipt again.");
|
|
960
|
+
parts.push("- Do not apologise for the delay and do not recap what they asked.");
|
|
961
|
+
parts.push("- If the work runs long, say nothing OR say something with CONTENT in it — a specific blocker, a partial finding, what you need from the sender.");
|
|
962
|
+
parts.push("===== END =====");
|
|
963
|
+
parts.push("");
|
|
964
|
+
} else if (type === "inbox" && item) {
|
|
965
|
+
// NOTHING HAS BEEN SENT, AND THAT IS THE DEFAULT. Gated on `inbox` because a
|
|
966
|
+
// backlog item is work the agent gave itself — there is no sender on the
|
|
967
|
+
// other end of it and this block would be addressed to nobody.
|
|
968
|
+
//
|
|
969
|
+
// WHAT THIS BLOCK MAY AND MAY NOT CLAIM. It is built at dispatch time, t≈0.
|
|
970
|
+
// It can state a FACT about the past — nothing has gone out yet — and it
|
|
971
|
+
// must not state a PREDICTION about the future, which is what "your reply is
|
|
972
|
+
// the first thing they will read" was: measured p50 session duration is
|
|
973
|
+
// 14.7 min against an ACK_AFTER_MS of 90 s, so for most sessions the sweep
|
|
974
|
+
// will in fact have posted one line before the reply lands. A prompt that
|
|
975
|
+
// tells a session it is the first voice in the room, when a holding line
|
|
976
|
+
// lands in front of it, produces exactly the double-contact the first two
|
|
977
|
+
// blocks exist to prevent — in the other direction.
|
|
978
|
+
parts.push("===== NO CONTACT YET =====");
|
|
979
|
+
parts.push("Nothing has been sent to the sender about this item so far. If this runs long, the daemon may post at most ONE short holding line before your reply — you will not be told if it does, and it is the only one this ask gets.");
|
|
980
|
+
parts.push("- Open with the answer, the draft or the finding. Do not open by acknowledging receipt, and do not apologise for the delay.");
|
|
981
|
+
parts.push("- Do not write as though a conversation is already underway — no 'as I mentioned', no 'following up on my earlier note'.");
|
|
982
|
+
parts.push("- If you cannot deliver something substantive, say the specific thing that is blocking you. Never a bare 'looking into it'.");
|
|
983
|
+
parts.push("===== END =====");
|
|
984
|
+
parts.push("");
|
|
929
985
|
}
|
|
930
986
|
|
|
931
987
|
// 2. Session context
|
|
@@ -987,11 +1043,14 @@ export async function buildPrompt(item, classResult, options = {}) {
|
|
|
987
1043
|
// If a holding message was already sent, prepend a second reminder so the
|
|
988
1044
|
// action block is unambiguous about not re-acknowledging.
|
|
989
1045
|
if (holdingMessage && holdingDelivered) {
|
|
990
|
-
parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
|
|
1046
|
+
parts.push("REMINDER: A holding message was already sent (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
|
|
991
1047
|
parts.push("");
|
|
992
1048
|
} else if (holdingMessage) {
|
|
993
1049
|
parts.push("REMINDER: The acknowledgement for this item FAILED to send (see top of prompt) — the sender has heard nothing. The action below describes WHAT to do; carry it out knowing this is still first contact, and escalate rather than close the item silently if you cannot reach them.");
|
|
994
1050
|
parts.push("");
|
|
1051
|
+
} else if (interimAlreadySent) {
|
|
1052
|
+
parts.push("REMINDER: An interim about this item already reached the sender (see top of prompt), and it was the only one this ask gets. The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
|
|
1053
|
+
parts.push("");
|
|
995
1054
|
}
|
|
996
1055
|
parts.push(actionBlock);
|
|
997
1056
|
parts.push("");
|
|
@@ -267,6 +267,74 @@ test("buildPrompt does NOT claim delivery when the acknowledgement failed to sen
|
|
|
267
267
|
assert.ok(prompt.includes("Understood — I'm digging into this now (the noted issues). I want to get this right, so I'll come back to you here with a full answer — usually within 10-20 minutes — and you'll hear from me either way."));
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// THE THIRD AND FOURTH STATES: an interim whose text this process does not
|
|
272
|
+
// hold, and no contact at all. Both were one block before 2026-09-12, and that
|
|
273
|
+
// block asserted a fact that is false on most dispatches.
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
test("REGRESSION: a retry session is NOT told the sender has heard nothing", async () => {
|
|
277
|
+
// The reachable-today path. An item re-delivered while its debt is open with
|
|
278
|
+
// interimSaid:true makes openAndAcknowledge return {acked:true, ackText:null}:
|
|
279
|
+
// holdingMessage is null, so both holding blocks are skipped, and before this
|
|
280
|
+
// fix the no-contact block rendered — telling a RETRY session that a human who
|
|
281
|
+
// had already received a holding line AND a "Hit a problem — … Retrying now"
|
|
282
|
+
// had heard nothing. On main this path produced no block at all, so the false
|
|
283
|
+
// claim was introduced by the acknowledgement-discipline change itself.
|
|
284
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox", interimAlreadySent: true });
|
|
285
|
+
assert.match(prompt, /ALREADY IN CONTACT/);
|
|
286
|
+
assert.match(prompt, /already reached the sender/);
|
|
287
|
+
assert.ok(!/heard nothing/.test(prompt), "must not assert silence at a sender who has been spoken to");
|
|
288
|
+
assert.ok(!/NO CONTACT YET/.test(prompt), "the no-contact block must not render alongside it");
|
|
289
|
+
// It must not invent the text it does not have.
|
|
290
|
+
assert.match(prompt, /do not quote it or guess at it/);
|
|
291
|
+
// And the second reminder, immediately before the action block, agrees.
|
|
292
|
+
assert.match(prompt, /REMINDER: An interim about this item already reached the sender/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("the no-contact block states a FACT about the past, not a PREDICTION about the future", async () => {
|
|
296
|
+
// What it said before: "your reply is the first thing they will read". That is
|
|
297
|
+
// false on the majority of dispatches — the sweep posts one interim at
|
|
298
|
+
// ACK_AFTER_MS (90 s) while measured p50 session duration is 14.7 min — and a
|
|
299
|
+
// session told it is the first voice in the room, with a holding line landing
|
|
300
|
+
// in front of it, produces exactly the double-contact the delivered-holding
|
|
301
|
+
// block exists to prevent, in the other direction.
|
|
302
|
+
const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
|
|
303
|
+
assert.match(prompt, /NO CONTACT YET/);
|
|
304
|
+
assert.match(prompt, /Nothing has been sent to the sender about this item so far/);
|
|
305
|
+
assert.ok(
|
|
306
|
+
!/first thing they will read/.test(prompt),
|
|
307
|
+
"the prompt may not predict that no interim will land before the reply",
|
|
308
|
+
);
|
|
309
|
+
// It must say the opposite instead: one may yet go out, silently.
|
|
310
|
+
assert.match(prompt, /may post at most ONE short holding line/);
|
|
311
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a delivered holding message outranks the interim-already-sent flag", async () => {
|
|
315
|
+
// When the text IS in hand, quoting it is strictly better than saying it
|
|
316
|
+
// exists — so the ordering of the branches is pinned rather than incidental.
|
|
317
|
+
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
318
|
+
type: "inbox",
|
|
319
|
+
holdingMessage: "Give me a few minutes on the July reconciliation — I'll have the variances.",
|
|
320
|
+
holdingSent: true,
|
|
321
|
+
interimAlreadySent: true,
|
|
322
|
+
});
|
|
323
|
+
assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
|
|
324
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
325
|
+
assert.ok(!/NO CONTACT YET/.test(prompt));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("neither block reaches a backlog item — there is no sender on the other end", async () => {
|
|
329
|
+
const prompt = await buildPrompt(null, CLASS, {
|
|
330
|
+
type: "backlog",
|
|
331
|
+
queueItem: { id: "q-1", title: "Sweep the stale worktrees", status: "open" },
|
|
332
|
+
interimAlreadySent: true,
|
|
333
|
+
});
|
|
334
|
+
assert.ok(!/ALREADY IN CONTACT/.test(prompt));
|
|
335
|
+
assert.ok(!/NO CONTACT YET/.test(prompt));
|
|
336
|
+
});
|
|
337
|
+
|
|
270
338
|
test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)", async () => {
|
|
271
339
|
const prompt = await buildPrompt(ITEM, CLASS, {
|
|
272
340
|
type: "inbox",
|
|
@@ -993,9 +993,14 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
993
993
|
* via the cheapest model under a tight (~5s, env-tunable) cap, and on ANY
|
|
994
994
|
* generation failure this function sends NOTHING and says so in the log. The
|
|
995
995
|
* durability story is unchanged — the caller opened the obligation before
|
|
996
|
-
* calling this, the assurance sweep
|
|
997
|
-
*
|
|
998
|
-
*
|
|
996
|
+
* calling this, and the assurance sweep owns whether a line goes out at all.
|
|
997
|
+
*
|
|
998
|
+
* NOTE (2026-09-12): the daemon no longer calls this from the acknowledgement
|
|
999
|
+
* path. `assurance.openAndAcknowledge` says NOTHING at open time; the sweep
|
|
1000
|
+
* emits at most one interim per obligation, past ASSURANCE_ACK_AFTER_MS and
|
|
1001
|
+
* inside the per-room budget. The timed progress updates are deleted. The
|
|
1002
|
+
* failure, stale and interrupted notices still guarantee the human is never
|
|
1003
|
+
* left in long-term silence.
|
|
999
1004
|
*
|
|
1000
1005
|
* @returns {{ sent: boolean, holdingText: string|null }} result
|
|
1001
1006
|
*/
|