@cohortapp/agent-sdk 2.5.0 → 2.6.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 (108) hide show
  1. package/bin/maestro.mjs +185 -88
  2. package/bin/maestro.test.mjs +175 -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/inbound/hydrate.mjs +107 -15
  48. package/lib/org/inbound/hydrate.test.mjs +127 -0
  49. package/lib/org/messaging.mjs +230 -3
  50. package/lib/org/messaging.test.mjs +110 -1
  51. package/lib/org/param-contract.mjs +56 -2
  52. package/lib/org/param-contract.test.mjs +26 -0
  53. package/lib/org/protocol.checksum +1 -1
  54. package/lib/org/protocol.mjs +5 -0
  55. package/lib/org/protocol.test.mjs +7 -1
  56. package/lib/org/tool-surface.mjs +506 -10
  57. package/lib/org/tool-surface.test.mjs +191 -7
  58. package/lib/org/ui-parity.mjs +333 -6
  59. package/lib/org/ui-parity.test.mjs +96 -3
  60. package/lib/org/work-ledger.mjs +241 -0
  61. package/lib/org/work-ledger.test.mjs +237 -0
  62. package/lib/plan/adoption-e2e.test.mjs +366 -0
  63. package/lib/plan/budget-enforcement.test.mjs +400 -0
  64. package/lib/plan/budget-runtime.mjs +215 -0
  65. package/lib/plan/compile.mjs +201 -5
  66. package/lib/plan/compile.test.mjs +19 -5
  67. package/lib/plan/emit.mjs +8 -0
  68. package/lib/plan/emit.test.mjs +18 -0
  69. package/lib/resource-governor.mjs +58 -12
  70. package/lib/resource-governor.test.mjs +41 -1
  71. package/lib/security/audit-engine.mjs +45 -8
  72. package/lib/security/audit-engine.test.mjs +35 -0
  73. package/lib/setup/enroll-from-cohort.mjs +14 -1
  74. package/lib/setup/sections/mandate.mjs +48 -7
  75. package/lib/setup/sections/mandate.test.mjs +17 -2
  76. package/lib/setup/sections/orgmail.mjs +10 -2
  77. package/lib/setup/state.mjs +83 -2
  78. package/lib/telemetry/collect.mjs +360 -20
  79. package/lib/telemetry/collect.test.mjs +266 -0
  80. package/package.json +1 -1
  81. package/scripts/cost/track-claude-usage.mjs +207 -48
  82. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  83. package/scripts/daemon/agent-daemon.mjs +315 -17
  84. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  85. package/scripts/daemon/assurance.mjs +944 -0
  86. package/scripts/daemon/assurance.test.mjs +668 -0
  87. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  88. package/scripts/daemon/cadence-consumer.mjs +147 -9
  89. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  90. package/scripts/daemon/cadence-handlers.mjs +158 -0
  91. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  92. package/scripts/daemon/deliver.mjs +314 -0
  93. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  94. package/scripts/daemon/dispatcher.mjs +64 -6
  95. package/scripts/daemon/responder-cost.test.mjs +68 -0
  96. package/scripts/daemon/responder.mjs +351 -298
  97. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  98. package/scripts/maintenance/backup-run.mjs +415 -0
  99. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  100. package/scripts/org/send-orgmail.mjs +16 -0
  101. package/scripts/record-receipt.sh +63 -0
  102. package/scripts/restore-from-backup.sh +14 -3
  103. package/scripts/restore-from-backup.test.mjs +8 -5
  104. package/scripts/send-email-threaded.py +47 -0
  105. package/scripts/send-sms.sh +4 -0
  106. package/scripts/send-whatsapp.sh +4 -0
  107. package/scripts/setup/init-backup.mjs +93 -38
  108. package/scripts/slack-send.sh +12 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ui-parity.test.mjs — Agent UI-parity helpers (full human-action mirror).
3
3
  *
4
- * The 328 wrappers in ui-parity.mjs are each a one-liner over call() from
4
+ * The 359 wrappers in ui-parity.mjs are each a one-liner over call() from
5
5
  * client.mjs, so client.test.mjs already proves the transport contract (headers,
6
6
  * idempotency, fail-open, frame normalisation). Here we just prove a representative
7
7
  * slice across families ROUTES to the correct method name, POSTs the params body,
@@ -326,7 +326,7 @@ test("a human-gated directory verb surfaces the server's refusal verbatim, never
326
326
  assert.match(frame.error.message, /human/);
327
327
  });
328
328
 
329
- test("every desk protocol method has exactly one ui-parity wrapper (196 desks / 328 total)", async () => {
329
+ test("every desk protocol method has exactly one ui-parity wrapper (196 desks / 359 total)", async () => {
330
330
  const fs = await import("node:fs");
331
331
  const path = await import("node:path");
332
332
  const url = await import("node:url");
@@ -335,8 +335,22 @@ test("every desk protocol method has exactly one ui-parity wrapper (196 desks /
335
335
  const called = [...src.matchAll(/return call\("([a-z]+\.[A-Za-z]+)"/g)].map((x) => x[1]);
336
336
  // 2026-08 mobile-parity delta: +16 desk methods (email 5, files 9,
337
337
  // calendar.ask, crm.ask) → 312 → 328.
338
- assert.equal(called.length, 328, "one call() site per wrapper");
338
+ // 2026-08 conversation-parity delta: the module claimed "one per agent-facing
339
+ // org method the human app exposes" while carrying 6 of messaging's 15 and
340
+ // NONE of calling's 22 — so the huddle control, the calendar Join, host
341
+ // mute/lock/remove, hand-raising and in-call chat had no wrapper at all.
342
+ // +9 messaging +22 calling → 328 → 359.
343
+ assert.equal(called.length, 359, "one call() site per wrapper");
339
344
  assert.equal(new Set(called).size, called.length, "no duplicate method bindings");
345
+ // The two conversation families are now WHOLE, which is what makes the
346
+ // module docblock's claim true rather than aspirational.
347
+ for (const fam of ["messaging", "calling"]) {
348
+ const famMethods = Object.entries((await import("./protocol.mjs")).METHODS)
349
+ .filter(([, d]) => d.family === fam)
350
+ .map(([n]) => n);
351
+ const unwrapped = famMethods.filter((m) => !new Set(called).has(m));
352
+ assert.deepEqual(unwrapped, [], `every ${fam}.* method is wrapped`);
353
+ }
340
354
  const p = await import("./protocol.mjs");
341
355
  const deskFams = new Set(["books", "calendar", "crm", "directory", "files"]);
342
356
  const deskMethods = Object.entries(p.METHODS)
@@ -346,3 +360,82 @@ test("every desk protocol method has exactly one ui-parity wrapper (196 desks /
346
360
  const missing = deskMethods.filter((m) => !bound.has(m));
347
361
  assert.deepEqual(missing, [], "every desk method is wrapped");
348
362
  });
363
+
364
+ /**
365
+ * The methods in a CLAIMED family that ui-parity deliberately does NOT wrap,
366
+ * with the reason. The module docblock lists its families flatly, which reads as
367
+ * "every method in these families" — so the exceptions have to be written down
368
+ * and enforced, or the docblock is an orphan describing a surface that is not
369
+ * there. Ledger, not an excuse list: each line says which OTHER surface carries
370
+ * the verb.
371
+ */
372
+ const DELIBERATELY_UNWRAPPED = new Map([
373
+ // board — the agent work kernel; no human control, curated tools instead.
374
+ ["board.create", "agent work kernel"],
375
+ ["board.claim", "agent work kernel — curated board_claim"],
376
+ ["board.heartbeat", "agent work kernel (lease slide)"],
377
+ ["board.complete", "agent work kernel — curated board_complete"],
378
+ ["board.block", "agent work kernel"],
379
+ ["board.comment", "agent work kernel (kanban comments = board.addTaskComment)"],
380
+ ["board.decompose", "agent work kernel — governance-gated §0.4"],
381
+ ["board.assign", "agent work kernel — governance-gated §0.4"],
382
+ ["board.link", "agent work kernel (DAG edge)"],
383
+ ["board.requestReview", "agent work kernel"],
384
+ ["board.resolveReview", "agent work kernel"],
385
+ // A human never calls this: it records where an AGENT is on an ask it took
386
+ // on. The daemon drives it from its own dispatch path (lib/org/work-ledger)
387
+ // and a session may nudge it mid-work via the curated `work_track` tool.
388
+ ["board.track", "agent work kernel — daemon-driven, curated work_track"],
389
+ // decision — the §0.4 control-plane acts, not the human ledger surface.
390
+ ["decision.propose", "control plane — curated decision_propose"],
391
+ ["decision.adopt", "control plane — governance-gated §0.4"],
392
+ ["decision.supersede", "control plane — governance-gated §0.4"],
393
+ // the rest — a different artifact or a different plane entirely.
394
+ ["escalation.ask", "OpenQuestion artifact, not the escalation strip"],
395
+ ["escalation.answer", "OpenQuestion artifact, not the escalation strip"],
396
+ ["memory.recall", "semantic retrieval — curated memory_recall"],
397
+ ["integration.toolsetVersion", "runtime toolset plane (daemon poll)"],
398
+ ["integration.listAgentTools", "runtime toolset plane (daemon poll)"],
399
+ ["integration.invokeTool", "runtime toolset plane (executor)"],
400
+ ["meetings.record", "knowledge plane writer, not the desk verb"],
401
+ ]);
402
+
403
+ test("the claimed families are wrapped WHOLE, minus a declared, self-cleaning exception list", async () => {
404
+ const fs = await import("node:fs");
405
+ const path = await import("node:path");
406
+ const url = await import("node:url");
407
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
408
+ const src = fs.readFileSync(path.join(here, "ui-parity.mjs"), "utf8");
409
+ const wrapped = new Set([...src.matchAll(/return call\("([a-z]+\.[A-Za-z]+)"/g)].map((x) => x[1]));
410
+ const p = await import("./protocol.mjs");
411
+
412
+ // Exactly the families the module docblock names (branding lives in client.mjs).
413
+ const CLAIMED = [
414
+ "charter", "member", "team", "channel", "persona", "profile", "sop",
415
+ "escalation", "annotation", "contact", "file", "memory", "compact",
416
+ "notification", "decision", "messaging", "calling", "board", "invitation",
417
+ "user", "settings", "billing", "org", "integration", "email", "artifact",
418
+ "books", "calendar", "crm", "directory", "files",
419
+ ];
420
+ const undeclared = [];
421
+ for (const name of Object.keys(p.METHODS)) {
422
+ const fam = name.slice(0, name.indexOf("."));
423
+ if (!CLAIMED.includes(fam)) continue;
424
+ if (wrapped.has(name)) continue;
425
+ if (DELIBERATELY_UNWRAPPED.has(name)) continue;
426
+ undeclared.push(name);
427
+ }
428
+ // If this fails: add the wrapper, or add a DELIBERATELY_UNWRAPPED line saying
429
+ // which surface carries the verb instead. Silence is the one option gone.
430
+ assert.deepEqual(undeclared, [], "every method of a claimed family is wrapped or declared");
431
+
432
+ // Self-cleaning both ways: a stale exception (now wrapped, or no longer a
433
+ // real method) must be deleted rather than left as decoration.
434
+ const nowWrapped = [...DELIBERATELY_UNWRAPPED.keys()].filter((m) => wrapped.has(m));
435
+ assert.deepEqual(nowWrapped, [], "these are wrapped now — delete their exception lines");
436
+ const notMethods = [...DELIBERATELY_UNWRAPPED.keys()].filter((m) => !p.methodDef(m));
437
+ assert.deepEqual(notMethods, [], "these are not protocol methods — delete their exception lines");
438
+
439
+ // And the docblock's own numbers stay honest.
440
+ assert.equal(wrapped.size, 359, "the docblock's wrapper count");
441
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * lib/org/work-ledger.mjs — the daemon's half of "make the work visible".
3
+ *
4
+ * THE COMPLAINT THIS EXISTS FOR. A colleague writes to the agent. The typing
5
+ * indicator comes on. Then nothing — for fifteen, thirty, forty-five minutes,
6
+ * because the reply IS the session and the session is long. Meanwhile the board
7
+ * says nothing was ever asked, nothing is in progress, and when the session dies
8
+ * (it does: `onClose({ok:false})` emits telemetry and messages nobody) the board
9
+ * still says nothing. The work is invisible from the moment it is accepted to
10
+ * the moment it silently fails.
11
+ *
12
+ * THE FIX IS NOT HERE. It is in hq's `server/work/ledger.ts`, which hq's own
13
+ * LLM responder calls directly and which this module reaches over the wire as
14
+ * `board.track`. All of the judgement — whether the ask deserves a board row,
15
+ * find-or-open, the column ladder, comment de-duplication, attachment de-
16
+ * duplication, who gets @-tagged and when — lives THERE, once. This file maps a
17
+ * daemon inbox item onto that call and gets out of the way.
18
+ *
19
+ * That asymmetry is deliberate. `4d762d30` means a member is animated by hq OR
20
+ * by their daemon, never both, and the owner must not be able to tell which is
21
+ * driving. Two implementations of "when does an ask deserve a board row" would
22
+ * be two answers within a week. So there is one, and this is a client of it.
23
+ *
24
+ * IDENTITY, NOT DIGESTS. We send the ask's identity (`service`, `channelId`,
25
+ * `messageId`) and let the server derive the dedupe key. The hashing algorithm
26
+ * therefore exists in exactly one repo, and a daemon can never compute a
27
+ * different key than hq would for the same message — which is what makes
28
+ * re-delivery after a dead session find the SAME row instead of opening a
29
+ * second one.
30
+ *
31
+ * FAIL-OPEN, NEVER SILENT. Every path returns a benign `{tracked:false, reason}`
32
+ * rather than throwing: a board write must never cost the user their reply. But
33
+ * the reason always comes back and the daemon always logs it — the entire reason
34
+ * this workstream exists is that a process died quietly.
35
+ *
36
+ * Pure-core + injectable IO: `trackImpl` / `cfg` / `fetchImpl` are all
37
+ * injectable, so the tests drive the real logic with no server. ESM, Node
38
+ * builtins only.
39
+ *
40
+ * @module lib/org/work-ledger
41
+ */
42
+
43
+ "use strict";
44
+
45
+ import { isEnabled as orgEnabled, configFromAgent, trackWork } from "./client.mjs";
46
+
47
+ /** The stages the ledger understands. Mirrors hq's `WorkStage`. */
48
+ export const WORK_STAGES = ["accepted", "working", "blocked", "review", "done", "failed"];
49
+
50
+ const MAX_NOTE = 4000;
51
+ const MAX_TITLE = 200;
52
+ const MAX_BODY = 20000;
53
+
54
+ /**
55
+ * Which Cohort channel did this inbox item arrive on?
56
+ *
57
+ * A board row inherits the visibility of the channel it lands on, so this is
58
+ * also the privacy answer: a DM's task lands on the DM's own board and is seen
59
+ * by exactly the DM's members. (That is strictly better than
60
+ * `session-outcomes.mjs`, which had to REFUSE every DM outright because
61
+ * `board.create` gives a channel-less row org-wide visibility. The owner's asks
62
+ * arrive by DM; refusing them meant refusing the actual use case.)
63
+ *
64
+ * Returns null for anything that is not a Cohort conversation — a Slack channel
65
+ * or an email thread is not a board we can write to, and guessing would put a
66
+ * task on a stranger's board.
67
+ *
68
+ * @param {object} item inbox item
69
+ * @returns {string|null}
70
+ */
71
+ export function cohortChannelId(item) {
72
+ if (!item || typeof item !== "object") return null;
73
+ const service = String(item.service || "").trim().toLowerCase();
74
+ if (service !== "cohort") return null;
75
+ const id = String(item.cohort_channel_id || item.channel_id || "").trim();
76
+ return id || null;
77
+ }
78
+
79
+ /**
80
+ * The originating Cohort entity id — the thing the ask IS, which for a messaging
81
+ * surface is the message id.
82
+ *
83
+ * Read off `raw_ref` (`cohort:<surface>:<entityId>:<seq>`, stamped by
84
+ * `lib/org/inbound/project.mjs` and carried verbatim through the YAML
85
+ * round-trip), falling back to the inbox id, which `inboxIdFor` mints as
86
+ * `cohort-<messageId>` for messaging. Both are STABLE across a re-delivery —
87
+ * which is the whole point: a session that died and left the item un-processed
88
+ * comes back with the same id, computes the same key server-side, and finds the
89
+ * SAME row rather than opening a second one.
90
+ *
91
+ * @param {object} item
92
+ * @returns {string|null}
93
+ */
94
+ export function cohortMessageId(item) {
95
+ if (!item || typeof item !== "object") return null;
96
+ const raw = String(item.raw_ref || "").trim();
97
+ if (raw.startsWith("cohort:")) {
98
+ const parts = raw.split(":");
99
+ if (parts.length >= 3 && parts[2]) return parts[2];
100
+ }
101
+ const id = String(item.id || "").trim();
102
+ if (id.startsWith("cohort-")) return id.slice("cohort-".length) || null;
103
+ return id || null;
104
+ }
105
+
106
+ /**
107
+ * The requester's Cohort member id.
108
+ *
109
+ * Deliberately usually NULL. The flattened inbox item carries the sender's
110
+ * NAME, not their id (`lib/channels/inbox-item.mjs` collapses `from:{id,name}`
111
+ * to a single `sender` string), and a name is not something to tag anybody on.
112
+ * Rather than plumb a new field through the poller, the YAML and the parser, the
113
+ * server resolves the requester from the originating message's own `authorId` —
114
+ * which it already holds, and which a client cannot spoof. This reads an id only
115
+ * when a caller genuinely has one.
116
+ *
117
+ * @param {object} item
118
+ * @returns {string|null}
119
+ */
120
+ export function requesterMemberId(item) {
121
+ if (!item || typeof item !== "object") return null;
122
+ const id = String(item.sender_member_id || item.cohort_author_id || "").trim();
123
+ return id || null;
124
+ }
125
+
126
+ /** Clip a string, tolerating non-strings. */
127
+ function clip(s, n) {
128
+ return String(s == null ? "" : s).slice(0, n);
129
+ }
130
+
131
+ /**
132
+ * Record one step of the agent's work on the board.
133
+ *
134
+ * @param {object} a
135
+ * @param {object} a.item the inbox item the work is for
136
+ * @param {string} a.stage one of {@link WORK_STAGES}
137
+ * @param {boolean} [a.deferred] could this NOT be answered inside the turn?
138
+ * (true for the session path — the same signal that fires the holding
139
+ * message; false for the quick-reply path). The server's gate reads it.
140
+ * @param {string} [a.title] row title (defaults server-side from the ask)
141
+ * @param {string} [a.detail] row detail, written once at open
142
+ * @param {string} [a.note] a progress comment for this step
143
+ * @param {string[]} [a.notify] extra member ids to @-tag on this step
144
+ * @param {Array<{name:string,mimeType?:string,sizeBytes?:number,dataUrl:string}>} [a.attachments]
145
+ * @param {object} [a.source] free provenance stamped into the row's `why`
146
+ * @param {object} [a.cfg] agent org config (config/org.yaml shape)
147
+ * @param {Function} [a.trackImpl] injectable board.track
148
+ * @param {Function} [a.fetchImpl] injectable fetch
149
+ * @returns {Promise<{tracked:boolean, reason:string, taskId?:string|null, col?:string|null,
150
+ * created?:boolean, moved?:boolean, commented?:boolean,
151
+ * attached?:number, tagged?:number}>}
152
+ */
153
+ export async function recordWorkStep(a = {}) {
154
+ try {
155
+ const cfg = a.cfg || {};
156
+ if (!orgEnabled(cfg)) return { tracked: false, reason: "org-disabled" };
157
+
158
+ const stage = String(a.stage || "").trim();
159
+ if (!WORK_STAGES.includes(stage)) return { tracked: false, reason: "bad-stage" };
160
+
161
+ const channelId = cohortChannelId(a.item);
162
+ if (!channelId) return { tracked: false, reason: "not-a-cohort-channel" };
163
+ const messageId = cohortMessageId(a.item);
164
+ if (!messageId) return { tracked: false, reason: "no-message-id" };
165
+
166
+ const conn = configFromAgent(cfg);
167
+ const impl = a.trackImpl || trackWork;
168
+
169
+ const params = {
170
+ service: "cohort",
171
+ channelId,
172
+ messageId,
173
+ stage,
174
+ // The flattened inbox item carries the ask under `content`.
175
+ body: clip(a.item && (a.item.content || a.item.text || a.item.subject), MAX_BODY),
176
+ // `mentions_agent` is the ingest layer's REAL directedness verdict (a DM,
177
+ // an @mention, a named address, a direct assignment) — `project.mjs` sets
178
+ // it only when the address is genuine, never guessed. A channel message
179
+ // the agent merely overheard is not assigned work.
180
+ directed: !!(a.item && a.item.priority_signals && a.item.priority_signals.mentions_agent),
181
+ authorKind: a.item && a.item.author_kind === "AI_AGENT" ? "AI_AGENT" : "HUMAN",
182
+ deferred: a.deferred === true,
183
+ };
184
+ const requester = requesterMemberId(a.item);
185
+ if (requester) params.requesterId = requester;
186
+ if (a.title) params.title = clip(a.title, MAX_TITLE);
187
+ if (a.detail) params.detail = clip(a.detail, MAX_BODY);
188
+ if (a.note) params.note = clip(a.note, MAX_NOTE);
189
+ if (Array.isArray(a.notify) && a.notify.length) params.notify = a.notify.slice(0, 8);
190
+ if (Array.isArray(a.attachments) && a.attachments.length) {
191
+ params.attachments = a.attachments.slice(0, 6);
192
+ }
193
+ if (a.source && typeof a.source === "object") {
194
+ // The wire schema is `.strict()` and string-valued; drop anything else
195
+ // rather than have the whole step rejected for one stray field.
196
+ const src = {};
197
+ for (const [k, v] of Object.entries(a.source)) {
198
+ if (v == null) continue;
199
+ src[String(k)] = clip(v, 300);
200
+ }
201
+ if (Object.keys(src).length) params.source = src;
202
+ }
203
+
204
+ const res = await impl(params, {
205
+ base: conn.base,
206
+ token: conn.token,
207
+ fetchImpl: a.fetchImpl,
208
+ // Same ask + same stage = the same call. A retry after a timeout must not
209
+ // double-post the tag.
210
+ idempotencyKey: `board.track:${channelId}:${messageId}:${stage}`,
211
+ });
212
+
213
+ // The client never throws; a failure comes back as an error frame.
214
+ if (!res || res.error) {
215
+ const reason = res && res.error ? `${res.error.code || "error"}: ${res.error.message || ""}` : "no-response";
216
+ return { tracked: false, reason: reason.slice(0, 200) };
217
+ }
218
+ const out = res.result !== undefined ? res.result : res;
219
+ return {
220
+ tracked: !!(out && out.tracked),
221
+ reason: (out && out.reason) || "ok",
222
+ taskId: (out && out.taskId) || null,
223
+ col: (out && out.col) || null,
224
+ created: !!(out && out.created),
225
+ moved: !!(out && out.moved),
226
+ commented: !!(out && out.commented),
227
+ attached: (out && out.attached) || 0,
228
+ tagged: (out && out.tagged) || 0,
229
+ };
230
+ } catch (err) {
231
+ return { tracked: false, reason: `error: ${err && err.message ? err.message : String(err)}`.slice(0, 200) };
232
+ }
233
+ }
234
+
235
+ export default {
236
+ WORK_STAGES,
237
+ cohortChannelId,
238
+ cohortMessageId,
239
+ requesterMemberId,
240
+ recordWorkStep,
241
+ };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * lib/org/work-ledger.test.mjs — the daemon's half of the work ledger.
3
+ *
4
+ * What is worth testing here is deliberately NARROW, because the policy is not
5
+ * here: the threshold, the idempotency and the tagging all live server-side in
6
+ * hq's `server/work/ledger.ts`, so re-testing them in this repo would be
7
+ * testing a second implementation that must not exist. What IS this module's
8
+ * job — and what these tests pin — is the mapping: does a real flattened inbox
9
+ * item become the right wire call, does a re-delivery of the SAME item produce
10
+ * the SAME identity, and does every failure path come back as a benign,
11
+ * REASONED refusal rather than an exception on the dispatch path.
12
+ *
13
+ * Run: node --test lib/org/work-ledger.test.mjs
14
+ */
15
+ "use strict";
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ import {
21
+ WORK_STAGES,
22
+ cohortChannelId,
23
+ cohortMessageId,
24
+ requesterMemberId,
25
+ recordWorkStep,
26
+ } from "./work-ledger.mjs";
27
+
28
+ const CFG = {
29
+ org: { cohort: { enabled: true, base: "https://os.example.test", token: "tok_test" } },
30
+ };
31
+
32
+ /** A flattened Cohort inbox item, in the shape lib/channels/inbox-item.mjs writes. */
33
+ function inboxItem(over = {}) {
34
+ return {
35
+ id: "cohort-msg_abc123",
36
+ service: "cohort",
37
+ channel: "Owner ↔ Isla",
38
+ channel_id: "ch_dm_1",
39
+ sender: "The Owner",
40
+ sender_privilege: "ceo",
41
+ timestamp: "2026-08-13T05:47:51.000Z",
42
+ subject: "Direct message",
43
+ content: "fix these issues you just noted please and push their fixes to git",
44
+ thread_id: "",
45
+ is_reply: false,
46
+ priority_signals: {
47
+ from_ceo: true,
48
+ tagged_urgent: false,
49
+ contains_deadline: false,
50
+ mentions_agent: true,
51
+ },
52
+ raw_ref: "cohort:message:msg_abc123:4821",
53
+ source: "cohort",
54
+ ...over,
55
+ };
56
+ }
57
+
58
+ /** Capture the params a step would send, without a server. */
59
+ function capture(result = { result: { tracked: true, taskId: "t_1", col: "triage", created: true } }) {
60
+ const calls = [];
61
+ const impl = async (params, opts) => {
62
+ calls.push({ params, opts });
63
+ return result;
64
+ };
65
+ return { calls, impl };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Identity — the thing that makes a re-delivery find the same row
70
+ // ---------------------------------------------------------------------------
71
+
72
+ test("cohortMessageId reads the entity id off raw_ref", () => {
73
+ assert.equal(cohortMessageId(inboxItem()), "msg_abc123");
74
+ });
75
+
76
+ test("cohortMessageId falls back to the inbox id when raw_ref is absent", () => {
77
+ assert.equal(cohortMessageId(inboxItem({ raw_ref: "" })), "msg_abc123");
78
+ });
79
+
80
+ test("a re-delivered item yields the SAME identity", () => {
81
+ // The dead-session case: the item was never marked processed, so the poller
82
+ // hands back a byte-identical file. Same channel + same message = same key
83
+ // server-side = the same board row, not a second one.
84
+ const first = inboxItem();
85
+ const again = inboxItem();
86
+ assert.equal(cohortChannelId(first), cohortChannelId(again));
87
+ assert.equal(cohortMessageId(first), cohortMessageId(again));
88
+ });
89
+
90
+ test("requesterMemberId is null for a normal item — the server resolves it", () => {
91
+ // The flat item carries the sender's NAME, not their id. Guessing a member id
92
+ // from a display name is how you tag the wrong person.
93
+ assert.equal(requesterMemberId(inboxItem()), null);
94
+ assert.equal(requesterMemberId({ sender_member_id: "m_owner" }), "m_owner");
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // The mapping
99
+ // ---------------------------------------------------------------------------
100
+
101
+ test("an accepted step sends the ask's identity and the deferred fact", async () => {
102
+ const { calls, impl } = capture();
103
+ const res = await recordWorkStep({
104
+ item: inboxItem(),
105
+ stage: "accepted",
106
+ deferred: true,
107
+ note: "Picked this up.",
108
+ cfg: CFG,
109
+ trackImpl: impl,
110
+ source: { service: "cohort", trace_id: "tr_9" },
111
+ });
112
+
113
+ assert.equal(calls.length, 1);
114
+ const { params, opts } = calls[0];
115
+ assert.equal(params.service, "cohort");
116
+ assert.equal(params.channelId, "ch_dm_1");
117
+ assert.equal(params.messageId, "msg_abc123");
118
+ assert.equal(params.stage, "accepted");
119
+ assert.equal(params.deferred, true);
120
+ assert.equal(params.directed, true);
121
+ assert.equal(params.authorKind, "HUMAN");
122
+ assert.equal(
123
+ params.body,
124
+ "fix these issues you just noted please and push their fixes to git",
125
+ );
126
+ assert.equal(params.note, "Picked this up.");
127
+ assert.deepEqual(params.source, { service: "cohort", trace_id: "tr_9" });
128
+ // Same ask + same stage = the same call, so a retry after a timeout cannot
129
+ // double-post the tag.
130
+ assert.equal(opts.idempotencyKey, "board.track:ch_dm_1:msg_abc123:accepted");
131
+ assert.equal(opts.base, CFG.org.cohort.base);
132
+ assert.equal(res.tracked, true);
133
+ assert.equal(res.taskId, "t_1");
134
+ });
135
+
136
+ test("directedness comes from the ingest layer's real verdict, never a guess", async () => {
137
+ const { calls, impl } = capture();
138
+ await recordWorkStep({
139
+ item: inboxItem({
140
+ priority_signals: { mentions_agent: false, from_ceo: false, tagged_urgent: false, contains_deadline: false },
141
+ }),
142
+ stage: "accepted",
143
+ cfg: CFG,
144
+ trackImpl: impl,
145
+ });
146
+ // An overheard channel message is not assigned work — and the SERVER is what
147
+ // acts on that, so the client's job is only to report it honestly.
148
+ assert.equal(calls[0].params.directed, false);
149
+ });
150
+
151
+ test("a session death is reported as `failed`, not swallowed", async () => {
152
+ const { calls, impl } = capture({ result: { tracked: true, taskId: "t_1", col: "blocked", moved: true } });
153
+ const res = await recordWorkStep({
154
+ item: inboxItem(),
155
+ stage: "failed",
156
+ deferred: true,
157
+ note: "The session handling this ended without completing (exit 143).",
158
+ cfg: CFG,
159
+ trackImpl: impl,
160
+ });
161
+ assert.equal(calls[0].params.stage, "failed");
162
+ assert.equal(res.col, "blocked");
163
+ });
164
+
165
+ test("attachments and extra tags are forwarded, and bounded", async () => {
166
+ const { calls, impl } = capture();
167
+ await recordWorkStep({
168
+ item: inboxItem(),
169
+ stage: "review",
170
+ cfg: CFG,
171
+ trackImpl: impl,
172
+ notify: Array.from({ length: 20 }, (_, i) => `m_${i}`),
173
+ attachments: Array.from({ length: 20 }, (_, i) => ({ name: `f${i}.txt`, dataUrl: "data:text/plain;base64,eA==" })),
174
+ });
175
+ assert.equal(calls[0].params.notify.length, 8);
176
+ assert.equal(calls[0].params.attachments.length, 6);
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Fail-open, never silent
181
+ // ---------------------------------------------------------------------------
182
+
183
+ test("a non-Cohort item is refused with a reason, not an exception", async () => {
184
+ const { calls, impl } = capture();
185
+ const res = await recordWorkStep({ item: inboxItem({ service: "slack" }), stage: "accepted", cfg: CFG, trackImpl: impl });
186
+ assert.equal(res.tracked, false);
187
+ assert.equal(res.reason, "not-a-cohort-channel");
188
+ assert.equal(calls.length, 0, "no wire call for a board we cannot write to");
189
+ });
190
+
191
+ test("an item with no resolvable channel is refused", async () => {
192
+ const res = await recordWorkStep({ item: inboxItem({ channel_id: "" }), stage: "accepted", cfg: CFG, trackImpl: async () => ({}) });
193
+ assert.equal(res.tracked, false);
194
+ assert.equal(res.reason, "not-a-cohort-channel");
195
+ });
196
+
197
+ test("a bad stage is refused rather than sent", async () => {
198
+ const { calls, impl } = capture();
199
+ const res = await recordWorkStep({ item: inboxItem(), stage: "finished", cfg: CFG, trackImpl: impl });
200
+ assert.equal(res.reason, "bad-stage");
201
+ assert.equal(calls.length, 0);
202
+ });
203
+
204
+ test("org integration off ⇒ a clean skip", async () => {
205
+ const res = await recordWorkStep({ item: inboxItem(), stage: "accepted", cfg: {}, trackImpl: async () => ({}) });
206
+ assert.equal(res.tracked, false);
207
+ assert.equal(res.reason, "org-disabled");
208
+ });
209
+
210
+ test("a server error frame comes back as a REASON, never a throw", async () => {
211
+ const res = await recordWorkStep({
212
+ item: inboxItem(),
213
+ stage: "accepted",
214
+ cfg: CFG,
215
+ trackImpl: async () => ({ error: { code: "FORBIDDEN_SCOPE", message: "board.write not granted" } }),
216
+ });
217
+ assert.equal(res.tracked, false);
218
+ assert.match(res.reason, /FORBIDDEN_SCOPE/);
219
+ assert.match(res.reason, /board\.write not granted/);
220
+ });
221
+
222
+ test("a thrown transport error is contained — the dispatch path never sees it", async () => {
223
+ const res = await recordWorkStep({
224
+ item: inboxItem(),
225
+ stage: "accepted",
226
+ cfg: CFG,
227
+ trackImpl: async () => {
228
+ throw new Error("ECONNREFUSED");
229
+ },
230
+ });
231
+ assert.equal(res.tracked, false);
232
+ assert.match(res.reason, /ECONNREFUSED/);
233
+ });
234
+
235
+ test("WORK_STAGES matches the server's ladder", () => {
236
+ assert.deepEqual(WORK_STAGES, ["accepted", "working", "blocked", "review", "done", "failed"]);
237
+ });