@cohortapp/agent-sdk 2.4.0 → 2.5.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 (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * scripts/daemon/execution-ladder.test.mjs — the daemon actually ENTERS
3
+ * lib/execution/**.
4
+ *
5
+ * Run: node --test scripts/daemon/execution-ladder.test.mjs
6
+ *
7
+ * `lib/execution/**` had exactly one production importer (lib/goals/loop.mjs,
8
+ * for `routeRung`); `processItem` went from ITEM_RECEIVED straight to the Haiku
9
+ * classifier and never entered the ladder. These tests drive the daemon's OWN
10
+ * entry point — `processItem` — with the network/subprocess collaborators
11
+ * injected, and assert on what lands in `state/execution/journal.jsonl`,
12
+ * `state/queues/inbound.yaml` and the fakes. Nothing about intake, match, route,
13
+ * disposition, drive or journal is faked.
14
+ *
15
+ * The two regression tests at the bottom encode bugs a live run of this wiring
16
+ * actually produced:
17
+ * - with no `need` estimate every DM routed to rung 3, which vetoed the quick
18
+ * reply and turned a 4-second answer into a spawned Claude session;
19
+ * - with the rule-based directedness override applied to ALL ignore reasons,
20
+ * a DM could never be `duplicate` (a DM is always "directed"), so the agent
21
+ * answered every redelivery twice.
22
+ */
23
+ import { test, before, beforeEach } from "node:test";
24
+ import assert from "node:assert/strict";
25
+ import { register } from "node:module";
26
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ // --- stub the agent-only `imapflow` dep so the daemon module can be imported ---
31
+ register(
32
+ "data:text/javascript," +
33
+ encodeURIComponent(`
34
+ export async function resolve(s, c, next) {
35
+ if (s === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
36
+ return next(s, c);
37
+ }
38
+ export async function load(u, c, next) {
39
+ if (u === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
40
+ return next(u, c);
41
+ }
42
+ `),
43
+ );
44
+
45
+ const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-exec-ladder-"));
46
+ process.env.AGENT_DIR = AGENT_DIR;
47
+ process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
48
+ process.env.COHORT_AGENT_ID = "mem_self_0001";
49
+ process.env.ANTHROPIC_API_KEY = "";
50
+
51
+ const PLAN = `version: 1
52
+ enforcement: enforce
53
+ obligations:
54
+ - key: react.message.dm
55
+ kind: REACT
56
+ status: active
57
+ trigger: { topic: message, kind: null, predicate: null }
58
+ uses: []
59
+ allowed_tools: []
60
+ action_classes: []
61
+ offline_safe: true
62
+ - key: react.task.assigned
63
+ kind: REACT
64
+ status: active
65
+ trigger: { topic: task, kind: assigned, predicate: null }
66
+ uses: []
67
+ allowed_tools: []
68
+ action_classes: []
69
+ offline_safe: true
70
+ `;
71
+
72
+ let daemon;
73
+ let responder;
74
+
75
+ before(async () => {
76
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
77
+ writeFileSync(
78
+ join(AGENT_DIR, "config", "agent.json"),
79
+ JSON.stringify({ firstName: "Nova", lastName: "Vale", slackMemberId: "U09NOVA" }),
80
+ );
81
+ writeFileSync(join(AGENT_DIR, "config", "plan.yaml"), PLAN);
82
+ daemon = await import("./agent-daemon.mjs");
83
+ responder = await import("./responder.mjs");
84
+ });
85
+
86
+ beforeEach(() => {
87
+ for (const rel of [["state", "execution"], ["state", "queues"], ["state", "inbox"], ["state", "locks"], ["logs", "daemon"]]) {
88
+ rmSync(join(AGENT_DIR, ...rel), { recursive: true, force: true });
89
+ }
90
+ if (daemon) daemon._resetLadderCaches();
91
+ delete process.env.DAEMON_EXECUTION_LADDER;
92
+ });
93
+
94
+ // --- helpers ---------------------------------------------------------------
95
+
96
+ function writeLive(item, service) {
97
+ const dir = join(AGENT_DIR, "state", "inbox", service);
98
+ mkdirSync(dir, { recursive: true });
99
+ writeFileSync(join(dir, `${item.id}.json`), JSON.stringify(item));
100
+ }
101
+
102
+ /** Fakes for exactly the network / subprocess boundary. Nothing else. */
103
+ function spy(classResult) {
104
+ const seen = { classify: 0, quick: [], dispatch: [], holding: 0 };
105
+ return {
106
+ seen,
107
+ deps: {
108
+ classify: async () => {
109
+ seen.classify += 1;
110
+ return classResult;
111
+ },
112
+ sendQuickResponse: async (item, _cr, routed) => {
113
+ seen.quick.push({ id: item.id, rung: routed ? routed.rung : null });
114
+ return { sent: true, text: "ack", via: "fake" };
115
+ },
116
+ sendHoldingMessage: async () => {
117
+ seen.holding += 1;
118
+ return { sent: false, holdingText: null };
119
+ },
120
+ dispatch: (_p, item, _cr, _s, opts) => {
121
+ seen.dispatch.push({ id: item.id, execution: item.execution || null });
122
+ opts.onClose({ ok: true, code: 0 });
123
+ },
124
+ },
125
+ };
126
+ }
127
+
128
+ function journal() {
129
+ const p = join(AGENT_DIR, "state", "execution", "journal.jsonl");
130
+ if (!existsSync(p)) return [];
131
+ return readFileSync(p, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
132
+ }
133
+
134
+ const decisions = () => journal().filter((r) => r.event === "decision");
135
+ const outcomes = () => journal().filter((r) => r.event === "outcome");
136
+
137
+ function dm(over = {}) {
138
+ return {
139
+ id: "MSG-DM",
140
+ raw_ref: "slack:D77:1",
141
+ channel: "dm/priya",
142
+ channel_id: "D77",
143
+ sender: "Priya Raman",
144
+ sender_id: "U_PRIYA",
145
+ content: "can you confirm the deadline?",
146
+ timestamp: new Date().toISOString(),
147
+ ...over,
148
+ };
149
+ }
150
+
151
+ const RESPOND = {
152
+ priority: "normal",
153
+ action: "respond",
154
+ model: "sonnet",
155
+ summary: "deadline question",
156
+ category: "action_required",
157
+ directed_at_agent: true,
158
+ };
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // 1) A DM goes THROUGH the ladder — and is still answered exactly as before.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ test("LADDER: a DM is journalled intake→match→route→outcome AND still quick-replies", async () => {
165
+ const item = dm();
166
+ writeLive(item, "slack");
167
+ const s = spy(RESPOND);
168
+ await daemon.processItem(item, "slack", s.deps);
169
+
170
+ const d = decisions();
171
+ assert.equal(d.length, 1, "one decision row");
172
+ assert.equal(d[0].surface, "dm");
173
+ assert.equal(d[0].disposition, "react_now");
174
+ assert.equal(d[0].reason, "directed_now");
175
+ assert.equal(d[0].obligationKey, "react.message.dm", "match bound the DM to the plan's REACT obligation");
176
+ assert.equal(d[0].rung, 0, "route chose rung 0 — one protocol method with the params in hand");
177
+ assert.equal(d[0].mechanism, "tool-surface");
178
+ assert.ok(d[0].why.some((w) => w.includes("single method messaging.send")), "the why records the rung reasoning");
179
+
180
+ const o = outcomes();
181
+ assert.equal(o.length, 1);
182
+ assert.equal(o[0].effect, "react", "the react effect ran");
183
+ assert.equal(o[0].ok, true);
184
+ assert.equal(o[0].spoke, true, "the agent spoke, so the ping-pong chain deepens");
185
+ assert.equal(o[0].ref.path, "quick_reply", "the effect drove the daemon's real quick-reply path");
186
+
187
+ // NO REGRESSION: the classifier ran and the reply was sent.
188
+ assert.equal(s.seen.classify, 1, "the Haiku classifier still ran for a directed DM");
189
+ assert.deepEqual(s.seen.quick, [{ id: "MSG-DM", rung: 0 }], "the quick reply was sent, carrying its rung");
190
+ assert.equal(s.seen.dispatch.length, 0, "no session spawned for a rung-0 reply");
191
+ });
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // 2) A board task is WORK: it queues, and never reaches the classifier.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ test("LADDER: an assigned board task is queued to state/queues/inbound.yaml, not answered", async () => {
198
+ const task = {
199
+ id: "cohort-task_assigned-149",
200
+ raw_ref: "cohort:task_assigned:cmql3j5j301jwhhv2ipp8gmir:149",
201
+ service: "cohort",
202
+ kind: "task_assigned",
203
+ sender: "Priya Raman",
204
+ sender_id: "mem_priya_0002",
205
+ content: "Ship the Q3 revenue reconciliation",
206
+ channel_id: "board_main",
207
+ timestamp: new Date().toISOString(),
208
+ };
209
+ writeLive(task, "cohort");
210
+ const s = spy(RESPOND);
211
+ await daemon.processItem(task, "cohort", s.deps);
212
+
213
+ const d = decisions()[0];
214
+ assert.equal(d.surface, "task_assigned");
215
+ assert.equal(d.disposition, "schedule");
216
+ assert.equal(d.reason, "not_respondable", "a board assignment is work, not conversation");
217
+ assert.equal(d.obligationKey, "react.task.assigned");
218
+ assert.equal(d.rung, 3, "no lower predicate holds for open work → a bounded session");
219
+
220
+ const o = outcomes()[0];
221
+ assert.equal(o.effect, "schedule");
222
+ assert.equal(o.ok, true);
223
+
224
+ // The queue row is real, and it is the file `sweepBacklog` already reads.
225
+ const q = readFileSync(join(AGENT_DIR, "state", "queues", "inbound.yaml"), "utf-8");
226
+ assert.match(q, /id: "inb-board\.task_assigned#149"/);
227
+ assert.match(q, /rung: 3/);
228
+ assert.match(q, /obligation_key: "react\.task\.assigned"/);
229
+ assert.match(q, /why: ".*not a conversational surface/, "the reasoning travels with the work");
230
+
231
+ assert.equal(s.seen.classify, 0, "no Haiku call — the ladder placed it without one");
232
+ assert.equal(s.seen.quick.length, 0);
233
+ assert.equal(s.seen.dispatch.length, 0);
234
+ });
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // 3) `ignore` is first-class: journalled, cheap, and it does not call Haiku.
238
+ // ---------------------------------------------------------------------------
239
+
240
+ test("LADDER: ambient channel chatter is IGNORED, with a journalled reason, and costs no classifier call", async () => {
241
+ const ambient = {
242
+ id: "MSG-AMBIENT",
243
+ raw_ref: "slack:C90:1",
244
+ channel: "#engineering",
245
+ channel_id: "C90",
246
+ sender: "Tomas Lind",
247
+ sender_id: "U_TOMAS",
248
+ content: "lunch anyone",
249
+ timestamp: new Date().toISOString(),
250
+ };
251
+ writeLive(ambient, "slack");
252
+ const s = spy(RESPOND);
253
+ await daemon.processItem(ambient, "slack", s.deps);
254
+
255
+ const d = decisions()[0];
256
+ assert.equal(d.disposition, "ignore");
257
+ assert.equal(d.reason, "ambient_channel");
258
+ assert.ok(d.why.some((w) => w.includes("park for the ambient sweep")), "the ignore explains itself");
259
+
260
+ const o = outcomes()[0];
261
+ assert.equal(o.effect, "none", "an ignore is a completed decision with no effect");
262
+ assert.equal(o.ok, true, "an ignore is a SUCCESS, not a failure");
263
+ assert.equal(o.spoke, false);
264
+
265
+ assert.equal(s.seen.classify, 0, "an ambient message no longer costs a Haiku call");
266
+ assert.equal(s.seen.quick.length, 0);
267
+ assert.equal(s.seen.dispatch.length, 0);
268
+
269
+ // It is finished with, not left to be re-polled forever.
270
+ const files = readdirSync(join(AGENT_DIR, "state", "inbox", "slack"));
271
+ assert.deepEqual(files, ["MSG-AMBIENT.json.processed"]);
272
+ });
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // 4) REQUIREMENT: an event no obligation covers still reaches today's path.
276
+ // ---------------------------------------------------------------------------
277
+
278
+ test("LADDER: an event no obligation covers is still answered, capped at rung ≤1, and proposes the missing obligation", async () => {
279
+ rmSync(join(AGENT_DIR, "config", "plan.yaml"), { force: true });
280
+ daemon._resetLadderCaches();
281
+ try {
282
+ const item = dm({ id: "MSG-DM-UNCOVERED", raw_ref: "slack:D78:1", channel_id: "D78" });
283
+ writeLive(item, "slack");
284
+ const s = spy(RESPOND);
285
+ await daemon.processItem(item, "slack", s.deps);
286
+
287
+ const d = decisions()[0];
288
+ assert.equal(d.disposition, "react_now", "an uncovered event is still answered");
289
+ assert.equal(d.obligationKey, null, "…but it is recorded as uncovered");
290
+ assert.ok(d.rung <= 1, `uncovered work is capped at rung ≤1, got ${d.rung}`);
291
+ assert.ok(
292
+ d.why.some((w) => w.includes("no obligation in the compiled plan covers this event")),
293
+ "the plan gap is stated in the reasoning",
294
+ );
295
+ assert.equal(s.seen.quick.length, 1, "the sender still got an answer");
296
+
297
+ // §6.1's other half: the gap becomes a proposal, not just a complaint.
298
+ const proposed = join(AGENT_DIR, "state", "plan", "proposed.jsonl");
299
+ assert.ok(existsSync(proposed), "an obligation was proposed to cover the gap");
300
+ assert.match(readFileSync(proposed, "utf-8"), /react\.message\./);
301
+ } finally {
302
+ writeFileSync(join(AGENT_DIR, "config", "plan.yaml"), PLAN);
303
+ daemon._resetLadderCaches();
304
+ }
305
+ });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // 5) REGRESSION (live run): the rule override must not defeat the GUARDS.
309
+ // ---------------------------------------------------------------------------
310
+
311
+ test("LADDER REGRESSION: a redelivered DM is dropped as `duplicate` and is NOT answered twice", async () => {
312
+ const item = dm({ id: "MSG-DM-DUP", raw_ref: "slack:D79:1", channel_id: "D79" });
313
+ writeLive(item, "slack");
314
+
315
+ const first = spy(RESPOND);
316
+ await daemon.processItem(item, "slack", first.deps);
317
+ assert.equal(first.seen.quick.length, 1, "the first delivery is answered");
318
+
319
+ // The SSE push and the poll backstop converge — that convergence is the design.
320
+ const again = { ...item };
321
+ writeLive(again, "slack");
322
+ const second = spy(RESPOND);
323
+ await daemon.processItem(again, "slack", second.deps);
324
+
325
+ const d = decisions();
326
+ assert.equal(d.length, 2);
327
+ assert.equal(d[1].disposition, "ignore");
328
+ assert.equal(d[1].reason, "duplicate", "the dedupe guard fired");
329
+
330
+ // The bug this encodes: `isDirectedAtAgent` returns true for ANY DM, so
331
+ // consulting it on every ignore reason made `duplicate` unreachable and the
332
+ // agent answered both deliveries.
333
+ assert.equal(second.seen.quick.length, 0, "the redelivery was NOT answered");
334
+ assert.equal(second.seen.classify, 0, "…and did not even reach the classifier");
335
+ });
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // 6) …but a directedness MISS must still fall through. Silence is the expensive
339
+ // failure, so the ladder never has the last word on "is this mine".
340
+ // ---------------------------------------------------------------------------
341
+
342
+ test("LADDER: a directedness disagreement falls through to the classifier path rather than gagging the agent", async () => {
343
+ // Ambient by intake's rules (no mention flag, not a DM, not a thread reply),
344
+ // but the agent's own name is in the text — which `isDirectedAtAgent` catches
345
+ // and intake does not.
346
+ const item = {
347
+ id: "MSG-NAMED",
348
+ raw_ref: "slack:C91:1",
349
+ channel: "#engineering",
350
+ channel_id: "C91",
351
+ sender: "Tomas Lind",
352
+ sender_id: "U_TOMAS",
353
+ content: "nova can you take the reconciliation?",
354
+ timestamp: new Date().toISOString(),
355
+ };
356
+ writeLive(item, "slack");
357
+ const s = spy(RESPOND);
358
+ const gate = await daemon.runExecutionLadder(item, "slack", item.raw_ref, "trace-x", s.deps);
359
+
360
+ assert.equal(gate.handled, false, "the ladder did not take responsibility");
361
+ assert.equal(gate.reason, "rule_override");
362
+ assert.equal(decisions()[0].disposition, "ignore", "the ignore is still journalled — the disagreement is on record");
363
+ });
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // 7) The rung is a VETO on the quick path, never a promotion.
367
+ // ---------------------------------------------------------------------------
368
+
369
+ test("RUNG: the quick-reply responder accepts rungs 0-1 and refuses 2+", async () => {
370
+ assert.equal(responder.rungPermitsQuickReply(null), true, "no routing decision → today's behaviour");
371
+ assert.equal(responder.rungPermitsQuickReply({ rung: 0 }), true);
372
+ assert.equal(responder.rungPermitsQuickReply({ rung: 1 }), true);
373
+ assert.equal(responder.rungPermitsQuickReply({ rung: 2 }), false);
374
+ assert.equal(responder.rungPermitsQuickReply({ rung: 3 }), false);
375
+ assert.equal(responder.rungPermitsQuickReply({ rung: 5 }), false, "no subagent-fanout mechanism exists here");
376
+
377
+ // A veto, not a promotion: rung 0 does not make research quick-repliable.
378
+ assert.equal(responder.isQuickReply({ action: "research", priority: "normal", model: "sonnet" }, { rung: 0 }), false);
379
+ assert.equal(responder.isQuickReply({ action: "respond", priority: "normal", model: "sonnet" }, { rung: 0 }), true);
380
+ assert.equal(
381
+ responder.isQuickReply({ action: "respond", priority: "normal", model: "sonnet" }, { rung: 3 }),
382
+ false,
383
+ "a rung-3 answer needs a session even when the classifier called it simple",
384
+ );
385
+ });
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // 8) REGRESSION (live run): with no need estimate every DM routed to rung 3,
389
+ // which vetoed the quick reply and spawned a session for a two-line message.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ test("LADDER REGRESSION: the need estimate marks a resolvable reply target as params-known", () => {
393
+ assert.equal(daemon.ladderNeed({ channel_id: "D1" }).paramsKnown, true, "a resolvable channel → rung 0 is available");
394
+ assert.equal(daemon.ladderNeed({ raw_ref: "cohort:dm:x:1" }).paramsKnown, true);
395
+ assert.equal(
396
+ daemon.ladderNeed({ sender: "someone" }).paramsKnown,
397
+ false,
398
+ "no resolvable target → params are NOT known and the ladder walks up to a session that can find it",
399
+ );
400
+ assert.equal(daemon.ladderNeed({ channel_id: "D1" }).steps, 1);
401
+ });
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // 9) The escape hatch preserves the old behaviour exactly — and says so.
405
+ // ---------------------------------------------------------------------------
406
+
407
+ test("LADDER: DAEMON_EXECUTION_LADDER=0 restores the pre-ladder path and warns about what goes dark", async () => {
408
+ process.env.DAEMON_EXECUTION_LADDER = "0";
409
+ daemon._resetLadderCaches();
410
+ const warnings = [];
411
+ const realWarn = console.warn;
412
+ console.warn = (...a) => warnings.push(a.join(" "));
413
+ try {
414
+ const item = dm({ id: "MSG-DM-OFF", raw_ref: "slack:D80:1", channel_id: "D80" });
415
+ writeLive(item, "slack");
416
+ const s = spy(RESPOND);
417
+ await daemon.processItem(item, "slack", s.deps);
418
+
419
+ assert.equal(journal().length, 0, "nothing is journalled with the ladder off");
420
+ assert.equal(s.seen.classify, 1, "the classifier path is untouched");
421
+ assert.equal(s.seen.quick.length, 1, "the DM is still answered");
422
+ assert.ok(
423
+ warnings.some((w) => w.includes("DAEMON_EXECUTION_LADDER=0") && w.includes("no record of what was ignored")),
424
+ "turning it off is never silent",
425
+ );
426
+ } finally {
427
+ console.warn = realWarn;
428
+ delete process.env.DAEMON_EXECUTION_LADDER;
429
+ }
430
+ });
@@ -241,3 +241,72 @@ test("the guard never throws, whatever it is handed", async () => {
241
241
  assert.equal(typeof r.ok, "boolean");
242
242
  assert.equal(r.cadence, "goal-steward");
243
243
  });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // THE SENSOR REGISTRY — the joint that made every KPI unmeasurable
247
+ // ---------------------------------------------------------------------------
248
+
249
+ test("the guard BUILDS the sensor registry and hands it to the loop", async () => {
250
+ const root = makeRoot();
251
+ try {
252
+ let got = null;
253
+ await guardGoalSteward(
254
+ { event: { cadence: "goal-steward" }, agentRoot: root, log: () => {} },
255
+ seams({ runImpl: async (deps) => { got = deps; return { ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }; } }),
256
+ );
257
+ assert.ok(got, "the loop ran");
258
+ assert.equal(typeof got.sensors, "object", "run() must receive a sensor registry");
259
+ // Real, executable sensors — not an empty object. Without this every
260
+ // objective returns {ok:false,"no-implementation"} and D3 can never admit.
261
+ assert.equal(typeof got.sensors.board_ready, "function");
262
+ assert.equal(typeof got.sensors.decision_list, "function");
263
+ assert.equal(typeof got.sensors.email_inbox, "function");
264
+ // …including a named refusal for every capability the derivation can pick.
265
+ assert.equal(typeof got.sensors.task_update, "function");
266
+ } finally { cleanup(root); }
267
+ });
268
+
269
+ test("a missing capability manifest passes reachable=undefined, NOT an empty set", async () => {
270
+ const root = makeRoot();
271
+ try {
272
+ let got = null;
273
+ await guardGoalSteward(
274
+ { event: { cadence: "goal-steward" }, agentRoot: root, log: () => {} },
275
+ seams({ runImpl: async (deps) => { got = deps; return { ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }; } }),
276
+ );
277
+ // measureKpi only enforces reachability when it is defined; an empty Set
278
+ // here would silently refuse every objective in the fleet.
279
+ assert.equal(got.reachable, undefined);
280
+ } finally { cleanup(root); }
281
+ });
282
+
283
+ test("the caller's sensors win — the registry is an override, not a mandate", async () => {
284
+ const root = makeRoot();
285
+ try {
286
+ let got = null;
287
+ const mine = { board_ready: async () => ({ value: 1 }) };
288
+ await guardGoalSteward(
289
+ { event: { cadence: "goal-steward" }, agentRoot: root, log: () => {} },
290
+ seams({ sensors: mine, reachable: new Set(["board_ready"]),
291
+ runImpl: async (deps) => { got = deps; return { ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }; } }),
292
+ );
293
+ assert.equal(got.sensors, mine);
294
+ assert.ok(got.reachable.has("board_ready"));
295
+ } finally { cleanup(root); }
296
+ });
297
+
298
+ test("a registry that cannot be built is LOUD, and the loop still runs", async () => {
299
+ const root = makeRoot();
300
+ const logs = [];
301
+ try {
302
+ let got = null;
303
+ await guardGoalSteward(
304
+ { event: { cadence: "goal-steward" }, agentRoot: root, log: (l, m) => logs.push(`${l}:${m}`) },
305
+ seams({
306
+ // A poisoned root: resolveAgentRoot of a non-string throws inside the builder.
307
+ runImpl: async (deps) => { got = deps; return { ok: true, measured: [], admitted: [], rejected: [], escalations: [], drift: [], decisions: [] }; },
308
+ }),
309
+ );
310
+ assert.ok(got, "the guard never blocks the loop on a registry failure");
311
+ } finally { cleanup(root); }
312
+ });
@@ -227,6 +227,59 @@ try {
227
227
  console.warn(`[DAEMON] org-mesh init failed (running standalone): ${err.message}`);
228
228
  }
229
229
 
230
+ // ---------------------------------------------------------------------------
231
+ // 3b''. Org PUSH channel (delivery)
232
+ // ---------------------------------------------------------------------------
233
+ // Time-to-notice, not a new ingestion route.
234
+ //
235
+ // Until now the ONLY way an org event reached this daemon was the 45s
236
+ // `messaging-inbound` cadence, so a human who @mentions an AI colleague waited
237
+ // up to three quarters of a minute — and hq had already gone quiet on their
238
+ // behalf (it suppresses its own in-process responder for any member whose daemon
239
+ // beat within 100s, hq 4d762d30). hq ships the delivery half at
240
+ // `GET /api/v1/stream` (SSE, per-seat filtered) with `agent.wait` as a bounded
241
+ // long-poll fallback; NOTHING in this repo consumed either. This is the consumer.
242
+ //
243
+ // The ladder is STREAM → LONGPOLL → CADENCE and the bottom rung is the existing
244
+ // 45s cadence, which is never disabled: a push frame is a HINT ("something
245
+ // happened at seq N — go look"), and the authoritative, ACL'd, cursor-guarded
246
+ // pull is still `guardMessagingInbound` → `pullWideInbound`. So a dropped frame
247
+ // is not lost work, a duplicate frame is dedup-guarded, and a push channel that
248
+ // dies entirely degrades the agent to exactly today's behaviour.
249
+ //
250
+ // Fully fail-open, never silent, and NEVER takes the daemon down.
251
+ // Kill switch: COHORT_PUSH_DISABLED=1.
252
+ try {
253
+ const { connectOrgPush } = await import("../../lib/org/push.mjs");
254
+ const { loadOrgConfig } = await import("../../lib/org/client.mjs");
255
+ const cfg = loadOrgConfig(AGENT_DIR);
256
+
257
+ // The seat id. Used ONLY to suppress this agent's own echoes — hq already
258
+ // filters delivery to the seat bound to the API key, so this is belt-and-
259
+ // braces, not the ACL.
260
+ const selfIds = [
261
+ process.env.COHORT_AGENT_ID,
262
+ cfg && cfg.org && cfg.org.cohort ? cfg.org.cohort.agentId : null,
263
+ ].filter(Boolean);
264
+
265
+ const orgPush = await connectOrgPush({ agentRoot: AGENT_DIR, cfg, selfIds });
266
+ if (orgPush && orgPush.isEnabled) {
267
+ console.log(`[DAEMON] org-push connected (rung: ${orgPush.rung()}) — the 45s messaging-inbound cadence remains the safety net`);
268
+ } else {
269
+ console.log("[DAEMON] org-push inert (org integration disabled or COHORT_PUSH_DISABLED=1) — inbound arrives on the 45s cadence only");
270
+ }
271
+ const stopPush = (sig) => {
272
+ try { orgPush.stop(); } catch { /* ignore */ }
273
+ if (orgPush && orgPush.isEnabled) console.log(`[DAEMON] org-push stopped (${sig})`);
274
+ };
275
+ process.on("SIGTERM", () => stopPush("SIGTERM"));
276
+ process.on("SIGINT", () => stopPush("SIGINT"));
277
+ } catch (err) {
278
+ // A push failure must cost latency, never delivery. Say which, so nobody
279
+ // reads a slow agent as a broken one.
280
+ console.warn(`[DAEMON] org-push init failed (${err.message}) — inbound still arrives on the 45s messaging-inbound cadence, just not instantly`);
281
+ }
282
+
230
283
  // ---------------------------------------------------------------------------
231
284
  // 3c. Model router config validation (1.12.0+, advisory)
232
285
  // ---------------------------------------------------------------------------
@@ -344,6 +344,25 @@ Why split read vs write: MCP tools are ideal for reading because they have full
344
344
  - Set appropriate priority, owner, SLA deadline, and next_action
345
345
  - Log the queuing decision`,
346
346
 
347
+ // `queue` is what an INBOX turn does when it decides work should wait. It is
348
+ // the wrong instruction for a BACKLOG turn, which exists because the waiting
349
+ // is over — and yet `sweepBacklog` sent `action:"queue"` on every row it
350
+ // drained, so every drained session was told "this item needs tracking but
351
+ // not immediate action". A calendar invite traced end-to-end reached a
352
+ // perfectly addressable row naming `calendar.rsvp` and the meeting id, and
353
+ // was then instructed to file it. Nothing ever RSVP'd, and nothing logged a
354
+ // failure, because re-queueing IS what the session was asked for.
355
+ execute: `ACTION: Do the queued work now.
356
+ - The waiting is over: this row was queued earlier and the drain has picked it up
357
+ - Read "Next action required" and the Context block above — they name the record
358
+ (Entity) and, where the surface knows it, the method (Suggested method)
359
+ - Take the action. Prefer the suggested method; override it when the context
360
+ plainly calls for something else, and say why
361
+ - If the action is blocked (missing permission, missing parameter, needs a human),
362
+ say exactly what blocks it and mark the item blocked — do NOT silently re-queue
363
+ - Update the queue item status to "resolved" (or "blocked") when done, with a
364
+ one-line history entry saying what you actually did`,
365
+
347
366
  archive: `ACTION: Archive — no action needed.
348
367
  - Mark this item as processed
349
368
  - Log that it was reviewed and archived with a brief reason
@@ -515,6 +534,34 @@ function buildBacklogContext(queueItem) {
515
534
  lines.push(`Owner: ${queueItem.owner || loadAgent().firstName.toLowerCase()}`);
516
535
  if (queueItem.source) lines.push(`Source: ${queueItem.source}`);
517
536
  if (queueItem.source_ref) lines.push(`Source ref: ${queueItem.source_ref}`);
537
+ // ── what to act ON, and with WHAT ────────────────────────────────────────
538
+ // `lib/execution/effects.scheduleToQueue` writes these; `parseQueueItems`
539
+ // reads them back. They are the whole difference between a session that can
540
+ // RSVP the invite it was woken for and one that was handed an event key
541
+ // built from a ledger seq and no method. Both optional.
542
+ if (queueItem.entity_id) lines.push(`Entity: ${queueItem.entity_id} (the record this work acts on)`);
543
+ if (Array.isArray(queueItem.uses) && queueItem.uses.length > 0) {
544
+ lines.push(`Suggested method: ${queueItem.uses.join(", ")} (the surface's own answer — override it if the context says otherwise)`);
545
+ }
546
+ // ── Why this item exists (goal-steward provenance) ────────────────────────
547
+ // `lib/goals/loop.renderQueueItems` writes `advances` / `expected_delta` /
548
+ // `obligation_key` / `rung` onto every item a measured KPI gap produced, and
549
+ // `lib/backlog.parseQueueItems` now reads all four back. Without them here the
550
+ // session sees only "Close the on_time_rate gap: raise from 75 toward 95" and
551
+ // has no idea which objective it is accountable to, by how much, or how big a
552
+ // move was budgeted — so it cannot report an outcome the intervention ledger
553
+ // can score. This block is the difference between a task and a commitment.
554
+ if (Array.isArray(queueItem.advances) && queueItem.advances.length > 0) {
555
+ lines.push(`Advances objective: ${queueItem.advances.join(", ")}`);
556
+ }
557
+ if (queueItem.obligation_key) lines.push(`Obligation: ${queueItem.obligation_key}`);
558
+ if (Number.isFinite(queueItem.expected_delta)) {
559
+ lines.push(`Expected delta: ${queueItem.expected_delta} (this is what the KPI is expected to move by)`);
560
+ }
561
+ if (queueItem.disposition) lines.push(`Staffing: ${queueItem.disposition}`);
562
+ if (Number.isFinite(queueItem.rung)) {
563
+ lines.push(`Execution rung: ${queueItem.rung}${queueItem.mechanism ? ` (${queueItem.mechanism})` : ""}`);
564
+ }
518
565
  if (queueItem.due) lines.push(`Due: ${queueItem.due}`);
519
566
  if (queueItem.sla_deadline) lines.push(`SLA deadline: ${queueItem.sla_deadline}`);
520
567
  lines.push("");