@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
@@ -0,0 +1,198 @@
1
+ /**
2
+ * lib/channels/repeat-suppressor.mjs — say it once, keep saying it occasionally,
3
+ * and say when it stops.
4
+ *
5
+ * ── The problem ─────────────────────────────────────────────────────────────
6
+ *
7
+ * A channel adapter's poll loop turns a STANDING condition into a per-tick log
8
+ * line. On a live seat the orgmail adapter polled `email.inbox` every 45s
9
+ * against a member with no mailbox and wrote
10
+ *
11
+ * [orgmail:warn] email.inbox error: NOT_FOUND no active mailbox is assigned…
12
+ *
13
+ * 478 times in one day (164 the day before). Every line was true. The net
14
+ * information content of the 478th was zero, and the volume actively hid
15
+ * everything else in the log. That is the failure mode this module exists to
16
+ * prevent — and it is the mirror image of a silent catch: FAIL-OPEN IS FINE,
17
+ * SILENT IS NOT, but "not silent" means *audible*, not *deafening*.
18
+ *
19
+ * ── The contract ────────────────────────────────────────────────────────────
20
+ *
21
+ * Per condition key:
22
+ * - the FIRST occurrence always emits, in full;
23
+ * - repeats are counted and swallowed;
24
+ * - every `restateMs` (default 1h) one line is emitted carrying the
25
+ * suppressed count and how long the condition has stood, so a standing
26
+ * problem cannot fade out of the log entirely;
27
+ * - `clear()` reports the recovery, WITH the total suppressed — so the log
28
+ * reads "started at T, still broken at T+1h (79 more), fixed at T+3h after
29
+ * 231 attempts" instead of a wall of identical lines.
30
+ *
31
+ * `observe()` also classifies whether a condition is TERMINAL — one that no
32
+ * amount of retrying will fix because it needs a human act (an admin assigning
33
+ * a mailbox, re-minting a key). Callers use that to back their poll off, so a
34
+ * seat waiting on an admin stops hammering the server as well as the log.
35
+ *
36
+ * Pure, injectable clock, no I/O, never throws.
37
+ *
38
+ * @module lib/channels/repeat-suppressor
39
+ */
40
+
41
+ "use strict";
42
+
43
+ /** Default gap between re-statements of a standing condition. */
44
+ export const DEFAULT_RESTATE_MS = 60 * 60 * 1000;
45
+
46
+ /**
47
+ * RPC error codes that will NOT resolve by retrying: they need a human to do
48
+ * something in Cohort. Callers slow their poll while one of these stands.
49
+ */
50
+ export const TERMINAL_CODES = Object.freeze([
51
+ "NOT_FOUND",
52
+ "FORBIDDEN_SCOPE",
53
+ "FORBIDDEN",
54
+ "UNAUTHORIZED",
55
+ ]);
56
+
57
+ export class RepeatSuppressor {
58
+ /**
59
+ * @param {object} [o]
60
+ * @param {number} [o.restateMs] gap between re-statements (default 1h)
61
+ * @param {() => number} [o.now] injectable clock
62
+ */
63
+ constructor(o = {}) {
64
+ this.restateMs =
65
+ Number.isFinite(o.restateMs) && o.restateMs > 0 ? o.restateMs : DEFAULT_RESTATE_MS;
66
+ this._now = typeof o.now === "function" ? o.now : Date.now;
67
+ /** @type {Map<string, {firstAt:number, lastEmitAt:number, count:number, sinceLastEmit:number, code:string}>} */
68
+ this._conditions = new Map();
69
+ }
70
+
71
+ /**
72
+ * Record one occurrence of a condition.
73
+ *
74
+ * @param {string} key stable identity of the condition (e.g. "inbox:NOT_FOUND")
75
+ * @param {object} [meta]
76
+ * @param {string} [meta.code] RPC error code, for terminal classification
77
+ * @returns {{
78
+ * emit: boolean,
79
+ * first: boolean,
80
+ * restated: boolean,
81
+ * count: number,
82
+ * suppressed: number,
83
+ * standingMs: number,
84
+ * terminal: boolean,
85
+ * suffix: string,
86
+ * }}
87
+ */
88
+ observe(key, meta = {}) {
89
+ const now = this._now();
90
+ const code = String(meta.code || "");
91
+ const terminal = TERMINAL_CODES.includes(code);
92
+ const prev = this._conditions.get(key);
93
+
94
+ if (!prev) {
95
+ this._conditions.set(key, {
96
+ firstAt: now,
97
+ lastEmitAt: now,
98
+ count: 1,
99
+ sinceLastEmit: 0,
100
+ code,
101
+ });
102
+ return {
103
+ emit: true,
104
+ first: true,
105
+ restated: false,
106
+ count: 1,
107
+ suppressed: 0,
108
+ standingMs: 0,
109
+ terminal,
110
+ suffix: "",
111
+ };
112
+ }
113
+
114
+ prev.count += 1;
115
+ prev.sinceLastEmit += 1;
116
+ prev.code = code || prev.code;
117
+ const standingMs = now - prev.firstAt;
118
+
119
+ if (now - prev.lastEmitAt >= this.restateMs) {
120
+ const suppressed = prev.sinceLastEmit - 1;
121
+ prev.lastEmitAt = now;
122
+ prev.sinceLastEmit = 0;
123
+ return {
124
+ emit: true,
125
+ first: false,
126
+ restated: true,
127
+ count: prev.count,
128
+ suppressed,
129
+ standingMs,
130
+ terminal,
131
+ suffix: ` [STILL UNRESOLVED after ${fmtDuration(standingMs)}; ${suppressed} identical occurrence(s) suppressed since the last line]`,
132
+ };
133
+ }
134
+
135
+ return {
136
+ emit: false,
137
+ first: false,
138
+ restated: false,
139
+ count: prev.count,
140
+ suppressed: prev.sinceLastEmit,
141
+ standingMs,
142
+ terminal,
143
+ suffix: "",
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Mark a condition resolved.
149
+ *
150
+ * @param {string} key
151
+ * @returns {{wasActive: boolean, count: number, standingMs: number, message: string}}
152
+ */
153
+ clear(key) {
154
+ const prev = this._conditions.get(key);
155
+ if (!prev) return { wasActive: false, count: 0, standingMs: 0, message: "" };
156
+ this._conditions.delete(key);
157
+ const standingMs = this._now() - prev.firstAt;
158
+ return {
159
+ wasActive: true,
160
+ count: prev.count,
161
+ standingMs,
162
+ message: `RESOLVED after ${fmtDuration(standingMs)} and ${prev.count} failed attempt(s)`,
163
+ };
164
+ }
165
+
166
+ /** Is `key` currently standing? */
167
+ isActive(key) {
168
+ return this._conditions.has(key);
169
+ }
170
+
171
+ /** Snapshot of every standing condition — for healthCheck / state files. */
172
+ active() {
173
+ const now = this._now();
174
+ return [...this._conditions.entries()].map(([key, v]) => ({
175
+ key,
176
+ code: v.code,
177
+ count: v.count,
178
+ firstAt: new Date(v.firstAt).toISOString(),
179
+ standingMs: now - v.firstAt,
180
+ terminal: TERMINAL_CODES.includes(v.code),
181
+ }));
182
+ }
183
+ }
184
+
185
+ /** "3h 12m" / "45s" — short enough to sit inside a log line. */
186
+ export function fmtDuration(ms) {
187
+ const s = Math.max(0, Math.round(ms / 1000));
188
+ if (s < 60) return `${s}s`;
189
+ const m = Math.floor(s / 60);
190
+ if (m < 60) return `${m}m`;
191
+ const h = Math.floor(m / 60);
192
+ const rem = m % 60;
193
+ if (h < 24) return rem ? `${h}h ${rem}m` : `${h}h`;
194
+ const d = Math.floor(h / 24);
195
+ return `${d}d ${h % 24}h`;
196
+ }
197
+
198
+ export default { RepeatSuppressor, DEFAULT_RESTATE_MS, TERMINAL_CODES, fmtDuration };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * repeat-suppressor.test.mjs — coverage for lib/channels/repeat-suppressor.mjs.
3
+ *
4
+ * The behaviour under test is the fix for a real incident: 478 identical
5
+ * `email.inbox NOT_FOUND` lines in one day from a 45s poll loop. The rules that
6
+ * must hold are "say it once", "keep saying it occasionally so it cannot fade
7
+ * out", and "say when it stops" — the middle one being what separates this from
8
+ * a silent catch.
9
+ *
10
+ * Hermetic: injected clock, no I/O.
11
+ */
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import { RepeatSuppressor, fmtDuration, TERMINAL_CODES } from "./repeat-suppressor.mjs";
17
+
18
+ /** A controllable clock. */
19
+ function clock(start = 0) {
20
+ const c = { t: start, now: () => c.t, advance: (ms) => { c.t += ms; } };
21
+ return c;
22
+ }
23
+
24
+ test("the first occurrence always emits, in full", () => {
25
+ const s = new RepeatSuppressor({ now: clock().now });
26
+ const r = s.observe("k", { code: "NOT_FOUND" });
27
+ assert.equal(r.emit, true);
28
+ assert.equal(r.first, true);
29
+ assert.equal(r.suppressed, 0);
30
+ assert.equal(r.suffix, "");
31
+ });
32
+
33
+ test("repeats inside the restate window are counted and swallowed", () => {
34
+ const c = clock();
35
+ const s = new RepeatSuppressor({ now: c.now, restateMs: 3_600_000 });
36
+ s.observe("k", { code: "NOT_FOUND" });
37
+ let emitted = 0;
38
+ // 45s poll for 30 minutes = 40 more ticks. The incident shape, in miniature.
39
+ for (let i = 0; i < 40; i += 1) {
40
+ c.advance(45_000);
41
+ if (s.observe("k", { code: "NOT_FOUND" }).emit) emitted += 1;
42
+ }
43
+ assert.equal(emitted, 0, "not one repeat may reach the log inside the window");
44
+ });
45
+
46
+ test("a standing condition is RE-STATED once per window, carrying the suppressed count", () => {
47
+ const c = clock();
48
+ const s = new RepeatSuppressor({ now: c.now, restateMs: 3_600_000 });
49
+ s.observe("k", { code: "NOT_FOUND" });
50
+ let restatement = null;
51
+ for (let i = 0; i < 100; i += 1) {
52
+ c.advance(45_000); // 100 ticks ≈ 75 minutes
53
+ const r = s.observe("k", { code: "NOT_FOUND" });
54
+ if (r.emit) { restatement = r; break; }
55
+ }
56
+ assert.ok(restatement, "a standing condition must not fade out of the log entirely");
57
+ assert.equal(restatement.restated, true);
58
+ assert.ok(restatement.suppressed > 70, `expected ~79 suppressed, got ${restatement.suppressed}`);
59
+ assert.match(restatement.suffix, /STILL UNRESOLVED after 1h/);
60
+ assert.match(restatement.suffix, /suppressed/);
61
+ });
62
+
63
+ test("over a full day the 478-line incident collapses to first + hourly re-statements", () => {
64
+ const c = clock();
65
+ const s = new RepeatSuppressor({ now: c.now, restateMs: 3_600_000 });
66
+ let emitted = 0;
67
+ const ticks = Math.round((24 * 3_600_000) / 45_000); // 1920 polls in a day
68
+ for (let i = 0; i < ticks; i += 1) {
69
+ if (s.observe("k", { code: "NOT_FOUND" }).emit) emitted += 1;
70
+ c.advance(45_000);
71
+ }
72
+ // 1 first line + one re-statement per elapsed hour (the 24th hour's
73
+ // re-statement lands just past the window, so 24 lines cover the day).
74
+ assert.equal(emitted, 24, `expected 24 lines a day, got ${emitted}`);
75
+ assert.ok(emitted < ticks / 50, "the whole point: ~2 orders of magnitude quieter than 1920 polls");
76
+ });
77
+
78
+ test("clear() reports the recovery WITH the total attempts, then forgets", () => {
79
+ const c = clock();
80
+ const s = new RepeatSuppressor({ now: c.now });
81
+ s.observe("k", { code: "NOT_FOUND" });
82
+ for (let i = 0; i < 9; i += 1) { c.advance(45_000); s.observe("k", { code: "NOT_FOUND" }); }
83
+ c.advance(45_000);
84
+ const r = s.clear("k");
85
+ assert.equal(r.wasActive, true);
86
+ assert.equal(r.count, 10);
87
+ assert.match(r.message, /RESOLVED after/);
88
+ assert.match(r.message, /10 failed attempt/);
89
+ // Idempotent: clearing a healthy condition says nothing at all.
90
+ assert.equal(s.clear("k").wasActive, false);
91
+ });
92
+
93
+ test("terminal codes are flagged so a caller can back its poll off", () => {
94
+ const s = new RepeatSuppressor({ now: clock().now });
95
+ assert.equal(s.observe("a", { code: "NOT_FOUND" }).terminal, true);
96
+ assert.equal(s.observe("b", { code: "FORBIDDEN_SCOPE" }).terminal, true);
97
+ assert.equal(s.observe("c", { code: "INTERNAL" }).terminal, false);
98
+ assert.equal(s.observe("d", { code: "TIMEOUT" }).terminal, false);
99
+ for (const code of TERMINAL_CODES) {
100
+ assert.equal(s.observe(`t-${code}`, { code }).terminal, true);
101
+ }
102
+ });
103
+
104
+ test("distinct keys are tracked independently", () => {
105
+ const c = clock();
106
+ const s = new RepeatSuppressor({ now: c.now });
107
+ assert.equal(s.observe("inbox", { code: "NOT_FOUND" }).emit, true);
108
+ assert.equal(s.observe("send", { code: "NOT_FOUND" }).emit, true);
109
+ c.advance(45_000);
110
+ assert.equal(s.observe("inbox", { code: "NOT_FOUND" }).emit, false);
111
+ assert.equal(s.isActive("send"), true);
112
+ assert.equal(s.active().length, 2);
113
+ });
114
+
115
+ test("active() snapshots standing conditions for healthCheck / state files", () => {
116
+ const c = clock(1_700_000_000_000);
117
+ const s = new RepeatSuppressor({ now: c.now });
118
+ s.observe("inbox", { code: "NOT_FOUND" });
119
+ c.advance(3 * 3_600_000);
120
+ const [a] = s.active();
121
+ assert.equal(a.key, "inbox");
122
+ assert.equal(a.code, "NOT_FOUND");
123
+ assert.equal(a.terminal, true);
124
+ assert.equal(a.standingMs, 3 * 3_600_000);
125
+ assert.match(a.firstAt, /^\d{4}-/);
126
+ });
127
+
128
+ test("fmtDuration stays short enough to live inside a log line", () => {
129
+ assert.equal(fmtDuration(45_000), "45s");
130
+ assert.equal(fmtDuration(90_000), "1m");
131
+ assert.equal(fmtDuration(3_600_000), "1h");
132
+ assert.equal(fmtDuration(3_600_000 + 720_000), "1h 12m");
133
+ assert.equal(fmtDuration(26 * 3_600_000), "1d 2h");
134
+ });
@@ -0,0 +1,297 @@
1
+ /**
2
+ * receipts.mjs — the durable record of "did a human actually hear from us".
3
+ *
4
+ * Every user-facing send in this agent funnels through one of three places:
5
+ * * the daemon's own delivery path (scripts/daemon/deliver.mjs)
6
+ * * lib/org/messaging.sendMessage (the in-process org send)
7
+ * * lib/org/tool-surface.executeOrgTool (what a spawned SESSION uses)
8
+ *
9
+ * The third one is the reason this file exists at all. A dispatched session is a
10
+ * separate `claude` child process; when it posts a reply, the daemon that
11
+ * spawned it learns nothing. The daemon therefore had no way to tell the
12
+ * difference between "the session answered the human" and "the session exited 0
13
+ * having said nothing", and it treated both as success — marking the item
14
+ * processed forever and emitting `sent`. That is the true silent death: a
15
+ * SUCCESSFUL session with zero human-facing output.
16
+ *
17
+ * A receipt is an append-only line on disk, so it crosses the process boundary.
18
+ * `spokeSince()` answers the only question the daemon actually needs: since this
19
+ * item arrived, did anything at all reach this channel?
20
+ *
21
+ * Everything here is fail-open and never throws. A receipt subsystem that could
22
+ * break a send would be worse than the bug it fixes — but note the asymmetry we
23
+ * deliberately choose: if the ledger is unreadable, `spokeSince()` returns
24
+ * `false`, i.e. "assume the human heard nothing". The failure mode is one extra
25
+ * message, never silence.
26
+ *
27
+ * @module lib/comms/receipts
28
+ */
29
+
30
+ "use strict";
31
+
32
+ import { appendFileSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "fs";
33
+ import { join } from "path";
34
+
35
+ /** Receipts older than this are irrelevant to any live obligation. */
36
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
37
+
38
+ function rootOf(agentRoot) {
39
+ return agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
40
+ }
41
+
42
+ function receiptDir(agentRoot) {
43
+ const dir = join(rootOf(agentRoot), "state", "comms");
44
+ mkdirSync(dir, { recursive: true });
45
+ return dir;
46
+ }
47
+
48
+ function dayFile(agentRoot, ms) {
49
+ const day = new Date(ms).toISOString().slice(0, 10);
50
+ return join(receiptDir(agentRoot), `receipts-${day}.jsonl`);
51
+ }
52
+
53
+ /**
54
+ * Normalise a channel identifier so a receipt written by a session (which knows
55
+ * the org channel id) matches a lookup by the daemon (which may hold the inbox
56
+ * item's `channel` or `channel_id`). Case-folded, trimmed; never null.
57
+ */
58
+ export function channelKey(channel) {
59
+ return String(channel == null ? "" : channel).trim().toLowerCase();
60
+ }
61
+
62
+ /**
63
+ * WHO the send belongs to, taken from the environment the dispatcher stamped on
64
+ * the session child. This is the difference between "somebody spoke into that
65
+ * room" and "the session that owes THIS person an answer spoke".
66
+ *
67
+ * Without it, receipts are keyed on the room alone, and a room is shared: two
68
+ * asks arriving in the same DM within a minute produce two debts and one
69
+ * channel. The first session's reply then discharges BOTH, and the second ask —
70
+ * the one nobody answered — is closed as "answered" with the human never told.
71
+ * Attribution is what makes the discharge specific.
72
+ */
73
+ function ambientAttribution(env = process.env) {
74
+ return {
75
+ obligationKey: env.MAESTRO_OBLIGATION_KEY || null,
76
+ sessionId: env.MAESTRO_SESSION_ID || env.AGENT_SESSION_ID || null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Record that something user-facing left the agent.
82
+ *
83
+ * @param {object} rec
84
+ * @param {string} rec.service "cohort" | "slack" | "gmail" | …
85
+ * @param {string} rec.channel channel / thread / recipient the text went to
86
+ * @param {string} [rec.kind] "reply" | "ack" | "progress" | "failure" | "session"
87
+ * @param {string} [rec.via] transport that carried it
88
+ * @param {number} [rec.chars] length of the delivered text (never the text)
89
+ * @param {string} [rec.obligationKey] the debt this send discharges, when known
90
+ * @param {string} [rec.sessionId] the session that sent it, when known
91
+ * @param {object} [rec.env] environment to read ambient attribution from
92
+ * @param {string} [rec.agentRoot]
93
+ * @param {number} [rec.now] injectable clock (tests)
94
+ * @returns {boolean} true if the receipt was durably appended
95
+ */
96
+ export function recordOutbound(rec = {}) {
97
+ try {
98
+ const ch = channelKey(rec.channel);
99
+ if (!ch) return false;
100
+ const now = Number.isFinite(rec.now) ? rec.now : Date.now();
101
+ const ambient = ambientAttribution(rec.env || process.env);
102
+ const line = JSON.stringify({
103
+ ts: new Date(now).toISOString(),
104
+ at: now,
105
+ service: rec.service || "unknown",
106
+ channel: ch,
107
+ kind: rec.kind || "reply",
108
+ via: rec.via || null,
109
+ chars: Number.isFinite(rec.chars) ? rec.chars : null,
110
+ // Explicit argument wins; the dispatcher's env is the fallback, which is
111
+ // what lets a CLI send lane inside a spawned session attribute itself
112
+ // without every script having to know about obligations.
113
+ obligationKey: rec.obligationKey || ambient.obligationKey,
114
+ sessionId: rec.sessionId || ambient.sessionId,
115
+ });
116
+ appendFileSync(dayFile(rec.agentRoot, now), line + "\n");
117
+ return true;
118
+ } catch {
119
+ // Fail-open: a send must never be broken by its own bookkeeping.
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Read every receipt at or after `sinceMs`. Scans only the day-files that can
126
+ * possibly contain them (today and yesterday cover any realistic obligation).
127
+ *
128
+ * @returns {object[]} receipts, oldest first; [] on any failure
129
+ */
130
+ export function readReceiptsSince(sinceMs, o = {}) {
131
+ const out = [];
132
+ try {
133
+ const dir = receiptDir(o.agentRoot);
134
+ const files = readdirSync(dir).filter((f) => f.startsWith("receipts-") && f.endsWith(".jsonl"));
135
+ for (const f of files) {
136
+ // `receipts-YYYY-MM-DD.jsonl` — skip whole days that end before `since`.
137
+ const day = f.slice("receipts-".length, -".jsonl".length);
138
+ const dayEnd = Date.parse(`${day}T23:59:59.999Z`);
139
+ if (Number.isFinite(dayEnd) && dayEnd < sinceMs) continue;
140
+ let raw = "";
141
+ try { raw = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
142
+ for (const line of raw.split("\n")) {
143
+ if (!line.trim()) continue;
144
+ try {
145
+ const rec = JSON.parse(line);
146
+ const at = Number.isFinite(rec.at) ? rec.at : Date.parse(rec.ts);
147
+ if (Number.isFinite(at) && at >= sinceMs) out.push({ ...rec, at });
148
+ } catch { /* a torn line is not a reason to lose the rest */ }
149
+ }
150
+ }
151
+ } catch { return []; }
152
+ out.sort((a, b) => a.at - b.at);
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * Did anything reach `channel` at or after `sinceMs`?
158
+ *
159
+ * This is the daemon's delivery check on session close. `kinds` lets the caller
160
+ * ask the sharper question — "did anything OTHER than my own acknowledgement
161
+ * reach them?" — because an ack is not an answer.
162
+ *
163
+ * @param {object} q
164
+ * @param {string} q.channel
165
+ * @param {number} q.sinceMs
166
+ * @param {string[]} [q.kinds] restrict to these receipt kinds
167
+ * @param {string[]} [q.excludeKinds] ignore these kinds (e.g. ["ack","progress"])
168
+ * @returns {boolean} false whenever we cannot prove a send happened
169
+ */
170
+ export function spokeSince(q = {}) {
171
+ const ch = channelKey(q.channel);
172
+ if (!ch || !Number.isFinite(q.sinceMs)) return false;
173
+ const recs = readReceiptsSince(q.sinceMs, q);
174
+ return recs.some((r) => {
175
+ if (r.channel !== ch) return false;
176
+ if (Array.isArray(q.kinds) && q.kinds.length && !q.kinds.includes(r.kind)) return false;
177
+ if (Array.isArray(q.excludeKinds) && q.excludeKinds.includes(r.kind)) return false;
178
+ return true;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Did the work for THIS obligation reach its human?
184
+ *
185
+ * `spokeSince` answers a question about a room. This answers a question about a
186
+ * debt, and the two differ precisely when a room holds more than one debt —
187
+ * which is the normal case for a DM with an owner who sends three messages in a
188
+ * row. The ladder is deliberate, most specific first:
189
+ *
190
+ * 1. a receipt stamped with this obligation's key → certain
191
+ * 2. a receipt stamped with this obligation's session → certain
192
+ * 3. an UNATTRIBUTED receipt in the room, and this debt is the only one open
193
+ * in that room, and a session of ours has actually run → assumed
194
+ * 4. anything else → NOT heard
195
+ *
196
+ * Rung 3 exists because attribution is best-effort: a send lane that loses the
197
+ * dispatcher's env still leaves an unattributed receipt, and treating that as
198
+ * silence would make every such reply produce a redundant "I didn't get back to
199
+ * you". Its two conditions are what keep it honest — with a second debt open in
200
+ * the room the receipt is ambiguous, and before any session of ours has run the
201
+ * message cannot have been our answer.
202
+ *
203
+ * Rung 4 is the module's standing bias made specific: unproven means not heard,
204
+ * and the cost of being wrong is one redundant message rather than silence.
205
+ *
206
+ * @param {object} q
207
+ * @param {object} q.obligation {key, channel, openedAt, sessionId, attempts}
208
+ * @param {number} [q.now]
209
+ * @param {number} [q.siblingOpenCount] open debts sharing this room, incl. self
210
+ * @param {string[]} [q.excludeKinds]
211
+ * @param {string} [q.agentRoot]
212
+ * @returns {{heard:boolean, basis:string}}
213
+ */
214
+ export function spokeFor(q = {}) {
215
+ const rec = q.obligation || {};
216
+ const ch = channelKey(rec.channel);
217
+ const since = Number.isFinite(rec.openedAt) ? rec.openedAt : q.now;
218
+ if (!ch || !Number.isFinite(since)) return { heard: false, basis: "no-channel" };
219
+ const exclude = Array.isArray(q.excludeKinds) ? q.excludeKinds : ["ack", "progress", "failure"];
220
+ const recs = readReceiptsSince(since, q).filter(
221
+ (r) => r.channel === ch && !exclude.includes(r.kind),
222
+ );
223
+ if (!recs.length) return { heard: false, basis: "no-receipts" };
224
+
225
+ if (rec.key && recs.some((r) => r.obligationKey === rec.key)) {
226
+ return { heard: true, basis: "obligation-keyed" };
227
+ }
228
+ if (rec.sessionId && recs.some((r) => r.sessionId && r.sessionId === rec.sessionId)) {
229
+ return { heard: true, basis: "session-keyed" };
230
+ }
231
+ // Any receipt that names a DIFFERENT debt or a DIFFERENT session is positive
232
+ // evidence that this one was not the sender — it must never count here.
233
+ const unattributed = recs.filter((r) => !r.obligationKey && !r.sessionId);
234
+ if (!unattributed.length) return { heard: false, basis: "attributed-elsewhere" };
235
+ const siblings = Number.isFinite(q.siblingOpenCount) ? q.siblingOpenCount : 1;
236
+ if (siblings > 1) return { heard: false, basis: "ambiguous-room" };
237
+ if (!(rec.attempts > 0)) return { heard: false, basis: "no-session-yet" };
238
+ return { heard: true, basis: "sole-debt-in-room" };
239
+ }
240
+
241
+ /** Drop receipt day-files past retention. Best-effort; returns files removed. */
242
+ export function pruneReceipts(o = {}) {
243
+ let removed = 0;
244
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
245
+ try {
246
+ const dir = receiptDir(o.agentRoot);
247
+ for (const f of readdirSync(dir)) {
248
+ if (!f.startsWith("receipts-")) continue;
249
+ const p = join(dir, f);
250
+ try {
251
+ if (now - statSync(p).mtimeMs > RETENTION_MS) { unlinkSync(p); removed++; }
252
+ } catch { /* best-effort */ }
253
+ }
254
+ } catch { /* best-effort */ }
255
+ return removed;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // CLI — `node lib/comms/receipts.mjs record --service slack --channel C123 …`
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * The send lanes a spawned session is REQUIRED to use are a bash script, a
264
+ * python script and a node script (prompt-builder mandates them; the org MCP
265
+ * send tools are hard-blocked by a pre-tool hook). None of them can import this
266
+ * module, so without this entry point no session send is ever witnessed — and
267
+ * the daemon's whole "did the session actually answer?" check reads every
268
+ * successful reply as silence and apologises for it.
269
+ *
270
+ * Exit code is always 0. A receipt that could break a send would be worse than
271
+ * the bug it fixes.
272
+ */
273
+ export function runReceiptCli(argv = []) {
274
+ if (argv[0] !== "record") return 0;
275
+ const a = {};
276
+ for (let i = 1; i < argv.length; i++) {
277
+ const m = /^--([a-zA-Z-]+)$/.exec(argv[i]);
278
+ if (m) a[m[1]] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "1";
279
+ }
280
+ recordOutbound({
281
+ service: a.service,
282
+ channel: a.channel,
283
+ kind: a.kind || "session",
284
+ via: a.via || null,
285
+ chars: a.chars ? parseInt(a.chars, 10) : null,
286
+ obligationKey: a["obligation-key"] || undefined,
287
+ sessionId: a["session-id"] || undefined,
288
+ agentRoot: a["agent-root"] || undefined,
289
+ });
290
+ return 0;
291
+ }
292
+
293
+ if (process.argv[1] && process.argv[1].endsWith("receipts.mjs")) {
294
+ try { runReceiptCli(process.argv.slice(2)); } catch { /* never fail a send */ }
295
+ }
296
+
297
+ export default { recordOutbound, readReceiptsSince, spokeSince, spokeFor, channelKey, pruneReceipts };