@dotdrelle/wiki-manager 0.15.66 → 0.15.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.env.example +10 -3
  2. package/README.md +57 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +39 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/activity/activityAggregator.test.js +2 -2
  8. package/src/agent/graph.js +13 -11
  9. package/src/agent/skillRecursion.test.js +13 -12
  10. package/src/cli/wiki-manager.js +125 -37
  11. package/src/cli/wiki-manager.test.js +16 -16
  12. package/src/commands/slash.js +59 -5
  13. package/src/contracts/schemas.js +67 -0
  14. package/src/core/activity.js +5 -0
  15. package/src/core/agentEvents.js +139 -25
  16. package/src/core/agentEvents.test.js +26 -1
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/commandFailure.test.js +2 -2
  19. package/src/core/currentArtifact.test.js +5 -5
  20. package/src/core/dockerCompose.test.js +8 -40
  21. package/src/core/env.js +14 -0
  22. package/src/core/env.test.js +19 -0
  23. package/src/core/googleGrants.test.js +1 -1
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/mcp.test.js +1 -1
  26. package/src/core/otherWorkspacesRunning.test.js +6 -6
  27. package/src/core/runtimeEventAdapter.js +81 -0
  28. package/src/core/runtimeEventAdapter.test.js +61 -0
  29. package/src/core/runtimeLog.js +35 -1
  30. package/src/core/runtimeLog.test.js +27 -2
  31. package/src/core/skillChainView.test.js +2 -2
  32. package/src/core/skillCompiler.test.js +1 -1
  33. package/src/core/skillInvocation.js +13 -8
  34. package/src/core/skillInvocation.test.js +1 -1
  35. package/src/core/startupCheck.js +58 -0
  36. package/src/core/startupCheck.test.js +29 -1
  37. package/src/core/wikiSetup.js +25 -0
  38. package/src/core/wikiSetup.test.js +35 -0
  39. package/src/core/wikirc.test.js +6 -6
  40. package/src/core/workspaceInherit.test.js +14 -14
  41. package/src/orchestrator/agentRegistry.js +1 -22
  42. package/src/orchestrator/agentRegistry.test.js +6 -6
  43. package/src/orchestrator/assignmentManager.js +16 -4
  44. package/src/orchestrator/capabilityRegistry.js +8 -1
  45. package/src/orchestrator/dispatcher.js +405 -2
  46. package/src/orchestrator/dispatcher.test.js +158 -4
  47. package/src/orchestrator/objectiveResolver.js +10 -6
  48. package/src/orchestrator/objectiveResolver.test.js +26 -27
  49. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  50. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  51. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  52. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  53. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  54. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  55. package/src/orchestrator/providers/runtimeProviders.js +378 -0
  56. package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
  57. package/src/orchestrator/resultAggregator.js +35 -2
  58. package/src/orchestrator/resultAggregator.test.js +62 -0
  59. package/src/orchestrator/scheduler.test.js +4 -4
  60. package/src/runtime/delegation.test.js +11 -11
  61. package/src/runtime/recoveryManager.js +70 -5
  62. package/src/runtime/runner.test.js +1 -1
  63. package/src/runtime/server.test.js +2 -2
  64. package/src/runtime/skillChain.e2e.test.js +2 -2
  65. package/src/runtime/store.test.js +8 -5
  66. package/src/runtime/supervisor.js +5 -10
  67. package/src/runtime/workspaceIsolation.test.js +26 -26
  68. package/src/shell/RightPane.tsx +23 -3
  69. package/src/shell/StartupScreen.tsx +44 -7
  70. package/src/shell/repl.js +24 -2
  71. package/src/shell/repl.test.js +13 -0
  72. package/wiki-workspace +53 -3
@@ -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, isDispatchPlumbingLine, normalizeRuntimeLog, shortTaskLabel } 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
@@ -312,16 +329,13 @@ function applyEvent(state, event) {
312
329
  : state.planRevision + 1;
313
330
  return;
314
331
  case 'plan.received':
315
- state.logs.push(`Plan received for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
316
- state.logs = state.logs.slice(-200);
332
+ appendLog(state, `${logTime(event.ts)} Plan received for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
317
333
  return;
318
334
  case 'plan.validated':
319
- state.logs.push(`Plan validated for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
320
- state.logs = state.logs.slice(-200);
335
+ appendLog(state, `${logTime(event.ts)} Plan validated for run ${String(event.runId ?? event.payload?.runId ?? '')}`.trim());
321
336
  return;
322
337
  case 'plan.rejected':
323
- state.logs.push(`Plan rejected: ${formatPlanErrors(event.payload?.errors)}`);
324
- state.logs = state.logs.slice(-200);
338
+ appendLog(state, `${logTime(event.ts)} Plan rejected: ${formatPlanErrors(event.payload?.errors)}`.trim());
325
339
  return;
326
340
  case 'task_group.created':
327
341
  return;
@@ -329,28 +343,24 @@ function applyEvent(state, event) {
329
343
  appendCreatedTask(state, event.payload?.task);
330
344
  return;
331
345
  case 'task.assigned':
332
- state.logs.push(`Task assigned: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
333
- state.logs = state.logs.slice(-200);
346
+ appendLog(state, taskLogLine(state, event, 'assigned'));
334
347
  return;
335
348
  case 'task.started':
336
- state.logs.push(`Task started: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
337
- state.logs = state.logs.slice(-200);
349
+ // Silent: always follows `task.assigned` (same task, milliseconds apart),
350
+ // which already printed the "started" line.
338
351
  return;
339
352
  case 'task.retry_scheduled':
340
- state.logs.push(`Task retry scheduled: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
341
- state.logs = state.logs.slice(-200);
353
+ appendLog(state, taskLogLine(state, event, 'retry'));
342
354
  return;
343
355
  case 'task.result_returned':
344
- state.logs.push(`Task result returned: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
345
- state.logs = state.logs.slice(-200);
356
+ // Silent: an internal transition immediately followed by
357
+ // `task.completed`/`task.failed`, which carry the same result.
346
358
  return;
347
359
  case 'task.completed':
348
- state.logs.push(`Task completed: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
349
- state.logs = state.logs.slice(-200);
360
+ appendLog(state, taskLogLine(state, event, 'completed'));
350
361
  return;
351
362
  case 'task.failed':
352
- state.logs.push(`Task failed: ${String(event.taskId ?? event.payload?.taskId ?? '')}`.trim());
353
- state.logs = state.logs.slice(-200);
363
+ appendLog(state, taskLogLine(state, event, 'failed'));
354
364
  return;
355
365
  case 'plan.revision_changed':
356
366
  if (Array.isArray(event.payload?.tasks)) {
@@ -364,12 +374,11 @@ function applyEvent(state, event) {
364
374
  // référence au state : `updatePlanStep` reste une fonction sur un
365
375
  // plan, et le journal reste la responsabilité de l'appelant.
366
376
  const anomaly = updatePlanStep(state.plan, event.payload ?? {});
367
- if (anomaly) state.logs.push(anomaly);
377
+ if (anomaly) appendLog(state, `${logTime(event.ts)} ${anomaly}`.trim());
368
378
  }
369
379
  return;
370
380
  case 'control_message_received':
371
- state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
372
- state.logs = state.logs.slice(-200);
381
+ appendLog(state, `${logTime(event.ts)} Control message: ${String(event.payload?.input ?? '')}`.trim());
373
382
  return;
374
383
  case 'plan_patch_proposed':
375
384
  upsertPlanPatch(state, {
@@ -551,7 +560,7 @@ function applyEvent(state, event) {
551
560
  return;
552
561
  case 'run_cancelled':
553
562
  state.status = 'cancelled';
554
- state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
563
+ appendLog(state, `${logTime(event.ts)} ${String(event.payload?.message ?? 'Agent run cancelled.')}`.trim());
555
564
  // A cancelled run must not leave its plan steps "running/pending" and
556
565
  // its activities spinning in the panels: mark every non-terminal one
557
566
  // cancelled so the display reflects reality immediately.
@@ -573,7 +582,7 @@ function applyEvent(state, event) {
573
582
  already in hand. What ends a run is essential by construction; the
574
583
  prefix states that instead of hoping the wording says so.
575
584
  */
576
- state.logs.push(`Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`);
585
+ appendLog(state, `${logTime(event.ts)} Run failed: ${String(event.payload?.message ?? 'Agent run failed.')}`.trim());
577
586
  // A dead run must not leave "pending" plan steps and spinning
578
587
  // activities in the persisted projection: they reappeared as ghosts
579
588
  // at every relaunch ("des trucs dans le plan qui n'existent pas") and
@@ -650,7 +659,7 @@ function applyEvent(state, event) {
650
659
  }, event.ts);
651
660
  return;
652
661
  case 'runtime_log':
653
- state.logs.push(formatRuntimeLogPayload(event.payload ?? {}, event.ts));
662
+ appendLog(state, formatRuntimeLogPayload(event.payload ?? {}, event.ts));
654
663
  return;
655
664
  default:
656
665
  return;
@@ -1035,6 +1044,111 @@ function formatPlanErrors(errors) {
1035
1044
  : 'unknown';
1036
1045
  }
1037
1046
 
1047
+ const LOG_TIME_PREFIX = /^\d{2}:\d{2}:\d{2}\s+/;
1048
+ const LOG_REPEAT_SUFFIX = / \(×\d+\)$/;
1049
+
1050
+ /*
1051
+ The single writer into `state.logs` — every push goes through here.
1052
+
1053
+ Two jobs the ad-hoc `push(...); logs = logs.slice(-200)` pairs did unevenly:
1054
+ the 200-entry cap is now applied on every path (the `runtime_log` case never
1055
+ capped and grew without bound during a long run), and a *plumbing* entry
1056
+ (isDispatchPlumbingLine) identical to the one before it — once the HH:MM:SS
1057
+ prefix is dropped — is collapsed into a `(×N)` counter instead of being
1058
+ printed again. `agent_status` polling and repeated progress ticks otherwise
1059
+ bury every readable event under dozens of identical rows. A business line (a
1060
+ ▸/✓/✗/↻ transition, "Run failed:", a control message) is never collapsed, so
1061
+ a second genuine failure and its timing are never folded away.
1062
+ */
1063
+ function appendLog(state, line) {
1064
+ const text = String(line ?? '').trim();
1065
+ if (!text) return;
1066
+ const last = state.logs.at(-1);
1067
+ if (last != null && isDispatchPlumbingLine(text) && isDispatchPlumbingLine(last)) {
1068
+ const bare = (value) => String(value).replace(LOG_TIME_PREFIX, '').replace(LOG_REPEAT_SUFFIX, '');
1069
+ if (bare(last) === bare(text)) {
1070
+ const count = Number(String(last).match(/ \(×(\d+)\)$/)?.[1] ?? '1') + 1;
1071
+ // Keep the LATEST timestamp so the panel shows when it last repeated.
1072
+ state.logs[state.logs.length - 1] = `${text.replace(LOG_REPEAT_SUFFIX, '')} (×${count})`;
1073
+ return;
1074
+ }
1075
+ }
1076
+ state.logs.push(text);
1077
+ if (state.logs.length > 200) state.logs = state.logs.slice(-200);
1078
+ }
1079
+
1080
+ function logTime(ts) {
1081
+ const date = ts ? new Date(ts) : new Date();
1082
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(11, 19);
1083
+ }
1084
+
1085
+ function planTaskById(state, taskId) {
1086
+ const id = String(taskId ?? '');
1087
+ if (!id) return null;
1088
+ return (state.plan ?? []).find((step) => String(step.id ?? step.step) === id) ?? null;
1089
+ }
1090
+
1091
+ /*
1092
+ The persisted taskId is `<runId-uuid>:<slug>-<hash8>` — neither the UUID nor
1093
+ the trailing hash means anything to a reader. Prefer the plan step's business
1094
+ label ("Polish deliverable: proposition/presentation"), then its description,
1095
+ and only fall back to a de-slugified task name when the plan carries neither.
1096
+ */
1097
+ function taskLabelFor(state, taskId) {
1098
+ const step = planTaskById(state, taskId);
1099
+ const label = step?.label ?? step?.description ?? null;
1100
+ if (label && !/^Step \d+$/.test(label)) return label;
1101
+ return shortTaskLabel(taskId) || String(taskId ?? '') || 'task';
1102
+ }
1103
+
1104
+ // One readable line per real task transition. `task.started` and
1105
+ // `task.result_returned` are deliberately silent in the reducer — each is an
1106
+ // internal step between two lines this function already prints (`assigned`
1107
+ // then `completed`/`failed`), and printing them doubled every task in the
1108
+ // Runtime panel.
1109
+ function taskLogLine(state, event, kind) {
1110
+ const payload = event.payload ?? {};
1111
+ const taskId = event.taskId ?? payload.taskId ?? '';
1112
+ const label = taskLabelFor(state, taskId);
1113
+ const time = logTime(event.ts);
1114
+ const step = planTaskById(state, taskId);
1115
+ const capability = payload.assignment?.capability ?? step?.requiredCapability ?? null;
1116
+ const agent = payload.assignment?.agentInstanceId
1117
+ ?? payload.agentInstanceId
1118
+ ?? payload.result?.assignment?.agentInstanceId
1119
+ ?? payload.assignment?.agentId
1120
+ ?? null;
1121
+
1122
+ if (kind === 'assigned') {
1123
+ const context = [capability, agent && `→ ${agent}`].filter(Boolean).join(' ');
1124
+ return `${time} ▸ ${label} — started${context ? ` (${context})` : ''}`.trim();
1125
+ }
1126
+ if (kind === 'retry') {
1127
+ const attempt = payload.attempts ?? payload.attempt ?? null;
1128
+ const max = payload.maxAttempts ?? null;
1129
+ const reason = payload.reason
1130
+ ?? payload.error?.code
1131
+ ?? payload.error?.message
1132
+ ?? 'retryable error';
1133
+ const nth = attempt != null ? ` ${attempt}${max ? `/${max}` : ''}` : '';
1134
+ return `${time} ↻ ${label} — retry${nth} (${reason})`.trim();
1135
+ }
1136
+
1137
+ const result = payload.result ?? {};
1138
+ if (kind === 'failed') {
1139
+ const error = result.error?.code
1140
+ ?? result.error?.message
1141
+ ?? payload.error?.code
1142
+ ?? payload.error?.message
1143
+ ?? (result.status && result.status !== 'succeeded' ? result.status : null);
1144
+ return `${time} ✗ ${label} — failed${error ? `: ${error}` : ''}`.trim();
1145
+ }
1146
+ // completed
1147
+ const outputs = result.outputRefs ?? result.result?.outputRefs ?? [];
1148
+ const count = Array.isArray(outputs) ? outputs.length : 0;
1149
+ return `${time} ✓ ${label} — done${count ? ` (${count} output${count > 1 ? 's' : ''})` : ''}`.trim();
1150
+ }
1151
+
1038
1152
  function cloneRef(value) {
1039
1153
  return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : String(value);
1040
1154
  }
@@ -720,8 +720,33 @@ test('run_error names the failure so the essential journal cannot filter it out'
720
720
 
721
721
  assert.equal(state.status, 'error');
722
722
  const line = state.logs.at(-1);
723
- assert.match(line, /^Run failed: /);
723
+ assert.match(line, /^\d{2}:\d{2}:\d{2} Run failed: /);
724
724
  assert.match(line, /workspace\.restore/);
725
725
  // Le mot qui rend l'entrée « essentielle » pour le journal serve.
726
726
  assert.match(line, /failed/i);
727
727
  });
728
+
729
+ test('appendLog collapses repeated dispatch plumbing but never a repeated business failure', () => {
730
+ const ev = (type, payload, ts, taskId = null) => ({ id: `${type}-${ts}`, ts, type, payload, taskId });
731
+ // Two identical agent_status polls, seconds apart → one (×2) row.
732
+ const polling = reduceAgentEvents([
733
+ ev('runtime_log', { event: 'agent_status', runId: 'r', taskId: 't', detail: 'production_status' }, '2026-07-08T14:00:01.000Z'),
734
+ ev('runtime_log', { event: 'agent_status', runId: 'r', taskId: 't', detail: 'production_status' }, '2026-07-08T14:00:04.000Z'),
735
+ ]);
736
+ assert.equal(polling.logs.length, 1);
737
+ assert.match(polling.logs[0], / \(×2\)$/);
738
+ assert.match(polling.logs[0], /^14:00:04 /, 'the counter keeps the most recent timestamp');
739
+
740
+ // A retry that fails the SAME way twice produces two byte-identical failure
741
+ // lines (bar the timestamp) — both must stay visible, a degradation must
742
+ // announce itself (root CLAUDE.md). The guard is that ✗ lines are not
743
+ // plumbing, not that the text differs.
744
+ const failPayload = { taskId: 'a', result: { status: 'failed', error: { code: 'rate_limit' } } };
745
+ const failures = reduceAgentEvents([
746
+ ev('task.failed', failPayload, '2026-07-08T14:00:01.000Z', 'a'),
747
+ ev('task.failed', failPayload, '2026-07-08T14:05:09.000Z', 'a'),
748
+ ]);
749
+ const failLines = failures.logs.filter((l) => /✗ a — failed: rate_limit/.test(l));
750
+ assert.equal(failLines.length, 2);
751
+ assert.equal(failLines.some((l) => / \(×\d+\)$/.test(l)), false);
752
+ });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.66",
3
- "commit": "d9639a4"
2
+ "version": "0.15.71",
3
+ "commit": "d5f51af"
4
4
  }
@@ -28,7 +28,7 @@ test('the fallback hint drops the command echo, compose warnings and host paths'
28
28
  message: [
29
29
  COMPOSE_COMMAND,
30
30
  'time="2026-07-28T11:37:41+02:00" level=warning msg="The \\"CONNECTORS_MCP_PORT\\" variable is not set."',
31
- 'error while creating mount source path /mnt/c/Users/p/Documents/docker/llm-wiki/workspaces/juno: denied',
31
+ 'error while creating mount source path /mnt/c/Users/p/Documents/docker/llm-wiki/workspaces/demo: denied',
32
32
  ].join('\n'),
33
33
  };
34
34
 
@@ -39,7 +39,7 @@ test('the fallback hint drops the command echo, compose warnings and host paths'
39
39
  // Only the basename survives: absolute paths describe this machine's install
40
40
  // layout and mean nothing to the person reading the answer.
41
41
  assert.doesNotMatch(hint, /\/mnt\/c/);
42
- assert.match(hint, /juno/);
42
+ assert.match(hint, /demo/);
43
43
  });
44
44
 
45
45
  test('a failed operation reaches Donna as facts, never as docker output', () => {
@@ -38,10 +38,10 @@ test('artifactFromToolCall ignores read tools and tools without a path', () => {
38
38
  });
39
39
 
40
40
  test('currentArtifactFor is workspace-scoped', () => {
41
- const artifact = { workspace: 'acpi', path: 'templates/notes/basic.md', kind: 'template' };
42
- assert.equal(currentArtifactFor({ workspace: 'acpi', currentArtifact: artifact }), artifact);
41
+ const artifact = { workspace: 'acme', path: 'templates/notes/basic.md', kind: 'template' };
42
+ assert.equal(currentArtifactFor({ workspace: 'acme', currentArtifact: artifact }), artifact);
43
43
  assert.equal(currentArtifactFor({ workspace: 'other', currentArtifact: artifact }), null);
44
- assert.equal(currentArtifactFor({ workspace: 'acpi' }), null);
44
+ assert.equal(currentArtifactFor({ workspace: 'acme' }), null);
45
45
  });
46
46
 
47
47
  test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
@@ -52,10 +52,10 @@ test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
52
52
  });
53
53
 
54
54
  test('rememberArtifact records a workspace-scoped artifact and ignores empty paths', () => {
55
- const session = { workspace: 'acpi' };
55
+ const session = { workspace: 'acme' };
56
56
  rememberArtifact(session, { path: 'templates/notes/basic.md', kind: 'template' });
57
57
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
58
- assert.equal(session.currentArtifact.workspace, 'acpi');
58
+ assert.equal(session.currentArtifact.workspace, 'acme');
59
59
  assert.equal(session.currentArtifact.kind, 'template');
60
60
  rememberArtifact(session, { path: ' ', kind: 'template' });
61
61
  assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
@@ -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.71';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -830,7 +830,7 @@ test('callMcpTool re-negotiates and replays once when the agent drops the sessio
830
830
 
831
831
  try {
832
832
  const endpoint = { status: 'connected', url: 'http://127.0.0.1:3336/mcp/' };
833
- const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'juno' });
833
+ const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'demo' });
834
834
 
835
835
  assert.equal(result.content[0].text, 'status: configured');
836
836
  assert.deepEqual(requests, [
@@ -27,25 +27,25 @@ function workspaceEntry(root, name) {
27
27
 
28
28
  test('the current workspace is never counted as another one', async () => {
29
29
  const root = mkdtempSync(join(tmpdir(), 'ws-running-self-'));
30
- const acpi = workspaceEntry(root, 'acpi');
30
+ const acme = workspaceEntry(root, 'acme');
31
31
 
32
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, [acpi]);
32
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, [acme]);
33
33
 
34
34
  assert.deepEqual(busy, [], 'stopping a workspace must not be blocked by itself');
35
35
  });
36
36
 
37
37
  test('an unqueryable workspace does not hold the shared agents hostage', async () => {
38
38
  const root = mkdtempSync(join(tmpdir(), 'ws-running-unknown-'));
39
- const workspaces = [workspaceEntry(root, 'acpi'), workspaceEntry(root, 'stale')];
39
+ const workspaces = [workspaceEntry(root, 'acme'), workspaceEntry(root, 'stale')];
40
40
 
41
- const busy = await otherWorkspacesRunning({ workspace: 'acpi' }, workspaces);
41
+ const busy = await otherWorkspacesRunning({ workspace: 'acme' }, workspaces);
42
42
 
43
43
  assert.deepEqual(busy, []);
44
44
  });
45
45
 
46
46
  test('a single workspace, or none at all, never blocks', async () => {
47
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, []), []);
47
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, []), []);
48
48
  assert.deepEqual(await otherWorkspacesRunning({}, []), []);
49
49
  // Entries without a name are registry noise, not workspaces.
50
- assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acpi' }, [{}, null]), []);
50
+ assert.deepEqual(await otherWorkspacesRunning({ workspace: 'acme' }, [{}, null]), []);
51
51
  });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Adapter RuntimeEvent -> événements natifs du manager (RFC § 16).
3
+ *
4
+ * Le flux d'événements d'un runtime externe est traduit ici dans le
5
+ * vocabulaire du reducer (`core/agentEvents.js`) SANS aucune refonte de l'UI :
6
+ *
7
+ * - `message` devient un `assistant_message` : c'est ce que Donna affiche ;
8
+ * - les événements d'action (`tool_*`, `subagent_*`, `approval_required`)
9
+ * deviennent des lignes de journal structurées (`runtime_log`) ;
10
+ * - le raisonnement privé (`agent_thinking`) n'est jamais ré-émis (RFC § 15) ;
11
+ * - les événements terminaux (`run_completed`/`run_failed`/`run_cancelled`)
12
+ * ne sont pas ré-émis : ils sont déjà portés par le poll `status()` du
13
+ * dispatcher, qui construit le résultat de tâche à partir de là.
14
+ *
15
+ * La fonction est pure et déterministe : un événement produit zéro ou
16
+ * plusieurs descripteurs `{ type, payload }`. Le dispatcher porte l'identité
17
+ * run/task au moment de la dépêche.
18
+ */
19
+ export function mapRuntimeEvent(event) {
20
+ const type = String(event?.type ?? '');
21
+ switch (type) {
22
+ case 'message': {
23
+ const content = String(event?.content ?? event?.message ?? '').trim();
24
+ return content ? [{ type: 'assistant_message', payload: { content } }] : [];
25
+ }
26
+ case 'tool_started':
27
+ return log(`tool ${toolLabel(event)} started`);
28
+ case 'tool_finished': {
29
+ const duration = Number.isFinite(Number(event?.durationMs))
30
+ ? ` (${Math.round(Number(event.durationMs))}ms)`
31
+ : '';
32
+ const summary = String(event?.resultSummary ?? '').trim();
33
+ const error = String(event?.error ?? '').trim();
34
+ if (error) return log(`tool ${toolLabel(event)} failed: ${error}${duration}`);
35
+ return log(`tool ${toolLabel(event)} done${duration}${summary ? ` — ${summary}` : ''}`);
36
+ }
37
+ case 'subagent_started':
38
+ return log(`subagent ${subagentLabel(event)} started`);
39
+ case 'subagent_finished':
40
+ return log(`subagent ${subagentLabel(event)} finished`);
41
+ case 'approval_required': {
42
+ // Human-in-the-loop du runtime (RFC § 14) : l'analyse pré-exécution
43
+ // devient une demande d'approbation native. Les mutations annoncées
44
+ // deviennent les classes d'approbation ; le dispatcher attend qu'un
45
+ // grant humain les couvre avant de débloquer le runtime.
46
+ const proposal = event?.proposal && typeof event.proposal === 'object' ? event.proposal : {};
47
+ const mutations = Array.isArray(proposal?.mutations) ? proposal.mutations : [];
48
+ const classes = [...new Set(mutations.map((mutation) => String(mutation?.kind ?? '').trim()).filter(Boolean))];
49
+ return [{
50
+ type: 'approval.requested',
51
+ payload: {
52
+ approvalId: String(event?.approvalId ?? 'runtime-approval'),
53
+ scope: 'run',
54
+ approvalClasses: classes,
55
+ reason: String(event?.reason ?? proposal?.summary ?? ''),
56
+ proposal,
57
+ },
58
+ }];
59
+ }
60
+ case 'run_started':
61
+ case 'run_created':
62
+ case 'agent_thinking':
63
+ case 'run_completed':
64
+ case 'run_failed':
65
+ case 'run_cancelled':
66
+ default:
67
+ return [];
68
+ }
69
+ }
70
+
71
+ function log(message) {
72
+ return [{ type: 'runtime_log', payload: { message } }];
73
+ }
74
+
75
+ function toolLabel(event) {
76
+ return String(event?.tool ?? event?.name ?? 'tool');
77
+ }
78
+
79
+ function subagentLabel(event) {
80
+ return String(event?.subagent ?? event?.tool ?? event?.name ?? 'subagent');
81
+ }