@cohortapp/agent-sdk 2.4.1 → 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 (79) 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/sections/mandate.mjs +43 -1
  57. package/lib/subagents/schema.mjs +14 -2
  58. package/lib/subagents/schema.test.mjs +22 -0
  59. package/package.json +1 -1
  60. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  61. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  62. package/scripts/ci/check.mjs +3 -0
  63. package/scripts/ci/conformance-org-api.mjs +16 -0
  64. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  65. package/scripts/daemon/agent-daemon.mjs +582 -28
  66. package/scripts/daemon/cadence-handlers.mjs +273 -17
  67. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  68. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  69. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  70. package/scripts/daemon/maestro-daemon.mjs +53 -0
  71. package/scripts/daemon/prompt-builder.mjs +47 -0
  72. package/scripts/daemon/responder.mjs +70 -3
  73. package/scripts/poller/imap-client.mjs +20 -1
  74. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  75. package/scripts/poller/utils.mjs +51 -0
  76. package/scripts/setup/generate-capability.mjs +120 -11
  77. package/scripts/setup/generate-capability.test.mjs +134 -0
  78. package/scripts/setup/generate-plan.mjs +6 -1
  79. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
package/bin/maestro.mjs CHANGED
@@ -128,6 +128,13 @@ function create(targetName) {
128
128
  "ingest",
129
129
  "mcp",
130
130
  "services",
131
+ // archetypes/ is DATA that lib/archetype.mjs resolves as its own sibling
132
+ // (ARCHETYPES_DIR = <lib>/../archetypes). It ships in the npm tarball, but
133
+ // until it was listed here the copier left it behind, so resolveArchetype()
134
+ // threw for every lookup in a real agent repo and took the generate-*
135
+ // setup scripts down with it. Data and the code that resolves it travel
136
+ // together or neither works.
137
+ "archetypes",
131
138
  // lib/ holds shared framework primitives — singleton lock, cadence bus,
132
139
  // action executor, tool definitions — that scripts/ imports via relative
133
140
  // paths. Must travel with each agent repo so the relative imports work
@@ -755,6 +762,8 @@ const UPGRADE_PATHS = [
755
762
  // (e.g. ../../lib/cadence-bus.mjs from scripts/cadence/*) resolve in
756
763
  // every agent repo without needing @cohortapp/agent-sdk in node_modules.
757
764
  { path: "lib", mode: "smart" },
765
+ // Must travel with lib/ — see the note in the scaffold copy list above.
766
+ { path: "archetypes", mode: "smart" },
758
767
  ];
759
768
 
760
769
  function sha256File(p) {
package/lib/backlog.mjs CHANGED
@@ -365,6 +365,41 @@ export function parseQueueItems(content, sourceFile = "") {
365
365
  const dispMatch = cleanBlock.match(/^\s*-?\s*"?disposition"?:\s*["']?(SELF|COLLABORATE|HANDOFF|NEEDS_REVIEW)["']?/m);
366
366
  if (dispMatch) item.disposition = dispMatch[1];
367
367
 
368
+ // `rung` — the execution rung `lib/execution/route.routeRung` chose and
369
+ // `lib/goals/loop.renderQueueItems` writes on every self-directed item.
370
+ // It was the ONE field the goal steward decided, audited, and wrote that no
371
+ // consumer read back: the daemon's backlog sweep dispatched every row as a
372
+ // plain session regardless, so the router's whole cost/blast-radius decision
373
+ // died on disk. Extracting it is what lets `sweepBacklog` size the session to
374
+ // the rung and name any mechanism the drain cannot honour.
375
+ const rungMatch = cleanBlock.match(/^\s*-?\s*"?rung"?:\s*["']?([0-5])["']?\s*$/m);
376
+ if (rungMatch) item.rung = Number(rungMatch[1]);
377
+
378
+ // --- what the work is ABOUT (execution-ladder provenance) --------------
379
+ // `lib/execution/effects.scheduleToQueue` writes `entity_id` / `source_ref`
380
+ // / `uses` / `context` onto every row the reactive ladder queues. They were
381
+ // the fields that made the difference between a row the drain could act on
382
+ // and one it could only read: without `entity_id` the dispatched session
383
+ // has no meeting/task/approval to name, and without `uses` no method to
384
+ // name it with. All optional — a goal-steward or hand-written row lacks
385
+ // them and behaves exactly as before.
386
+ const entityMatch = cleanBlock.match(/^\s*-?\s*"?entity_id"?:\s*["']?([^"'\n]*?)["']?\s*$/m);
387
+ const entityId = entityMatch?.[1]?.trim();
388
+ if (entityId) item.entity_id = entityId;
389
+ const srcRefMatch = cleanBlock.match(/^\s*-?\s*"?source_ref"?:\s*["']?([^"'\n]*?)["']?\s*$/m);
390
+ const srcRef = srcRefMatch?.[1]?.trim();
391
+ if (srcRef) item.source_ref = srcRef;
392
+ const usesMatch = cleanBlock.match(/^\s*-?\s*"?uses"?:\s*["']?([^"'\n]*?)["']?\s*$/m);
393
+ const usesRaw = usesMatch?.[1]?.trim();
394
+ if (usesRaw) item.uses = usesRaw.split(",").map((u) => u.trim()).filter(Boolean);
395
+ // `context:` is a quoted single-line scalar (scheduleToQueue collapses
396
+ // newlines precisely so this stays one line and cannot tear the block).
397
+ const ctxMatch = cleanBlock.match(/^\s*-?\s*"?context"?:\s*"(.*)"\s*$/m);
398
+ const ctx = ctxMatch?.[1];
399
+ if (ctx) item.context = ctx.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
400
+ const sourceMatch = cleanBlock.match(/^\s*-?\s*"?source"?:\s*["']?([a-z0-9._-]+)["']?\s*$/m);
401
+ if (sourceMatch) item.source = sourceMatch[1];
402
+
368
403
  out.push(item);
369
404
  }
370
405
  return out;
@@ -264,3 +264,39 @@ test("buildBacklogSkeleton: standalone (no orgContext) is byte-for-byte the arch
264
264
  const b = buildBacklogSkeleton(profile, IDENTITY, { date: "2026-06-01", orgContext: null });
265
265
  assert.deepEqual(a, b);
266
266
  });
267
+
268
+ test("REGRESSION — parseQueueItems reads back the `rung` the goal steward wrote", () => {
269
+ // `lib/goals/loop.renderQueueItems` writes rung: on every self-directed item.
270
+ // Nothing read it back, so the daemon's sweep dispatched a rung-0 single-method
271
+ // call and a rung-5 subagent fan-out as the identical plain session: the
272
+ // router's whole cost/blast-radius decision died on disk.
273
+ const yaml = [
274
+ "queue: goals",
275
+ "items:",
276
+ " - id: goal-x-2026-W33-1",
277
+ ' title: "Close the on_time_rate gap"',
278
+ " status: open",
279
+ " priority: normal",
280
+ ' next_action: "Close the on_time_rate gap"',
281
+ " source: goal-steward",
282
+ " advances: [board-on-time-delivery]",
283
+ " expected_delta: 10",
284
+ " obligation_key: outcome.board-on-time-delivery",
285
+ " disposition: SELF",
286
+ " rung: 3",
287
+ " created: 2026-08-11",
288
+ ].join("\n");
289
+ const [item] = parseQueueItems(yaml, "goals.yaml");
290
+ assert.equal(item.rung, 3);
291
+ assert.equal(item.source, "goal-steward");
292
+ assert.equal(item.disposition, "SELF");
293
+ assert.deepEqual(item.advances, ["board-on-time-delivery"]);
294
+ assert.equal(item.expected_delta, 10);
295
+ assert.equal(item.obligation_key, "outcome.board-on-time-delivery");
296
+ });
297
+
298
+ test("a hand-written item with no rung is unchanged (rung stays absent, not 0)", () => {
299
+ const yaml = ['items:', ' - id: seeded-1', ' title: "Tidy docs"', " status: open", ' next_action: "tidy"'].join("\n");
300
+ const [item] = parseQueueItems(yaml, "backlog.yaml");
301
+ assert.equal("rung" in item, false, "absence must not become rung 0 — that is the cheapest rung");
302
+ });
@@ -81,6 +81,7 @@ export const EVENT_KINDS = Object.freeze([
81
81
  "decision",
82
82
  "escalation",
83
83
  "handoff",
84
+ "calendar",
84
85
  "email",
85
86
  ]);
86
87
 
@@ -27,7 +27,7 @@ import { ChannelMessage } from "./index.mjs";
27
27
  test("EVENT_KINDS is the closed set from the design", () => {
28
28
  // Two groups. The five CHAT kinds are the original design set and must not
29
29
  // move — `message` in particular is the default and the byte-compat anchor.
30
- // The ten WORK-SURFACE kinds were added for wide org inbound
30
+ // The eleven WORK-SURFACE kinds were added for wide org inbound
31
31
  // (lib/org/inbound/surfaces.mjs): an org agent is addressed through board
32
32
  // items, approvals, decisions, docs and mail as well as chat, and `kind` is
33
33
  // the routing hint the daemon classifier reads. Adding one here without a
@@ -35,6 +35,7 @@ test("EVENT_KINDS is the closed set from the design", () => {
35
35
  // lib/org/inbound/project.test.mjs.
36
36
  assert.deepEqual([...EVENT_KINDS].sort(), [
37
37
  "approval",
38
+ "calendar",
38
39
  "call",
39
40
  "channel_cc",
40
41
  "decision",
@@ -183,6 +183,60 @@ export function eventToInboxItem(ev) {
183
183
  // this seam existed (it never wrote a `kind` field).
184
184
  if (kind !== DEFAULT_EVENT_KIND) item.kind = kind;
185
185
 
186
+ // THE CHAIN KIND. `ev.cohort` is the wide-inbound projection's provenance
187
+ // block (lib/org/inbound/project.mjs) and this converter drops unknown keys,
188
+ // so the hq event kind — `item.blocked`, `item.completed`, `item.commented` —
189
+ // has always died here. The SURFACE survives (in `kind` and in `raw_ref`), but
190
+ // ten distinct board kinds share the surface `task_comment`, so on the poll
191
+ // lane they all arrive indistinguishable.
192
+ //
193
+ // That is not cosmetic: `lib/execution/match.kindAliases` derives an
194
+ // obligation's bindable tokens from the candidate kind, so an obligation
195
+ // written `{topic: task, kind: blocked}` BINDS when the event arrives on the
196
+ // push/chain lane and reads UNCOVERED when the very same event arrives on the
197
+ // poll lane — capping it at rung ≤1 and filing a bogus coverage-gap proposal.
198
+ // §6.1 has both lanes converging on the same events, so which behaviour you
199
+ // got was a race. Measured, on all four board kinds.
200
+ //
201
+ // One extra scalar closes it. Absent for every non-org producer, so nothing
202
+ // else changes shape.
203
+ const cohort = ev.cohort && typeof ev.cohort === "object" ? ev.cohort : null;
204
+ const chainKind = cohort ? cohort.event_kind : null;
205
+ if (typeof chainKind === "string" && chainKind) {
206
+ item.event_kind = sanitizeYamlField(chainKind);
207
+ }
208
+
209
+ // THE DIRECTEDNESS REASON, for the same reason and by the same rule.
210
+ //
211
+ // `lib/org/inbound/directedness.mjs` does real work to answer WHY an event is
212
+ // this seat's — `reviewer`, `assignee`, `waiting_on`, `mention`, `channel` —
213
+ // and until this line the answer died here too. `intake.fromInboxItem` then
214
+ // RE-GUESSED it from a per-surface default table, so an approval blocking a
215
+ // task the seat REVIEWS was journalled as "directed: approver on approval".
216
+ // That is not a rounding error: `disposition.tierOf` maps the reason onto the
217
+ // T0/T1 tier that drives the whole ladder, so a guess that lands on a
218
+ // different tier than the truth changes the decision. Here it was lucky (both
219
+ // T0); for a `channel`-tier approval the guess would have promoted T1→T0 and
220
+ // made the agent jump on something it was only entitled to notice.
221
+ //
222
+ // Carried as a scalar, absent for every non-org producer.
223
+ const reason = cohort ? cohort.reason : null;
224
+ if (typeof reason === "string" && reason) {
225
+ item.direct_reason = sanitizeYamlField(reason);
226
+ }
227
+
228
+ // The surface's OWN scope id — the task an approval blocks, the decision a
229
+ // comment is on. `channel_id` only ever carries a real Cohort channel
230
+ // (project.mjs is explicit that putting an entity id there would make a reply
231
+ // post-to-a-non-channel), so a non-channel surface arrived at the ladder with
232
+ // no id for its subject at all. `effects.escalate` needs one: hq's
233
+ // `escalation.create` requires a `taskId` or `channelId` and refuses to invent
234
+ // either.
235
+ const scopeId = cohort ? cohort.scope_id : null;
236
+ if (typeof scopeId === "string" && scopeId) {
237
+ item.scope_id = sanitizeYamlField(scopeId);
238
+ }
239
+
186
240
  // Carry attachments through when present (voice notes, media). The legacy
187
241
  // YAML writer (scripts/poller/utils.mjs) renders these when shaped as
188
242
  // { id, name, mimetype, size }; adapters that produce contract-style
@@ -94,12 +94,22 @@ export const BANNED_OPENERS = Object.freeze([
94
94
 
95
95
  /* ────────────────────────── recipient classification ─────────────────────── */
96
96
 
97
+ /**
98
+ * A Cohort entity id (channel or member). hq mints cuid/cuid2 — a lowercase
99
+ * alphanumeric token — and also accepts uuid-shaped ids on some rows. Anchored
100
+ * and length-floored so it can never match an email local part or a phone number.
101
+ */
102
+ const COHORT_ID = /^(?:[a-z][a-z0-9]{14,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
103
+
104
+ /** One-shot guard so the org-recipient warning is not emitted per message. */
105
+ let _warnedExternalOrgRecipient = false;
106
+
97
107
  /**
98
108
  * Decide whether a recipient is INTERNAL (workspace member / internal domain) or
99
109
  * EXTERNAL (everyone else). Default-deny: anything unrecognised is external, so
100
110
  * the fail-CLOSED branch covers the unknown case.
101
111
  *
102
- * @param {string} channel slack|telegram|whatsapp|sms|email|voice|document
112
+ * @param {string} channel cohort|slack|telegram|whatsapp|sms|email|voice|document
103
113
  * @param {string} recipient
104
114
  * @param {string[]} internalDomains lower-cased email domains treated internal
105
115
  * @returns {"internal"|"external"}
@@ -109,6 +119,35 @@ export function classifyRecipient(channel, recipient, internalDomains = []) {
109
119
  if (!r) return "external"; // no recipient ⇒ treat conservatively
110
120
 
111
121
  switch (channel) {
122
+ // Both spellings are live in the tree: `lib/org/messaging.sendMessage` says
123
+ // "cohort", `lib/org/tool-surface` says "cohort-org". They are the same
124
+ // surface and must classify the same way, so both are listed rather than
125
+ // one of them being quietly wrong.
126
+ case "cohort":
127
+ case "cohort-org":
128
+ // The agent's OWN org workspace. `lib/org/messaging.sendMessage` screens
129
+ // every outbound org message as `{channel:"cohort", recipient:<channelId>}`,
130
+ // and a Cohort channel/member id is a row inside the org the agent is a
131
+ // member of — hq's ACL is what put it in reach. There is no such thing as
132
+ // an "external" Cohort channel id.
133
+ //
134
+ // WITHOUT THIS CASE the switch fell to `default`, the id has no "@", and
135
+ // the function returned "external" for every message the agent posts into
136
+ // its own workspace. Two live consequences, both observed:
137
+ // 1. `failPolicy` fails CLOSED on any policy-file fault, so a missing or
138
+ // malformed `policies/*.yaml` silently gags the agent in every space
139
+ // and DM it is in. A public #general thread reply was blocked with
140
+ // "failing closed for external recipient".
141
+ // 2. Every `information-barriers.yaml` wall scoped
142
+ // `recipient_class: external` applied to internal org chat, and the
143
+ // AI-disclosure posture resolved on the external branch.
144
+ //
145
+ // Scoped deliberately: an id-shaped recipient is internal by construction;
146
+ // anything else on this channel (an email address handed to the org mailer)
147
+ // still falls through to the domain test below, so a genuinely external
148
+ // address is NOT laundered into "internal" by arriving on this channel.
149
+ if (COHORT_ID.test(r)) return "internal";
150
+ break;
112
151
  case "slack":
113
152
  case "telegram":
114
153
  // Slack channel/DM ids (C…, D…, G…, U…) and Telegram chat ids live inside
@@ -388,6 +427,22 @@ export async function screenOutbound({
388
427
  const recipientClass = classifyRecipient(channel, recipient, internalDomains);
389
428
  const isExternal = recipientClass === "external";
390
429
 
430
+ // NEVER SILENT. An org-surface recipient that still classifies EXTERNAL is
431
+ // the surprising case, and it is the expensive one: it flips `failPolicy` to
432
+ // fail-CLOSED, so a policy-file fault gags the agent everywhere in its own
433
+ // workspace with nothing in the log but a per-message "blocked" reason. Say
434
+ // it once, at the moment the classification is taken.
435
+ if (isExternal && (channel === "cohort" || channel === "cohort-org") && !_warnedExternalOrgRecipient) {
436
+ _warnedExternalOrgRecipient = true;
437
+ try {
438
+ console.warn(
439
+ `[send-gate] org recipient "${recipient}" did not match a Cohort entity id — screening it as EXTERNAL. ` +
440
+ "Required-policy faults will now fail CLOSED for this surface (the agent goes quiet rather than posts). " +
441
+ "If this is a real org channel/member id, COHORT_ID in lib/comms/send-gate.mjs needs to learn its shape.",
442
+ );
443
+ } catch { /* never throw from logging */ }
444
+ }
445
+
391
446
  // failPolicy: for a missing/unreadable REQUIRED policy file. External → block,
392
447
  // internal → open (so transient FS faults never wall off internal ops chatter).
393
448
  const failPolicy = (file) =>
@@ -549,6 +549,62 @@ describe("classifyRecipient", () => {
549
549
  it("treats empty recipient as external (conservative)", () => {
550
550
  assert.equal(classifyRecipient("email", ""), "external");
551
551
  });
552
+
553
+ // REGRESSION — observed live, 2026-08-11, tracing a public-space thread reply
554
+ // end to end for a real seat. `sendMessage` screens every org post as
555
+ // {channel:"cohort", recipient:<channelId>}; with no `cohort` case the switch
556
+ // fell through and a cuid has no "@", so the agent's OWN #general channel
557
+ // classified EXTERNAL. That flipped `failPolicy` to fail-closed and the reply
558
+ // was refused with: outbound policy "policies/ai-disclosure.yaml" unreadable
559
+ // — failing closed for external recipient.
560
+ it("classifies Cohort channel/member ids as internal — the agent's own workspace", () => {
561
+ // The literal ids from the live trace.
562
+ assert.equal(classifyRecipient("cohort", "cmqs5i86p0008ta0129ql6rv9"), "internal"); // #general
563
+ assert.equal(classifyRecipient("cohort", "cmqh0tcml004ihhl6zev6ocb5"), "internal"); // a member (DM)
564
+ // lib/org/tool-surface.mjs spells the same surface "cohort-org".
565
+ assert.equal(classifyRecipient("cohort-org", "cms6vuxjw00a7mx01ljlyrluu"), "internal");
566
+ // uuid-shaped org ids too.
567
+ assert.equal(
568
+ classifyRecipient("cohort", "3f2504e0-4f89-11d3-9a0c-0305e82c3301"),
569
+ "internal",
570
+ );
571
+ });
572
+
573
+ it("does NOT launder a genuinely external address arriving on the org channel", () => {
574
+ // The whole point of scoping the case to id-shaped recipients: an email or
575
+ // a phone number handed to an org surface must still be external, or the
576
+ // fix would open the AI-disclosure gate for real outsiders.
577
+ assert.equal(classifyRecipient("cohort", "someone@external.com"), "external");
578
+ assert.equal(classifyRecipient("cohort-org", "client@bank.example"), "external");
579
+ assert.equal(classifyRecipient("cohort", "+15551234567"), "external");
580
+ // …and an allow-listed internal domain still resolves through the domain test.
581
+ assert.equal(
582
+ classifyRecipient("cohort", "alex@internal.example", ["internal.example"]),
583
+ "internal",
584
+ );
585
+ });
586
+
587
+ it("an unreadable required policy no longer gags the agent in its own workspace", async () => {
588
+ // The end-to-end symptom, reduced: no policies/ on disk at all.
589
+ const emptyFs = { existsSync: () => false, readFileSync: () => { throw new Error("ENOENT"); } };
590
+ const org = await screenOutbound({
591
+ channel: "cohort",
592
+ recipient: "cmqs5i86p0008ta0129ql6rv9",
593
+ text: "Already voted espresso — I'm in the count.",
594
+ fs: emptyFs,
595
+ });
596
+ assert.equal(org.allow, true, `org post must fail OPEN, got: ${org.reason}`);
597
+
598
+ // The external posture is untouched — this is the half that must NOT regress.
599
+ const outsider = await screenOutbound({
600
+ channel: "email",
601
+ recipient: "someone@external.com",
602
+ text: "Already voted espresso — I'm in the count.",
603
+ fs: emptyFs,
604
+ });
605
+ assert.equal(outsider.allow, false);
606
+ assert.match(outsider.reason, /failing closed for external recipient/);
607
+ });
552
608
  });
553
609
 
554
610
  /* ─── real-disk integration smoke (uses the actual readPolicy path) ────────*/
@@ -293,9 +293,27 @@ export function decide(o = {}) {
293
293
  // the terminal authority on its own approvals or on committing the org.
294
294
  if (policy.selfApprove === false) {
295
295
  why.push(`${surface} may never be resolved by the agent itself → escalate to a human decider`);
296
+ // The options are what make this an ESCALATION rather than a shrug. A caller
297
+ // that worked out the real alternatives wins; otherwise the surface's own
298
+ // structurally-known set is used, because hq's `escalation.ask` refuses a
299
+ // question with fewer than two and an approval with no options attached is
300
+ // just a second chat message — the exact thing OpenQuestion exists to
301
+ // replace. NEVER SILENT when a surface declares none.
302
+ const declared = Array.isArray(policy.escalationOptions) ? policy.escalationOptions : [];
303
+ const options = Array.isArray(facts.ambiguousOptions) && facts.ambiguousOptions.length >= 2
304
+ ? facts.ambiguousOptions
305
+ : declared;
306
+ if (options.length >= 2) {
307
+ why.push(`offering the ${options.length} ways this can go: ${options.join(" / ")}`);
308
+ } else {
309
+ why.push(
310
+ `no options are available for ${surface} (neither the caller nor the surface policy ` +
311
+ "declared any) — this will raise a blockage flag, not a decision",
312
+ );
313
+ }
296
314
  return finish(base, "escalate", "cannot_self_decide", why, o, {
297
315
  escalateTo: facts.escalateTo || null,
298
- options: Array.isArray(facts.ambiguousOptions) ? facts.ambiguousOptions : [],
316
+ options,
299
317
  });
300
318
  }
301
319
 
@@ -450,7 +468,49 @@ function finish(base, disposition, reason, why, o, extra = {}) {
450
468
  for (const line of route.why) why.push(`route: ${line}`);
451
469
 
452
470
  const gates = {};
453
- if (route.approval) gates.approval = route.approval;
471
+ // ── the blast-radius gate, and the two dispositions it must NOT be applied to.
472
+ //
473
+ // THE DEADLOCK THIS FIXES. `surface-policy` gives `approval` and `decision`
474
+ // `actionClasses:["irreversible"]` AND `selfApprove:false`. Rule 6 above
475
+ // therefore ALWAYS returns `escalate` on them — and, until this branch, also
476
+ // attached an approval gate to that escalation. `drive.mjs` step 4 runs the
477
+ // gate BEFORE the effect and fails closed, so the sequence was:
478
+ //
479
+ // the agent may not decide this approval
480
+ // → escalate it to a human
481
+ // → but first obtain an approval
482
+ // → which is the thing it may not decide.
483
+ //
484
+ // The escalate effect never ran. Observed live against a production org:
485
+ // `escalate @rung 1 (claude-skill) — cannot_self_decide`, `writes=[]`. The
486
+ // one disposition that exists to put a decision in front of a person was
487
+ // structurally unreachable on every surface that needs it.
488
+ //
489
+ // `escalate` and `delegate` are exempt for the same reason: neither ACTS on
490
+ // the surface. They hand the thing to somebody else — a human decider, or a
491
+ // peer seat that runs this identical ladder and is gated in its own right.
492
+ // Gating them buys no safety and costs the handover.
493
+ //
494
+ // `react_now` and `schedule` KEEP the gate. `react_now` obviously — the
495
+ // classes are literally the cost of replying. `schedule` less obviously,
496
+ // and deliberately: the queued item is drained by the backlog sweep into a
497
+ // session that does NOT re-enter this ladder, so a gate dropped here is a
498
+ // gate lost for good. An inbound email queues, and it stays gated.
499
+ //
500
+ // NEVER SILENT: when the gate is dropped, say so and why, so a journal
501
+ // reader does not have to know this rule to read the trace.
502
+ const HANDS_OFF = disposition === "escalate" || disposition === "delegate";
503
+ if (route.approval) {
504
+ if (!HANDS_OFF) {
505
+ gates.approval = route.approval;
506
+ } else {
507
+ why.push(
508
+ `blast radius ${route.approval.classes.join("+")} is the cost of ACTING on ${base.surface}; ` +
509
+ `this decision is \`${disposition}\`, which hands the act to someone else who is gated in ` +
510
+ "their own right — the approval gate does not apply and would deadlock it",
511
+ );
512
+ }
513
+ }
454
514
  if (route.gate) gates[route.gate.kind] = route.gate.detail;
455
515
  // A reply into a shared room must win the thread-ownership lease first, or two
456
516
  // agents answer the same @mention. DMs and queued work need no arbitration.
@@ -95,6 +95,10 @@ const SURFACE_CASES = {
95
95
  escalation: { verdict: yes("escalation", "waiting_on"), candidate: { family: "escalation", kind: "raised", topic: "escalation", surfaces: ["escalation"], ids: { escalationId: "e1" } } },
96
96
  handoff: { verdict: yes("handoff", "direct"), candidate: { family: "handoff", kind: "offered", topic: "handoff", surfaces: ["handoff"], ids: { handoffId: "h1" } } },
97
97
  email: { verdict: yes("email", "direct"), candidate: { family: "email", kind: "received", topic: "email", surfaces: ["email"], ids: { messageId: "e1" } } },
98
+ // JOINT 3. `attendee` is the reason `resolveCalendar` emits when the ACL'd
99
+ // `calendar.list` read (owner OR attendeeRows) proved the event is this
100
+ // seat's — the payload can only ever prove `calendar_owner`.
101
+ calendar: { verdict: yes("calendar", "attendee"), candidate: { family: "calendar", kind: "event.created", topic: "calendar", surfaces: ["calendar"], ids: { eventId: "ev1" } } },
98
102
  };
99
103
 
100
104
  test("every inbound surface has a decision case in this test file", () => {
@@ -480,3 +484,53 @@ test("decide is PURE: the same inputs give the same decision", () => {
480
484
  const b = run(SURFACE_CASES.mention);
481
485
  assert.deepEqual(a, b);
482
486
  });
487
+
488
+ // ───────────────────────────────────────────────────────────────────────────
489
+ // The escalate deadlock. Regression for a live failure, not a hypothetical.
490
+ // ───────────────────────────────────────────────────────────────────────────
491
+
492
+ test("ESCALATE is never blast-radius gated — gating it deadlocks the only route to a human", () => {
493
+ // `approval` and `decision` carry actionClasses:["irreversible"] AND
494
+ // selfApprove:false, so rule 6 always escalates them. Attaching the approval
495
+ // gate to that escalation means: to escalate an approval, first get an
496
+ // approval. drive.mjs step 4 fails closed, so the effect never ran and the
497
+ // lane produced nothing. Observed live (writes=[]).
498
+ for (const s of ["approval", "decision"]) {
499
+ const d = run(SURFACE_CASES[s]);
500
+ assert.equal(d.disposition, "escalate");
501
+ assert.equal(d.gates.approval, undefined, `${s} escalation was gated behind an approval`);
502
+ assert.ok(
503
+ d.why.some((w) => /would deadlock it/.test(w)),
504
+ "dropping the gate must be stated out loud, never done quietly",
505
+ );
506
+ }
507
+ });
508
+
509
+ test("an escalation carries >= 2 concrete options — hq refuses a question with fewer", () => {
510
+ // hq's escalation.ask 400s below two options ("a question with fewer is a
511
+ // message, not a decision"). Nothing supplies facts.ambiguousOptions for an
512
+ // approval, so without the surface policy's declared set the ladder decided
513
+ // `escalate` and the effect had nothing to ask with.
514
+ for (const s of ["approval", "decision"]) {
515
+ const d = run(SURFACE_CASES[s]);
516
+ assert.ok(Array.isArray(d.options), `${s} carried no options array`);
517
+ assert.ok(d.options.length >= 2, `${s} escalation offered ${d.options.length} option(s)`);
518
+ assert.equal(new Set(d.options).size, d.options.length, "duplicate labels are not a choice");
519
+ }
520
+ });
521
+
522
+ test("a caller-supplied option set beats the surface policy's default", () => {
523
+ const d = run({
524
+ ...SURFACE_CASES.approval,
525
+ facts: { ambiguousOptions: ["ship it", "hold it", "hand it to finance"] },
526
+ });
527
+ assert.deepEqual(d.options, ["ship it", "hold it", "hand it to finance"]);
528
+ });
529
+
530
+ test("SCHEDULE keeps its blast-radius gate — the queue drain does not re-enter this ladder", () => {
531
+ // The exemption is for handovers (escalate/delegate) only. An inbound email
532
+ // queues, and dropping the `external` gate here would lose it for good.
533
+ const email = run(SURFACE_CASES.email);
534
+ assert.equal(email.disposition, "schedule");
535
+ assert.deepEqual(email.gates.approval, { required: true, classes: ["external"] });
536
+ });
@@ -330,7 +330,7 @@ export async function drive(decision, o = {}) {
330
330
  degraded,
331
331
  });
332
332
  }
333
- const r = await runEffect(effects[name], name, { decision, candidate: o.candidate });
333
+ const r = await runEffect(effects[name], name, { decision, candidate: o.candidate, item: o.item });
334
334
  if (r.missing) {
335
335
  degraded.push(`${name}_effect_missing`);
336
336
  log("error", `no \`${name}\` effect wired — the event was decided but not acted on`, { key: decision.key });