@cohortapp/agent-sdk 2.3.2 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +105 -17
  97. package/lib/setup/enroll-from-cohort.test.mjs +68 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * lib/execution/journal.mjs — the append-only record of "why did the agent do
3
+ * that?", and the memory the decision ladder consults to avoid doing it twice.
4
+ *
5
+ * Two jobs, deliberately in one module because they are the same file:
6
+ *
7
+ * 1. WRITE. Every decision — including every `ignore` — is appended to
8
+ * `state/execution/journal.jsonl` with the ordered rule trace that produced
9
+ * it. An ignore that leaves no trace is indistinguishable from a crash, and
10
+ * "the agent silently did nothing" has been the hardest class of bug in this
11
+ * system to diagnose. So the ignore path writes MORE, not less.
12
+ *
13
+ * 2. READ. The ladder needs three facts that only history can answer:
14
+ * - have I already handled this exact event? (idempotency)
15
+ * - how many turns have I taken in this thread with no
16
+ * human in between? (ping-pong guard)
17
+ * - how many times has this actor woken me lately? (flood guard)
18
+ * All three are derived from the same tail scan, so one bounded read of the
19
+ * file answers all of them.
20
+ *
21
+ * The file is read TAIL-FIRST and bounded (`MAX_SCAN_BYTES`, `MAX_SCAN_ROWS`) so
22
+ * a long-lived agent's journal cannot turn a per-event decision into an O(file)
23
+ * operation. A journal older than the scan window simply stops contributing to
24
+ * the guards — which is correct: a reply chain from three weeks ago is not a
25
+ * ping-pong loop.
26
+ *
27
+ * Every failure is soft (returns an empty view) but NEVER silent: the caller
28
+ * gets `{degraded, degradedReason}` and is expected to log it.
29
+ *
30
+ * @module lib/execution/journal
31
+ */
32
+
33
+ "use strict";
34
+
35
+ import { existsSync, openSync, readSync, fstatSync, closeSync } from "node:fs";
36
+ import { join } from "node:path";
37
+
38
+ import { resolveAgentRoot } from "../agent-root.mjs";
39
+ import { appendJsonl } from "../fs-atomic.mjs";
40
+
41
+ /** Journal path relative to the agent root. */
42
+ export const JOURNAL_REL = "state/execution/journal.jsonl";
43
+
44
+ /** Never read more than this much of the tail for a guard query. */
45
+ export const MAX_SCAN_BYTES = 512 * 1024;
46
+
47
+ /** Never consider more than this many rows, even inside the byte budget. */
48
+ export const MAX_SCAN_ROWS = 2000;
49
+
50
+ /** Default flood window: how far back `actorActivity` looks. */
51
+ export const DEFAULT_FLOOD_WINDOW_MS = 15 * 60 * 1000;
52
+
53
+ /** Default idempotency window: how far back a duplicate still counts as one. */
54
+ export const DEFAULT_DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
55
+
56
+ /** Absolute path to the journal for an agent root. */
57
+ export function journalPath(agentRoot) {
58
+ return join(resolveAgentRoot(agentRoot), JOURNAL_REL);
59
+ }
60
+
61
+ /**
62
+ * The idempotency key for one inbound event. Stable across restarts and across
63
+ * the two delivery paths (SSE push and the polling sweep), which is the whole
64
+ * point: the same event arriving twice by two routes must collapse to one key.
65
+ *
66
+ * `seq` is preferred when the ledger supplied one — it is the org-wide cursor
67
+ * and cannot collide. Falling back to `entityId` alone would collapse DISTINCT
68
+ * events on the same entity (two comments on one task), so the fallback carries
69
+ * the coordinates and the timestamp too.
70
+ *
71
+ * @param {{seq?:number|string|null, family?:string, kind?:string, entityId?:string|null, at?:string}} cand
72
+ * @returns {string}
73
+ */
74
+ export function dedupeKey(cand = {}) {
75
+ // A default parameter fires only on `undefined`. These two helpers are called
76
+ // on the live inbound path with whatever the classifier produced, so an
77
+ // explicit `null` must degrade to a placeholder key, not throw.
78
+ if (!cand || typeof cand !== "object") cand = {};
79
+ const family = String(cand.family || "?");
80
+ const kind = String(cand.kind || "?");
81
+ if (cand.seq !== undefined && cand.seq !== null && cand.seq !== "") {
82
+ return `${family}.${kind}#${cand.seq}`;
83
+ }
84
+ const entity = String(cand.entityId || "-");
85
+ const at = String(cand.at || "-");
86
+ return `${family}.${kind}:${entity}@${at}`;
87
+ }
88
+
89
+ /**
90
+ * The thread identity a ping-pong guard counts turns within. A board comment
91
+ * thread, a chat thread and an email thread are all "a conversation" and all
92
+ * need the same guard, so they normalise onto one key space.
93
+ *
94
+ * @param {{family?:string, ids?:Record<string,string|undefined>, entityId?:string|null}} cand
95
+ * @returns {string}
96
+ */
97
+ export function threadKey(cand = {}) {
98
+ if (!cand || typeof cand !== "object") cand = {};
99
+ const ids = (cand.ids && typeof cand.ids === "object") ? cand.ids : {};
100
+ const family = String(cand.family || "?");
101
+ const thread =
102
+ ids.threadRootId ||
103
+ ids.threadId ||
104
+ ids.messageId ||
105
+ ids.taskId ||
106
+ ids.callId ||
107
+ ids.fileId ||
108
+ cand.entityId ||
109
+ "-";
110
+ const room = ids.channelId || ids.boardId || "-";
111
+ return `${family}:${room}:${thread}`;
112
+ }
113
+
114
+ /**
115
+ * Read the tail of the journal as parsed rows, newest LAST (file order).
116
+ * Bounded by bytes and rows. Never throws.
117
+ *
118
+ * @param {object} [o]
119
+ * @param {string} [o.agentRoot]
120
+ * @param {number} [o.maxBytes]
121
+ * @param {number} [o.maxRows]
122
+ * @returns {{rows:object[], degraded:boolean, degradedReason:string|null, truncated:boolean}}
123
+ */
124
+ export function readTail(o = {}) {
125
+ const path = o.path || journalPath(o.agentRoot);
126
+ if (!existsSync(path)) {
127
+ return { rows: [], degraded: false, degradedReason: null, truncated: false };
128
+ }
129
+ const maxBytes = Number.isFinite(o.maxBytes) ? o.maxBytes : MAX_SCAN_BYTES;
130
+ const maxRows = Number.isFinite(o.maxRows) ? o.maxRows : MAX_SCAN_ROWS;
131
+
132
+ let fd = null;
133
+ try {
134
+ fd = openSync(path, "r");
135
+ const size = fstatSync(fd).size;
136
+ const start = Math.max(0, size - maxBytes);
137
+ const length = size - start;
138
+ const buf = Buffer.allocUnsafe(length);
139
+ let read = 0;
140
+ while (read < length) {
141
+ const n = readSync(fd, buf, read, length - read, start + read);
142
+ if (n <= 0) break;
143
+ read += n;
144
+ }
145
+ let text = buf.subarray(0, read).toString("utf-8");
146
+ const truncated = start > 0;
147
+ // A non-zero start almost certainly lands mid-line; drop the first partial.
148
+ if (truncated) {
149
+ const nl = text.indexOf("\n");
150
+ text = nl === -1 ? "" : text.slice(nl + 1);
151
+ }
152
+ const lines = text.split("\n").filter((l) => l.trim());
153
+ const slice = lines.length > maxRows ? lines.slice(lines.length - maxRows) : lines;
154
+ const rows = [];
155
+ let bad = 0;
156
+ for (const line of slice) {
157
+ try {
158
+ const row = JSON.parse(line);
159
+ if (row && typeof row === "object") rows.push(row);
160
+ } catch {
161
+ bad += 1;
162
+ }
163
+ }
164
+ return {
165
+ rows,
166
+ degraded: bad > 0,
167
+ degradedReason: bad > 0 ? `${bad} unparseable journal line(s)` : null,
168
+ truncated: truncated || lines.length > maxRows,
169
+ };
170
+ } catch (err) {
171
+ return {
172
+ rows: [],
173
+ degraded: true,
174
+ degradedReason: `journal read failed: ${err && err.message ? err.message : String(err)}`,
175
+ truncated: false,
176
+ };
177
+ } finally {
178
+ if (fd !== null) {
179
+ try { closeSync(fd); } catch { /* fd already gone; nothing to salvage */ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Build the history view the decision ladder needs, from one tail scan.
186
+ *
187
+ * @param {object} [o]
188
+ * @param {string} [o.agentRoot]
189
+ * @param {number} [o.nowMs]
190
+ * @param {number} [o.dedupeWindowMs]
191
+ * @param {number} [o.floodWindowMs]
192
+ * @param {object[]} [o.rows] pre-read rows (tests / a caller that already scanned)
193
+ * @returns {{
194
+ * seen:(key:string)=>boolean,
195
+ * chainDepth:(threadKey:string)=>number,
196
+ * actorActivity:(actor:string)=>number,
197
+ * failures:(obligationKey:string)=>number,
198
+ * rows:object[], degraded:boolean, degradedReason:string|null
199
+ * }}
200
+ */
201
+ export function loadHistory(o = {}) {
202
+ const now = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
203
+ const dedupeWindow = Number.isFinite(o.dedupeWindowMs) ? o.dedupeWindowMs : DEFAULT_DEDUPE_WINDOW_MS;
204
+ const floodWindow = Number.isFinite(o.floodWindowMs) ? o.floodWindowMs : DEFAULT_FLOOD_WINDOW_MS;
205
+
206
+ const scan = Array.isArray(o.rows)
207
+ ? { rows: o.rows, degraded: false, degradedReason: null }
208
+ : readTail(o);
209
+
210
+ const seenKeys = new Set();
211
+ const chains = new Map(); // threadKey → consecutive agent turns (reset by a human turn)
212
+ const actors = new Map(); // actor → count inside floodWindow
213
+ const failures = new Map(); // obligationKey → failure count
214
+
215
+ for (const row of scan.rows) {
216
+ const ts = typeof row.ts === "string" ? Date.parse(row.ts) : NaN;
217
+ const age = Number.isFinite(ts) ? now - ts : Infinity;
218
+
219
+ if (row.key && age <= dedupeWindow) seenKeys.add(String(row.key));
220
+
221
+ // Chain depth counts only turns where the agent actually SPOKE. A decision
222
+ // to ignore or to queue does not deepen a conversation, so it must not
223
+ // count toward the ping-pong ceiling — otherwise three ignores in a row
224
+ // would gag the agent on the fourth, genuinely-directed message.
225
+ const tk = row.thread ? String(row.thread) : null;
226
+ if (tk) {
227
+ if (row.event === "human_turn") {
228
+ chains.set(tk, 0);
229
+ } else if (row.event === "outcome" && row.spoke === true) {
230
+ chains.set(tk, (chains.get(tk) || 0) + 1);
231
+ }
232
+ }
233
+
234
+ if (row.actor && age <= floodWindow && row.event === "decision") {
235
+ const a = String(row.actor);
236
+ actors.set(a, (actors.get(a) || 0) + 1);
237
+ }
238
+
239
+ if (row.obligationKey && row.event === "outcome") {
240
+ const k = String(row.obligationKey);
241
+ if (row.ok === false) failures.set(k, (failures.get(k) || 0) + 1);
242
+ else if (row.ok === true) failures.set(k, 0);
243
+ }
244
+ }
245
+
246
+ return {
247
+ seen: (key) => seenKeys.has(String(key)),
248
+ chainDepth: (tk) => chains.get(String(tk)) || 0,
249
+ actorActivity: (actor) => actors.get(String(actor)) || 0,
250
+ failures: (k) => failures.get(String(k)) || 0,
251
+ rows: scan.rows,
252
+ degraded: !!scan.degraded,
253
+ degradedReason: scan.degradedReason || null,
254
+ };
255
+ }
256
+
257
+ /** Coerce to a plain, JSON-safe object; never lets a getter throw into the log path. */
258
+ function safe(v) {
259
+ try {
260
+ return JSON.parse(JSON.stringify(v === undefined ? null : v));
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Append a decision row. Returns the row so the caller can correlate it with the
268
+ * outcome row it will write later.
269
+ *
270
+ * @param {object} decision the result of `disposition.decide`
271
+ * @param {object} [o] { agentRoot, nowMs, traceId, append }
272
+ * @returns {{ok:boolean, row:object}}
273
+ */
274
+ export function recordDecision(decision = {}, o = {}) {
275
+ const now = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
276
+ const row = {
277
+ ts: new Date(now).toISOString(),
278
+ event: "decision",
279
+ key: decision.key || null,
280
+ thread: decision.thread || null,
281
+ actor: decision.actor || null,
282
+ surface: decision.surface || null,
283
+ topic: decision.topic || null,
284
+ disposition: decision.disposition || null,
285
+ reason: decision.reason || null,
286
+ rung: Number.isFinite(decision.rung) ? decision.rung : null,
287
+ mechanism: decision.mechanism || null,
288
+ obligationKey: decision.obligationKey || null,
289
+ objectiveId: decision.objectiveId || null,
290
+ // The ordered rule trace. This is the answer to "why did the agent do that?"
291
+ // and it is why the journal is worth writing at all.
292
+ why: Array.isArray(decision.why) ? decision.why.map(String) : [],
293
+ gates: safe(decision.gates || null),
294
+ drift: safe(decision.drift || null),
295
+ trace_id: o.traceId || null,
296
+ };
297
+ const append = typeof o.append === "function" ? o.append : appendJsonl;
298
+ const ok = append(o.path || journalPath(o.agentRoot), row);
299
+ return { ok, row };
300
+ }
301
+
302
+ /**
303
+ * Append the outcome of acting on a decision.
304
+ *
305
+ * `spoke` is separate from `ok` on purpose: an action can succeed without the
306
+ * agent having said anything (queued a task), and the ping-pong guard counts
307
+ * SPEAKING, not succeeding.
308
+ *
309
+ * @param {object} outcome { key, thread, disposition, ok, spoke, effect, ref, error, obligationKey, costCents }
310
+ * @param {object} [o] { agentRoot, nowMs, traceId, append }
311
+ * @returns {{ok:boolean, row:object}}
312
+ */
313
+ export function recordOutcome(outcome = {}, o = {}) {
314
+ const now = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
315
+ const row = {
316
+ ts: new Date(now).toISOString(),
317
+ event: "outcome",
318
+ key: outcome.key || null,
319
+ thread: outcome.thread || null,
320
+ surface: outcome.surface || null,
321
+ disposition: outcome.disposition || null,
322
+ effect: outcome.effect || null,
323
+ ok: outcome.ok === true,
324
+ spoke: outcome.spoke === true,
325
+ ref: outcome.ref === undefined ? null : safe(outcome.ref),
326
+ error: outcome.error ? String(outcome.error) : null,
327
+ degraded: outcome.degraded ? String(outcome.degraded) : null,
328
+ obligationKey: outcome.obligationKey || null,
329
+ costCents: Number.isFinite(outcome.costCents) ? outcome.costCents : null,
330
+ trace_id: o.traceId || null,
331
+ };
332
+ const append = typeof o.append === "function" ? o.append : appendJsonl;
333
+ const ok = append(o.path || journalPath(o.agentRoot), row);
334
+ return { ok, row };
335
+ }
336
+
337
+ /**
338
+ * Record that a HUMAN spoke in a thread, which resets the ping-pong counter.
339
+ * Called by the inbound pipeline for any event whose actor is not an agent — it
340
+ * is what lets a conversation continue past `maxChainDepth` when the human is
341
+ * still engaged, instead of the agent going mute mid-exchange.
342
+ *
343
+ * @param {{thread:string, actor?:string, surface?:string}} o1
344
+ * @param {object} [o]
345
+ */
346
+ export function recordHumanTurn(o1 = {}, o = {}) {
347
+ const now = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
348
+ const row = {
349
+ ts: new Date(now).toISOString(),
350
+ event: "human_turn",
351
+ thread: o1.thread || null,
352
+ actor: o1.actor || null,
353
+ surface: o1.surface || null,
354
+ trace_id: o.traceId || null,
355
+ };
356
+ const append = typeof o.append === "function" ? o.append : appendJsonl;
357
+ return { ok: append(o.path || journalPath(o.agentRoot), row), row };
358
+ }
359
+
360
+ export default {
361
+ JOURNAL_REL,
362
+ MAX_SCAN_BYTES,
363
+ MAX_SCAN_ROWS,
364
+ DEFAULT_FLOOD_WINDOW_MS,
365
+ DEFAULT_DEDUPE_WINDOW_MS,
366
+ journalPath,
367
+ dedupeKey,
368
+ threadKey,
369
+ readTail,
370
+ loadHistory,
371
+ recordDecision,
372
+ recordOutcome,
373
+ recordHumanTurn,
374
+ };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * journal.test.mjs — the reasoning record and the three guards it answers.
3
+ * Run: node --test lib/execution/journal.test.mjs
4
+ */
5
+ "use strict";
6
+
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname } from "node:path";
12
+
13
+ import {
14
+ dedupeKey,
15
+ threadKey,
16
+ readTail,
17
+ loadHistory,
18
+ recordDecision,
19
+ recordOutcome,
20
+ recordHumanTurn,
21
+ journalPath,
22
+ JOURNAL_REL,
23
+ } from "./journal.mjs";
24
+
25
+ const NOW = Date.parse("2026-08-11T12:00:00Z");
26
+ function tmp() { return mkdtempSync(join(tmpdir(), "exec-journal-")); }
27
+ function ago(ms) { return new Date(NOW - ms).toISOString(); }
28
+
29
+ function seed(root, rows) {
30
+ const p = join(root, JOURNAL_REL);
31
+ mkdirSync(dirname(p), { recursive: true });
32
+ writeFileSync(p, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
33
+ return p;
34
+ }
35
+
36
+ test("dedupeKey prefers the ledger seq — the collision-free coordinate", () => {
37
+ assert.equal(dedupeKey({ family: "messaging", kind: "send", seq: 41 }), "messaging.send#41");
38
+ // seq 0 is a real cursor position, not "absent"
39
+ assert.equal(dedupeKey({ family: "board", kind: "item.assigned", seq: 0 }), "board.item.assigned#0");
40
+ });
41
+
42
+ test("dedupeKey without a seq does NOT collapse distinct events on one entity", () => {
43
+ const a = dedupeKey({ family: "board", kind: "item.commented", entityId: "t1", at: "2026-08-11T10:00:00Z" });
44
+ const b = dedupeKey({ family: "board", kind: "item.commented", entityId: "t1", at: "2026-08-11T10:05:00Z" });
45
+ assert.notEqual(a, b, "two comments on one task must be two events");
46
+ // ...but the SAME event redelivered is the same key
47
+ const c = dedupeKey({ family: "board", kind: "item.commented", entityId: "t1", at: "2026-08-11T10:00:00Z" });
48
+ assert.equal(a, c);
49
+ });
50
+
51
+ test("dedupeKey and threadKey never throw on junk", () => {
52
+ for (const v of [undefined, null, {}, { ids: null }, { seq: "" }]) {
53
+ assert.equal(typeof dedupeKey(v), "string");
54
+ assert.equal(typeof threadKey(v), "string");
55
+ }
56
+ });
57
+
58
+ test("threadKey normalises chat, board and file threads into one key space", () => {
59
+ const chat = threadKey({ family: "messaging", ids: { channelId: "c1", threadRootId: "m9" } });
60
+ const board = threadKey({ family: "board", ids: { taskId: "t1" }, entityId: "t1" });
61
+ assert.equal(chat, "messaging:c1:m9");
62
+ assert.equal(board, "board:-:t1");
63
+ // the same thread from two deliveries yields one key
64
+ assert.equal(chat, threadKey({ family: "messaging", ids: { channelId: "c1", threadRootId: "m9" } }));
65
+ // different threads in one room do not collide
66
+ assert.notEqual(chat, threadKey({ family: "messaging", ids: { channelId: "c1", threadRootId: "m8" } }));
67
+ });
68
+
69
+ test("readTail: absent file is empty and NOT degraded", () => {
70
+ const root = tmp();
71
+ try {
72
+ const r = readTail({ agentRoot: root });
73
+ assert.deepEqual(r.rows, []);
74
+ assert.equal(r.degraded, false);
75
+ } finally { rmSync(root, { recursive: true, force: true }); }
76
+ });
77
+
78
+ test("readTail: unparseable lines are counted and REPORTED, not silently dropped", () => {
79
+ const root = tmp();
80
+ try {
81
+ const p = join(root, JOURNAL_REL);
82
+ mkdirSync(dirname(p), { recursive: true });
83
+ writeFileSync(p, `{"event":"decision","key":"a"}\nnot json at all\n{"event":"decision","key":"b"}\n`);
84
+ const r = readTail({ agentRoot: root });
85
+ assert.equal(r.rows.length, 2);
86
+ assert.equal(r.degraded, true);
87
+ assert.match(r.degradedReason, /1 unparseable/);
88
+ } finally { rmSync(root, { recursive: true, force: true }); }
89
+ });
90
+
91
+ test("readTail: a huge journal is bounded, and the partial first line is dropped", () => {
92
+ const root = tmp();
93
+ try {
94
+ const p = join(root, JOURNAL_REL);
95
+ mkdirSync(dirname(p), { recursive: true });
96
+ const rows = [];
97
+ for (let i = 0; i < 5000; i++) rows.push(JSON.stringify({ event: "decision", key: `k${i}`, pad: "x".repeat(200) }));
98
+ writeFileSync(p, rows.join("\n") + "\n");
99
+ const r = readTail({ agentRoot: root, maxBytes: 20 * 1024 });
100
+ assert.ok(r.truncated, "a bounded read reports truncation");
101
+ assert.ok(r.rows.length > 0 && r.rows.length < 5000);
102
+ // every surviving row parsed — i.e. the partial head line was discarded
103
+ assert.equal(r.degraded, false, `partial line leaked: ${r.degradedReason}`);
104
+ // and the rows we kept are the NEWEST ones
105
+ assert.equal(r.rows[r.rows.length - 1].key, "k4999");
106
+ } finally { rmSync(root, { recursive: true, force: true }); }
107
+ });
108
+
109
+ test("readTail: maxRows caps even inside the byte budget", () => {
110
+ const root = tmp();
111
+ try {
112
+ seed(root, Array.from({ length: 100 }, (_, i) => ({ event: "decision", key: `k${i}` })));
113
+ const r = readTail({ agentRoot: root, maxRows: 10 });
114
+ assert.equal(r.rows.length, 10);
115
+ assert.equal(r.rows[9].key, "k99", "keeps the newest");
116
+ } finally { rmSync(root, { recursive: true, force: true }); }
117
+ });
118
+
119
+ test("seen(): a redelivery inside the window is caught; outside it is not", () => {
120
+ const root = tmp();
121
+ try {
122
+ seed(root, [
123
+ { ts: ago(60_000), event: "decision", key: "messaging.send#1" },
124
+ { ts: ago(48 * 3600 * 1000), event: "decision", key: "messaging.send#2" },
125
+ ]);
126
+ const h = loadHistory({ agentRoot: root, nowMs: NOW });
127
+ assert.equal(h.seen("messaging.send#1"), true);
128
+ assert.equal(h.seen("messaging.send#2"), false, "48h old, 24h window");
129
+ assert.equal(h.seen("messaging.send#999"), false);
130
+ } finally { rmSync(root, { recursive: true, force: true }); }
131
+ });
132
+
133
+ test("chainDepth counts only turns where the agent SPOKE", () => {
134
+ const root = tmp();
135
+ try {
136
+ seed(root, [
137
+ { ts: ago(5000), event: "outcome", thread: "t", spoke: true },
138
+ { ts: ago(4000), event: "outcome", thread: "t", spoke: false }, // a queued item
139
+ { ts: ago(3000), event: "decision", thread: "t" }, // a decision alone
140
+ { ts: ago(2000), event: "outcome", thread: "t", spoke: true },
141
+ ]);
142
+ const h = loadHistory({ agentRoot: root, nowMs: NOW });
143
+ assert.equal(h.chainDepth("t"), 2, "ignores and queues must not gag the agent");
144
+ assert.equal(h.chainDepth("other"), 0);
145
+ } finally { rmSync(root, { recursive: true, force: true }); }
146
+ });
147
+
148
+ test("a human turn RESETS the chain — a live conversation is never cut off", () => {
149
+ const root = tmp();
150
+ try {
151
+ seed(root, [
152
+ { ts: ago(9000), event: "outcome", thread: "t", spoke: true },
153
+ { ts: ago(8000), event: "outcome", thread: "t", spoke: true },
154
+ { ts: ago(7000), event: "human_turn", thread: "t" },
155
+ { ts: ago(6000), event: "outcome", thread: "t", spoke: true },
156
+ ]);
157
+ const h = loadHistory({ agentRoot: root, nowMs: NOW });
158
+ assert.equal(h.chainDepth("t"), 1);
159
+ } finally { rmSync(root, { recursive: true, force: true }); }
160
+ });
161
+
162
+ test("actorActivity counts decisions inside the flood window only", () => {
163
+ const root = tmp();
164
+ try {
165
+ seed(root, [
166
+ ...Array.from({ length: 5 }, () => ({ ts: ago(60_000), event: "decision", actor: "m1" })),
167
+ { ts: ago(60 * 60 * 1000), event: "decision", actor: "m1" }, // an hour ago
168
+ { ts: ago(60_000), event: "outcome", actor: "m1" }, // outcomes do not count
169
+ ]);
170
+ const h = loadHistory({ agentRoot: root, nowMs: NOW });
171
+ assert.equal(h.actorActivity("m1"), 5);
172
+ assert.equal(h.actorActivity("m2"), 0);
173
+ } finally { rmSync(root, { recursive: true, force: true }); }
174
+ });
175
+
176
+ test("failures(): counts consecutive failures and is RESET by a success", () => {
177
+ const root = tmp();
178
+ try {
179
+ seed(root, [
180
+ { ts: ago(5000), event: "outcome", obligationKey: "o1", ok: false },
181
+ { ts: ago(4000), event: "outcome", obligationKey: "o1", ok: false },
182
+ { ts: ago(3000), event: "outcome", obligationKey: "o2", ok: false },
183
+ { ts: ago(2000), event: "outcome", obligationKey: "o2", ok: true },
184
+ ]);
185
+ const h = loadHistory({ agentRoot: root, nowMs: NOW });
186
+ assert.equal(h.failures("o1"), 2, "two failures buys exactly one rung");
187
+ assert.equal(h.failures("o2"), 0, "a success clears the walk-up");
188
+ assert.equal(h.failures("never-seen"), 0);
189
+ } finally { rmSync(root, { recursive: true, force: true }); }
190
+ });
191
+
192
+ test("recordDecision writes the full rule trace — including for an ignore", () => {
193
+ const root = tmp();
194
+ try {
195
+ recordDecision(
196
+ {
197
+ key: "messaging.send#7", thread: "messaging:c1:m1", actor: "m2", surface: "mention",
198
+ disposition: "ignore", reason: "reply_chain_depth",
199
+ why: ["directed: mention on mention (tier T0)", "3 consecutive agent turns → stop talking"],
200
+ },
201
+ { agentRoot: root, nowMs: NOW },
202
+ );
203
+ const rows = readTail({ agentRoot: root }).rows;
204
+ assert.equal(rows.length, 1);
205
+ assert.equal(rows[0].event, "decision");
206
+ assert.equal(rows[0].disposition, "ignore");
207
+ assert.equal(rows[0].reason, "reply_chain_depth");
208
+ assert.equal(rows[0].why.length, 2, "an ignore records MORE, not less");
209
+ assert.ok(rows[0].ts);
210
+ } finally { rmSync(root, { recursive: true, force: true }); }
211
+ });
212
+
213
+ test("recordOutcome separates `ok` from `spoke`", () => {
214
+ const root = tmp();
215
+ try {
216
+ recordOutcome({ key: "k", thread: "t", disposition: "schedule", effect: "schedule", ok: true, spoke: false },
217
+ { agentRoot: root, nowMs: NOW });
218
+ const [row] = readTail({ agentRoot: root }).rows;
219
+ assert.equal(row.ok, true);
220
+ assert.equal(row.spoke, false, "queuing work succeeded without the agent saying anything");
221
+ } finally { rmSync(root, { recursive: true, force: true }); }
222
+ });
223
+
224
+ test("record* survive non-serialisable payloads instead of throwing", () => {
225
+ const root = tmp();
226
+ try {
227
+ const cyclic = {}; cyclic.self = cyclic;
228
+ const r = recordDecision({ key: "k", why: ["x"], gates: cyclic, drift: cyclic }, { agentRoot: root, nowMs: NOW });
229
+ assert.equal(r.ok, true);
230
+ const [row] = readTail({ agentRoot: root }).rows;
231
+ assert.equal(row.gates, null, "unserialisable input degrades to null, it does not crash the log path");
232
+ } finally { rmSync(root, { recursive: true, force: true }); }
233
+ });
234
+
235
+ test("a failed append is REPORTED to the caller, never swallowed", () => {
236
+ const r = recordDecision({ key: "k" }, { agentRoot: "/x", append: () => false });
237
+ assert.equal(r.ok, false, "the caller must be able to see that the reasoning was not persisted");
238
+ });
239
+
240
+ test("recordHumanTurn round-trips through loadHistory", () => {
241
+ const root = tmp();
242
+ try {
243
+ recordOutcome({ thread: "t", ok: true, spoke: true }, { agentRoot: root, nowMs: NOW - 2000 });
244
+ recordHumanTurn({ thread: "t", actor: "human1" }, { agentRoot: root, nowMs: NOW - 1000 });
245
+ assert.equal(loadHistory({ agentRoot: root, nowMs: NOW }).chainDepth("t"), 0);
246
+ } finally { rmSync(root, { recursive: true, force: true }); }
247
+ });
248
+
249
+ test("loadHistory accepts pre-read rows (the batch path) without touching disk", () => {
250
+ const h = loadHistory({
251
+ nowMs: NOW,
252
+ rows: [{ ts: ago(1000), event: "decision", key: "a", actor: "m1" }],
253
+ agentRoot: "/nonexistent-on-purpose",
254
+ });
255
+ assert.equal(h.seen("a"), true);
256
+ assert.equal(h.actorActivity("m1"), 1);
257
+ });
258
+
259
+ test("journalPath resolves under the agent root", () => {
260
+ assert.equal(journalPath("/tmp/agent-x"), join("/tmp/agent-x", JOURNAL_REL));
261
+ });