@dotdrelle/wiki-manager 0.15.66 → 0.15.70

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 (50) hide show
  1. package/.env.example +10 -3
  2. package/README.md +54 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +35 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/agent/graph.js +12 -11
  8. package/src/agent/skillRecursion.test.js +13 -12
  9. package/src/cli/wiki-manager.js +124 -36
  10. package/src/commands/slash.js +38 -3
  11. package/src/contracts/schemas.js +67 -0
  12. package/src/core/activity.js +5 -0
  13. package/src/core/agentEvents.js +18 -1
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +8 -40
  16. package/src/core/env.js +14 -0
  17. package/src/core/env.test.js +19 -0
  18. package/src/core/googleGrants.test.js +1 -1
  19. package/src/core/mcp.js +1 -1
  20. package/src/core/runtimeEventAdapter.js +81 -0
  21. package/src/core/runtimeEventAdapter.test.js +61 -0
  22. package/src/core/skillChainView.test.js +2 -2
  23. package/src/core/skillCompiler.test.js +1 -1
  24. package/src/core/skillInvocation.js +13 -8
  25. package/src/core/startupCheck.js +58 -0
  26. package/src/core/startupCheck.test.js +29 -1
  27. package/src/orchestrator/agentRegistry.js +1 -22
  28. package/src/orchestrator/assignmentManager.js +16 -4
  29. package/src/orchestrator/capabilityRegistry.js +8 -1
  30. package/src/orchestrator/dispatcher.js +361 -2
  31. package/src/orchestrator/dispatcher.test.js +112 -1
  32. package/src/orchestrator/objectiveResolver.js +10 -6
  33. package/src/orchestrator/objectiveResolver.test.js +26 -27
  34. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  35. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  36. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  37. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  38. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  39. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  40. package/src/orchestrator/providers/runtimeProviders.js +325 -0
  41. package/src/orchestrator/providers/runtimeProviders.test.js +361 -0
  42. package/src/orchestrator/resultAggregator.js +35 -2
  43. package/src/orchestrator/resultAggregator.test.js +62 -0
  44. package/src/runtime/recoveryManager.js +70 -5
  45. package/src/runtime/skillChain.e2e.test.js +2 -2
  46. package/src/runtime/supervisor.js +5 -10
  47. package/src/shell/RightPane.tsx +9 -1
  48. package/src/shell/StartupScreen.tsx +44 -7
  49. package/src/shell/repl.test.js +13 -0
  50. package/wiki-workspace +19 -3
@@ -30,6 +30,7 @@ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core
30
30
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
31
31
  import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
32
32
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
33
+ import { CapabilityUnavailableError, resolve as resolveCapability } from '../orchestrator/capabilityResolver.js';
33
34
  import { listWorkspaces } from '../core/workspaces.js';
34
35
  import { findSkill } from '../core/skills.js';
35
36
  import { rememberArtifact } from '../core/currentArtifact.js';
@@ -69,6 +70,20 @@ function unavailableRuntime(err) {
69
70
  return { url: null, error: reason };
70
71
  }
71
72
 
73
+ // The user-facing reason a capability cannot start — one sentence, the likely
74
+ // cause, and the fix. The raw registry goes to the logs, never to the chat.
75
+ function capabilityUnavailableMessage(capabilityId, seen = []) {
76
+ const agentic = String(capabilityId).startsWith('agent.');
77
+ const servicesDown = seen.some((entry) => /\[none\]$/.test(entry));
78
+ if (agentic) {
79
+ return `No agent provides capability ${capabilityId} — the agentic runtime is not enabled or not reachable. Enable it (GATEWAY_ENABLED=true + wiki-workspace agents up) and check /status.`;
80
+ }
81
+ if (servicesDown) {
82
+ return `No agent provides capability ${capabilityId} — the workspace services are not running. Start them (wiki-workspace up <workspace>), then retry.`;
83
+ }
84
+ return `No agent provides capability ${capabilityId} — no connected agent declares it. Check /status.`;
85
+ }
86
+
72
87
  export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
73
88
  const provider = selection.provider;
74
89
  const capability = provider.capability ?? {};
@@ -923,6 +938,7 @@ async function runRuntime(argv, agent) {
923
938
  const { startRuntimeServer } = await import('../runtime/server.js');
924
939
  const { recoverActiveRuns } = await import('../runtime/recoveryManager.js');
925
940
  const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs, discoverAgentsOnce } = await import('../runtime/supervisor.js');
941
+ const { discoverRuntimeProvidersOnce } = await import('../orchestrator/providers/runtimeProviders.js');
926
942
  const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
927
943
  const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
928
944
  const { createApprovalManager } = await import('../runtime/approvals.js');
@@ -950,6 +966,7 @@ async function runRuntime(argv, agent) {
950
966
  await Promise.all(resolved.map(async (context) => {
951
967
  await refreshMcpRuntimeStatus(context.session);
952
968
  await discoverAgentsOnce(context.session);
969
+ await discoverRuntimeProvidersOnce(context.session);
953
970
  }));
954
971
  }
955
972
 
@@ -1137,6 +1154,14 @@ async function runRuntime(argv, agent) {
1137
1154
  try {
1138
1155
  const context = await getWorkspaceContext(workspace);
1139
1156
  await refreshMcpRuntimeStatus(context.session);
1157
+ // The supervisor kicks off agent + runtime-provider discovery fire-and-
1158
+ // forget, so at boot `session.runtimeProviderAgents` is usually still
1159
+ // undefined here. recoverActiveRuns resolves an external-runtime task by
1160
+ // finding its synthetic agent in that list; without it the task falls to
1161
+ // the MCP path, calls a non-existent server named after the capability,
1162
+ // throws, and the run is interrupted instead of recovered. Await the same
1163
+ // discovery the /run path already awaits (wiki-manager.js's turn setup).
1164
+ await discoverRuntimeProvidersOnce(context.session);
1140
1165
  const gaps = recoveryMcpGaps(context);
1141
1166
  if (gaps.length > 0) {
1142
1167
  const interrupted = store.interruptRuns({ workspace: context.workspace });
@@ -1243,6 +1268,7 @@ async function runRuntime(argv, agent) {
1243
1268
  // live endpoints and await one discovery pass before resolving.
1244
1269
  await refreshMcpRuntimeStatus(session);
1245
1270
  await discoverAgentsOnce(session, { registry: session.agentRegistry });
1271
+ await discoverRuntimeProvidersOnce(session);
1246
1272
  let selection;
1247
1273
  try {
1248
1274
  selection = await resolveObjective(objective, session);
@@ -1457,11 +1483,37 @@ async function runRuntime(argv, agent) {
1457
1483
  if (body.capabilityPlan?.capability) {
1458
1484
  const { validateFragment } = await import('../orchestrator/planValidator.js');
1459
1485
  const { integrate } = await import('../orchestrator/planIntegrator.js');
1486
+ // Same boot-race guard as prepareDelegation below: discovery is
1487
+ // asynchronous at startup, so a structured capability run submitted
1488
+ // right after boot must not observe the transient empty registry and
1489
+ // fail while the provider is already healthy (first E2E run of the
1490
+ // day did exactly that: "No agent provides capability agent.review"
1491
+ // while the gateway answered /health).
1492
+ await refreshMcpRuntimeStatus(session);
1493
+ await discoverAgentsOnce(session, { registry: session.agentRegistry });
1494
+ await discoverRuntimeProvidersOnce(session);
1460
1495
  const registry = capabilityRegistryForSession(session);
1461
- const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
1462
- const provider = agents.find((item) => (item.description?.capabilities ?? [])
1463
- .some((capability) => capability.id === body.capabilityPlan.capability));
1464
- if (!provider?.serverName) {
1496
+ const capabilityId = String(body.capabilityPlan.capability);
1497
+ const candidates = registry.providersFor(capabilityId) ?? [];
1498
+ // Route through the same resolver every other task assignment uses
1499
+ // (assignmentManager.js, resultAggregator.js), instead of a bespoke
1500
+ // "prefer external-runtime, else any with serverName" chain that
1501
+ // ignored the workspace's own capabilityRouting config (preferred/
1502
+ // allowed/fallback agents) and never checked health/availability at
1503
+ // all — this could hand a direct /run capability request to a
1504
+ // disallowed or unhealthy agent while every other entry point honors
1505
+ // the routing config.
1506
+ const workspaceConfig = session?.wikircConfig ?? session?.wikirc?.config ?? {};
1507
+ let provider = null;
1508
+ let resolutionReason = null;
1509
+ try {
1510
+ const resolved = resolveCapability(capabilityId, { workspaceConfig, registry });
1511
+ provider = candidates.find((item) => item.agentInstanceId === resolved.agentInstanceId) ?? null;
1512
+ } catch (error) {
1513
+ if (!(error instanceof CapabilityUnavailableError)) throw error;
1514
+ resolutionReason = error.reason;
1515
+ }
1516
+ if (!provider) {
1465
1517
  /*
1466
1518
  Say what WAS seen, not only what was missing.
1467
1519
 
@@ -1471,38 +1523,60 @@ async function runRuntime(argv, agent) {
1471
1523
  PRODUCTION_ALLOWED_STEPS drops its whole capability from
1472
1524
  agent_describe, silently), or a name mismatch. Listing the registry
1473
1525
  turns the next occurrence into its own diagnosis instead of a guess.
1474
- */
1475
- const seen = agents.map((item) => {
1476
- const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
1477
- return `${item.serverName ?? '?'}[${ids.join(', ') || 'none'}]`;
1478
- });
1479
- throw new Error(
1480
- `No agent provides capability ${body.capabilityPlan.capability}. `
1481
- + (seen.length
1482
- ? `Registered agents: ${seen.join('; ')}.`
1483
- : 'No agent is registered: none answered agent_describe.'),
1484
- );
1526
+ */
1527
+ const seen = [
1528
+ ...(session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? []),
1529
+ ...(session.runtimeProviderAgents ?? []),
1530
+ ].map((item) => {
1531
+ const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
1532
+ return `${item.serverName ?? item.runtimeId ?? '?'}[${ids.join(', ') || 'none'}]`;
1533
+ });
1534
+ // The raw registry stays for diagnosis, in the LOGS — never in the
1535
+ // user-facing message, which explains the likely cause instead.
1536
+ emitRuntimeLog(session, `capability-plan: ${capabilityId} unresolvable (${resolutionReason ?? 'no_candidate'}) — registry: ${seen.join('; ') || 'no agent answered agent_describe'}`);
1537
+ const error = new Error(capabilityUnavailableMessage(capabilityId, seen));
1538
+ error.code = 'capability_unavailable';
1539
+ throw error;
1485
1540
  }
1486
- const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
1487
- capability: body.capabilityPlan.capability,
1488
- operation: body.capabilityPlan.operation ?? undefined,
1489
- workspace: { revision: String(Date.now()) },
1490
- constraints: {
1491
- // The agent declares its capacity. Request/env values are only
1492
- // constraints: they may lower that capacity, never raise it.
1493
- maxConcurrency: resolveCapabilityConcurrency(
1494
- provider,
1495
- body.capabilityPlan.maxConcurrency,
1496
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
1497
- ),
1498
- requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1499
- },
1500
- ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1501
- ? { arguments: body.capabilityPlan.arguments }
1502
- : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1503
- ? { arguments: { inputs: body.capabilityPlan.inputs } }
1504
- : {}),
1505
- })));
1541
+ const operation = body.capabilityPlan.operation
1542
+ ?? provider.capability?.supportedOperations?.[0]
1543
+ ?? 'run';
1544
+ const canPlan = provider.description?.orchestration?.canPlan !== false;
1545
+ const fragment = canPlan
1546
+ ? parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
1547
+ capability: capabilityId,
1548
+ operation,
1549
+ workspace: { revision: String(Date.now()) },
1550
+ constraints: {
1551
+ // The agent declares its capacity. Request/env values are only
1552
+ // constraints: they may lower that capacity, never raise it.
1553
+ maxConcurrency: resolveCapabilityConcurrency(
1554
+ provider,
1555
+ body.capabilityPlan.maxConcurrency,
1556
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
1557
+ ),
1558
+ requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1559
+ },
1560
+ ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1561
+ ? { arguments: body.capabilityPlan.arguments }
1562
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1563
+ ? { arguments: { inputs: body.capabilityPlan.inputs } }
1564
+ : {}),
1565
+ })))
1566
+ : buildExecutorOnlyFragment({
1567
+ objective: `Capability run ${capabilityId}`,
1568
+ workspace: session.workspace ?? 'workspace',
1569
+ selection: {
1570
+ capability: capabilityId,
1571
+ operation,
1572
+ provider,
1573
+ arguments: body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1574
+ ? body.capabilityPlan.arguments
1575
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1576
+ ? { inputs: body.capabilityPlan.inputs }
1577
+ : {},
1578
+ },
1579
+ });
1506
1580
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
1507
1581
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
1508
1582
  origin: 'runtime',
@@ -1534,7 +1608,18 @@ async function runRuntime(argv, agent) {
1534
1608
  if (!integrated.ok) {
1535
1609
  throw new Error(`Capability plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
1536
1610
  }
1537
- emitRuntimeLog(session, `capability-plan: ${fragment.tasks.length} task(s) integrated from ${provider.serverName}.agent_plan (${body.capabilityPlan.capability}); approvals: ${(session.agentProjection?.approvals ?? []).filter((approval) => approval.status === 'pending_approval').length} pending`);
1611
+ // The structured path integrates the plan directly, so it must honor
1612
+ // the same explicit opt-in as the delegation path (delegation.js
1613
+ // `resolvePreparedDelegationApproval`): autoApprove grants one
1614
+ // run-scoped approval right after integration. Without it, a
1615
+ // headless/CI caller with autoApprove:true deadlocks on the
1616
+ // scheduler's approval gate until the timeout — first E2E research
1617
+ // run did exactly that.
1618
+ if (body.autoApprove === true) {
1619
+ const { resolvePreparedDelegationApproval } = await import('../runtime/delegation.js');
1620
+ resolvePreparedDelegationApproval({ autoApprove: true, approvalManager: context.approvalManager, runId });
1621
+ }
1622
+ emitRuntimeLog(session, `capability-plan: ${fragment.tasks.length} task(s) integrated${provider.serverName ? ` from ${provider.serverName}.agent_plan` : ''} (${body.capabilityPlan.capability}); approvals: ${(session.agentProjection?.approvals ?? []).filter((approval) => approval.status === 'pending_approval').length} pending`);
1538
1623
  }
1539
1624
  await runRuntimeAgenticWorkflow(agent, session, input, {
1540
1625
  signal,
@@ -1568,6 +1653,9 @@ async function runRuntime(argv, agent) {
1568
1653
  payload: {
1569
1654
  runId,
1570
1655
  message: err instanceof Error ? err.message : String(err),
1656
+ // UIs map this to amber ("service not available") instead of red
1657
+ // ("the run failed"): the run could not START, it did not crash.
1658
+ ...(err?.code === 'capability_unavailable' ? { kind: 'capability_unavailable' } : {}),
1571
1659
  },
1572
1660
  }));
1573
1661
  } finally {
@@ -30,6 +30,7 @@ import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
30
30
  import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
31
31
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
32
  import { emitRuntimeLog } from '../runtime/supervisor.js';
33
+ import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
33
34
  import {
34
35
  cancelQueueItem,
35
36
  clearFinishedQueueItems,
@@ -660,8 +661,30 @@ function reportNewlyDegradedMcp(session, previousMcp) {
660
661
  }
661
662
  }
662
663
 
664
+ function runtimeProvidersSection(session) {
665
+ const agents = session.runtimeProviderAgents ?? [];
666
+ if (agents.length === 0) return null;
667
+ const byRuntime = new Map();
668
+ for (const agent of agents) {
669
+ const runtimeId = agent.runtimeId ?? 'external';
670
+ if (!byRuntime.has(runtimeId)) byRuntime.set(runtimeId, []);
671
+ byRuntime.get(runtimeId).push(agent);
672
+ }
673
+ const lines = [];
674
+ for (const [runtimeId, list] of byRuntime) {
675
+ const health = list[0]?.health ?? 'unknown';
676
+ const capabilities = list
677
+ .map((agent) => agent.description?.capabilities?.[0]?.id ?? agent.agentInstanceId)
678
+ .join(', ');
679
+ lines.push(`${runtimeId}: ${health}`);
680
+ lines.push(`capabilities: ${capabilities}`);
681
+ }
682
+ return sectionBlock('Agentic runtime', lines);
683
+ }
684
+
663
685
  async function statusText(session) {
664
686
  const states = await refreshMcpRuntimeStatus(session);
687
+ await discoverRuntimeProvidersOnce(session);
665
688
  const workspaceStats = collectWorkspaceStats(session);
666
689
  const workspaceColumn = sectionBlock(`Workspace · ${session.workspace ?? '-'}`, [
667
690
  `path: ${compactPath(session.workspacePath ?? '-')}`,
@@ -678,11 +701,20 @@ async function statusText(session) {
678
701
  `baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
679
702
  ...(skillDiagnosticCount ? [`skill diagnostics: ${skillDiagnostics.rejected.length} rejected, ${skillDiagnostics.warnings.length} warning(s) (/skills)`] : []),
680
703
  ]);
681
- const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
704
+ // The workspace stack (serve / mcp-http / production-mcp) only means
705
+ // something once a workspace is loaded AND services are actually declared;
706
+ // an empty list is noise either way. Named "Services" (not "Runtime") to
707
+ // avoid colliding with the "Agentic runtime" section that lists the
708
+ // external engines.
709
+ const hasServices = Boolean(states && Object.keys(states).length > 0);
710
+ const runtimeColumn = hasServices
711
+ ? sectionBlock('Services', serviceStatesText(states).split('\n'))
712
+ : null;
682
713
  const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
714
+ const runtimesColumn = runtimeProvidersSection(session);
683
715
  const stats = workspaceStatsColumns(workspaceStats, session);
684
716
 
685
- const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
717
+ const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn, runtimesColumn].filter(Boolean).join('\n\n');
686
718
  const rightColumn = [configColumn, stats.right].filter(Boolean).join('\n\n');
687
719
 
688
720
  // Leading/trailing blank row so the boxed pair doesn't butt directly against
@@ -1396,7 +1428,10 @@ export async function handleSlashCommand(line, context) {
1396
1428
  },
1397
1429
  });
1398
1430
  if (result?.runId) {
1399
- return { output: `▶ Capability run accepted (${String(result.runId).slice(0, 8)}) — the agent's plan will be integrated and dispatched in parallel; approval requested before mutations (/approve).` };
1431
+ // Honest and generic: the runtime decides, per task, whether a
1432
+ // mutation needs approval. Claiming "awaiting approval" here would
1433
+ // be false for read-only capabilities like agent.review.
1434
+ return { output: `▶ Capability run accepted (${String(result.runId).slice(0, 8)}) — the plan will be integrated and dispatched in parallel; any mutating step will wait for /approve.` };
1400
1435
  }
1401
1436
  return { output: `Run not started: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
1402
1437
  }
@@ -330,6 +330,71 @@ const patchTaskSchema = {
330
330
  },
331
331
  };
332
332
 
333
+ const runtimeCapabilitySchema = {
334
+ type: 'object',
335
+ required: ['name'],
336
+ additionalProperties: true,
337
+ properties: {
338
+ name: { type: 'string', minLength: 1 },
339
+ description: { type: 'string' },
340
+ operations: stringArraySchema,
341
+ aliases: stringArraySchema,
342
+ aliasOperations: { type: 'object', additionalProperties: true },
343
+ mutationClass: { type: 'string' },
344
+ defaultRequiresApproval: { type: 'boolean' },
345
+ },
346
+ };
347
+
348
+ const runtimeDescriptionSchema = {
349
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/runtime-description/v1',
350
+ title: 'RuntimeDescription',
351
+ schemaVersion: '1',
352
+ type: 'object',
353
+ required: ['runtime', 'version', 'protocolVersion'],
354
+ additionalProperties: true,
355
+ properties: {
356
+ runtime: { type: 'string', minLength: 1 },
357
+ version: { type: 'string', minLength: 1 },
358
+ protocolVersion: { type: 'string', minLength: 1 },
359
+ health: { type: 'string' },
360
+ capabilities: { type: 'array', items: runtimeCapabilitySchema },
361
+ },
362
+ };
363
+
364
+ const runtimeEventSchema = {
365
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/runtime-event/v1',
366
+ title: 'RuntimeEvent',
367
+ schemaVersion: '1',
368
+ type: 'object',
369
+ required: ['type'],
370
+ additionalProperties: true,
371
+ properties: {
372
+ type: {
373
+ type: 'string',
374
+ enum: [
375
+ 'run_created',
376
+ 'run_started',
377
+ 'agent_thinking',
378
+ 'tool_started',
379
+ 'tool_finished',
380
+ 'subagent_started',
381
+ 'subagent_finished',
382
+ 'message',
383
+ 'approval_required',
384
+ 'run_completed',
385
+ 'run_failed',
386
+ 'run_cancelled',
387
+ ],
388
+ },
389
+ runId: { type: 'string' },
390
+ tool: { type: 'string' },
391
+ durationMs: { type: 'number' },
392
+ resultSummary: { type: 'string' },
393
+ error: nullableString,
394
+ args: nullableObject,
395
+ },
396
+ };
397
+
333
398
  export const contractSchemas = {
334
399
  activity: {
335
400
  $id: 'https://dotdrelle.dev/wiki-manager/contracts/activity/v1',
@@ -462,6 +527,8 @@ export const contractSchemas = {
462
527
  plannedTask: plannedTaskSchema,
463
528
  taskGraphFragment: taskGraphFragmentSchema,
464
529
  planExpansionRequest: planExpansionRequestSchema,
530
+ runtimeDescription: runtimeDescriptionSchema,
531
+ runtimeEvent: runtimeEventSchema,
465
532
  };
466
533
 
467
534
  export function validateContract(name, value) {
@@ -214,6 +214,11 @@ export function mergePolledActivity(tracked, polled) {
214
214
  // Identity: keep the tracked source/id so activityKey() stays stable.
215
215
  id: tracked.id ?? polled.id,
216
216
  source: tracked.source ?? polled.source,
217
+ // A later poll often reports only {status, progress} with no phase/type,
218
+ // and activityFromStatusPayload then falls back to the generic 'job' —
219
+ // spreading that over the tracked value degraded a real kind (e.g.
220
+ // 'knowledge.update') to 'job' on the very next poll tick.
221
+ kind: tracked.kind ?? polled.kind,
217
222
  // Keep polling with the descriptor that worked when the agent's answer
218
223
  // does not carry one of its own.
219
224
  poll: polled.poll ?? tracked.poll,
@@ -1,7 +1,7 @@
1
1
  import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
- import { formatRuntimeLogPayload } from './runtimeLog.js';
4
+ import { formatRuntimeLogPayload, normalizeRuntimeLog } from './runtimeLog.js';
5
5
  import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
6
6
  import { projectWorkflow } from './workflow.js';
7
7
  import { validateContractInDev } from '../contracts/schemas.js';
@@ -108,6 +108,23 @@ export function dispatchAgentEvent(session, event) {
108
108
  return normalized;
109
109
  }
110
110
 
111
+ // Shared `runtime_log` shaping — was independently reimplemented byte-for-byte
112
+ // in runtime/supervisor.js, orchestrator/agentRegistry.js and
113
+ // orchestrator/providers/runtimeProviders.js (each citing the same "avoid a
114
+ // cycle with supervisor.js" reason, even where no such cycle existed). This
115
+ // module already sits below all three, so it is the one safe common home.
116
+ export function dispatchRuntimeLog(session, message) {
117
+ if (!session) return;
118
+ const payload = normalizeRuntimeLog(message, { session });
119
+ return dispatchAgentEvent(session, createAgentEvent('runtime_log', {
120
+ origin: 'runtime',
121
+ runId: payload.runId ?? null,
122
+ taskId: payload.taskId ?? null,
123
+ workspace: payload.workspaceId ?? null,
124
+ payload,
125
+ }));
126
+ }
127
+
111
128
  // Full in-memory projection reset for a session. The runtime keeps the live
112
129
  // projection in memory (session.agentProjection) and serves it from /state, so
113
130
  // interrupting runs is not enough to clear the PLAN/ACTIVITY/LOGS panels — the
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.66",
3
- "commit": "d9639a4"
2
+ "version": "0.15.70",
3
+ "commit": "1cb3a19"
4
4
  }
@@ -27,52 +27,20 @@ test('workspace production agent enables restore by default', async () => {
27
27
  assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
28
  });
29
29
 
30
- test('every shipped default allows the taxonomy step', async () => {
30
+ test('the shipped default carries no step the engine retired in 0.15.66', async () => {
31
31
  /*
32
- L'absence de `taxonomy` est SILENCIEUSE.
33
-
34
- `agent_plan` retire la tâche taxonomique du fragment d'ingestion quand
35
- l'étape n'est pas autorisée (`"taxonomy" in _ALLOWED_STEPS`), sans erreur ni
36
- avertissement. Un déploiement par compose ingérait donc sans jamais publier
37
- de taxonomie, et la carte restait périmée — exactement le défaut que le Lot 4
38
- avait corrigé côté moteur. Le défaut de Python porte `taxonomy` ; un compose
39
- POSITIONNE toujours la variable, donc ce défaut ne s'applique jamais là.
32
+ 0.15.66 a retiré du moteur llm-wiki les commandes `wiki concepts`,
33
+ `reclassify-concepts` et `taxonomy` (simplification : le concept EST le
34
+ dossier). Un défaut livré qui les autoriserait encore ferait échouer le
35
+ pipeline à la première de ces étapes — `unknown command 'concepts'` — sans
36
+ jamais les exécuter. Ces trois étapes ne doivent donc figurer dans AUCUN
37
+ défaut livré, ni ici ni dans agent-production.
40
38
  */
41
39
  const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
42
40
  const compose = YAML.parse(raw);
43
41
  const allowed = compose.services['production-mcp'].environment
44
42
  .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
45
- assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
46
- });
47
-
48
- test('every shipped default allows the concepts step', async () => {
49
- /*
50
- Same silent-omission risk as `taxonomy` above, one lot earlier: without
51
- `concepts`, `wiki concepts --apply` (which writes wiki/concepts-grid.md) is
52
- never reachable through /wiki-sync, /pipeline, or any orchestrated flow.
53
- Every ingest then files every concept page under the reserved
54
- `unclassified` class forever, with nothing surfacing why.
55
- */
56
- const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
57
- const compose = YAML.parse(raw);
58
- const allowed = compose.services['production-mcp'].environment
59
- .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
60
- assert.match(String(allowed), /(?:^|,)concepts(?:,|})/);
61
- });
62
-
63
- test('every shipped default allows the reclassify-concepts step', async () => {
64
- /*
65
- One step further than `concepts`: without `reclassify-concepts`, a page
66
- already stuck under wiki/concepts/unclassified stays there even after a
67
- grid exists — re-ingesting its source is not a reliable fix, since the
68
- ingest prompt updates an existing leaf at its existing path instead of
69
- moving it.
70
- */
71
- const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
72
- const compose = YAML.parse(raw);
73
- const allowed = compose.services['production-mcp'].environment
74
- .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
75
- assert.match(String(allowed), /(?:^|,)reclassify-concepts(?:,|})/);
43
+ assert.doesNotMatch(String(allowed), /(?:^|,)(?:concepts|reclassify-concepts|taxonomy)(?:,|})/);
76
44
  });
77
45
 
78
46
  test('shipped compose files never carry a build context', async () => {
package/src/core/env.js CHANGED
@@ -49,6 +49,10 @@ export function managerMcpEndpointsFile() {
49
49
  return join(managerStateDir(), 'mcp.endpoints.json');
50
50
  }
51
51
 
52
+ export function managerAgentRuntimesFile() {
53
+ return join(managerStateDir(), 'agent-runtimes.json');
54
+ }
55
+
52
56
  // User-owned compose overrides, one per stack. `.wiki/compose` is persistent
53
57
  // operator configuration; `.wiki/runtime` is generated state rewritten by
54
58
  // compose commands. Keeping those directories separate makes the lifecycle
@@ -164,6 +168,16 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
164
168
  }
165
169
  }
166
170
  }
171
+ // External agent runtimes (RFC § 37) are optional and DISABLED in the
172
+ // packaged example: seeding a live endpoint would make every fresh install
173
+ // probe a runtime that does not ship. Copy once, never rewrite — an existing
174
+ // file is the operator's.
175
+ const runtimesFile = managerAgentRuntimesFile();
176
+ const runtimesExample = join(packageRoot, 'agent-runtimes.example.json');
177
+ if (existsSync(runtimesExample) && !existsSync(runtimesFile)) {
178
+ copyFileSync(runtimesExample, runtimesFile);
179
+ created.push('agent-runtimes.json');
180
+ }
167
181
  const envFile = managerEnvFile();
168
182
  const envExample = join(packageRoot, '.env.example');
169
183
  if (!existsSync(envFile) && existsSync(envExample)) {
@@ -37,6 +37,25 @@ test('scaffold copies the packaged examples into a fresh directory', () => {
37
37
  });
38
38
  });
39
39
 
40
+ test('scaffold seeds an enabled agent-runtimes example and never rewrites an existing one', () => {
41
+ withTempManagerDir((dir) => {
42
+ const created = ensureManagerScaffold();
43
+ assert.ok(created.includes('agent-runtimes.json'));
44
+ const runtimes = JSON.parse(readFileSync(join(dir, 'agent-runtimes.json'), 'utf8'));
45
+ assert.ok(Array.isArray(runtimes.runtimes));
46
+ assert.equal(runtimes.runtimes[0].enabled, true, 'the scaffold ships the gateway enabled (GATEWAY_ENABLED=true)');
47
+
48
+ // Operator-owned: once present, the file is never touched again.
49
+ writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [] }));
50
+ const second = ensureManagerScaffold();
51
+ assert.ok(!second.includes('agent-runtimes.json'));
52
+ assert.deepEqual(
53
+ JSON.parse(readFileSync(join(dir, 'agent-runtimes.json'), 'utf8')),
54
+ { runtimes: [] },
55
+ );
56
+ });
57
+ });
58
+
40
59
  test('an install predating the required runtime host receives it on its placeholder', () => {
41
60
  withTempManagerDir((dir) => {
42
61
  const envFile = join(dir, '.env');
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
6
6
 
7
7
  // `agent-connectors` n'est pas cloné par le CI du manager, qui ne tire que
8
- // llm-wiki, agent-wiki-production, agent-cme et agent-wiki-documents (voir
8
+ // llm-wiki, agent-production, agent-cme et agent-documents (voir
9
9
  // check-versions.js). Les contrôles de cohérence croisée ci-dessous lisent la
10
10
  // source de l'agent connectors : sans elle, on les saute plutôt que d'échouer
11
11
  // sur un ENOENT. Le flux de release complet (build-and-push.sh) la fournit,
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.66';
4
+ const WIKI_MANAGER_VERSION = '0.15.70';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();