mixdog 0.9.38 → 0.9.39

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 (114) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +179 -0
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/src/agents/heavy-worker/AGENT.md +10 -7
  28. package/src/agents/reviewer/AGENT.md +6 -4
  29. package/src/agents/worker/AGENT.md +7 -5
  30. package/src/rules/agent/00-common.md +4 -4
  31. package/src/rules/agent/00-core.md +11 -14
  32. package/src/rules/agent/20-skip-protocol.md +3 -3
  33. package/src/rules/agent/30-explorer.md +50 -60
  34. package/src/rules/agent/40-cycle1-agent.md +15 -24
  35. package/src/rules/agent/41-cycle2-agent.md +33 -57
  36. package/src/rules/agent/42-cycle3-agent.md +28 -42
  37. package/src/rules/lead/01-general.md +7 -10
  38. package/src/rules/lead/lead-brief.md +11 -14
  39. package/src/rules/lead/lead-tool.md +6 -5
  40. package/src/rules/shared/01-tool.md +44 -45
  41. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  42. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  43. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  46. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  48. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  55. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  71. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  72. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  73. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  74. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  75. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  76. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  77. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  78. package/src/runtime/shared/tool-primitives.mjs +4 -1
  79. package/src/runtime/shared/tool-status.mjs +27 -0
  80. package/src/runtime/shared/tool-surface.mjs +6 -3
  81. package/src/session-runtime/config-helpers.mjs +14 -0
  82. package/src/session-runtime/context-status.mjs +1 -0
  83. package/src/session-runtime/effort.mjs +6 -2
  84. package/src/session-runtime/model-recency.mjs +5 -2
  85. package/src/session-runtime/runtime-core.mjs +35 -2
  86. package/src/session-runtime/tool-catalog.mjs +34 -0
  87. package/src/standalone/agent-tool/notify.mjs +13 -0
  88. package/src/standalone/agent-tool.mjs +45 -69
  89. package/src/standalone/explore-tool.mjs +6 -7
  90. package/src/tui/App.jsx +31 -0
  91. package/src/tui/app/model-options.mjs +5 -3
  92. package/src/tui/app/model-picker.mjs +12 -24
  93. package/src/tui/app/transcript-window.mjs +1 -0
  94. package/src/tui/components/ToolExecution.jsx +11 -6
  95. package/src/tui/components/TranscriptItem.jsx +1 -1
  96. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  97. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  98. package/src/tui/dist/index.mjs +517 -142
  99. package/src/tui/engine/agent-job-feed.mjs +144 -17
  100. package/src/tui/engine/agent-response-tail.mjs +68 -0
  101. package/src/tui/engine/notification-plan.mjs +16 -0
  102. package/src/tui/engine/session-api.mjs +8 -2
  103. package/src/tui/engine/session-flow.mjs +19 -1
  104. package/src/tui/engine/tool-card-results.mjs +54 -32
  105. package/src/tui/engine/tool-result-status.mjs +75 -21
  106. package/src/tui/engine/turn.mjs +77 -42
  107. package/src/tui/engine.mjs +63 -2
  108. package/src/workflows/bench/WORKFLOW.md +25 -35
  109. package/src/workflows/default/WORKFLOW.md +38 -32
  110. package/src/workflows/solo/WORKFLOW.md +19 -22
  111. package/scripts/_jitter-fuzz.mjs +0 -44410
  112. package/scripts/_jitter-fuzz2.mjs +0 -44400
  113. package/scripts/_jitter-probe.mjs +0 -44397
  114. package/scripts/_jp2.mjs +0 -45614
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // node scripts/internal-comms-bench.mjs
9
9
  // node scripts/internal-comms-bench.mjs --run [--model grok] [--provider P] [--json]
10
- import { execFileSync } from 'node:child_process';
10
+ import { execFileSync, spawnSync } from 'node:child_process';
11
11
  import {
12
12
  copyFileSync,
13
13
  cpSync,
@@ -31,11 +31,16 @@ const HEADLESS = pathToFileURL(resolve(__dir, '../src/headless-role.mjs')).href;
31
31
  const RULE_FILES = [
32
32
  'rules/agent/00-core.md',
33
33
  'rules/agent/00-common.md',
34
+ 'rules/agent/20-skip-protocol.md',
34
35
  'agents/worker/AGENT.md',
35
36
  'agents/heavy-worker/AGENT.md',
36
37
  'agents/reviewer/AGENT.md',
37
38
  'agents/debugger/AGENT.md',
38
39
  'workflows/default/WORKFLOW.md',
40
+ 'workflows/solo/WORKFLOW.md',
41
+ 'workflows/bench/WORKFLOW.md',
42
+ 'rules/lead/01-general.md',
43
+ 'rules/lead/02-channels.md',
39
44
  'rules/lead/lead-tool.md',
40
45
  'rules/lead/lead-brief.md',
41
46
  ];
@@ -48,11 +53,12 @@ const DEFAULT_WORKER_PROMPT =
48
53
  // Identical across variants A/B; only the on-disk rule files differ.
49
54
  const DEFAULT_LEAD_PROMPT = [
50
55
  'You are the Lead in an automation benchmark. A file named math.js already exists in your working directory.',
51
- 'Do NOT edit any file yourself and do NOT call apply_patch yourself. You MUST delegate every implementation step.',
52
- 'Step 1: call the agent tool with agent "worker" and give it a brief to add an exported function add(a, b) that returns a + b to math.js, keeping the existing mul export unchanged, using apply_patch only.',
53
- 'Step 2: after the worker hands off, call the agent tool with agent "reviewer" and give it a brief to verify that math.js exports both add and mul.',
54
- 'Step 3: stop and reply with a one-line outcome plus math.js:line. Do exactly these three steps and nothing else.',
56
+ 'This is the planning turn: give a concise plan only, then wait for a later explicit approval. Do not edit, mutate state, or delegate in this turn.',
57
+ 'After approval, do NOT edit any file yourself or call apply_patch yourself. Delegate every implementation step: first a worker adds exported add(a, b) returning a + b to math.js while preserving mul, then a reviewer verifies both exports. Finally reply with a one-line outcome plus math.js:line.',
55
58
  ].join(' ');
59
+ export const DEFAULT_LEAD_APPROVAL_PROMPT = 'Proceed with the latest plan.';
60
+ export const CHILD_RESULT_START = '__INTERNAL_COMMS_BENCH_RESULT_START__';
61
+ export const CHILD_RESULT_END = '__INTERNAL_COMMS_BENCH_RESULT_END__';
56
62
 
57
63
  const MODEL_ALIASES = {
58
64
  opus: { provider: 'anthropic-oauth', model: 'claude-opus-4-8' },
@@ -383,6 +389,18 @@ function emptyRoleSplit(dataDir) {
383
389
  };
384
390
  }
385
391
 
392
+ function mathTaskCompleted(taskCwd) {
393
+ let source = '';
394
+ try {
395
+ source = readFileSync(join(taskCwd, 'math.js'), 'utf8');
396
+ } catch {
397
+ return false;
398
+ }
399
+ return source !== INITIAL_MATH_JS
400
+ && /export\s+function\s+add\s*\(\s*a\s*,\s*b\s*\)\s*\{\s*return\s+a\s*\+\s*b\s*;?\s*\}/.test(source)
401
+ && /export\s+function\s+mul\s*\(\s*a\s*,\s*b\s*\)\s*\{\s*return\s+a\s*\*\s*b\s*;?\s*\}/.test(source);
402
+ }
403
+
386
404
  function splitTokensByRole(dataDir, rootSessionId) {
387
405
  const tracePath = join(dataDir, 'history', 'agent-trace.jsonl');
388
406
  const rows = readRows(tracePath);
@@ -420,6 +438,21 @@ function splitTokensByRole(dataDir, rootSessionId) {
420
438
  return { tracePath, session_ids: [...treeIds], byRole, total };
421
439
  }
422
440
 
441
+ export function validateLeadRun({ taskCwd, run, split }) {
442
+ const reasons = [];
443
+ if (!run.ok) reasons.push('child did not return a successful benchmark result');
444
+ if (!mathTaskCompleted(taskCwd)) reasons.push('math.js does not contain the approved add and preserved mul implementation');
445
+ if ((split?.byRole?.worker?.usage_rows || 0) < 1) reasons.push('worker participation/usage is missing');
446
+ if ((split?.byRole?.reviewer?.usage_rows || 0) < 1) reasons.push('reviewer participation/usage is missing');
447
+ return { valid: reasons.length === 0, reasons };
448
+ }
449
+
450
+ export function leadModeExitCode(runsMeta, perVariant) {
451
+ const allValid = ['A', 'B'].every((variant) => runsMeta[variant].length > 0 && runsMeta[variant].every((run) => run.valid));
452
+ const anyUsage = ['A', 'B'].some((variant) => perVariant[variant].some((split) => split.total.usage_rows > 0));
453
+ return allValid && anyUsage ? 0 : 1;
454
+ }
455
+
423
456
  function median(values) {
424
457
  const arr = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
425
458
  if (!arr.length) return 0;
@@ -443,54 +476,120 @@ function aggregateVariant(splits) {
443
476
  return out;
444
477
  }
445
478
 
446
- // Drives a REAL Lead session in the variant sandbox (createSession/askSession/
447
- // closeSession from the runtime manager same pattern as output-style-bench).
448
- // The variant rule files take effect through MIXDOG_ROOT; the runtime modules
449
- // themselves are imported from the real PLUGIN_ROOT (identical across variants).
450
- function runLiveLeadDelegation({ pluginRoot, dataDir, taskCwd, prompt, provider, model, effort, fast }) {
451
- const cfgUrl = pathToFileURL(join(PLUGIN_ROOT, 'runtime/agent/orchestrator/config.mjs')).href;
452
- const regUrl = pathToFileURL(join(PLUGIN_ROOT, 'runtime/agent/orchestrator/providers/registry.mjs')).href;
453
- const mgrUrl = pathToFileURL(join(PLUGIN_ROOT, 'runtime/agent/orchestrator/session/manager.mjs')).href;
454
- const driver = [
455
- `import * as cfgMod from ${JSON.stringify(cfgUrl)};`,
456
- `import * as reg from ${JSON.stringify(regUrl)};`,
457
- `import { createSession, askSession, closeSession } from ${JSON.stringify(mgrUrl)};`,
458
- `const config = cfgMod.loadConfig({ secrets: true });`,
459
- `await reg.initProviders(config.providers || {});`,
460
- `const sessionOpts = { provider: ${JSON.stringify(provider)}, model: ${JSON.stringify(model)},`,
461
- ` owner: 'cli', agent: 'lead', lane: 'cli', sourceType: 'lead', sourceName: 'internal-comms-bench-lead',`,
462
- ` cwd: ${JSON.stringify(taskCwd)}, tools: 'full', fast: ${fast ? 'true' : 'false'} };`,
463
- effort ? `sessionOpts.effort = ${JSON.stringify(effort)};` : '',
464
- `const session = createSession(sessionOpts);`,
465
- `let result;`,
466
- `try { result = await askSession(session.id, ${JSON.stringify(prompt)}, null, null, ${JSON.stringify(taskCwd)}); }`,
467
- `finally { try { closeSession(session.id, 'internal-comms-bench-lead'); } catch {} }`,
468
- `process.stdout.write(JSON.stringify({ text: String(result?.text || result?.content || '').trim(), sessionId: session.id }));`,
479
+ // Drives a REAL Lead session in the variant sandbox through the public session
480
+ // runtime, including its deferred standalone tool surface.
481
+ // The variant rule files and public runtime modules take effect through the
482
+ // sandboxed MIXDOG_ROOT.
483
+ export function buildLiveLeadDriver({
484
+ runtimeUrl,
485
+ traceUrl = pathToFileURL(join(PLUGIN_ROOT, 'runtime/agent/orchestrator/agent-trace-io.mjs')).href,
486
+ taskCwd,
487
+ prompt,
488
+ approvalPrompt = DEFAULT_LEAD_APPROVAL_PROMPT,
489
+ provider,
490
+ model,
491
+ effort,
492
+ fast,
493
+ }) {
494
+ return [
495
+ `import { createMixdogSessionRuntime } from ${JSON.stringify(runtimeUrl)};`,
496
+ `import { drainAgentTrace } from ${JSON.stringify(traceUrl)};`,
497
+ `const rt = await createMixdogSessionRuntime({ provider: ${JSON.stringify(provider)}, model: ${JSON.stringify(model)},`,
498
+ ` cwd: ${JSON.stringify(taskCwd)}, toolMode: 'full' });`,
499
+ effort ? `await rt.setEffort(${JSON.stringify(effort)});` : '',
500
+ fast !== undefined ? `await rt.setFast(${JSON.stringify(fast)});` : '',
501
+ `const prePromptTools = rt.toolsStatus?.()?.activeTools || [];`,
502
+ `if (!prePromptTools.includes('agent')) throw new Error('Lead runtime agent tool is not active');`,
503
+ `let busy = false; let kickDeferred = false; let wake = null;`,
504
+ `rt.onNotification(() => { queueMicrotask(() => { kickDeferred = true; if (!busy && wake) wake(); }); return false; });`,
505
+ `const askOnce = async (msg) => { busy = true; try { const { result } = await rt.ask(msg); return result; } finally { busy = false; } };`,
506
+ `const agentsBusy = () => { const jobs = rt.agentStatus?.()?.agentJobs || []; return jobs.some((j) => /running|pending|spawn|queued/i.test(String(j?.status || j?.state || ''))); };`,
507
+ `let result; let sessionId = '';`,
508
+ `try {`,
509
+ ` await askOnce(${JSON.stringify(prompt)});`,
510
+ ` result = await askOnce(${JSON.stringify(approvalPrompt)});`,
511
+ ` const deadline = Date.now() + 30 * 60_000;`,
512
+ ` while (Date.now() < deadline) {`,
513
+ ` if (!kickDeferred) {`,
514
+ ` if (!agentsBusy()) break;`,
515
+ ` const kicked = await new Promise((resolve) => { wake = () => resolve(true); setTimeout(() => resolve(false), 10_000); });`,
516
+ ` wake = null;`,
517
+ ` if (!kicked && !kickDeferred && !agentsBusy()) break;`,
518
+ ` }`,
519
+ ` if (!kickDeferred && !agentsBusy()) break;`,
520
+ ` kickDeferred = false; result = (await askOnce('')) || result;`,
521
+ ` }`,
522
+ ` sessionId = rt.sessionId || rt.id || rt.session?.id || '';`,
523
+ `}`,
524
+ `finally { try { await rt.close('internal-comms-bench-lead'); } catch {} }`,
525
+ `await drainAgentTrace();`,
526
+ `const benchResult = JSON.stringify({ text: String(result?.text || result?.content || '').trim(), sessionId });`,
527
+ `process.stdout.write(${JSON.stringify(CHILD_RESULT_START)} + benchResult + ${JSON.stringify(CHILD_RESULT_END)} + '\\n', () => process.exit(0));`,
469
528
  ].filter(Boolean).join('\n');
470
- const started = Date.now();
471
- let raw = '';
472
- let ok = false;
473
- let sid = null;
529
+ }
530
+
531
+ export function parseLiveLeadResult(stdout) {
532
+ const source = String(stdout || '');
533
+ const start = source.lastIndexOf(CHILD_RESULT_START);
534
+ if (start < 0) return null;
535
+ const jsonStart = start + CHILD_RESULT_START.length;
536
+ const end = source.indexOf(CHILD_RESULT_END, jsonStart);
537
+ if (end < 0) return null;
474
538
  try {
475
- raw = execFileSync('node', ['--input-type=module', '-e', driver], {
476
- encoding: 'utf8',
477
- maxBuffer: 64 * 1024 * 1024,
478
- env: {
479
- ...process.env,
480
- MIXDOG_ROOT: pluginRoot,
481
- MIXDOG_DATA_DIR: dataDir,
482
- },
483
- });
484
- const j = raw.lastIndexOf('{');
485
- if (j >= 0) { try { sid = JSON.parse(raw.slice(j)).sessionId || null; } catch { /* tail */ } }
486
- ok = !!sid;
487
- } catch (e) {
488
- raw = String(e.stdout || '') + String(e.stderr || '');
489
- const j = raw.lastIndexOf('{');
490
- if (j >= 0) { try { sid = JSON.parse(raw.slice(j)).sessionId || null; } catch { /* tail */ } }
491
- ok = false;
539
+ const result = JSON.parse(source.slice(jsonStart, end));
540
+ return typeof result?.sessionId === 'string' && result.sessionId ? result : null;
541
+ } catch {
542
+ return null;
492
543
  }
493
- return { sessionId: sid || extractSessionId(raw), ms: Date.now() - started, ok, raw };
544
+ }
545
+
546
+ export function runLiveLeadDelegation({
547
+ pluginRoot,
548
+ dataDir,
549
+ taskCwd,
550
+ prompt,
551
+ provider,
552
+ model,
553
+ effort,
554
+ fast,
555
+ runtimeUrls = null,
556
+ }) {
557
+ const runtimeUrl = runtimeUrls?.runtimeUrl || pathToFileURL(join(pluginRoot, 'mixdog-session-runtime.mjs')).href;
558
+ const traceUrl = runtimeUrls?.traceUrl || pathToFileURL(join(pluginRoot, 'runtime/agent/orchestrator/agent-trace-io.mjs')).href;
559
+ const driver = buildLiveLeadDriver({
560
+ runtimeUrl,
561
+ traceUrl,
562
+ taskCwd,
563
+ prompt,
564
+ provider,
565
+ model,
566
+ effort,
567
+ fast,
568
+ });
569
+ const started = Date.now();
570
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', driver], {
571
+ encoding: 'utf8',
572
+ maxBuffer: 64 * 1024 * 1024,
573
+ env: {
574
+ ...process.env,
575
+ MIXDOG_ROOT: pluginRoot,
576
+ MIXDOG_DATA_DIR: dataDir,
577
+ },
578
+ });
579
+ const stdout = String(child.stdout || '');
580
+ const stderr = String(child.stderr || '');
581
+ const result = parseLiveLeadResult(stdout);
582
+ return {
583
+ sessionId: result?.sessionId || null,
584
+ ms: Date.now() - started,
585
+ ok: child.status === 0 && !child.error && !!result,
586
+ raw: stdout + stderr,
587
+ stdout,
588
+ stderr,
589
+ exitCode: child.status,
590
+ signal: child.signal || null,
591
+ error: child.error ? String(child.error.message || child.error) : null,
592
+ };
494
593
  }
495
594
 
496
595
  function fmtDeltaEntry(entry) {
@@ -505,12 +604,13 @@ function runLeadMode({ route, effort, fast, repeat, jsonMode, leadPrompt }) {
505
604
  const sandboxRoot = mkdtempSync(join(REPO_ROOT, '.tmp-internal-comms-bench-lead-'));
506
605
  const perVariant = { A: [], B: [] };
507
606
  const runsMeta = { A: [], B: [] };
607
+ let invalidRun = null;
508
608
  try {
509
609
  const pluginRoots = {
510
610
  A: materializePluginRoot('A', sandboxRoot),
511
611
  B: materializePluginRoot('B', sandboxRoot),
512
612
  };
513
- for (let i = 0; i < repeat; i += 1) {
613
+ runLoop: for (let i = 0; i < repeat; i += 1) {
514
614
  for (const variant of ['A', 'B']) {
515
615
  const taskCwd = prepareTaskCwd(sandboxRoot);
516
616
  const dataDir = prepareDataDir(sandboxRoot, `${variant}-r${i}`, realDataDir, userUnified, route.provider);
@@ -526,15 +626,44 @@ function runLeadMode({ route, effort, fast, repeat, jsonMode, leadPrompt }) {
526
626
  fast,
527
627
  });
528
628
  const split = run.sessionId ? splitTokensByRole(dataDir, run.sessionId) : emptyRoleSplit(dataDir);
529
- perVariant[variant].push(split);
530
- runsMeta[variant].push({ ok: run.ok, ms: run.ms, sessionId: run.sessionId, total: split.total.total_tokens });
531
- process.stderr.write(`[internal-comms-bench] -> ${run.ok ? 'ok' : 'FAIL'} ${Math.round(run.ms / 1000)}s session=${run.sessionId || '(none)'} total=${split.total.total_tokens} lead=${split.byRole.lead.total_tokens} worker=${split.byRole.worker.total_tokens} reviewer=${split.byRole.reviewer.total_tokens} other=${split.byRole.other.total_tokens}\n`);
629
+ const validation = validateLeadRun({ taskCwd, run, split });
630
+ if (validation.valid) perVariant[variant].push(split);
631
+ const meta = {
632
+ ok: run.ok,
633
+ valid: validation.valid,
634
+ reasons: validation.reasons,
635
+ ms: run.ms,
636
+ sessionId: run.sessionId,
637
+ total: split.total.total_tokens,
638
+ diagnostics: {
639
+ stdout: run.stdout,
640
+ stderr: run.stderr,
641
+ exitCode: run.exitCode,
642
+ signal: run.signal,
643
+ error: run.error,
644
+ },
645
+ };
646
+ runsMeta[variant].push(meta);
647
+ process.stderr.write(`[internal-comms-bench] -> ${validation.valid ? 'ok' : 'FAIL'} ${Math.round(run.ms / 1000)}s session=${run.sessionId || '(none)'} total=${split.total.total_tokens} lead=${split.byRole.lead.total_tokens} worker=${split.byRole.worker.total_tokens} reviewer=${split.byRole.reviewer.total_tokens} other=${split.byRole.other.total_tokens}${validation.valid ? '' : ` reasons=${validation.reasons.join('; ')}`}\n`);
648
+ if (!validation.valid && (run.stdout || run.stderr || run.error)) {
649
+ process.stderr.write(`[internal-comms-bench] child diagnostics variant=${variant} stdout=${JSON.stringify(run.stdout)} stderr=${JSON.stringify(run.stderr)} error=${JSON.stringify(run.error)}\n`);
650
+ }
651
+ if (!validation.valid) {
652
+ invalidRun = { variant, index: i, reasons: validation.reasons };
653
+ break runLoop;
654
+ }
532
655
  }
533
656
  }
534
657
  } finally {
535
658
  rmSync(sandboxRoot, { recursive: true, force: true });
536
659
  }
537
660
 
661
+ if (invalidRun) {
662
+ process.stderr.write(`[internal-comms-bench] aborting before aggregation: invalid variant=${invalidRun.variant} run=${invalidRun.index + 1} reasons=${invalidRun.reasons.join('; ')}\n`);
663
+ process.exit(1);
664
+ return;
665
+ }
666
+
538
667
  const aggA = aggregateVariant(perVariant.A);
539
668
  const aggB = aggregateVariant(perVariant.B);
540
669
  const deltaMedian = {};
@@ -574,9 +703,7 @@ function runLeadMode({ route, effort, fast, repeat, jsonMode, leadPrompt }) {
574
703
  console.log('delta B-vs-A (mean): ' + [...ROLES, 'total'].map((r) => `${r}=${fmtDeltaEntry(deltaMean[r])}`).join(' '));
575
704
  }
576
705
 
577
- const allOk = runsMeta.A.every((r) => r.ok) && runsMeta.B.every((r) => r.ok);
578
- const anyUsage = perVariant.A.some((s) => s.total.usage_rows > 0) || perVariant.B.some((s) => s.total.usage_rows > 0);
579
- process.exit(allOk && anyUsage ? 0 : 1);
706
+ process.exit(leadModeExitCode(runsMeta, perVariant));
580
707
  }
581
708
 
582
709
  function pctChange(b, a) {
@@ -724,4 +851,4 @@ function main() {
724
851
  process.exit(ok ? 0 : 1);
725
852
  }
726
853
 
727
- main();
854
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
@@ -19,38 +19,80 @@ function assert(condition, message) {
19
19
  function readSrc(...parts) {
20
20
  return readFileSync(join(root, 'src', ...parts), 'utf8');
21
21
  }
22
- // Rules wrap across lines; collapse whitespace so phrase asserts are line-agnostic.
23
- function flat(text) {
24
- return String(text || '').replace(/\s+/g, ' ');
22
+ function normalize(text) {
23
+ return String(text || '').replace(/\s+/g, ' ').trim();
24
+ }
25
+ function rawBlock(text, start, end, label) {
26
+ const source = String(text);
27
+ const from = source.indexOf(start);
28
+ if (from < 0) throw new Error(`${label}: missing start marker ${start}`);
29
+ const finish = source.indexOf(end, from + start.length);
30
+ if (finish < 0) throw new Error(`${label}: missing end marker ${end}`);
31
+ return source.slice(from, finish);
32
+ }
33
+ function block(text, start, end, label) {
34
+ return normalize(rawBlock(text, start, end, label));
35
+ }
36
+ function bodyAfterFrontmatter(text, label) {
37
+ const source = String(text);
38
+ const opening = source.match(/^---[ \t]*\r?\n/);
39
+ if (!opening) throw new Error(`${label}: missing frontmatter opening fence`);
40
+ const closing = source.slice(opening[0].length).match(/^---[ \t]*(?:\r?\n|$)/m);
41
+ if (!closing) throw new Error(`${label}: missing frontmatter closing fence`);
42
+ return source.slice(opening[0].length + closing.index + closing[0].length);
43
+ }
44
+ function roleBody(text, label) {
45
+ return normalize(bodyAfterFrontmatter(text, label));
46
+ }
47
+ function frontmatter(text, label) {
48
+ const source = String(text);
49
+ const opening = source.match(/^---[ \t]*\r?\n/);
50
+ if (!opening) throw new Error(`${label}: missing frontmatter opening fence`);
51
+ const closing = source.slice(opening[0].length).match(/^---[ \t]*(?:\r?\n|$)/m);
52
+ if (!closing) throw new Error(`${label}: missing frontmatter closing fence`);
53
+ return source.slice(opening[0].length, opening[0].length + closing.index);
25
54
  }
26
55
 
27
- // --- Lead brief contract: canonical in lead-brief.md, referenced from WORKFLOW -
56
+ // --- Compact rule contracts -------------------------------------------------
28
57
  const workflow = readSrc('workflows', 'default', 'WORKFLOW.md');
29
58
  const leadBrief = readSrc('rules', 'lead', 'lead-brief.md');
59
+ const solo = readSrc('workflows', 'solo', 'WORKFLOW.md');
60
+ const bench = readSrc('workflows', 'bench', 'WORKFLOW.md');
61
+ const general = readSrc('rules', 'lead', '01-general.md');
62
+ const leadTool = readSrc('rules', 'lead', 'lead-tool.md');
63
+ const core = readSrc('rules', 'agent', '00-core.md');
64
+ const common = readSrc('rules', 'agent', '00-common.md');
65
+ const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
30
66
  const BRIEF_FIELDS = ['Goal:', 'Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
31
- // Canonical brief contract lives in lead-brief.md (Lead brief contract).
32
- assert(/minimum characters, maximum information/i.test(flat(leadBrief)), 'lead-brief.md: brief must state min-char/max-info principle');
67
+ const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
68
+ function requireAll(text, label, patterns) {
69
+ for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
70
+ }
71
+ assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must state min-char/max-info principle');
33
72
  for (const field of BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing labeled field ${field}`);
34
73
  assert(leadBrief.includes('Stop:'), 'lead-brief.md: brief must add Stop: for heavy-worker bound');
35
- assert(/role-known|already (?:owns|knows)|wasted cost|wasted/i.test(flat(leadBrief)), 'lead-brief.md: brief must ban restating known rules/background as cost');
36
- assert(/authoritative/i.test(flat(leadBrief)), 'lead-brief.md: brief must state spec-file precedence over summary');
37
- // WORKFLOW.md must not duplicate the field list; it defers to the lead brief contract.
38
- assert(/lead brief contract/i.test(flat(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
74
+ assert(/role-known|already (?:owns|knows)|wasted cost|wasted/i.test(normalize(leadBrief)), 'lead-brief.md: brief must ban restating known rules/background as cost');
75
+ assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
76
+ requireAll(leadBrief, 'Lead brief lifecycle', [
77
+ /full brief only for fresh spawn\/`respawned: true`/, /live follow-ups are delta/,
78
+ /dead-tag send is cold: re-supply anchors/, /never `send` mid-run/,
79
+ /batch one follow-up after completion/, /interrupt only to cancel/,
80
+ /agent communication is english/,
81
+ ]);
82
+ assert(/lead brief contract/i.test(normalize(workflow)), 'WORKFLOW.md: must defer to the lead brief contract');
39
83
  assert(!BRIEF_FIELDS.every((field) => workflow.includes(field)), 'WORKFLOW.md: must not duplicate the full brief field list');
40
84
 
41
- // --- Agent handoff contract (00-core.md) -----------------------------------
42
- const core = readSrc('rules', 'agent', '00-core.md');
43
- assert(/minimum characters, maximum information/i.test(flat(core)), '00-core: handoff must state token-optimized principle');
44
85
  assert(/fragments/i.test(core), '00-core: handoff must require fragments');
45
86
  assert(/file:line/i.test(core), '00-core: handoff must anchor evidence to file:line');
46
- assert(/Banned as pure cost/i.test(flat(core)), '00-core: handoff must list banned cost items');
47
87
  for (const banned of ['headings', 'tables', 'narration', 'raw logs', 'next-checks']) {
48
88
  assert(core.toLowerCase().includes(banned), `00-core: banned list missing ${banned}`);
49
89
  }
50
- const common = readSrc('rules', 'agent', '00-common.md');
51
90
  assert(/Public Agent Constraints/i.test(common), '00-common: must be titled public-only constraints');
52
- assert(/git operations deferred to Lead/i.test(common), '00-common: must refuse git/Ship');
91
+ assert(/git operations deferred to Lead/i.test(normalize(common)), '00-common: must refuse git/Ship');
53
92
  assert(/Overflow goes to a file/i.test(common), '00-common: must keep overflow-to-file rule');
93
+ requireAll(common, 'Public-agent shell', [
94
+ /shell only verifies own edits/, /no exploration, install, or state change beyond brief/,
95
+ ]);
54
96
 
55
97
  // --- Per-role output contracts --------------------------------------------
56
98
  const roles = {
@@ -59,19 +101,125 @@ const roles = {
59
101
  'reviewer/AGENT.md': readSrc('agents', 'reviewer', 'AGENT.md'),
60
102
  'debugger/AGENT.md': readSrc('agents', 'debugger', 'AGENT.md'),
61
103
  };
62
- for (const [name, text] of Object.entries(roles)) {
63
- assert(/file:line/i.test(text), `${name}: role output must anchor to file:line`);
64
- assert(/fragments|no report bloat|no prose|no narration|no preamble/i.test(flat(text)), `${name}: role output must forbid prose bloat`);
104
+
105
+ // Semantic contracts deliberately avoid prose snapshots.
106
+ function snapshot(actual, expected, label) {
107
+ assert(normalize(actual) === normalize(expected), `${label}: canonical snapshot changed`);
108
+ }
109
+ assert(/^agents:\s*$/m.test(frontmatter(solo, 'Solo workflow')), 'Solo: agents frontmatter must be empty');
110
+ requireAll(workflow, 'Default approval', [
111
+ /read-only investigation\/planning while consulting/, /later explicit user message after the latest plan/,
112
+ /initial\/additional\/changed requests reset planning/, /scope change needs a revised plan and fresh approval/,
113
+ /no edits, state mutation, or delegation/,
114
+ ]);
115
+ requireAll(workflow, 'Default routing', [
116
+ /delegate by default/, /only coordinates, does git, or an obvious 1-edit\/ 1-check change/,
117
+ /implementation\/research\/debugging to its matching agent/, /indicators, not automatic categories/,
118
+ /worker: bounded established path, low local risk\/coupling\/verification, clear local check/,
119
+ /heavy worker: high risk\/coupling\/verification \(including any high-risk scope\), or coupled staged work needing coordinated verification/,
120
+ /reviewer verifies an implementation/, /debugger handles requested debugging or root cause after a failed fix/,
121
+ ]);
122
+ requireAll(workflow, 'Default lifecycle', [
123
+ /draft before any implementation/, /parallel gain exceeds coordination\/merge cost/,
124
+ /all in one turn, with no count cap/, /real dependency, overlapping write, or inseparable coupling/,
125
+ /after async spawn, end the turn/, /every delegated implementation gets one reviewer/,
126
+ /lead integration\/cross-scope verification in parallel/, /high-risk scopes add distinct lenses/,
127
+ /original live session/, /loop fix -> re-verify \(same reviewer \+ lead re-check\) until clean/,
128
+ /bug surviving 2\+ fix cycles/, /agent reports relay.*as in-progress, never conclusions/,
129
+ /final \(not interim\) report/, /never forward raw agent output/,
130
+ /explicit user request after issue-free feedback/, /outcome\/direction change, pause and re-consult/,
131
+ ]);
132
+ requireAll(solo, 'Solo lifecycle', [
133
+ /no edits or state mutation/, /never spawn, send, delegate, or ask agents to work/,
134
+ /read-only investigation\/planning while consulting/,
135
+ /later explicit user message after the latest plan/,
136
+ /initial\/additional\/changed requests reset planning/,
137
+ /scope change needs a revised plan and fresh approval/,
138
+ /checks\/fixes directly until clean or reports a blocker/, /final \(not interim\) report/,
139
+ /interim updates are in-progress, never conclusions/,
140
+ /issue-free user feedback/,
141
+ ]);
142
+ requireAll(bench, 'Bench lifecycle', [
143
+ /never wait for approval or ask questions/, /verified complete or provably blocked/,
144
+ /maximum independent scopes/, /spawning every scope in one turn/,
145
+ /build\/test-green gate/, /no polling, guessing, or dependent work/,
146
+ /one reviewer per implementation scope/, /never deferred\/batched/,
147
+ /fact-check agent responses and cross-check implementation\/review yourself before acting/,
148
+ /return fixes to the original scope/, /loop verify -> fix -> re-verify until clean/,
149
+ /skip only simple, low-risk review/, /hard blocked/, /outcome and evidence/,
150
+ ]);
151
+ requireAll(leadTool, 'Lead tools', [
152
+ /write-role agents self-verify/, /cross-scope verification.*benches.*all git/,
153
+ /workflow permits delegation/, /no-delegation workflow.*controls/,
154
+ ]);
155
+ requireAll(general, 'General safety', [
156
+ /identify as mixdog\/current coding agent/, /destructive\/hard-to-reverse action needs explicit confirmation/,
157
+ /never push, build, deploy without explicit user request/, /implementation approval is not deploy approval/,
158
+ ]);
159
+ requireAll(skip, 'Silent skip', [
160
+ /webhook-handler/, /scheduler-task/, /label-only, duplicate\/dedup, no action needed\/report/,
161
+ /whole response.*\[meta:silent\]/,
162
+ ]);
163
+
164
+ const roleSnapshots = {
165
+ 'worker/AGENT.md': `# Worker
166
+ Scoped implementation agent.
167
+
168
+ Own only the bounded responsibility assigned in the brief. Trust its
169
+ \`file:line\` anchors; do only minimal targeted discovery, then make the
170
+ smallest coherent patch. No drive-by cleanup or scope expansion.
171
+
172
+ EDIT-FIRST DISCIPLINE. Patch promptly rather than repeating read-only turns;
173
+ stop and report blocked when the assigned scope cannot be completed.
174
+
175
+ Self-verify with a targeted check (for example, \`node --check\` or a focused
176
+ test), then report the changed \`file:line\` and stop.`,
177
+ 'heavy-worker/AGENT.md': `# Heavy Worker
178
+ Own the assigned implementation slice through staged delivery.
179
+
180
+ Break work into bounded, dependency-aware slices and execute them in sequence.
181
+ At each checkpoint, run the narrowest relevant test or build before expanding
182
+ the slice. Keep the smallest coherent change; control blast radius rather than
183
+ rewriting adjacent systems.
184
+
185
+ EDIT-FIRST DISCIPLINE. Patch incrementally and stop at the first explicit
186
+ boundary: unclear ownership, a missing dependency, or growing blast radius.
187
+ Do not cross that boundary without a new bounded assignment; report blocked
188
+ work with the relevant file:line.
189
+
190
+ Self-verify each checkpoint and the final slice with shell (targeted test/build).`,
191
+ 'reviewer/AGENT.md': `# Reviewer
192
+ Independent regression/risk review agent.
193
+
194
+ Review the approved intent, diff, and tests with independent judgment. Prioritize
195
+ actionable correctness, regression, security, and verification risks; inspect
196
+ affected boundaries. Do not reimplement the change or report non-risky nits.
197
+ Report findings first, severity-ordered, with one line per \`file:line\`. If clean,
198
+ say so in one line and include only material residual risk.`,
199
+ 'debugger/AGENT.md': `# Debugger
200
+ Root-cause analysis agent.
201
+
202
+ Smallest confirmed cause chain before fixes. Return likely cause, evidence
203
+ (\`file:line\`), smallest next check/fix. Mark confirmed facts vs inferences;
204
+ avoid broad speculation.
205
+
206
+ Converge, don't sweep: when new evidence stops accruing, report the best
207
+ cause chain so far.`,
208
+ };
209
+ for (const [name, expected] of Object.entries(roleSnapshots)) {
210
+ const text = roles[name];
211
+ const permission = text.match(/^permission:\s*(.+)$/m)?.[1];
212
+ assert(permission === (name === 'worker/AGENT.md' || name === 'heavy-worker/AGENT.md' ? 'read-write' : 'read'), `${name}: permission contract changed`);
213
+ snapshot(roleBody(text, name), expected, name);
65
214
  }
66
- assert(/severity-ordered/i.test(flat(roles['reviewer/AGENT.md'])), 'reviewer: must keep severity-ordered findings');
67
- assert(/confirmed[\s\S]*inferences/i.test(roles['debugger/AGENT.md']), 'debugger: must separate confirmed facts vs inferences');
68
- assert(/Stop:|how-to-verify|how to verify/i.test(flat(roles['heavy-worker/AGENT.md'])), 'heavy-worker: must bound scope / state how to verify');
215
+ assert(/confirmed facts vs inferences/i.test(normalize(roles['debugger/AGENT.md'])), 'debugger: must separate confirmed facts from inferences');
216
+ assert(/file:line/i.test(normalize(roles['debugger/AGENT.md'])), 'debugger: must anchor evidence to file:line');
69
217
 
70
218
  // --- Injection: Lead rules actually carry the brief contract ---------------
71
219
  const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-internal-comms-smoke-'));
72
220
  try {
73
221
  const leadRules = rulesBuilder.buildInjectionContent({ PLUGIN_ROOT: join(root, 'src'), DATA_DIR: dataDir });
74
- assert(/minimum characters, maximum information/i.test(flat(leadRules)), 'injected Lead rules must carry the brief token principle');
222
+ assert(TOKEN_PRINCIPLE.test(normalize(leadRules)), 'injected Lead rules must carry the brief token principle');
75
223
  for (const field of BRIEF_FIELDS) assert(leadRules.includes(field), `injected Lead rules missing brief field ${field}`);
76
224
  } finally {
77
225
  rmSync(dataDir, { recursive: true, force: true });
@@ -35,8 +35,44 @@ function taskId(text) {
35
35
  async function main() {
36
36
  const leadToolRules = readFileSync('src/rules/lead/lead-tool.md', 'utf8');
37
37
  const workflowRules = readFileSync('src/workflows/default/WORKFLOW.md', 'utf8');
38
- assert(/Use `agent` for scoped implementation/i.test(leadToolRules), 'lead rules must direct scoped work to agents');
39
- assert(/PARALLEL across independent scopes/i.test(workflowRules), 'workflow rules must keep independent work parallel');
38
+ const soloRules = readFileSync('src/workflows/solo/WORKFLOW.md', 'utf8');
39
+ const compact = (text) => text.toLowerCase().replace(/\s+/g, ' ');
40
+ const hasAll = (text, ...terms) => terms.every((term) => text.includes(term));
41
+ const lead = compact(leadToolRules);
42
+ const workflow = compact(workflowRules);
43
+ const solo = compact(soloRules);
44
+ const reviewSkipViolation = (text) => compact(text)
45
+ .split(/[.!?]\s+|;|,\s+(?=(?:but|however|yet)\b)/)
46
+ .some((clause) => {
47
+ const hasReview = /\b(review|reviewer|verification)\b/.test(clause);
48
+ const hasSkip = /\b(skip|skipping|skipped|omit|omits|omitting|omitted)\b/.test(clause) ||
49
+ /\bwithout\s+(?:any\s+)?(?:a\s+)?(?:review|reviewer|verification)\b/.test(clause);
50
+ const negated = /\b(?:never|(?:must|shall|may)\s+not|can(?:not|'t)|do\s+not|don't|not)\b[^;]{0,40}\b(?:skip|skipping|skipped|omit|omitting|omitted)\b/.test(clause) ||
51
+ /\b(?:skip|skipping|skipped|omit|omitting|omitted)\b[^;]{0,40}\b(?:not allowed|forbidden|prohibited)\b/.test(clause) ||
52
+ /\b(?:never|(?:must|shall|may)\s+not|can(?:not|'t)|do\s+not|don't|not)\b[^;]{0,40}\bwithout\s+(?:any\s+)?(?:a\s+)?(?:review|reviewer|verification)\b/.test(clause);
53
+ const exception = /\b(?:unless|except(?:\s+when)?|only\s+if)\b/.test(clause);
54
+ return hasReview && hasSkip && (!negated || exception);
55
+ });
56
+ for (const [phrase, expected] of [
57
+ ['Never skip Reviewer verification', false],
58
+ ['Reviewer verification must not be skipped', false],
59
+ ['Must not proceed without review', false],
60
+ ['Never skip risky review, but may skip simple review', true],
61
+ ['Never skip review unless low-risk', true],
62
+ ['Never skip review except when low-risk', true],
63
+ ['Never skip review only if low-risk', true],
64
+ ['May omit review', true],
65
+ ['Proceed without review', true],
66
+ ]) assert(reviewSkipViolation(phrase) === expected, `review-skip detector case failed: ${phrase}`);
67
+ const skipsReview = reviewSkipViolation(workflow);
68
+ assert(hasAll(lead, 'workflow permits delegation', 'use `agent`', 'implementation', 'research', 'review', 'debugging'), 'lead rules must conditionally direct scoped work to agents');
69
+ assert(hasAll(workflow, 'after approval', 'delegate', 'by default'), 'default workflow must delegate after approval');
70
+ assert(hasAll(workflow, 'coordinates', 'git', '1-edit', '1-check'), 'default workflow must limit Lead direct work');
71
+ assert(hasAll(workflow, 'implementation/research/debugging', 'matching agent'), 'default workflow must route other work to matching agents');
72
+ assert(hasAll(workflow, 'parallel', 'independent', 'every delegated implementation'), 'workflow rules must keep independent work parallel');
73
+ assert(hasAll(workflow, 'every delegated implementation', 'reviewer', 'lead integration', 'fix', 're-verify'), 'default workflow must require review and the fix loop');
74
+ assert(!skipsReview, 'default workflow must not permit delegated-review skips');
75
+ assert(hasAll(solo, 'never spawn', 'send', 'delegate', 'ask agents'), 'Solo workflow must forbid delegation');
40
76
  assert(/always start background tasks/i.test(AGENT_TOOL.description || '') && /distinct tags?/i.test(AGENT_TOOL.description || '') && /completion notification/i.test(AGENT_TOOL.description || ''), 'agent tool description must expose async parallel tags');
41
77
 
42
78
  mkdirSync(dataDir, { recursive: true });