@tagma/sdk 0.7.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +84 -44
  2. package/dist/bootstrap.d.ts +20 -0
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +21 -11
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/core/dataflow.d.ts.map +1 -1
  7. package/dist/core/dataflow.js +45 -9
  8. package/dist/core/dataflow.js.map +1 -1
  9. package/dist/core/run-context.d.ts +3 -0
  10. package/dist/core/run-context.d.ts.map +1 -1
  11. package/dist/core/run-context.js +2 -0
  12. package/dist/core/run-context.js.map +1 -1
  13. package/dist/core/task-executor.d.ts.map +1 -1
  14. package/dist/core/task-executor.js +46 -84
  15. package/dist/core/task-executor.js.map +1 -1
  16. package/dist/engine.d.ts +6 -0
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +3 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins.d.ts +2 -2
  25. package/dist/plugins.d.ts.map +1 -1
  26. package/dist/ports.d.ts +4 -0
  27. package/dist/ports.d.ts.map +1 -1
  28. package/dist/ports.js +27 -4
  29. package/dist/ports.js.map +1 -1
  30. package/dist/registry.d.ts +10 -4
  31. package/dist/registry.d.ts.map +1 -1
  32. package/dist/registry.js +64 -25
  33. package/dist/registry.js.map +1 -1
  34. package/dist/runtime.d.ts +9 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +8 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +1 -7
  40. package/dist/schema.js.map +1 -1
  41. package/dist/tagma.d.ts +11 -1
  42. package/dist/tagma.d.ts.map +1 -1
  43. package/dist/tagma.js +6 -0
  44. package/dist/tagma.js.map +1 -1
  45. package/dist/validate-raw.d.ts +4 -4
  46. package/dist/validate-raw.d.ts.map +1 -1
  47. package/dist/validate-raw.js +89 -230
  48. package/dist/validate-raw.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bootstrap.ts +23 -14
  51. package/src/core/dataflow.test.ts +8 -9
  52. package/src/core/dataflow.ts +57 -14
  53. package/src/core/run-context.test.ts +12 -0
  54. package/src/core/run-context.ts +4 -0
  55. package/src/core/task-executor.ts +75 -135
  56. package/src/engine-ports-mixed.test.ts +68 -411
  57. package/src/engine-ports.test.ts +37 -341
  58. package/src/engine.ts +8 -0
  59. package/src/index.ts +5 -0
  60. package/src/pipeline-runner.test.ts +5 -9
  61. package/src/plugin-registry.test.ts +138 -1
  62. package/src/plugins.ts +5 -2
  63. package/src/ports.test.ts +80 -0
  64. package/src/ports.ts +36 -4
  65. package/src/registry.ts +81 -26
  66. package/src/runtime.ts +20 -0
  67. package/src/schema-ports.test.ts +47 -197
  68. package/src/schema.ts +1 -7
  69. package/src/tagma.test.ts +72 -1
  70. package/src/tagma.ts +16 -1
  71. package/src/validate-raw-ports.test.ts +80 -393
  72. package/src/validate-raw.ts +90 -250
@@ -3,9 +3,10 @@ import { PluginRegistry } from './registry';
3
3
  import { bootstrapBuiltins } from './bootstrap';
4
4
  import { runPipeline } from './engine';
5
5
  import type { DriverPlugin, TriggerPlugin, PipelineConfig } from './types';
6
- import { mkdtempSync, rmSync } from 'node:fs';
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
+ import type { TagmaPlugin } from './types';
9
10
 
10
11
  function makeDriver(name: string, marker: string[]): DriverPlugin {
11
12
  return {
@@ -100,6 +101,130 @@ describe('PluginRegistry — instance isolation', () => {
100
101
  });
101
102
  });
102
103
 
104
+ describe('PluginRegistry — capability plugins', () => {
105
+ test('registerTagmaPlugin registers multiple capabilities from one package', () => {
106
+ const reg = new PluginRegistry();
107
+ const driver = makeDriver('cap-driver', []);
108
+ const trigger = makeTrigger('cap-trigger', []);
109
+ const plugin: TagmaPlugin = {
110
+ name: 'tagma-plugin-multi',
111
+ capabilities: {
112
+ drivers: { cap_driver: driver },
113
+ triggers: { cap_trigger: trigger },
114
+ },
115
+ };
116
+
117
+ expect(reg.registerTagmaPlugin(plugin)).toEqual([
118
+ { category: 'drivers', type: 'cap_driver', result: 'registered' },
119
+ { category: 'triggers', type: 'cap_trigger', result: 'registered' },
120
+ ]);
121
+ expect(reg.getHandler<DriverPlugin>('drivers', 'cap_driver')).toBe(driver);
122
+ expect(reg.getHandler<TriggerPlugin>('triggers', 'cap_trigger')).toBe(trigger);
123
+ });
124
+
125
+ test('registerTagmaPlugin keeps replacement warnings from the registry path', () => {
126
+ const reg = new PluginRegistry();
127
+ const originalWarn = console.warn;
128
+ const warnings: string[] = [];
129
+ console.warn = (message?: unknown) => {
130
+ warnings.push(String(message));
131
+ };
132
+ try {
133
+ reg.registerPlugin('drivers', 'mock', makeDriver('first', []));
134
+ const result = reg.registerTagmaPlugin({
135
+ name: 'tagma-plugin-replacement',
136
+ capabilities: {
137
+ drivers: { mock: makeDriver('second', []) },
138
+ },
139
+ });
140
+
141
+ expect(result).toEqual([{ category: 'drivers', type: 'mock', result: 'replaced' }]);
142
+ expect(warnings).toContain(
143
+ '[tagma-sdk] registerPlugin: replaced existing drivers/mock - check for duplicate plugin packages claiming the same type.',
144
+ );
145
+ } finally {
146
+ console.warn = originalWarn;
147
+ }
148
+ });
149
+
150
+ test('loadPlugins accepts capability plugin default exports', async () => {
151
+ const dir = mkdtempSync(join(tmpdir(), 'tagma-capability-plugin-'));
152
+ const pluginDir = join(dir, 'node_modules', 'tagma-plugin-capability');
153
+ mkdirSync(pluginDir, { recursive: true });
154
+ writeFileSync(
155
+ join(pluginDir, 'package.json'),
156
+ JSON.stringify({ name: 'tagma-plugin-capability', version: '1.0.0', type: 'module', main: './index.js' }),
157
+ 'utf-8',
158
+ );
159
+ writeFileSync(
160
+ join(pluginDir, 'index.js'),
161
+ [
162
+ 'const driver = {',
163
+ " name: 'cap-driver',",
164
+ ' capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },',
165
+ " async buildCommand() { return { args: ['echo', 'cap'] }; },",
166
+ '};',
167
+ 'const trigger = {',
168
+ " name: 'cap-trigger',",
169
+ ' async watch() {}',
170
+ '};',
171
+ 'export default {',
172
+ " name: 'tagma-plugin-capability',",
173
+ ' capabilities: {',
174
+ ' drivers: { cap_driver: driver },',
175
+ ' triggers: { cap_trigger: trigger },',
176
+ ' },',
177
+ '};',
178
+ '',
179
+ ].join('\n'),
180
+ 'utf-8',
181
+ );
182
+
183
+ try {
184
+ const reg = new PluginRegistry();
185
+ await reg.loadPlugins(['tagma-plugin-capability'], dir);
186
+ expect(reg.hasHandler('drivers', 'cap_driver')).toBe(true);
187
+ expect(reg.hasHandler('triggers', 'cap_trigger')).toBe(true);
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test('loadPlugins rejects legacy plugin module exports', async () => {
194
+ const dir = mkdtempSync(join(tmpdir(), 'tagma-legacy-plugin-'));
195
+ const pluginDir = join(dir, 'node_modules', 'tagma-plugin-legacy');
196
+ mkdirSync(pluginDir, { recursive: true });
197
+ writeFileSync(
198
+ join(pluginDir, 'package.json'),
199
+ JSON.stringify({ name: 'tagma-plugin-legacy', version: '1.0.0', type: 'module', main: './index.js' }),
200
+ 'utf-8',
201
+ );
202
+ writeFileSync(
203
+ join(pluginDir, 'index.js'),
204
+ [
205
+ "export const pluginCategory = 'drivers';",
206
+ "export const pluginType = 'legacy';",
207
+ 'export default {',
208
+ " name: 'legacy',",
209
+ ' capabilities: { sessionResume: false, systemPrompt: false, outputFormat: false },',
210
+ " async buildCommand() { return { args: ['echo', 'legacy'] }; },",
211
+ '};',
212
+ '',
213
+ ].join('\n'),
214
+ 'utf-8',
215
+ );
216
+
217
+ try {
218
+ const reg = new PluginRegistry();
219
+ await expect(reg.loadPlugins(['tagma-plugin-legacy'], dir)).rejects.toThrow(
220
+ /must default-export a TagmaPlugin/,
221
+ );
222
+ } finally {
223
+ rmSync(dir, { recursive: true, force: true });
224
+ }
225
+ });
226
+ });
227
+
103
228
  describe('PluginRegistry — validation', () => {
104
229
  test('rejects unknown category', () => {
105
230
  const reg = new PluginRegistry();
@@ -153,6 +278,18 @@ describe('PluginRegistry — validation', () => {
153
278
  /bun add @tagma\/middleware-audit/,
154
279
  );
155
280
  });
281
+
282
+ test('rejects middleware without enhanceDoc', () => {
283
+ const reg = new PluginRegistry();
284
+ expect(() =>
285
+ reg.registerPlugin('middlewares', 'old', {
286
+ name: 'old',
287
+ async enhance(prompt: string) {
288
+ return prompt;
289
+ },
290
+ } as never),
291
+ ).toThrow(/must export enhanceDoc/);
292
+ });
156
293
  });
157
294
 
158
295
  describe('runPipeline — options.registry isolation', () => {
package/src/plugins.ts CHANGED
@@ -5,14 +5,17 @@ export {
5
5
  PLUGIN_NAME_RE,
6
6
  readPluginManifest,
7
7
  } from './registry';
8
- export type { RegisterResult } from './registry';
8
+ export type { RegisteredCapability, RegisterResult } from './registry';
9
9
  export type {
10
+ CapabilityHandler,
10
11
  PluginCategory,
12
+ PluginCapabilities,
11
13
  PluginModule,
12
14
  PluginManifest,
15
+ PluginSetupContext,
16
+ TagmaPlugin,
13
17
  DriverPlugin,
14
18
  TriggerPlugin,
15
19
  CompletionPlugin,
16
20
  MiddlewarePlugin,
17
21
  } from './types';
18
-
package/src/ports.test.ts CHANGED
@@ -249,6 +249,59 @@ describe('resolveTaskInputs', () => {
249
249
  // ─── resolveTaskBindingInputs ────────────────────────────────────────
250
250
 
251
251
  describe('resolveTaskBindingInputs', () => {
252
+ test('coerces typed unified inputs from upstream outputs', () => {
253
+ const t = task({
254
+ id: 'downstream',
255
+ command: 'echo',
256
+ inputs: {
257
+ id: { from: 't.up.outputs.id', type: 'number', required: true },
258
+ enabled: { value: 'true', type: 'boolean' },
259
+ },
260
+ });
261
+ const upstream = new Map([
262
+ [
263
+ 't.up',
264
+ {
265
+ outputs: { id: '42' },
266
+ stdout: '',
267
+ stderr: '',
268
+ normalizedOutput: null,
269
+ exitCode: 0,
270
+ },
271
+ ],
272
+ ]);
273
+ const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
274
+ expect(res.kind).toBe('ready');
275
+ if (res.kind !== 'ready') return;
276
+ expect(res.inputs).toEqual({ id: 42, enabled: true });
277
+ });
278
+
279
+ test('blocks typed unified input coercion failures', () => {
280
+ const t = task({
281
+ id: 'downstream',
282
+ command: 'echo',
283
+ inputs: {
284
+ id: { from: 't.up.outputs.id', type: 'number', required: true },
285
+ },
286
+ });
287
+ const upstream = new Map([
288
+ [
289
+ 't.up',
290
+ {
291
+ outputs: { id: 'not-a-number' },
292
+ stdout: '',
293
+ stderr: '',
294
+ normalizedOutput: null,
295
+ exitCode: 0,
296
+ },
297
+ ],
298
+ ]);
299
+ const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
300
+ expect(res.kind).toBe('blocked');
301
+ if (res.kind !== 'blocked') return;
302
+ expect(res.typeErrors).toEqual([{ input: 'id', reason: 'expected number, got string' }]);
303
+ });
304
+
252
305
  test('resolves literal values and defaults without requiring ports', () => {
253
306
  const t = task({
254
307
  id: 'downstream',
@@ -387,6 +440,33 @@ describe('extractTaskOutputs', () => {
387
440
  // ─── extractTaskBindingOutputs ───────────────────────────────────────
388
441
 
389
442
  describe('extractTaskBindingOutputs', () => {
443
+ test('coerces typed unified outputs from final-line JSON', () => {
444
+ const r = extractTaskBindingOutputs(
445
+ {
446
+ id: { type: 'number' },
447
+ ok: { from: 'json.success', type: 'boolean' },
448
+ },
449
+ 'log\n{"id":"42","success":"true"}\n',
450
+ '',
451
+ null,
452
+ );
453
+ expect(r.outputs).toEqual({ id: 42, ok: true });
454
+ expect(r.diagnostic).toBeNull();
455
+ });
456
+
457
+ test('diagnoses typed unified output coercion failures', () => {
458
+ const r = extractTaskBindingOutputs(
459
+ {
460
+ id: { type: 'number' },
461
+ },
462
+ '{"id":"nope"}',
463
+ '',
464
+ null,
465
+ );
466
+ expect(r.outputs).toEqual({});
467
+ expect(r.diagnostic).toContain('"id": expected number, got string');
468
+ });
469
+
390
470
  test('extracts loose outputs from final-line JSON by default', () => {
391
471
  const r = extractTaskBindingOutputs(
392
472
  {
package/src/ports.ts CHANGED
@@ -270,6 +270,7 @@ export type BindingInputResolution =
270
270
  readonly kind: 'blocked';
271
271
  readonly missingRequired: readonly string[];
272
272
  readonly ambiguous: readonly { input: string; producers: readonly string[] }[];
273
+ readonly typeErrors: readonly { input: string; reason: string }[];
273
274
  readonly reason: string;
274
275
  };
275
276
 
@@ -287,6 +288,7 @@ export function resolveTaskBindingInputs(
287
288
  const missingRequired: string[] = [];
288
289
  const missingOptional: string[] = [];
289
290
  const ambiguous: { input: string; producers: string[] }[] = [];
291
+ const typeErrors: { input: string; reason: string }[] = [];
290
292
 
291
293
  for (const [name, binding] of Object.entries(bindings)) {
292
294
  let value: unknown;
@@ -321,10 +323,16 @@ export function resolveTaskBindingInputs(
321
323
  continue;
322
324
  }
323
325
 
324
- inputs[name] = value;
326
+ const coerced = coerceBindingValue(binding, value);
327
+ if (coerced.kind === 'error') {
328
+ typeErrors.push({ input: name, reason: coerced.reason });
329
+ continue;
330
+ }
331
+
332
+ inputs[name] = coerced.value;
325
333
  }
326
334
 
327
- if (missingRequired.length > 0 || ambiguous.length > 0) {
335
+ if (missingRequired.length > 0 || ambiguous.length > 0 || typeErrors.length > 0) {
328
336
  const lines: string[] = [];
329
337
  if (missingRequired.length > 0) {
330
338
  lines.push(`missing required binding input(s): ${missingRequired.join(', ')}`);
@@ -335,7 +343,10 @@ export function resolveTaskBindingInputs(
335
343
  `(${amb.producers.join(', ')}) — use "taskId.outputs.${amb.input}"`,
336
344
  );
337
345
  }
338
- return { kind: 'blocked', missingRequired, ambiguous, reason: lines.join('\n') };
346
+ for (const te of typeErrors) {
347
+ lines.push(`binding input "${te.input}": ${te.reason}`);
348
+ }
349
+ return { kind: 'blocked', missingRequired, ambiguous, typeErrors, reason: lines.join('\n') };
339
350
  }
340
351
 
341
352
  return { kind: 'ready', inputs, missingOptional };
@@ -494,6 +505,21 @@ function coerceValue(port: PortDef, raw: unknown): Coercion {
494
505
  }
495
506
  }
496
507
 
508
+ function coerceBindingValue(
509
+ binding: { readonly type?: PortType; readonly enum?: readonly string[] },
510
+ raw: unknown,
511
+ ): Coercion {
512
+ if (!binding.type) return { kind: 'ok', value: raw };
513
+ return coerceValue(
514
+ {
515
+ name: 'binding',
516
+ type: binding.type,
517
+ ...(binding.enum ? { enum: binding.enum } : {}),
518
+ },
519
+ raw,
520
+ );
521
+ }
522
+
497
523
  function describe(v: unknown): string {
498
524
  if (v === null) return 'null';
499
525
  if (Array.isArray(v)) return 'array';
@@ -630,7 +656,13 @@ export function extractTaskBindingOutputs(
630
656
  continue;
631
657
  }
632
658
 
633
- outputs[name] = value;
659
+ const coerced = coerceBindingValue(binding, value);
660
+ if (coerced.kind === 'error') {
661
+ missing.push(`"${name}": ${coerced.reason}`);
662
+ continue;
663
+ }
664
+
665
+ outputs[name] = coerced.value;
634
666
  }
635
667
 
636
668
  return {
package/src/registry.ts CHANGED
@@ -1,24 +1,34 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import type {
4
+ CapabilityHandler,
4
5
  PluginCategory,
5
6
  DriverPlugin,
6
7
  TriggerPlugin,
7
8
  CompletionPlugin,
8
9
  MiddlewarePlugin,
9
10
  PluginManifest,
11
+ TagmaPlugin,
10
12
  } from './types';
11
13
 
12
- type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
14
+ type PluginType = CapabilityHandler;
13
15
 
14
- const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
16
+ const CAPABILITY_CATEGORIES = [
15
17
  'drivers',
16
18
  'triggers',
17
19
  'completions',
18
20
  'middlewares',
19
- ]);
21
+ ] as const satisfies readonly PluginCategory[];
22
+
23
+ const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set(CAPABILITY_CATEGORIES);
20
24
  const PLUGIN_TYPE_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
21
25
 
26
+ export interface RegisteredCapability {
27
+ readonly category: PluginCategory;
28
+ readonly type: string;
29
+ readonly result: RegisterResult;
30
+ }
31
+
22
32
  function singularCategory(category: PluginCategory): string {
23
33
  switch (category) {
24
34
  case 'drivers':
@@ -37,7 +47,7 @@ function singularCategory(category: PluginCategory): string {
37
47
  * registration time rather than crashing the engine mid-run.
38
48
  *
39
49
  * For drivers we materialize `capabilities` and assert each field is a
40
- * boolean �?otherwise a plugin author can write
50
+ * boolean otherwise a plugin author can write
41
51
  * get capabilities() { throw new Error('boom') }
42
52
  * and pass the basic typeof check, then crash preflight when the engine
43
53
  * touches `driver.capabilities.sessionResume`. (R8)
@@ -55,7 +65,7 @@ function validateContract(category: PluginCategory, handler: unknown): void {
55
65
  if (typeof h.buildCommand !== 'function') {
56
66
  throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
57
67
  }
58
- // Materialize capabilities �?this triggers any throwing getter NOW
68
+ // Materialize capabilities this triggers any throwing getter NOW
59
69
  // instead of during preflight.
60
70
  let caps: unknown;
61
71
  try {
@@ -96,22 +106,11 @@ function validateContract(category: PluginCategory, handler: unknown): void {
96
106
  }
97
107
  break;
98
108
  case 'middlewares':
99
- // A middleware must provide at least one entry point. `enhanceDoc` is
100
- // the structured PromptDocument API (preferred); `enhance` is the
101
- // legacy string-in/string-out API the engine still supports for
102
- // v0.x plugins. Requiring only `enhance` here rejects every built-in
103
- // and every plugin written against the current types.
104
- if (typeof h.enhanceDoc !== 'function' && typeof h.enhance !== 'function') {
109
+ if (typeof h.enhanceDoc !== 'function') {
105
110
  throw new Error(
106
- `middlewares plugin "${h.name}" must export enhanceDoc() or enhance()`,
111
+ `middlewares plugin "${h.name}" must export enhanceDoc()`,
107
112
  );
108
113
  }
109
- if (h.enhanceDoc !== undefined && typeof h.enhanceDoc !== 'function') {
110
- throw new Error(`middlewares plugin "${h.name}".enhanceDoc must be a function or undefined`);
111
- }
112
- if (h.enhance !== undefined && typeof h.enhance !== 'function') {
113
- throw new Error(`middlewares plugin "${h.name}".enhance must be a function or undefined`);
114
- }
115
114
  break;
116
115
  }
117
116
  }
@@ -132,7 +131,7 @@ export function isValidPluginName(name: unknown): name is string {
132
131
  *
133
132
  * Returns the strongly-typed manifest if the field is present and
134
133
  * well-formed (`category` is one of the four known categories and `type`
135
- * is a non-empty string). Returns `null` if the field is absent �?that
134
+ * is a non-empty string). Returns `null` if the field is absent that
136
135
  * is the host's signal that the package is a library, not a plugin.
137
136
  *
138
137
  * Throws if the field is present but malformed: that's a packaging bug
@@ -167,6 +166,33 @@ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
167
166
  return { category: category as PluginCategory, type };
168
167
  }
169
168
 
169
+ function isRecord(value: unknown): value is Record<string, unknown> {
170
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
171
+ }
172
+
173
+ function isTagmaPlugin(value: unknown): value is TagmaPlugin {
174
+ if (!isRecord(value)) return false;
175
+ if (typeof value.name !== 'string' || value.name.length === 0) return false;
176
+ if (value.capabilities !== undefined && !isRecord(value.capabilities)) return false;
177
+ if (value.setup !== undefined && typeof value.setup !== 'function') return false;
178
+ return true;
179
+ }
180
+
181
+ function hasSupportedCapabilityMap(plugin: TagmaPlugin): boolean {
182
+ if (!plugin.capabilities) return false;
183
+ const capabilities = plugin.capabilities as Record<string, unknown>;
184
+ return CAPABILITY_CATEGORIES.some((category) => capabilities[category] !== undefined);
185
+ }
186
+
187
+ function moduleDefaultPlugin(name: string, mod: unknown): TagmaPlugin {
188
+ if (!isRecord(mod) || !isTagmaPlugin(mod.default) || !hasSupportedCapabilityMap(mod.default)) {
189
+ throw new Error(
190
+ `Plugin "${name}" must default-export a TagmaPlugin with capabilities maps`,
191
+ );
192
+ }
193
+ return mod.default;
194
+ }
195
+
170
196
  /**
171
197
  * Instance-scoped plugin registry. Each workspace in a multi-tenant sidecar
172
198
  * owns its own PluginRegistry, so installing/uninstalling a driver in one
@@ -214,22 +240,54 @@ export class PluginRegistry {
214
240
  if (wasReplaced) {
215
241
  // D18: surface silent shadowing. Hot-reload flows legitimately replace
216
242
  // handlers; installing two different plugin packages that both claim
217
- // the same (category, type) does not �?the second wins and breaks the
243
+ // the same (category, type) does not the second wins and breaks the
218
244
  // first's consumers with no audit trail. A console.warn is cheap,
219
245
  // respects existing callers that rely on 'replaced', and gives ops a
220
246
  // grep-able signal when registrations collide unexpectedly.
221
247
  console.warn(
222
- `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} �?` +
248
+ `[tagma-sdk] registerPlugin: replaced existing ${category}/${type} - ` +
223
249
  `check for duplicate plugin packages claiming the same type.`,
224
250
  );
225
251
  }
226
252
  return wasReplaced ? 'replaced' : 'registered';
227
253
  }
228
254
 
255
+ registerTagmaPlugin(plugin: TagmaPlugin): RegisteredCapability[] {
256
+ if (!isTagmaPlugin(plugin)) {
257
+ throw new Error('TagmaPlugin must be an object with a non-empty "name"');
258
+ }
259
+ if (!plugin.capabilities) {
260
+ throw new Error(`TagmaPlugin "${plugin.name}" must declare capabilities`);
261
+ }
262
+
263
+ const registered: RegisteredCapability[] = [];
264
+ const capabilities = plugin.capabilities as Record<string, unknown>;
265
+ for (const category of CAPABILITY_CATEGORIES) {
266
+ const handlers = capabilities[category];
267
+ if (handlers === undefined) continue;
268
+ if (!isRecord(handlers)) {
269
+ throw new Error(
270
+ `TagmaPlugin "${plugin.name}" capabilities.${category} must be an object map`,
271
+ );
272
+ }
273
+ for (const [type, handler] of Object.entries(handlers)) {
274
+ const result = this.registerPlugin(category, type, handler as PluginType);
275
+ registered.push({ category, type, result });
276
+ }
277
+ }
278
+
279
+ if (registered.length === 0) {
280
+ throw new Error(
281
+ `TagmaPlugin "${plugin.name}" must declare at least one supported capability`,
282
+ );
283
+ }
284
+ return registered;
285
+ }
286
+
229
287
  /**
230
288
  * Remove a plugin from the in-process registry. Returns true if a plugin
231
289
  * was actually removed. Note: ESM module caching is not affected, so
232
- * re-importing the same file after unregister will yield the cached module �? * callers wanting a fresh load must restart the host process.
290
+ * re-importing the same file after unregister will yield the cached module * callers wanting a fresh load must restart the host process.
233
291
  */
234
292
  unregisterPlugin(category: PluginCategory, type: string): boolean {
235
293
  if (!VALID_CATEGORIES.has(category)) return false;
@@ -289,10 +347,7 @@ export class PluginRegistry {
289
347
  moduleUrl = pathToFileURL(resolved).href;
290
348
  }
291
349
  const mod = await import(moduleUrl);
292
- if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
293
- throw new Error(`Plugin "${name}" must export pluginCategory, pluginType, and default`);
294
- }
295
- this.registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
350
+ this.registerTagmaPlugin(moduleDefaultPlugin(name, mod));
296
351
  }
297
352
  }
298
353
  }
package/src/runtime.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { DriverPlugin, SpawnSpec, TaskResult } from './types';
2
+ import { runCommand, runSpawn, type RunOptions } from './runner';
3
+
4
+ export type { RunOptions };
5
+
6
+ export interface TagmaRuntime {
7
+ runSpawn(
8
+ spec: SpawnSpec,
9
+ driver: DriverPlugin | null,
10
+ options?: RunOptions,
11
+ ): Promise<TaskResult>;
12
+ runCommand(command: string, cwd: string, options?: RunOptions): Promise<TaskResult>;
13
+ }
14
+
15
+ export function bunRuntime(): TagmaRuntime {
16
+ return {
17
+ runSpawn,
18
+ runCommand,
19
+ };
20
+ }