@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
@@ -64,12 +64,46 @@ test("augmentedPath includes common bin locations and preserves existing PATH",
64
64
  assert.ok(p.includes("/opt/homebrew/bin"));
65
65
  });
66
66
 
67
- test("daemonClaudeArgs returns strict-mcp-config by default", () => {
68
- const prev = process.env.DAEMON_LOAD_MCPS;
69
- delete process.env.DAEMON_LOAD_MCPS;
70
- try {
71
- assert.deepEqual(daemonClaudeArgs(), ["--strict-mcp-config"]);
72
- } finally {
73
- if (prev !== undefined) process.env.DAEMON_LOAD_MCPS = prev;
74
- }
67
+ test("daemonClaudeArgs returns strict-mcp-config when the agent has no .mcp.json", () => {
68
+ // Injected deps, so the result does not depend on the cwd the suite runs from.
69
+ const args = daemonClaudeArgs("/nope/agent", { existsSync: () => false, env: {} });
70
+ assert.deepEqual(args, ["--strict-mcp-config"]);
71
+ });
72
+
73
+ test("daemonClaudeArgs pairs --mcp-config with --strict-mcp-config so the ORG tools load", () => {
74
+ // The regression this guards: `--strict-mcp-config` alone means "load no MCP
75
+ // servers at all", which silently killed the agent's own curated org server
76
+ // and made rung 0 of the execution ladder unreachable.
77
+ const seen = [];
78
+ const args = daemonClaudeArgs("/agent", { existsSync: (p) => { seen.push(p); return true; }, env: {} });
79
+ assert.deepEqual(args, ["--mcp-config", "/agent/.mcp.json", "--strict-mcp-config"]);
80
+ assert.ok(seen.some((p) => String(p).endsWith("/agent/.mcp.json")));
81
+ });
82
+
83
+ test("daemonClaudeArgs falls back to $AGENT_ROOT when no root is passed", () => {
84
+ const args = daemonClaudeArgs(undefined, { existsSync: () => true, env: { AGENT_ROOT: "/from/env" } });
85
+ assert.deepEqual(args, ["--mcp-config", "/from/env/.mcp.json", "--strict-mcp-config"]);
86
+ });
87
+
88
+ test("daemonClaudeArgs: DAEMON_LOAD_MCPS=1 still means 'no flags at all'", () => {
89
+ assert.deepEqual(daemonClaudeArgs("/agent", { existsSync: () => true, env: { DAEMON_LOAD_MCPS: "1" } }), []);
90
+ });
91
+
92
+ test("daemonClaudeArgs: DAEMON_SKIP_ORG_MCP=1 restores the old blanket behaviour", () => {
93
+ assert.deepEqual(
94
+ daemonClaudeArgs("/agent", { existsSync: () => true, env: { DAEMON_SKIP_ORG_MCP: "1" } }),
95
+ ["--strict-mcp-config"],
96
+ );
97
+ });
98
+
99
+ test("daemonClaudeArgs: --bare stays first, ahead of the mcp flags", () => {
100
+ assert.deepEqual(
101
+ daemonClaudeArgs("/agent", { existsSync: () => true, env: { DAEMON_BARE_MODE: "1" } }),
102
+ ["--bare", "--mcp-config", "/agent/.mcp.json", "--strict-mcp-config"],
103
+ );
104
+ });
105
+
106
+ test("daemonClaudeArgs never throws on a bad root (a daemon must still spawn)", () => {
107
+ const args = daemonClaudeArgs("/agent", { existsSync: () => { throw new Error("EACCES"); }, env: {} });
108
+ assert.deepEqual(args, ["--strict-mcp-config"]);
75
109
  });
@@ -0,0 +1,501 @@
1
+ /**
2
+ * lib/execution/disposition.mjs — given a DIRECTED event, decide what to do.
3
+ *
4
+ * This is the layer above `lib/org/inbound/`. That layer answers "did something
5
+ * happen, and is it mine?". This one answers the question the agent actually has
6
+ * to get right:
7
+ *
8
+ * react now · schedule · delegate · escalate · ignore
9
+ *
10
+ * and it answers it the same way for every surface, so a board comment and a DM
11
+ * and a bounced email go through one ladder instead of three ad-hoc branches.
12
+ *
13
+ * Three properties are non-negotiable:
14
+ *
15
+ * 1. PURE. Every fact arrives on the argument — history, runtime state, the
16
+ * capability manifest, the clock. No disk, no network, no `Date.now()`
17
+ * unless the caller declined to supply one. That is what makes a decision
18
+ * replayable from the journal months later, and testable per surface.
19
+ *
20
+ * 2. TOTAL. There is no input for which this returns undefined, throws, or
21
+ * falls off the end. An unrecognised surface, a malformed candidate and a
22
+ * verdict from a future version of the inbound layer all produce a decision
23
+ * with a reason attached.
24
+ *
25
+ * 3. IGNORE IS A DECISION. An agent that replies to everything is exactly as
26
+ * broken as one that replies to nothing, and the second failure mode is the
27
+ * one this codebase keeps shipping — a guard returns "not configured", logs
28
+ * nothing, and the message is gone. So `ignore` carries a machine-readable
29
+ * `reason`, the full rule trace in `why`, and is journaled like any other
30
+ * outcome. `ignore` never means "we lost it".
31
+ *
32
+ * The `why` array is the load-bearing output. It is an ordered trace of every
33
+ * rule that fired, in the order it fired, ending with the rule that decided.
34
+ * Read top to bottom it is a plain-English explanation of the agent's reasoning.
35
+ *
36
+ * @module lib/execution/disposition
37
+ */
38
+
39
+ "use strict";
40
+
41
+ import { policyFor, isDisposition } from "./surface-policy.mjs";
42
+ import { routeRung } from "./route.mjs";
43
+ import { dedupeKey, threadKey } from "./journal.mjs";
44
+
45
+ /**
46
+ * Verdict reasons that mean "the event structurally named me" — tier T0 in the
47
+ * directedness ladder. A human pointed at this agent; the agent owes an answer.
48
+ */
49
+ export const T0_REASONS = Object.freeze([
50
+ "direct", "dm", "mention", "named", "assignee", "reviewer", "owner",
51
+ "approver", "requester", "proposer", "participant", "invited", "attendee",
52
+ "waiting_on",
53
+ ]);
54
+
55
+ /**
56
+ * Verdict reasons that mean "this landed in a lane I work in" — tier T1. Mine to
57
+ * notice, not necessarily mine to answer inside a minute.
58
+ */
59
+ export const T1_REASONS = Object.freeze([
60
+ "thread", "channel", "shared", "commenter", "watcher", "creator", "lane",
61
+ "home_channel",
62
+ ]);
63
+
64
+ /** Directedness tier for a verdict reason. Unknown ⇒ T1 (notice, don't jump). */
65
+ export function tierOf(reason) {
66
+ const r = String(reason || "");
67
+ if (T0_REASONS.includes(r)) return "T0";
68
+ if (T1_REASONS.includes(r)) return "T1";
69
+ return "T1";
70
+ }
71
+
72
+ /**
73
+ * How many times one actor may wake this agent inside the flood window before
74
+ * their traffic gets batched instead of answered turn-by-turn. Not a silence —
75
+ * a downgrade from `react_now` to `schedule`, so nothing is lost, it is just
76
+ * answered once rather than eight times.
77
+ */
78
+ export const DEFAULT_ACTOR_FLOOD_LIMIT = 8;
79
+
80
+ /** Staleness rungs from SPEC §6.4, in seconds. */
81
+ export const STALE_SUSPEND_OUTCOME_S = 24 * 3600;
82
+ export const STALE_OFFLINE_ONLY_S = 72 * 3600;
83
+ export const STALE_IDLE_S = 7 * 24 * 3600;
84
+
85
+ /**
86
+ * Surfaces the staleness ladder does NOT gate, because handling them is what
87
+ * ENDS the staleness.
88
+ *
89
+ * §6.4 says the cache "is refreshed on every `topic:"mandate"` frame" and that a
90
+ * >7d-stale daemon "posts one PlanDrift per day and idles". Read together those
91
+ * two sentences are a deadlock if the ladder is applied uniformly: the mandate
92
+ * frame is the only thing that can clear the staleness, and at 7d the ladder
93
+ * would drop it, so the agent idles forever and never recovers on its own.
94
+ *
95
+ * `alert` is exempt for the same structural reason from the other direction — a
96
+ * local health/budget alert is derived from files on this disk, owes nothing to
97
+ * the mandate, and a partition is precisely when it matters most.
98
+ *
99
+ * Both are cheap and inward-facing (no action classes, nothing leaves the org),
100
+ * so exempting them cannot turn a stale agent into one emailing counterparties
101
+ * on week-old instructions — which is the risk the ladder exists to stop.
102
+ */
103
+ export const RECOVERY_SURFACES = Object.freeze(["mandate", "alert"]);
104
+
105
+ /** Build the terminal decision object. Internal; every exit funnels through it. */
106
+ function verdictOut(base, disposition, reason, why, extra = {}) {
107
+ return {
108
+ ...base,
109
+ disposition,
110
+ reason,
111
+ why: why.slice(),
112
+ ...extra,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Decide what to do with one inbound event.
118
+ *
119
+ * @param {object} o
120
+ * @param {object} o.candidate a `directedness.classifyEvent` Candidate
121
+ * @param {object} o.verdict a `directedness.resolveDirected` verdict
122
+ * @param {string} [o.me] my member id
123
+ * @param {object} [o.history] a `journal.loadHistory` view (guards are skipped,
124
+ * LOUDLY, when absent — see `degraded`)
125
+ * @param {object} [o.runtime] {
126
+ * emergencyStop, directive, mandateStaleSeconds, enforcement,
127
+ * remainingCents, dailyCapExhausted, withinWorkingHours, maxRung
128
+ * }
129
+ * @param {object|null} [o.obligation] the matched REACT obligation, or null
130
+ * @param {object} [o.need] routing need estimate (see route.mjs Need)
131
+ * @param {Array} [o.manifest] capability manifest
132
+ * @param {object} [o.facts] delegation/authority facts {
133
+ * ownerId, capableMemberId, capabilityReachable, ambiguousOptions, authorised
134
+ * }
135
+ * @param {number} [o.nowMs]
136
+ * @param {number} [o.actorFloodLimit]
137
+ * @returns {object} decision
138
+ */
139
+ export function decide(o = {}) {
140
+ const why = [];
141
+ const cand = o.candidate && typeof o.candidate === "object" ? o.candidate : null;
142
+ const verdict = o.verdict && typeof o.verdict === "object" ? o.verdict : null;
143
+ const runtime = o.runtime && typeof o.runtime === "object" ? o.runtime : {};
144
+ const facts = o.facts && typeof o.facts === "object" ? o.facts : {};
145
+ const me = o.me ? String(o.me) : "";
146
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : Date.now();
147
+
148
+ const surface = verdict && verdict.surface ? String(verdict.surface) : null;
149
+ const policy = o.policy || policyFor(surface);
150
+ const tier = verdict && verdict.directed ? tierOf(verdict.reason) : null;
151
+
152
+ const base = {
153
+ key: cand ? dedupeKey(cand) : null,
154
+ thread: cand ? threadKey(cand) : null,
155
+ actor: cand ? String(cand.actor || "") || null : null,
156
+ surface,
157
+ topic: cand ? String(cand.topic || "") || null : null,
158
+ tier,
159
+ family: cand ? String(cand.family || "") || null : null,
160
+ kind: cand ? String(cand.kind || "") || null : null,
161
+ obligationKey: o.obligation ? o.obligation.key || null : null,
162
+ objectiveId: o.obligation ? o.obligation.objective_id || o.obligation.objectiveId || null : null,
163
+ policySurface: policy.unknown ? "default" : surface,
164
+ rung: null,
165
+ mechanism: null,
166
+ action: null,
167
+ gates: {},
168
+ drift: null,
169
+ degraded: [],
170
+ at: new Date(nowMs).toISOString(),
171
+ };
172
+
173
+ // ── 0. structural sanity ────────────────────────────────────────────────
174
+ if (!cand) {
175
+ why.push("no candidate: the event did not classify into an inbound surface");
176
+ return verdictOut(base, "ignore", "not_classified", why);
177
+ }
178
+ if (!verdict) {
179
+ // A candidate with no verdict means the caller skipped the directedness
180
+ // join. Fail CLOSED on speaking: we will not reply to something nobody
181
+ // established was ours, but we will not drop it either.
182
+ why.push("no directedness verdict supplied — cannot establish this event is mine");
183
+ return verdictOut(base, "ignore", "no_verdict", why);
184
+ }
185
+ if (policy.unknown) {
186
+ why.push(`surface ${surface || "(none)"} has no policy row — falling back to the restrictive default`);
187
+ base.degraded.push(`unknown_surface:${surface || "none"}`);
188
+ }
189
+ if (!o.history) {
190
+ why.push("no journal history supplied — dedupe, ping-pong and flood guards are INACTIVE for this decision");
191
+ base.degraded.push("history_absent");
192
+ } else if (o.history.degraded) {
193
+ why.push(`journal history degraded: ${o.history.degradedReason || "unknown"}`);
194
+ base.degraded.push(`history_degraded:${o.history.degradedReason || "unknown"}`);
195
+ }
196
+
197
+ // ── 1. kill switches. Cooperative, and honestly labelled as such: these stop
198
+ // a well-behaved daemon. The real control against a wedged laptop is
199
+ // org-side credential revocation, not this branch.
200
+ if (runtime.emergencyStop === true) {
201
+ why.push("state/EMERGENCY_STOP present → the agent takes no action of any kind");
202
+ return verdictOut(base, "ignore", "emergency_stop", why);
203
+ }
204
+ const directive = runtime.directive ? String(runtime.directive) : null;
205
+ if (directive === "halt") {
206
+ why.push("beat directive `halt` → the seat is stopped");
207
+ return verdictOut(base, "ignore", "halt", why);
208
+ }
209
+
210
+ // ── 2. is it even mine? THE ignore case, and the most common one by volume.
211
+ if (verdict.directed !== true) {
212
+ const r = String(verdict.reason || "not_directed");
213
+ // An undirected verdict carries NO surface — that is the whole point of it.
214
+ // So the park-or-drop question is asked of the surfaces this event could
215
+ // plausibly have been, which the classifier listed most-specific-first. If
216
+ // ANY of them is a look-twice surface we park: a message in a space that
217
+ // matched no rule might be a mention whose hydration was incomplete, and
218
+ // that is exactly the case worth a second pass rather than a drop.
219
+ const plausible = Array.isArray(cand.surfaces) && cand.surfaces.length ? cand.surfaces : [surface];
220
+ const parkers = plausible.filter((s) => policyFor(s).ambientDrop === false);
221
+ if (parkers.length) {
222
+ why.push(`not directed (${r}), but it could have been ${parkers.join("/")} → park for the ambient sweep`);
223
+ return verdictOut(base, "ignore", r, why, { ambient: true, plausibleSurfaces: plausible });
224
+ }
225
+ why.push(`not directed (${r}) → drop`);
226
+ return verdictOut(base, "ignore", r, why, { ambient: false, plausibleSurfaces: plausible });
227
+ }
228
+ why.push(`directed: ${verdict.reason} on ${surface} (tier ${tier})`);
229
+
230
+ // Defensive echo check. `resolveDirected` already drops own-echo, but this is
231
+ // the branch that turns a bug there into a self-conversation, so it is worth
232
+ // asserting twice.
233
+ if (me && base.actor && base.actor === me) {
234
+ why.push("actor is me — this is my own echo arriving back");
235
+ return verdictOut(base, "ignore", "own_echo", why);
236
+ }
237
+
238
+ // ── 3. idempotency. The SSE push and the polling sweep BOTH deliver; without
239
+ // this the agent answers every directed message exactly twice.
240
+ if (o.history && base.key && o.history.seen(base.key)) {
241
+ why.push(`already decided on ${base.key} inside the dedupe window → this is a redelivery`);
242
+ return verdictOut(base, "ignore", "duplicate", why);
243
+ }
244
+
245
+ // ── 4. ping-pong guard. Counts turns where the agent actually SPOKE, and is
246
+ // reset by a human turn, so a live conversation is never cut off — only
247
+ // an agent talking to itself is.
248
+ if (o.history && base.thread) {
249
+ const depth = o.history.chainDepth(base.thread);
250
+ if (depth >= policy.maxChainDepth) {
251
+ why.push(
252
+ `${depth} consecutive agent turn(s) in this thread with no human reply, ceiling ${policy.maxChainDepth} → stop talking`,
253
+ );
254
+ return verdictOut(base, "ignore", "reply_chain_depth", why, { chainDepth: depth });
255
+ }
256
+ if (depth > 0) why.push(`${depth} prior agent turn(s) in this thread (ceiling ${policy.maxChainDepth})`);
257
+ }
258
+
259
+ // ── 5. mandate staleness ladder (§6.4). A partitioned agent degrades on a
260
+ // ramp; it does not go dark, and it does not keep speaking externally on
261
+ // week-old instructions.
262
+ const stale = Number.isFinite(runtime.mandateStaleSeconds) ? runtime.mandateStaleSeconds : 0;
263
+ const recoverySurface = surface != null && RECOVERY_SURFACES.includes(surface);
264
+ if (recoverySurface && stale >= STALE_SUSPEND_OUTCOME_S) {
265
+ // NEVER SILENT: say out loud that the ladder was skipped and why, so a
266
+ // journal reader does not have to know this rule to understand the trace.
267
+ why.push(
268
+ `mandate cache is ${Math.floor(stale / 3600)}h stale, but ${surface} is a recovery surface — handling it is what clears the staleness, so the ladder does not gate it`,
269
+ );
270
+ }
271
+ if (!recoverySurface && stale >= STALE_IDLE_S) {
272
+ why.push(`mandate cache is ${Math.floor(stale / 86400)}d stale (≥7d) → idle and report drift`);
273
+ return verdictOut(base, "ignore", "mandate_stale", why, {
274
+ drift: { kind: "mandate_stale", detail: { staleSeconds: stale } },
275
+ });
276
+ }
277
+ if (!recoverySurface && stale >= STALE_OFFLINE_ONLY_S) {
278
+ const unsafe = policy.offlineSafe !== true || (policy.actionClasses || []).length > 0;
279
+ if (unsafe) {
280
+ why.push(
281
+ `mandate cache is ${Math.floor(stale / 3600)}h stale (>72h) and ${surface} is not offline-safe → defer until the mandate refreshes`,
282
+ );
283
+ return verdictOut(base, "schedule", "mandate_stale_deferred", why, {
284
+ notBefore: null,
285
+ drift: { kind: "mandate_stale", detail: { staleSeconds: stale, surface } },
286
+ });
287
+ }
288
+ why.push(`mandate ${Math.floor(stale / 3600)}h stale but ${surface} is offline-safe → proceeding`);
289
+ }
290
+
291
+ // ── 6. things the agent is structurally not allowed to decide. This is the
292
+ // same law as `mandate.adopt` refusing self-adoption: a seat may not be
293
+ // the terminal authority on its own approvals or on committing the org.
294
+ if (policy.selfApprove === false) {
295
+ why.push(`${surface} may never be resolved by the agent itself → escalate to a human decider`);
296
+ return finish(base, "escalate", "cannot_self_decide", why, o, {
297
+ escalateTo: facts.escalateTo || null,
298
+ options: Array.isArray(facts.ambiguousOptions) ? facts.ambiguousOptions : [],
299
+ });
300
+ }
301
+
302
+ // ── 7. capability wall. A missing capability is a FIRST-CLASS path, never a
303
+ // silent failure — that is the concrete fix for "never refuse, walk the
304
+ // ladder" breaking on the SDK side today.
305
+ if (facts.capabilityReachable === false) {
306
+ if (facts.capableMemberId && facts.capableMemberId !== me) {
307
+ why.push(
308
+ `the capability this needs is unreachable for me but ${facts.capableMemberId} has it → hand it to them`,
309
+ );
310
+ return finish(base, "delegate", "unreachable_capability_delegated", why, o, {
311
+ delegateTo: facts.capableMemberId,
312
+ drift: { kind: "unreachable_capability", detail: { key: base.obligationKey } },
313
+ });
314
+ }
315
+ why.push("the capability this needs is unreachable and no peer has it → ask the owner to grant, retarget or retire");
316
+ return finish(base, "escalate", "unreachable_capability", why, o, {
317
+ escalateTo: facts.escalateTo || null,
318
+ options: ["grant the capability", "retarget the objective", "retire the obligation"],
319
+ drift: { kind: "unreachable_capability", detail: { key: base.obligationKey } },
320
+ });
321
+ }
322
+
323
+ // ── 8. genuine ambiguity. Two or more viable options and no basis to choose
324
+ // is an `escalation.ask` with concrete options — not a coin flip, and not
325
+ // a paragraph of prose asking the human to work it out.
326
+ const options = Array.isArray(facts.ambiguousOptions) ? facts.ambiguousOptions : [];
327
+ if (options.length >= 2) {
328
+ why.push(`${options.length} viable options and no basis to choose → ask, with the options attached`);
329
+ return finish(base, "escalate", "ambiguous", why, o, {
330
+ escalateTo: facts.escalateTo || null,
331
+ options,
332
+ });
333
+ }
334
+
335
+ // ── 9. someone else owns this. Being mentioned on a task that is not yours is
336
+ // a reason to route it, not to do it.
337
+ if (facts.ownerId && me && String(facts.ownerId) !== me && tier === "T1") {
338
+ why.push(`${facts.ownerId} owns this and I was only in the lane → route it to them`);
339
+ return finish(base, "delegate", "not_my_lane", why, o, { delegateTo: String(facts.ownerId) });
340
+ }
341
+
342
+ // ── 10. pause_schedules: the seat stops INITIATING but keeps ANSWERING. So a
343
+ // conversational reply survives; queueing new work does not.
344
+ const paused = directive === "pause_schedules";
345
+
346
+ // ── 11. observe-only enforcement (§5.10). Decisions are still made and still
347
+ // journaled — they just produce proposals instead of work. Downgrading
348
+ // the EFFECT rather than the DECISION is what makes the 14-day window
349
+ // readable afterwards: you can see exactly what the agent would have done.
350
+ const observeOnly = String(runtime.enforcement || "").toLowerCase() === "observe_only";
351
+ if (observeOnly) why.push("enforcement=observe_only → the decision stands but produces a proposal, not an action");
352
+
353
+ // ── 12. the respond-or-queue split.
354
+ const budgetOut =
355
+ runtime.dailyCapExhausted === true ||
356
+ (Number.isFinite(runtime.remainingCents) && runtime.remainingCents <= 0);
357
+
358
+ if (!policy.respondable) {
359
+ if (paused) {
360
+ why.push("`pause_schedules` is in force and this surface cannot be answered conversationally → park it");
361
+ return verdictOut(base, "ignore", "paused", why, { observeOnly });
362
+ }
363
+ why.push(`${surface} is not a conversational surface → queue the work`);
364
+ return finish(base, "schedule", "not_respondable", why, o, { observeOnly });
365
+ }
366
+
367
+ if (budgetOut) {
368
+ why.push("the spend envelope is exhausted → queue rather than spend; nothing is dropped");
369
+ return finish(base, "schedule", "budget_exhausted", why, o, { observeOnly });
370
+ }
371
+
372
+ // Flood control. A downgrade, not a silence.
373
+ const floodLimit = Number.isFinite(o.actorFloodLimit) ? o.actorFloodLimit : DEFAULT_ACTOR_FLOOD_LIMIT;
374
+ if (o.history && base.actor) {
375
+ const hits = o.history.actorActivity(base.actor);
376
+ if (hits >= floodLimit) {
377
+ why.push(`${base.actor} has woken me ${hits} time(s) in the flood window (limit ${floodLimit}) → batch the reply`);
378
+ return finish(base, "schedule", "actor_flood", why, o, { observeOnly, batched: true });
379
+ }
380
+ }
381
+
382
+ if (policy.latency !== "now") {
383
+ why.push(`${surface} is a ${policy.latency} surface — nobody is watching for an instant reply → next tick`);
384
+ return finish(base, "schedule", "batch_surface", why, o, { observeOnly });
385
+ }
386
+
387
+ if (tier !== "T0" && runtime.withinWorkingHours === false) {
388
+ why.push("lane-tier event outside working hours → next tick");
389
+ return finish(base, "schedule", "outside_hours", why, o, { observeOnly });
390
+ }
391
+
392
+ why.push(`${surface} wants an answer now and I am the one who owes it`);
393
+ return finish(base, "react_now", "directed_now", why, o, { observeOnly });
394
+ }
395
+
396
+ /**
397
+ * Attach routing (rung, mechanism, gates) to a decision that will act, and flag
398
+ * an uncovered event when nothing in the plan matched.
399
+ *
400
+ * Split out so every acting exit routes identically — an earlier draft routed in
401
+ * three places and two of them forgot the approval gate.
402
+ */
403
+ function finish(base, disposition, reason, why, o, extra = {}) {
404
+ const policy = o.policy || policyFor(base.surface);
405
+ const runtime = o.runtime && typeof o.runtime === "object" ? o.runtime : {};
406
+ const drift = extra.drift || null;
407
+ const out = { ...extra };
408
+
409
+ // An event that is unmistakably mine but matches NO obligation is the signal
410
+ // that the plan is incomplete. Handle it once, cheaply, and record the gap so
411
+ // the plan learns instead of ossifying. Capping the rung is what keeps
412
+ // "unplanned" from meaning "unbounded".
413
+ let maxRung = Number.isFinite(runtime.maxRung) ? runtime.maxRung : undefined;
414
+ if (!o.obligation && disposition !== "ignore") {
415
+ why.push("no obligation in the compiled plan covers this event → handle once at rung ≤1 and record the gap");
416
+ maxRung = Math.min(maxRung === undefined ? 1 : maxRung, 1);
417
+ out.drift = drift || {
418
+ kind: "uncovered_event",
419
+ detail: { surface: base.surface, family: base.family, kind: base.kind },
420
+ };
421
+ } else if (drift) {
422
+ out.drift = drift;
423
+ }
424
+
425
+ // The blast radius of the ACTION is the union of what the surface costs to
426
+ // speak on and what the obligation itself declares.
427
+ const actionClasses = []
428
+ .concat(policy.actionClasses || [])
429
+ .concat((o.need && o.need.actionClasses) || [])
430
+ .concat((o.obligation && o.obligation.action_classes) || []);
431
+
432
+ const need = {
433
+ ...(o.need || {}),
434
+ actionClasses,
435
+ obligationKey: base.obligationKey,
436
+ };
437
+ if (!need.methods && policy.replyMethod && disposition === "react_now") {
438
+ need.methods = [policy.replyMethod];
439
+ }
440
+
441
+ const route = routeRung(need, {
442
+ manifest: o.manifest,
443
+ failures: o.history && base.obligationKey ? o.history.failures(base.obligationKey) : 0,
444
+ estCents: runtime.estCents,
445
+ remainingCents: runtime.remainingCents,
446
+ hasAdoptedObjective: !!base.objectiveId,
447
+ maxRung,
448
+ });
449
+
450
+ for (const line of route.why) why.push(`route: ${line}`);
451
+
452
+ const gates = {};
453
+ if (route.approval) gates.approval = route.approval;
454
+ if (route.gate) gates[route.gate.kind] = route.gate.detail;
455
+ // A reply into a shared room must win the thread-ownership lease first, or two
456
+ // agents answer the same @mention. DMs and queued work need no arbitration.
457
+ if (disposition === "react_now" && policy.replyMethod === "messaging.send" && base.surface !== "dm") {
458
+ gates.arbitration = { scope: "thread-ownership", resource: base.thread };
459
+ }
460
+
461
+ // Budget over-run turns an action into an enqueue. Never a silent downgrade
462
+ // to a cheaper rung — a cheaper rung is a worse answer, not a cheaper one.
463
+ let finalDisposition = disposition;
464
+ let finalReason = reason;
465
+ if (route.blocked && disposition === "react_now") {
466
+ why.push("routing is budget-blocked → the reply becomes a queued item");
467
+ finalDisposition = "schedule";
468
+ finalReason = "budget_blocked";
469
+ }
470
+
471
+ return verdictOut(base, finalDisposition, finalReason, why, {
472
+ ...out,
473
+ rung: route.rung,
474
+ mechanism: route.mechanism,
475
+ rungReason: route.reason,
476
+ escalatedFrom: route.escalatedFrom,
477
+ gates,
478
+ action: {
479
+ method: policy.replyMethod || null,
480
+ classes: route.approval ? route.approval.classes : [],
481
+ },
482
+ });
483
+ }
484
+
485
+ /** True when a decision means the agent will attempt something. */
486
+ export function willAct(decision) {
487
+ return !!decision && isDisposition(decision.disposition) && decision.disposition !== "ignore";
488
+ }
489
+
490
+ export default {
491
+ decide,
492
+ willAct,
493
+ tierOf,
494
+ T0_REASONS,
495
+ T1_REASONS,
496
+ RECOVERY_SURFACES,
497
+ DEFAULT_ACTOR_FLOOD_LIMIT,
498
+ STALE_SUSPEND_OUTCOME_S,
499
+ STALE_OFFLINE_ONLY_S,
500
+ STALE_IDLE_S,
501
+ };