@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.
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 +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -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,340 @@
1
+ /**
2
+ * lib/execution/effects.mjs — the real machinery behind each disposition.
3
+ *
4
+ * `drive.mjs` sequences; this module supplies. It is the only file in
5
+ * `lib/execution/` that touches the network or the filesystem, which is what
6
+ * keeps the decision ladder and the driver testable with plain fakes.
7
+ *
8
+ * Every effect here is a THIN adapter onto machinery that already exists. This
9
+ * repo has repeatedly grown a second mechanism next to a working one, so the
10
+ * rule for this file is: if there is an existing module that does the thing,
11
+ * call it, and if there isn't, write to the on-disk format an existing consumer
12
+ * already reads.
13
+ *
14
+ * react → handed to the caller's responder/session spawner. NOT
15
+ * reimplemented here: the dispatcher owns session spawning
16
+ * and prompt construction, and a second spawner would be
17
+ * exactly the duplication this codebase keeps suffering.
18
+ * schedule → appends a queue item to `state/queues/inbound.yaml` in the
19
+ * format `lib/backlog.mjs parseQueueItems` already parses and
20
+ * the daemon's 2-minute backlog sweep already dispatches. No
21
+ * dispatcher edit, no new transport.
22
+ * delegate → `lib/org/handoff.mjs sendHandoff` (the A2A envelope path).
23
+ * escalate → `escalation.create` via `lib/org/client.mjs call`, which is
24
+ * the first real writer for the escalation lane.
25
+ * requestApproval → `lib/org/approvals.mjs requestApproval` + `awaitDecision`,
26
+ * including the `payloadHash` single-use binding.
27
+ * arbitrate → `lib/org/leases.mjs preSendArbitration` (3s fail-open).
28
+ * reportDrift → append to `state/plan/drift.jsonl`, mirrored best-effort.
29
+ * propose → append to `state/plan/proposed.jsonl` (the observe-only sink).
30
+ *
31
+ * Every function returns `{ok, ref, error}` rather than throwing, matching the
32
+ * shape `drive.runEffect` normalises to.
33
+ *
34
+ * @module lib/execution/effects
35
+ */
36
+
37
+ "use strict";
38
+
39
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
40
+ import { dirname, join } from "node:path";
41
+
42
+ import { resolveAgentRoot } from "../agent-root.mjs";
43
+ import { appendJsonl } from "../fs-atomic.mjs";
44
+ import { yamlScalar } from "../channels/inbox-item.mjs";
45
+
46
+ /** Where scheduled work lands — a queue file the daemon's sweep already reads. */
47
+ export const INBOUND_QUEUE_REL = "state/queues/inbound.yaml";
48
+ /** Where drift records land, mirrored by the reconciler. */
49
+ export const DRIFT_REL = "state/plan/drift.jsonl";
50
+ /** Where observe-only proposals land instead of becoming work (§5.10). */
51
+ export const PROPOSED_REL = "state/plan/proposed.jsonl";
52
+
53
+ /** Map a directedness tier + surface latency onto the queue's priority vocabulary. */
54
+ function priorityFor(decision) {
55
+ if (decision.reason === "budget_exhausted" || decision.reason === "actor_flood") return "normal";
56
+ if (decision.tier === "T0") return "high";
57
+ return "normal";
58
+ }
59
+
60
+ /**
61
+ * A stable, human-legible queue item id. Derived from the dedupe key so a
62
+ * redelivery that somehow slips past the journal guard collides on the id rather
63
+ * than creating a second item.
64
+ */
65
+ function queueItemId(decision) {
66
+ const raw = String(decision.key || `${decision.family}.${decision.kind}`);
67
+ return `inb-${raw.replace(/[^A-Za-z0-9._#:@-]/g, "-").slice(0, 80)}`;
68
+ }
69
+
70
+ /**
71
+ * Append one item to `state/queues/inbound.yaml`, creating the file with its
72
+ * header when absent. Idempotent on the item id: an id already present in the
73
+ * file is a no-op, so a retry of the same decision cannot double-queue.
74
+ *
75
+ * @param {object} decision
76
+ * @param {object} [o] { agentRoot, nowMs }
77
+ * @returns {{ok:boolean, ref:object|null, error:string|null}}
78
+ */
79
+ export function scheduleToQueue(decision, o = {}) {
80
+ const root = resolveAgentRoot(o.agentRoot);
81
+ const path = join(root, INBOUND_QUEUE_REL);
82
+ const id = queueItemId(decision);
83
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
84
+
85
+ try {
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ if (!existsSync(path)) {
88
+ writeFileSync(
89
+ path,
90
+ [
91
+ "queue: inbound",
92
+ "description: >-",
93
+ " Directed inbound events the execution ladder queued rather than answered",
94
+ " immediately. Written by lib/execution/effects.mjs; consumed by the daemon's",
95
+ " backlog sweep. Each item carries the decision that produced it.",
96
+ "items:",
97
+ "",
98
+ ].join("\n"),
99
+ );
100
+ } else {
101
+ const body = readFileSync(path, "utf-8");
102
+ // Anchor on a whole `id:` key at line start so a `correlation_id:` can
103
+ // never be mistaken for a match — the same trap lib/backlog.mjs documents.
104
+ const re = new RegExp(`^\\s*-?\\s*"?id"?:\\s*["']?${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']?\\s*$`, "m");
105
+ if (re.test(body)) {
106
+ return { ok: true, ref: { id, path, deduped: true }, error: null };
107
+ }
108
+ }
109
+
110
+ const title = `${decision.surface || "inbound"}: ${decision.reason || "queued"}`;
111
+ const nextAction =
112
+ decision.disposition === "schedule" && decision.surface
113
+ ? `Handle the ${decision.surface} event ${decision.key} at rung ${decision.rung ?? "?"}`
114
+ : `Handle ${decision.key}`;
115
+
116
+ const lines = [
117
+ ` - id: ${yamlScalar(id)}`,
118
+ ` title: ${yamlScalar(title)}`,
119
+ " status: open",
120
+ ` priority: ${priorityFor(decision)}`,
121
+ ` next_action: ${yamlScalar(nextAction)}`,
122
+ ` created: ${yamlScalar(new Date(nowMs).toISOString())}`,
123
+ ` surface: ${yamlScalar(decision.surface || "")}`,
124
+ ` event_key: ${yamlScalar(decision.key || "")}`,
125
+ ` thread: ${yamlScalar(decision.thread || "")}`,
126
+ ` rung: ${Number.isFinite(decision.rung) ? decision.rung : "null"}`,
127
+ ` obligation_key: ${yamlScalar(decision.obligationKey || "")}`,
128
+ // The reasoning travels WITH the work. A queue item whose provenance is a
129
+ // mystery is a queue item nobody trusts enough to action.
130
+ ` why: ${yamlScalar((decision.why || []).join(" | "))}`,
131
+ "",
132
+ ];
133
+ appendFileSync(path, lines.join("\n"));
134
+ return { ok: true, ref: { id, path, deduped: false }, error: null };
135
+ } catch (err) {
136
+ return { ok: false, ref: null, error: err && err.message ? err.message : String(err) };
137
+ }
138
+ }
139
+
140
+ /** Append a drift record locally. Mirroring to hq is the reconciler's job. */
141
+ export function writeDrift(drift, o = {}) {
142
+ const root = resolveAgentRoot(o.agentRoot);
143
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
144
+ const ok = appendJsonl(join(root, DRIFT_REL), {
145
+ at: new Date(nowMs).toISOString(),
146
+ kind: drift && drift.kind ? String(drift.kind) : "unknown",
147
+ key: (drift && drift.key) || (o.decision && o.decision.obligationKey) || null,
148
+ detail: (drift && drift.detail) || null,
149
+ source: "execution-ladder",
150
+ });
151
+ return { ok, ref: { kind: drift && drift.kind }, error: ok ? null : "drift append failed" };
152
+ }
153
+
154
+ /** Append an observe-only proposal instead of doing the work. */
155
+ export function writeProposal(decision, o = {}) {
156
+ const root = resolveAgentRoot(o.agentRoot);
157
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
158
+ const ok = appendJsonl(join(root, PROPOSED_REL), {
159
+ at: new Date(nowMs).toISOString(),
160
+ source: "execution-ladder",
161
+ key: decision.key || null,
162
+ surface: decision.surface || null,
163
+ disposition: decision.disposition,
164
+ reason: decision.reason,
165
+ rung: decision.rung ?? null,
166
+ obligationKey: decision.obligationKey || null,
167
+ why: decision.why || [],
168
+ });
169
+ return { ok, ref: { proposed: true }, error: ok ? null : "proposal append failed" };
170
+ }
171
+
172
+ /**
173
+ * Append a PROPOSED REACT obligation for an event the compiled plan did not
174
+ * cover (§6.1: "emit `PlanDrift{uncovered_event}` **and** a proposed REACT
175
+ * obligation — this is how the plan learns instead of ossifying").
176
+ *
177
+ * Lands in the same file as observe-only proposals, tagged `type:"obligation"`,
178
+ * so a human reviewing the window sees one list: what the agent would have done,
179
+ * and what the plan is missing.
180
+ *
181
+ * @param {object} obligation a schema-valid proposed obligation
182
+ * @param {object} [o] { agentRoot, nowMs, decision }
183
+ */
184
+ export function writeObligationProposal(obligation, o = {}) {
185
+ const root = resolveAgentRoot(o.agentRoot);
186
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
187
+ if (!obligation || typeof obligation !== "object" || !obligation.key) {
188
+ return { ok: false, ref: null, error: "obligation proposal missing a key" };
189
+ }
190
+ const decision = o.decision || {};
191
+ const ok = appendJsonl(join(root, PROPOSED_REL), {
192
+ at: new Date(nowMs).toISOString(),
193
+ source: "execution-ladder",
194
+ type: "obligation",
195
+ key: obligation.key,
196
+ obligation,
197
+ from_event: {
198
+ key: decision.key || null,
199
+ surface: decision.surface || null,
200
+ tier: decision.tier || null,
201
+ reason: decision.reason || null,
202
+ },
203
+ });
204
+ return { ok, ref: { key: obligation.key }, error: ok ? null : "obligation proposal append failed" };
205
+ }
206
+
207
+ /**
208
+ * Build the default effect set.
209
+ *
210
+ * `react` is deliberately NOT defaulted to a session spawn: the dispatcher owns
211
+ * spawning, and this module must not grow a second spawner. The caller wires it
212
+ * (see the wiring notes) and gets a loud `react_effect_missing` degradation if
213
+ * it forgets — which is the correct failure, because a missing responder is a
214
+ * real outage and must not look like a successful no-op.
215
+ *
216
+ * @param {object} o {
217
+ * agentRoot, nowMs, client, base, token, me, principal,
218
+ * react // (args) => Promise<{ok, ref}> — REQUIRED for react_now
219
+ * escalationTarget // member id / "principal"
220
+ * }
221
+ * @returns {object} the effects object `drive` consumes
222
+ */
223
+ export function defaultEffects(o = {}) {
224
+ const agentRoot = resolveAgentRoot(o.agentRoot);
225
+ const common = { agentRoot, nowMs: o.nowMs };
226
+
227
+ return {
228
+ react: typeof o.react === "function" ? o.react : undefined,
229
+
230
+ schedule: async ({ decision }) => scheduleToQueue(decision, common),
231
+
232
+ delegate: async ({ decision, candidate }) => {
233
+ const { sendHandoff } = await import("../org/handoff.mjs");
234
+ const r = await sendHandoff({
235
+ agentRoot,
236
+ from: o.me || "",
237
+ to: decision.delegateTo,
238
+ intent: `${decision.surface || "inbound"} event ${decision.key}: ${decision.reason}`,
239
+ payload: {
240
+ event_key: decision.key,
241
+ surface: decision.surface,
242
+ thread: decision.thread,
243
+ why: decision.why,
244
+ entityId: candidate ? candidate.entityId : null,
245
+ },
246
+ ...(o.transport ? { transport: o.transport } : {}),
247
+ });
248
+ return { ok: r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
249
+ },
250
+
251
+ escalate: async ({ decision }) => {
252
+ const { call } = await import("../org/client.mjs");
253
+ const r = await call(
254
+ "escalation.create",
255
+ {
256
+ // No message bodies, ever — ids and reasons only. `assertRedactionSafe`
257
+ // bans addresses and content, not member ids.
258
+ summary: `${decision.surface || "inbound"}: ${decision.reason}`,
259
+ waitingOnMemberId: decision.escalateTo || o.escalationTarget || undefined,
260
+ options: (decision.options || []).map((label) => ({ label: String(label) })),
261
+ context: {
262
+ event_key: decision.key,
263
+ surface: decision.surface,
264
+ thread: decision.thread,
265
+ obligation_key: decision.obligationKey || null,
266
+ why: decision.why,
267
+ },
268
+ },
269
+ { agentRoot, client: o.client, base: o.base, token: o.token },
270
+ );
271
+ return { ok: !!r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
272
+ },
273
+
274
+ requestApproval: async ({ decision, classes }) => {
275
+ const { requestApproval, awaitDecision } = await import("../org/approvals.mjs");
276
+ const asked = await requestApproval({
277
+ agentRoot,
278
+ action: decision.action && decision.action.method ? decision.action.method : decision.surface,
279
+ classification: classes,
280
+ payload: {
281
+ event_key: decision.key,
282
+ surface: decision.surface,
283
+ thread: decision.thread,
284
+ rung: decision.rung,
285
+ },
286
+ summary: `${decision.surface}: ${decision.reason}`,
287
+ client: o.client,
288
+ base: o.base,
289
+ token: o.token,
290
+ });
291
+ if (!asked || asked.ok === false) {
292
+ return { ok: false, ref: asked, error: (asked && asked.error) || "approval request failed" };
293
+ }
294
+ const id = asked.id || (asked.approval && asked.approval.id);
295
+ if (!id) return { ok: true, ref: { granted: undefined, asked }, error: null };
296
+ const decided = await awaitDecision(id, o.approvalTimeoutMs, {
297
+ agentRoot,
298
+ client: o.client,
299
+ base: o.base,
300
+ token: o.token,
301
+ });
302
+ const status = String((decided && (decided.status || decided.state)) || "").toLowerCase();
303
+ const granted = status === "approved" ? true : status === "rejected" || status === "denied" ? false : undefined;
304
+ return { ok: true, ref: { granted, id, status, decided }, error: null };
305
+ },
306
+
307
+ arbitrate: async ({ decision, candidate, resource }) => {
308
+ const { preSendArbitration } = await import("../org/leases.mjs");
309
+ const ids = (candidate && candidate.ids) || {};
310
+ const r = await preSendArbitration({
311
+ platform: "cohort",
312
+ channel: ids.channelId || resource || decision.thread || "",
313
+ thread: ids.threadRootId || ids.messageId || "",
314
+ agentRoot,
315
+ client: o.client,
316
+ base: o.base,
317
+ token: o.token,
318
+ });
319
+ return { ok: true, ref: r, error: null };
320
+ },
321
+
322
+ reportDrift: async ({ drift, decision }) => writeDrift(drift, { ...common, decision }),
323
+
324
+ propose: async ({ decision }) => writeProposal(decision, common),
325
+
326
+ proposeObligation: async ({ obligation, decision }) =>
327
+ writeObligationProposal(obligation, { ...common, decision }),
328
+ };
329
+ }
330
+
331
+ export default {
332
+ defaultEffects,
333
+ scheduleToQueue,
334
+ writeDrift,
335
+ writeProposal,
336
+ writeObligationProposal,
337
+ INBOUND_QUEUE_REL,
338
+ DRIFT_REL,
339
+ PROPOSED_REL,
340
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * effects.test.mjs — the on-disk side effects, and the format contract with the
3
+ * machinery that already consumes them.
4
+ * Run: node --test lib/execution/effects.test.mjs
5
+ */
6
+ "use strict";
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { parseQueueItems } from "../backlog.mjs";
15
+ import {
16
+ scheduleToQueue,
17
+ writeDrift,
18
+ writeProposal,
19
+ defaultEffects,
20
+ INBOUND_QUEUE_REL,
21
+ DRIFT_REL,
22
+ PROPOSED_REL,
23
+ } from "./effects.mjs";
24
+
25
+ const NOW = Date.parse("2026-08-11T12:00:00Z");
26
+ function tmp() { return mkdtempSync(join(tmpdir(), "exec-effects-")); }
27
+
28
+ const DECISION = {
29
+ key: "board.item.assigned#42",
30
+ thread: "board:-:t1",
31
+ surface: "task_assigned",
32
+ disposition: "schedule",
33
+ reason: "not_respondable",
34
+ tier: "T0",
35
+ rung: 3,
36
+ obligationKey: "react.task_assigned",
37
+ why: ["directed: assignee on task_assigned (tier T0)", "task_assigned is not a conversational surface"],
38
+ };
39
+
40
+ test("a scheduled item is parseable by the EXISTING backlog parser", () => {
41
+ // The whole reuse claim rests on this: the daemon's 2-minute sweep already
42
+ // reads this format, so scheduling needs no dispatcher change.
43
+ const root = tmp();
44
+ try {
45
+ const r = scheduleToQueue(DECISION, { agentRoot: root, nowMs: NOW });
46
+ assert.equal(r.ok, true);
47
+ const body = readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8");
48
+ const items = parseQueueItems(body, "inbound.yaml");
49
+ assert.equal(items.length, 1);
50
+ assert.equal(items[0].status, "open");
51
+ assert.equal(items[0].priority, "high", "a T0 event is high priority");
52
+ assert.ok(items[0].id, "the id must survive — the claim subsystem is gated on it");
53
+ assert.ok(items[0].title.includes("task_assigned"));
54
+ assert.ok(items[0].next_action);
55
+ } finally { rmSync(root, { recursive: true, force: true }); }
56
+ });
57
+
58
+ test("the reasoning travels WITH the queued work", () => {
59
+ const root = tmp();
60
+ try {
61
+ scheduleToQueue(DECISION, { agentRoot: root, nowMs: NOW });
62
+ const body = readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8");
63
+ assert.match(body, /why:/);
64
+ assert.match(body, /tier T0/, "a queue item whose provenance is a mystery is one nobody actions");
65
+ assert.match(body, /event_key: "board.item.assigned#42"/);
66
+ assert.match(body, /obligation_key: "react.task_assigned"/);
67
+ } finally { rmSync(root, { recursive: true, force: true }); }
68
+ });
69
+
70
+ test("scheduling the same decision twice does NOT double-queue", () => {
71
+ const root = tmp();
72
+ try {
73
+ const a = scheduleToQueue(DECISION, { agentRoot: root, nowMs: NOW });
74
+ const b = scheduleToQueue(DECISION, { agentRoot: root, nowMs: NOW });
75
+ assert.equal(a.ref.deduped, false);
76
+ assert.equal(b.ref.deduped, true);
77
+ const items = parseQueueItems(readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8"), "q");
78
+ assert.equal(items.length, 1);
79
+ } finally { rmSync(root, { recursive: true, force: true }); }
80
+ });
81
+
82
+ test("distinct events append distinct items", () => {
83
+ const root = tmp();
84
+ try {
85
+ scheduleToQueue(DECISION, { agentRoot: root, nowMs: NOW });
86
+ scheduleToQueue({ ...DECISION, key: "board.item.assigned#43" }, { agentRoot: root, nowMs: NOW });
87
+ const items = parseQueueItems(readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8"), "q");
88
+ assert.equal(items.length, 2);
89
+ assert.notEqual(items[0].id, items[1].id);
90
+ } finally { rmSync(root, { recursive: true, force: true }); }
91
+ });
92
+
93
+ test("the dedupe check anchors on a whole `id` key, not a substring", () => {
94
+ const root = tmp();
95
+ try {
96
+ // `#4` must not be seen as already present because `#42` is.
97
+ scheduleToQueue({ ...DECISION, key: "board.item.assigned#42" }, { agentRoot: root, nowMs: NOW });
98
+ const r = scheduleToQueue({ ...DECISION, key: "board.item.assigned#4" }, { agentRoot: root, nowMs: NOW });
99
+ assert.equal(r.ref.deduped, false, "a prefix collision must not swallow a distinct event");
100
+ assert.equal(parseQueueItems(readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8"), "q").length, 2);
101
+ } finally { rmSync(root, { recursive: true, force: true }); }
102
+ });
103
+
104
+ test("a hostile surface/reason cannot inject YAML lines", () => {
105
+ const root = tmp();
106
+ try {
107
+ scheduleToQueue(
108
+ { ...DECISION, key: "k1", surface: 'x"\nstatus: blocked\npriority: "critical', why: ["a\nb"] },
109
+ { agentRoot: root, nowMs: NOW },
110
+ );
111
+ const items = parseQueueItems(readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8"), "q");
112
+ assert.equal(items.length, 1, "the injected `status: blocked` must not become a real key");
113
+ assert.equal(items[0].status, "open");
114
+ assert.equal(items[0].priority, "high");
115
+ } finally { rmSync(root, { recursive: true, force: true }); }
116
+ });
117
+
118
+ test("a lower-tier or downgraded event queues at normal priority", () => {
119
+ const root = tmp();
120
+ try {
121
+ scheduleToQueue({ ...DECISION, key: "k1", tier: "T1" }, { agentRoot: root, nowMs: NOW });
122
+ scheduleToQueue({ ...DECISION, key: "k2", reason: "actor_flood" }, { agentRoot: root, nowMs: NOW });
123
+ const items = parseQueueItems(readFileSync(join(root, INBOUND_QUEUE_REL), "utf-8"), "q");
124
+ assert.deepEqual(items.map((i) => i.priority), ["normal", "normal"]);
125
+ } finally { rmSync(root, { recursive: true, force: true }); }
126
+ });
127
+
128
+ test("an unwritable root is reported, not thrown", () => {
129
+ const r = scheduleToQueue(DECISION, { agentRoot: "/proc/nonexistent-cannot-create", nowMs: NOW });
130
+ assert.equal(r.ok, false);
131
+ assert.ok(r.error, "the caller must be told the work was not queued");
132
+ });
133
+
134
+ test("drift and proposals are appended as JSONL with their provenance", () => {
135
+ const root = tmp();
136
+ try {
137
+ writeDrift({ kind: "uncovered_event", detail: { surface: "dm" } }, { agentRoot: root, nowMs: NOW, decision: DECISION });
138
+ const drift = JSON.parse(readFileSync(join(root, DRIFT_REL), "utf-8").trim());
139
+ assert.equal(drift.kind, "uncovered_event");
140
+ assert.equal(drift.key, "react.task_assigned");
141
+ assert.equal(drift.source, "execution-ladder");
142
+
143
+ writeProposal({ ...DECISION, disposition: "react_now" }, { agentRoot: root, nowMs: NOW });
144
+ const prop = JSON.parse(readFileSync(join(root, PROPOSED_REL), "utf-8").trim());
145
+ assert.equal(prop.disposition, "react_now");
146
+ assert.deepEqual(prop.why, DECISION.why, "the observe-only window is only readable if the reasoning is kept");
147
+ } finally { rmSync(root, { recursive: true, force: true }); }
148
+ });
149
+
150
+ test("writeDrift tolerates a malformed drift record", () => {
151
+ const root = tmp();
152
+ try {
153
+ assert.equal(writeDrift(null, { agentRoot: root, nowMs: NOW }).ok, true);
154
+ const row = JSON.parse(readFileSync(join(root, DRIFT_REL), "utf-8").trim());
155
+ assert.equal(row.kind, "unknown");
156
+ } finally { rmSync(root, { recursive: true, force: true }); }
157
+ });
158
+
159
+ test("defaultEffects wires every effect EXCEPT react, which the caller must supply", () => {
160
+ const root = tmp();
161
+ try {
162
+ const e = defaultEffects({ agentRoot: root });
163
+ for (const name of ["schedule", "delegate", "escalate", "requestApproval", "arbitrate", "reportDrift", "propose"]) {
164
+ assert.equal(typeof e[name], "function", `${name} should be wired`);
165
+ }
166
+ // `react` is deliberately absent: the dispatcher owns session spawning and
167
+ // this module must not grow a second spawner. A caller that forgets gets a
168
+ // loud `react_effect_missing`, which is the correct failure.
169
+ assert.equal(e.react, undefined);
170
+ assert.equal(typeof defaultEffects({ agentRoot: root, react: async () => ({ ok: true }) }).react, "function");
171
+ } finally { rmSync(root, { recursive: true, force: true }); }
172
+ });
173
+
174
+ test("the default schedule effect writes through to the queue", async () => {
175
+ const root = tmp();
176
+ try {
177
+ const e = defaultEffects({ agentRoot: root, nowMs: NOW });
178
+ const r = await e.schedule({ decision: DECISION });
179
+ assert.equal(r.ok, true);
180
+ assert.ok(existsSync(join(root, INBOUND_QUEUE_REL)));
181
+ } finally { rmSync(root, { recursive: true, force: true }); }
182
+ });
183
+
184
+ test("the default propose + reportDrift effects write through", async () => {
185
+ const root = tmp();
186
+ try {
187
+ const e = defaultEffects({ agentRoot: root, nowMs: NOW });
188
+ assert.equal((await e.propose({ decision: DECISION })).ok, true);
189
+ assert.equal((await e.reportDrift({ drift: { kind: "budget_breach" }, decision: DECISION })).ok, true);
190
+ assert.ok(existsSync(join(root, PROPOSED_REL)));
191
+ assert.ok(existsSync(join(root, DRIFT_REL)));
192
+ } finally { rmSync(root, { recursive: true, force: true }); }
193
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * lib/execution/index.mjs — the one function the inbound pipeline calls.
3
+ *
4
+ * The inbound layer (`lib/org/inbound/`) produces "here is an event, and here is
5
+ * whether it is yours". Everything after that — should we answer, queue, hand
6
+ * off, ask, or drop; how much machinery it deserves; what gates it must clear;
7
+ * and writing down why — is this package, behind one call:
8
+ *
9
+ * import { handleInboundEvent } from "../execution/index.mjs";
10
+ *
11
+ * const result = await handleInboundEvent({
12
+ * candidate, // directedness.classifyEvent(ev)
13
+ * verdict, // directedness.resolveDirected(candidate, me, facts)
14
+ * me, // my member id
15
+ * runtime, // emergencyStop / directive / staleness / budget
16
+ * effects, // defaultEffects({ agentRoot, react, ... })
17
+ * agentRoot,
18
+ * });
19
+ *
20
+ * `result.decision.why` is the ordered, plain-English reasoning; `result.effect`
21
+ * is what actually happened. Both are already on disk in
22
+ * `state/execution/journal.jsonl` by the time this returns.
23
+ *
24
+ * The history view is loaded HERE rather than in `decide` so that the decision
25
+ * function stays pure and the (bounded, tail-only) journal read happens once per
26
+ * event. A caller batching a burst of events can load the history itself and
27
+ * pass it in, which is what the sweep does.
28
+ *
29
+ * @module lib/execution
30
+ */
31
+
32
+ "use strict";
33
+
34
+ import { decide, willAct } from "./disposition.mjs";
35
+ import { drive } from "./drive.mjs";
36
+ import { loadHistory, recordHumanTurn, threadKey } from "./journal.mjs";
37
+
38
+ export { decide, willAct } from "./disposition.mjs";
39
+ export { drive, EFFECT_FOR } from "./drive.mjs";
40
+ export { routeRung, baseRung, RUNGS } from "./route.mjs";
41
+ export {
42
+ policyFor,
43
+ SURFACE_POLICY,
44
+ ORG_SURFACE_POLICY,
45
+ EXTENDED_SURFACE_POLICY,
46
+ EXTENDED_SURFACE_NAMES,
47
+ DISPOSITIONS,
48
+ uncoveredSurfaces,
49
+ orphanPolicies,
50
+ adoptedExtendedSurfaces,
51
+ } from "./surface-policy.mjs";
52
+ export {
53
+ intake,
54
+ fromPair,
55
+ fromMessageEvent,
56
+ fromInboxItem,
57
+ fromChannelEvent,
58
+ fromAlert,
59
+ fromRawEvent,
60
+ parseRawRef,
61
+ } from "./intake.mjs";
62
+ export {
63
+ matchObligation,
64
+ proposeReact,
65
+ reactObligations,
66
+ loadReactObligations,
67
+ } from "./match.mjs";
68
+ export { processInbound, processOne, batchHistory } from "./pipeline.mjs";
69
+ export {
70
+ loadHistory,
71
+ recordDecision,
72
+ recordOutcome,
73
+ recordHumanTurn,
74
+ dedupeKey,
75
+ threadKey,
76
+ journalPath,
77
+ } from "./journal.mjs";
78
+ export { defaultEffects, scheduleToQueue, writeDrift, writeProposal } from "./effects.mjs";
79
+
80
+ /**
81
+ * Decide and act on one inbound event.
82
+ *
83
+ * @param {object} o
84
+ * @param {object} o.candidate
85
+ * @param {object} o.verdict
86
+ * @param {string} [o.me]
87
+ * @param {object} [o.history] pre-loaded (a batch caller); loaded here if absent
88
+ * @param {object} [o.runtime]
89
+ * @param {object|null} [o.obligation]
90
+ * @param {object} [o.need]
91
+ * @param {Array} [o.manifest]
92
+ * @param {object} [o.facts]
93
+ * @param {object} [o.effects]
94
+ * @param {string} [o.agentRoot]
95
+ * @param {number} [o.nowMs]
96
+ * @param {string} [o.traceId]
97
+ * @param {Function} [o.log]
98
+ * @param {boolean} [o.humanTurn] the actor is a human — resets the ping-pong
99
+ * counter for this thread BEFORE the decision is taken, so a human
100
+ * re-entering a conversation immediately unblocks the agent.
101
+ * @returns {Promise<{decision:object, ok:boolean, effect:string|null, ref:any, spoke:boolean, degraded:string[], error:string|null}>}
102
+ */
103
+ export async function handleInboundEvent(o = {}) {
104
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
105
+
106
+ // A human turn resets the chain counter first. Doing this before the history
107
+ // load (rather than after the decision) is what makes "human replies, agent
108
+ // may speak again" work on the very next event instead of the one after.
109
+ if (o.humanTurn === true && o.candidate) {
110
+ recordHumanTurn(
111
+ {
112
+ thread: threadKey(o.candidate),
113
+ actor: String(o.candidate.actor || ""),
114
+ surface: o.verdict ? o.verdict.surface : null,
115
+ },
116
+ { agentRoot: o.agentRoot, nowMs, traceId: o.traceId },
117
+ );
118
+ }
119
+
120
+ const history =
121
+ o.history || loadHistory({ agentRoot: o.agentRoot, nowMs, path: o.journalPath, rows: o.rows });
122
+
123
+ const decision = decide({
124
+ candidate: o.candidate,
125
+ verdict: o.verdict,
126
+ me: o.me,
127
+ history,
128
+ runtime: o.runtime,
129
+ obligation: o.obligation,
130
+ need: o.need,
131
+ manifest: o.manifest,
132
+ facts: o.facts,
133
+ policy: o.policy,
134
+ actorFloodLimit: o.actorFloodLimit,
135
+ nowMs,
136
+ });
137
+
138
+ const res = await drive(decision, {
139
+ candidate: o.candidate,
140
+ effects: o.effects,
141
+ agentRoot: o.agentRoot,
142
+ nowMs,
143
+ traceId: o.traceId,
144
+ log: o.log,
145
+ append: o.append,
146
+ journalPath: o.journalPath,
147
+ });
148
+
149
+ return { ...res, decision };
150
+ }
151
+
152
+ export default { handleInboundEvent, decide, drive, willAct };