@haoyiyin/workflow 0.2.0 → 0.2.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 (62) hide show
  1. package/package.json +15 -10
  2. package/scripts/postinstall.js +2 -2
  3. package/src/agents/contracts.ts +559 -0
  4. package/src/agents/dispatcher-enhanced.ts +350 -0
  5. package/src/agents/dispatcher.ts +680 -0
  6. package/src/agents/index.ts +48 -0
  7. package/src/agents/resilience.ts +255 -0
  8. package/src/agents/token-budget.ts +83 -0
  9. package/src/agents/types.ts +73 -0
  10. package/src/guard/main-agent.ts +245 -0
  11. package/src/hooks/builtin/index.ts +8 -0
  12. package/src/hooks/builtin/on-error.ts +23 -0
  13. package/src/hooks/builtin/post-execute.ts +40 -0
  14. package/src/hooks/builtin/post-plan.ts +23 -0
  15. package/src/hooks/builtin/pre-execute.ts +30 -0
  16. package/src/hooks/builtin/pre-plan.ts +26 -0
  17. package/src/hooks/index.ts +7 -0
  18. package/src/hooks/loader.ts +98 -0
  19. package/src/hooks/manager.ts +99 -0
  20. package/src/hooks/types-enhanced.ts +38 -0
  21. package/src/hooks/types.ts +35 -0
  22. package/src/index.ts +127 -0
  23. package/src/persistence/index.ts +17 -0
  24. package/src/persistence/plan-md.ts +141 -0
  25. package/src/persistence/state-md.ts +167 -0
  26. package/src/persistence/types.ts +89 -0
  27. package/src/router/classifier.ts +610 -0
  28. package/src/router/guard.ts +483 -0
  29. package/src/router/index.ts +22 -0
  30. package/src/router/router.ts +108 -0
  31. package/src/router/types.ts +127 -0
  32. package/src/skills/agents-md/SKILL.md +45 -0
  33. package/src/skills/agents-md/index.ts +33 -0
  34. package/src/skills/execute-plan/SKILL.md +60 -0
  35. package/src/skills/execute-plan/index.ts +970 -0
  36. package/src/skills/index.ts +13 -0
  37. package/src/skills/quick-task/SKILL.md +54 -0
  38. package/src/skills/quick-task/index.ts +346 -0
  39. package/src/skills/registry.ts +59 -0
  40. package/src/skills/review-diff/SKILL.md +53 -0
  41. package/src/skills/review-diff/index.ts +394 -0
  42. package/src/skills/skill.ts +59 -0
  43. package/src/skills/systematic-debugging/SKILL.md +56 -0
  44. package/src/skills/systematic-debugging/index.ts +404 -0
  45. package/src/skills/tdd/SKILL.md +52 -0
  46. package/src/skills/tdd/index.ts +409 -0
  47. package/src/skills/to-plan/SKILL.md +56 -0
  48. package/src/skills/to-plan/index-enhanced.ts +551 -0
  49. package/src/skills/to-plan/index.ts +586 -0
  50. package/src/skills/types.ts +47 -0
  51. package/src/state/cleanup.ts +118 -0
  52. package/src/state/index.ts +8 -0
  53. package/src/state/manager.ts +96 -0
  54. package/src/state/persistence.ts +77 -0
  55. package/src/state/types.ts +30 -0
  56. package/src/state/validator.ts +78 -0
  57. package/src/types.ts +102 -0
  58. package/src/utils/compress.ts +347 -0
  59. package/src/utils/git.ts +82 -0
  60. package/src/utils/index.ts +6 -0
  61. package/src/utils/logger.ts +23 -0
  62. package/src/utils/paths.ts +55 -0
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Subagent Dispatcher - Spawns isolated subagents to do all real work
3
+ *
4
+ * Constructs prompts from contracts, manages timeouts, validates outputs,
5
+ * and collects results. Uses a pluggable executor backend so the dispatch
6
+ * mechanism can be swapped (process, HTTP, in-process) without changing
7
+ * the orchestration logic.
8
+ */
9
+
10
+ import { nanoid } from 'nanoid';
11
+ import type {
12
+ SubagentConfig,
13
+ SubagentContract,
14
+ SubagentResult,
15
+ SubagentArtifact,
16
+ PermissionSet,
17
+ SubagentRole,
18
+ } from './types.js';
19
+ import type { Logger } from '../types.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Executor abstraction
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface ExecutorOptions {
26
+ id: string;
27
+ model?: string;
28
+ timeout?: number;
29
+ workDir?: string;
30
+ }
31
+
32
+ export interface ExecutorResult {
33
+ output: string;
34
+ tokensUsed: number;
35
+ errors: string[];
36
+ }
37
+
38
+ export interface SubagentExecutor {
39
+ execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult>;
40
+ abort(id: string): Promise<void>;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Default executor (process-based)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export interface ProcessExecutorConfig {
48
+ /** CLI command to invoke (default: "claude") */
49
+ command: string;
50
+ /** CLI arguments appended after the prompt */
51
+ args: string[];
52
+ /** Environment variables to forward */
53
+ env: Record<string, string>;
54
+ }
55
+
56
+ const DEFAULT_PROCESS_CONFIG: ProcessExecutorConfig = {
57
+ command: 'claude',
58
+ args: ['--print', '--output-format', 'json'],
59
+ env: {},
60
+ };
61
+
62
+ /**
63
+ * Creates a SubagentExecutor that spawns a subprocess for each dispatch.
64
+ *
65
+ * The executor writes the prompt to a temp file, invokes the configured
66
+ * CLI command with `--input-file`, collects stdout/stderr, and cleans up.
67
+ */
68
+ export function createProcessExecutor(
69
+ config: Partial<ProcessExecutorConfig> = {},
70
+ ): SubagentExecutor {
71
+ const resolved: ProcessExecutorConfig = {
72
+ ...DEFAULT_PROCESS_CONFIG,
73
+ ...config,
74
+ };
75
+
76
+ const active = new Map<
77
+ string,
78
+ ReturnType<typeof import('child_process').spawn>
79
+ >();
80
+
81
+ return {
82
+ async execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult> {
83
+ const { spawn } = await import('child_process');
84
+ const { writeFile, mkdir, rm } = await import('fs/promises');
85
+ const { join } = await import('path');
86
+ const { tmpdir } = await import('os');
87
+
88
+ const workDir = options.workDir ?? join(tmpdir(), `yi-subagent-${options.id}`);
89
+ await mkdir(workDir, { recursive: true });
90
+
91
+ const promptPath = join(workDir, 'prompt.md');
92
+ await writeFile(promptPath, prompt, 'utf-8');
93
+
94
+ const child = spawn(
95
+ resolved.command,
96
+ [...resolved.args, '--input-file', promptPath],
97
+ {
98
+ env: { ...process.env, ...resolved.env },
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ timeout: options.timeout ? options.timeout * 1000 : undefined,
101
+ },
102
+ );
103
+
104
+ active.set(options.id, child);
105
+
106
+ const outputChunks: Buffer[] = [];
107
+ const errorChunks: Buffer[] = [];
108
+
109
+ child.stdout?.on('data', (chunk: Buffer) => outputChunks.push(chunk));
110
+ child.stderr?.on('data', (chunk: Buffer) => errorChunks.push(chunk));
111
+
112
+ try {
113
+ const exitCode = await new Promise<number | null>((resolve, reject) => {
114
+ child.on('close', resolve);
115
+ child.on('error', reject);
116
+ });
117
+
118
+ active.delete(options.id);
119
+
120
+ const stdout = Buffer.concat(outputChunks).toString('utf-8').trim();
121
+ const stderr = Buffer.concat(errorChunks).toString('utf-8').trim();
122
+
123
+ const errors: string[] = [];
124
+ if (exitCode !== 0) {
125
+ errors.push(`Subprocess exited with code ${exitCode}`);
126
+ }
127
+ if (stderr) {
128
+ errors.push(stderr);
129
+ }
130
+
131
+ // Best-effort cleanup
132
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
133
+
134
+ // Rough token estimate: ~4 chars per token
135
+ const tokensUsed = Math.ceil((prompt.length + stdout.length) / 4);
136
+
137
+ return { output: stdout, tokensUsed, errors };
138
+ } catch (err) {
139
+ active.delete(options.id);
140
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
141
+ return {
142
+ output: '',
143
+ tokensUsed: Math.ceil(prompt.length / 4),
144
+ errors: [String(err)],
145
+ };
146
+ }
147
+ },
148
+
149
+ async abort(id: string): Promise<void> {
150
+ const child = active.get(id);
151
+ if (child) {
152
+ child.kill('SIGTERM');
153
+ active.delete(id);
154
+ }
155
+ },
156
+ };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Constants
161
+ // ---------------------------------------------------------------------------
162
+
163
+ const DEFAULT_TIMEOUT_SECONDS = 300;
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Role-specific system instructions
167
+ // ---------------------------------------------------------------------------
168
+
169
+ const ROLE_INSTRUCTIONS: Record<SubagentRole, string> = {
170
+ explorer:
171
+ 'You are an EXPLORER subagent. Your job is to search and read the codebase. You CANNOT write or modify any files.',
172
+ implementer:
173
+ 'You are an IMPLEMENTER subagent. Your job is to write code, run tests, and verify your changes compile and pass.',
174
+ reviewer:
175
+ 'You are a REVIEWER subagent. Your job is to read code changes and produce a structured review report. You CANNOT write files.',
176
+ planner:
177
+ 'You are a PLANNER subagent. Your job is to analyse requirements and create implementation plans.',
178
+ debugger:
179
+ 'You are a DEBUGGER subagent. Your job is to analyse failures, identify root causes, and propose fixes.',
180
+ verifier:
181
+ 'You are a VERIFIER subagent. Your job is to verify that implemented changes satisfy the original requirements. You CANNOT write files.',
182
+ general:
183
+ 'You are a GENERAL subagent. Complete the assigned task within your permissions.',
184
+ researcher:
185
+ 'You are a RESEARCHER subagent. Your job is to research topics and provide findings.',
186
+ };
187
+
188
+ const PERMISSION_LABELS: Record<keyof PermissionSet, string> = {
189
+ readFiles: 'Read Files',
190
+ searchCode: 'Search Code',
191
+ runCommands: 'Run Commands',
192
+ writeFiles: 'Write Files',
193
+ gitOperations: 'Git Operations',
194
+ };
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Subagent Dispatcher
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export class SubagentDispatcher {
201
+ private readonly executor: SubagentExecutor;
202
+ private readonly logger: Logger;
203
+ private readonly activeIds: Set<string>;
204
+
205
+ constructor(executor: SubagentExecutor, logger: Logger) {
206
+ this.executor = executor;
207
+ this.logger = logger;
208
+ this.activeIds = new Set();
209
+ }
210
+
211
+ // -----------------------------------------------------------------------
212
+ // Public API
213
+ // -----------------------------------------------------------------------
214
+
215
+ /**
216
+ * Dispatch a single subagent with the given config and contract.
217
+ *
218
+ * Constructs a structured prompt from the contract, sends it to the
219
+ * executor, validates the output against the optional output schema,
220
+ * and returns a typed SubagentResult.
221
+ */
222
+ async dispatch(
223
+ config: SubagentConfig,
224
+ contract: SubagentContract,
225
+ ): Promise<SubagentResult> {
226
+ const id = nanoid();
227
+ const startTime = Date.now();
228
+ const timeoutSeconds = config.timeout ?? DEFAULT_TIMEOUT_SECONDS;
229
+
230
+ this.activeIds.add(id);
231
+ this.logger.info(`[Dispatcher] Dispatching ${id} (role=${config.role})`);
232
+
233
+ try {
234
+ const prompt = this.buildPrompt(config, contract);
235
+
236
+ const executorResult = await this.withTimeout(
237
+ this.executor.execute(prompt, {
238
+ id,
239
+ model: config.model,
240
+ timeout: timeoutSeconds,
241
+ }),
242
+ timeoutSeconds * 1000,
243
+ id,
244
+ );
245
+
246
+ const duration = Date.now() - startTime;
247
+
248
+ const validationErrors = this.validateOutput(
249
+ executorResult.output,
250
+ contract.outputSchema,
251
+ );
252
+
253
+ const allErrors = [...executorResult.errors, ...validationErrors];
254
+ const status: SubagentResult['status'] =
255
+ allErrors.length > 0 ? 'failure' : 'success';
256
+
257
+ const artifacts = extractArtifacts(executorResult.output);
258
+
259
+ this.logger.info(
260
+ `[Dispatcher] Subagent ${id} completed: status=${status} duration=${duration}ms tokens=${executorResult.tokensUsed}`,
261
+ );
262
+
263
+ return {
264
+ id,
265
+ role: config.role,
266
+ status,
267
+ output: executorResult.output,
268
+ artifacts,
269
+ tokensUsed: executorResult.tokensUsed,
270
+ duration,
271
+ errors: allErrors,
272
+ };
273
+ } catch (error) {
274
+ const duration = Date.now() - startTime;
275
+ const errorMessage =
276
+ error instanceof Error ? error.message : String(error);
277
+ const isTimeout = errorMessage.includes('timed out');
278
+
279
+ this.logger.error(`[Dispatcher] Subagent ${id} failed: ${errorMessage}`);
280
+
281
+ return {
282
+ id,
283
+ role: config.role,
284
+ status: isTimeout ? 'timeout' : 'failure',
285
+ output: '',
286
+ artifacts: [],
287
+ tokensUsed: 0,
288
+ duration,
289
+ errors: [errorMessage],
290
+ };
291
+ } finally {
292
+ this.activeIds.delete(id);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Dispatch multiple subagents in parallel.
298
+ *
299
+ * All subagents run concurrently. Use when tasks are independent
300
+ * of each other (e.g. exploring multiple directories at once).
301
+ */
302
+ async dispatchParallel(
303
+ configs: readonly SubagentConfig[],
304
+ contracts: readonly SubagentContract[],
305
+ ): Promise<SubagentResult[]> {
306
+ if (configs.length !== contracts.length) {
307
+ throw new Error(
308
+ `configs.length (${configs.length}) must equal contracts.length (${contracts.length})`,
309
+ );
310
+ }
311
+
312
+ this.logger.info(
313
+ `[Dispatcher] Dispatching ${configs.length} subagents in parallel`,
314
+ );
315
+
316
+ const pairs = configs.map((config, i) => ({ config, contract: contracts[i] }));
317
+ const results = await Promise.all(
318
+ pairs.map(({ config, contract }) => this.dispatch(config, contract)),
319
+ );
320
+
321
+ const counts = {
322
+ success: results.filter((r) => r.status === 'success').length,
323
+ failure: results.filter((r) => r.status === 'failure').length,
324
+ timeout: results.filter((r) => r.status === 'timeout').length,
325
+ };
326
+
327
+ this.logger.info(
328
+ `[Dispatcher] Parallel batch complete: ${counts.success} success, ${counts.failure} failure, ${counts.timeout} timeout`,
329
+ );
330
+
331
+ return results;
332
+ }
333
+
334
+ /**
335
+ * Dispatch multiple subagents sequentially.
336
+ *
337
+ * Each subagent receives the output of all previous subagents as
338
+ * additional context. The pipeline stops early if a subagent fails
339
+ * or times out. Use when tasks form a pipeline (e.g. explore ->
340
+ * implement -> review -> verify).
341
+ */
342
+ async dispatchSequential(
343
+ configs: readonly SubagentConfig[],
344
+ contracts: readonly SubagentContract[],
345
+ ): Promise<SubagentResult[]> {
346
+ if (configs.length !== contracts.length) {
347
+ throw new Error(
348
+ `configs.length (${configs.length}) must equal contracts.length (${contracts.length})`,
349
+ );
350
+ }
351
+
352
+ this.logger.info(
353
+ `[Dispatcher] Dispatching ${configs.length} subagents sequentially`,
354
+ );
355
+
356
+ const results: SubagentResult[] = [];
357
+
358
+ for (let i = 0; i < configs.length; i++) {
359
+ const augmentedContract = augmentWithPreviousResults(
360
+ contracts[i],
361
+ results,
362
+ );
363
+ const result = await this.dispatch(configs[i], augmentedContract);
364
+ results.push(result);
365
+
366
+ if (result.status === 'failure' || result.status === 'timeout') {
367
+ this.logger.warn(
368
+ `[Dispatcher] Stopping sequential pipeline at index ${i} (${result.status})`,
369
+ );
370
+ break;
371
+ }
372
+ }
373
+
374
+ return results;
375
+ }
376
+
377
+ /**
378
+ * Abort a running subagent by its ID.
379
+ */
380
+ async abort(id: string): Promise<void> {
381
+ if (!this.activeIds.has(id)) {
382
+ this.logger.warn(`[Dispatcher] Cannot abort ${id}: not active`);
383
+ return;
384
+ }
385
+ await this.executor.abort(id);
386
+ this.activeIds.delete(id);
387
+ this.logger.info(`[Dispatcher] Aborted subagent ${id}`);
388
+ }
389
+
390
+ // -----------------------------------------------------------------------
391
+ // Prompt construction
392
+ // -----------------------------------------------------------------------
393
+
394
+ /**
395
+ * Build a structured subagent prompt from config and contract.
396
+ *
397
+ * The prompt follows the Claude Code agent tool pattern: explicit
398
+ * permissions, owned/read file scopes, isolation instructions,
399
+ * token budget, output format requirements, and the task itself.
400
+ */
401
+ private buildPrompt(
402
+ config: SubagentConfig,
403
+ contract: SubagentContract,
404
+ ): string {
405
+ const sections: string[] = [];
406
+
407
+ sections.push(buildRoleHeader(config.role));
408
+ sections.push(buildPermissionsBlock(contract.permissions));
409
+ sections.push(buildFileScopeBlock(contract.owns, contract.reads));
410
+
411
+ if (config.isolation === 'worktree') {
412
+ sections.push(buildIsolationBlock());
413
+ }
414
+
415
+ sections.push(buildBudgetBlock(config.tokenBudget));
416
+ sections.push(buildOutputSchemaBlock(contract.outputSchema));
417
+ sections.push(buildTaskBlock(contract.prompt));
418
+
419
+ return sections.join('\n\n---\n\n');
420
+ }
421
+
422
+ // -----------------------------------------------------------------------
423
+ // Timeout handling
424
+ // -----------------------------------------------------------------------
425
+
426
+ /**
427
+ * Wraps a promise with a timeout. If the timeout fires first the
428
+ * subagent is aborted and the promise rejects.
429
+ */
430
+ private withTimeout<T>(
431
+ promise: Promise<T>,
432
+ timeoutMs: number,
433
+ id: string,
434
+ ): Promise<T> {
435
+ if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
436
+ return promise;
437
+ }
438
+
439
+ return new Promise<T>((resolve, reject) => {
440
+ const timer = setTimeout(() => {
441
+ this.executor.abort(id).catch(() => undefined);
442
+ reject(new Error(`Subagent ${id} timed out after ${timeoutMs}ms`));
443
+ }, timeoutMs);
444
+
445
+ promise
446
+ .then((result) => {
447
+ clearTimeout(timer);
448
+ resolve(result);
449
+ })
450
+ .catch((error) => {
451
+ clearTimeout(timer);
452
+ reject(error);
453
+ });
454
+ });
455
+ }
456
+
457
+ // -----------------------------------------------------------------------
458
+ // Output validation
459
+ // -----------------------------------------------------------------------
460
+
461
+ /**
462
+ * Validate subagent output against an optional schema.
463
+ * The schema is a Record<string, unknown> where values are type strings
464
+ * like "string", "number", "boolean", "object".
465
+ *
466
+ * Returns an array of validation error messages (empty = valid).
467
+ */
468
+ private validateOutput(
469
+ output: string,
470
+ schema?: Record<string, unknown>,
471
+ ): string[] {
472
+ if (!schema || Object.keys(schema).length === 0) {
473
+ return [];
474
+ }
475
+
476
+ // Try to extract a JSON block from the output
477
+ const jsonMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
478
+ const jsonText = jsonMatch ? jsonMatch[1].trim() : output.trim();
479
+
480
+ let parsed: unknown;
481
+ try {
482
+ parsed = JSON.parse(jsonText);
483
+ } catch {
484
+ return ['Output is not valid JSON'];
485
+ }
486
+
487
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
488
+ return ['Output must be a JSON object'];
489
+ }
490
+
491
+ const obj = parsed as Record<string, unknown>;
492
+ const errors: string[] = [];
493
+
494
+ for (const [key, expectedType] of Object.entries(schema)) {
495
+ if (!(key in obj)) {
496
+ errors.push(`Missing required field: "${key}"`);
497
+ continue;
498
+ }
499
+ const actualType = typeof obj[key];
500
+ if (actualType !== expectedType) {
501
+ errors.push(
502
+ `Field "${key}": expected type ${String(expectedType)}, got ${actualType}`,
503
+ );
504
+ }
505
+ }
506
+
507
+ return errors;
508
+ }
509
+ }
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // Prompt builder helpers (pure functions)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ function buildRoleHeader(role: SubagentRole): string {
516
+ const instruction = ROLE_INSTRUCTIONS[role] ?? ROLE_INSTRUCTIONS.general;
517
+ return `# Role: ${role}\n\n${instruction}`;
518
+ }
519
+
520
+ function buildPermissionsBlock(permissions: PermissionSet): string {
521
+ const lines: string[] = ['## Permissions'];
522
+ for (const key of Object.keys(permissions) as (keyof PermissionSet)[]) {
523
+ const label = PERMISSION_LABELS[key];
524
+ const allowed = permissions[key] ? 'ALLOWED' : 'DENIED';
525
+ lines.push(`- ${label}: ${allowed}`);
526
+ }
527
+ return lines.join('\n');
528
+ }
529
+
530
+ function buildFileScopeBlock(
531
+ owns: readonly string[],
532
+ reads: readonly string[],
533
+ ): string {
534
+ const lines: string[] = ['## File Scope'];
535
+
536
+ lines.push('\n### Owned Files (may modify)');
537
+ if (owns.length === 0) {
538
+ lines.push('- None');
539
+ } else {
540
+ for (const file of owns) {
541
+ lines.push(`- ${file}`);
542
+ }
543
+ }
544
+
545
+ lines.push('\n### Read-only Files');
546
+ if (reads.length === 0) {
547
+ lines.push('- None');
548
+ } else {
549
+ for (const file of reads) {
550
+ lines.push(`- ${file}`);
551
+ }
552
+ }
553
+
554
+ return lines.join('\n');
555
+ }
556
+
557
+ function buildIsolationBlock(): string {
558
+ return [
559
+ '## Isolation: Worktree',
560
+ '',
561
+ 'You are running in an isolated git worktree. All changes you make are',
562
+ 'contained within this worktree. You MUST:',
563
+ '- Create and modify files only within the worktree',
564
+ '- Run tests within the worktree',
565
+ '- Commit your changes before exiting',
566
+ '- Never modify files outside the worktree',
567
+ ].join('\n');
568
+ }
569
+
570
+ function buildBudgetBlock(tokenBudget?: number): string {
571
+ const budget = tokenBudget ?? 32000;
572
+ return [
573
+ '## Token Budget',
574
+ '',
575
+ `You have a budget of **${budget.toLocaleString()} tokens**.`,
576
+ 'Stay within this limit. Be concise. Prefer essential information.',
577
+ ].join('\n');
578
+ }
579
+
580
+ function buildOutputSchemaBlock(schema?: Record<string, unknown>): string {
581
+ if (!schema || Object.keys(schema).length === 0) {
582
+ return '## Output Format\n\nProvide your response as plain text or markdown.';
583
+ }
584
+
585
+ const keys = Object.keys(schema);
586
+ const lines: string[] = [
587
+ '## Required Output Format',
588
+ '',
589
+ 'Your response MUST be a JSON object with these fields:',
590
+ ];
591
+
592
+ for (const key of keys) {
593
+ lines.push(`- \`${key}\`: ${String(schema[key])}`);
594
+ }
595
+
596
+ lines.push(
597
+ '',
598
+ 'Wrap your output in a fenced JSON block:',
599
+ '',
600
+ '```json',
601
+ `{ ${keys.map((k) => `"${k}": ...`).join(', ')} }`,
602
+ '```',
603
+ );
604
+
605
+ return lines.join('\n');
606
+ }
607
+
608
+ function buildTaskBlock(prompt: string): string {
609
+ return `## Task\n\n${prompt}`;
610
+ }
611
+
612
+ /**
613
+ * Augment a contract with output from previous subagents.
614
+ * Used by dispatchSequential to provide pipeline context.
615
+ */
616
+ function augmentWithPreviousResults(
617
+ contract: SubagentContract,
618
+ previousResults: readonly SubagentResult[],
619
+ ): SubagentContract {
620
+ if (previousResults.length === 0) {
621
+ return contract;
622
+ }
623
+
624
+ const context = previousResults
625
+ .map(
626
+ (r) =>
627
+ `### Previous Subagent (role=${r.role}, status=${r.status})\n\n${r.output || '(no output)'}`,
628
+ )
629
+ .join('\n\n');
630
+
631
+ return {
632
+ ...contract,
633
+ prompt: `${contract.prompt}\n\n## Context from Previous Steps\n\n${context}`,
634
+ };
635
+ }
636
+
637
+ /**
638
+ * Extract structured artifacts from subagent output text.
639
+ * Recognises fenced code blocks (JSON, diff) and converts them
640
+ * to SubagentArtifact records.
641
+ */
642
+ function extractArtifacts(output: string): SubagentArtifact[] {
643
+ const artifacts: SubagentArtifact[] = [];
644
+
645
+ const jsonBlocks = output.matchAll(/```json\s*([\s\S]*?)\s*```/g);
646
+ for (const match of jsonBlocks) {
647
+ artifacts.push({ type: 'report', content: match[1].trim() });
648
+ }
649
+
650
+ const diffBlocks = output.matchAll(/```diff\s*([\s\S]*?)\s*```/g);
651
+ for (const match of diffBlocks) {
652
+ artifacts.push({ type: 'diff', content: match[1].trim() });
653
+ }
654
+
655
+ return artifacts;
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // Factory
660
+ // ---------------------------------------------------------------------------
661
+
662
+ export interface DispatcherOptions {
663
+ executor?: SubagentExecutor;
664
+ processConfig?: Partial<ProcessExecutorConfig>;
665
+ }
666
+
667
+ /**
668
+ * Create a SubagentDispatcher with sensible defaults.
669
+ *
670
+ * If no executor is provided, a process-based executor is created
671
+ * using `createProcessExecutor` which spawns a CLI command.
672
+ */
673
+ export function createDispatcher(
674
+ logger: Logger,
675
+ options: DispatcherOptions = {},
676
+ ): SubagentDispatcher {
677
+ const executor =
678
+ options.executor ?? createProcessExecutor(options.processConfig);
679
+ return new SubagentDispatcher(executor, logger);
680
+ }