@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.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- 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;
|
package/lib/backlog.test.mjs
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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
|
package/lib/comms/send-gate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
});
|
package/lib/execution/drive.mjs
CHANGED
|
@@ -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 });
|