@cohortapp/agent-sdk 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,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
+ });