@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.
- 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/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
|
@@ -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
|
+
});
|
package/lib/comms/receipts.mjs
CHANGED
|
@@ -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 :
|
|
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.
|
|
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,
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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. **
|
|
21
|
-
|
|
22
|
-
|
|
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. **
|
|
38
|
-
(
|
|
39
|
-
|
|
40
|
-
|
|
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)? →
|
|
58
|
-
|
|
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`):
|
|
63
|
-
|
|
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
|
|
72
|
+
## The one-interim rule
|
|
75
73
|
|
|
76
|
-
Whatever the outcome, the person hears from you in the turn the event
|
|
77
|
-
arrived.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
|
|
985
|
-
|
|
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.
|
|
2532
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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 ⇒
|
|
709
|
-
// (the assurance sweep owns the outcome; the human
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
238
|
-
//
|
|
239
|
-
let virtual =
|
|
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 =
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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 };
|