@cohortapp/agent-sdk 2.5.1 → 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 (106) 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/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/deliver.mjs +314 -0
  91. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  92. package/scripts/daemon/dispatcher.mjs +64 -6
  93. package/scripts/daemon/responder-cost.test.mjs +68 -0
  94. package/scripts/daemon/responder.mjs +351 -298
  95. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  96. package/scripts/maintenance/backup-run.mjs +415 -0
  97. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  98. package/scripts/org/send-orgmail.mjs +16 -0
  99. package/scripts/record-receipt.sh +63 -0
  100. package/scripts/restore-from-backup.sh +14 -3
  101. package/scripts/restore-from-backup.test.mjs +8 -5
  102. package/scripts/send-email-threaded.py +47 -0
  103. package/scripts/send-sms.sh +4 -0
  104. package/scripts/send-whatsapp.sh +4 -0
  105. package/scripts/setup/init-backup.mjs +93 -38
  106. package/scripts/slack-send.sh +12 -0
@@ -0,0 +1,376 @@
1
+ /**
2
+ * lib/org/engagement-ledger.mjs — the record of who this agent pulled in, why,
3
+ * and what it deliberately did NOT do.
4
+ *
5
+ * Engagement is the one agent behaviour whose failure modes are symmetric and
6
+ * both bad: engage nobody when someone is blocked and the work dies silently;
7
+ * engage everybody and you have built a spam bot with a directory. The judgement
8
+ * that separates them lives in `engagement.mjs`. This module is its memory —
9
+ * without it the judgement is stateless, and a stateless judgement re-asks the
10
+ * same person the same question every time the same work comes round.
11
+ *
12
+ * ── WHAT GETS WRITTEN ──
13
+ * EVERY decision, engaged or not. An "I decided nobody needs to be involved"
14
+ * row is the entire point: it is the difference between a considered silence and
15
+ * a bug. `state/org/engagement.jsonl`, append-only, one JSON object per line:
16
+ *
17
+ * {
18
+ * at, atMs, when
19
+ * workId, reasonCode, trigger, what and why
20
+ * dedupeKey, (workId, reasonCode, targets) identity
21
+ * engaged: bool,
22
+ * outcome: "engaged" | "none" | "duplicate" | "rate_limited"
23
+ * | "unroutable" | "send_blocked" | "send_failed",
24
+ * surface, channelId, roomCreated: bool,
25
+ * targets: [memberId],
26
+ * why: [ audit lines ],
27
+ * resolvedAtMs: null | number set when the ask came back answered
28
+ * }
29
+ *
30
+ * ── THE DEDUPE IDENTITY ──
31
+ * `(workId, reasonCode, sorted targetIds)`. Deliberately NOT the message text:
32
+ * two differently-worded pings about the same blocker to the same person are the
33
+ * same ping, and text-keyed dedupe is exactly the bug that left 49 duplicate
34
+ * `machine_mem_high` alert rows open in production because the detail string
35
+ * carried a live measurement. Key on the identity of the ask, never on its
36
+ * rendering.
37
+ *
38
+ * ── FAIL-OPEN, BUT NOT ON THE GUARDRAIL ──
39
+ * Reads fail open to `[]` (an unreadable ledger must not wedge the agent). The
40
+ * consequence is deliberate and worth naming: with no history, dedupe cannot
41
+ * fire and the caps read as unspent, so a broken ledger biases towards sending.
42
+ * That is the right bias for a system whose owner's actual complaint is silence
43
+ * — but the caller is told (`degraded: ["ledger_unreadable"]`) rather than left
44
+ * to assume the guardrails ran.
45
+ *
46
+ * Node builtins only. ESM. Every clock and path injectable.
47
+ *
48
+ * @module lib/org/engagement-ledger
49
+ */
50
+
51
+ "use strict";
52
+
53
+ import { existsSync, readFileSync } from "node:fs";
54
+ import { join } from "node:path";
55
+ import { createHash } from "node:crypto";
56
+
57
+ import { resolveAgentRoot } from "../agent-root.mjs";
58
+ import { appendJsonl } from "../fs-atomic.mjs";
59
+
60
+ /** Where the record lives, relative to the agent root. */
61
+ export const ENGAGEMENT_LEDGER_REL = "state/org/engagement.jsonl";
62
+
63
+ const HOUR_MS = 60 * 60 * 1000;
64
+ const DAY_MS = 24 * HOUR_MS;
65
+
66
+ /**
67
+ * The caps, and why each number is what it is.
68
+ *
69
+ * These are not arbitrary. Each one answers "at what point does one more
70
+ * message stop carrying information and start carrying noise?" — and each is
71
+ * env-overridable because the right answer differs between a two-person startup
72
+ * and a fifty-seat org.
73
+ */
74
+ export const DEFAULT_LIMITS = Object.freeze({
75
+ /**
76
+ * 3/day/person. A colleague who hears from an agent three times in a day is
77
+ * being kept informed; the fourth time they start filtering it, and a filtered
78
+ * channel is a dead channel. This is the cap that protects the CHANNEL, not
79
+ * the person's time.
80
+ */
81
+ perTargetPerDay: num(process.env.ENGAGE_PER_TARGET_PER_DAY, 3),
82
+ /**
83
+ * 5 total per piece of work, ever. Work that needs a sixth engagement is not
84
+ * blocked on a person, it is badly specified — and the honest move then is to
85
+ * escalate the SHAPE of the work, not to keep pinging.
86
+ */
87
+ perWorkTotal: num(process.env.ENGAGE_PER_WORK_TOTAL, 5),
88
+ /**
89
+ * 10/hour across the whole agent. The blast-radius cap: whatever goes wrong in
90
+ * the judgement, one seat cannot generate more than ten pings in an hour.
91
+ * Sized so a genuinely bad hour (several parallel blockages) still fits, and a
92
+ * runaway loop does not.
93
+ */
94
+ globalPerHour: num(process.env.ENGAGE_GLOBAL_PER_HOUR, 10),
95
+ /**
96
+ * 2 new rooms/day. A room is the most expensive thing an agent can create —
97
+ * persistent, visible to everyone in it, and impossible to un-see. Two a day
98
+ * is "this genuinely needed its own space"; five a day is an agent that has
99
+ * confused activity with progress.
100
+ */
101
+ newRoomsPerDay: num(process.env.ENGAGE_NEW_ROOMS_PER_DAY, 2),
102
+ /**
103
+ * 6h before the same ask may be repeated to the same people. Under that, a
104
+ * repeat is a nag; over it, silence starts to look like the agent forgot.
105
+ * Only applies while the earlier ask is UNRESOLVED — an answered ask is not
106
+ * duplicated by a new one, it is superseded.
107
+ */
108
+ reengageAfterMs: num(process.env.ENGAGE_REENGAGE_AFTER_MS, 6 * HOUR_MS),
109
+ });
110
+
111
+ function num(v, dflt) {
112
+ const n = Number(v);
113
+ return Number.isFinite(n) && n > 0 ? n : dflt;
114
+ }
115
+
116
+ function ledgerPath(o = {}) {
117
+ if (o.path) return o.path;
118
+ return join(resolveAgentRoot(o.agentRoot), ENGAGEMENT_LEDGER_REL);
119
+ }
120
+
121
+ /**
122
+ * The identity of an ask: who is being asked, about what, for which reason.
123
+ * Stable across rewordings, retries and process restarts.
124
+ *
125
+ * @param {{workId?:string, reasonCode?:string, targets?:Array<string|{memberId:string}>}} o
126
+ * @returns {string} 16 hex chars
127
+ */
128
+ export function dedupeKeyFor(o = {}) {
129
+ const targets = (Array.isArray(o.targets) ? o.targets : [])
130
+ .map((t) => String(t && typeof t === "object" ? t.memberId : t || "").trim())
131
+ .filter(Boolean)
132
+ .sort();
133
+ const raw = [String(o.workId || ""), String(o.reasonCode || ""), targets.join(",")].join("|");
134
+ return createHash("sha1").update(raw).digest("hex").slice(0, 16);
135
+ }
136
+
137
+ /**
138
+ * Read the ledger. Newest last (append order). Never throws.
139
+ *
140
+ * @param {object} [o] { agentRoot, path, sinceMs, nowMs, limit }
141
+ * @returns {{rows:object[], degraded:string[]}}
142
+ */
143
+ export function loadEngagements(o = {}) {
144
+ const path = ledgerPath(o);
145
+ if (!existsSync(path)) return { rows: [], degraded: [] };
146
+ let text;
147
+ try {
148
+ text = readFileSync(path, "utf8");
149
+ } catch {
150
+ // Named, not swallowed — the caller surfaces it so a wedged guardrail is
151
+ // visible rather than merely permissive.
152
+ return { rows: [], degraded: ["ledger_unreadable"] };
153
+ }
154
+ const rows = [];
155
+ let bad = 0;
156
+ for (const line of text.split("\n")) {
157
+ const t = line.trim();
158
+ if (!t) continue;
159
+ try {
160
+ const row = JSON.parse(t);
161
+ if (row && typeof row === "object") rows.push(row);
162
+ } catch {
163
+ bad += 1; // a torn line is one lost record, not a lost ledger
164
+ }
165
+ }
166
+ const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
167
+ const kept = sinceMs == null ? rows : rows.filter((r) => Number(r.atMs) >= sinceMs);
168
+ const limited = Number.isFinite(o.limit) ? kept.slice(-o.limit) : kept;
169
+ return { rows: limited, degraded: bad ? [`ledger_torn_lines:${bad}`] : [] };
170
+ }
171
+
172
+ /**
173
+ * Append one decision — engaged or not.
174
+ * @param {object} record
175
+ * @param {object} [o] { agentRoot, path, nowMs }
176
+ * @returns {{ok:boolean, path:string}}
177
+ */
178
+ export function appendEngagement(record, o = {}) {
179
+ const path = ledgerPath(o);
180
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
181
+ const row = {
182
+ at: new Date(nowMs).toISOString(),
183
+ atMs: nowMs,
184
+ workId: record && record.workId ? String(record.workId) : "",
185
+ trigger: (record && record.trigger) || null,
186
+ reasonCode: (record && record.reasonCode) || null,
187
+ reason: (record && record.reason) || null,
188
+ dedupeKey: (record && record.dedupeKey) || null,
189
+ engaged: !!(record && record.engaged),
190
+ outcome: (record && record.outcome) || (record && record.engaged ? "engaged" : "none"),
191
+ surface: (record && record.surface) || "none",
192
+ channelId: (record && record.channelId) || null,
193
+ roomCreated: !!(record && record.roomCreated),
194
+ targets: (Array.isArray(record && record.targets) ? record.targets : []).map((t) =>
195
+ String(t && typeof t === "object" ? t.memberId : t),
196
+ ),
197
+ why: Array.isArray(record && record.why) ? record.why.map(String) : [],
198
+ resolvedAtMs: null,
199
+ };
200
+ const ok = appendJsonl(path, row);
201
+ return { ok, path, row };
202
+ }
203
+
204
+ /**
205
+ * Mark an ask answered, so the 6h re-engage clock stops applying to it. Recorded
206
+ * as its own append (the file is append-only; the newest row for a dedupeKey
207
+ * wins) rather than a rewrite, so a crash mid-update cannot corrupt history.
208
+ *
209
+ * @param {string} dedupeKey
210
+ * @param {object} [o] { agentRoot, path, nowMs, by }
211
+ */
212
+ export function markResolved(dedupeKey, o = {}) {
213
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
214
+ const ok = appendJsonl(ledgerPath(o), {
215
+ at: new Date(nowMs).toISOString(),
216
+ atMs: nowMs,
217
+ dedupeKey: String(dedupeKey || ""),
218
+ outcome: "resolved",
219
+ engaged: false,
220
+ resolvedAtMs: nowMs,
221
+ resolvedBy: o.by ? String(o.by) : null,
222
+ targets: [],
223
+ why: [],
224
+ });
225
+ return { ok };
226
+ }
227
+
228
+ /**
229
+ * Has this exact ask already gone out, and is it still outstanding?
230
+ * PURE over the rows the caller loaded.
231
+ *
232
+ * @param {object[]} rows
233
+ * @param {string} dedupeKey
234
+ * @param {number} nowMs
235
+ * @param {number} reengageAfterMs
236
+ * @returns {{duplicate:boolean, lastAtMs:number|null, resolved:boolean, ageMs:number|null}}
237
+ */
238
+ export function findOutstanding(rows, dedupeKey, nowMs, reengageAfterMs) {
239
+ const key = String(dedupeKey || "");
240
+ if (!key) return { duplicate: false, lastAtMs: null, resolved: false, ageMs: null, sends: 0 };
241
+ let lastSent = null;
242
+ let resolvedAfter = false;
243
+ // How many times this exact ask has actually gone out. The dedupe key is
244
+ // deliberately timeless — that is what makes the 6h window enforceable — so
245
+ // something else has to distinguish the FIRST ask from the re-ask at the far
246
+ // side of that window. This is that something: hq de-dupes a send on
247
+ // (org, channel, clientMsgId) and returns the existing message verbatim, so a
248
+ // re-ask carrying the first ask's message id posts nothing at all while every
249
+ // local signal reports it delivered. The count is the attempt ordinal.
250
+ let sends = 0;
251
+ for (const r of Array.isArray(rows) ? rows : []) {
252
+ if (String(r.dedupeKey || "") !== key) continue;
253
+ if (r.outcome === "resolved") {
254
+ resolvedAfter = true;
255
+ lastSent = null; // an answer supersedes everything before it
256
+ continue;
257
+ }
258
+ if (r.engaged === true) {
259
+ lastSent = Number(r.atMs) || null;
260
+ resolvedAfter = false;
261
+ sends += 1;
262
+ }
263
+ }
264
+ if (lastSent == null) return { duplicate: false, lastAtMs: null, resolved: resolvedAfter, ageMs: null, sends };
265
+ const ageMs = nowMs - lastSent;
266
+ return { duplicate: ageMs < reengageAfterMs, lastAtMs: lastSent, resolved: false, ageMs, sends };
267
+ }
268
+
269
+ /**
270
+ * Apply every cap. PURE over the loaded rows — no clock, no disk, no network —
271
+ * so the whole guardrail set is testable by handing it an array.
272
+ *
273
+ * Order matters and is deliberate: DUPLICATE first, because "you already asked
274
+ * this" is a better explanation than "you are over quota", and a duplicate must
275
+ * never consume a rate-limit slot.
276
+ *
277
+ * @param {object} o {
278
+ * rows, nowMs, dedupeKey, workId, targets:[memberId], createsRoom:boolean, limits?
279
+ * }
280
+ * @returns {{allowed:boolean, code:string|null, detail:string|null, counts:object}}
281
+ */
282
+ export function checkGuardrails(o = {}) {
283
+ const limits = { ...DEFAULT_LIMITS, ...(o.limits || {}) };
284
+ const rows = Array.isArray(o.rows) ? o.rows : [];
285
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
286
+ const targets = (Array.isArray(o.targets) ? o.targets : [])
287
+ .map((t) => String(t && typeof t === "object" ? t.memberId : t || ""))
288
+ .filter(Boolean);
289
+
290
+ const sent = rows.filter((r) => r.engaged === true);
291
+ const dayAgo = nowMs - DAY_MS;
292
+ const hourAgo = nowMs - HOUR_MS;
293
+
294
+ const counts = {
295
+ globalLastHour: sent.filter((r) => Number(r.atMs) >= hourAgo).length,
296
+ roomsLastDay: sent.filter((r) => Number(r.atMs) >= dayAgo && r.roomCreated === true).length,
297
+ perTargetLastDay: {},
298
+ thisWorkTotal: sent.filter((r) => String(r.workId || "") === String(o.workId || "")).length,
299
+ };
300
+ for (const t of targets) {
301
+ counts.perTargetLastDay[t] = sent.filter(
302
+ (r) => Number(r.atMs) >= dayAgo && Array.isArray(r.targets) && r.targets.includes(t),
303
+ ).length;
304
+ }
305
+
306
+ // 1. Already asked, still unanswered.
307
+ const dup = findOutstanding(rows, o.dedupeKey, nowMs, limits.reengageAfterMs);
308
+ if (dup.duplicate) {
309
+ const mins = Math.round(dup.ageMs / 60000);
310
+ return {
311
+ allowed: false,
312
+ code: "already_engaged",
313
+ detail: `the same ask went to the same people ${mins}m ago and has not been answered; re-ask allowed after ${Math.round(limits.reengageAfterMs / 60000)}m`,
314
+ counts,
315
+ };
316
+ }
317
+
318
+ // 2. Per-person daily cap.
319
+ for (const t of targets) {
320
+ if (counts.perTargetLastDay[t] >= limits.perTargetPerDay) {
321
+ return {
322
+ allowed: false,
323
+ code: "rate_limited_target",
324
+ detail: `${t} has already been engaged ${counts.perTargetLastDay[t]}× in the last 24h (cap ${limits.perTargetPerDay})`,
325
+ counts,
326
+ };
327
+ }
328
+ }
329
+
330
+ // 3. Per-work cap.
331
+ if (o.workId && counts.thisWorkTotal >= limits.perWorkTotal) {
332
+ return {
333
+ allowed: false,
334
+ code: "rate_limited_work",
335
+ detail: `work ${o.workId} has already generated ${counts.thisWorkTotal} engagements (cap ${limits.perWorkTotal}) — the work is mis-specified, not blocked`,
336
+ counts,
337
+ };
338
+ }
339
+
340
+ // 4. Global hourly blast radius.
341
+ if (counts.globalLastHour >= limits.globalPerHour) {
342
+ return {
343
+ allowed: false,
344
+ code: "rate_limited_global",
345
+ detail: `${counts.globalLastHour} engagements in the last hour (cap ${limits.globalPerHour})`,
346
+ counts,
347
+ };
348
+ }
349
+
350
+ // 5. Room creation cap — only consulted when this decision would make one.
351
+ if (o.createsRoom && counts.roomsLastDay >= limits.newRoomsPerDay) {
352
+ return {
353
+ allowed: false,
354
+ code: "rate_limited_rooms",
355
+ detail: `${counts.roomsLastDay} new rooms already created today (cap ${limits.newRoomsPerDay})`,
356
+ counts,
357
+ };
358
+ }
359
+
360
+ // `attempt` is the ordinal of the send this permission authorises: 1 for the
361
+ // first ask, 2 for the re-ask once the window reopens. The caller must fold it
362
+ // into the outbound message id — see findOutstanding for why a timeless dedupe
363
+ // key alone turns a re-ask into a no-op that reports success.
364
+ return { allowed: true, code: null, detail: null, counts, attempt: (dup.sends || 0) + 1 };
365
+ }
366
+
367
+ export default {
368
+ ENGAGEMENT_LEDGER_REL,
369
+ DEFAULT_LIMITS,
370
+ dedupeKeyFor,
371
+ loadEngagements,
372
+ appendEngagement,
373
+ markResolved,
374
+ findOutstanding,
375
+ checkGuardrails,
376
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * engagement-ledger.test.mjs — the on-disk mechanics of the engagement record.
3
+ * Run: node --test lib/org/engagement-ledger.test.mjs
4
+ */
5
+ "use strict";
6
+
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+
13
+ import {
14
+ appendEngagement,
15
+ loadEngagements,
16
+ markResolved,
17
+ findOutstanding,
18
+ dedupeKeyFor,
19
+ ENGAGEMENT_LEDGER_REL,
20
+ } from "./engagement-ledger.mjs";
21
+
22
+ const NOW = Date.parse("2026-08-13T12:00:00.000Z");
23
+ const HOUR = 3600_000;
24
+ const root = () => mkdtempSync(join(tmpdir(), "engledger-"));
25
+
26
+ test("an append round-trips, and the agent root resolves to the documented path", () => {
27
+ const r = root();
28
+ const res = appendEngagement(
29
+ { workId: "W-1", reasonCode: "blocked:access", dedupeKey: "k1", engaged: true, surface: "dm", targets: [{ memberId: "M-2" }], why: ["because"] },
30
+ { agentRoot: r, nowMs: NOW },
31
+ );
32
+ assert.equal(res.ok, true);
33
+ assert.equal(res.path, join(r, ENGAGEMENT_LEDGER_REL));
34
+
35
+ const { rows } = loadEngagements({ agentRoot: r });
36
+ assert.equal(rows.length, 1);
37
+ assert.deepEqual(rows[0].targets, ["M-2"], "target objects are flattened to ids on disk");
38
+ assert.equal(rows[0].at, new Date(NOW).toISOString());
39
+ assert.equal(rows[0].outcome, "engaged");
40
+ assert.deepEqual(rows[0].why, ["because"]);
41
+ });
42
+
43
+ test("a non-engagement is recorded just as loudly as an engagement", () => {
44
+ const r = root();
45
+ appendEngagement({ workId: "W-2", reasonCode: "self_contained", engaged: false, why: ["no trigger fired"] }, { agentRoot: r, nowMs: NOW });
46
+ const row = loadEngagements({ agentRoot: r }).rows[0];
47
+ assert.equal(row.engaged, false);
48
+ assert.equal(row.outcome, "none");
49
+ assert.equal(row.reasonCode, "self_contained");
50
+ });
51
+
52
+ test("a torn line loses one record, not the ledger, and the loss is reported", () => {
53
+ const r = root();
54
+ const p = join(r, ENGAGEMENT_LEDGER_REL);
55
+ appendEngagement({ workId: "A", dedupeKey: "k", engaged: true }, { agentRoot: r, nowMs: NOW });
56
+ writeFileSync(p, readFileSync(p, "utf8") + '{"workId":"B",trunca\n');
57
+ appendEngagement({ workId: "C", dedupeKey: "k", engaged: true }, { agentRoot: r, nowMs: NOW + 1000 });
58
+
59
+ const { rows, degraded } = loadEngagements({ agentRoot: r });
60
+ assert.deepEqual(rows.map((x) => x.workId), ["A", "C"]);
61
+ assert.deepEqual(degraded, ["ledger_torn_lines:1"]);
62
+ });
63
+
64
+ test("an unreadable ledger is reported, not silently treated as empty", () => {
65
+ const r = root();
66
+ appendEngagement({ workId: "A", engaged: true }, { agentRoot: r, nowMs: NOW });
67
+ const p = join(r, ENGAGEMENT_LEDGER_REL);
68
+ chmodSync(p, 0o000);
69
+ try {
70
+ const { rows, degraded } = loadEngagements({ agentRoot: r });
71
+ // Root can read anything; skip the assertion rather than assert a falsehood.
72
+ if (degraded.length) {
73
+ assert.deepEqual(degraded, ["ledger_unreadable"]);
74
+ assert.deepEqual(rows, [], "biases towards sending — which is the right bias, but it must be visible");
75
+ }
76
+ } finally {
77
+ chmodSync(p, 0o600);
78
+ }
79
+ });
80
+
81
+ test("markResolved supersedes an outstanding ask without rewriting history", () => {
82
+ const r = root();
83
+ const key = dedupeKeyFor({ workId: "W-3", reasonCode: "blocked:decision", targets: ["M-2"] });
84
+ appendEngagement({ workId: "W-3", dedupeKey: key, engaged: true, targets: ["M-2"] }, { agentRoot: r, nowMs: NOW - HOUR });
85
+
86
+ let rows = loadEngagements({ agentRoot: r }).rows;
87
+ assert.equal(findOutstanding(rows, key, NOW, 6 * HOUR).duplicate, true);
88
+
89
+ markResolved(key, { agentRoot: r, nowMs: NOW - 60_000, by: "M-2" });
90
+ rows = loadEngagements({ agentRoot: r }).rows;
91
+ assert.equal(rows.length, 2, "append-only: nothing was rewritten");
92
+ const out = findOutstanding(rows, key, NOW, 6 * HOUR);
93
+ assert.equal(out.duplicate, false);
94
+ assert.equal(out.resolved, true);
95
+ });
96
+
97
+ test("the re-engage clock releases the ask once the window passes", () => {
98
+ const rows = [{ atMs: NOW - 7 * HOUR, dedupeKey: "k", engaged: true, targets: ["M-2"] }];
99
+ assert.equal(findOutstanding(rows, "k", NOW, 6 * HOUR).duplicate, false);
100
+ assert.equal(findOutstanding(rows, "k", NOW, 8 * HOUR).duplicate, true);
101
+ });
102
+
103
+ test("sinceMs trims the window without touching the file", () => {
104
+ const r = root();
105
+ appendEngagement({ workId: "old", engaged: true }, { agentRoot: r, nowMs: NOW - 48 * HOUR });
106
+ appendEngagement({ workId: "new", engaged: true }, { agentRoot: r, nowMs: NOW });
107
+ assert.deepEqual(
108
+ loadEngagements({ agentRoot: r, sinceMs: NOW - 24 * HOUR }).rows.map((x) => x.workId),
109
+ ["new"],
110
+ );
111
+ assert.equal(loadEngagements({ agentRoot: r }).rows.length, 2);
112
+ });