@cohortapp/agent-sdk 2.5.1 → 2.6.1

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 (107) hide show
  1. package/bin/maestro.mjs +305 -89
  2. package/bin/maestro.test.mjs +357 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/classifier.test.mjs +18 -9
  91. package/scripts/daemon/deliver.mjs +314 -0
  92. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  93. package/scripts/daemon/dispatcher.mjs +64 -6
  94. package/scripts/daemon/responder-cost.test.mjs +68 -0
  95. package/scripts/daemon/responder.mjs +351 -298
  96. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  97. package/scripts/maintenance/backup-run.mjs +415 -0
  98. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  99. package/scripts/org/send-orgmail.mjs +16 -0
  100. package/scripts/record-receipt.sh +63 -0
  101. package/scripts/restore-from-backup.sh +14 -3
  102. package/scripts/restore-from-backup.test.mjs +8 -5
  103. package/scripts/send-email-threaded.py +47 -0
  104. package/scripts/send-sms.sh +4 -0
  105. package/scripts/send-whatsapp.sh +4 -0
  106. package/scripts/setup/init-backup.mjs +93 -38
  107. package/scripts/slack-send.sh +12 -0
@@ -7,16 +7,24 @@
7
7
  * - the native function-call path (`lib/tool-definitions.js` category 7 +
8
8
  * `lib/action-executor.js` executor routing).
9
9
  *
10
- * 28 curated tools + 5 email tools + 5 artifact tools + 69 agent-desk tools
10
+ * 38 curated tools + 5 email tools + 5 artifact tools + 69 agent-desk tools
11
11
  * (mail app / files / calendar / crm / books / meetings.recapFile / directory /
12
12
  * design / call working sessions — the additive families register only when
13
13
  * the vendored protocol carries them — see emailFamilyAvailable /
14
- * artifactFamilyAvailable / deskFamilyAvailable) — NOT a 406-method
15
- * one-to-one mirror. The long tail
14
+ * artifactFamilyAvailable / deskFamilyAvailable) — NOT a one-to-one mirror of
15
+ * the 460-method protocol table. The long tail
16
16
  * stays reachable through
17
17
  * the `org_rpc` / `org_read` escape hatches, which validate against the frozen
18
18
  * protocol table before any network I/O and are deliberately `access:"admin"`.
19
19
  *
20
+ * WHAT "REACHABLE THROUGH THE ESCAPE HATCH" IS WORTH. `access:"admin"` puts
21
+ * org_rpc/org_read in the CEO tier ONLY (lib/tool-definitions.js: they are
22
+ * withheld from writeToolsLeadership and from the default lookup set). So for a
23
+ * leadership or default agent, "the long tail is reachable via org_rpc" is
24
+ * false — a capability with no curated tool simply does not exist for it. When
25
+ * a human-UI verb matters to an ordinary agent, it needs a CURATED tool; the
26
+ * escape hatch is not a parity argument, it is an operator's console.
27
+ *
20
28
  * Governance parity (spec §1.5):
21
29
  * - Tools that emit human-visible outbound content carry `outbound: true`
22
30
  * (exactly messaging_send + email_send + email_draft_send +
@@ -76,6 +84,11 @@ import {
76
84
  ESCALATION_SEVERITIES,
77
85
  MEMORY_CLASSES,
78
86
  } from "./param-contract.mjs";
87
+ // Delivery receipts. A spawned session speaks to humans through THIS surface,
88
+ // in its own process; the receipt is the only way the daemon that spawned it can
89
+ // later tell "the session answered" from "the session exited 0 and said
90
+ // nothing". Fail-open by construction — recordOutbound never throws.
91
+ import { recordOutbound } from "../comms/receipts.mjs";
79
92
 
80
93
  /** Default org server origin when neither env nor config names one. */
81
94
  export const DEFAULT_COHORT_BASE = "https://os.cohortapp.com";
@@ -219,11 +232,38 @@ export const ORG_TOOLS = Object.freeze([
219
232
  {
220
233
  name: "org_directory",
221
234
  title: "Org directory",
222
- description: "The org roster: members + teams from the /v1 directory read.",
235
+ description:
236
+ "The org roster: members + teams from the /v1 directory read. Each AI member carries " +
237
+ "`presence` — {lane, detail, lastBeatMs, lastSeenMs} — which is the SAME green/amber/grey " +
238
+ "dot a human sees next to that colleague, from the same server-side resolver: lane " +
239
+ "\"online\" = available, \"indisposed\" = alive but blocked (detail says why, in the words " +
240
+ "the human reads), \"offline\" = genuinely dark, \"unknown\" = never beaten. Quote `lane` " +
241
+ "and `detail` rather than guessing from `status`, whose `ts` is client-stamped and can be " +
242
+ "skewed. Human members carry presence: null — that is \"not this derivation's question\", " +
243
+ "NOT \"offline\".",
223
244
  input_schema: S({}),
224
245
  access: "read",
225
246
  binding: { kind: "read", path: "directory" },
226
247
  },
248
+ {
249
+ name: "org_pulse",
250
+ title: "What needs attention",
251
+ description:
252
+ "The founder's home surface, as the human sees it (GET /v1/ops?pulse=1): `stats` " +
253
+ "{agentsRunning, blocked, decisionsToday, needs}, `needs` — the attention list in the " +
254
+ "human's own order, severity and wording (escalations, approvals, blocked colleagues) — " +
255
+ "and `activity`, the last 12 live-rail sentences (each carries an `age` such as 12m or 6d — a RELATIVE age, not a timestamp; the array is newest-first). Use this to answer \"what needs my " +
256
+ "human right now?\" and \"what just happened?\" instead of re-deriving either from raw " +
257
+ "board/decision reads: this IS the number on their screen, so a re-derivation can only " +
258
+ "disagree with it. Org-wide and PUBLIC-channel-scoped; it deliberately carries no " +
259
+ "per-person mentions, threads or drafts, because this plane resolves no viewer and an " +
260
+ "empty \"nothing mentions you\" would be a false negative. `pulse: null` means the " +
261
+ "derivation failed — say so, do not report zero. Also returns the plain probe fields " +
262
+ "(chainHead, members, openAlerts, readyTasks).",
263
+ input_schema: S({}),
264
+ access: "read",
265
+ binding: { kind: "read", path: "ops", query: { pulse: "1" } },
266
+ },
227
267
  {
228
268
  name: "org_snapshot",
229
269
  title: "Org snapshot",
@@ -274,12 +314,20 @@ export const ORG_TOOLS = Object.freeze([
274
314
  title: "Send org message",
275
315
  description:
276
316
  "Post a message into an org channel (messaging.send). Send-gate screened locally; " +
277
- "the server enforces channel ACLs + pairing. An idempotencyId is auto-minted for dedup.",
317
+ "the server enforces channel ACLs + pairing. An idempotencyId is auto-minted for dedup. " +
318
+ "`mentions` TAGS people — an @mention is what turns a message in a busy room into an ask " +
319
+ "someone is accountable for answering; without it the message is decoration. Pass member " +
320
+ "ids; they are normalised to the {memberId} objects hq validates.",
278
321
  input_schema: S(
279
322
  {
280
323
  channelId: str("Channel id (use messaging_open_dm for a member DM)."),
281
324
  body: str("Message body."),
282
325
  threadRootId: str("Reply under this thread root (optional)."),
326
+ mentions: arr(
327
+ { type: "string" },
328
+ "Member ids to @mention. Tag only the people who must ACT — a mention is a claim on " +
329
+ "someone's attention, and tagging bystanders is how a room gets muted.",
330
+ ),
283
331
  },
284
332
  ["channelId", "body"],
285
333
  ),
@@ -297,6 +345,244 @@ export const ORG_TOOLS = Object.freeze([
297
345
  access: "write",
298
346
  binding: { kind: "rpc", method: "channel.resolveOrCreateDm" },
299
347
  },
348
+ {
349
+ name: "messaging_open_group",
350
+ title: "Open a group conversation",
351
+ description:
352
+ "Open a conversation with two or more people (channel.createConversation) and get back a " +
353
+ "channelId for messaging_send. One other person collapses to the deterministic 1:1 DM; two " +
354
+ "or more creates a GROUP_DM. Use this when several people must decide something TOGETHER — " +
355
+ "if they only need to know separately, DM them, and if a room already holds all of them, " +
356
+ "post there instead. A new room is the most expensive thing you can make: everyone in it " +
357
+ "carries it forever.",
358
+ input_schema: S(
359
+ {
360
+ memberIds: arr({ type: "string" }, "The people to put in the room (not yourself)."),
361
+ name: str("Optional room name — say what the room is FOR, not who is in it."),
362
+ },
363
+ ["memberIds"],
364
+ ),
365
+ access: "write",
366
+ binding: { kind: "rpc", method: "channel.createConversation" },
367
+ },
368
+ {
369
+ name: "messaging_create_channel",
370
+ title: "Create a channel",
371
+ description:
372
+ "Create a named org channel (channel.create). Reach for this ONLY when the work is durable " +
373
+ "and the audience standing — a channel is permanent and sits in everyone's sidebar. For a " +
374
+ "one-off decision use messaging_open_group. Slug must be lowercase alphanumerics + hyphens.",
375
+ input_schema: S(
376
+ {
377
+ slug: str("URL-safe slug: lowercase alphanumerics and hyphens."),
378
+ name: str("Human name."),
379
+ kind: str("PUBLIC | PRIVATE (default PRIVATE)."),
380
+ topic: str("What this channel is for."),
381
+ members: arr({ type: "string" }, "Member ids to seat alongside you."),
382
+ },
383
+ ["slug", "name"],
384
+ ),
385
+ access: "write",
386
+ binding: { kind: "rpc", method: "channel.create" },
387
+ },
388
+ {
389
+ name: "messaging_add_to_channel",
390
+ title: "Add someone to a channel",
391
+ description:
392
+ "Add a member to a channel you are already in (channel.addMember). Idempotent — re-adding " +
393
+ "is a no-op. Prefer this over creating a second room when the conversation is already " +
394
+ "happening somewhere and one more person needs to be in it.",
395
+ input_schema: S({ channelId: str("Channel id."), memberId: str("Who to add.") }, ["channelId", "memberId"]),
396
+ access: "write",
397
+ binding: { kind: "rpc", method: "channel.addMember" },
398
+ },
399
+ {
400
+ name: "engage_colleagues",
401
+ title: "Bring the right people in",
402
+ description:
403
+ "Decide whether anyone actually needs to be pulled into a piece of work — and if so WHO and " +
404
+ "in WHICH surface — then do it. This is the judged path: it applies one threshold (engage " +
405
+ "only when a specific named person's action or consent is a PRECONDITION for the work to " +
406
+ "proceed or be safe, and you cannot supply it yourself), resolves the addressee from the org " +
407
+ "directory rather than guessing, picks the cheapest surface that reaches them (reply in " +
408
+ "place > DM > an existing room > a new room), tags them, and refuses to repeat an ask that " +
409
+ "is already outstanding. It will frequently decide NOBODY needs involving and say so — that " +
410
+ "is the tool working, not failing. Use it instead of hand-rolling a DM whenever you are " +
411
+ "blocked, need consent for something irreversible or external, need a required review, or " +
412
+ "have to tell someone a commitment to them is going to slip. Describe the work honestly: " +
413
+ "`selfServeExhausted` must be true for a blocked ask, because asking a human for something " +
414
+ "you could still work out yourself is outsourcing your own job.",
415
+ input_schema: S(
416
+ {
417
+ id: str("Stable id for this piece of work (used to dedupe and rate-limit)."),
418
+ title: str("One line naming the work."),
419
+ summary: str("What is going on, in the words the reader needs."),
420
+ state: str("in_progress | blocked | failed | done."),
421
+ scope: str("Subject area — used to find who owns it when nobody is named."),
422
+ blockerKind: str("decision | access | information | approval | capacity."),
423
+ blockerDetail: str("Exactly what is missing."),
424
+ blockerOwnerId: str("Member id, if you already know whose it is."),
425
+ selfServeExhausted: {
426
+ type: "boolean",
427
+ description: "True only if you have genuinely exhausted what you can do alone.",
428
+ },
429
+ irreversible: { type: "boolean", description: "The next step cannot be undone." },
430
+ external: { type: "boolean", description: "The next step leaves the org." },
431
+ financial: { type: "boolean", description: "The next step spends money." },
432
+ reviewRequired: { type: "boolean", description: "A policy or role requires sign-off." },
433
+ reviewerId: str("The required reviewer, if named."),
434
+ commitmentToMemberId: str("Who you promised this to."),
435
+ commitmentAtRisk: { type: "boolean", description: "That promise will be missed." },
436
+ channelId: str("Where the ask came from, so a reply lands in context."),
437
+ threadRootId: str("The thread it came from."),
438
+ requesterId: str("Who asked."),
439
+ question: str("The single question you need answered."),
440
+ options: arr({ type: "string" }, "Two or more concrete choices, if it is a decision."),
441
+ tried: arr({ type: "string" }, "What you already tried — this is what stops it reading as lazy."),
442
+ interestedParties: arr(
443
+ { type: "string" },
444
+ "People who might like to know but whose actions do NOT change. They are recorded as " +
445
+ "considered-and-declined, never messaged.",
446
+ ),
447
+ durable: { type: "boolean", description: "If a new room is needed, make it a named channel." },
448
+ },
449
+ ["id", "title", "state"],
450
+ ),
451
+ access: "write",
452
+ // Deliberately NOT `outbound: true`. The send-gate still runs on every
453
+ // message this tool produces — inside `messaging.sendMessage`, which is the
454
+ // one shared chokepoint — and it screens the COMPOSED BODY. Screening here
455
+ // instead would screen the work description, which is not what gets sent:
456
+ // the fallback shape stringifies the whole input blob, so the gate would be
457
+ // judging JSON that never reaches anybody while the real prose went
458
+ // unexamined. One gate, on the actual text.
459
+ binding: { kind: "local" },
460
+ },
461
+ // ── per-message actions ──────────────────────────────────────────────────
462
+ // The human long-press action sheet (react · reply · save · mark unread ·
463
+ // delete) had no curated counterpart: an agent could SEND into a room but
464
+ // could not react to, retract, bookmark or unread anything in it. The long
465
+ // tail was nominally reachable through org_rpc, but org_rpc is access:"admin"
466
+ // and lands only in the CEO tier (lib/tool-definitions.js), so for every
467
+ // leadership/default agent these verbs did not exist at all. They are
468
+ // always-on rather than desk-gated: the base `calling`/`messaging`/`channel`
469
+ // families have been vendored since SP3, so probing a newer method would
470
+ // withhold them on a protocol that carries them perfectly well.
471
+ {
472
+ name: "messaging_react",
473
+ title: "React to a message",
474
+ description:
475
+ "Add or remove YOUR reaction on a message (messaging.react). You must belong to the " +
476
+ "message's channel. Idempotent: re-adding the same emoji is a no-op, as is removing one " +
477
+ "you never added. Use a reaction where a reply would just be noise (acknowledging, " +
478
+ "voting, signalling 'seen') — it is the lightest honest signal you have.",
479
+ input_schema: S(
480
+ {
481
+ messageId: str("Message id."),
482
+ emoji: str("The emoji (or short name), e.g. \"✅\"."),
483
+ op: str("add (default) | remove."),
484
+ },
485
+ ["messageId", "emoji"],
486
+ ),
487
+ access: "write",
488
+ binding: { kind: "rpc", method: "messaging.react" },
489
+ },
490
+ {
491
+ name: "messaging_bookmark",
492
+ title: "Save a message",
493
+ description:
494
+ "Save a message to YOUR bookmarks (messaging.bookmark) — the Save row on the human " +
495
+ "action sheet. Private to you; it neither notifies the author nor changes the room.",
496
+ input_schema: S({ messageId: str("Message id.") }, ["messageId"]),
497
+ access: "write",
498
+ binding: { kind: "rpc", method: "messaging.bookmark" },
499
+ },
500
+ {
501
+ name: "messaging_delete",
502
+ title: "Delete your message",
503
+ description:
504
+ "Soft-delete a message you AUTHORED (messaging.delete) — the body is redacted, the " +
505
+ "audit trail is not. You cannot delete a colleague's message. Use this to retract " +
506
+ "something you posted in error; say so in the room rather than deleting silently.",
507
+ input_schema: S({ messageId: str("Message id (must be yours).") }, ["messageId"]),
508
+ access: "write",
509
+ binding: { kind: "rpc", method: "messaging.delete" },
510
+ },
511
+ {
512
+ name: "messaging_mark_unread_from",
513
+ title: "Mark a room unread from here",
514
+ description:
515
+ "Move YOUR OWN read cursor in a channel back to just before a message " +
516
+ "(channel.markUnreadFrom), so everything from it onward recomputes as unread — the " +
517
+ "human 'Mark unread from here'. Affects only your cursor; nobody else's badge moves.",
518
+ input_schema: S(
519
+ {
520
+ channelId: str("Channel id."),
521
+ fromTimestamp: str("ISO datetime of the message to become the first unread one."),
522
+ },
523
+ ["channelId", "fromTimestamp"],
524
+ ),
525
+ access: "write",
526
+ binding: { kind: "rpc", method: "channel.markUnreadFrom" },
527
+ },
528
+ // ── huddles ──────────────────────────────────────────────────────────────
529
+ // The human surfaces collapsed audio+video onto ONE headphones control, and a
530
+ // calendar Join and an impromptu huddle resolve to the SAME room. The agent
531
+ // surface had the working-session tools (share/step/end) but no way to OPEN
532
+ // or ENTER the room they present into — a share tool whose callId nothing
533
+ // curated could produce.
534
+ {
535
+ name: "org_huddle_start",
536
+ title: "Start a huddle",
537
+ description:
538
+ "Open a live huddle (calling.start) — either anchored to a CHANNEL (every member of " +
539
+ "that room can walk in; you must belong to it) or as a direct call to named " +
540
+ "participantIds. Returns {callId, token, room}; feed callId to org_huddle_join for " +
541
+ "others, or to the share tools to present a surface. Give at least one of channelId or " +
542
+ "participantIds. Announce it in the room first — a huddle that opens unannounced is a " +
543
+ "ring nobody expected.",
544
+ input_schema: S(
545
+ {
546
+ channelId: str("Anchor the huddle to this channel (you must be a member)."),
547
+ participantIds: arr({ type: "string" }, "Member ids to ring (direct call)."),
548
+ kind: str("ONE_ON_ONE (default) | GROUP | CHANNEL."),
549
+ },
550
+ [],
551
+ ),
552
+ access: "write",
553
+ binding: { kind: "rpc", method: "calling.start" },
554
+ },
555
+ {
556
+ name: "org_huddle_join",
557
+ title: "Join a huddle",
558
+ description:
559
+ "Join a live call you were invited to, or any huddle anchored to a channel you belong " +
560
+ "to (calling.join). Idempotent — rejoining returns a fresh token. Refused when the host " +
561
+ "has LOCKED the floor, when you were removed, or when the call has ended; relay that " +
562
+ "reason plainly rather than retrying.",
563
+ input_schema: S(
564
+ {
565
+ callId: str("Live call id."),
566
+ audioOnly: bool("Join without video (default false)."),
567
+ },
568
+ ["callId"],
569
+ ),
570
+ access: "write",
571
+ binding: { kind: "rpc", method: "calling.join" },
572
+ },
573
+ {
574
+ name: "org_huddle_end",
575
+ title: "End a huddle",
576
+ description:
577
+ "End a live huddle for EVERYONE and tear down its room (calling.end) — the close half of " +
578
+ "org_huddle_start, so a huddle you opened is never a room you cannot shut. Only a " +
579
+ "PARTICIPANT may end a call; ending one that has already ended returns CONFLICT, which " +
580
+ "means it is already closed, not that you failed. This ends the call for everybody, not " +
581
+ "just for you — say so in the room first.",
582
+ input_schema: S({ callId: str("Live call id.") }, ["callId"]),
583
+ access: "write",
584
+ binding: { kind: "rpc", method: "calling.end" },
585
+ },
300
586
  // ── board / tasks ────────────────────────────────────────────────────────
301
587
  {
302
588
  name: "board_ready",
@@ -418,6 +704,50 @@ export const ORG_TOOLS = Object.freeze([
418
704
  access: "write",
419
705
  binding: { kind: "rpc", method: "board.addTaskComment" },
420
706
  },
707
+ {
708
+ name: "work_track",
709
+ title: "Show your working",
710
+ description:
711
+ "Record where you are on the ask you are currently handling (board.track). The daemon " +
712
+ "already opens the board row when it takes the ask on and closes it when the session ends " +
713
+ "— you do NOT need to do either. Use this MID-WORK, when something a waiting human would " +
714
+ "want to know changes: you have started the substantive part (stage 'working'), you are " +
715
+ "stuck on something outside your control (stage 'blocked' — say what you need, and pass " +
716
+ "`notify` with the member id of whoever can unstick it), or the work is finished but wants " +
717
+ "a human's eyes (stage 'review'). Pass `channelId` and `messageId` of the message that " +
718
+ "asked, so this lands on the SAME row rather than opening a second one; the server dedupes " +
719
+ "on that pair, so a repeated call is free. Attach what you produced with `attachments`. " +
720
+ "Do not narrate every step — a comment per meaningful change, not per file edited.",
721
+ input_schema: S(
722
+ {
723
+ channelId: str("The channel the ask arrived on."),
724
+ messageId: str("The id of the message that asked. Identifies the board row."),
725
+ stage: {
726
+ type: "string",
727
+ enum: ["working", "blocked", "review"],
728
+ description:
729
+ "Where the work now stands. 'working' = started; 'blocked' = stuck and needs someone; " +
730
+ "'review' = finished, needs a human's eyes.",
731
+ },
732
+ note: str("What changed, in a sentence or two, for the person waiting."),
733
+ notify: {
734
+ type: "array",
735
+ items: { type: "string" },
736
+ description:
737
+ "Member ids to @-tag on this step — the people who genuinely need to act or know. " +
738
+ "Tagging everyone teaches everyone to ignore the tag.",
739
+ },
740
+ attachments: {
741
+ type: "array",
742
+ description: "Files this step produced: [{name, mimeType?, sizeBytes?, dataUrl}].",
743
+ items: { type: "object" },
744
+ },
745
+ },
746
+ ["channelId", "messageId", "stage"],
747
+ ),
748
+ access: "write",
749
+ binding: { kind: "rpc", method: "board.track" },
750
+ },
421
751
  // ── decisions / approvals ────────────────────────────────────────────────
422
752
  {
423
753
  name: "decision_list",
@@ -505,11 +835,43 @@ export const ORG_TOOLS = Object.freeze([
505
835
  title: "Search org knowledge",
506
836
  description:
507
837
  "Search the ACL'd shared org knowledge plane (knowledge.search). Every retrieval is " +
508
- "audit-logged server-side; the server decides what this agent may see.",
838
+ "audit-logged server-side; the server decides what this agent may see. This is the " +
839
+ "NARROW view — shared facts only. Prefer memory_recall, which is the same index with " +
840
+ "your own perspective, scope/entity/time controls and drill-down pointers.",
509
841
  input_schema: S({ query: str("Free-text query."), limit: num("Max hits (optional).") }, ["query"]),
510
842
  access: "read",
511
843
  binding: { kind: "rpc", method: "knowledge.search" },
512
844
  },
845
+ {
846
+ name: "memory_recall",
847
+ title: "Recall from memory",
848
+ description:
849
+ "Layered semantic recall over your unified agent memory (memory.recall) — the SAME " +
850
+ "recall the chat lane's colleagues use, so a seat driven from here reasons off the same " +
851
+ "memory as the same colleague in a channel. Read-only. Defaults to org-wide; `scope: " +
852
+ "\"self\"` narrows to your own perspective, `channelId` to one room, and refType+refId " +
853
+ "(given TOGETHER) pins one entity and wins over scope. `depth: \"specific\"` for a " +
854
+ "pinpoint fact, \"broad\" for context. ACL is server-side and fail-closed: the seat is " +
855
+ "your own key's, never a parameter, so you cannot recall another colleague's private " +
856
+ "view. An unkeyed embedder or an empty index returns {found:false, items:[]} — that is " +
857
+ "an honest \"nothing indexed\", not an error to retry.",
858
+ input_schema: S(
859
+ {
860
+ query: str("What to recall (2–500 chars)."),
861
+ scope: str("org (default) | self."),
862
+ channelId: str("Narrow to one channel (overrides scope)."),
863
+ refType: str("Entity type to pin (with refId; wins over scope)."),
864
+ refId: str("Entity id to pin (with refType)."),
865
+ since: str("ISO datetime lower bound."),
866
+ until: str("ISO datetime upper bound."),
867
+ depth: str("broad | specific."),
868
+ k: num("Max items, 1–25."),
869
+ },
870
+ ["query"],
871
+ ),
872
+ access: "read",
873
+ binding: { kind: "rpc", method: "memory.recall" },
874
+ },
513
875
  {
514
876
  name: "knowledge_append",
515
877
  title: "Append org knowledge",
@@ -859,8 +1221,13 @@ export const ORG_TOOLS = Object.freeze([
859
1221
  name: "email_triage",
860
1222
  title: "Triage mail thread",
861
1223
  description:
862
- "One triage facet per call (email.triage): set priority (P0|P1|P2), category, tags, " +
863
- "starred, read, OR assigneeMemberId — the same one-verb-per-call rule the Inbox UI applies.",
1224
+ "One triage facet per call (email.triage): priority (P0|P1|P2) + category + tags, OR " +
1225
+ "assigneeMemberId, OR muted, OR done — the same one-verb-per-call rule the Inbox UI " +
1226
+ "applies (the server refuses combinations with BAD_REQUEST). starred / read / " +
1227
+ "unreadFromMessageId ride alongside the first two, but read and unreadFromMessageId are " +
1228
+ "mutually exclusive (both move the same read cursor). muted = no pings or badges, still " +
1229
+ "filed; done = cleared from the working list (false reopens); unreadFromMessageId = the " +
1230
+ "long-press 'Mark unread' a human gets on ONE message, leaving everything above it read.",
864
1231
  input_schema: S(
865
1232
  {
866
1233
  threadId: str("Thread id."),
@@ -868,7 +1235,12 @@ export const ORG_TOOLS = Object.freeze([
868
1235
  category: str("Category label (optional)."),
869
1236
  tags: arr({ type: "string" }, "Replace the tag set (≤12; optional)."),
870
1237
  starred: bool("Star/unstar."),
871
- read: bool("Mark read/unread."),
1238
+ read: bool("Mark the whole thread read/unread."),
1239
+ unreadFromMessageId: str(
1240
+ "Mark unread FROM this message down (per-message granularity; exclusive with read).",
1241
+ ),
1242
+ muted: bool("Mute/unmute — stays filed, stops pinging."),
1243
+ done: bool("Mark done (true) or reopen (false); alone in its call."),
872
1244
  assigneeMemberId: str("Assign to this member (null to unassign)."),
873
1245
  },
874
1246
  ["threadId"],
@@ -2448,7 +2820,54 @@ function readToFrame(r) {
2448
2820
  * screenImpl?, bumpImpl? } (injectables for tests)
2449
2821
  * @returns {Promise<object>} res frame
2450
2822
  */
2823
+ /**
2824
+ * Which org tools put words in front of a human, and where to find the room.
2825
+ *
2826
+ * A spawned session is a separate `claude` process: when it answers the user,
2827
+ * the daemon that spawned it learns nothing. That is why the daemon used to
2828
+ * treat exit 0 as proof of a reply and mark the ask handled forever — including
2829
+ * the observed case of a 241-second session whose own final text was "Nothing
2830
+ * was sent." A receipt written here crosses the process boundary and lets the
2831
+ * daemon ask the real question instead of the convenient one.
2832
+ */
2833
+ const HUMAN_FACING_TOOLS = {
2834
+ messaging_send: (i) => i.channelId || i.channel,
2835
+ email_send: (i) => i.to || (Array.isArray(i.recipients) ? i.recipients[0] : null),
2836
+ email_reply: (i) => i.to || i.threadId,
2837
+ };
2838
+
2839
+ /**
2840
+ * Execute an org tool and, when it was one that speaks to a person and it
2841
+ * succeeded, leave a delivery receipt.
2842
+ *
2843
+ * Strictly observational: the receipt is written AFTER the frame is resolved and
2844
+ * only on success, it cannot alter the frame, and any failure writing it is
2845
+ * swallowed by `recordOutbound` itself. A bookkeeping fault must never break a
2846
+ * send — but the absence of a receipt is read as "the human heard nothing",
2847
+ * which errs toward one extra message rather than silence.
2848
+ */
2451
2849
  export async function executeOrgTool(name, input = {}, o = {}) {
2850
+ const frame = await executeOrgToolInner(name, input, o);
2851
+ try {
2852
+ const locate = HUMAN_FACING_TOOLS[name];
2853
+ if (locate && frame && frame.ok) {
2854
+ const channel = locate(input || {});
2855
+ if (channel) {
2856
+ recordOutbound({
2857
+ service: name.startsWith("email") ? "gmail" : "cohort",
2858
+ channel,
2859
+ kind: "session",
2860
+ via: name,
2861
+ chars: typeof (input && input.body) === "string" ? input.body.length : null,
2862
+ agentRoot: (o && o.agentRoot) || process.env.AGENT_ROOT || process.env.AGENT_DIR,
2863
+ });
2864
+ }
2865
+ }
2866
+ } catch { /* never let a receipt break a send */ }
2867
+ return frame;
2868
+ }
2869
+
2870
+ async function executeOrgToolInner(name, input = {}, o = {}) {
2452
2871
  try {
2453
2872
  const cfg = resolveOrgToolConfig(o);
2454
2873
  // A curated tool first; else a granted integration tool from the disk cache
@@ -2507,6 +2926,78 @@ export async function executeOrgTool(name, input = {}, o = {}) {
2507
2926
  case "org_whoami":
2508
2927
  return whoami(cfg, { fetchImpl: o.fetchImpl, env: o.env || process.env });
2509
2928
 
2929
+ case "engage_colleagues": {
2930
+ // The judged path. `engage` loads the directory + rooms + this agent's
2931
+ // engagement history, applies the threshold, and either sends or records
2932
+ // why it didn't. Both outcomes come back as an OK frame carrying the
2933
+ // reasoning: "nobody needed involving" is an answer, not an error, and
2934
+ // returning it as an error would teach the model to retry until it
2935
+ // manages to interrupt somebody.
2936
+ const { engage } = await import("./engagement.mjs");
2937
+ const i = input || {};
2938
+ const work = {
2939
+ id: String(i.id || ""),
2940
+ title: String(i.title || ""),
2941
+ summary: i.summary ? String(i.summary) : "",
2942
+ scope: i.scope ? String(i.scope) : "",
2943
+ state: String(i.state || "in_progress"),
2944
+ durable: i.durable === true,
2945
+ selfServeExhausted: i.selfServeExhausted === true,
2946
+ ...(i.blockerKind || i.blockerDetail || i.blockerOwnerId
2947
+ ? {
2948
+ blocker: {
2949
+ kind: String(i.blockerKind || "information"),
2950
+ scope: i.scope ? String(i.scope) : "",
2951
+ detail: i.blockerDetail ? String(i.blockerDetail) : "",
2952
+ ownerId: i.blockerOwnerId ? String(i.blockerOwnerId) : null,
2953
+ },
2954
+ }
2955
+ : {}),
2956
+ risk: { irreversible: i.irreversible === true, external: i.external === true, financial: i.financial === true },
2957
+ ...(i.reviewRequired || i.reviewerId
2958
+ ? { review: { required: i.reviewRequired === true, reviewerId: i.reviewerId ? String(i.reviewerId) : null } }
2959
+ : {}),
2960
+ ...(i.commitmentToMemberId
2961
+ ? { commitment: { toMemberId: String(i.commitmentToMemberId), atRisk: i.commitmentAtRisk === true } }
2962
+ : {}),
2963
+ origin: {
2964
+ channelId: i.channelId ? String(i.channelId) : null,
2965
+ threadRootId: i.threadRootId ? String(i.threadRootId) : null,
2966
+ requesterId: i.requesterId ? String(i.requesterId) : null,
2967
+ },
2968
+ question: i.question ? String(i.question) : "",
2969
+ options: Array.isArray(i.options) ? i.options.map(String) : [],
2970
+ tried: Array.isArray(i.tried) ? i.tried.map(String) : [],
2971
+ interestedParties: Array.isArray(i.interestedParties) ? i.interestedParties.map(String) : [],
2972
+ };
2973
+ if (!work.id || !work.title) return errFrame("BAD_REQUEST", "engage_colleagues: id and title are required");
2974
+ // Own member id, so the judgement never routes the ask back to this
2975
+ // seat. Unresolvable is survivable — `resolveTargets` only uses it to
2976
+ // exclude self and to find a supervisor edge.
2977
+ const envv = o.env || process.env;
2978
+ const me = String(envv.COHORT_AGENT_ID || envv.COHORT_MEMBER_ID || "");
2979
+ const run = await engage(work, {
2980
+ cfg: { org: { cohort: { enabled: true, base: cfg.base, token: cfg.token, orgId: cfg.orgId, agentId: me } } },
2981
+ agentRoot: cfg.agentRoot,
2982
+ me,
2983
+ fetchImpl: o.fetchImpl,
2984
+ });
2985
+ const d = run.decision || {};
2986
+ return okFrame({
2987
+ engaged: run.engaged,
2988
+ outcome: run.outcome,
2989
+ surface: run.surface,
2990
+ channelId: run.channelId,
2991
+ trigger: d.trigger || null,
2992
+ reasonCode: d.reasonCode || null,
2993
+ reason: d.reason || null,
2994
+ targets: (d.targets || []).map((t) => ({ memberId: t.memberId, name: t.name, rung: t.rung, why: t.why })),
2995
+ consideredNotEngaged: d.consideredNotEngaged || [],
2996
+ why: d.why || [],
2997
+ error: run.error || null,
2998
+ });
2999
+ }
3000
+
2510
3001
  case "org_events_tail": {
2511
3002
  const q = {};
2512
3003
  if (input && input.cursor != null) q.cursor = input.cursor;
@@ -2562,7 +3053,12 @@ export async function executeOrgTool(name, input = {}, o = {}) {
2562
3053
  default: {
2563
3054
  const binding = def.binding || {};
2564
3055
  if (binding.kind === "read") {
2565
- return readToFrame(await read(binding.path, callOpts));
3056
+ // `binding.query` lets a curated tool PIN a read's query params (e.g.
3057
+ // org_pulse → ops?pulse=1). The alternative — telling every agent to
3058
+ // pass ?pulse=1 through org_read — is not reachable at all below the
3059
+ // CEO tier, since org_read is access:"admin". Same encoder org_read
3060
+ // uses, so one read path cannot encode two ways.
3061
+ return readToFrame(await read(withQuery(binding.path, binding.query), callOpts));
2566
3062
  }
2567
3063
  if (binding.kind === "rpc") {
2568
3064
  // SP5: a granted integration tool proxies to integration.invokeTool