@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
@@ -20,8 +20,12 @@
20
20
  * the daemon's 2-minute backlog sweep already dispatches. No
21
21
  * dispatcher edit, no new transport.
22
22
  * delegate → `lib/org/handoff.mjs sendHandoff` (the A2A envelope path).
23
- * escalate → `escalation.create` via `lib/org/client.mjs call`, which is
24
- * the first real writer for the escalation lane.
23
+ * escalate → `escalation.ask` when the decision carries ≥2 concrete
24
+ * options (an OpenQuestion a human can settle in one click),
25
+ * else `escalation.create` against the task/channel the
26
+ * event names (a blockage flag). Both via `lib/org/client.mjs
27
+ * call`, whose `{base, token}` this module now resolves from
28
+ * the agent root — see `orgConn` for why it had to.
25
29
  * requestApproval → `lib/org/approvals.mjs requestApproval` + `awaitDecision`,
26
30
  * including the `payloadHash` single-use binding.
27
31
  * arbitrate → `lib/org/leases.mjs preSendArbitration` (3s fail-open).
@@ -42,6 +46,68 @@ import { dirname, join } from "node:path";
42
46
  import { resolveAgentRoot } from "../agent-root.mjs";
43
47
  import { appendJsonl } from "../fs-atomic.mjs";
44
48
  import { yamlScalar } from "../channels/inbox-item.mjs";
49
+ import { policyFor } from "./surface-policy.mjs";
50
+
51
+ /**
52
+ * Resolve `{base, token, orgId}` for an agent root.
53
+ *
54
+ * ── WHY THIS EXISTS ──
55
+ * `lib/org/client.mjs call()` resolves NOTHING from an agent root: its first
56
+ * line is `const {base, token, orgId} = o; if (!base) return errFrame(...)`.
57
+ * Every effect below was calling it as `call(m, p, {agentRoot, client, base,
58
+ * token})` with `base`/`token` undefined, because `defaultEffects` is built by
59
+ * `scripts/daemon/agent-daemon.mjs#runExecutionLadder` as
60
+ * `defaultEffects({agentRoot, me, react})` — the daemon has an agent root and
61
+ * nothing else to give.
62
+ *
63
+ * The result, live: EVERY network effect the execution ladder owns returned
64
+ *
65
+ * {ok:false, error:{code:"BAD_REQUEST", message:"missing base"}}
66
+ *
67
+ * before a single byte left the machine. The ladder could decide to escalate and
68
+ * could never actually escalate. `agentRoot` was accepted, forwarded, and
69
+ * silently ignored one layer down.
70
+ *
71
+ * An agent root IS the org binding (`config/org.yaml` + the env token fallbacks
72
+ * in `client.configFromAgent`), so resolving it here is not a new mechanism —
73
+ * it is calling the loader every other org module already calls.
74
+ *
75
+ * @param {object} o {agentRoot, base, token, orgId}
76
+ * @returns {Promise<{base:string, token:string, orgId:string}>}
77
+ */
78
+ async function orgConn(o = {}) {
79
+ if (o.base) return { base: o.base, token: o.token, orgId: o.orgId };
80
+ const { loadOrgConfig, configFromAgent } = await import("../org/client.mjs");
81
+ const c = configFromAgent(loadOrgConfig(resolveAgentRoot(o.agentRoot)));
82
+ return {
83
+ base: c.base,
84
+ token: o.token || c.token,
85
+ orgId: o.orgId || c.orgId,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Render an RPC error frame as a LINE, not as `[object Object]`.
91
+ *
92
+ * `String({code, message})` is `"[object Object]"`, and that is exactly what
93
+ * every effect below used to hand back — so the daemon's fall-through logged
94
+ * `escalate effect failed for … ([object Object])` and the operator learned
95
+ * nothing at all about why. Errors are the one thing that must never be lossy.
96
+ *
97
+ * @param {any} err
98
+ * @returns {string|null}
99
+ */
100
+ export function errText(err) {
101
+ if (err == null) return null;
102
+ if (typeof err === "string") return err;
103
+ if (typeof err === "object") {
104
+ const code = err.code ? String(err.code) : "";
105
+ const msg = err.message ? String(err.message) : "";
106
+ if (code || msg) return code && msg ? `${code}: ${msg}` : code || msg;
107
+ try { return JSON.stringify(err); } catch { return String(err); }
108
+ }
109
+ return String(err);
110
+ }
45
111
 
46
112
  /** Where scheduled work lands — a queue file the daemon's sweep already reads. */
47
113
  export const INBOUND_QUEUE_REL = "state/queues/inbound.yaml";
@@ -67,13 +133,68 @@ function queueItemId(decision) {
67
133
  return `inb-${raw.replace(/[^A-Za-z0-9._#:@-]/g, "-").slice(0, 80)}`;
68
134
  }
69
135
 
136
+ /**
137
+ * One line saying what the queued work actually IS, for the session that will
138
+ * drain the row. Collapsed to a single line because `parseQueueItems` splits
139
+ * items on a `- ` at line start — a body line beginning with a dash would tear
140
+ * the item in half — and truncated because a queue row is an instruction, not
141
+ * an archive.
142
+ */
143
+ function contextLine(item, candidate) {
144
+ const bits = [];
145
+ const subject = item && typeof item.subject === "string" ? item.subject.trim() : "";
146
+ const content = item && typeof item.content === "string" ? item.content.trim() : "";
147
+ // WHERE it happened. A call invite hydrates to the two words "Call invite";
148
+ // the room it was called in is the only thing that makes it answerable.
149
+ const where = item && typeof item.channel === "string" ? item.channel.trim() : "";
150
+ const who = item && typeof item.sender === "string" ? item.sender.trim() : "";
151
+ if (subject) bits.push(subject);
152
+ if (where) bits.push(`in ${where}`);
153
+ if (who) bits.push(`from ${who}`);
154
+ if (content && content !== subject) bits.push(content);
155
+ if (!bits.length && candidate && candidate.ids) {
156
+ // No inbox body (a raw-event caller). The candidate's own ids are still a
157
+ // better instruction than nothing at all.
158
+ for (const [k, v] of Object.entries(candidate.ids)) {
159
+ if (typeof v === "string" && v) bits.push(`${k}=${v}`);
160
+ }
161
+ }
162
+ return bits.join(" — ").replace(/\s*\n+\s*/g, " · ").slice(0, 600);
163
+ }
164
+
165
+ /**
166
+ * The concrete protocol method the queued work would call, in priority order:
167
+ * what the router already decided, then the bound obligation's `uses[]`, then
168
+ * the surface policy's `queueUses` fallback. Returns [] when nothing knows —
169
+ * and an empty list is written as an empty field rather than a guess.
170
+ */
171
+ function usesFor(decision) {
172
+ const fromAction = decision && decision.action && decision.action.method;
173
+ if (fromAction) return [String(fromAction)];
174
+ const fromObligation = decision && Array.isArray(decision.uses) ? decision.uses.filter(Boolean) : [];
175
+ if (fromObligation.length) return fromObligation.map(String);
176
+ const policy = policyFor(decision && decision.policySurface ? decision.policySurface : decision && decision.surface);
177
+ const fallback = policy && Array.isArray(policy.queueUses) ? policy.queueUses : [];
178
+ return fallback.map(String);
179
+ }
180
+
70
181
  /**
71
182
  * Append one item to `state/queues/inbound.yaml`, creating the file with its
72
183
  * header when absent. Idempotent on the item id: an id already present in the
73
184
  * file is a no-op, so a retry of the same decision cannot double-queue.
74
185
  *
186
+ * THE ROW MUST BE ACTIONABLE. It used to carry the decision and nothing else:
187
+ * `event_key`, `thread`, `rung`, `why`. A live calendar invite queued through
188
+ * it produced `next_action: "Handle the calendar event calendar.calendar#8261
189
+ * at rung 1"` — an event key built from the LEDGER SEQ, with the meeting id
190
+ * buried inside `thread` and the title, the time and the method absent
191
+ * entirely. `buildBacklogContext` then rendered that row into a prompt, and the
192
+ * session it dispatched had nothing to RSVP. The decision reached the queue;
193
+ * the WORK did not. So the subject travels with it now: `entity_id`,
194
+ * `source_ref`, `context`, `uses`.
195
+ *
75
196
  * @param {object} decision
76
- * @param {object} [o] { agentRoot, nowMs }
197
+ * @param {object} [o] { agentRoot, nowMs, candidate, item }
77
198
  * @returns {{ok:boolean, ref:object|null, error:string|null}}
78
199
  */
79
200
  export function scheduleToQueue(decision, o = {}) {
@@ -107,11 +228,36 @@ export function scheduleToQueue(decision, o = {}) {
107
228
  }
108
229
  }
109
230
 
110
- const title = `${decision.surface || "inbound"}: ${decision.reason || "queued"}`;
111
- const nextAction =
112
- decision.disposition === "schedule" && decision.surface
113
- ? `Handle the ${decision.surface} event ${decision.key} at rung ${decision.rung ?? "?"}`
114
- : `Handle ${decision.key}`;
231
+ const candidate = o.candidate || null;
232
+ const item = o.item || null;
233
+ const entityId =
234
+ (candidate && (candidate.entityId || (candidate.ids && candidate.ids.entityId))) ||
235
+ (item && (item.thread_id || item.id)) ||
236
+ "";
237
+ const sourceRef = (item && item.raw_ref) || (candidate && candidate.rawRef) || "";
238
+ const context = contextLine(item, candidate);
239
+ const uses = usesFor(decision);
240
+
241
+ // The title is what an operator scanning the queue reads, so it names the
242
+ // thing, not the verdict. "calendar: not_respondable" told nobody anything.
243
+ const subject = (item && typeof item.subject === "string" && item.subject.trim()) || "";
244
+ const title = subject
245
+ ? `${decision.surface || "inbound"}: ${subject}`.slice(0, 160)
246
+ : `${decision.surface || "inbound"}: ${decision.reason || "queued"}`;
247
+
248
+ let nextAction;
249
+ if (decision.disposition === "schedule" && decision.surface) {
250
+ const target = entityId ? ` ${entityId}` : "";
251
+ const method = uses.length ? ` using ${uses.join(" / ")}` : "";
252
+ nextAction = `Handle the ${decision.surface}${target}${method} (rung ${decision.rung ?? "?"}, ${decision.reason || "queued"})`;
253
+ if (!entityId) {
254
+ // FAIL-OPEN IS FINE; SILENT IS NOT. A row with no subject is the exact
255
+ // dead end this field exists to close, so say so on the row itself.
256
+ nextAction += " — WARNING: no entity id reached the queue; the drain cannot address this";
257
+ }
258
+ } else {
259
+ nextAction = `Handle ${decision.key}`;
260
+ }
115
261
 
116
262
  const lines = [
117
263
  ` - id: ${yamlScalar(id)}`,
@@ -122,9 +268,32 @@ export function scheduleToQueue(decision, o = {}) {
122
268
  ` created: ${yamlScalar(new Date(nowMs).toISOString())}`,
123
269
  ` surface: ${yamlScalar(decision.surface || "")}`,
124
270
  ` event_key: ${yamlScalar(decision.key || "")}`,
271
+ // The three fields that make the row addressable rather than merely
272
+ // traceable. `entity_id` is the thing to act ON, `uses` is the method to
273
+ // act WITH, `context` is what actually arrived.
274
+ ` entity_id: ${yamlScalar(entityId)}`,
275
+ ` source_ref: ${yamlScalar(sourceRef)}`,
276
+ ` uses: ${yamlScalar(uses.join(","))}`,
277
+ ` context: ${yamlScalar(context)}`,
125
278
  ` thread: ${yamlScalar(decision.thread || "")}`,
126
279
  ` rung: ${Number.isFinite(decision.rung) ? decision.rung : "null"}`,
127
280
  ` obligation_key: ${yamlScalar(decision.obligationKey || "")}`,
281
+ // THE LEARNING LINK. `lib/goals/loop.openItemsFor` selects the work that is
282
+ // in flight against an objective by reading `advances: [...]`, and
283
+ // `journalRealizedDeltas` scores each of those against the next KPI sample
284
+ // via `lib/kpi.recordIntervention`. Without these two lines a board item the
285
+ // ladder queued was structurally invisible to that loop: the row named its
286
+ // obligation but never the OBJECTIVE that obligation serves, so reactive
287
+ // work could never move a number, and no intervention was ever scored for
288
+ // it. Traced end-to-end on a live board assignment — the journey reached
289
+ // the queue and stopped dead there.
290
+ //
291
+ // `decision.objectiveId` comes from the matched obligation
292
+ // (lib/execution/disposition.mjs). An UNCOVERED event has none; the row then
293
+ // says `advances: []`, which reads correctly as "this advances nothing
294
+ // anyone has adopted" rather than silently omitting the field.
295
+ ` advances: [${decision.objectiveId ? String(decision.objectiveId) : ""}]`,
296
+ ` expected_delta: ${Number.isFinite(decision.expectedDelta) ? decision.expectedDelta : "null"}`,
128
297
  // The reasoning travels WITH the work. A queue item whose provenance is a
129
298
  // mystery is a queue item nobody trusts enough to action.
130
299
  ` why: ${yamlScalar((decision.why || []).join(" | "))}`,
@@ -227,7 +396,12 @@ export function defaultEffects(o = {}) {
227
396
  return {
228
397
  react: typeof o.react === "function" ? o.react : undefined,
229
398
 
230
- schedule: async ({ decision }) => scheduleToQueue(decision, common),
399
+ // `candidate` was already on the wire from `drive` and was being dropped on
400
+ // the floor here — it carries the entity id the queued row needs to be
401
+ // addressable at all. `item` is the originating inbox item when the caller
402
+ // has one (the daemon's ladder passes it); absent for raw-event callers.
403
+ schedule: async ({ decision, candidate, item }) =>
404
+ scheduleToQueue(decision, { ...common, candidate, item: item || o.item || null }),
231
405
 
232
406
  delegate: async ({ decision, candidate }) => {
233
407
  const { sendHandoff } = await import("../org/handoff.mjs");
@@ -248,27 +422,111 @@ export function defaultEffects(o = {}) {
248
422
  return { ok: r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
249
423
  },
250
424
 
251
- escalate: async ({ decision }) => {
425
+ /**
426
+ * Raise the decision up the line.
427
+ *
428
+ * ── TWO METHODS, CHOSEN BY SHAPE ──
429
+ * hq's `escalation` family carries two different entities and this effect
430
+ * has to pick the right one, because neither accepts the other's params:
431
+ *
432
+ * `escalation.ask` → an **OpenQuestion**. Requires ≥2 distinct options
433
+ * (hq `methods/escalation/ask.ts` 400s on fewer:
434
+ * "a question with fewer is a message, not a
435
+ * decision"). This is the shape a human can act on
436
+ * in one click, and it is what `disposition.mjs`
437
+ * rules 6/7/8 are documented to produce.
438
+ * `escalation.create` → an **Escalation**: a blockage flag. Requires
439
+ * `title` AND at least one of `taskId`/`channelId`
440
+ * (hq `methods/escalation/create.ts`).
441
+ *
442
+ * ── WHAT WAS BROKEN ──
443
+ * This effect sent `escalation.create({summary, waitingOnMemberId, options,
444
+ * context:{…}})`. Against the live handler that is four faults at once:
445
+ * `summary` is not `title` (the param contract aliases `subject→title`, not
446
+ * `summary`), no `taskId`/`channelId` was ever supplied so the `oneOf`
447
+ * refine could not pass, `waitingOnMemberId` is not a field hq reads on this
448
+ * method, and `context` is a STRING there, not an object. So the escalation
449
+ * lane 400'd on every call — and `escalation.ask`, the method the ladder's
450
+ * own comments name, had no caller anywhere in the repo.
451
+ *
452
+ * ── REDACTION ──
453
+ * Unchanged and load-bearing: ids, surfaces and machine reasons only. No
454
+ * message body, no room name, no address ever reaches either method. The
455
+ * question below is assembled from the decision, never from the event text.
456
+ */
457
+ escalate: async ({ decision, candidate }) => {
252
458
  const { call } = await import("../org/client.mjs");
459
+ const conn = await orgConn({ agentRoot, base: o.base, token: o.token, orgId: o.orgId });
460
+ if (!conn.base) {
461
+ return { ok: false, ref: null, error: "org not configured (no base) — cannot escalate" };
462
+ }
463
+
464
+ const surface = decision.surface || "inbound";
465
+ const ids = (candidate && candidate.ids) || {};
466
+ const options = (decision.options || [])
467
+ .map((l) => String(l || "").trim())
468
+ .filter(Boolean);
469
+
470
+ // ≥2 concrete options ⇒ a DECISION. Ask it.
471
+ if (options.length >= 2) {
472
+ const r = await call(
473
+ "escalation.ask",
474
+ {
475
+ question:
476
+ `${surface} ${decision.key || ""}: I stopped at \`${decision.reason}\` and may not ` +
477
+ "decide this myself. Which way should it go?",
478
+ options: options.map((label) => ({ label })),
479
+ // hq's `context` is a STRING (≤4000). Ids + reasoning only.
480
+ context: [
481
+ `event_key: ${decision.key || "?"}`,
482
+ `surface: ${surface}`,
483
+ `obligation: ${decision.obligationKey || "(uncovered)"}`,
484
+ `rung: ${decision.rung ?? "?"} (${decision.mechanism || "?"})`,
485
+ ...(decision.why || []).map((w) => `why: ${w}`),
486
+ ].join("\n").slice(0, 4000),
487
+ trigger: String(decision.reason || "escalate").slice(0, 100),
488
+ },
489
+ { ...conn, agentRoot },
490
+ );
491
+ return {
492
+ ok: !!r && r.ok !== false,
493
+ ref: { method: "escalation.ask", frame: r },
494
+ error: r && r.error ? errText(r.error) : null,
495
+ };
496
+ }
497
+
498
+ // Fewer than two options ⇒ a BLOCKAGE. Flag it — but only if there is
499
+ // something to flag it against, because hq requires a target and a made-up
500
+ // one is worse than an honest refusal.
501
+ const taskId = ids.taskId || ids.itemId || null;
502
+ const channelId = ids.channelId || null;
503
+ if (!taskId && !channelId) {
504
+ return {
505
+ ok: false,
506
+ ref: null,
507
+ error:
508
+ `escalation.create needs a taskId or channelId and ${surface} event ` +
509
+ `${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
510
+ `the decision offered ${options.length}. Nothing was raised.`,
511
+ };
512
+ }
253
513
  const r = await call(
254
514
  "escalation.create",
255
515
  {
256
- // No message bodies, ever — ids and reasons only. `assertRedactionSafe`
257
- // bans addresses and content, not member ids.
258
- summary: `${decision.surface || "inbound"}: ${decision.reason}`,
259
- waitingOnMemberId: decision.escalateTo || o.escalationTarget || undefined,
260
- options: (decision.options || []).map((label) => ({ label: String(label) })),
261
- context: {
262
- event_key: decision.key,
263
- surface: decision.surface,
264
- thread: decision.thread,
265
- obligation_key: decision.obligationKey || null,
266
- why: decision.why,
267
- },
516
+ title: `${surface}: ${decision.reason}`.slice(0, 500),
517
+ detail: (decision.why || []).join("; ").slice(0, 2000),
518
+ waitingOn: decision.escalateTo || o.escalationTarget || undefined,
519
+ resolvable: true,
520
+ ...(taskId ? { taskId: String(taskId) } : {}),
521
+ ...(channelId ? { channelId: String(channelId) } : {}),
268
522
  },
269
- { agentRoot, client: o.client, base: o.base, token: o.token },
523
+ { ...conn, agentRoot },
270
524
  );
271
- return { ok: !!r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
525
+ return {
526
+ ok: !!r && r.ok !== false,
527
+ ref: { method: "escalation.create", frame: r },
528
+ error: r && r.error ? errText(r.error) : null,
529
+ };
272
530
  },
273
531
 
274
532
  requestApproval: async ({ decision, classes }) => {
@@ -17,6 +17,7 @@ import {
17
17
  writeDrift,
18
18
  writeProposal,
19
19
  defaultEffects,
20
+ errText,
20
21
  INBOUND_QUEUE_REL,
21
22
  DRIFT_REL,
22
23
  PROPOSED_REL,
@@ -191,3 +192,114 @@ test("the default propose + reportDrift effects write through", async () => {
191
192
  assert.ok(existsSync(join(root, DRIFT_REL)));
192
193
  } finally { rmSync(root, { recursive: true, force: true }); }
193
194
  });
195
+
196
+ // ───────────────────────────────────────────────────────────────────────────
197
+ // The joint: does the escalate effect actually reach the org?
198
+ // ───────────────────────────────────────────────────────────────────────────
199
+
200
+ import { writeFileSync as _wf, mkdirSync as _mk } from "node:fs";
201
+
202
+ /** An agent root with a real config/org.yaml, as `maestro setup` writes it. */
203
+ function enrolledRoot() {
204
+ const root = mkdtempSync(join(tmpdir(), "effects-org-"));
205
+ _mk(join(root, "config"), { recursive: true });
206
+ _wf(
207
+ join(root, "config", "org.yaml"),
208
+ "org:\n cohort:\n enabled: true\n base: https://example.invalid\n orgId: org_t\n token: tok_t\n",
209
+ );
210
+ return root;
211
+ }
212
+
213
+ test("escalate resolves the org binding from the agent root — `agentRoot` alone used to be ignored", async () => {
214
+ // `client.call()` reads `o.base` and nothing else; `defaultEffects` is built by
215
+ // the daemon as `{agentRoot, me, react}`. So every network effect returned
216
+ // BAD_REQUEST "missing base" before a byte left the machine: the ladder could
217
+ // decide to escalate and could never escalate.
218
+ const root = enrolledRoot();
219
+ const seen = [];
220
+ const orig = globalThis.fetch;
221
+ globalThis.fetch = async (url, init) => {
222
+ seen.push({ url: String(url), body: JSON.parse(init.body) });
223
+ return new Response(JSON.stringify({ ok: true, result: { id: "oq_1" } }), {
224
+ status: 200, headers: { "content-type": "application/json" },
225
+ });
226
+ };
227
+ try {
228
+ const eff = defaultEffects({ agentRoot: root, me: "M-1" });
229
+ const r = await eff
230
+ .escalate({
231
+ decision: {
232
+ key: "approval.requested#1", surface: "approval", reason: "cannot_self_decide",
233
+ rung: 1, mechanism: "claude-skill", why: ["a", "b"],
234
+ options: ["approve it", "reject it"],
235
+ },
236
+ candidate: { ids: { taskId: "T-1" } },
237
+ });
238
+ {
239
+ assert.equal(r.ok, true, r.error || "escalate failed");
240
+ assert.equal(seen.length, 1, "no request was made");
241
+ assert.match(seen[0].url, /escalation\.ask$/, "≥2 options must go to ask, not create");
242
+ assert.equal(seen[0].body.options.length, 2);
243
+ assert.equal(typeof seen[0].body.context, "string", "hq's `context` is a STRING");
244
+ assert.equal(seen[0].body.trigger, "cannot_self_decide");
245
+ assert.ok(seen[0].body.question, "hq requires a question");
246
+ }
247
+ } finally {
248
+ globalThis.fetch = orig;
249
+ }
250
+ });
251
+
252
+ test("fewer than two options falls back to escalation.create WITH a target", async () => {
253
+ const root = enrolledRoot();
254
+ const seen = [];
255
+ const orig = globalThis.fetch;
256
+ globalThis.fetch = async (url, init) => {
257
+ seen.push({ url: String(url), body: JSON.parse(init.body) });
258
+ return new Response(JSON.stringify({ ok: true, result: { id: "esc_1" } }), {
259
+ status: 200, headers: { "content-type": "application/json" },
260
+ });
261
+ };
262
+ try {
263
+ const eff = defaultEffects({ agentRoot: root, me: "M-1" });
264
+ const r = await eff
265
+ .escalate({
266
+ decision: { key: "k", surface: "task_comment", reason: "stuck", why: ["x"], options: [] },
267
+ candidate: { ids: { taskId: "T-7" } },
268
+ });
269
+ {
270
+ assert.equal(r.ok, true);
271
+ assert.match(seen[0].url, /escalation\.create$/);
272
+ assert.ok(seen[0].body.title, "hq requires a title, not a `summary`");
273
+ assert.equal(seen[0].body.taskId, "T-7", "hq requires a taskId or channelId");
274
+ }
275
+ } finally {
276
+ globalThis.fetch = orig;
277
+ }
278
+ });
279
+
280
+ test("no options AND no target REFUSES loudly rather than 400ing on the wire", async () => {
281
+ const root = enrolledRoot();
282
+ const orig = globalThis.fetch;
283
+ let called = 0;
284
+ globalThis.fetch = async () => { called += 1; return new Response("{}", { status: 200 }); };
285
+ try {
286
+ const eff = defaultEffects({ agentRoot: root, me: "M-1" });
287
+ const r = await eff
288
+ .escalate({ decision: { key: "k", surface: "approval", reason: "r", why: [], options: [] }, candidate: { ids: {} } });
289
+ {
290
+ assert.equal(r.ok, false);
291
+ assert.match(r.error, /needs a taskId or channelId/);
292
+ assert.match(r.error, /Nothing was raised/);
293
+ assert.equal(called, 0, "a call that cannot succeed must not be made");
294
+ }
295
+ } finally {
296
+ globalThis.fetch = orig;
297
+ }
298
+ });
299
+
300
+ test("errText renders an RPC error frame as a line, never `[object Object]`", () => {
301
+ assert.equal(errText({ code: "BAD_REQUEST", message: "title is required" }), "BAD_REQUEST: title is required");
302
+ assert.equal(errText("plain"), "plain");
303
+ assert.equal(errText(null), null);
304
+ assert.notEqual(String(errText({ code: "X" })), "[object Object]");
305
+ });
@@ -137,6 +137,7 @@ export async function handleInboundEvent(o = {}) {
137
137
 
138
138
  const res = await drive(decision, {
139
139
  candidate: o.candidate,
140
+ item: o.item,
140
141
  effects: o.effects,
141
142
  agentRoot: o.agentRoot,
142
143
  nowMs,
@@ -319,26 +319,60 @@ export function fromInboxItem(item, o = {}) {
319
319
  }
320
320
 
321
321
  const mentions = !!(item.priority_signals && item.priority_signals.mentions_agent);
322
- let reason = RECONSTRUCTED_REASON[surface] || "direct";
323
- if (mentions && (reason === "thread" || reason === "shared" || reason === "owner")) {
324
- // The one signal the flattening preserves, and it upgrades a lane-tier
325
- // reason to a structural one — being named IS the strongest reason there is.
326
- reason = "named";
327
- why.push("priority_signals.mentions_agent → the item names me outright (T0)");
322
+
323
+ // PREFER THE REAL REASON. `lib/org/inbound/directedness.mjs` already worked
324
+ // out WHY this event is mine and `lib/channels/inbox-item.mjs` now carries the
325
+ // answer across the flattening as `direct_reason`. Guessing it from a
326
+ // per-surface default is a last resort, not the normal path: the guess feeds
327
+ // `disposition.tierOf`, so being wrong about the reason can be being wrong
328
+ // about the tier, and therefore about the decision. Observed: an approval
329
+ // blocking a task the seat REVIEWS was journalled "directed: approver on
330
+ // approval" — a false statement about the agent's own reasoning.
331
+ const carried = s(item.direct_reason);
332
+ let reason;
333
+ if (carried) {
334
+ reason = carried;
335
+ why.push(`org inbox item carries its directedness reason: ${reason}`);
336
+ } else {
337
+ reason = RECONSTRUCTED_REASON[surface] || "direct";
338
+ if (mentions && (reason === "thread" || reason === "shared" || reason === "owner")) {
339
+ // The one signal the flattening preserves, and it upgrades a lane-tier
340
+ // reason to a structural one — being named IS the strongest reason there is.
341
+ reason = "named";
342
+ why.push("priority_signals.mentions_agent → the item names me outright (T0)");
343
+ }
344
+ degraded.push(`verdict_reconstructed:${surface}`);
345
+ why.push(`org inbox item: reconstructing directed:true (it was written because the join said so), reason ${reason}`);
328
346
  }
329
- degraded.push(`verdict_reconstructed:${surface}`);
330
- why.push(`org inbox item: reconstructing directed:true (it was written because the join said so), reason ${reason}`);
331
347
 
332
348
  const ids = {};
333
349
  if (item.channel_id) ids.channelId = s(item.channel_id);
334
350
  if (item.thread_id) ids.threadId = s(item.thread_id);
335
351
  if (ref && ref.entityId) ids.entityId = ref.entityId;
336
352
  if (item.id) ids.messageId = s(item.id);
353
+ // The surface's own subject id (the blocked task, the decision, the file).
354
+ // Named per family so the consumers that ask for `ids.taskId` find it where
355
+ // they already look.
356
+ if (item.scope_id) {
357
+ const scopeId = s(item.scope_id);
358
+ const family = SURFACE_FAMILY[surface];
359
+ if (family === "decision") ids.decisionId = scopeId;
360
+ else if (family === "files") ids.fileId = scopeId;
361
+ else ids.taskId = scopeId;
362
+ }
337
363
 
338
364
  const candidate = candidateFrom({
339
365
  seq: ref ? ref.seq : null,
340
366
  family: SURFACE_FAMILY[surface],
341
- kind: s(item.kind) || surface,
367
+ // PREFER THE CHAIN KIND. `item.event_kind` is the hq event kind the wide
368
+ // projection carried through the flattening (`item.blocked`,
369
+ // `item.commented`, …). `item.kind` is only the SURFACE, and ten board kinds
370
+ // share the surface `task_comment` — so without this an obligation written
371
+ // `{topic:task, kind:blocked}` binds on the push lane (where the candidate
372
+ // still has the chain kind) and reads UNCOVERED on the poll lane.
373
+ // `kindAliases` still derives the surface tokens from the verdict, so both
374
+ // vocabularies stay bindable on both lanes.
375
+ kind: s(item.event_kind) || s(item.kind) || surface,
342
376
  entityId: ref ? ref.entityId : s(item.id) || null,
343
377
  actor: s(item.sender_id) || s(item.sender),
344
378
  at: s(item.timestamp) || s(o.now),
@@ -341,3 +341,49 @@ test("KIND_SURFACE covers every event kind the channel contract ships", async ()
341
341
  const missing = EVENT_KINDS.filter((k) => !["reaction", "channel_cc"].includes(k) && !KIND_SURFACE[k]);
342
342
  assert.deepEqual(missing, [], "a shipped event kind with no surface would fall through to the default policy");
343
343
  });
344
+
345
+ // ───────────────────────────────────────────────────────────────────────────
346
+ // The poll lane must agree with the push lane about WHY an event is mine.
347
+ // ───────────────────────────────────────────────────────────────────────────
348
+
349
+ test("a carried `direct_reason` beats the per-surface guess — the guess sets the TIER", () => {
350
+ // Before this, `fromInboxItem` re-derived the reason from a default table, so
351
+ // an approval blocking a task the seat REVIEWS was journalled as "directed:
352
+ // approver on approval". Since `disposition.tierOf` maps the reason onto the
353
+ // T0/T1 tier, guessing wrong is deciding wrong.
354
+ const guessed = fromInboxItem({
355
+ raw_ref: "cohort:approval:aprv_1:99",
356
+ kind: "approval",
357
+ id: "cohort-approval-99",
358
+ timestamp: NOW,
359
+ }, { me: ME });
360
+ assert.equal(guessed.verdict.reason, "approver", "the fallback guess is unchanged");
361
+ assert.ok(guessed.degraded.some((d) => /verdict_reconstructed/.test(d)));
362
+
363
+ const carried = fromInboxItem({
364
+ raw_ref: "cohort:approval:aprv_1:99",
365
+ kind: "approval",
366
+ id: "cohort-approval-99",
367
+ timestamp: NOW,
368
+ direct_reason: "reviewer",
369
+ }, { me: ME });
370
+ assert.equal(carried.verdict.reason, "reviewer");
371
+ assert.ok(
372
+ !carried.degraded.some((d) => /verdict_reconstructed/.test(d)),
373
+ "a carried reason is not a reconstruction and must not be reported as one",
374
+ );
375
+ });
376
+
377
+ test("`scope_id` lands on the id its family is looked up by", () => {
378
+ const approval = fromInboxItem(
379
+ { raw_ref: "cohort:approval:aprv_1:99", kind: "approval", id: "i", timestamp: NOW, scope_id: "T-9" },
380
+ { me: ME },
381
+ );
382
+ assert.equal(approval.candidate.ids.taskId, "T-9", "effects.escalate reads ids.taskId");
383
+
384
+ const decision = fromInboxItem(
385
+ { raw_ref: "cohort:decision:dec_1:99", kind: "decision", id: "i", timestamp: NOW, scope_id: "D-1" },
386
+ { me: ME },
387
+ );
388
+ assert.equal(decision.candidate.ids.decisionId, "D-1");
389
+ });
@@ -162,6 +162,11 @@ export async function processOne(o = {}) {
162
162
  const res = await handleInboundEvent({
163
163
  candidate: norm.candidate,
164
164
  verdict: norm.verdict,
165
+ // The ORIGINATING item travels with the decision. `intake` distils it down
166
+ // to a candidate + verdict — which is right for DECIDING — but an effect
167
+ // that has to hand the work to something else (the schedule queue, and any
168
+ // later drain) needs the subject back: the title, the body, the raw_ref.
169
+ item: o.item,
165
170
  me: o.me,
166
171
  history,
167
172
  runtime: o.runtime,