@directive-run/claude-plugin 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/claude-plugin",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "Claude Code plugin for Directive — 12 skills covering modules, constraints, resolvers, derivations, AI orchestration, and adapters. Installable via Claude Code's plugin marketplace or consumable programmatically as an npm package.",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -52,7 +52,7 @@
52
52
  "tsx": "^4.19.2",
53
53
  "typescript": "^5.7.2",
54
54
  "vitest": "^3.0.0",
55
- "@directive-run/knowledge": "1.18.0"
55
+ "@directive-run/knowledge": "1.19.1"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsx scripts/build-skills.ts && tsup",
@@ -54,7 +54,11 @@ gate execution on `facts.<lifecycle>.healthy` derivations.
54
54
  - **MCP server connect/disconnect** → see "MCP Lifecycle" below.
55
55
  - **Health checks** → poll inside the source's `attach`; publish
56
56
  `health_changed` events as the upstream service responds.
57
- - **WebSocket open / close** → `sourceFromWebSocket()` (sources/websocket).
57
+ - **WebSocket open / close (Cloudflare DO)** → `sourceFromWebSocketMessage()`
58
+ (`@directive-run/sources/cloudflare`). For raw Node/browser WebSocket
59
+ bridges, declare a source whose `attach` wires `addEventListener` and
60
+ publishes `MESSAGE` / `CLOSE` / `ERROR` events (a generic
61
+ `sourceFromWebSocket()` helper is queued for a follow-up RFC).
58
62
 
59
63
  ### "I want my agent's facts to update from a live external feed"
60
64
 
@@ -101,9 +105,6 @@ const result = orchestrator.runStream(agent, input, {
101
105
  system, // the Directive system whose facts feed the agent
102
106
  keys: ["pr.headSha", "pr.state"], // REQUIRED — fact keys to watch
103
107
  interruptWhen: (facts, changedKeys) => boolean, // optional; default: () => true
104
- mode: "inject-system-message", // shipped today (default behavior).
105
- // "restart" reserved for follow-up minor;
106
- // behaves identically to inject-system-message today.
107
108
  notifyOn: "interrupt-only", // default; "all-changes" emits chunk per watched change
108
109
  onContextUpdate: (changedKeys) => void, // optional logging hook
109
110
  },
@@ -154,10 +155,14 @@ Two new chunk variants land on the stream:
154
155
  `runStream({ liveContext })` is the dedicated subject of
155
156
  [RFC 0005](../../docs/rfcs/0005-live-context-agent.md). Today's
156
157
  implementation is **231 LOC in `agent-orchestrator.ts`** — under the
157
- RFC's 300-LOC scope guard. The `mode: "restart"` automatic
158
- re-invocation is reserved for a follow-up minor; today both `mode`
159
- values produce identical behavior (the chunk-emission contract +
160
- abort wiring; consumers re-prompt via a fresh `runStream` call).
158
+ RFC's 300-LOC scope guard. Behavior is **abort-and-emit**: when
159
+ `interruptWhen` returns `true`, the orchestrator aborts the in-flight
160
+ LLM run and emits an `interrupted` chunk with the partial output; the
161
+ caller resumes by issuing a fresh `runStream` against the still-live
162
+ subscription (or fully tears down via `result.abort()`). Automatic
163
+ re-invocation (the "restart" semantic the original RFC drafted) is
164
+ reserved for a follow-up RFC + field — the original `mode` field was
165
+ removed before release because the impl never read it.
161
166
 
162
167
  **Security companion (mandatory when watched facts may carry PII):**
163
168
  wire `createFactPIIGuardrail` on the same Directive system. Without
@@ -170,70 +175,118 @@ threat model.
170
175
 
171
176
  ## MCP Lifecycle as a Source
172
177
 
173
- `@directive-run/ai/mcp` already exposes `MCPAdapterConfig.events`
174
- (`onConnect`, `onDisconnect`, `onToolCall`, ...) as callback hooks. The
175
- canonical adapter wraps those callbacks into a source on the
176
- orchestrator's module:
178
+ `@directive-run/ai/mcp` exposes `MCPAdapterConfig.events`
179
+ (`onConnect`, `onDisconnect`, `onToolCall`, `onError`, ...) as a single
180
+ bag of callbacks set at adapter-construction time. To bridge the
181
+ adapter's lifecycle into a Directive source the recipe is: declare a
182
+ holder that the source's `attach` populates with the live `publish`,
183
+ then point the adapter's `events.onConnect` / `onDisconnect` at the
184
+ holder. The holder pattern lets you construct the adapter and the
185
+ source in either order — the source's mount/unmount drives which
186
+ publish closure (if any) receives lifecycle events.
187
+
188
+ > **Multi-tenant safety.** If you import the module that owns the
189
+ > holder from two places (e.g., a Worker that mounts one Directive
190
+ > system per tenant DO), a module-level `let publishRef` would be
191
+ > SHARED — the second tenant's `attach` would silently overwrite the
192
+ > first tenant's pipe. **Always declare the holder + adapter inside a
193
+ > factory function** so each call yields an isolated closure pair.
194
+ > The recipe below uses `makeOrchestrator(adapter)` for exactly this
195
+ > reason; if you create the adapter outside the factory, pass it in
196
+ > per call too.
177
197
 
178
198
  ```ts
179
199
  import { createSystem, createModule, t } from "@directive-run/core";
200
+ import type { SourcePublish } from "@directive-run/core";
180
201
  import { createMCPAdapter } from "@directive-run/ai/mcp";
181
202
 
182
- const adapter = createMCPAdapter({ /* ... */ });
203
+ // Factory closure each call yields a fresh `(adapter, module)`
204
+ // pair with its own `publishRef`. Multi-tenant safe; SSR / Vitest
205
+ // hot-reload safe; one tenant's MCP traffic can never leak into
206
+ // another tenant's facts.
207
+ function makeOrchestrator() {
208
+ let publishRef: SourcePublish | null = null;
183
209
 
184
- const orchestrator = createModule("orchestrator", {
185
- schema: {
186
- facts: {
187
- mcp: t.object<{ servers: Record<string, "connected" | "disconnected"> }>(),
188
- },
210
+ const adapter = createMCPAdapter({
211
+ servers: [/* ... */],
189
212
  events: {
190
- MCP_SERVER_CONNECTED: { name: t.string() },
191
- MCP_SERVER_DISCONNECTED: { name: t.string() },
213
+ onConnect: (name) => publishRef?.("MCP_SERVER_CONNECTED", { name }),
214
+ onDisconnect: (name) =>
215
+ publishRef?.("MCP_SERVER_DISCONNECTED", { name }),
192
216
  },
193
- },
194
- init: (f) => {
195
- f.mcp = { servers: {} };
196
- },
197
- events: {
198
- MCP_SERVER_CONNECTED: (f, p) => {
199
- f.mcp = {
200
- ...f.mcp,
201
- servers: { ...f.mcp.servers, [p.name]: "connected" },
202
- };
217
+ });
218
+
219
+ const orchestrator = createModule("orchestrator", {
220
+ schema: {
221
+ facts: {
222
+ mcp: t.object<{
223
+ servers: Record<string, "connected" | "disconnected">;
224
+ }>(),
225
+ },
226
+ events: {
227
+ MCP_SERVER_CONNECTED: { name: t.string() },
228
+ MCP_SERVER_DISCONNECTED: { name: t.string() },
229
+ },
230
+ derivations: {
231
+ allServersHealthy: t.boolean(),
232
+ },
203
233
  },
204
- MCP_SERVER_DISCONNECTED: (f, p) => {
205
- f.mcp = {
206
- ...f.mcp,
207
- servers: { ...f.mcp.servers, [p.name]: "disconnected" },
208
- };
234
+ init: (f) => {
235
+ f.mcp = { servers: {} };
209
236
  },
210
- },
211
- derive: {
212
- allServersHealthy: (f) =>
213
- Object.values(f.mcp.servers).every((s) => s === "connected"),
214
- },
215
- sources: {
216
- mcpLifecycle: {
217
- attach: (publish) => {
218
- const off1 = adapter.onConnect((name) =>
219
- publish("MCP_SERVER_CONNECTED", { name }),
220
- );
221
- const off2 = adapter.onDisconnect((name) =>
222
- publish("MCP_SERVER_DISCONNECTED", { name }),
223
- );
224
- return () => {
225
- off1();
226
- off2();
237
+ events: {
238
+ MCP_SERVER_CONNECTED: (f, p) => {
239
+ f.mcp = {
240
+ ...f.mcp,
241
+ servers: { ...f.mcp.servers, [p.name]: "connected" },
242
+ };
243
+ },
244
+ MCP_SERVER_DISCONNECTED: (f, p) => {
245
+ f.mcp = {
246
+ ...f.mcp,
247
+ servers: { ...f.mcp.servers, [p.name]: "disconnected" },
227
248
  };
228
249
  },
229
250
  },
230
- },
231
- });
251
+ derive: {
252
+ allServersHealthy: (facts) =>
253
+ Object.values(facts.mcp.servers).every((s) => s === "connected"),
254
+ },
255
+ sources: {
256
+ mcpLifecycle: {
257
+ attach: (publish) => {
258
+ publishRef = publish;
259
+ // Kick the adapter's connection lifecycle once the source is
260
+ // attached so the first `onConnect` flows into our publish.
261
+ void adapter.connect();
262
+ return async () => {
263
+ publishRef = null;
264
+ await adapter.disconnect();
265
+ };
266
+ },
267
+ },
268
+ },
269
+ });
270
+
271
+ return { orchestrator, adapter };
272
+ }
273
+
274
+ // Per-tenant / per-request usage:
275
+ const { orchestrator, adapter } = makeOrchestrator();
276
+ const system = createSystem({ module: orchestrator });
232
277
  ```
233
278
 
234
279
  Constraints can now gate agent execution on
235
280
  `facts.allServersHealthy` — no separate health-check loop needed.
236
281
 
282
+ > The holder + closure pattern is the canonical bridge from any
283
+ > single-callback-bag third-party SDK (MCP, telemetry SDKs that take an
284
+ > `onEvent` config at construction) into a Directive source. The
285
+ > alternative — passing `publish` from inside `attach` into the
286
+ > adapter's events — requires the adapter to be constructed AFTER the
287
+ > source is created, which is awkward when the adapter is used outside
288
+ > Directive too.
289
+
237
290
  ---
238
291
 
239
292
  ## Sources × Security
@@ -317,7 +370,8 @@ exports. One install, optional peerDependencies per vendor.
317
370
  |---|---|---|
318
371
  | `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel |
319
372
  | `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Cloudflare Durable Object alarm |
320
- | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw WebSocket |
373
+ | `@directive-run/sources/cloudflare` | `sourceFromWebSocketMessage()` | Cloudflare Durable Object WebSocket message stream |
374
+ | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw browser / Node WebSocket |
321
375
  | `@directive-run/sources/sentry` | `sourceFromSentryHook()` | (future RFC) Sentry production-error stream |
322
376
 
323
377
  Install only the umbrella; the vendor peerDeps are optional and pull in
@@ -274,20 +274,38 @@ flushes return Promises) work with both sync and async teardown:
274
274
  wall-clock cutoff so the runtime can evict the isolate even if
275
275
  some sources hang.
276
276
 
277
+ Use the **holder + closure** bridge pattern (the same shape the MCP
278
+ recipe in `ai-sources.md` documents) so the `attach` and `onEvict`
279
+ sibling closures share the channel handle:
280
+
277
281
  ```typescript
282
+ // Holder shared between `attach` and `onEvict`. Both closures live
283
+ // inside the same `sources` object; declaring the channel above them
284
+ // keeps the handle reachable from both.
285
+ let channel: RealtimeChannel | null = null;
286
+
278
287
  sources: {
279
288
  channel: {
280
289
  attach: (publish) => {
281
- const ch = supabase.channel("game").subscribe();
282
- ch.on("postgres_changes", { event: "UPDATE" }, (p) =>
290
+ channel = supabase.channel("game");
291
+ channel.on("postgres_changes", { event: "UPDATE" }, (p) =>
283
292
  publish("ROW_UPDATED", p),
284
293
  );
285
- return () => ch.unsubscribe(); // returns a Promise
294
+ channel.subscribe();
295
+ // Per RFC 0009, the unsubscribe MAY return a Promise; awaiting
296
+ // it on `system.stopAsync()` ensures the broker has dropped the
297
+ // subscription before the next start cycle re-attaches.
298
+ return async () => {
299
+ if (channel) await supabase.removeChannel(channel);
300
+ channel = null;
301
+ };
286
302
  },
287
303
  onEvict: async () => {
288
304
  // Cloudflare DO hibernate signal: close the channel actively
289
305
  // so the broker drops the subscription before the isolate dies.
290
- await supabase.removeChannel(ch);
306
+ // The unsubscribe will also run, but onEvict fires first and
307
+ // bounds the pre-hibernation work to a deadline.
308
+ if (channel) await supabase.removeChannel(channel);
291
309
  },
292
310
  },
293
311
  }
@@ -421,4 +439,4 @@ sources: {
421
439
  - `anti-patterns.md` (#20) — "subscribe inside an effect / useEffect" anti-pattern; prefer sources.
422
440
  - `naming.md` — canonical-term entry for `sources` + cross-paradigm aliases (RxJS Observable, DOM EventTarget, XState callback actor, Supabase realtime).
423
441
  - [`../ai/ai-sources.md`](../ai/ai-sources.md) — AI integration: `runStream({ liveContext })`, MCP lifecycle as a source, the `@directive-run/sources/*` adapter subpaths.
424
- - [`../ai/ai-security.md`](../ai/ai-security.md#sources-pii--closing-the-fact-injection-bypass) — `createFactPIIGuardrail`: closes the source → fact → agent-prompt PII bypass. Wire whenever sources publish into facts the agent reads.
442
+ - [`../ai/ai-security.md`](../ai/ai-security.md#sources--pii--closing-the-fact-injection-bypass) — `createFactPIIGuardrail`: closes the source → fact → agent-prompt PII bypass. Wire whenever sources publish into facts the agent reads.