@dotdrelle/wiki-manager 0.12.11 → 0.14.0

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 (42) hide show
  1. package/.env.example +6 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/agent/graph.js +377 -142
  5. package/src/agent/graph.test.js +576 -34
  6. package/src/agent/llm.js +5 -5
  7. package/src/cli/wiki-manager.js +294 -9
  8. package/src/cli/wiki-manager.test.js +28 -0
  9. package/src/commands/slash.js +80 -13
  10. package/src/commands/slash.test.js +9 -1
  11. package/src/contracts/schemas.js +33 -0
  12. package/src/contracts/schemas.test.js +14 -0
  13. package/src/core/agentEvents.js +6 -0
  14. package/src/core/agentEvents.test.js +26 -0
  15. package/src/core/agentLoop.js +15 -16
  16. package/src/core/agentLoop.test.js +9 -7
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/mcp.js +13 -6
  19. package/src/core/mcp.test.js +0 -12
  20. package/src/core/skills.js +0 -28
  21. package/src/orchestrator/capabilityRegistry.js +14 -0
  22. package/src/orchestrator/capabilityRegistry.test.js +12 -1
  23. package/src/orchestrator/dependencyResolver.js +10 -1
  24. package/src/orchestrator/dispatcher.js +34 -3
  25. package/src/orchestrator/dispatcher.test.js +34 -0
  26. package/src/orchestrator/objectiveResolver.js +79 -0
  27. package/src/orchestrator/objectiveResolver.test.js +50 -0
  28. package/src/orchestrator/scheduler.js +24 -0
  29. package/src/orchestrator/scheduler.test.js +65 -1
  30. package/src/runtime/client.js +34 -2
  31. package/src/runtime/lifecycle.js +1 -1
  32. package/src/runtime/recoveryManager.js +14 -7
  33. package/src/runtime/runner.js +214 -14
  34. package/src/runtime/runner.test.js +100 -2
  35. package/src/runtime/server.js +43 -3
  36. package/src/runtime/supervisor.js +65 -1
  37. package/src/runtime/supervisor.test.js +80 -0
  38. package/src/shell/LeftPane.tsx +9 -2
  39. package/src/shell/repl.js +57 -42
  40. package/src/shell/repl.test.js +81 -12
  41. package/src/shell/tui.tsx +26 -3
  42. package/src/shell/useSession.ts +15 -3
@@ -15,6 +15,8 @@ import { extractActivity, parseJsonText, sessionActivities, terminalFailures } f
15
15
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
16
16
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
17
17
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
18
+ import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
19
+ import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
18
20
  // Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
19
21
  // They are imported dynamically so the shell / TUI path never loads them.
20
22
 
@@ -55,6 +57,11 @@ function createSession() {
55
57
  };
56
58
  }
57
59
 
60
+ export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
61
+ const context = await getWorkspaceContext(request.workspace ?? null);
62
+ return context.approvalManager?.approve(request) ?? { approved: false };
63
+ }
64
+
58
65
  function timestampForFile() {
59
66
  return new Date().toISOString().replace(/[:.]/g, '-');
60
67
  }
@@ -197,6 +204,109 @@ async function runHeadlessAgenticLoop(agent, session, initialInput, log, { timeo
197
204
  return { exitCode: result.ok ? 0 : (result.waitResult?.exitCode ?? 1) };
198
205
  }
199
206
 
207
+ // Observe a runtime-delegated run from headless: the run executes server-side,
208
+ // so poll /state and mirror status transitions, new logs and the final plan
209
+ // into the headless log until the run reaches a terminal state.
210
+ async function waitForRuntimeRun(session, log, { timeoutMs, pollMs = 1500, autoApprove = false, priorRunIds = [] } = {}) {
211
+ const { fetchRuntimeState, postRuntimeApprove } = await import('../runtime/client.js');
212
+ const url = session.runtime?.url;
213
+ const workspace = session.workspace ?? null;
214
+ if (!url) return { exitCode: 0 };
215
+ // Scope strictly to the run this turn created: any run already present before
216
+ // the turn (including a stuck/zombie run) must be ignored, or the wait would
217
+ // observe/approve the wrong run and never finish.
218
+ const priorSet = new Set((priorRunIds ?? []).map(String));
219
+ const terminal = new Set(['succeeded', 'success', 'done', 'complete', 'completed', 'failed', 'error', 'cancelled', 'canceled']);
220
+ const deadline = Date.now() + timeoutMs;
221
+ const graceDeadline = Date.now() + 8000;
222
+ let lastStatus = null;
223
+ let lastLogCount = 0;
224
+ let sawRun = false;
225
+ const approvedRevisions = new Set();
226
+ while (Date.now() < deadline) {
227
+ let state;
228
+ try {
229
+ state = await fetchRuntimeState({ url, workspace });
230
+ } catch (err) {
231
+ const line = `runtime-wait: state fetch failed (${err instanceof Error ? err.message : String(err)})`;
232
+ log.push(line); console.error(line);
233
+ return { exitCode: 1 };
234
+ }
235
+ const logs = Array.isArray(state?.logs) ? state.logs : [];
236
+ for (const entry of logs.slice(lastLogCount)) {
237
+ const text = typeof entry === 'string' ? entry : String(entry?.message ?? JSON.stringify(entry));
238
+ log.push(`runtime: ${text}`); console.log(`[runtime] ${text}`);
239
+ }
240
+ lastLogCount = logs.length;
241
+ const runs = Array.isArray(state?.runs) ? state.runs : [];
242
+ const currentRun = runs.find((run) => run?.id && !priorSet.has(String(run.id)));
243
+ if (!currentRun) {
244
+ if (sawRun) return { exitCode: 0 };
245
+ if (Date.now() >= graceDeadline) {
246
+ const line = 'runtime-wait: no run was delegated this turn (Donna answered without starting a run).';
247
+ log.push(line); console.log(line);
248
+ return { exitCode: 0 };
249
+ }
250
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
251
+ continue;
252
+ }
253
+ sawRun = true;
254
+ const status = String(currentRun.status ?? 'running').toLowerCase();
255
+ if (status !== lastStatus) {
256
+ log.push(`runtime-status: ${status} (run ${currentRun.id})`); console.log(`[runtime] status=${status}`);
257
+ lastStatus = status;
258
+ }
259
+ // Approval is granted per task, so the run status stays "running" while a
260
+ // task waits — detect the block via state.approvals, scoped to this run.
261
+ const pendingApprovals = (Array.isArray(state?.approvals) ? state.approvals : [])
262
+ .filter((approval) => approval.status === 'pending_approval'
263
+ && (approval.runId == null || String(approval.runId) === String(currentRun.id)));
264
+ if (pendingApprovals.length > 0) {
265
+ if (!autoApprove) {
266
+ const line = `runtime-wait: run ${currentRun.id} waiting for approval (${pendingApprovals.length} task(s)); re-run with --auto-approve to drive it through.`;
267
+ log.push(line); console.log(line);
268
+ return { exitCode: 0 };
269
+ }
270
+ const planRevision = state?.planRevision ?? currentRun.planRevision ?? 0;
271
+ if (!approvedRevisions.has(planRevision)) {
272
+ approvedRevisions.add(planRevision);
273
+ const approvalClasses = [...new Set(pendingApprovals.flatMap((approval) => {
274
+ const value = approval.approvalClasses ?? approval.approvalClass ?? [];
275
+ return Array.isArray(value) ? value : [value];
276
+ }).map(String).filter(Boolean))];
277
+ try {
278
+ const result = await postRuntimeApprove({
279
+ url,
280
+ workspace,
281
+ runId: currentRun.id,
282
+ scope: 'run',
283
+ planRevision,
284
+ approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
285
+ });
286
+ const line = `runtime-wait: auto-approved run ${currentRun.id} (revision ${planRevision})${result?.approved ? '' : ' [no pending approval matched]'}`;
287
+ log.push(line); console.log(line);
288
+ } catch (err) {
289
+ const line = `runtime-wait: auto-approve failed (${err instanceof Error ? err.message : String(err)})`;
290
+ log.push(line); console.error(line);
291
+ return { exitCode: 1 };
292
+ }
293
+ }
294
+ }
295
+ if (terminal.has(status)) {
296
+ const plan = Array.isArray(currentRun.plan) ? currentRun.plan
297
+ : (Array.isArray(state?.plan) ? state.plan : []);
298
+ if (plan.length > 0) {
299
+ log.push(`runtime-plan:\n${plan.map((planStep) => ` - ${planStep.description ?? planStep.id ?? planStep.step ?? ''}: ${planStep.status ?? ''}`).join('\n')}`);
300
+ }
301
+ return { exitCode: status === 'failed' || status === 'error' ? 1 : 0 };
302
+ }
303
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
304
+ }
305
+ const line = 'runtime-wait: timeout waiting for the delegated run to finish.';
306
+ log.push(line); console.error(line);
307
+ return { exitCode: 1 };
308
+ }
309
+
200
310
  async function runHeadless(argv, agent) {
201
311
  const workspaceName = valueAfter(argv, '--workspace');
202
312
  const skillName = valueAfter(argv, '--skill');
@@ -228,6 +338,37 @@ async function runHeadless(argv, agent) {
228
338
  if (!session.workspacePath) throw new Error(useResult.output || `Workspace not loaded: ${workspaceName}`);
229
339
  if (!session.llm) throw new Error(`Workspace ${workspaceName} has no usable LLM config.`);
230
340
 
341
+ // Agent-mode parity: the interactive TUI runs every turn against the
342
+ // runtime (delegation + run control) with MCP connected. Without a
343
+ // runtime, the graph exposes no runtime__delegate tool, so any action
344
+ // request degrades to a chat-like text answer instead of a real delegated
345
+ // run — which is exactly why headless "looked like chat mode". Connect the
346
+ // same way the TUI does. Use --no-runtime for the legacy direct-MCP path.
347
+ if (!argv.includes('--no-runtime')) {
348
+ try {
349
+ const { ensureRuntime } = await import('../runtime/lifecycle.js');
350
+ const runtime = await ensureRuntime();
351
+ session.runtime = runtime?.url ? { url: runtime.url, started: Boolean(runtime.started) } : null;
352
+ step(`runtime: ${session.runtime ? `connected ${session.runtime.url}` : 'unavailable'}`);
353
+ } catch (err) {
354
+ session.runtime = null;
355
+ step(`runtime: unavailable (${err instanceof Error ? err.message : String(err)})`);
356
+ }
357
+ }
358
+ await refreshMcpRuntimeStatus(session);
359
+ step(`mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`);
360
+ // Surface which tools Donna is actually offered this turn: if a factual
361
+ // question is answered without the matching read tool appearing here, the
362
+ // problem is discovery/connection, not the model.
363
+ for (const [name, value] of Object.entries(session.mcp ?? {})) {
364
+ if (value?.status !== 'connected') continue;
365
+ const toolNames = (value.tools ?? []).map((tool) => tool.name).join(', ');
366
+ step(`mcp-tools ${name}: ${toolNames || '(none discovered)'}`);
367
+ }
368
+ // Wire the graph's step trace (classification, tool calls, retries) into
369
+ // the headless log so the agent-mode decision is observable.
370
+ session._onStep = step;
371
+
231
372
  let input = prompt;
232
373
  if (skillName) {
233
374
  const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
@@ -259,11 +400,26 @@ async function runHeadless(argv, agent) {
259
400
  if (useAgenticLoop) {
260
401
  ({ exitCode } = await runHeadlessAgenticLoop(agent, session, input, log, { timeoutMs, maxTurns }));
261
402
  } else {
403
+ // Snapshot existing runs so the wait scopes strictly to the run this turn
404
+ // creates and never observes a pre-existing / zombie run.
405
+ let priorRunIds = [];
406
+ if (session.runtime?.url && wait) {
407
+ try {
408
+ const { fetchRuntimeState } = await import('../runtime/client.js');
409
+ const before = await fetchRuntimeState({ url: session.runtime.url, workspace: session.workspace ?? null });
410
+ priorRunIds = (Array.isArray(before?.runs) ? before.runs : []).map((run) => run?.id).filter(Boolean);
411
+ } catch { priorRunIds = []; }
412
+ }
262
413
  const response = await runAgentTurn(agent, session, input);
263
414
  log.push('response:');
264
415
  log.push(response);
265
416
  console.log(response);
266
- ({ exitCode } = await runHeadlessActivityLoop(session, log, { wait, timeoutMs }));
417
+ // When connected to the runtime, an action turn delegates a run that
418
+ // executes server-side — its progress lives in runtime state, not in the
419
+ // local session. Poll it so the headless log shows the real outcome.
420
+ ({ exitCode } = session.runtime?.url && wait
421
+ ? await waitForRuntimeRun(session, log, { timeoutMs, autoApprove: argv.includes('--auto-approve'), priorRunIds })
422
+ : await runHeadlessActivityLoop(session, log, { wait, timeoutMs }));
267
423
  }
268
424
  const saved = await writeHeadlessLog(session, log, logFile);
269
425
  console.log(`Headless log: ${saved}`);
@@ -590,6 +746,55 @@ async function runRuntime(argv, agent) {
590
746
  };
591
747
  }
592
748
 
749
+ async function prepareDelegation(context, { objective }) {
750
+ const { resolveObjective } = await import('../orchestrator/objectiveResolver.js');
751
+ const { validateFragment } = await import('../orchestrator/planValidator.js');
752
+ const session = context.session;
753
+ const selection = await resolveObjective(objective, session);
754
+ const provider = selection.provider;
755
+ const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(
756
+ session.mcp,
757
+ provider.serverName,
758
+ 'agent_plan',
759
+ {
760
+ capability: selection.capability,
761
+ operation: selection.operation,
762
+ objective,
763
+ workspace: { revision: String(Date.now()) },
764
+ constraints: {
765
+ maxConcurrency: resolveCapabilityConcurrency(
766
+ provider,
767
+ undefined,
768
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
769
+ ),
770
+ requireApprovalForMutations: true,
771
+ },
772
+ },
773
+ )));
774
+ if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
775
+ throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
776
+ }
777
+ const validation = validateFragment(fragment, {
778
+ registry: capabilityRegistryForSession(session),
779
+ run: { plannerAgentInstanceId: provider.agentInstanceId ?? provider.serverName },
780
+ });
781
+ if (!validation.ok) {
782
+ throw new Error(`Delegated plan rejected: ${validation.errors.map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
783
+ }
784
+ return {
785
+ capability: selection.capability,
786
+ operation: selection.operation,
787
+ provider: { serverName: provider.serverName, agentInstanceId: provider.agentInstanceId ?? provider.serverName },
788
+ fragment: validation.normalizedFragment,
789
+ summary: {
790
+ capability: selection.capability,
791
+ operation: selection.operation,
792
+ agent: provider.agentInstanceId ?? provider.serverName,
793
+ tasks: validation.normalizedFragment.tasks.length,
794
+ },
795
+ };
796
+ }
797
+
593
798
  async function executeRun(context, body, { signal } = {}) {
594
799
  const session = context.session;
595
800
  const supervisor = context.supervisor;
@@ -628,6 +833,87 @@ async function runRuntime(argv, agent) {
628
833
  : undefined;
629
834
  supervisor?.setRunSignal(signal);
630
835
  session._onStep = (message) => emitRuntimeLog(session, message);
836
+ if (body.preparedDelegation?.fragment) {
837
+ const { integrate } = await import('../orchestrator/planIntegrator.js');
838
+ const prepared = body.preparedDelegation;
839
+ const integrated = integrate(runId, prepared.fragment, {
840
+ registry: capabilityRegistryForSession(session),
841
+ session,
842
+ store,
843
+ workspace: session.workspace ?? null,
844
+ enforceApprovalCoverage: true,
845
+ });
846
+ if (!integrated.ok) {
847
+ throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
848
+ }
849
+ emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
850
+ body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
851
+ }
852
+ // Deterministic capability run (/ingest): ask the capable agent for its
853
+ // task-graph fragment and integrate it as the plan BEFORE any LLM turn.
854
+ // The parallel path must not depend on a small model deciding to call
855
+ // agent_plan by itself.
856
+ if (body.capabilityPlan?.capability) {
857
+ const { validateFragment } = await import('../orchestrator/planValidator.js');
858
+ const { integrate } = await import('../orchestrator/planIntegrator.js');
859
+ const registry = capabilityRegistryForSession(session);
860
+ const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
861
+ const provider = agents.find((item) => (item.description?.capabilities ?? [])
862
+ .some((capability) => capability.id === body.capabilityPlan.capability));
863
+ if (!provider?.serverName) {
864
+ throw new Error(`No agent provides capability ${body.capabilityPlan.capability}.`);
865
+ }
866
+ const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
867
+ capability: body.capabilityPlan.capability,
868
+ operation: body.capabilityPlan.operation ?? undefined,
869
+ workspace: { revision: String(Date.now()) },
870
+ constraints: {
871
+ // The agent declares its capacity. Request/env values are only
872
+ // constraints: they may lower that capacity, never raise it.
873
+ maxConcurrency: resolveCapabilityConcurrency(
874
+ provider,
875
+ body.capabilityPlan.maxConcurrency,
876
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
877
+ ),
878
+ requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
879
+ },
880
+ ...(Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
881
+ ? { arguments: { inputs: body.capabilityPlan.inputs } }
882
+ : {}),
883
+ })));
884
+ if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
885
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
886
+ origin: 'runtime',
887
+ runId,
888
+ payload: { content: `Aucune tâche à planifier pour ${body.capabilityPlan.capability} (${fragment?.summary?.initialSynthesis?.[0] ?? 'fragment vide'}).` },
889
+ }));
890
+ dispatchAgentEvent(session, createAgentEvent('run_done', { origin: 'runtime', runId, payload: { runId } }));
891
+ return;
892
+ }
893
+ // Full official integration path — NOT a bare plan_set: integrate()
894
+ // validates the fragment, persists the tasks, and CREATES the
895
+ // approval requests the scheduler's approvalCovered() filter waits
896
+ // for. A bare plan_set left requiresApproval tasks unreachable
897
+ // forever (stalled as no_ready_plan_task with nothing to approve).
898
+ const validation = validateFragment(fragment, {
899
+ registry,
900
+ run: { plannerAgentInstanceId: provider.agentInstanceId ?? provider.serverName },
901
+ });
902
+ if (!validation.ok) {
903
+ throw new Error(`Capability plan rejected: ${validation.errors.map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
904
+ }
905
+ const integrated = integrate(runId, validation.normalizedFragment, {
906
+ registry,
907
+ session,
908
+ store,
909
+ workspace: session.workspace ?? null,
910
+ enforceApprovalCoverage: true,
911
+ });
912
+ if (!integrated.ok) {
913
+ throw new Error(`Capability plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
914
+ }
915
+ 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`);
916
+ }
631
917
  await runRuntimeAgenticWorkflow(agent, session, input, {
632
918
  signal,
633
919
  timeoutMs,
@@ -638,6 +924,7 @@ async function runRuntime(argv, agent) {
638
924
  ...(maxReplans === undefined ? {} : { maxReplans }),
639
925
  });
640
926
  } catch (err) {
927
+ body._planReady?.reject?.(err);
641
928
  if (err?.name === 'AbortError') {
642
929
  // Cancel the asynchronous agent jobs the run started: aborting only
643
930
  // the manager loop left ingest subprocesses running for minutes with
@@ -681,12 +968,10 @@ async function runRuntime(argv, agent) {
681
968
  .filter((context) => context?.running)
682
969
  .map((context) => ({ workspace: context.workspace ?? null, runId: context.currentRunId ?? null })),
683
970
  run: executeRun,
971
+ delegate: prepareDelegation,
684
972
  cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
685
973
  resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
686
- approve: async ({ workspace, runId, itemId, approvalId }) => {
687
- const context = await getWorkspaceContext(workspace);
688
- return context.approvalManager?.approve({ runId, itemId, approvalId }) ?? { approved: false };
689
- },
974
+ approve: (request) => forwardRuntimeApproval(getWorkspaceContext, request),
690
975
  configProfiles: async (context) => {
691
976
  const profiles = listWikircProfiles(context.session.workspacePath);
692
977
  return {
@@ -811,11 +1096,11 @@ export async function runCli(argv) {
811
1096
  runtime = unavailableRuntime(err);
812
1097
  console.error(`Runtime unavailable: ${runtime.error}`);
813
1098
  }
1099
+ // The owned-runtime shutdown happens inside the TUI's own exit paths
1100
+ // (see tui.tsx onShellExit): render() resolves at MOUNT, so anything
1101
+ // after this await would run while the shell is still on screen —
1102
+ // 0.12.9 shipped exactly that bug and killed the runtime under the user.
814
1103
  await runOpenTuiShell({ agent, packageJson, runtime });
815
- if (runtime?.url) {
816
- const { shutdownOwnedRuntime } = await import('../runtime/lifecycle.js');
817
- await shutdownOwnedRuntime(runtime, { log: (message) => console.log(`[wiki-manager] ${message}`) });
818
- }
819
1104
  return;
820
1105
  }
821
1106
 
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { forwardRuntimeApproval } from './wiki-manager.js';
4
+
5
+ test('runtime approval bridge preserves the complete run-scoped grant', async () => {
6
+ let forwarded = null;
7
+ const request = {
8
+ workspace: 'test4',
9
+ workspaceId: 'test4',
10
+ runId: 'run-1',
11
+ scope: 'run',
12
+ planRevision: 3,
13
+ approvalClasses: ['mutation'],
14
+ };
15
+
16
+ const result = await forwardRuntimeApproval(async (workspace) => ({
17
+ approvalManager: {
18
+ approve(value) {
19
+ assert.equal(workspace, 'test4');
20
+ forwarded = value;
21
+ return { approved: true };
22
+ },
23
+ },
24
+ }), request);
25
+
26
+ assert.deepEqual(forwarded, request);
27
+ assert.deepEqual(result, { approved: true });
28
+ });
@@ -43,7 +43,7 @@ import {
43
43
  listDocumentUploads,
44
44
  storeAndMaybeConvertDocument,
45
45
  } from '../core/documentIntake.js';
46
- import { fetchRuntimeState, postRuntimeCancel, postRuntimeKill } from '../runtime/client.js';
46
+ import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeKill, postRuntimeRun } from '../runtime/client.js';
47
47
  import { versionWithBuild } from '../core/buildInfo.js';
48
48
 
49
49
  export function printVersion(packageJson) {
@@ -629,6 +629,8 @@ ${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
629
629
  ${helpPair('/chat', 'Chat mode', '/agent', 'Agent mode')}
630
630
  ${helpPair('/openui', 'Open web UI in browser', '', '')}
631
631
  ${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
632
+ ${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
633
+ ${helpPair('/cancel', 'Cancel active run', '', '')}
632
634
  ${helpPair('/run cancel', 'Cancel active run', '', '')}
633
635
  ${helpPair('/queue', 'MCP job queue', '/queue clear', 'Clear finished')}
634
636
  ${helpPair('/queue cancel <id>', 'Cancel queued/running', '', '')}
@@ -674,6 +676,20 @@ function rawCommandResult(command, output) {
674
676
  };
675
677
  }
676
678
 
679
+ export function localizedOperationResult({ operation, target, status = 'succeeded' }) {
680
+ const facts = JSON.stringify({ operation, target, status });
681
+ return {
682
+ output: facts,
683
+ rawOutput: true,
684
+ agentTrigger: [
685
+ 'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
686
+ 'Réponds par une seule phrase humaine et naturelle.',
687
+ 'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
688
+ `Résultat: ${facts}`,
689
+ ].join('\n'),
690
+ };
691
+ }
692
+
677
693
  function formatRuntimeRunStatus(state) {
678
694
  const status = state?.status ?? 'unknown';
679
695
  const runId = state?.runId ? ` run=${state.runId}` : '';
@@ -709,13 +725,14 @@ export async function handleSlashCommand(line, context) {
709
725
  const args = line.slice(1).trim().split(/\s+/).filter(Boolean);
710
726
  const [command] = args;
711
727
  const step = context.onStep ?? (() => {});
712
- const runAgentCommand = async (fn, verb, commandLabel) => {
728
+ const runAgentCommand = async (fn, verb) => {
713
729
  try {
714
730
  step(`Agents: ${verb}ing external agents…`);
715
- const output = await fn();
716
- return output
717
- ? rawCommandResult(commandLabel, output)
718
- : { output: `Agents ${verb}ed.` };
731
+ await fn();
732
+ return localizedOperationResult({
733
+ operation: verb,
734
+ target: 'agents',
735
+ });
719
736
  } catch (err) {
720
737
  step(formatActivityError('agents', verb, err));
721
738
  return { output: err instanceof Error ? err.message : String(err) };
@@ -874,13 +891,16 @@ export async function handleSlashCommand(line, context) {
874
891
  // do not remap it to undefined, that bypasses any custom "all" target list and always
875
892
  // falls back to the hardcoded COMPOSE_SERVICES constant instead.
876
893
  const service = args[1];
877
- if (service === 'agents') return runAgentCommand(startAgents, 'start', '/start agents');
894
+ if (service === 'agents') return runAgentCommand(startAgents, 'start');
878
895
  try {
879
896
  step(`Services: starting ${service ?? 'workspace services'}…`);
880
- const output = await startService(context.session, service);
897
+ await startService(context.session, service);
881
898
  step('Services: refreshing MCP runtime…');
882
899
  await refreshMcpRuntimeStatus(context.session);
883
- return rawCommandResult(`/start${service ? ` ${service}` : ''}`, output);
900
+ return localizedOperationResult({
901
+ operation: 'start',
902
+ target: service || 'workspace-services',
903
+ });
884
904
  } catch (err) {
885
905
  const message = err instanceof Error ? err.message : String(err);
886
906
  step(formatActivityError('services', 'stop', err));
@@ -889,13 +909,16 @@ export async function handleSlashCommand(line, context) {
889
909
  }
890
910
  case 'stop': {
891
911
  const service = args[1];
892
- if (service === 'agents') return runAgentCommand(stopAgents, 'stop', '/stop agents');
912
+ if (service === 'agents') return runAgentCommand(stopAgents, 'stop');
893
913
  try {
894
914
  step(`Services: stopping ${service ?? 'workspace services'}…`);
895
- const output = await stopService(context.session, service);
915
+ await stopService(context.session, service);
896
916
  step('Services: refreshing MCP runtime…');
897
917
  await refreshMcpRuntimeStatus(context.session);
898
- return rawCommandResult(`/stop${service ? ` ${service}` : ''}`, output);
918
+ return localizedOperationResult({
919
+ operation: 'stop',
920
+ target: service || 'workspace-services',
921
+ });
899
922
  } catch (err) {
900
923
  const message = err instanceof Error ? err.message : String(err);
901
924
  step(formatActivityError('services', 'logs', err));
@@ -966,6 +989,27 @@ export async function handleSlashCommand(line, context) {
966
989
  }
967
990
  return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
968
991
  }
992
+ case 'cancel': {
993
+ // Alias of /run cancel — people type /cancel when they want out.
994
+ const runtime = context.runtime ?? {};
995
+ if (!runtime.url) return { output: 'Runtime unavailable. Start/connect the runtime before using /cancel.' };
996
+ const result = await postRuntimeCancel({ url: runtime.url, workspace: context.session.workspace ?? null });
997
+ return { output: result.cancelled ? 'Runtime cancel requested.' : `Nothing to cancel${result.reason ? ` (${result.reason})` : ''} — use /run kill to purge everything.` };
998
+ }
999
+ case 'approve': {
1000
+ // /approve was only wired in the legacy REPL — in the opentui TUI it
1001
+ // returned "Unknown command", which made every approval time out and
1002
+ // every requiresApproval plan stall forever.
1003
+ const runtime = context.runtime ?? {};
1004
+ if (!runtime.url) return { output: 'Runtime unavailable. Start/connect the runtime before using /approve.' };
1005
+ const result = await postRuntimeControl('message', {
1006
+ url: runtime.url,
1007
+ workspace: context.session.workspace ?? null,
1008
+ input: args.slice(1).join(' ') || 'approve',
1009
+ intent: 'approve',
1010
+ });
1011
+ return { output: String(result?.explanation ?? (result?.accepted ? 'Approval granted.' : 'No pending approval found.')) };
1012
+ }
969
1013
  case 'run': {
970
1014
  const subcommand = args[1] ?? 'status';
971
1015
  const runtime = context.runtime ?? {};
@@ -979,11 +1023,34 @@ export async function handleSlashCommand(line, context) {
979
1023
  const result = await postRuntimeCancel({ url, workspace: context.session.workspace ?? null });
980
1024
  return { output: result.cancelled ? 'Runtime cancel requested.' : `Runtime cancel skipped: ${result.reason ?? 'no active run'}` };
981
1025
  }
1026
+ if (subcommand === 'capability') {
1027
+ // Business-agnostic deterministic run: mirrors the capability
1028
+ // registry instead of hardcoding an application verb. The agent's
1029
+ // task graph is validated/integrated server-side before any LLM turn.
1030
+ const capability = args[2];
1031
+ if (!capability) return { output: 'Usage: /run capability <capability-id> [operation] [files…]' };
1032
+ if (!context.session.workspace) return { output: 'No workspace loaded. Use /use <workspace> first.' };
1033
+ const operation = args[3] && !args[3].includes('.') && !args[3].includes('/') ? args[3] : undefined;
1034
+ const inputs = args.slice(operation ? 4 : 3);
1035
+ const result = await postRuntimeRun(`Run de capability ${capability}${operation ? ` (${operation})` : ''} demandé via /run capability.`, {
1036
+ url,
1037
+ workspace: context.session.workspace,
1038
+ capabilityPlan: {
1039
+ capability,
1040
+ ...(operation ? { operation } : {}),
1041
+ ...(inputs.length > 0 ? { inputs } : {}),
1042
+ },
1043
+ });
1044
+ if (result?.runId) {
1045
+ return { output: `▶ Run de capability accepté (${String(result.runId).slice(0, 8)}) — le plan de l'agent sera intégré et dispatché en parallèle ; approbation demandée avant les mutations (« valide tout » ou /approve).` };
1046
+ }
1047
+ return { output: `Run non démarré: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
1048
+ }
982
1049
  if (subcommand === 'kill') {
983
1050
  const result = await postRuntimeKill({ url, workspace: context.session.workspace ?? null, runId: args[2] ?? null });
984
1051
  return { output: `Runtime kill requested: ${result.runs ?? 0} run${result.runs === 1 ? '' : 's'}, ${result.tasks ?? 0} task${result.tasks === 1 ? '' : 's'} cancelled.` };
985
1052
  }
986
- return { output: 'Usage: /run [status|cancel|kill [runId]]' };
1053
+ return { output: 'Usage: /run [status|cancel|kill [runId]|capability <id> [operation] [files…]]' };
987
1054
  }
988
1055
  case 'queue': {
989
1056
  const subcommand = args[1] ?? 'list';
@@ -4,9 +4,17 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { handleSlashCommand } from './slash.js';
7
+ import { handleSlashCommand, localizedOperationResult } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
+ test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
11
+ const result = localizedOperationResult({ operation: 'start', target: 'agents' });
12
+ assert.equal(result.rawOutput, true);
13
+ assert.deepEqual(JSON.parse(result.output), { operation: 'start', target: 'agents', status: 'succeeded' });
14
+ assert.match(result.agentTrigger, /une seule phrase humaine et naturelle/);
15
+ assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
16
+ });
17
+
10
18
  test('/workspace delete removes files and clears current session context after confirmation', async () => {
11
19
  const root = await mkdtemp(join(tmpdir(), 'wiki-manager-delete-workspace-'));
12
20
  const registryRoot = join(root, 'registry');
@@ -131,6 +131,38 @@ const agentDescriptionSchema = {
131
131
  },
132
132
  };
133
133
 
134
+ const pendingInputSchema = {
135
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/pending-input/v1',
136
+ title: 'PendingInput',
137
+ schemaVersion: '1',
138
+ type: 'object',
139
+ required: ['type', 'ref'],
140
+ additionalProperties: true,
141
+ properties: {
142
+ type: { type: 'string', minLength: 1 },
143
+ ref: { type: 'string', minLength: 1 },
144
+ label: nullableString,
145
+ mediaType: nullableString,
146
+ },
147
+ };
148
+
149
+ const capabilityStatusSchema = {
150
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/capability-status/v1',
151
+ title: 'CapabilityStatus',
152
+ schemaVersion: '1',
153
+ type: 'object',
154
+ required: ['contractVersion', 'agentInstanceId', 'capability', 'operation', 'available', 'pendingInputs'],
155
+ additionalProperties: true,
156
+ properties: {
157
+ contractVersion: { type: 'string', minLength: 1 },
158
+ agentInstanceId: { type: 'string', minLength: 1 },
159
+ capability: { type: 'string', minLength: 1 },
160
+ operation: { type: 'string', minLength: 1 },
161
+ available: { type: 'boolean' },
162
+ pendingInputs: { type: 'array', items: pendingInputSchema },
163
+ },
164
+ };
165
+
134
166
  const taskGroupSchema = {
135
167
  $id: 'https://dotdrelle.dev/wiki-manager/contracts/task-group/v1',
136
168
  title: 'TaskGroup',
@@ -424,6 +456,7 @@ export const contractSchemas = {
424
456
  outputReference: outputReferenceSchema,
425
457
  capabilityDescription: capabilityDescriptionSchema,
426
458
  agentDescription: agentDescriptionSchema,
459
+ capabilityStatus: capabilityStatusSchema,
427
460
  retryPolicy: retryPolicySchema,
428
461
  taskGroup: taskGroupSchema,
429
462
  plannedTask: plannedTaskSchema,
@@ -236,3 +236,17 @@ test('agent description contract validates orchestrable agent capabilities', ()
236
236
  assert.equal(validateContract('capabilityDescription', description.capabilities[0]).ok, true);
237
237
  assert.equal(validateContract('agentDescription', { ...description, health: { status: 'offline' } }).ok, false);
238
238
  });
239
+
240
+ test('capability status contract carries dynamic pending inputs without prescribing storage paths', () => {
241
+ const status = {
242
+ contractVersion: '1',
243
+ agentInstanceId: 'production-main',
244
+ capability: 'knowledge.update',
245
+ operation: 'ingest',
246
+ available: true,
247
+ pendingInputs: [{ type: 'file', ref: 'provider-owned/source-a', label: 'source-a.md', mediaType: 'text/markdown' }],
248
+ };
249
+
250
+ assert.equal(validateContract('capabilityStatus', status).ok, true);
251
+ assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
252
+ });
@@ -490,6 +490,12 @@ function applyEvent(state, event) {
490
490
  case 'run_error':
491
491
  state.status = 'error';
492
492
  state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
493
+ // A dead run must not leave "pending" plan steps and spinning
494
+ // activities in the persisted projection: they reappeared as ghosts
495
+ // at every relaunch ("des trucs dans le plan qui n'existent pas") and
496
+ // /kill honestly reported 0 because nothing was actually running.
497
+ cancelPendingPlanSteps(state.plan);
498
+ cancelActiveActivities(state.activities, event.ts);
493
499
  finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
494
500
  return;
495
501
  case 'control_enqueued':