@animalabs/agent-framework 0.8.0 → 0.10.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 (204) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +33 -3
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +184 -18
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1323 -246
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +73 -7
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +399 -22
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/types.d.ts +56 -0
  14. package/dist/src/gate/types.d.ts.map +1 -1
  15. package/dist/src/index.d.ts +1 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +1 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  20. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  21. package/dist/src/mcpl/capability-grant.js +291 -0
  22. package/dist/src/mcpl/capability-grant.js.map +1 -0
  23. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  24. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  25. package/dist/src/mcpl/capability-mask.js +190 -0
  26. package/dist/src/mcpl/capability-mask.js.map +1 -0
  27. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +163 -36
  30. package/dist/src/mcpl/channel-registry.js.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  32. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  33. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  34. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  35. package/dist/src/mcpl/errors.d.ts +6 -0
  36. package/dist/src/mcpl/errors.d.ts.map +1 -1
  37. package/dist/src/mcpl/errors.js +6 -0
  38. package/dist/src/mcpl/errors.js.map +1 -1
  39. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  40. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  41. package/dist/src/mcpl/feature-set-manager.js +45 -4
  42. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  43. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  44. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  46. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  47. package/dist/src/mcpl/index.d.ts +1 -1
  48. package/dist/src/mcpl/index.d.ts.map +1 -1
  49. package/dist/src/mcpl/index.js.map +1 -1
  50. package/dist/src/mcpl/prose-grammar.d.ts +11 -0
  51. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  52. package/dist/src/mcpl/prose-grammar.js +21 -0
  53. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  54. package/dist/src/mcpl/prose-stream-router.d.ts +11 -2
  55. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  56. package/dist/src/mcpl/prose-stream-router.js +73 -13
  57. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  58. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  59. package/dist/src/mcpl/push-handler.js +20 -2
  60. package/dist/src/mcpl/push-handler.js.map +1 -1
  61. package/dist/src/mcpl/server-connection.d.ts +93 -9
  62. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  63. package/dist/src/mcpl/server-connection.js +231 -32
  64. package/dist/src/mcpl/server-connection.js.map +1 -1
  65. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  66. package/dist/src/mcpl/server-registry.js +4 -3
  67. package/dist/src/mcpl/server-registry.js.map +1 -1
  68. package/dist/src/mcpl/tags.d.ts +24 -0
  69. package/dist/src/mcpl/tags.d.ts.map +1 -0
  70. package/dist/src/mcpl/tags.js +52 -0
  71. package/dist/src/mcpl/tags.js.map +1 -0
  72. package/dist/src/mcpl/transport.d.ts.map +1 -1
  73. package/dist/src/mcpl/transport.js +10 -0
  74. package/dist/src/mcpl/transport.js.map +1 -1
  75. package/dist/src/mcpl/types.d.ts +159 -64
  76. package/dist/src/mcpl/types.d.ts.map +1 -1
  77. package/dist/src/mcpl/types.js +16 -2
  78. package/dist/src/mcpl/types.js.map +1 -1
  79. package/dist/src/modules/workspace/index.d.ts +23 -0
  80. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  81. package/dist/src/modules/workspace/index.js +48 -0
  82. package/dist/src/modules/workspace/index.js.map +1 -1
  83. package/dist/src/modules/workspace/sync.d.ts +11 -2
  84. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  85. package/dist/src/modules/workspace/sync.js +18 -8
  86. package/dist/src/modules/workspace/sync.js.map +1 -1
  87. package/dist/src/refusal-reactions.d.ts +27 -0
  88. package/dist/src/refusal-reactions.d.ts.map +1 -0
  89. package/dist/src/refusal-reactions.js +35 -0
  90. package/dist/src/refusal-reactions.js.map +1 -0
  91. package/dist/src/tool-result-history.d.ts +20 -0
  92. package/dist/src/tool-result-history.d.ts.map +1 -1
  93. package/dist/src/tool-result-history.js +26 -5
  94. package/dist/src/tool-result-history.js.map +1 -1
  95. package/dist/src/types/agent.d.ts +39 -1
  96. package/dist/src/types/agent.d.ts.map +1 -1
  97. package/dist/src/types/framework.d.ts +16 -0
  98. package/dist/src/types/framework.d.ts.map +1 -1
  99. package/dist/src/types/trace.d.ts +24 -0
  100. package/dist/src/types/trace.d.ts.map +1 -1
  101. package/dist/test/channel-registry-routing.test.js +38 -10
  102. package/dist/test/channel-registry-routing.test.js.map +1 -1
  103. package/dist/test/channel-registry-typing.test.js +10 -1
  104. package/dist/test/channel-registry-typing.test.js.map +1 -1
  105. package/dist/test/checkpoint-attribution.test.js +168 -8
  106. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  107. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  108. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  109. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  110. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  111. package/dist/test/event-gate-passthrough.test.js +289 -0
  112. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  113. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  114. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  115. package/dist/test/event-gate-selfwake.test.js +125 -0
  116. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  117. package/dist/test/explicit-prose-routing.test.js +137 -7
  118. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  119. package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +276 -0
  120. package/dist/test/fixtures/flaky-mcpl-server.mjs +41 -0
  121. package/dist/test/machine-close-provenance.test.js +6 -0
  122. package/dist/test/machine-close-provenance.test.js.map +1 -1
  123. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  124. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  125. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  126. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  127. package/dist/test/mcpl-capability-grant.test.js +109 -0
  128. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  129. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  130. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  131. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  132. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  133. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  134. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  135. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  136. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  137. package/dist/test/mcpl-list-status.test.js +78 -0
  138. package/dist/test/mcpl-list-status.test.js.map +1 -0
  139. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  140. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  141. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  142. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  143. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  144. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  145. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  146. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  147. package/dist/test/mcpl-request-timeout.test.js +1 -1
  148. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  149. package/dist/test/physical-window-restart.test.d.ts +14 -0
  150. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  151. package/dist/test/physical-window-restart.test.js +222 -0
  152. package/dist/test/physical-window-restart.test.js.map +1 -0
  153. package/dist/test/present-while-acting.test.js +199 -1
  154. package/dist/test/present-while-acting.test.js.map +1 -1
  155. package/dist/test/prose-stream-router.test.js +43 -0
  156. package/dist/test/prose-stream-router.test.js.map +1 -1
  157. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  158. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  159. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  160. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  161. package/dist/test/state-scaling-e2e.test.js +7 -1
  162. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  163. package/dist/test/state-scaling.test.js +9 -1
  164. package/dist/test/state-scaling.test.js.map +1 -1
  165. package/dist/test/tool-result-spill.test.d.ts +13 -0
  166. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  167. package/dist/test/tool-result-spill.test.js +472 -0
  168. package/dist/test/tool-result-spill.test.js.map +1 -0
  169. package/dist/test/trunk-channel-routing.test.js +45 -0
  170. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  171. package/dist/test/xml-round-persist.test.d.ts +19 -0
  172. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  173. package/dist/test/xml-round-persist.test.js +235 -0
  174. package/dist/test/xml-round-persist.test.js.map +1 -0
  175. package/dist/tsconfig.tsbuildinfo +1 -1
  176. package/package.json +5 -4
  177. package/src/agent.ts +38 -4
  178. package/src/framework.ts +1488 -272
  179. package/src/gate/event-gate.ts +427 -24
  180. package/src/gate/types.ts +58 -0
  181. package/src/index.ts +1 -0
  182. package/src/mcpl/capability-grant.ts +319 -0
  183. package/src/mcpl/capability-mask.ts +228 -0
  184. package/src/mcpl/channel-registry.ts +167 -39
  185. package/src/mcpl/checkpoint-manager.ts +28 -8
  186. package/src/mcpl/errors.ts +7 -0
  187. package/src/mcpl/feature-set-manager.ts +50 -5
  188. package/src/mcpl/hook-orchestrator.ts +64 -86
  189. package/src/mcpl/index.ts +0 -2
  190. package/src/mcpl/prose-grammar.ts +21 -0
  191. package/src/mcpl/prose-stream-router.ts +65 -14
  192. package/src/mcpl/push-handler.ts +18 -2
  193. package/src/mcpl/server-connection.ts +262 -34
  194. package/src/mcpl/server-registry.ts +4 -3
  195. package/src/mcpl/tags.ts +50 -0
  196. package/src/mcpl/transport.ts +11 -0
  197. package/src/mcpl/types.ts +176 -78
  198. package/src/modules/workspace/index.ts +48 -0
  199. package/src/modules/workspace/sync.ts +30 -7
  200. package/src/refusal-reactions.ts +37 -0
  201. package/src/tool-result-history.ts +27 -5
  202. package/src/types/agent.ts +40 -1
  203. package/src/types/framework.ts +17 -0
  204. package/src/types/trace.ts +22 -0
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * This is the bridge between the MCPL protocol and the agent-framework inference
5
5
  * pipeline. It collects ContextInjection[] from MCPL servers via beforeInference
6
- * and feeds them into context-manager's compile(). After inference, it notifies
7
- * servers of the result via afterInference.
6
+ * and feeds them into context-manager's compile(). Turn boundaries are announced
7
+ * via metadata-only `inference/lifecycle` notifications (§10.5) —
8
+ * context/afterInference and its modifiedResponse are removed in 0.5.0.
8
9
  *
9
10
  * Design principles:
10
11
  * - Fail-open: timeouts and errors never block inference
@@ -20,18 +21,24 @@ import type {
20
21
  McplContextInjection,
21
22
  BeforeInferenceParams,
22
23
  BeforeInferenceResult,
23
- AfterInferenceParams,
24
- AfterInferenceResult,
24
+ InferenceLifecycleParams,
25
25
  } from './types.js';
26
26
  import type { McplServerRegistry } from './server-registry.js';
27
27
  import type { McplServerConnection } from './server-connection.js';
28
28
  import type { FeatureSetManager } from './feature-set-manager.js';
29
+ import { CapabilityGrant } from './capability-grant.js';
29
30
 
30
31
  /** Timeout for beforeInference per server (fail-open). */
31
32
  const BEFORE_INFERENCE_TIMEOUT_MS = 5_000;
32
33
 
33
- /** Timeout for blocking afterInference per server. */
34
- const AFTER_INFERENCE_BLOCKING_TIMEOUT_MS = 10_000;
34
+ /** The three injection positions and their §6.2 capability paths. Position
35
+ * is the TYPED field on the injection — authorization never reads the
36
+ * response's featureSet (§5.4). */
37
+ const INJECT_LEAVES: ReadonlyArray<{ position: string; path: string }> = [
38
+ { position: 'system', path: 'contextHooks.beforeInference.inject.system' },
39
+ { position: 'beforeUser', path: 'contextHooks.beforeInference.inject.beforeUser' },
40
+ { position: 'afterUser', path: 'contextHooks.beforeInference.inject.afterUser' },
41
+ ];
35
42
 
36
43
  /**
37
44
  * Races a promise against a timeout. Rejects with a descriptive error on timeout.
@@ -141,7 +148,16 @@ export class HookOrchestrator {
141
148
  * Fail-open: servers that time out or error are silently skipped.
142
149
  */
143
150
  async beforeInference(params: BeforeInferenceParams): Promise<ContextInjection[]> {
144
- const servers = this.registry.getServersWithCapability('contextHooks.beforeInference');
151
+ // §5.4: the GRANT decides fan-out, not the raw advertisement — which
152
+ // also fixes selection for the recursive §5.1 shape, where
153
+ // `beforeInference: {observe: true, …}` is an object, not `true`. A
154
+ // server granted any inject.* leaf but not observe is still CALLED
155
+ // (§10.1 — the hook is how injection happens) and receives
156
+ // `userMessage: null` instead of the user's text.
157
+ const servers = this.registry.getAllServers().filter((s) =>
158
+ CapabilityGrant.of(s).has('contextHooks.beforeInference.observe')
159
+ || INJECT_LEAVES.some((leaf) => CapabilityGrant.of(s).has(leaf.path)),
160
+ );
145
161
  if (servers.length === 0) {
146
162
  return [];
147
163
  }
@@ -155,23 +171,20 @@ export class HookOrchestrator {
155
171
  }
156
172
 
157
173
  /**
158
- * Fan out `context/afterInference` to all capable MCPL servers.
159
- *
160
- * Non-blocking servers receive a notification (fire-and-forget).
161
- * Blocking servers receive a request with a 10s timeout.
162
- * Returns the first valid AfterInferenceResult from a blocking server, or null.
174
+ * Fan out `inference/lifecycle` (§10.5) — the metadata-only replacement
175
+ * for context/afterInference. Notifications, fire-and-forget, gated on the
176
+ * inferenceLifecycle grant. BEST-EFFORT by design: the host attempts one
177
+ * terminal per `started` on every exit path it controls; consumers dedupe
178
+ * by inferenceId and keep a safety timeout. Never blocks the turn.
163
179
  */
164
- async afterInference(params: AfterInferenceParams): Promise<AfterInferenceResult | null> {
165
- const servers = this.registry.getServersWithCapability('contextHooks.afterInference');
166
- if (servers.length === 0) {
167
- return null;
168
- }
169
-
170
- this._isInHook = true;
171
- try {
172
- return await this.fanOutAfterInference(servers, params);
173
- } finally {
174
- this._isInHook = false;
180
+ emitLifecycle(params: InferenceLifecycleParams): void {
181
+ for (const server of this.registry.getAllServers()) {
182
+ if (!CapabilityGrant.of(server).has('inferenceLifecycle')) continue;
183
+ try {
184
+ server.sendInferenceLifecycle(params);
185
+ } catch {
186
+ /* best-effort — a failed notify never disturbs the turn */
187
+ }
175
188
  }
176
189
  }
177
190
 
@@ -184,13 +197,21 @@ export class HookOrchestrator {
184
197
  params: BeforeInferenceParams,
185
198
  ): Promise<ContextInjection[]> {
186
199
  const results = await Promise.allSettled(
187
- servers.map((server) =>
188
- withTimeout(
189
- server.sendBeforeInference(params),
200
+ servers.map((server) => {
201
+ // §10.1: a server not granted observe MUST receive
202
+ // `userMessage: null` — the field is ABSENT authority, not merely
203
+ // discouraged — while the hook is still invoked so granted
204
+ // injection positions keep working (write-without-read).
205
+ const perServer: BeforeInferenceParams =
206
+ CapabilityGrant.of(server).has('contextHooks.beforeInference.observe')
207
+ ? params
208
+ : { ...params, userMessage: null };
209
+ return withTimeout(
210
+ server.sendBeforeInference(perServer),
190
211
  BEFORE_INFERENCE_TIMEOUT_MS,
191
212
  `beforeInference to "${server.id}"`,
192
- ).then((result) => ({ server, result })),
193
- ),
213
+ ).then((result) => ({ server, result }));
214
+ }),
194
215
  );
195
216
 
196
217
  const injections: ContextInjection[] = [];
@@ -203,7 +224,9 @@ export class HookOrchestrator {
203
224
 
204
225
  const { server, result } = settled.value;
205
226
 
206
- // Validate feature set
227
+ // Feature-set discipline (diagnostics; §5.4 forbids using the
228
+ // response-supplied featureSet as AUTHORIZATION — that is the
229
+ // position check below).
207
230
  try {
208
231
  this.featureSetManager.validateInbound(server.id, result.featureSet);
209
232
  } catch {
@@ -211,9 +234,20 @@ export class HookOrchestrator {
211
234
  continue;
212
235
  }
213
236
 
214
- // Convert wire-format injections to context-manager format
237
+ // §5.4/§10.8: authorize EACH injection by its typed position against
238
+ // the grant CURRENT NOW, at response receipt — not when the request
239
+ // was sent. A revocation that landed while the hook was in flight is
240
+ // enforced here without extra machinery.
215
241
  if (result.contextInjections && result.contextInjections.length > 0) {
216
242
  for (const mcplInj of result.contextInjections) {
243
+ const leaf = INJECT_LEAVES.find((l) => l.position === mcplInj.position);
244
+ if (!leaf || !CapabilityGrant.of(server).has(leaf.path)) {
245
+ console.error(
246
+ `[mcpl] ${server.id}: dropped beforeInference injection at position ` +
247
+ `"${mcplInj.position}" — not in the effective grant (§10.8)`,
248
+ );
249
+ continue;
250
+ }
217
251
  injections.push(convertMcplInjection(mcplInj));
218
252
  }
219
253
  }
@@ -222,60 +256,4 @@ export class HookOrchestrator {
222
256
  return injections;
223
257
  }
224
258
 
225
- // ==========================================================================
226
- // Private: afterInference fan-out
227
- // ==========================================================================
228
-
229
- private async fanOutAfterInference(
230
- servers: McplServerConnection[],
231
- params: AfterInferenceParams,
232
- ): Promise<AfterInferenceResult | null> {
233
- const blockingPromises: Promise<AfterInferenceResult | null>[] = [];
234
-
235
- for (const server of servers) {
236
- const isBlocking = this.isBlockingAfterInference(server);
237
-
238
- if (isBlocking) {
239
- // Send as request, await response with timeout
240
- const promise = withTimeout(
241
- server.sendAfterInference(params, true) as Promise<AfterInferenceResult>,
242
- AFTER_INFERENCE_BLOCKING_TIMEOUT_MS,
243
- `afterInference (blocking) to "${server.id}"`,
244
- ).catch((): null => {
245
- // Fail-open: timeout or error — treat as no modification
246
- return null;
247
- });
248
- blockingPromises.push(promise);
249
- } else {
250
- // Send as notification (fire-and-forget)
251
- server.sendAfterInference(params, false);
252
- }
253
- }
254
-
255
- if (blockingPromises.length === 0) {
256
- return null;
257
- }
258
-
259
- // Await all blocking responses and return the first valid result
260
- const results = await Promise.all(blockingPromises);
261
- for (const result of results) {
262
- if (result && result.modifiedResponse) {
263
- return result;
264
- }
265
- }
266
-
267
- return null;
268
- }
269
-
270
- /**
271
- * Check if a server's afterInference capability is blocking.
272
- */
273
- private isBlockingAfterInference(server: McplServerConnection): boolean {
274
- const caps = server.capabilities;
275
- if (!caps?.contextHooks?.afterInference) {
276
- return false;
277
- }
278
- const afterCap = caps.contextHooks.afterInference;
279
- return typeof afterCap === 'object' && afterCap !== null && 'blocking' in afterCap && afterCap.blocking === true;
280
- }
281
259
  }
package/src/mcpl/index.ts CHANGED
@@ -47,8 +47,6 @@ export type {
47
47
  BeforeInferenceParams,
48
48
  McplContextInjection,
49
49
  BeforeInferenceResult,
50
- AfterInferenceParams,
51
- AfterInferenceResult,
52
50
 
53
51
  // Server-initiated inference
54
52
  McplMessage,
@@ -38,3 +38,24 @@ export function parseProsePrefix(text: string): ProsePrefix {
38
38
  }
39
39
  return { kind: 'target', target, continueTurn, body };
40
40
  }
41
+
42
+ /** A real hybrid routing line: exact triple-arrow plus an optional whitespace
43
+ * run and a non-whitespace target token. */
44
+ export const HYBRID_PROSE_PREFIX_LINE = /^>>>[ \t]*\S/;
45
+
46
+ /**
47
+ * Hybrid/locus-preserving routing grammar.
48
+ *
49
+ * `>>>#cafe body` and `>>> #cafe body` route this envelope explicitly while
50
+ * unprefixed prose remains ordinary frozen-locus speech. The authored source
51
+ * is never rewritten; callers consume this view only for publication.
52
+ */
53
+ export function parseHybridProsePrefix(text: string): ProsePrefix {
54
+ const m = /^>>>[ \t]*(\S+)([ \t]+!)?[ \t]*\n?/.exec(text);
55
+ if (!m) return { kind: 'none', continueTurn: false, body: text };
56
+ const target = m[1]!;
57
+ const continueTurn = m[2] !== undefined;
58
+ const body = text.slice(m[0].length);
59
+ if (target === 'skip_reply') return { kind: 'private', continueTurn, body };
60
+ return { kind: 'target', target, continueTurn, body };
61
+ }
@@ -30,7 +30,7 @@ export interface RoutedDelta {
30
30
  delta: string;
31
31
  }
32
32
 
33
- type State = 'line-start' | 'maybe-prefix' | 'prefix-token' | 'after-token' | 'body';
33
+ type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body';
34
34
 
35
35
  export class ProseStreamRouter {
36
36
  private target: string | null;
@@ -42,8 +42,8 @@ export class ProseStreamRouter {
42
42
 
43
43
  constructor(
44
44
  private opts: {
45
- /** 'explicit' parses `>>` prefixes; 'locus' is pure passthrough. */
46
- mode: 'explicit' | 'locus';
45
+ /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */
46
+ mode: 'explicit' | 'hybrid' | 'locus';
47
47
  /** Initial destination: the frozen turn locus (locus mode) or null. */
48
48
  initialTarget: string | null;
49
49
  /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */
@@ -59,6 +59,23 @@ export class ProseStreamRouter {
59
59
  return this.accumulated;
60
60
  }
61
61
 
62
+ /**
63
+ * Drop everything routed so far and return to the constructed state.
64
+ *
65
+ * For a membrane refusal retry (RetryingEvent): the abandoned attempt's
66
+ * prose must not survive into the new one, and `accumulated` feeds
67
+ * outgoing/complete — leaving it would finalize text the model never
68
+ * actually said in the attempt that stands.
69
+ */
70
+ reset(): void {
71
+ this.target = this.opts.initialTarget;
72
+ this.state = this.opts.mode === 'locus' ? 'body' : 'line-start';
73
+ this.held = '';
74
+ this.token = '';
75
+ this.sawBang = false;
76
+ this.accumulated = new Map();
77
+ }
78
+
62
79
  feed(delta: string): RoutedDelta[] {
63
80
  const out: RoutedDelta[] = [];
64
81
  for (const ch of delta) this.step(ch, out);
@@ -70,7 +87,12 @@ export class ProseStreamRouter {
70
87
  * no body — dropped, matching delivery which would deliver empty body). */
71
88
  finish(): RoutedDelta[] {
72
89
  const out: RoutedDelta[] = [];
73
- if (this.state === 'maybe-prefix') this.emit(this.held, out); // "" | ">" | ">>"
90
+ if (this.state === 'hybrid-indent') this.emit(this.held, out);
91
+ if (this.state === 'maybe-prefix') {
92
+ const full = this.opts.mode === 'hybrid' ? '>>>' : '>>';
93
+ const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
94
+ if (arrows !== full) this.emit(this.held, out);
95
+ }
74
96
  // prefix-token / after-token at EOF: prefix without body — nothing to emit.
75
97
  this.held = '';
76
98
  this.state = 'line-start';
@@ -80,7 +102,7 @@ export class ProseStreamRouter {
80
102
  private step(ch: string, out: RoutedDelta[]): void {
81
103
  switch (this.state) {
82
104
  case 'body':
83
- if (ch === '\n' && this.opts.mode === 'explicit') {
105
+ if (ch === '\n' && this.opts.mode !== 'locus') {
84
106
  this.emit('\n', out);
85
107
  this.state = 'line-start';
86
108
  } else {
@@ -89,34 +111,63 @@ export class ProseStreamRouter {
89
111
  return;
90
112
 
91
113
  case 'line-start':
114
+ if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
115
+ this.state = 'hybrid-indent';
116
+ this.held = ch;
117
+ return;
118
+ }
92
119
  if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; }
93
120
  this.state = 'body';
94
121
  this.step(ch, out);
95
122
  return;
96
123
 
97
- case 'maybe-prefix':
98
- if (this.held === '>' && ch === '>') { this.held = '>>'; return; }
99
- if (this.held === '>>') {
124
+ case 'hybrid-indent':
125
+ if (ch === ' ' || ch === '\t') { this.held += ch; return; }
126
+ if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; }
127
+ this.emit(this.held, out);
128
+ this.held = '';
129
+ this.state = 'body';
130
+ this.step(ch, out);
131
+ return;
132
+
133
+ case 'maybe-prefix': {
134
+ const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>';
135
+ const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held;
136
+ if (ch === '>' && arrows.length < prefix.length) {
137
+ this.held += '>';
138
+ return;
139
+ }
140
+ if (arrows === prefix) {
141
+ if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) {
142
+ this.held = '';
143
+ this.state = 'prefix-gap';
144
+ return;
145
+ }
100
146
  if (ch === '\n' || ch === ' ' || ch === '\t') {
101
- // `>> quoted arrow` / bare `>>`: body text, not a prefix.
102
- this.emit(this.held, out);
147
+ if (this.opts.mode === 'explicit') this.emit(this.held, out);
103
148
  this.held = '';
104
- this.state = 'body';
105
- this.step(ch, out);
149
+ this.state = ch === '\n' ? 'line-start' : 'body';
150
+ if (ch !== '\n') this.step(ch, out);
106
151
  return;
107
152
  }
108
- // real prefix begins
109
153
  this.state = 'prefix-token';
110
154
  this.token = ch;
111
155
  this.held = '';
112
156
  return;
113
157
  }
114
- // held === '>' and ch !== '>': plain body starting with '>'
115
158
  this.emit(this.held, out);
116
159
  this.held = '';
117
160
  this.state = 'body';
118
161
  this.step(ch, out);
119
162
  return;
163
+ }
164
+
165
+ case 'prefix-gap':
166
+ if (ch === ' ' || ch === '\t') return;
167
+ if (ch === '\n') { this.target = null; this.state = 'line-start'; return; }
168
+ this.state = 'prefix-token';
169
+ this.token = ch;
170
+ return;
120
171
 
121
172
  case 'prefix-token':
122
173
  if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; }
@@ -16,6 +16,7 @@ import type {
16
16
  } from './types.js';
17
17
  import type { FeatureSetManager } from './feature-set-manager.js';
18
18
  import { McplFeatureSetError } from './feature-set-manager.js';
19
+ import { expandCoreTags } from './tags.js';
19
20
 
20
21
  // ============================================================================
21
22
  // McplPushEvent (the ProcessEvent shape pushed to the queue)
@@ -165,7 +166,18 @@ export class PushHandler {
165
166
  params: PushEventParams,
166
167
  responder?: Responder,
167
168
  ): void {
168
- // 1. Validate feature set
169
+ // §16.3: expand the normative chat:* core closure once, at entry, so
170
+ // every downstream consumer (wake matching, metadata, the queued event)
171
+ // sees the closed set. Producer `implies` edges are NOT consumed —
172
+ // advisory pending acceptance (§16.4). Tags were admitted before this
173
+ // point and grant nothing (§16.6).
174
+ if (params.tags) params.tags = expandCoreTags(params.tags);
175
+ // 1. Validate feature set. §6.6: rejection is diagnostics, not
176
+ // authorization, and MUST be a JSON-RPC error object — not a result
177
+ // carrying a failure flag. (The old `{accepted:false, reason}` result
178
+ // was AUDIT-001's finding: the error factories existed and were never
179
+ // invoked.) Falls back to the result shape only for a legacy responder
180
+ // with no error path.
169
181
  try {
170
182
  this.featureSetManager.validateInbound(serverId, params.featureSet);
171
183
  } catch (err) {
@@ -175,7 +187,11 @@ export class PushHandler {
175
187
  // Loud rejection — a rejected push event is an agent that silently
176
188
  // never hears the message. (2026-07-09 diagnosability pass.)
177
189
  console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=${reason}`);
178
- responder?.respond({ accepted: false, reason });
190
+ if (err instanceof McplFeatureSetError && responder?.respondError) {
191
+ responder.respondError(err.code, reason, { featureSet: err.featureSet });
192
+ } else {
193
+ responder?.respond({ accepted: false, reason });
194
+ }
179
195
  return;
180
196
  }
181
197