@loomwork/scaffold 0.0.0-stage → 0.1.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/dist/plugin.js ADDED
@@ -0,0 +1,1807 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SEAM_WIRING = exports.SEAM_SPI = exports.SEAM_KEYS = void 0;
4
+ exports.joinNoun = joinNoun;
5
+ exports.listSeams = listSeams;
6
+ exports.usesNodeGlobals = usesNodeGlobals;
7
+ exports.pluginFiles = pluginFiles;
8
+ exports.pluginNextSteps = pluginNextSteps;
9
+ const codegen_1 = require("./codegen");
10
+ // ── Plugin scaffolding (used by `loom plugin new`) ───────────────────────────
11
+ //
12
+ // A Loom plugin is an ordinary package that implements an existing SPI and/or
13
+ // exports a preset `(opts) => Partial<LoomConfig>` (or a singleton like a
14
+ // StorageConfig), imported statically and passed to `defineConfig`. See ADR 0043
15
+ // and docs/plugins.md. This module emits a ready-to-fill package for a chosen
16
+ // seam, in either first-party (`@loomwork/plugin-*`, in `plugins/`) or third-party
17
+ // (`loom-plugin-*`, standalone) shape.
18
+ /**
19
+ * Join a plugin's name to a seam's noun WITHOUT repeating words: `cloudrun-sandbox` + `SandboxBackend`
20
+ * is `CloudrunSandboxBackend`, not `CloudrunSandboxSandboxBackend`. Words are compared as camel-case
21
+ * words, case-insensitively; the longest suffix of `base` that is a prefix of `noun` is shared, so
22
+ * `FooTimerStore` + `TimerStore` is `FooTimerStore` and a name sharing no words is plain concatenation.
23
+ */
24
+ function joinNoun(base, noun) {
25
+ const words = (x) => x.split(/(?=[A-Z])/).map((w) => w.toLowerCase());
26
+ const b = words(base);
27
+ const n = words(noun);
28
+ for (let k = Math.min(b.length, n.length); k > 0; k--) {
29
+ if (b.slice(b.length - k).join("|") === n.slice(0, k).join("|")) {
30
+ return (base +
31
+ noun
32
+ .split(/(?=[A-Z])/)
33
+ .slice(k)
34
+ .join(""));
35
+ }
36
+ }
37
+ return base + noun;
38
+ }
39
+ const SEAMS = {
40
+ store: {
41
+ key: "store",
42
+ spi: "EventStore",
43
+ title: "Durable storage backend (EventStore + SnapshotStore + ArchiveStore [+ BranchRegistry])",
44
+ wiring: "storage: <name>Storage({ /* … */ })",
45
+ spiDeps: ["@loomwork/event-runtime", "@loomwork/snapshot-runtime", "@loomwork/plugin-kit"],
46
+ conformance: [
47
+ "runEventStoreConformance",
48
+ "runSnapshotStoreConformance",
49
+ "runArchiveStoreConformance",
50
+ ],
51
+ index: (name) => {
52
+ const P = (0, codegen_1.pascal)(name);
53
+ const c = (0, codegen_1.camel)(name);
54
+ return [
55
+ "import type {",
56
+ " ArchiveExecutionPage,",
57
+ " ArchiveSegment,",
58
+ " ArchiveSegmentHeader,",
59
+ " ArchiveStore,",
60
+ " EventStore,",
61
+ `} from "@loomwork/event-runtime";`,
62
+ `import type { SnapshotStore } from "@loomwork/snapshot-runtime";`,
63
+ `import type { CustomStorage } from "@loomwork/plugin-kit";`,
64
+ "",
65
+ `export interface ${joinNoun(P, "StorageOptions")} {`,
66
+ " /** Whatever your backend needs — a URL, a directory, a token. */",
67
+ " url: string;",
68
+ " /** Also provision a branch registry for fork/branch (optional). */",
69
+ " branching?: boolean;",
70
+ "}",
71
+ "",
72
+ "/**",
73
+ " * `defineConfig({ storage })` preset. Returns a `custom` storage descriptor with",
74
+ " * your stores constructed — so `@loomwork/app` never learns your backend exists and a",
75
+ " * third-party backend wires exactly like a first-party one (ADR 0043).",
76
+ " */",
77
+ `export function ${joinNoun(c, "Storage")}(options: ${joinNoun(P, "StorageOptions")}): CustomStorage {`,
78
+ " return {",
79
+ ' kind: "custom",',
80
+ ` eventStore: new ${joinNoun(P, "EventStore")}(options.url),`,
81
+ ` snapshotStore: new ${joinNoun(P, "SnapshotStore")}(options.url),`,
82
+ " // Compaction MOVES the events it removes here (ADR 0138): without it `compact` refuses.",
83
+ ` archiveStore: new ${joinNoun(P, "ArchiveStore")}(options.url),`,
84
+ ` // ...(options.branching === true && { branchRegistry: new ${joinNoun(P, "BranchRegistry")}(options.url) }),`,
85
+ " };",
86
+ "}",
87
+ "",
88
+ "// --- Implement the SPIs against your backend; prove them with the conformance",
89
+ "// kit (see index.test.ts). These stubs exist only so the template type-checks. ---",
90
+ "",
91
+ `export class ${joinNoun(P, "EventStore")} implements EventStore {`,
92
+ " constructor(private readonly url: string) {}",
93
+ " append(): Promise<void> {",
94
+ " throw new Error(`not implemented (${this.url})`);",
95
+ " }",
96
+ " read(): Promise<never[]> {",
97
+ ' throw new Error("not implemented");',
98
+ " }",
99
+ " readFrom(): Promise<never[]> {",
100
+ ' throw new Error("not implemented");',
101
+ " }",
102
+ " subscribe(): Promise<() => void> {",
103
+ ' throw new Error("not implemented");',
104
+ " }",
105
+ " close(): Promise<void> {",
106
+ " return Promise.resolve();",
107
+ " }",
108
+ "}",
109
+ "",
110
+ `export class ${joinNoun(P, "ArchiveStore")} implements ArchiveStore {`,
111
+ " constructor(private readonly url: string) {}",
112
+ " append(_executionId: string, _segment: ArchiveSegment): Promise<void> {",
113
+ " throw new Error(`not implemented (${this.url})`);",
114
+ " }",
115
+ " headers(_executionId: string): Promise<ArchiveSegmentHeader[]> {",
116
+ ' throw new Error("not implemented");',
117
+ " }",
118
+ " read(_executionId: string): Promise<ArchiveSegment[]> {",
119
+ ' throw new Error("not implemented");',
120
+ " }",
121
+ " listExecutions(): Promise<ArchiveExecutionPage> {",
122
+ ' throw new Error("not implemented");',
123
+ " }",
124
+ " close(): Promise<void> {",
125
+ " return Promise.resolve();",
126
+ " }",
127
+ "}",
128
+ "",
129
+ `export class ${joinNoun(P, "SnapshotStore")} implements SnapshotStore {`,
130
+ " constructor(private readonly url: string) {}",
131
+ " save(): Promise<void> {",
132
+ " throw new Error(`not implemented (${this.url})`);",
133
+ " }",
134
+ " loadLatest(): Promise<null> {",
135
+ ' throw new Error("not implemented");',
136
+ " }",
137
+ " loadAt(): Promise<null> {",
138
+ ' throw new Error("not implemented");',
139
+ " }",
140
+ " loadLatestBefore(): Promise<null> {",
141
+ ' throw new Error("not implemented");',
142
+ " }",
143
+ " close(): Promise<void> {",
144
+ " return Promise.resolve();",
145
+ " }",
146
+ "}",
147
+ "",
148
+ ].join("\n");
149
+ },
150
+ test: (name) => {
151
+ const P = (0, codegen_1.pascal)(name);
152
+ const c = (0, codegen_1.camel)(name);
153
+ return [
154
+ "// Prove your stores satisfy the SPI with the published conformance kit — this is",
155
+ '// what makes "plugin" a checkable category (ADR 0043). Wire real backends here.',
156
+ "//",
157
+ "// import {",
158
+ "// runEventStoreConformance,",
159
+ "// runSnapshotStoreConformance,",
160
+ "// runArchiveStoreConformance,",
161
+ '// } from "@loomwork/conformance";',
162
+ `// import { ${joinNoun(P, "ArchiveStore")}, ${joinNoun(P, "EventStore")}, ${joinNoun(P, "SnapshotStore")} } from "./index";`,
163
+ "//",
164
+ `// runEventStoreConformance(() => new ${joinNoun(P, "EventStore")}(":memory:"));`,
165
+ `// runSnapshotStoreConformance(() => new ${joinNoun(P, "SnapshotStore")}(":memory:"));`,
166
+ `// runArchiveStoreConformance(() => new ${joinNoun(P, "ArchiveStore")}(":memory:"));`,
167
+ "",
168
+ 'import { describe, expect, it } from "vitest";',
169
+ `import { ${joinNoun(c, "Storage")} } from "./index";`,
170
+ "",
171
+ `describe("${joinNoun(c, "Storage")} preset", () => {`,
172
+ ' it("returns a custom storage descriptor", () => {',
173
+ ` const s = ${joinNoun(c, "Storage")}({ url: ":memory:" });`,
174
+ ' expect(s.kind).toBe("custom");',
175
+ " expect(s.eventStore).toBeDefined();",
176
+ " expect(s.snapshotStore).toBeDefined();",
177
+ " expect(s.archiveStore).toBeDefined();",
178
+ " });",
179
+ "});",
180
+ "",
181
+ ].join("\n");
182
+ },
183
+ },
184
+ dispatcher: {
185
+ key: "dispatcher",
186
+ spi: "ExecutionDispatcher",
187
+ title: "Compute placement (ExecutionDispatcher)",
188
+ wiring: "execution: { dispatcher: new <Name>Dispatcher({ /* … */ }) }",
189
+ spiDeps: ["@loomwork/dispatcher"],
190
+ conformance: ["runExecutionDispatcherConformance"],
191
+ index: (name) => {
192
+ const P = (0, codegen_1.pascal)(name);
193
+ return [
194
+ "import type {",
195
+ " DispatchMetadata,",
196
+ " DispatcherHealth,",
197
+ " ExecutionDispatcher,",
198
+ " ExecutionPackage,",
199
+ " ExecutionResult,",
200
+ `} from "@loomwork/dispatcher";`,
201
+ "",
202
+ `export interface ${joinNoun(P, "DispatcherOptions")} {`,
203
+ " /** Where/how this dispatcher places work. */",
204
+ " endpoint?: string;",
205
+ "}",
206
+ "",
207
+ "/** An `ExecutionDispatcher`: decides WHERE an execution runs. Wire as `dispatcher`. */",
208
+ `export class ${joinNoun(P, "Dispatcher")} implements ExecutionDispatcher {`,
209
+ ` constructor(private readonly options: ${joinNoun(P, "DispatcherOptions")} = {}) {}`,
210
+ "",
211
+ " dispatch(pkg: ExecutionPackage, _meta?: DispatchMetadata): Promise<ExecutionResult> {",
212
+ " throw new Error(`not implemented: dispatch ${pkg.executionId} to ${this.options.endpoint}`);",
213
+ " }",
214
+ "",
215
+ " cancel(_executionId: string): Promise<void> {",
216
+ " return Promise.resolve();",
217
+ " }",
218
+ "",
219
+ " health(): Promise<DispatcherHealth> {",
220
+ ' throw new Error("not implemented");',
221
+ " }",
222
+ "}",
223
+ "",
224
+ ].join("\n");
225
+ },
226
+ test: (name) => {
227
+ const P = (0, codegen_1.pascal)(name);
228
+ return [
229
+ "// Prove your dispatcher with the conformance kit once dispatch() is real:",
230
+ '// import { runExecutionDispatcherConformance } from "@loomwork/conformance";',
231
+ `// import { ${joinNoun(P, "Dispatcher")} } from "./index";`,
232
+ `// runExecutionDispatcherConformance(() => new ${joinNoun(P, "Dispatcher")}());`,
233
+ "",
234
+ 'import { describe, expect, it } from "vitest";',
235
+ `import { ${joinNoun(P, "Dispatcher")} } from "./index";`,
236
+ "",
237
+ `describe("${joinNoun(P, "Dispatcher")}", () => {`,
238
+ ' it("constructs and cancel() is a safe no-op", async () => {',
239
+ ` const d = new ${joinNoun(P, "Dispatcher")}();`,
240
+ ' await expect(d.cancel("x")).resolves.toBeUndefined();',
241
+ " });",
242
+ "});",
243
+ "",
244
+ ].join("\n");
245
+ },
246
+ },
247
+ telemetry: {
248
+ key: "telemetry",
249
+ spi: "TelemetryAdapter",
250
+ title: "Telemetry adapter (flow/step lifecycle hooks)",
251
+ wiring: "observability: { telemetry: [<name>Telemetry({ /* … */ })] }",
252
+ spiDeps: ["@loomwork/telemetry"],
253
+ conformance: [],
254
+ index: (name) => {
255
+ const P = (0, codegen_1.pascal)(name);
256
+ const c = (0, codegen_1.camel)(name);
257
+ return [
258
+ `import type { TelemetryAdapter } from "@loomwork/telemetry";`,
259
+ "",
260
+ `export interface ${joinNoun(P, "TelemetryOptions")} {`,
261
+ " /** e.g. a sink URL, a logger, a sampling rate. */",
262
+ " sink?: (line: string) => void;",
263
+ "}",
264
+ "",
265
+ "/**",
266
+ " * A `TelemetryAdapter`. Every hook is optional — implement the ones you care about.",
267
+ ` * Wire it as \`observability: { telemetry: [${joinNoun(c, "Telemetry")}()] }\`; \`defineConfig\` composes multiple.`,
268
+ " */",
269
+ `export function ${joinNoun(c, "Telemetry")}(options: ${joinNoun(P, "TelemetryOptions")} = {}): TelemetryAdapter {`,
270
+ " const emit: (line: string) => void = options.sink ?? (() => {});",
271
+ " return {",
272
+ " onStepComplete(info) {",
273
+ " emit(`step ${info.stepId} ${info.status} in ${info.durationMs ?? 0}ms`);",
274
+ " },",
275
+ " onStepFail(info) {",
276
+ " emit(`step ${info.stepId} FAILED`);",
277
+ " },",
278
+ " };",
279
+ "}",
280
+ "",
281
+ ].join("\n");
282
+ },
283
+ test: (name) => {
284
+ const P = (0, codegen_1.pascal)(name);
285
+ const c = (0, codegen_1.camel)(name);
286
+ return [
287
+ 'import { describe, expect, it } from "vitest";',
288
+ `import { ${joinNoun(c, "Telemetry")} } from "./index";`,
289
+ "",
290
+ `describe("${P} telemetry", () => {`,
291
+ ' it("emits on step completion", () => {',
292
+ " const lines: string[] = [];",
293
+ ` const t = ${joinNoun(c, "Telemetry")}({ sink: (l) => lines.push(l) });`,
294
+ " t.onStepComplete?.({",
295
+ ' executionId: "e",',
296
+ ' workflowId: "w",',
297
+ ' stepId: "s",',
298
+ " attempt: 1,",
299
+ " startedAt: 0,",
300
+ ' status: "completed",',
301
+ " durationMs: 5,",
302
+ " });",
303
+ ' expect(lines[0]).toContain("step s completed");',
304
+ " });",
305
+ "});",
306
+ "",
307
+ ].join("\n");
308
+ },
309
+ },
310
+ middleware: {
311
+ key: "middleware",
312
+ spi: "EffectMiddleware",
313
+ title: "Effect middleware (wrap every effect execution)",
314
+ wiring: "execution: { effectMiddleware: [<name>Middleware({ /* … */ })] }",
315
+ spiDeps: ["@loomwork/effect-runtime"],
316
+ conformance: [],
317
+ index: (name) => {
318
+ const P = (0, codegen_1.pascal)(name);
319
+ const c = (0, codegen_1.camel)(name);
320
+ return [
321
+ `import type { EffectMiddleware } from "@loomwork/effect-runtime";`,
322
+ "",
323
+ `export interface ${joinNoun(P, "MiddlewareOptions")} {`,
324
+ " /** your knobs here */",
325
+ " label?: string;",
326
+ "}",
327
+ "",
328
+ "/**",
329
+ " * An `EffectMiddleware` — an onion layer over every effect. `middleware[0]` is",
330
+ " * outermost. It runs only for non-completed steps, so it is replay-safe. Wire as",
331
+ ` * \`execution: { effectMiddleware: [${joinNoun(c, "Middleware")}()] }\`; order is behavioural, so document where`,
332
+ " * yours belongs relative to retry/metering.",
333
+ " *",
334
+ " * STREAMING (ADR 0119): if this middleware REJECTS or REWRITES the effect's output after",
335
+ " * `next()`, a streamed delta would bypass it. It MUST then be marked `inspectsOutput: true`",
336
+ " * (the runtime then disables live streaming for the call, fail-closed) or provide a",
337
+ " * `deltaGuard` that checks the running text. Leave both unset only if you never alter or",
338
+ " * refuse the output.",
339
+ " */",
340
+ `export function ${joinNoun(c, "Middleware")}(options: ${joinNoun(P, "MiddlewareOptions")} = {}): EffectMiddleware {`,
341
+ ` const label = options.label ?? "${name}";`,
342
+ " return async (ctx, input, next) => {",
343
+ " // before: inspect ctx.stepId / ctx.attempt / ctx.emit(...)",
344
+ " void label;",
345
+ " const result = await next(input);",
346
+ " // after: inspect / transform result",
347
+ " return result;",
348
+ " };",
349
+ "}",
350
+ "",
351
+ ].join("\n");
352
+ },
353
+ test: (name) => {
354
+ const P = (0, codegen_1.pascal)(name);
355
+ const c = (0, codegen_1.camel)(name);
356
+ return [
357
+ 'import { describe, expect, it } from "vitest";',
358
+ `import { ${joinNoun(c, "Middleware")} } from "./index";`,
359
+ "",
360
+ `describe("${P} middleware", () => {`,
361
+ ' it("passes the effect result through", async () => {',
362
+ ` const mw = ${joinNoun(c, "Middleware")}();`,
363
+ " const ctx = {",
364
+ ' executionId: "e",',
365
+ ' stepId: "s",',
366
+ ' workflowId: "w",',
367
+ " attempt: 1,",
368
+ " emit: () => {},",
369
+ " };",
370
+ " // biome-ignore lint/suspicious/noExplicitAny: minimal EffectContext for the test",
371
+ " const out = await mw(ctx as any, 42, async (i) => i);",
372
+ " expect(out).toBe(42);",
373
+ " });",
374
+ "});",
375
+ "",
376
+ ].join("\n");
377
+ },
378
+ },
379
+ timer: {
380
+ key: "timer",
381
+ spi: "TimerStore",
382
+ title: "Durable timer store (T1 scheduled wakes)",
383
+ wiring: "timers: { store: new <Name>TimerStore() }",
384
+ spiDeps: ["@loomwork/event-runtime"],
385
+ conformance: ["runTimerStoreConformance"],
386
+ index: (name) => {
387
+ const P = (0, codegen_1.pascal)(name);
388
+ return [
389
+ 'import type { TimerRecord, TimerStore } from "@loomwork/event-runtime";',
390
+ "",
391
+ "/** A `TimerStore` — the findable half of `ctx.sleep`. Wire as `timers: { store }`. */",
392
+ `export class ${joinNoun(P, "TimerStore")} implements TimerStore {`,
393
+ " schedule(_timer: TimerRecord): Promise<void> {",
394
+ ' throw new Error("not implemented");',
395
+ " }",
396
+ " due(_now: number, _limit?: number): Promise<TimerRecord[]> {",
397
+ ' throw new Error("not implemented");',
398
+ " }",
399
+ " complete(_executionId: string, _wakeAt: number): Promise<void> {",
400
+ ' throw new Error("not implemented");',
401
+ " }",
402
+ " close(): Promise<void> {",
403
+ " return Promise.resolve();",
404
+ " }",
405
+ "}",
406
+ "",
407
+ ].join("\n");
408
+ },
409
+ test: (name) => {
410
+ const P = (0, codegen_1.pascal)(name);
411
+ return [
412
+ "// Prove your timer store with the conformance kit once the methods are real:",
413
+ '// import { runTimerStoreConformance } from "@loomwork/conformance";',
414
+ `// import { ${joinNoun(P, "TimerStore")} } from "./index";`,
415
+ `// runTimerStoreConformance(() => new ${joinNoun(P, "TimerStore")}());`,
416
+ "",
417
+ 'import { describe, expect, it } from "vitest";',
418
+ `import { ${joinNoun(P, "TimerStore")} } from "./index";`,
419
+ "",
420
+ `describe("${joinNoun(P, "TimerStore")}", () => {`,
421
+ ' it("constructs", () => {',
422
+ ` expect(new ${joinNoun(P, "TimerStore")}()).toBeDefined();`,
423
+ " });",
424
+ "});",
425
+ "",
426
+ ].join("\n");
427
+ },
428
+ },
429
+ lease: {
430
+ key: "lease",
431
+ spi: "LeaseStore",
432
+ title: "Execution ownership leases (#380 — claim before waking)",
433
+ wiring: 'timers: { store, leases: { store: new <Name>LeaseStore(), owner: "node-1" } }',
434
+ spiDeps: ["@loomwork/event-runtime"],
435
+ conformance: ["runLeaseStoreConformance"],
436
+ index: (name) => {
437
+ const P = (0, codegen_1.pascal)(name);
438
+ return [
439
+ 'import type { Lease, LeaseStore } from "@loomwork/event-runtime";',
440
+ "",
441
+ "/**",
442
+ " * A `LeaseStore` — who owns an execution right now.",
443
+ " *",
444
+ " * The three return values carry the whole contract, and getting one of them wrong is",
445
+ " * how a lease store causes the split-brain it exists to prevent:",
446
+ " *",
447
+ " * - `acquire` returns `null` when ANOTHER owner holds an unexpired lease. Re-acquiring",
448
+ " * one you already hold is a renewal, not a conflict — a node that restarts must be",
449
+ " * able to take back its own work.",
450
+ " * - `renew` returns `false` when ownership was lost. Never resurrect a lease another",
451
+ " * owner has taken: a caller that keeps working on `false` is the split-brain.",
452
+ " * - `expired` is the evidence an execution was orphaned — soonest-expired first.",
453
+ " *",
454
+ " * The optional `get` answers a different question from `expired`: *is anything watching",
455
+ " * this execution at all*. An execution that never had a lease is invisible to `expired`,",
456
+ " * and it is not orphaned — it is unwatched (`loom census` counts those separately).",
457
+ " *",
458
+ " * OPTIONAL batch methods (SPI 2.2.0): `acquireMany`, `renewMany` (returns the HELD set) and",
459
+ " * `releaseMany`. Implement all three or none; they are NOT emitted here (a throwing stub",
460
+ " * would advertise a capability this store does not have), so the conformance call below",
461
+ " * passes no `spiVersion`. `steal` is single-id `acquire` only (`acquireMany` rejects it).",
462
+ " *",
463
+ " * Implement against a store with an atomic compare-and-set; the first-party SQLite and",
464
+ " * Postgres stores each do it in ONE statement, because a read-then-write is exactly the",
465
+ " * race this is meant to close.",
466
+ " */",
467
+ `export class ${joinNoun(P, "LeaseStore")} implements LeaseStore {`,
468
+ " acquire(",
469
+ " _executionId: string,",
470
+ " _owner: string,",
471
+ " _ttlMs: number,",
472
+ " _now?: number",
473
+ " ): Promise<Lease | null> {",
474
+ ' throw new Error("not implemented");',
475
+ " }",
476
+ " renew(_executionId: string, _owner: string, _ttlMs: number, _now?: number): Promise<boolean> {",
477
+ ' throw new Error("not implemented");',
478
+ " }",
479
+ " expired(_now?: number, _limit?: number): Promise<Lease[]> {",
480
+ ' throw new Error("not implemented");',
481
+ " }",
482
+ " release(_executionId: string, _owner: string): Promise<void> {",
483
+ ' throw new Error("not implemented");',
484
+ " }",
485
+ " /** Optional. Omit it and `loom census` reports coverage as unknown rather than zero. */",
486
+ " get(_executionId: string): Promise<Lease | null> {",
487
+ ' throw new Error("not implemented");',
488
+ " }",
489
+ " close(): Promise<void> {",
490
+ " return Promise.resolve();",
491
+ " }",
492
+ "}",
493
+ "",
494
+ ].join("\n");
495
+ },
496
+ test: (name) => {
497
+ const P = (0, codegen_1.pascal)(name);
498
+ return [
499
+ "// Prove your lease store with the conformance kit once the methods are real. Do not",
500
+ "// skip it: the kit covers the concurrent cases (two owners racing, renewal after",
501
+ "// expiry, release by a non-owner) that a hand-written test usually misses.",
502
+ '// import { runLeaseStoreConformance } from "@loomwork/conformance";',
503
+ `// import { ${joinNoun(P, "LeaseStore")} } from "./index";`,
504
+ `// runLeaseStoreConformance(() => new ${joinNoun(P, "LeaseStore")}());`,
505
+ "",
506
+ 'import { describe, expect, it } from "vitest";',
507
+ `import { ${joinNoun(P, "LeaseStore")} } from "./index";`,
508
+ "",
509
+ `describe("${joinNoun(P, "LeaseStore")}", () => {`,
510
+ ' it("constructs", () => {',
511
+ ` expect(new ${joinNoun(P, "LeaseStore")}()).toBeDefined();`,
512
+ " });",
513
+ "});",
514
+ "",
515
+ ].join("\n");
516
+ },
517
+ },
518
+ content: {
519
+ key: "content",
520
+ spi: "ContentStore",
521
+ title: "Content-addressed blob store (claim-check payloads)",
522
+ wiring: "claimCheckMiddleware({ store: new <Name>ContentStore(), inlineCapBytes: 16 * 1024 })",
523
+ spiDeps: ["@loomwork/plugin-content"],
524
+ conformance: ["runContentStoreConformance"],
525
+ index: (name) => {
526
+ const P = (0, codegen_1.pascal)(name);
527
+ return [
528
+ 'import type { ContentRef, ContentStore } from "@loomwork/plugin-content";',
529
+ "",
530
+ "/**",
531
+ " * A `ContentStore` — where a claim-check payload actually lives (ADR 0046).",
532
+ " *",
533
+ " * Content-ADDRESSED: `put` is keyed by the hash of the bytes, so storing the same",
534
+ " * payload twice is one object and a retry is free. That is what makes the store safe",
535
+ " * to call from a replayed effect.",
536
+ " *",
537
+ " * `get` returns `null` for an absent ref and MUST NOT throw: a missing blob is a fact",
538
+ " * the caller handles (the log still has the digest), not an exception that aborts a",
539
+ " * replay. `delete` is idempotent PHYSICAL erasure — it is the K10 shredding path, so",
540
+ " * deleting what is already gone must succeed rather than object.",
541
+ " */",
542
+ `export class ${joinNoun(P, "ContentStore")} implements ContentStore {`,
543
+ " put(_bytes: Uint8Array): Promise<ContentRef> {",
544
+ ' throw new Error("not implemented");',
545
+ " }",
546
+ " get(_ref: ContentRef): Promise<Uint8Array | null> {",
547
+ ' throw new Error("not implemented");',
548
+ " }",
549
+ " delete(_ref: ContentRef): Promise<void> {",
550
+ ' throw new Error("not implemented");',
551
+ " }",
552
+ " // Optional, and worth implementing where the backend has them natively:",
553
+ " // putStream(stream: AsyncIterable<Uint8Array>): Promise<ContentRef> — ADR 0059",
554
+ " // getMany(refs: readonly ContentRef[]): Promise<(Uint8Array | null)[]> — ADR 0062",
555
+ " // deleteIfUntouchedSince(ref: ContentRef, cutoffMs: number): Promise<boolean>",
556
+ " // listPage(opts: ContentListPageOptions): Promise<ContentListPage> — ADR 0055 D5/D6",
557
+ " // touchClock(): Promise<number> — now, on the clock you stamp touch times with",
558
+ " // touch(addresses: readonly string[]): Promise<string[]> — SPI 1.4.0: bump touch times",
559
+ " // WITHOUT a put (max); unknown addresses are a no-op, resolved as the missing list.",
560
+ " // `loom sweep content --orphans --apply` REFUSES a store without it (forks and appends",
561
+ " // that carry a ref call it).",
562
+ " // Implement the first three TOGETHER (the kit fails a partial set), and then EVERY put must",
563
+ " // bump the blob's touch time, never backwards, even for bytes you already hold",
564
+ " // (ON CONFLICT DO UPDATE, not INSERT OR IGNORE): they make `loom sweep content`",
565
+ " // race-safe and let it reclaim orphans.",
566
+ " // Left out rather than stubbed: a throwing optional method is worse than an absent",
567
+ " // one, because callers feature-detect it.",
568
+ "}",
569
+ "",
570
+ ].join("\n");
571
+ },
572
+ test: (name) => {
573
+ const P = (0, codegen_1.pascal)(name);
574
+ return [
575
+ "// Prove it with the kit once the methods are real — it covers the idempotence that",
576
+ "// content addressing depends on (same bytes twice, delete twice, get after delete):",
577
+ '// import { runContentStoreConformance } from "@loomwork/conformance";',
578
+ `// import { ${joinNoun(P, "ContentStore")} } from "./index";`,
579
+ `// runContentStoreConformance(() => new ${joinNoun(P, "ContentStore")}());`,
580
+ "",
581
+ 'import { describe, expect, it } from "vitest";',
582
+ `import { ${joinNoun(P, "ContentStore")} } from "./index";`,
583
+ "",
584
+ `describe("${joinNoun(P, "ContentStore")}", () => {`,
585
+ ' it("constructs", () => {',
586
+ ` expect(new ${joinNoun(P, "ContentStore")}()).toBeDefined();`,
587
+ " });",
588
+ "});",
589
+ "",
590
+ ].join("\n");
591
+ },
592
+ },
593
+ "durable-state": {
594
+ key: "durable-state",
595
+ spi: "DurableStateStore",
596
+ title: "Carry state across executions (compare-and-set)",
597
+ wiring: 'durableState({ id: "carry", version: 1, schema, store: new <Name>DurableStateStore() })',
598
+ spiDeps: ["@loomwork/plugin-durable-state"],
599
+ conformance: ["runDurableStateStoreConformance"],
600
+ index: (name) => {
601
+ const P = (0, codegen_1.pascal)(name);
602
+ return [
603
+ 'import type { DurableStateRecord, DurableStateStore } from "@loomwork/plugin-durable-state";',
604
+ "",
605
+ "/**",
606
+ " * A `DurableStateStore` — state that OUTLIVES one execution (a sentinel's carry).",
607
+ " *",
608
+ " * `save` is a compare-and-set on `expectedRevision`, and that is the whole point: two",
609
+ " * nodes finishing the same sentinel run must not silently overwrite each other. A",
610
+ " * mismatch REJECTS rather than wins; the caller re-reads and decides. Implement it as",
611
+ " * one conditional statement, not read-then-write, or the race you are closing is the",
612
+ " * race you introduce.",
613
+ " *",
614
+ " * `load` returns `null` when the key was never written — distinct from a record whose",
615
+ " * payload happens to be empty.",
616
+ " */",
617
+ `export class ${joinNoun(P, "DurableStateStore")} implements DurableStateStore {`,
618
+ " load(_key: string): Promise<DurableStateRecord | null> {",
619
+ ' throw new Error("not implemented");',
620
+ " }",
621
+ " save(",
622
+ " _key: string,",
623
+ " _value: { contractVersion: number; payload: unknown },",
624
+ " _expectedRevision: number",
625
+ " ): Promise<DurableStateRecord> {",
626
+ ' throw new Error("not implemented");',
627
+ " }",
628
+ " clear(_key: string): Promise<void> {",
629
+ ' throw new Error("not implemented");',
630
+ " }",
631
+ " close(): Promise<void> {",
632
+ " return Promise.resolve();",
633
+ " }",
634
+ "}",
635
+ "",
636
+ ].join("\n");
637
+ },
638
+ test: (name) => {
639
+ const P = (0, codegen_1.pascal)(name);
640
+ return [
641
+ "// The kit covers the concurrent compare-and-set cases a hand-written test misses:",
642
+ '// import { runDurableStateStoreConformance } from "@loomwork/conformance";',
643
+ `// import { ${joinNoun(P, "DurableStateStore")} } from "./index";`,
644
+ `// runDurableStateStoreConformance(() => new ${joinNoun(P, "DurableStateStore")}());`,
645
+ "",
646
+ 'import { describe, expect, it } from "vitest";',
647
+ `import { ${joinNoun(P, "DurableStateStore")} } from "./index";`,
648
+ "",
649
+ `describe("${joinNoun(P, "DurableStateStore")}", () => {`,
650
+ ' it("constructs", () => {',
651
+ ` expect(new ${joinNoun(P, "DurableStateStore")}()).toBeDefined();`,
652
+ " });",
653
+ "});",
654
+ "",
655
+ ].join("\n");
656
+ },
657
+ },
658
+ "read-model": {
659
+ key: "read-model",
660
+ spi: "ReadModelStore",
661
+ title: "Durable cross-execution query layer (ADR 0058)",
662
+ wiring: "new ReadModelService(new <Name>ReadModelStore())",
663
+ spiDeps: ["@loomwork/plugin-read-model"],
664
+ conformance: ["runReadModelStoreConformance"],
665
+ index: (name) => {
666
+ const P = (0, codegen_1.pascal)(name);
667
+ return [
668
+ "import type {",
669
+ " ReadModelPage,",
670
+ " ReadModelQuery,",
671
+ " ReadModelRow,",
672
+ " ReadModelStore,",
673
+ '} from "@loomwork/plugin-read-model";',
674
+ "",
675
+ "/**",
676
+ " * A `ReadModelStore` — the durable index behind cross-execution queries (ADR 0058).",
677
+ " *",
678
+ " * Two properties carry it, and both are about REPLAY rather than about querying:",
679
+ " *",
680
+ " * - `apply` is idempotent per `rowId`. A catch-up re-reads events the store already",
681
+ " * saw, so applying the same row twice must leave one row, not two.",
682
+ " * - the watermark is MONOTONIC. `apply` advances it to `upToSequence` atomically with",
683
+ " * the rows; if the rows land and the watermark does not, the next pass re-applies",
684
+ " * them (fine), but the reverse loses them forever.",
685
+ " *",
686
+ " * `query` pages by KEYSET, not offset: a read model grows while you page through it.",
687
+ " */",
688
+ `export class ${joinNoun(P, "ReadModelStore")} implements ReadModelStore {`,
689
+ " apply(",
690
+ " _modelId: string,",
691
+ " _executionId: string,",
692
+ " _upToSequence: number,",
693
+ " _rows: readonly ReadModelRow[]",
694
+ " ): Promise<void> {",
695
+ ' throw new Error("not implemented");',
696
+ " }",
697
+ " watermark(_modelId: string, _executionId: string): Promise<number | null> {",
698
+ ' throw new Error("not implemented");',
699
+ " }",
700
+ " watermarkedExecutions(_modelId: string): Promise<string[]> {",
701
+ ' throw new Error("not implemented");',
702
+ " }",
703
+ " query(_modelId: string, _q?: ReadModelQuery): Promise<ReadModelPage> {",
704
+ ' throw new Error("not implemented");',
705
+ " }",
706
+ " clear(_modelId: string): Promise<void> {",
707
+ ' throw new Error("not implemented");',
708
+ " }",
709
+ " close(): Promise<void> {",
710
+ " return Promise.resolve();",
711
+ " }",
712
+ "}",
713
+ "",
714
+ ].join("\n");
715
+ },
716
+ test: (name) => {
717
+ const P = (0, codegen_1.pascal)(name);
718
+ return [
719
+ "// The kit covers idempotent re-apply and watermark monotonicity — the two things a",
720
+ "// read model is wrong about silently:",
721
+ '// import { runReadModelStoreConformance } from "@loomwork/conformance";',
722
+ `// import { ${joinNoun(P, "ReadModelStore")} } from "./index";`,
723
+ `// runReadModelStoreConformance(() => new ${joinNoun(P, "ReadModelStore")}());`,
724
+ "",
725
+ 'import { describe, expect, it } from "vitest";',
726
+ `import { ${joinNoun(P, "ReadModelStore")} } from "./index";`,
727
+ "",
728
+ `describe("${joinNoun(P, "ReadModelStore")}", () => {`,
729
+ ' it("constructs", () => {',
730
+ ` expect(new ${joinNoun(P, "ReadModelStore")}()).toBeDefined();`,
731
+ " });",
732
+ "});",
733
+ "",
734
+ ].join("\n");
735
+ },
736
+ },
737
+ keystore: {
738
+ key: "keystore",
739
+ spi: "KeyStore",
740
+ title: "Per-subject encryption keys (K10 crypto-shredding)",
741
+ wiring: "new EncryptingEventStore(inner, new <Name>KeyStore(), { sensitive })",
742
+ spiDeps: ["@loomwork/plugin-crypto"],
743
+ conformance: ["runKeyStoreConformance"],
744
+ index: (name) => {
745
+ const P = (0, codegen_1.pascal)(name);
746
+ return [
747
+ 'import type { KeyStore } from "@loomwork/plugin-crypto";',
748
+ "",
749
+ "/**",
750
+ " * A `KeyStore` — one encryption key per subject, and therefore the erasure boundary.",
751
+ " *",
752
+ " * `destroy` is IRREVERSIBLE and is how loom honours an erasure request without",
753
+ " * rewriting an append-only log: destroy the key and the subject's events become",
754
+ " * undecryptable everywhere at once, including in backups you cannot reach. There is no",
755
+ " * undo, so back this with a KMS/HSM in production rather than with a table you might",
756
+ " * restore from a snapshot.",
757
+ " *",
758
+ " * `get` returns `null` both for a key never created AND for one destroyed — the caller",
759
+ " * cannot distinguish them, deliberately: after shredding there is nothing true to say.",
760
+ " *",
761
+ " * `getOrCreate` MUST be an ATOMIC create-if-absent across every caller and node (a conditional",
762
+ " * write, e.g. INSERT ... ON CONFLICT DO NOTHING then re-read), never get -> write: two racing",
763
+ " * first calls must get the SAME key, or an erasure destroys one and the other stays readable.",
764
+ " */",
765
+ `export class ${joinNoun(P, "KeyStore")} implements KeyStore {`,
766
+ " getOrCreate(_subjectId: string): Promise<Buffer> {",
767
+ ' throw new Error("not implemented");',
768
+ " }",
769
+ " get(_subjectId: string): Promise<Buffer | null> {",
770
+ ' throw new Error("not implemented");',
771
+ " }",
772
+ " destroy(_subjectId: string): Promise<void> {",
773
+ ' throw new Error("not implemented");',
774
+ " }",
775
+ " // OPTIONAL, left out until implemented (conformance skips a store without it): true only",
776
+ " // if THIS store destroyed the subject's key (persist the tombstone), so a retry can prove",
777
+ " // destruction. Uncomment and implement it; do not stub it to return true.",
778
+ " // wasDestroyed(_subjectId: string): Promise<boolean> {",
779
+ ' // throw new Error("not implemented");',
780
+ " // }",
781
+ "}",
782
+ "",
783
+ ].join("\n");
784
+ },
785
+ test: (name) => {
786
+ const P = (0, codegen_1.pascal)(name);
787
+ return [
788
+ "// The kit covers the irreversibility: get-after-destroy is null, and getOrCreate does",
789
+ "// NOT resurrect a shredded subject.",
790
+ '// import { runKeyStoreConformance } from "@loomwork/conformance";',
791
+ `// import { ${joinNoun(P, "KeyStore")} } from "./index";`,
792
+ `// runKeyStoreConformance(() => new ${joinNoun(P, "KeyStore")}());`,
793
+ "",
794
+ 'import { describe, expect, it } from "vitest";',
795
+ `import { ${joinNoun(P, "KeyStore")} } from "./index";`,
796
+ "",
797
+ `describe("${joinNoun(P, "KeyStore")}", () => {`,
798
+ ' it("constructs", () => {',
799
+ ` expect(new ${joinNoun(P, "KeyStore")}()).toBeDefined();`,
800
+ " });",
801
+ "});",
802
+ "",
803
+ ].join("\n");
804
+ },
805
+ },
806
+ memory: {
807
+ key: "memory",
808
+ spi: "MemoryStore",
809
+ title: "Agent memory store (scoped entries, optional relevance search)",
810
+ wiring: "memoryStore: new <Name>MemoryStore()",
811
+ spiDeps: ["@loomwork/memory"],
812
+ conformance: ["runMemoryStoreConformance"],
813
+ index: (name) => {
814
+ const P = (0, codegen_1.pascal)(name);
815
+ return [
816
+ 'import type { MemoryEntry, MemoryStore } from "@loomwork/memory";',
817
+ "",
818
+ "/**",
819
+ " * A `MemoryStore` — durable agent memory. Wire as `memoryStore`.",
820
+ " *",
821
+ " * A key names an ORDERED LIST of entries. `read` returns them in APPEND order,",
822
+ " * not timestamp order: entries routinely share a timestamp (one turn written in",
823
+ " * one tick), so a backend needs an insertion key of its own. An absent key reads",
824
+ " * as `[]`, never a throw, and `clear` is idempotent.",
825
+ " *",
826
+ " * An entry may carry a caller-assigned `id`, and round-tripping it is REQUIRED —",
827
+ " * appended with one it reads back with the same one, appended without one it reads",
828
+ " * back with NONE (`{ content }` must not become `{ id: null, content }`). This is",
829
+ " * the field a backend that decomposes an entry into columns forgets, and the",
830
+ " * forgetting is silent: removal above the SPI matches on the id `read` gives back,",
831
+ " * so a dropped id turns a delete into a no-op rather than an error.",
832
+ " *",
833
+ " * The other four are OPTIONAL, and PRESENCE IS THE FEATURE FLAG:",
834
+ " * the runtime tests for the method and falls back when it is missing. A stub that",
835
+ " * throws is therefore worse than no stub at all — it would advertise a capability",
836
+ " * the store does not have. Add each one only when you implement it:",
837
+ " *",
838
+ " * replace(key, entries) atomic whole-list rewrite. Without it, eviction is",
839
+ " * clear-then-re-append, and a crash between the two loses",
840
+ " * the scope.",
841
+ " * search(key, query, o) relevance retrieval, best first — what separates memory",
842
+ " * from the tail of a conversation. Index",
843
+ " * `searchableText(entry)` and split terms with `tokenize`",
844
+ " * (both exported by @loomwork/memory), because what gets",
845
+ " * indexed and what counts as a term are the contract.",
846
+ " * remove(key, ids) delete named entries, survivors keeping append order.",
847
+ " * Idempotent; an entry with NO id is never removed by any",
848
+ " * list of ids. Without it, the runtime rewrites the whole",
849
+ " * key instead — correct, and O(scope) per document edit.",
850
+ " * close() release a handle or a pool.",
851
+ " *",
852
+ " * `runMemoryStoreConformance` covers the required verbs and skips the optional",
853
+ " * ones you have not added.",
854
+ " */",
855
+ `export class ${joinNoun(P, "MemoryStore")} implements MemoryStore {`,
856
+ " append(_key: string, _entry: MemoryEntry): Promise<void> {",
857
+ ' throw new Error("not implemented");',
858
+ " }",
859
+ " read(_key: string): Promise<MemoryEntry[]> {",
860
+ ' throw new Error("not implemented");',
861
+ " }",
862
+ " clear(_key: string): Promise<void> {",
863
+ ' throw new Error("not implemented");',
864
+ " }",
865
+ "}",
866
+ "",
867
+ ].join("\n");
868
+ },
869
+ test: (name) => {
870
+ const P = (0, codegen_1.pascal)(name);
871
+ return [
872
+ "// Prove your memory store with the conformance kit once the methods are real:",
873
+ '// import { runMemoryStoreConformance } from "@loomwork/conformance";',
874
+ `// import { ${joinNoun(P, "MemoryStore")} } from "./index";`,
875
+ `// runMemoryStoreConformance(() => new ${joinNoun(P, "MemoryStore")}());`,
876
+ "",
877
+ 'import { describe, expect, it } from "vitest";',
878
+ `import { ${joinNoun(P, "MemoryStore")} } from "./index";`,
879
+ "",
880
+ `describe("${joinNoun(P, "MemoryStore")}", () => {`,
881
+ ' it("constructs", () => {',
882
+ ` expect(new ${joinNoun(P, "MemoryStore")}()).toBeDefined();`,
883
+ " });",
884
+ "});",
885
+ "",
886
+ ].join("\n");
887
+ },
888
+ },
889
+ projection: {
890
+ key: "projection",
891
+ spi: "ProjectionRuntime",
892
+ title: "Projection runtime (custom read-model backend)",
893
+ wiring: "projectionRuntime: new <Name>ProjectionRuntime()",
894
+ spiDeps: ["@loomwork/core", "@loomwork/projection-runtime"],
895
+ conformance: ["runProjectionRuntimeConformance"],
896
+ index: (name) => {
897
+ const P = (0, codegen_1.pascal)(name);
898
+ return [
899
+ 'import type { EventEnvelope } from "@loomwork/core";',
900
+ 'import type { Projection, ProjectionRuntime } from "@loomwork/projection-runtime";',
901
+ "",
902
+ "/** A `ProjectionRuntime` — folds events into read-models. Wire as `projectionRuntime`. */",
903
+ `export class ${joinNoun(P, "ProjectionRuntime")} implements ProjectionRuntime {`,
904
+ " register<TState>(_projection: Projection<TState>): void {",
905
+ ' throw new Error("not implemented");',
906
+ " }",
907
+ " getState<TState>(_projectionId: string, _executionId: string): TState {",
908
+ ' throw new Error("not implemented");',
909
+ " }",
910
+ " subscribe<TState>(",
911
+ " _projectionId: string,",
912
+ " _executionId: string,",
913
+ " _handler: (state: TState) => void",
914
+ " ): () => void {",
915
+ ' throw new Error("not implemented");',
916
+ " }",
917
+ " /**",
918
+ " * Slice-scoped subscription (ADR 0068). Change-only, exactly like `subscribe`: it",
919
+ " * does NOT fire on subscribe, and the last slice is seeded from current state so the",
920
+ " * first handler call is the first genuine change.",
921
+ " */",
922
+ " subscribeSelector<TState, TSlice>(",
923
+ " _projectionId: string,",
924
+ " _executionId: string,",
925
+ " _selector: (state: TState) => TSlice,",
926
+ " _handler: (slice: TSlice) => void,",
927
+ " _isEqual?: (a: TSlice, b: TSlice) => boolean",
928
+ " ): () => void {",
929
+ ' throw new Error("not implemented");',
930
+ " }",
931
+ " handleEvent(_event: EventEnvelope): void {",
932
+ ' throw new Error("not implemented");',
933
+ " }",
934
+ " seedState(_projectionId: string, _executionId: string, _state: unknown): void {",
935
+ ' throw new Error("not implemented");',
936
+ " }",
937
+ "}",
938
+ "",
939
+ ].join("\n");
940
+ },
941
+ test: (name) => {
942
+ const P = (0, codegen_1.pascal)(name);
943
+ return [
944
+ "// Prove your projection runtime with the conformance kit once methods are real:",
945
+ '// import { runProjectionRuntimeConformance } from "@loomwork/conformance";',
946
+ `// import { ${joinNoun(P, "ProjectionRuntime")} } from "./index";`,
947
+ `// runProjectionRuntimeConformance(() => new ${joinNoun(P, "ProjectionRuntime")}());`,
948
+ "",
949
+ 'import { describe, expect, it } from "vitest";',
950
+ `import { ${joinNoun(P, "ProjectionRuntime")} } from "./index";`,
951
+ "",
952
+ `describe("${joinNoun(P, "ProjectionRuntime")}", () => {`,
953
+ ' it("constructs", () => {',
954
+ ` expect(new ${joinNoun(P, "ProjectionRuntime")}()).toBeDefined();`,
955
+ " });",
956
+ "});",
957
+ "",
958
+ ].join("\n");
959
+ },
960
+ },
961
+ worker: {
962
+ key: "worker",
963
+ spi: "WorkerRuntime",
964
+ title: "Worker runtime (run an execution on some compute)",
965
+ wiring: "execution: { dispatcher: new LocalDispatcher(new <Name>Worker()) }",
966
+ spiDeps: ["@loomwork/worker-runtime"],
967
+ conformance: ["runWorkerRuntimeConformance"],
968
+ index: (name) => {
969
+ const P = (0, codegen_1.pascal)(name);
970
+ return [
971
+ "import type {",
972
+ " ExecutionPackage,",
973
+ " ExecutionResult,",
974
+ " WorkerLease,",
975
+ " WorkerRequest,",
976
+ " WorkerRuntime,",
977
+ '} from "@loomwork/worker-runtime";',
978
+ "",
979
+ "/** A `WorkerRuntime` — runs an ExecutionPackage on some compute; wrap it in a dispatcher. */",
980
+ `export class ${joinNoun(P, "Worker")} implements WorkerRuntime {`,
981
+ " allocate(request: WorkerRequest): Promise<WorkerLease> {",
982
+ " return Promise.resolve({ leaseId: `lease-${request.executionId}` });",
983
+ " }",
984
+ " execute(_lease: WorkerLease, pkg: ExecutionPackage): Promise<ExecutionResult> {",
985
+ " throw new Error(`not implemented: execute ${pkg.executionId}`);",
986
+ " }",
987
+ " release(_lease: WorkerLease): Promise<void> {",
988
+ " return Promise.resolve();",
989
+ " }",
990
+ "}",
991
+ "",
992
+ ].join("\n");
993
+ },
994
+ test: (name) => {
995
+ const P = (0, codegen_1.pascal)(name);
996
+ return [
997
+ "// Prove your worker with the conformance kit once execute() is real — it needs a",
998
+ "// package your worker can run, so the factory returns { worker, pkg }:",
999
+ '// import { runWorkerRuntimeConformance } from "@loomwork/conformance";',
1000
+ `// import { ${joinNoun(P, "Worker")} } from "./index";`,
1001
+ `// runWorkerRuntimeConformance(() => ({ worker: new ${joinNoun(P, "Worker")}(), pkg: /* runnable ExecutionPackage */ }));`,
1002
+ "",
1003
+ 'import { describe, expect, it } from "vitest";',
1004
+ `import { ${joinNoun(P, "Worker")} } from "./index";`,
1005
+ "",
1006
+ `describe("${joinNoun(P, "Worker")}", () => {`,
1007
+ ' it("allocate returns a lease", async () => {',
1008
+ ` const lease = await new ${joinNoun(P, "Worker")}().allocate({ executionId: "e" });`,
1009
+ ' expect(typeof lease.leaseId).toBe("string");',
1010
+ " });",
1011
+ "});",
1012
+ "",
1013
+ ].join("\n");
1014
+ },
1015
+ },
1016
+ llm: {
1017
+ key: "llm",
1018
+ spi: "LlmProvider",
1019
+ title: "LLM provider (model backend)",
1020
+ wiring: "createLlmEffect({ provider: new <Name>Provider() })",
1021
+ spiDeps: ["@loomwork/llm"],
1022
+ conformance: ["runLlmProviderConformance"],
1023
+ index: (name) => {
1024
+ const P = (0, codegen_1.pascal)(name);
1025
+ return [
1026
+ 'import { assertRequestSupported } from "@loomwork/llm";',
1027
+ 'import type { LlmCapabilities, LlmProvider, LlmRequest, LlmResponse } from "@loomwork/llm";',
1028
+ "",
1029
+ "/** An `LlmProvider` — wire via `createLlmEffect({ provider })` or an agent's `provider`. */",
1030
+ `export class ${joinNoun(P, "Provider")} implements LlmProvider {`,
1031
+ " // Media this provider accepts (R5). ABSENT = text + tool calls only; declare e.g.",
1032
+ ' // `{ image: ["image/png", "image/jpeg"] }` and map the parts in complete().',
1033
+ " readonly capabilities?: LlmCapabilities;",
1034
+ "",
1035
+ " async complete(request: LlmRequest): Promise<LlmResponse> {",
1036
+ " // Fail closed on an undeclared modality BEFORE any network call.",
1037
+ ` assertRequestSupported(request, this.capabilities, "${joinNoun(P, "Provider")}");`,
1038
+ " // Message content is `string | LlmContentPart[]` — read it with `textOf` / `toParts`.",
1039
+ " throw new Error(`not implemented: complete (${request.messages.length} messages)`);",
1040
+ " }",
1041
+ " // stream?(request: LlmRequest): AsyncIterable<LlmStreamChunk> — optional",
1042
+ " // warm?(): void | Promise<void> — optional advisory pre-warm",
1043
+ "}",
1044
+ "",
1045
+ ].join("\n");
1046
+ },
1047
+ test: (name) => {
1048
+ const P = (0, codegen_1.pascal)(name);
1049
+ return [
1050
+ "// Prove your provider with the conformance kit once complete() is real:",
1051
+ '// import { runLlmProviderConformance } from "@loomwork/conformance";',
1052
+ `// import { ${joinNoun(P, "Provider")} } from "./index";`,
1053
+ "//",
1054
+ "// A `toolRequest` this provider answers WITH a tool call is REQUIRED once it has",
1055
+ "// `stream` — without one the kit fails (an unproven tool-call parity is a refusal,",
1056
+ "// not a pass). A provider that never calls tools opts out with `toolRequest: null`.",
1057
+ `// runLlmProviderConformance(() => new ${joinNoun(P, "Provider")}(), "${joinNoun(P, "Provider")}", { toolRequest: someToolRequest });`,
1058
+ "",
1059
+ 'import { describe, expect, it } from "vitest";',
1060
+ `import { ${joinNoun(P, "Provider")} } from "./index";`,
1061
+ "",
1062
+ `describe("${joinNoun(P, "Provider")}", () => {`,
1063
+ ' it("constructs", () => {',
1064
+ ` expect(new ${joinNoun(P, "Provider")}()).toBeDefined();`,
1065
+ " });",
1066
+ "});",
1067
+ "",
1068
+ ].join("\n");
1069
+ },
1070
+ },
1071
+ authorizer: {
1072
+ key: "authorizer",
1073
+ spi: "CapabilityAuthorizer",
1074
+ title: "Capability authorizer (allow/deny each effect)",
1075
+ wiring: "security: { authorizer: new <Name>Authorizer() }",
1076
+ spiDeps: ["@loomwork/effect-runtime"],
1077
+ conformance: ["runCapabilityAuthorizerConformance"],
1078
+ index: (name) => {
1079
+ const P = (0, codegen_1.pascal)(name);
1080
+ return [
1081
+ "import type {",
1082
+ " CapabilityAuthorizer,",
1083
+ " CapabilityDecision,",
1084
+ " CapabilityRequest,",
1085
+ '} from "@loomwork/effect-runtime";',
1086
+ "",
1087
+ "/** A `CapabilityAuthorizer` — allow/deny each effect. Wire as `authorizer`. */",
1088
+ `export class ${joinNoun(P, "Authorizer")} implements CapabilityAuthorizer {`,
1089
+ " authorize(req: CapabilityRequest): CapabilityDecision {",
1090
+ " // TODO: your policy. Default-deny is the safe placeholder.",
1091
+ " return { allowed: false, reason: `not implemented (${req.toolId})` };",
1092
+ " }",
1093
+ "}",
1094
+ "",
1095
+ ].join("\n");
1096
+ },
1097
+ test: (name) => {
1098
+ const P = (0, codegen_1.pascal)(name);
1099
+ return [
1100
+ "// Prove your authorizer with the conformance kit (decision shape + determinism):",
1101
+ '// import { runCapabilityAuthorizerConformance } from "@loomwork/conformance";',
1102
+ `// import { ${joinNoun(P, "Authorizer")} } from "./index";`,
1103
+ `// runCapabilityAuthorizerConformance(() => new ${joinNoun(P, "Authorizer")}());`,
1104
+ "",
1105
+ 'import { describe, expect, it } from "vitest";',
1106
+ `import { ${joinNoun(P, "Authorizer")} } from "./index";`,
1107
+ "",
1108
+ `describe("${joinNoun(P, "Authorizer")}", () => {`,
1109
+ ' it("returns a boolean decision", () => {',
1110
+ ` const d = new ${joinNoun(P, "Authorizer")}().authorize({`,
1111
+ ' toolId: "t",',
1112
+ ' executionId: "e",',
1113
+ ' workflowId: "w",',
1114
+ ' stepId: "s",',
1115
+ " });",
1116
+ ' expect(typeof d.allowed).toBe("boolean");',
1117
+ " });",
1118
+ "});",
1119
+ "",
1120
+ ].join("\n");
1121
+ },
1122
+ },
1123
+ "execution-authorizer": {
1124
+ key: "execution-authorizer",
1125
+ spi: "ExecutionAuthorizer",
1126
+ title: "Execution-lifecycle authorizer (start/resume/cancel/query)",
1127
+ wiring: "security: { executionAuthorizer: new <Name>ExecutionAuthorizer() }",
1128
+ spiDeps: ["@loomwork/workflow-runtime"],
1129
+ conformance: ["runExecutionAuthorizerConformance"],
1130
+ index: (name) => {
1131
+ const P = (0, codegen_1.pascal)(name);
1132
+ return [
1133
+ "import type {",
1134
+ " ExecutionAuthorizer,",
1135
+ " ExecutionAuthzRequest,",
1136
+ " ExecutionAuthzVerdict,",
1137
+ '} from "@loomwork/workflow-runtime";',
1138
+ "",
1139
+ "/**",
1140
+ " * An `ExecutionAuthorizer` — gates the execution LIFECYCLE, where a",
1141
+ " * `CapabilityAuthorizer` gates individual effects. Consulted at the entry of",
1142
+ " * `start`, `resume`, `cancel` and the public `loadState` (query).",
1143
+ " *",
1144
+ " * Answer every op: `cancel` and `query` are the two that get forgotten, and an",
1145
+ " * authorizer that allows an op it never considered is a hole that looks like a policy.",
1146
+ " * The verdict is SYNCHRONOUS — it is read before anything is appended, so there is no",
1147
+ " * async escape hatch here by design.",
1148
+ " *",
1149
+ " * `request.principal` is OPTIONAL, and how to treat its absence is your decision:",
1150
+ " * denying anonymous access and allowing it are both real policies; throwing is not,",
1151
+ " * because the caller is left with no verdict to act on.",
1152
+ " */",
1153
+ `export class ${joinNoun(P, "ExecutionAuthorizer")} implements ExecutionAuthorizer {`,
1154
+ " authorize(request: ExecutionAuthzRequest): ExecutionAuthzVerdict {",
1155
+ " // TODO: your policy. Default-deny is the safe placeholder.",
1156
+ " return { allowed: false, reason: `not implemented (${request.op})` };",
1157
+ " }",
1158
+ "}",
1159
+ "",
1160
+ ].join("\n");
1161
+ },
1162
+ test: (name) => {
1163
+ const P = (0, codegen_1.pascal)(name);
1164
+ return [
1165
+ "// The kit checks every lifecycle op is answered, determinism, and that the verdict is",
1166
+ "// synchronous — un-comment once your policy is real:",
1167
+ '// import { runExecutionAuthorizerConformance } from "@loomwork/conformance";',
1168
+ `// import { ${joinNoun(P, "ExecutionAuthorizer")} } from "./index";`,
1169
+ `// runExecutionAuthorizerConformance(() => new ${joinNoun(P, "ExecutionAuthorizer")}());`,
1170
+ "",
1171
+ 'import { describe, expect, it } from "vitest";',
1172
+ `import { ${joinNoun(P, "ExecutionAuthorizer")} } from "./index";`,
1173
+ "",
1174
+ `describe("${joinNoun(P, "ExecutionAuthorizer")}", () => {`,
1175
+ ' it("denies by default until a policy is written", () => {',
1176
+ ` const verdict = new ${joinNoun(P, "ExecutionAuthorizer")}().authorize({`,
1177
+ ' op: "start",',
1178
+ ' executionId: "e",',
1179
+ ' workflowId: "w",',
1180
+ " });",
1181
+ " expect(verdict.allowed).toBe(false);",
1182
+ " });",
1183
+ "});",
1184
+ "",
1185
+ ].join("\n");
1186
+ },
1187
+ },
1188
+ "resume-authenticator": {
1189
+ key: "resume-authenticator",
1190
+ spi: "ResumeAuthenticator",
1191
+ title: "Resume authority (K12.2 — may this event reopen an execution?)",
1192
+ wiring: "security: { resumeAuthenticator: new <Name>ResumeAuthenticator() }",
1193
+ spiDeps: ["@loomwork/workflow-runtime"],
1194
+ conformance: ["runResumeAuthenticatorConformance"],
1195
+ index: (name) => {
1196
+ const P = (0, codegen_1.pascal)(name);
1197
+ return [
1198
+ "import type {",
1199
+ " ResumeAuthenticator,",
1200
+ " ResumeEvent,",
1201
+ " ResumeVerdict,",
1202
+ '} from "@loomwork/workflow-runtime";',
1203
+ "",
1204
+ "/**",
1205
+ " * A `ResumeAuthenticator` — may this event REOPEN a suspended execution (K12.2)?",
1206
+ " *",
1207
+ " * The narrowest of loom's three authority rings, and the one an attacker reaches",
1208
+ " * first: a resume arrives from OUTSIDE the process, where an effect call and a",
1209
+ " * lifecycle call do not. A signed approval from your identity provider is verified",
1210
+ " * here, before the event is appended.",
1211
+ " *",
1212
+ " * Three statuses, and the third is the one to get right:",
1213
+ " *",
1214
+ " * - `verified` — you checked, and it is genuine. Name the `actor` and what",
1215
+ " * `verifiedBy` them, because both land in the log as the record of WHO resumed.",
1216
+ " * - `rejected` — refuse it. Carries a reason, which `ApprovalRejectedError` puts in",
1217
+ " * front of whoever tried.",
1218
+ " * - `not-applicable` — *this event is not mine to judge*. The runtime treats it as no",
1219
+ " * opinion and lets the resume proceed. It is NOT a soft denial: returning it for an",
1220
+ " * event you meant to refuse has allowed that event.",
1221
+ " */",
1222
+ `export class ${joinNoun(P, "ResumeAuthenticator")} implements ResumeAuthenticator {`,
1223
+ " verify(event: ResumeEvent): ResumeVerdict {",
1224
+ " // TODO: verify the signature/token over the WHOLE event — `payload` AND, when present,",
1225
+ " // `discardLegacyAnswers` (ADR 0104: honoured only on a verified event, so a signature",
1226
+ " // over `payload` alone lets a caller attach it). Refusing by default is",
1227
+ " // the safe placeholder — `not-applicable` would let everything through.",
1228
+ ' return { status: "rejected", reason: `not implemented (${event.eventType})` };',
1229
+ " }",
1230
+ "}",
1231
+ "",
1232
+ ].join("\n");
1233
+ },
1234
+ test: (name) => {
1235
+ const P = (0, codegen_1.pascal)(name);
1236
+ return [
1237
+ "// The kit checks the verdict is one of the three statuses, is deterministic, and that",
1238
+ "// a rejection carries a non-empty reason:",
1239
+ '// import { runResumeAuthenticatorConformance } from "@loomwork/conformance";',
1240
+ `// import { ${joinNoun(P, "ResumeAuthenticator")} } from "./index";`,
1241
+ `// runResumeAuthenticatorConformance(() => new ${joinNoun(P, "ResumeAuthenticator")}());`,
1242
+ "",
1243
+ 'import { describe, expect, it } from "vitest";',
1244
+ `import { ${joinNoun(P, "ResumeAuthenticator")} } from "./index";`,
1245
+ "",
1246
+ `describe("${joinNoun(P, "ResumeAuthenticator")}", () => {`,
1247
+ ' it("refuses by default until the verification is written", () => {',
1248
+ ` const verdict = new ${joinNoun(P, "ResumeAuthenticator")}().verify({`,
1249
+ ' eventType: "Approved",',
1250
+ " payload: {},",
1251
+ " });",
1252
+ ' expect(verdict.status).toBe("rejected");',
1253
+ " });",
1254
+ "});",
1255
+ "",
1256
+ ].join("\n");
1257
+ },
1258
+ },
1259
+ "timer-dispatcher": {
1260
+ key: "timer-dispatcher",
1261
+ spi: "TimerDispatcher",
1262
+ title: "Where a due wake runs (#392 placement for timers)",
1263
+ wiring: "timers: { store, dispatcher: new <Name>TimerDispatcher() }",
1264
+ spiDeps: ["@loomwork/workflow-runtime"],
1265
+ conformance: ["runTimerDispatcherConformance"],
1266
+ index: (name) => {
1267
+ const P = (0, codegen_1.pascal)(name);
1268
+ return [
1269
+ 'import type { TimerDispatcher } from "@loomwork/workflow-runtime";',
1270
+ "",
1271
+ "/**",
1272
+ " * A `TimerDispatcher` — where a due wake RUNS (#392).",
1273
+ " *",
1274
+ " * Without one, a timer resumes the execution inside whichever process happens to poll",
1275
+ " * the store; with one, it runs on the lane the execution was STARTED on. That matters",
1276
+ " * when a branch was given its own compute: it otherwise wakes up in the web server.",
1277
+ " *",
1278
+ " * The `target` is a HINT and may name a lane that no longer exists — a node is often",
1279
+ " * recovering an execution precisely BECAUSE its lane went away. Fall back; never",
1280
+ " * strand the wake, which would leave the execution suspended forever.",
1281
+ " *",
1282
+ " * Pass `resumeEvent` through UNCHANGED. The kernel owns the resume vocabulary (K7.5);",
1283
+ " * a transport that reinterprets it changes what the log records.",
1284
+ " */",
1285
+ `export class ${joinNoun(P, "TimerDispatcher")} implements TimerDispatcher {`,
1286
+ " dispatch(",
1287
+ " _pkg: {",
1288
+ " executionId: string;",
1289
+ " workflowId: string;",
1290
+ " flowId: string;",
1291
+ " resumeEvent: { eventType: string; payload: unknown };",
1292
+ " metadata?: Record<string, unknown>;",
1293
+ " },",
1294
+ " _meta?: { target?: string }",
1295
+ " ): Promise<unknown> {",
1296
+ ' throw new Error("not implemented");',
1297
+ " }",
1298
+ "}",
1299
+ "",
1300
+ ].join("\n");
1301
+ },
1302
+ test: (name) => {
1303
+ const P = (0, codegen_1.pascal)(name);
1304
+ return [
1305
+ "// The kit checks the two things a dispatcher is silently wrong about: an unknown",
1306
+ "// target must not strand the wake, and the resume event must pass through unchanged.",
1307
+ '// import { runTimerDispatcherConformance } from "@loomwork/conformance";',
1308
+ `// import { ${joinNoun(P, "TimerDispatcher")} } from "./index";`,
1309
+ `// runTimerDispatcherConformance(() => new ${joinNoun(P, "TimerDispatcher")}());`,
1310
+ "",
1311
+ 'import { describe, expect, it } from "vitest";',
1312
+ `import { ${joinNoun(P, "TimerDispatcher")} } from "./index";`,
1313
+ "",
1314
+ `describe("${joinNoun(P, "TimerDispatcher")}", () => {`,
1315
+ ' it("constructs", () => {',
1316
+ ` expect(new ${joinNoun(P, "TimerDispatcher")}()).toBeDefined();`,
1317
+ " });",
1318
+ "});",
1319
+ "",
1320
+ ].join("\n");
1321
+ },
1322
+ },
1323
+ policy: {
1324
+ key: "policy",
1325
+ spi: "Policy",
1326
+ title: "Learned policy (choose an action from an observation)",
1327
+ // The one seam that is NOT a defineConfig contributor. A policy is consumed
1328
+ // from flow code, deliberately: ADR 0080 keeps the runtime unaware that a
1329
+ // policy exists, so a learned choice can only ever reach a run through
1330
+ // `ctx.decide` — recorded, replayable, auditable.
1331
+ wiring: "decideWithPolicy(ctx, id, new <Name>Policy(), context, actions) — flow code, NOT defineConfig",
1332
+ spiDeps: ["@loomwork/learning"],
1333
+ conformance: ["runPolicyConformance"],
1334
+ index: (name) => {
1335
+ const P = (0, codegen_1.pascal)(name);
1336
+ return [
1337
+ 'import type { Policy, PolicyChoice, PolicyContext } from "@loomwork/learning";',
1338
+ "",
1339
+ "/**",
1340
+ " * A `Policy` — given an observation, choose one of the offered actions.",
1341
+ " *",
1342
+ " * Consumed from flow code via `decideWithPolicy(ctx, id, policy, context, actions)`,",
1343
+ " * which records the choice as a durable decision along with the context and the",
1344
+ " * propensity. There is no `defineConfig` key: the runtime never learns a policy",
1345
+ " * exists (ADR 0080).",
1346
+ " *",
1347
+ " * Two contracts to honour:",
1348
+ " * - `select` must be PURE and cheap. It runs before `ctx.decide`, so on replay it",
1349
+ " * is called and its answer discarded.",
1350
+ " * - `propensity` is `P(chosen | context)` and must be EXACT. Nothing downstream",
1351
+ " * can detect a misreported one, and it biases every later off-policy estimate.",
1352
+ " * Return 1 only if this policy is genuinely deterministic — a log written that",
1353
+ " * way holds no counterfactuals and can never evaluate the next candidate.",
1354
+ " */",
1355
+ `export class ${joinNoun(P, "Policy")}<A = string> implements Policy<A> {`,
1356
+ ` readonly id = "${name}";`,
1357
+ " /** Bump when you retrain. A policy object is one immutable snapshot. */",
1358
+ ' readonly version = "1";',
1359
+ "",
1360
+ " select(context: PolicyContext, actions: readonly A[]): PolicyChoice<A> {",
1361
+ " if (actions.length === 0) {",
1362
+ ` throw new Error("${joinNoun(P, "Policy")}: no actions to choose from");`,
1363
+ " }",
1364
+ " // Replace with your algorithm. Train offline from `trajectories(events)`",
1365
+ " // and ship the result as a NEW version.",
1366
+ " void context;",
1367
+ " return { action: actions[0] as A, propensity: 1 };",
1368
+ " }",
1369
+ "}",
1370
+ "",
1371
+ ].join("\n");
1372
+ },
1373
+ test: (name) => {
1374
+ const P = (0, codegen_1.pascal)(name);
1375
+ return [
1376
+ 'import { runPolicyConformance } from "@loomwork/conformance";',
1377
+ 'import { describe, expect, it } from "vitest";',
1378
+ `import { ${joinNoun(P, "Policy")} } from "./index";`,
1379
+ "",
1380
+ "// The SPI kit: proves the shape and the propensity contract, never the quality",
1381
+ "// of what you choose. Pass observations representative of what you will see.",
1382
+ "runPolicyConformance<string>(",
1383
+ ` () => new ${joinNoun(P, "Policy")}<string>(),`,
1384
+ ' ["a", "b", "c"],',
1385
+ " [{ feature: 0 }, { feature: 1 }]",
1386
+ ");",
1387
+ "",
1388
+ `describe("${joinNoun(P, "Policy")}", () => {`,
1389
+ ' it("chooses from the offered actions", () => {',
1390
+ ` expect(["a", "b"]).toContain(new ${joinNoun(P, "Policy")}<string>().select({}, ["a", "b"]).action);`,
1391
+ " });",
1392
+ "});",
1393
+ "",
1394
+ ].join("\n");
1395
+ },
1396
+ },
1397
+ sandbox: {
1398
+ key: "sandbox",
1399
+ spi: "SandboxBackend",
1400
+ title: "Sandbox backend (run model-written code in your own isolation boundary)",
1401
+ // Like `policy`, not a defineConfig contributor: the effect is registered from flow code, where
1402
+ // the content store that holds the scripts is in hand (ADR 0144 D6).
1403
+ wiring: "registerSandboxBackend(effects, scripts, new <Name>SandboxBackend(), { … }) — flow code, NOT defineConfig",
1404
+ spiDeps: ["@loomwork/sandbox"],
1405
+ conformance: ["runSandboxBackendConformance"],
1406
+ readmeWire: (name, packageName) => [
1407
+ "```ts",
1408
+ 'import { registerSandboxBackend } from "@loomwork/sandbox";',
1409
+ `import { ${joinNoun((0, codegen_1.pascal)(name), "SandboxBackend")} } from "${packageName}";`,
1410
+ "",
1411
+ "// Registered from flow code, next to the content store that holds the scripts.",
1412
+ "// There is no defineConfig key for it.",
1413
+ "const effect = registerSandboxBackend(",
1414
+ " effects,",
1415
+ " scripts,",
1416
+ ` new ${joinNoun((0, codegen_1.pascal)(name), "SandboxBackend")}({ /* endpoint, image, … */ })`,
1417
+ ");",
1418
+ "await effect.warm(); // boot",
1419
+ "await effect.close(); // shutdown",
1420
+ "```",
1421
+ ],
1422
+ readmeExtra: () => [
1423
+ "## Before you ship a remote backend",
1424
+ "",
1425
+ "The effect owns the limits, channels and replay rules. You own the isolation boundary, and",
1426
+ "these are the things that make it one:",
1427
+ "",
1428
+ "- **No service account, no credentials.** The sandbox runs with no identity and no secrets",
1429
+ " of the platform that launches it.",
1430
+ "- **Egress denied at the network layer**, not by the script's language. Declare",
1431
+ ' `network: "none"` only if the network itself refuses.',
1432
+ "- **One request per instance.** Nothing a script wrote survives to the next request",
1433
+ " (`freshPerRun: true`), which may belong to another tenant.",
1434
+ "- **The request timeout is the hard kill.** Enforce `request.limits.wallClockMs` outside the",
1435
+ ' script\'s own process (`wallClock: "hard"`).',
1436
+ "- **Memory and CPU caps** on the instance (`memory: true`, `cpu: true`).",
1437
+ "- **No shared writable state**: no shared volume, cache or temp directory between instances.",
1438
+ "- Report what happened as a `SandboxOutcome`; never throw a typed failure, never return text",
1439
+ " you did not bound — the effect bounds it again, but a log is append-only.",
1440
+ "",
1441
+ 'The generated backend declares the worst case (`network: "open"`, `freshPerRun: false`,',
1442
+ '`wallClock: "soft"`, `memory: false`, `cpu: false`). Each stronger value must be earned by',
1443
+ "`src/index.test.ts` (the conformance kit) passing with it; the kit passes the effect the",
1444
+ "`allowNetwork` / `allowReusedEnvironment` opt-ins exactly when your declaration needs them.",
1445
+ "",
1446
+ "- **`memory: false` / `cpu: false` mean an in-process backend can exhaust the host.** Nothing",
1447
+ " caps a script's heap or CPU, so a runaway or allocation bomb takes down the process that runs",
1448
+ " the backend. Declare them `true` only for a backend that enforces the caps (an engine memory",
1449
+ " limit, or a separate process or instance); never run an uncapped backend inside your host.",
1450
+ "",
1451
+ "A non-JavaScript backend must pass its own `fixtures` to `runSandboxBackendConformance`",
1452
+ "(model them on `SandboxFixtures` / `JAVASCRIPT_FIXTURES` in `@loomwork/conformance`).",
1453
+ "",
1454
+ "A backend that cold-starts an instance per run cannot finish the kit's `fresh per run` case",
1455
+ "(16 sequential runs plus 8 concurrent) inside the default 60 s per-test timeout. Pass a larger",
1456
+ "`timeoutMs` to `runSandboxBackendConformance` (e.g. `300_000`); it applies to every case, and",
1457
+ "the run counts do not change.",
1458
+ ],
1459
+ index: (name) => {
1460
+ const P = (0, codegen_1.pascal)(name);
1461
+ return [
1462
+ "import type {",
1463
+ " SandboxBackend,",
1464
+ " SandboxBackendCapabilities,",
1465
+ " SandboxOutcome,",
1466
+ " SandboxRequest,",
1467
+ '} from "@loomwork/sandbox";',
1468
+ "",
1469
+ `export interface ${joinNoun(P, "SandboxBackendOptions")} {`,
1470
+ " /** Whatever your backend needs — a service URL, an image, a token source. */",
1471
+ " endpoint?: string;",
1472
+ "}",
1473
+ "",
1474
+ "/**",
1475
+ " * A `SandboxBackend` — the ENGINE: run one verified script against one bounded input and",
1476
+ " * report what happened. Register it from flow code with `registerSandboxBackend(...)`;",
1477
+ " * `SandboxEffect` owns the limits, content-address check, channels and replay form",
1478
+ " * (ADR 0144), so do not re-implement any of them here.",
1479
+ " *",
1480
+ " * The capabilities below are the most conservative honest claims: they assume the WORST",
1481
+ " * (open network, a reused environment, a soft clock, no memory or CPU cap). Each stronger",
1482
+ " * value must be EARNED: change it only when the conformance kit (index.test.ts) passes with",
1483
+ ' * it. The effect refuses `network` other than "none" and `freshPerRun: false` unless the',
1484
+ " * operator opts in, and then is not `idempotent`.",
1485
+ " */",
1486
+ `export class ${joinNoun(P, "SandboxBackend")} implements SandboxBackend {`,
1487
+ " readonly capabilities: SandboxBackendCapabilities = {",
1488
+ ' // "javascript" runs the kit\'s built-in fixtures. A backend for any OTHER language must',
1489
+ " // pass its own `fixtures` (one script per case) to runSandboxBackendConformance in",
1490
+ " // index.test.ts — model them on `SandboxFixtures` / `JAVASCRIPT_FIXTURES` in @loomwork/conformance.",
1491
+ ' language: "javascript",',
1492
+ ' network: "open",',
1493
+ ' wallClock: "soft",',
1494
+ " memory: false,",
1495
+ " cpu: false,",
1496
+ " freshPerRun: false,",
1497
+ " };",
1498
+ "",
1499
+ ` constructor(private readonly options: ${joinNoun(P, "SandboxBackendOptions")} = {}) {}`,
1500
+ "",
1501
+ " async evaluate(request: SandboxRequest): Promise<SandboxOutcome> {",
1502
+ " // TODO: run `request.script` with `request.inputJson` parsed as the script's `input`,",
1503
+ " // inside your isolation boundary, honouring `request.limits` and `request.signal`.",
1504
+ " // Report a closed outcome: { ok: true, value } | limit | threw | inputTooLarge | infrastructure.",
1505
+ " //",
1506
+ " // A REMOTE backend (uncomment and adapt; this is the shape, not live code):",
1507
+ " //",
1508
+ " // const res = await fetch(this.options.endpoint, {",
1509
+ ' // method: "POST",',
1510
+ " // body: JSON.stringify({ script: request.script, input: request.inputJson }),",
1511
+ " // signal: request.signal, // the effect aborts through this; a hung call must stop",
1512
+ " // });",
1513
+ " // // Cap what you read: a log is append-only, never buffer an unbounded body.",
1514
+ " // const bytes = await readAtMost(res, MAX_RESPONSE_BYTES); // your helper",
1515
+ " // if (!res.ok || bytes === undefined) {",
1516
+ " // // Any unexpected response is infrastructure, with a message YOU wrote —",
1517
+ " // // never the response body (it is untrusted and may carry secrets).",
1518
+ ' // return { ok: false, kind: "infrastructure", message: `backend returned ${res.status}` };',
1519
+ " // }",
1520
+ " // return parseOutcome(bytes); // map to the closed SandboxOutcome set, or infrastructure",
1521
+ " void request;",
1522
+ " return {",
1523
+ " ok: false,",
1524
+ ' kind: "infrastructure",',
1525
+ ' message: `not implemented (${this.options.endpoint ?? "no endpoint"})`,',
1526
+ " };",
1527
+ " }",
1528
+ "",
1529
+ " /** Optional: pre-warm pools or connections. */",
1530
+ " async warm(): Promise<void> {}",
1531
+ "",
1532
+ " /** Optional: release pools, connections and instances. */",
1533
+ " async close(): Promise<void> {}",
1534
+ "}",
1535
+ "",
1536
+ ].join("\n");
1537
+ },
1538
+ test: (name) => {
1539
+ const P = (0, codegen_1.pascal)(name);
1540
+ return [
1541
+ 'import { runSandboxBackendConformance } from "@loomwork/conformance";',
1542
+ `import { ${joinNoun(P, "SandboxBackend")} } from "./index";`,
1543
+ "",
1544
+ "// The SPI kit: runs ADR 0064's security list through SandboxEffect — echo, throw, syntax",
1545
+ "// error, runaway loop, allocation bomb, no network, fresh per run, abort, outcome hygiene.",
1546
+ "// It fails until evaluate() is real.",
1547
+ "// - A language other than JavaScript: pass `fixtures` (one script per case), modelled on",
1548
+ "// `SandboxFixtures` / `JAVASCRIPT_FIXTURES` in @loomwork/conformance.",
1549
+ "// - A backend that cold-starts per run: pass a larger `timeoutMs` (e.g. 300_000) — the",
1550
+ "// `fresh per run` case makes 16 sequential + 8 concurrent runs under one timeout.",
1551
+ `runSandboxBackendConformance(() => new ${joinNoun(P, "SandboxBackend")}(), { label: "${joinNoun(P, "SandboxBackend")}" });`,
1552
+ "",
1553
+ ].join("\n");
1554
+ },
1555
+ },
1556
+ };
1557
+ exports.SEAM_KEYS = Object.keys(SEAMS);
1558
+ /** seam key → SPI interface name. Consumed by the seam/doc coverage fitness test. */
1559
+ exports.SEAM_SPI = Object.fromEntries(Object.entries(SEAMS).map(([k, v]) => [k, v.spi]));
1560
+ /** seam key → the line that wires it. Consumed by the wiring fitness test. */
1561
+ exports.SEAM_WIRING = Object.fromEntries(Object.entries(SEAMS).map(([k, v]) => [k, v.wiring]));
1562
+ /** Human-readable seam list for `loom plugin seams`. */
1563
+ function listSeams() {
1564
+ // Derived, not a constant: a seam key longer than the column silently breaks the alignment
1565
+ // of every row, which is how `durable-state` landed mid-column the first time.
1566
+ const width = Math.max(...exports.SEAM_KEYS.map((k) => k.length));
1567
+ const rows = exports.SEAM_KEYS.map((k) => {
1568
+ const s = SEAMS[k];
1569
+ const conf = s.conformance.length > 0 ? " (conformance ✓)" : "";
1570
+ return ` ${k.padEnd(width)} ${s.title}${conf}\n ${" ".repeat(width)} wire: ${s.wiring}`;
1571
+ });
1572
+ return `Loom plugin seams (loom plugin new <seam> <name>):\n\n${rows.join("\n")}`;
1573
+ }
1574
+ function pluginPackageJson(seam, packageName, firstParty) {
1575
+ // Per seam: only what the template imports (a peer dependency nothing imports is a
1576
+ // constraint on the consumer for no reason).
1577
+ const spi = [...new Set(seam.spiDeps)];
1578
+ const version = firstParty ? "workspace:*" : "*";
1579
+ const deps = {};
1580
+ for (const d of spi)
1581
+ deps[d] = version;
1582
+ const devDeps = {};
1583
+ if (seam.conformance.length > 0)
1584
+ devDeps["@loomwork/conformance"] = version;
1585
+ devDeps["@types/node"] = "^22.0.0";
1586
+ devDeps.typescript = "^5.6.3";
1587
+ devDeps.vitest = "^2.1.8";
1588
+ // `@types/node` is always declared: a template that writes `Buffer` in a signature needs it
1589
+ // to compile (which is how `loom plugin new keystore` once shipped code nobody could build),
1590
+ // and vitest's config and any real backend reach for node's modules anyway.
1591
+ const pkg = {
1592
+ name: packageName,
1593
+ version: "0.0.0",
1594
+ ...(firstParty ? {} : { private: false }),
1595
+ description: pluginDescription(seam),
1596
+ keywords: firstParty ? ["loom", "loom-plugin"] : ["loom", "loom-plugin", `loom-${seam.key}`],
1597
+ type: "commonjs",
1598
+ main: "./dist/index.js",
1599
+ types: "./dist/index.d.ts",
1600
+ // `types` first (TypeScript matches conditions in order), `default` LAST — it is
1601
+ // the fallback that any condition set matches, and without it an `import`, which
1602
+ // offers ["node", "import"] and never "require", resolves nothing at all.
1603
+ exports: {
1604
+ ".": {
1605
+ types: "./dist/index.d.ts",
1606
+ require: "./dist/index.js",
1607
+ default: "./dist/index.js",
1608
+ },
1609
+ },
1610
+ ...(firstParty ? {} : { engines: { node: ">=20.0.0" }, files: ["dist"] }),
1611
+ scripts: {
1612
+ build: "tsc --build",
1613
+ test: "vitest run",
1614
+ clean: "rm -rf dist tsconfig.tsbuildinfo",
1615
+ ...(firstParty ? { "typecheck:tests": "tsc -p tsconfig.test.json" } : {}),
1616
+ },
1617
+ ...(firstParty ? { dependencies: deps } : { peerDependencies: deps }),
1618
+ devDependencies: firstParty ? devDeps : { ...deps, ...devDeps },
1619
+ };
1620
+ return `${JSON.stringify(pkg, null, 2)}\n`;
1621
+ }
1622
+ /** Node globals a template can reference that only `@types/node` declares. */
1623
+ const NODE_GLOBALS = /\b(Buffer|process|__dirname|NodeJS\.)/;
1624
+ /**
1625
+ * Does this template's CODE use a node global?
1626
+ *
1627
+ * Comments are stripped first, and that is not a detail: two seams describe "the process" in
1628
+ * their prose, and matching those would have added `@types/node` to packages whose code never
1629
+ * touches it. A sweep that reads comments is measuring prose, not code.
1630
+ */
1631
+ function usesNodeGlobals(source) {
1632
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
1633
+ return NODE_GLOBALS.test(code);
1634
+ }
1635
+ /**
1636
+ * One-line description, shared by package.json and the README. Several seam titles begin with the
1637
+ * seam's own word ("Sandbox backend (…)"), which read back-to-back after "A Loom sandbox plugin —".
1638
+ */
1639
+ function pluginDescription(seam) {
1640
+ const norm = (x) => x.toLowerCase().replace(/-/g, " ");
1641
+ return norm(seam.title).startsWith(norm(seam.key))
1642
+ ? `A Loom plugin — ${seam.title}.`
1643
+ : `A Loom ${seam.key} plugin — ${seam.title}.`;
1644
+ }
1645
+ function pluginVitestConfig(firstParty) {
1646
+ if (firstParty) {
1647
+ return 'import { loomTest } from "../../vitest.shared";\n\nexport default loomTest();\n';
1648
+ }
1649
+ return [
1650
+ 'import { defineConfig } from "vitest/config";',
1651
+ "",
1652
+ "export default defineConfig({",
1653
+ ' resolve: { conditions: ["require", "default"] },',
1654
+ ' test: { environment: "node" },',
1655
+ "});",
1656
+ "",
1657
+ ].join("\n");
1658
+ }
1659
+ function pluginTsconfigTest() {
1660
+ return [
1661
+ "{",
1662
+ ' "extends": "./tsconfig.json",',
1663
+ ' "compilerOptions": {',
1664
+ ' "noEmit": true,',
1665
+ ' "composite": false,',
1666
+ ' "declaration": false,',
1667
+ ' "declarationMap": false,',
1668
+ ' "sourceMap": false',
1669
+ " },",
1670
+ ' "include": ["src/**/*.ts"],',
1671
+ ' "exclude": ["node_modules", "dist"]',
1672
+ "}",
1673
+ "",
1674
+ ].join("\n");
1675
+ }
1676
+ function pluginTsconfig(seam, firstParty) {
1677
+ if (firstParty) {
1678
+ const refs = [...new Set(seam.spiDeps)].map((d) => ` { "path": "../../packages/${d.replace("@loomwork/", "")}" }`);
1679
+ return [
1680
+ "{",
1681
+ ' "extends": "../../tsconfig.base.json",',
1682
+ ' "compilerOptions": { "rootDir": "src", "outDir": "dist" },',
1683
+ ' "include": ["src/**/*"],',
1684
+ ' "exclude": ["node_modules", "dist", "**/*.test.ts"],',
1685
+ ' "references": [',
1686
+ refs.join(",\n"),
1687
+ " ]",
1688
+ "}",
1689
+ "",
1690
+ ].join("\n");
1691
+ }
1692
+ return [
1693
+ "{",
1694
+ ' "compilerOptions": {',
1695
+ ' "target": "ES2022",',
1696
+ ' "module": "CommonJS",',
1697
+ ' "moduleResolution": "node",',
1698
+ ' "declaration": true,',
1699
+ ' "strict": true,',
1700
+ ' "esModuleInterop": true,',
1701
+ ' "skipLibCheck": true,',
1702
+ ' "rootDir": "src",',
1703
+ ' "outDir": "dist"',
1704
+ " },",
1705
+ ' "include": ["src/**/*"],',
1706
+ ' "exclude": ["node_modules", "dist", "**/*.test.ts"]',
1707
+ "}",
1708
+ "",
1709
+ ].join("\n");
1710
+ }
1711
+ function pluginReadme(seam, name, packageName, firstParty) {
1712
+ const wire = seam.wiring
1713
+ .replace(/<name>/g, (0, codegen_1.camel)(name))
1714
+ .replace(/<Name>([A-Za-z]*)/g, (_m, noun) => joinNoun((0, codegen_1.pascal)(name), noun));
1715
+ const wireBlock = seam.readmeWire !== undefined
1716
+ ? seam.readmeWire(name, packageName)
1717
+ : [
1718
+ "```ts",
1719
+ 'import { defineConfig } from "@loomwork/app";',
1720
+ `// import { … } from "${packageName}";`,
1721
+ "",
1722
+ `export const app = defineConfig({ ${wire} });`,
1723
+ "```",
1724
+ ];
1725
+ return [
1726
+ `# ${packageName}`,
1727
+ "",
1728
+ pluginDescription(seam).replace(/^A Loom (\S+) plugin/, "A Loom **$1** plugin"),
1729
+ "",
1730
+ "## Wire it",
1731
+ "",
1732
+ ...wireBlock,
1733
+ "",
1734
+ ...(seam.readmeExtra !== undefined ? [...seam.readmeExtra(name), ""] : []),
1735
+ "## Contract",
1736
+ "",
1737
+ "- Implement the SPI; keep the preset a pure function.",
1738
+ "- Contribute only to existing seams — no kernel hook, no reducer into the replay fold.",
1739
+ "- Prove it with `@loomwork/conformance` (see `src/index.test.ts`).",
1740
+ firstParty
1741
+ ? "- First-party: lives in `plugins/`, conformance-gated in Loom CI."
1742
+ : "- Third-party: publish under `loom-plugin-*`, keyword `loom-plugin`; you own the supply-chain trust.",
1743
+ "",
1744
+ "See https://github.com/chhetrisushil/loom/blob/main/docs/plugins.md",
1745
+ "",
1746
+ ].join("\n");
1747
+ }
1748
+ /**
1749
+ * Scaffold a plugin package for `seam`, named `name`, in first- or third-party
1750
+ * shape. Returns a map of relative path → file contents (pure — the CLI writes).
1751
+ */
1752
+ function pluginFiles(seamKey, name, opts) {
1753
+ const seam = SEAMS[seamKey];
1754
+ if (seam === undefined) {
1755
+ throw new Error(`unknown seam "${seamKey}" — expected ${exports.SEAM_KEYS.join(" | ")}`);
1756
+ }
1757
+ // `name` is interpolated straight into a path AND a package name, and the caller writes
1758
+ // every returned key to disk. Unvalidated, `loom plugin new store ../../x` scaffolds
1759
+ // outside the target directory. A plugin name is a single npm-style token; anything with
1760
+ // a separator or a leading dot is a mistake at best.
1761
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
1762
+ throw new Error(`invalid plugin name "${name}" — use lowercase letters, digits and hyphens (no slashes, dots, or leading hyphen)`);
1763
+ }
1764
+ const dir = opts.firstParty ? `plugins/${name}` : `loom-plugin-${name}`;
1765
+ const packageName = opts.firstParty ? `@loomwork/plugin-${name}` : `loom-plugin-${name}`;
1766
+ return {
1767
+ [`${dir}/package.json`]: pluginPackageJson(seam, packageName, opts.firstParty),
1768
+ [`${dir}/tsconfig.json`]: pluginTsconfig(seam, opts.firstParty),
1769
+ [`${dir}/vitest.config.ts`]: pluginVitestConfig(opts.firstParty),
1770
+ ...(opts.firstParty ? { [`${dir}/tsconfig.test.json`]: pluginTsconfigTest() } : {}),
1771
+ [`${dir}/src/index.ts`]: seam.index(name),
1772
+ [`${dir}/src/index.test.ts`]: seam.test(name),
1773
+ [`${dir}/README.md`]: pluginReadme(seam, name, packageName, opts.firstParty),
1774
+ };
1775
+ }
1776
+ /**
1777
+ * The "next:" guidance `loom plugin new` prints, derived from what was generated so it cannot
1778
+ * drift from it: whether the conformance test is live or commented out is read from the test file
1779
+ * itself, and a third-party author is told which `@loomwork/*` packages need resolving.
1780
+ */
1781
+ function pluginNextSteps(files, opts) {
1782
+ const testKey = Object.keys(files).find((f) => f.endsWith("src/index.test.ts")) ?? "";
1783
+ const test = files[testKey] ?? "";
1784
+ const live = /^(?!\s*\/\/).*\brun\w+Conformance\(/m.test(test);
1785
+ const lines = [
1786
+ "",
1787
+ live
1788
+ ? `next: implement the SPI in ${opts.root}/src/index.ts (the conformance test in src/index.test.ts is`
1789
+ : `next: implement the SPI in ${opts.root}/src/index.ts, un-comment the conformance test in`,
1790
+ live
1791
+ ? " already wired and fails until you do), and run your formatter (e.g. biome/prettier)."
1792
+ : " src/index.test.ts, and run your formatter (e.g. biome/prettier) to tidy the scaffold.",
1793
+ ];
1794
+ if (!opts.firstParty) {
1795
+ const pkgKey = Object.keys(files).find((f) => f.endsWith("package.json")) ?? "";
1796
+ const pkg = JSON.parse(files[pkgKey] ?? "{}");
1797
+ const loom = [
1798
+ ...new Set([
1799
+ ...Object.keys(pkg.peerDependencies ?? {}),
1800
+ ...Object.keys(pkg.devDependencies ?? {}),
1801
+ ].filter((d) => d.startsWith("@loomwork/"))),
1802
+ ].sort();
1803
+ lines.push("", `this plugin depends on: ${loom.join(", ")}`, " If you consume an unpublished Loom checkout, your workspace needs a pnpm override", " (a `link:` path into your Loom checkout) for each of them, e.g.", ' "@loomwork/<name>": "link:../loom/packages/<dir>" under pnpm.overrides in the root package.json.');
1804
+ }
1805
+ return lines;
1806
+ }
1807
+ //# sourceMappingURL=plugin.js.map