@cohortapp/agent-sdk 2.3.2 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +105 -17
- package/lib/setup/enroll-from-cohort.test.mjs +68 -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
package/lib/claude-bin.test.mjs
CHANGED
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
};
|