@cohortapp/agent-sdk 2.3.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +58 -22
- package/lib/org/client.test.mjs +3 -1
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +8 -3
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/execution/surface-policy.mjs — what the agent is ALLOWED to do, per surface.
|
|
3
|
+
*
|
|
4
|
+
* `lib/org/inbound/surfaces.mjs` answers "what kinds of thing can arrive?" and
|
|
5
|
+
* `lib/org/inbound/directedness.mjs` answers "is this one mine?". Neither answers
|
|
6
|
+
* the question this layer exists for: **given that it IS mine, what should happen
|
|
7
|
+
* to it?** A DM and a bounced email are both "directed at me" and must not be
|
|
8
|
+
* treated the same way — one wants a reply inside a minute, the other wants a
|
|
9
|
+
* task on a queue and no reply at all.
|
|
10
|
+
*
|
|
11
|
+
* This table is that answer expressed as data, one row per surface, so adding a
|
|
12
|
+
* surface is a data edit rather than a new branch in the decision ladder.
|
|
13
|
+
*
|
|
14
|
+
* Columns:
|
|
15
|
+
*
|
|
16
|
+
* `respondable` Can the agent post a conversational reply on this surface at
|
|
17
|
+
* all? `false` means "react_now" is structurally impossible and
|
|
18
|
+
* the ladder must pick schedule/delegate/escalate/ignore.
|
|
19
|
+
* `replyMethod` The protocol method a react_now would use. Null when not
|
|
20
|
+
* respondable. This is a HINT for the driver, not a promise —
|
|
21
|
+
* the session may choose a different tool.
|
|
22
|
+
* `latency` "now" the surface has a human waiting in a live UI,
|
|
23
|
+
* "batch" the surface is fine being handled on the next
|
|
24
|
+
* inbox-processor tick,
|
|
25
|
+
* "queue" the surface is work, not conversation; it belongs on
|
|
26
|
+
* the backlog even when it is unambiguously mine.
|
|
27
|
+
* `actionClasses` The blast radius of REPLYING here, seeded into the approval
|
|
28
|
+
* gate. Replying to a DM is internal and free; sending an email
|
|
29
|
+
* leaves the org and is `external`; resolving an approval or
|
|
30
|
+
* adopting a decision is `irreversible`. This is the honest
|
|
31
|
+
* reading of §8 "blast radius forces an approval BEFORE any
|
|
32
|
+
* rung executes", applied to the reply itself rather than only
|
|
33
|
+
* to whatever the reply talks about.
|
|
34
|
+
* `offlineSafe` May this surface be handled from a stale mandate cache
|
|
35
|
+
* (§6.4, the >72h rung)? Anything that speaks outside the org
|
|
36
|
+
* or commits the org is false.
|
|
37
|
+
* `selfApprove` `false` means the agent may never be the terminal decider on
|
|
38
|
+
* this surface — approvals and decisions land as `escalate`
|
|
39
|
+
* even when the payload names the agent as the approver. This
|
|
40
|
+
* is the same anti-self-grading law as `mandate.adopt`.
|
|
41
|
+
* `maxChainDepth` How many consecutive agent turns are allowed in one thread
|
|
42
|
+
* before the ladder calls it a ping-pong loop and stops. Lower
|
|
43
|
+
* for broadcast surfaces (a space) than for a private DM.
|
|
44
|
+
* `ambientDrop` When the event is visible but NOT directed, is dropping it
|
|
45
|
+
* correct (`true`), or should it be parked for the ambient
|
|
46
|
+
* sweep (`false`)? An @mention that misfired is worth a second
|
|
47
|
+
* look; a board heartbeat on someone else's task is not.
|
|
48
|
+
*
|
|
49
|
+
* Pure data + pure lookups. No IO, no imports beyond the surface vocabulary.
|
|
50
|
+
*
|
|
51
|
+
* @module lib/execution/surface-policy
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
import { SURFACES, SURFACE_NAMES } from "../org/inbound/surfaces.mjs";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The five things that can happen to a directed event. Ordered from cheapest to
|
|
60
|
+
* most expensive; the ladder in `disposition.mjs` returns exactly one.
|
|
61
|
+
*
|
|
62
|
+
* `ignore` is a FIRST-CLASS outcome, not an error path. An agent that replies to
|
|
63
|
+
* everything is as broken as one that replies to nothing, so every ignore
|
|
64
|
+
* carries a machine-readable reason and is journaled like any other decision.
|
|
65
|
+
*/
|
|
66
|
+
export const DISPOSITIONS = Object.freeze([
|
|
67
|
+
"react_now",
|
|
68
|
+
"schedule",
|
|
69
|
+
"delegate",
|
|
70
|
+
"escalate",
|
|
71
|
+
"ignore",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** True when `d` is one of the five dispositions. */
|
|
75
|
+
export function isDisposition(d) {
|
|
76
|
+
return typeof d === "string" && DISPOSITIONS.includes(d);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The conservative row applied to any surface not named below — including a
|
|
81
|
+
* surface the parallel inbound workflow adds after this file was written. It is
|
|
82
|
+
* deliberately the most restrictive useful row: never auto-reply, never speak
|
|
83
|
+
* externally, queue the work and let a human or a later tick sort it out. A new
|
|
84
|
+
* surface therefore degrades to "safe and visible", never to "silently dropped"
|
|
85
|
+
* and never to "free to email the world".
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
88
|
+
respondable: false,
|
|
89
|
+
replyMethod: null,
|
|
90
|
+
latency: "queue",
|
|
91
|
+
actionClasses: Object.freeze([]),
|
|
92
|
+
offlineSafe: true,
|
|
93
|
+
selfApprove: true,
|
|
94
|
+
maxChainDepth: 2,
|
|
95
|
+
ambientDrop: true,
|
|
96
|
+
unknown: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Per-surface policy for the surfaces `lib/org/inbound/surfaces.mjs` names.
|
|
101
|
+
* Keys MUST be exactly `SURFACE_NAMES`; the coverage test asserts every shipped
|
|
102
|
+
* surface has a row so a new surface cannot silently inherit DEFAULT_POLICY
|
|
103
|
+
* without someone deciding that is right.
|
|
104
|
+
*/
|
|
105
|
+
export const ORG_SURFACE_POLICY = Object.freeze({
|
|
106
|
+
// ── conversation ────────────────────────────────────────────────────────
|
|
107
|
+
/** A private room. Someone typed at me and is watching for a reply. */
|
|
108
|
+
dm: Object.freeze({
|
|
109
|
+
respondable: true,
|
|
110
|
+
replyMethod: "messaging.send",
|
|
111
|
+
latency: "now",
|
|
112
|
+
actionClasses: Object.freeze([]),
|
|
113
|
+
offlineSafe: true,
|
|
114
|
+
selfApprove: true,
|
|
115
|
+
maxChainDepth: 4,
|
|
116
|
+
ambientDrop: true,
|
|
117
|
+
}),
|
|
118
|
+
/** @mention in a space. Public, so a wrong reply is expensive — tighter chain. */
|
|
119
|
+
mention: Object.freeze({
|
|
120
|
+
respondable: true,
|
|
121
|
+
replyMethod: "messaging.send",
|
|
122
|
+
latency: "now",
|
|
123
|
+
actionClasses: Object.freeze([]),
|
|
124
|
+
offlineSafe: true,
|
|
125
|
+
selfApprove: true,
|
|
126
|
+
maxChainDepth: 2,
|
|
127
|
+
ambientDrop: false,
|
|
128
|
+
}),
|
|
129
|
+
/** A reply in a thread I am part of. Same room, same tightness as a mention. */
|
|
130
|
+
thread_reply: Object.freeze({
|
|
131
|
+
respondable: true,
|
|
132
|
+
replyMethod: "messaging.send",
|
|
133
|
+
latency: "now",
|
|
134
|
+
actionClasses: Object.freeze([]),
|
|
135
|
+
offlineSafe: true,
|
|
136
|
+
selfApprove: true,
|
|
137
|
+
maxChainDepth: 2,
|
|
138
|
+
ambientDrop: false,
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
// ── calls ───────────────────────────────────────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* A call invite. NOT respondable as a conversational turn: hq owns the live
|
|
144
|
+
* call floor (SPEC §6.1, §10.8) and a laptop daemon reached over HTTP cannot
|
|
145
|
+
* hold it. The right disposition is to schedule — accept/decline the invite,
|
|
146
|
+
* put the slot on the calendar — never to "answer" the call here.
|
|
147
|
+
*/
|
|
148
|
+
call: Object.freeze({
|
|
149
|
+
respondable: false,
|
|
150
|
+
replyMethod: null,
|
|
151
|
+
latency: "now",
|
|
152
|
+
actionClasses: Object.freeze([]),
|
|
153
|
+
offlineSafe: false,
|
|
154
|
+
selfApprove: true,
|
|
155
|
+
maxChainDepth: 1,
|
|
156
|
+
ambientDrop: true,
|
|
157
|
+
}),
|
|
158
|
+
|
|
159
|
+
// ── work ────────────────────────────────────────────────────────────────
|
|
160
|
+
/** A board item assigned to me. This is work, not conversation: it queues. */
|
|
161
|
+
task_assigned: Object.freeze({
|
|
162
|
+
respondable: false,
|
|
163
|
+
replyMethod: null,
|
|
164
|
+
latency: "queue",
|
|
165
|
+
actionClasses: Object.freeze([]),
|
|
166
|
+
offlineSafe: true,
|
|
167
|
+
selfApprove: true,
|
|
168
|
+
maxChainDepth: 1,
|
|
169
|
+
ambientDrop: true,
|
|
170
|
+
}),
|
|
171
|
+
/**
|
|
172
|
+
* A comment/block/move on an item I own or review. A comment addressed at me
|
|
173
|
+
* deserves an answer in the comment thread, so this one IS respondable — but
|
|
174
|
+
* on the batch tick, because nobody sits watching a board comment the way they
|
|
175
|
+
* watch a DM.
|
|
176
|
+
*/
|
|
177
|
+
task_comment: Object.freeze({
|
|
178
|
+
respondable: true,
|
|
179
|
+
replyMethod: "board.comment",
|
|
180
|
+
latency: "batch",
|
|
181
|
+
actionClasses: Object.freeze([]),
|
|
182
|
+
offlineSafe: true,
|
|
183
|
+
selfApprove: true,
|
|
184
|
+
maxChainDepth: 2,
|
|
185
|
+
ambientDrop: false,
|
|
186
|
+
}),
|
|
187
|
+
/** A comment on a chat-attached file. Answer in the room it was attached to. */
|
|
188
|
+
file_comment: Object.freeze({
|
|
189
|
+
respondable: true,
|
|
190
|
+
replyMethod: "messaging.send",
|
|
191
|
+
latency: "batch",
|
|
192
|
+
actionClasses: Object.freeze([]),
|
|
193
|
+
offlineSafe: true,
|
|
194
|
+
selfApprove: true,
|
|
195
|
+
maxChainDepth: 2,
|
|
196
|
+
ambientDrop: false,
|
|
197
|
+
}),
|
|
198
|
+
/** A comment/suggestion on a workspace doc. Same, via the doc's comment lane. */
|
|
199
|
+
doc_comment: Object.freeze({
|
|
200
|
+
respondable: true,
|
|
201
|
+
replyMethod: "files.comment",
|
|
202
|
+
latency: "batch",
|
|
203
|
+
actionClasses: Object.freeze([]),
|
|
204
|
+
offlineSafe: true,
|
|
205
|
+
selfApprove: true,
|
|
206
|
+
maxChainDepth: 2,
|
|
207
|
+
ambientDrop: false,
|
|
208
|
+
}),
|
|
209
|
+
|
|
210
|
+
// ── governance ──────────────────────────────────────────────────────────
|
|
211
|
+
/**
|
|
212
|
+
* An approval on my desk. `selfApprove:false` is the load-bearing bit: an
|
|
213
|
+
* agent NEVER resolves an approval, no matter how confident it is or how
|
|
214
|
+
* explicitly the payload named it the approver. It escalates to a human.
|
|
215
|
+
* Resolving one is `irreversible` because the single-use `consumedAt` latch
|
|
216
|
+
* means there is no second attempt.
|
|
217
|
+
*/
|
|
218
|
+
approval: Object.freeze({
|
|
219
|
+
respondable: false,
|
|
220
|
+
replyMethod: null,
|
|
221
|
+
latency: "now",
|
|
222
|
+
actionClasses: Object.freeze(["irreversible"]),
|
|
223
|
+
offlineSafe: false,
|
|
224
|
+
selfApprove: false,
|
|
225
|
+
maxChainDepth: 1,
|
|
226
|
+
ambientDrop: true,
|
|
227
|
+
}),
|
|
228
|
+
/** A decision I proposed / must sign. Adoption commits the org: never self-signed. */
|
|
229
|
+
decision: Object.freeze({
|
|
230
|
+
respondable: false,
|
|
231
|
+
replyMethod: null,
|
|
232
|
+
latency: "batch",
|
|
233
|
+
actionClasses: Object.freeze(["irreversible"]),
|
|
234
|
+
offlineSafe: false,
|
|
235
|
+
selfApprove: false,
|
|
236
|
+
maxChainDepth: 1,
|
|
237
|
+
ambientDrop: true,
|
|
238
|
+
}),
|
|
239
|
+
/**
|
|
240
|
+
* An escalation waiting on me. Respondable — answering an escalation IS the
|
|
241
|
+
* work — but it is `now` latency because by construction something is blocked
|
|
242
|
+
* behind it.
|
|
243
|
+
*/
|
|
244
|
+
escalation: Object.freeze({
|
|
245
|
+
respondable: true,
|
|
246
|
+
replyMethod: "escalation.resolve",
|
|
247
|
+
latency: "now",
|
|
248
|
+
actionClasses: Object.freeze([]),
|
|
249
|
+
offlineSafe: true,
|
|
250
|
+
selfApprove: true,
|
|
251
|
+
maxChainDepth: 2,
|
|
252
|
+
ambientDrop: true,
|
|
253
|
+
}),
|
|
254
|
+
/** A delegation offered to me (or mine being accepted/declined). Accept/decline promptly. */
|
|
255
|
+
handoff: Object.freeze({
|
|
256
|
+
respondable: true,
|
|
257
|
+
replyMethod: "handoff.accept",
|
|
258
|
+
latency: "now",
|
|
259
|
+
actionClasses: Object.freeze([]),
|
|
260
|
+
offlineSafe: true,
|
|
261
|
+
selfApprove: true,
|
|
262
|
+
maxChainDepth: 2,
|
|
263
|
+
ambientDrop: true,
|
|
264
|
+
}),
|
|
265
|
+
|
|
266
|
+
// ── outside the org ─────────────────────────────────────────────────────
|
|
267
|
+
/**
|
|
268
|
+
* Inbound email. The only default surface whose reply LEAVES the org, so it
|
|
269
|
+
* carries `external` and is not offline-safe: a stale-mandate agent must not
|
|
270
|
+
* be emailing counterparties on yesterday's instructions.
|
|
271
|
+
*/
|
|
272
|
+
email: Object.freeze({
|
|
273
|
+
respondable: true,
|
|
274
|
+
replyMethod: "email.send",
|
|
275
|
+
latency: "batch",
|
|
276
|
+
actionClasses: Object.freeze(["external"]),
|
|
277
|
+
offlineSafe: false,
|
|
278
|
+
selfApprove: true,
|
|
279
|
+
maxChainDepth: 2,
|
|
280
|
+
ambientDrop: true,
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Surfaces this layer decides for that the org inbound table does NOT name yet.
|
|
286
|
+
*
|
|
287
|
+
* Three of them exist, and each is a real event class that reaches an agent
|
|
288
|
+
* today with no policy at all:
|
|
289
|
+
*
|
|
290
|
+
* `calendar` the protocol ships nine `calendar.*` methods and hq's classifier
|
|
291
|
+
* gains a `calendar` topic (SPEC §3), but `directedness.classifyEvent`
|
|
292
|
+
* has no `calendar` branch — an invite where I am an attendee is
|
|
293
|
+
* currently policy-less.
|
|
294
|
+
* `alert` a LOCAL fact, not an org event: `lib/diagnostics/alerts.mjs` and
|
|
295
|
+
* `lib/telemetry/alerts.mjs` derive `{id, severity, detail}` records
|
|
296
|
+
* that have never had a route to a decision — they were emitted to a
|
|
297
|
+
* sink and that was the end of them.
|
|
298
|
+
* `mandate` the frame that tells a daemon its mandate moved (SPEC §6.4: "cache
|
|
299
|
+
* is refreshed on every `topic:"mandate"` frame").
|
|
300
|
+
*
|
|
301
|
+
* Kept in a SEPARATE table rather than merged into the literal above so the
|
|
302
|
+
* "every org surface has a row" coverage assertion stays exact, and so the day
|
|
303
|
+
* the parallel inbound workflow adds `calendar` to `SURFACES` the drift guard
|
|
304
|
+
* {@link adoptedExtendedSurfaces} fires and someone MOVES the row instead of
|
|
305
|
+
* quietly ending up with two definitions of the same surface.
|
|
306
|
+
*/
|
|
307
|
+
export const EXTENDED_SURFACE_POLICY = Object.freeze({
|
|
308
|
+
/**
|
|
309
|
+
* A meeting invite / reschedule / cancellation naming me as an attendee. Not
|
|
310
|
+
* respondable: the answer is an RSVP (`calendar.rsvp`), not a sentence. It is
|
|
311
|
+
* `now` latency because a 9am invite answered on tomorrow's batch tick is a
|
|
312
|
+
* missed meeting, and NOT offline-safe because accepting commits the
|
|
313
|
+
* principal's time — and `calendar.write` queues real invite/update/cancel
|
|
314
|
+
* mail, which leaves the org.
|
|
315
|
+
*/
|
|
316
|
+
calendar: Object.freeze({
|
|
317
|
+
// `replyMethod` is null because it is null for every non-respondable row:
|
|
318
|
+
// it names the method a CONVERSATIONAL reply would use, and an RSVP is an
|
|
319
|
+
// action the ladder routes, not a turn in a conversation. The routing hint
|
|
320
|
+
// lives on the obligation's `uses[]`, where it belongs.
|
|
321
|
+
respondable: false,
|
|
322
|
+
replyMethod: null,
|
|
323
|
+
latency: "now",
|
|
324
|
+
actionClasses: Object.freeze(["external"]),
|
|
325
|
+
offlineSafe: false,
|
|
326
|
+
selfApprove: true,
|
|
327
|
+
maxChainDepth: 1,
|
|
328
|
+
ambientDrop: true,
|
|
329
|
+
}),
|
|
330
|
+
/**
|
|
331
|
+
* A local health / budget / freshness alert about this agent. `offlineSafe` is
|
|
332
|
+
* TRUE and deliberately so: a partition is exactly when alerts matter, and an
|
|
333
|
+
* alert costs nothing outside the box. Never respondable — an alert is not a
|
|
334
|
+
* conversation, it is a work item or an escalation.
|
|
335
|
+
*/
|
|
336
|
+
alert: Object.freeze({
|
|
337
|
+
respondable: false,
|
|
338
|
+
replyMethod: null,
|
|
339
|
+
latency: "now",
|
|
340
|
+
actionClasses: Object.freeze([]),
|
|
341
|
+
offlineSafe: true,
|
|
342
|
+
selfApprove: true,
|
|
343
|
+
maxChainDepth: 1,
|
|
344
|
+
ambientDrop: true,
|
|
345
|
+
}),
|
|
346
|
+
/**
|
|
347
|
+
* "Your mandate moved." The recovery surface: handling it is what REFRESHES
|
|
348
|
+
* the cache, so it is offline-safe, carries no action class, and is exempt
|
|
349
|
+
* from the staleness ladder (see `RECOVERY_SURFACES` in disposition.mjs).
|
|
350
|
+
* Gating it on mandate freshness would be a deadlock — the one event that
|
|
351
|
+
* fixes staleness cannot be the one staleness blocks.
|
|
352
|
+
*/
|
|
353
|
+
mandate: Object.freeze({
|
|
354
|
+
respondable: false,
|
|
355
|
+
replyMethod: null,
|
|
356
|
+
latency: "now",
|
|
357
|
+
actionClasses: Object.freeze([]),
|
|
358
|
+
offlineSafe: true,
|
|
359
|
+
selfApprove: true,
|
|
360
|
+
maxChainDepth: 1,
|
|
361
|
+
ambientDrop: true,
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** Extended surface names, sorted. */
|
|
366
|
+
export const EXTENDED_SURFACE_NAMES = Object.freeze(Object.keys(EXTENDED_SURFACE_POLICY).sort());
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The whole policy table — org surfaces plus the extended ones. This is what
|
|
370
|
+
* {@link policyFor} looks up, so there is exactly ONE lookup path and no caller
|
|
371
|
+
* ever has to know which half a surface lives in.
|
|
372
|
+
*/
|
|
373
|
+
export const SURFACE_POLICY = Object.freeze({
|
|
374
|
+
...ORG_SURFACE_POLICY,
|
|
375
|
+
...EXTENDED_SURFACE_POLICY,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Look up the policy row for a surface. Never throws and never returns null —
|
|
380
|
+
* an unknown surface gets {@link DEFAULT_POLICY} with `unknown:true` set, which
|
|
381
|
+
* the ladder logs as a degradation rather than swallowing.
|
|
382
|
+
*
|
|
383
|
+
* @param {string|null|undefined} surface
|
|
384
|
+
* @returns {typeof DEFAULT_POLICY}
|
|
385
|
+
*/
|
|
386
|
+
export function policyFor(surface) {
|
|
387
|
+
const key = typeof surface === "string" ? surface : "";
|
|
388
|
+
const row = Object.prototype.hasOwnProperty.call(SURFACE_POLICY, key)
|
|
389
|
+
? SURFACE_POLICY[key]
|
|
390
|
+
: null;
|
|
391
|
+
return row || DEFAULT_POLICY;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The surfaces that have no policy row (should always be empty in a shipped
|
|
396
|
+
* tree). Exported so `verify` and the coverage test can assert on it rather than
|
|
397
|
+
* re-deriving the set.
|
|
398
|
+
* @returns {string[]}
|
|
399
|
+
*/
|
|
400
|
+
export function uncoveredSurfaces() {
|
|
401
|
+
return SURFACE_NAMES.filter((n) => !Object.prototype.hasOwnProperty.call(SURFACE_POLICY, n));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The policy rows that name a surface which is neither in the inbound
|
|
406
|
+
* vocabulary nor a declared extended surface — the other direction of the same
|
|
407
|
+
* drift. Should always be empty.
|
|
408
|
+
* @returns {string[]}
|
|
409
|
+
*/
|
|
410
|
+
export function orphanPolicies() {
|
|
411
|
+
return Object.keys(SURFACE_POLICY)
|
|
412
|
+
.filter(
|
|
413
|
+
(n) =>
|
|
414
|
+
!Object.prototype.hasOwnProperty.call(SURFACES, n) &&
|
|
415
|
+
!Object.prototype.hasOwnProperty.call(EXTENDED_SURFACE_POLICY, n),
|
|
416
|
+
)
|
|
417
|
+
.sort();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Extended surfaces the org inbound table has SINCE adopted. Non-empty means two
|
|
422
|
+
* definitions of one surface now exist and the row must be MOVED from
|
|
423
|
+
* {@link EXTENDED_SURFACE_POLICY} into {@link ORG_SURFACE_POLICY}. The coverage
|
|
424
|
+
* test asserts this is empty, so the merge fails loudly rather than leaving the
|
|
425
|
+
* spread order to decide which definition wins.
|
|
426
|
+
* @returns {string[]}
|
|
427
|
+
*/
|
|
428
|
+
export function adoptedExtendedSurfaces() {
|
|
429
|
+
return EXTENDED_SURFACE_NAMES.filter((n) =>
|
|
430
|
+
Object.prototype.hasOwnProperty.call(SURFACES, n),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export default {
|
|
435
|
+
DISPOSITIONS,
|
|
436
|
+
isDisposition,
|
|
437
|
+
DEFAULT_POLICY,
|
|
438
|
+
SURFACE_POLICY,
|
|
439
|
+
ORG_SURFACE_POLICY,
|
|
440
|
+
EXTENDED_SURFACE_POLICY,
|
|
441
|
+
EXTENDED_SURFACE_NAMES,
|
|
442
|
+
policyFor,
|
|
443
|
+
uncoveredSurfaces,
|
|
444
|
+
orphanPolicies,
|
|
445
|
+
adoptedExtendedSurfaces,
|
|
446
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-policy.test.mjs — every inbound surface has a decided policy.
|
|
3
|
+
* Run: node --test lib/execution/surface-policy.test.mjs
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
|
|
10
|
+
import { SURFACES, SURFACE_NAMES } from "../org/inbound/surfaces.mjs";
|
|
11
|
+
import {
|
|
12
|
+
SURFACE_POLICY,
|
|
13
|
+
ORG_SURFACE_POLICY,
|
|
14
|
+
EXTENDED_SURFACE_POLICY,
|
|
15
|
+
EXTENDED_SURFACE_NAMES,
|
|
16
|
+
DEFAULT_POLICY,
|
|
17
|
+
DISPOSITIONS,
|
|
18
|
+
isDisposition,
|
|
19
|
+
policyFor,
|
|
20
|
+
uncoveredSurfaces,
|
|
21
|
+
orphanPolicies,
|
|
22
|
+
adoptedExtendedSurfaces,
|
|
23
|
+
} from "./surface-policy.mjs";
|
|
24
|
+
|
|
25
|
+
test("every shipped inbound surface has an explicit policy row", () => {
|
|
26
|
+
// The point of this test: a new surface added by the inbound workflow must not
|
|
27
|
+
// silently inherit DEFAULT_POLICY. Someone has to decide what it means.
|
|
28
|
+
assert.deepEqual(uncoveredSurfaces(), [], "surfaces with no policy row");
|
|
29
|
+
assert.deepEqual(orphanPolicies(), [], "policy rows for surfaces that no longer exist");
|
|
30
|
+
assert.equal(
|
|
31
|
+
Object.keys(SURFACE_POLICY).length,
|
|
32
|
+
SURFACE_NAMES.length + EXTENDED_SURFACE_NAMES.length,
|
|
33
|
+
"the merged table is exactly the org surfaces plus the declared extended ones",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an extended surface adopted by the inbound layer fails loudly instead of double-defining", () => {
|
|
38
|
+
// `calendar`, `alert` and `mandate` are decided here but not (yet) named by
|
|
39
|
+
// `lib/org/inbound/surfaces.mjs`. The day that workflow adds one, the row must
|
|
40
|
+
// MOVE — leaving both definitions in place would make the spread order decide
|
|
41
|
+
// which one wins, silently.
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
adoptedExtendedSurfaces(),
|
|
44
|
+
[],
|
|
45
|
+
"these extended surfaces now exist in the inbound table — move their rows into ORG_SURFACE_POLICY",
|
|
46
|
+
);
|
|
47
|
+
for (const name of EXTENDED_SURFACE_NAMES) {
|
|
48
|
+
assert.ok(ORG_SURFACE_POLICY[name] === undefined, `${name} is defined twice`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the extended surfaces are all inward-facing or gated, never free to speak out", () => {
|
|
53
|
+
// They bypass parts of the ladder (`mandate` and `alert` skip the staleness
|
|
54
|
+
// gate), so none of them may be a surface that can talk outside the org.
|
|
55
|
+
for (const [name, p] of Object.entries(EXTENDED_SURFACE_POLICY)) {
|
|
56
|
+
assert.equal(p.respondable, false, `${name} must not be conversationally respondable`);
|
|
57
|
+
if (p.actionClasses.includes("external")) {
|
|
58
|
+
assert.equal(p.offlineSafe, false, `${name} speaks externally but claims to be offline-safe`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// The two staleness-exempt surfaces in particular must cost nothing outside.
|
|
62
|
+
for (const name of ["mandate", "alert"]) {
|
|
63
|
+
assert.deepEqual(EXTENDED_SURFACE_POLICY[name].actionClasses, [], `${name} must carry no action class`);
|
|
64
|
+
assert.equal(EXTENDED_SURFACE_POLICY[name].offlineSafe, true);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("every policy row is structurally complete and internally consistent", () => {
|
|
69
|
+
for (const [name, p] of Object.entries(SURFACE_POLICY)) {
|
|
70
|
+
assert.equal(typeof p.respondable, "boolean", `${name}.respondable`);
|
|
71
|
+
assert.ok(["now", "batch", "queue"].includes(p.latency), `${name}.latency=${p.latency}`);
|
|
72
|
+
assert.ok(Array.isArray(p.actionClasses), `${name}.actionClasses`);
|
|
73
|
+
assert.equal(typeof p.offlineSafe, "boolean", `${name}.offlineSafe`);
|
|
74
|
+
assert.equal(typeof p.selfApprove, "boolean", `${name}.selfApprove`);
|
|
75
|
+
assert.ok(Number.isInteger(p.maxChainDepth) && p.maxChainDepth >= 1, `${name}.maxChainDepth`);
|
|
76
|
+
assert.equal(typeof p.ambientDrop, "boolean", `${name}.ambientDrop`);
|
|
77
|
+
// A respondable surface must name the method it replies with, and a
|
|
78
|
+
// non-respondable one must not pretend it can.
|
|
79
|
+
if (p.respondable) assert.ok(p.replyMethod, `${name} is respondable but names no reply method`);
|
|
80
|
+
else assert.equal(p.replyMethod, null, `${name} is not respondable but names ${p.replyMethod}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("anything that speaks outside the org is external and not offline-safe", () => {
|
|
85
|
+
// email is the only default surface whose reply leaves the org.
|
|
86
|
+
assert.ok(SURFACE_POLICY.email.actionClasses.includes("external"));
|
|
87
|
+
assert.equal(SURFACE_POLICY.email.offlineSafe, false);
|
|
88
|
+
// and nothing internal is mislabelled as external
|
|
89
|
+
assert.deepEqual(SURFACE_POLICY.dm.actionClasses, []);
|
|
90
|
+
assert.deepEqual(SURFACE_POLICY.mention.actionClasses, []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the agent may never be the terminal decider on approvals or decisions", () => {
|
|
94
|
+
assert.equal(SURFACE_POLICY.approval.selfApprove, false);
|
|
95
|
+
assert.equal(SURFACE_POLICY.decision.selfApprove, false);
|
|
96
|
+
// ...and both are irreversible, so they also carry an approval gate.
|
|
97
|
+
assert.ok(SURFACE_POLICY.approval.actionClasses.includes("irreversible"));
|
|
98
|
+
assert.ok(SURFACE_POLICY.decision.actionClasses.includes("irreversible"));
|
|
99
|
+
// Every other surface is self-decidable, or the agent could do nothing at all.
|
|
100
|
+
const decidable = Object.entries(SURFACE_POLICY).filter(([, p]) => p.selfApprove);
|
|
101
|
+
assert.ok(decidable.length >= SURFACE_NAMES.length - 2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("hq keeps the live call floor: `call` is never conversationally respondable", () => {
|
|
105
|
+
assert.equal(SURFACE_POLICY.call.respondable, false);
|
|
106
|
+
assert.equal(SURFACE_POLICY.call.replyMethod, null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("public surfaces have a tighter ping-pong ceiling than private ones", () => {
|
|
110
|
+
assert.ok(
|
|
111
|
+
SURFACE_POLICY.mention.maxChainDepth < SURFACE_POLICY.dm.maxChainDepth,
|
|
112
|
+
"a wrong answer in a space is more expensive than in a DM",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("policyFor: unknown surface degrades to the restrictive default, flagged", () => {
|
|
117
|
+
const p = policyFor("surface_from_the_future");
|
|
118
|
+
assert.equal(p, DEFAULT_POLICY);
|
|
119
|
+
assert.equal(p.unknown, true);
|
|
120
|
+
// The restrictive default must not be able to speak, and must not be able to
|
|
121
|
+
// speak EXTERNALLY in particular.
|
|
122
|
+
assert.equal(p.respondable, false);
|
|
123
|
+
assert.deepEqual(p.actionClasses, []);
|
|
124
|
+
// negatives
|
|
125
|
+
assert.equal(policyFor(null).unknown, true);
|
|
126
|
+
assert.equal(policyFor(undefined).unknown, true);
|
|
127
|
+
assert.equal(policyFor(42).unknown, true);
|
|
128
|
+
assert.equal(policyFor("").unknown, true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("policyFor: a real surface never returns the default", () => {
|
|
132
|
+
for (const name of SURFACE_NAMES) {
|
|
133
|
+
const p = policyFor(name);
|
|
134
|
+
assert.notEqual(p.unknown, true, `${name} fell through to the default`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("policyFor: prototype keys do not resolve to a policy", () => {
|
|
139
|
+
// `hasOwnProperty` guard, not a bare lookup — "constructor" must not be a surface.
|
|
140
|
+
assert.equal(policyFor("constructor").unknown, true);
|
|
141
|
+
assert.equal(policyFor("toString").unknown, true);
|
|
142
|
+
assert.equal(policyFor("__proto__").unknown, true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the disposition vocabulary is exactly the five documented outcomes", () => {
|
|
146
|
+
assert.deepEqual([...DISPOSITIONS].sort(), ["delegate", "escalate", "ignore", "react_now", "schedule"]);
|
|
147
|
+
assert.ok(isDisposition("ignore"));
|
|
148
|
+
assert.equal(isDisposition("maybe"), false);
|
|
149
|
+
assert.equal(isDisposition(null), false);
|
|
150
|
+
assert.equal(isDisposition(undefined), false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("surface topics stay aligned with the inbound vocabulary", () => {
|
|
154
|
+
// Guards the seam: if the inbound layer renames a surface, this fails rather
|
|
155
|
+
// than the policy silently applying to nothing. Extended surfaces are exempt
|
|
156
|
+
// by construction — they exist precisely because the inbound layer has no row
|
|
157
|
+
// for them — and `orphanPolicies` above is what keeps that list honest.
|
|
158
|
+
for (const name of Object.keys(SURFACE_POLICY)) {
|
|
159
|
+
if (EXTENDED_SURFACE_NAMES.includes(name)) continue;
|
|
160
|
+
assert.ok(SURFACES[name], `policy names ${name}, which the inbound layer does not define`);
|
|
161
|
+
}
|
|
162
|
+
});
|