@cohortapp/agent-sdk 2.3.2 → 2.4.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.
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- 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
|
+
});
|