@dotdrelle/wiki-manager 0.11.0 → 0.11.6

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.
package/README.md CHANGED
@@ -11,6 +11,12 @@ jobs, and run one-shot headless tasks.
11
11
  The manager does not implement the wiki engine or the external agents. It
12
12
  **orchestrates** them.
13
13
 
14
+ Scope note: 0.11.0 is an industrialized single-user deployment baseline. The
15
+ multi-user model is specified in `llm-wiki/docs/industrialisation.md` and
16
+ planned for 0.12.0. Until then, do not expose the runtime as a shared write
17
+ surface; it binds to `127.0.0.1` by default, and `--host 0.0.0.0` must be an
18
+ explicit deployment choice with bearer-token and network protection.
19
+
14
20
  ---
15
21
 
16
22
  ## What it's for, in one sentence
@@ -538,6 +544,14 @@ Runtime split: the host manager/runtime uses Node.js 22+ for `node:sqlite`; the
538
544
  interactive OpenTUI shell uses Bun 1.2+; workspace Docker services run from the
539
545
  published images and do not depend on host `node_modules`.
540
546
 
547
+ As of 0.11.4, the host runtime store carries a minimal format guard:
548
+ `PRAGMA user_version = 1` in SQLite plus `.wiki/meta.json` with
549
+ `schemaVersion: 1`. Unknown future versions stop startup with a clear error.
550
+ On startup, terminal runs older than 30 days are deleted with their events and
551
+ the database is vacuumed. The runtime test suite also includes a fixed-latency
552
+ parallel scheduler guard asserting that two independent build tasks run under
553
+ 65% of the sequential duration.
554
+
541
555
  ```bash
542
556
  wiki-workspace list
543
557
  wiki-workspace agents up
@@ -95,7 +95,7 @@ services:
95
95
  - MAILER_REQUIRE_CONFIRMATION=${MAILER_REQUIRE_CONFIRMATION:-true}
96
96
  - MAILER_DRY_RUN=${MAILER_DRY_RUN:-false}
97
97
  - MCP_AUTH_TOKEN=${MAILER_MCP_AUTH_TOKEN:-}
98
- volumes:
99
- # Optional: mount a CA bundle and set MAILERSEND_CA_CERT to its container path.
100
- #- ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
98
+ # Optional: mount a CA bundle and set MAILERSEND_CA_CERT to its container path.
99
+ # volumes:
100
+ # - ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
101
101
  restart: unless-stopped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.11.0",
3
+ "version": "0.11.6",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/donna-contract.test.js src/runtime/auth.test.js",
15
15
  "check-versions": "node scripts/check-versions.js",
16
16
  "prepack": "node scripts/check-versions.js",
17
17
  "prepublishOnly": "node scripts/check-versions.js",
@@ -14,6 +14,37 @@ import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy }
14
14
 
15
15
  const MAX_TOOL_ITERATIONS = 80;
16
16
  const MAX_SPINNER_ARG_LENGTH = 96;
17
+
18
+ // Deterministic guard: on the first turn of a fresh /agent input, only bind
19
+ // job-starting/mutating MCP tools when the raw text actually looks like an
20
+ // action request. Plain chat (greetings, thanks, small talk) never sees those
21
+ // tools bound, so the LLM cannot start a production job/plan from a "salut" —
22
+ // prompting alone was not reliable enough (see plan-directeur history).
23
+ const ACTION_INTENT_RE = /\b(lance|lancer|lancez|d[ée]marre|d[ée]marrer|d[ée]marrez|ex[ée]cute|ex[ée]cuter|exporte|exporter|export|importe|importer|import|g[ée]n[èe]re|g[ée]n[ée]rer|g[ée]n[ée]ration|cr[ée]e|cr[ée]er|construis|build|run|start|launch|deploy|d[ée]ploie|d[ée]ployer|d[ée]ploiement|publie|publier|publish|publication|synchronise|synchroniser|sync|convertis|convertir|convert|envoie|envoyer|send|configure|configurer|ajoute|ajouter|add|planifie|planifier|schedule|ingest|ing[èe]re|ing[èe]rer|ingestion|polish|pipeline|skill|job|t[âa]che|workflow|refais|relance|retente|retry)\b/i;
24
+ const READ_ONLY_TOOL_NAME_RE = /(^|_)(list|status|get|read|describe|show|search|find)(_|$)/i;
25
+
26
+ function isReadOnlyToolName(name) {
27
+ return READ_ONLY_TOOL_NAME_RE.test(String(name ?? ''));
28
+ }
29
+
30
+ function filterToolsForChatOnlyTurn(tools) {
31
+ return [
32
+ SHELL_READ_COMMAND_TOOL,
33
+ ...tools.filter((tool) => isReadOnlyToolName(tool?.function?.name ?? '')),
34
+ ];
35
+ }
36
+
37
+ function toolsForDonnaTurn(input, tools, iterations) {
38
+ if (iterations !== 0) return tools;
39
+ const text = String(input ?? '');
40
+ if (ACTION_INTENT_RE.test(text)) return tools;
41
+ // Plain chat (no action, no read intent) still gets the minimal read-only
42
+ // set, never an empty array: the system prompt unconditionally describes
43
+ // the connected MCP tools regardless of what's actually bound this turn,
44
+ // and sending zero tools while the prompt talks about them confuses some
45
+ // models into an empty/malformed completion instead of a plain reply.
46
+ return filterToolsForChatOnlyTurn(tools);
47
+ }
17
48
  const AGENT_SLASH_COMMANDS = new Set([
18
49
  'help',
19
50
  'version',
@@ -51,6 +82,29 @@ const SHELL_RUN_COMMAND_TOOL = {
51
82
  },
52
83
  };
53
84
 
85
+ const SHELL_READ_COMMAND_TOOL = {
86
+ type: 'function',
87
+ function: {
88
+ name: 'shell__read_command',
89
+ description: [
90
+ 'Run a read-only deterministic wiki-manager slash command inside the current shell session.',
91
+ 'Allowed commands: /help, /version, /config, /config list, /config status, /status, /services, /skills, /skills list, /skills show <name>, /uploads, /uploads list, /queue.',
92
+ 'Do not use for workspace creation/deletion, uploads conversion, service start/stop, MCP calls, wiki runs, or any mutation.',
93
+ ].join(' '),
94
+ parameters: {
95
+ type: 'object',
96
+ additionalProperties: false,
97
+ properties: {
98
+ command: {
99
+ type: 'string',
100
+ description: 'Read-only slash command to run, for example "/status", "/config status", or "/services".',
101
+ },
102
+ },
103
+ required: ['command'],
104
+ },
105
+ },
106
+ };
107
+
54
108
  const WIKI_PLAN_SET_TOOL = {
55
109
  type: 'function',
56
110
  function: {
@@ -238,6 +292,24 @@ function assertAgentSlashCommandAllowed(commandLine) {
238
292
  }
239
293
  }
240
294
 
295
+ function assertAgentReadSlashCommandAllowed(commandLine) {
296
+ const parts = commandLine.slice(1).trim().split(/\s+/).filter(Boolean);
297
+ const command = parts[0] ?? '';
298
+ const subcommand = parts[1] ?? '';
299
+ const allowed =
300
+ command === 'help' ||
301
+ command === 'version' ||
302
+ command === 'status' ||
303
+ command === 'services' ||
304
+ command === 'queue' ||
305
+ (command === 'config' && ['', 'list', 'status'].includes(subcommand)) ||
306
+ (command === 'skills' && ['', 'list', 'show'].includes(subcommand)) ||
307
+ (command === 'uploads' && ['', 'list'].includes(subcommand));
308
+ if (!allowed) {
309
+ throw new Error(`Read-only command is not available to the agent: /${parts.join(' ')}`);
310
+ }
311
+ }
312
+
241
313
  function withActiveWorkspaceForExternalTool(session, server, tool, args) {
242
314
  const needsWorkspace =
243
315
  (server === 'documents' && tool.startsWith('documents_') && tool !== 'documents_status') ||
@@ -269,6 +341,21 @@ async function runShellCommandTool(session, commandLine) {
269
341
  return result.output ?? 'Command completed.';
270
342
  }
271
343
 
344
+ async function runShellReadCommandTool(session, commandLine) {
345
+ const command = normalizeShellCommand(commandLine);
346
+ assertAgentReadSlashCommandAllowed(command);
347
+ session._onStep?.(`Shell: ${command}`);
348
+ const result = await handleSlashCommand(command, {
349
+ packageJson: session.packageJson ?? { version: '0.0.0' },
350
+ session,
351
+ onStep: session._onStep,
352
+ });
353
+ if (result.exit) {
354
+ throw new Error('/exit is not available to the agent.');
355
+ }
356
+ return result.output ?? 'Command completed.';
357
+ }
358
+
272
359
  function rememberProductionProgress(session, payload, label) {
273
360
  const job = payload?.job;
274
361
  const jobId = payload?.jobId ?? job?.jobId;
@@ -434,6 +521,7 @@ export function buildAgentSystemPrompt(state) {
434
521
  const agentContext = [
435
522
  'You are Donna, the terminal orchestrator agent for llm-wiki-manager.',
436
523
  'The shell is agent-first: every input without a leading slash is routed to you.',
524
+ 'Default to a plain conversational reply with no tool call. Only call a tool, create a plan, or start a job when the user\'s message clearly requests an action (ingest, build, export, configure, run a skill, check a concrete status, etc.). Greetings, small talk, thanks, and general questions do not warrant starting a job or calling a tool — just answer in text.',
437
525
  'Commands starting with / are deterministic primitives. You may run a safe subset through shell__run_command.',
438
526
  `Reply language: ${language}.`,
439
527
  `Current workspace: ${workspace}.`,
@@ -512,12 +600,17 @@ export function buildLimitedAgentResponse(state, reason = 'no workspace loaded w
512
600
  ].join('\n');
513
601
  }
514
602
 
603
+ export function formatLlmUnavailableMessage(reason) {
604
+ const clean = String(reason ?? 'raison inconnue').replace(/\s+/g, ' ').trim();
605
+ return `⚠ LLM injoignable : ${clean || 'raison inconnue'}`;
606
+ }
607
+
515
608
  export function createAgentGraph(options = {}) {
516
609
  async function orchestratorNode(state) {
517
610
  const llm = state.session.llm ?? options.llm ?? null;
518
611
 
519
612
  if (!llm) {
520
- return { response: buildLimitedAgentResponse(state), pendingToolCalls: null, readyToStream: false };
613
+ return { response: formatLlmUnavailableMessage('aucun client LLM configure'), pendingToolCalls: null, readyToStream: false };
521
614
  }
522
615
 
523
616
  const iterations = state.toolIterations ?? 0;
@@ -535,12 +628,13 @@ export function createAgentGraph(options = {}) {
535
628
  state.session._onStep?.('Agent: planning next action…');
536
629
  }
537
630
 
538
- const tools = [
631
+ const allTools = [
539
632
  SHELL_RUN_COMMAND_TOOL,
540
633
  WIKI_PLAN_SET_TOOL,
541
634
  WIKI_PLAN_DONE_TOOL,
542
635
  ...buildLlmTools(state.session.mcp),
543
636
  ];
637
+ const tools = toolsForDonnaTurn(state.input, allTools, iterations);
544
638
  const system = buildAgentSystemPrompt(state);
545
639
 
546
640
  // On iteration 0: prior history is in state.messages, user input must be appended.
@@ -619,7 +713,7 @@ export function createAgentGraph(options = {}) {
619
713
  } catch (err) {
620
714
  if (err.name === 'AbortError') throw err;
621
715
  const message = err instanceof Error ? err.message : String(err);
622
- return { response: buildLimitedAgentResponse(state, `LLM unavailable: ${message}`), pendingToolCalls: null, readyToStream: false };
716
+ return { response: formatLlmUnavailableMessage(message), pendingToolCalls: null, readyToStream: false };
623
717
  }
624
718
  }
625
719
 
@@ -663,6 +757,8 @@ export function createAgentGraph(options = {}) {
663
757
  } else if (server === 'shell' && tool === 'run_command') {
664
758
  await awaitRunApproval(state.session, { runId, tool: toolName });
665
759
  resultText = await runShellCommandTool(state.session, args.command);
760
+ } else if (server === 'shell' && tool === 'read_command') {
761
+ resultText = await runShellReadCommandTool(state.session, args.command);
666
762
  } else if (server !== 'shell') {
667
763
  await awaitRunApproval(state.session, { runId, tool: toolName });
668
764
  await awaitToolApproval(state.session, {
@@ -99,6 +99,87 @@ test('agent graph waits for run-level approval before first MCP action', async (
99
99
  }
100
100
  });
101
101
 
102
+ test('agent graph reports LLM unavailable without Donna active boilerplate', async () => {
103
+ const agent = createAgentGraph();
104
+ const result = await agent.invoke({ input: 'salut', session: sessionBase({ llm: null }) });
105
+
106
+ assert.equal(result.response, '⚠ LLM injoignable : aucun client LLM configure');
107
+ assert.doesNotMatch(result.response, /Donna is active/);
108
+ });
109
+
110
+ test('agent graph binds only the minimal read-only tool set for plain discussion, never an empty array', async () => {
111
+ const seenTools = [];
112
+ const session = sessionBase({
113
+ llm: {
114
+ async completeWithTools({ tools }) {
115
+ seenTools.push(...tools.map((tool) => tool.function.name));
116
+ return {
117
+ content: 'Salut, je suis là.',
118
+ message: { role: 'assistant', content: 'Salut, je suis là.' },
119
+ tool_calls: null,
120
+ };
121
+ },
122
+ },
123
+ });
124
+
125
+ const agent = createAgentGraph();
126
+ const result = await agent.invoke({ input: 'salut', session });
127
+
128
+ assert.equal(result.response, 'Salut, je suis là.');
129
+ // Never literally empty: the system prompt unconditionally describes the
130
+ // connected MCP tools, and sending zero tools while the prompt talks about
131
+ // them risks an empty/malformed completion from some models instead of a
132
+ // plain reply — this is what actually broke plain "salut" discussion.
133
+ assert.ok(seenTools.length > 0);
134
+ assert.deepEqual(seenTools, ['shell__read_command']);
135
+ assert.equal(session.headlessPlan ?? null, null);
136
+ assert.equal(Object.keys(session.activities ?? {}).length, 0);
137
+ });
138
+
139
+ test('agent graph binds read-only tools for config questions without plan tools', async () => {
140
+ const seenTools = [];
141
+ const session = sessionBase({
142
+ mcp: {
143
+ production: {
144
+ status: 'connected',
145
+ url: 'http://127.0.0.1:3000/mcp/',
146
+ tools: [
147
+ {
148
+ name: 'production_start_job',
149
+ description: 'Start production job',
150
+ inputSchema: { type: 'object', properties: { type: { type: 'string' } } },
151
+ },
152
+ {
153
+ name: 'production_job_status',
154
+ description: 'Read production job status',
155
+ inputSchema: { type: 'object', properties: { jobId: { type: 'string' } } },
156
+ },
157
+ ],
158
+ },
159
+ },
160
+ llm: {
161
+ async completeWithTools({ tools }) {
162
+ seenTools.push(...tools.map((tool) => tool.function.name));
163
+ return {
164
+ content: 'Le profil actif est docs.',
165
+ message: { role: 'assistant', content: 'Le profil actif est docs.' },
166
+ tool_calls: null,
167
+ };
168
+ },
169
+ },
170
+ });
171
+
172
+ const agent = createAgentGraph();
173
+ const result = await agent.invoke({ input: 'quel est le profil actif ?', session });
174
+
175
+ assert.equal(result.response, 'Le profil actif est docs.');
176
+ assert.ok(seenTools.includes('shell__read_command'));
177
+ assert.ok(seenTools.includes('production__production_job_status'));
178
+ assert.equal(seenTools.includes('wiki__plan_set'), false);
179
+ assert.equal(seenTools.includes('production__production_start_job'), false);
180
+ assert.equal(seenTools.includes('shell__run_command'), false);
181
+ });
182
+
102
183
  test('agent graph waits for tool-level approval configured on endpoint', async () => {
103
184
  const originalFetch = globalThis.fetch;
104
185
  globalThis.fetch = async () => ({
@@ -29,6 +29,11 @@ function valueAfter(argv, flag) {
29
29
  return argv[index + 1];
30
30
  }
31
31
 
32
+ function unavailableRuntime(err) {
33
+ const reason = err instanceof Error ? err.message : String(err);
34
+ return { url: null, error: reason };
35
+ }
36
+
32
37
  function createSession() {
33
38
  return {
34
39
  workspace: null,
@@ -281,7 +286,7 @@ async function runRuntime(argv, agent) {
281
286
  'Runs the local agentic runtime used by wiki-manager Shell and llm-wiki serve.',
282
287
  '',
283
288
  'Defaults:',
284
- ' --host 0.0.0.0',
289
+ ' --host 127.0.0.1',
285
290
  ' --port 7788',
286
291
  ' --state-dir .wiki-manager',
287
292
  ].join('\n'));
@@ -295,7 +300,7 @@ async function runRuntime(argv, agent) {
295
300
  const { createApprovalManager } = await import('../runtime/approvals.js');
296
301
  const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
297
302
 
298
- const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0';
303
+ const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1';
299
304
  const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
300
305
  const stateDir = valueAfter(argv, '--state-dir') ?? defaultRuntimeStateDir();
301
306
  const auth = resolveRuntimeAuthToken({ host, stateDir });
@@ -745,7 +750,8 @@ export async function runCli(argv) {
745
750
  const { ensureRuntime } = await import('../runtime/lifecycle.js');
746
751
  runtime = await ensureRuntime();
747
752
  } catch (err) {
748
- console.error(`Runtime unavailable: ${err instanceof Error ? err.message : String(err)}`);
753
+ runtime = unavailableRuntime(err);
754
+ console.error(`Runtime unavailable: ${runtime.error}`);
749
755
  }
750
756
  await runOpenTuiShell({ agent, packageJson, runtime });
751
757
  return;
@@ -757,7 +763,8 @@ export async function runCli(argv) {
757
763
  const { ensureRuntime } = await import('../runtime/lifecycle.js');
758
764
  runtime = await ensureRuntime();
759
765
  } catch (err) {
760
- console.error(`Runtime unavailable: ${err instanceof Error ? err.message : String(err)}`);
766
+ runtime = unavailableRuntime(err);
767
+ console.error(`Runtime unavailable: ${runtime.error}`);
761
768
  }
762
769
  }
763
770
  await runShell({ agent, packageJson, runtime });
@@ -29,7 +29,12 @@ import {
29
29
  summarizeWikircConfig,
30
30
  } from '../core/wikirc.js';
31
31
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
32
- import { deleteWorkspaceAndFiles, startAgents, stopAgents } from '../core/wikiSetup.js';
32
+ import {
33
+ deleteWorkspaceAndFiles,
34
+ finalizeCreatedWorkspace,
35
+ startAgents,
36
+ stopAgents,
37
+ } from '../core/wikiSetup.js';
33
38
  import {
34
39
  cleanDocumentUploads,
35
40
  convertPendingDocumentUploads,
@@ -454,6 +459,7 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
454
459
  try {
455
460
  context.onStep?.(`Workspace: creating ${workspaceName}…`);
456
461
  const output = await createWorkspace(workspaceName, targetPath, { timeout: 600_000 });
462
+ finalizeCreatedWorkspace(workspaceName);
457
463
  return {
458
464
  output: [
459
465
  output,
@@ -1,8 +1,8 @@
1
- import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/graph.js';
1
+ import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
4
  import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
5
- import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks } from './planPatch.js';
5
+ import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
6
6
 
7
7
  export function abortError(message = 'Agent run cancelled.') {
8
8
  const err = new Error(message);
@@ -31,7 +31,7 @@ export async function runAgentTurn(agent, session, input, {
31
31
  if (session._abortSignal === signal) delete session._abortSignal;
32
32
  }
33
33
  if (result.streamedInline) {
34
- return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
34
+ return streamedContent.trim() || formatLlmUnavailableMessage('flux vide');
35
35
  }
36
36
  if (result.response != null) return result.response;
37
37
  if (result.readyToStream && session.llm?.stream) {
@@ -44,9 +44,9 @@ export async function runAgentTurn(agent, session, input, {
44
44
  })) {
45
45
  content += delta;
46
46
  }
47
- return content.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
47
+ return content.trim() || formatLlmUnavailableMessage('flux vide');
48
48
  }
49
- return buildLimitedAgentResponse({ input, session });
49
+ return formatLlmUnavailableMessage('reponse vide');
50
50
  }
51
51
 
52
52
  export async function runAgenticLoop(agent, session, initialInput, {
@@ -109,6 +109,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
109
109
  onPlanAlreadySet?.({ steps: session.headlessPlan });
110
110
  }
111
111
  }
112
+ sanitizeSessionPlan(session, { runId });
112
113
 
113
114
  const newPending = newNonTerminalActivities(snapshot, session);
114
115
  if (newPending.length === 0) {
@@ -158,6 +159,20 @@ export async function runAgenticLoop(agent, session, initialInput, {
158
159
  return { ok: false, maxTurns: true };
159
160
  }
160
161
 
162
+ function sanitizeSessionPlan(session, { runId = null } = {}) {
163
+ if (!session.headlessPlan) return;
164
+ const sanitized = sanitizePlanForExecution(session.headlessPlan);
165
+ if (sanitized.warnings.length === 0) return;
166
+ session.headlessPlan = sanitized.plan;
167
+ dispatchAgentEvent(session, createAgentEvent('runtime_log', {
168
+ origin: 'runtime',
169
+ runId,
170
+ payload: {
171
+ message: `plan warning: ${sanitized.warnings.join('; ')}`,
172
+ },
173
+ }));
174
+ }
175
+
161
176
  function pendingStepsPrompt(initialInput, plan, readyTask) {
162
177
  return [
163
178
  'Original task:',
@@ -1,7 +1,25 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
4
- import { runAgenticLoop } from './agentLoop.js';
4
+ import { runAgentTurn, runAgenticLoop } from './agentLoop.js';
5
+
6
+ test('runAgentTurn returns a one-line LLM error on empty stream', async () => {
7
+ const session = {
8
+ commands: [],
9
+ llm: {
10
+ async *stream() {},
11
+ },
12
+ };
13
+ const agent = {
14
+ async invoke() {
15
+ return { readyToStream: true, streamContext: { messages: [] } };
16
+ },
17
+ };
18
+
19
+ const response = await runAgentTurn(agent, session, 'salut');
20
+
21
+ assert.equal(response, '⚠ LLM injoignable : flux vide');
22
+ });
5
23
 
6
24
  test('runAgenticLoop waits for new activities and continues with a completion summary', async () => {
7
25
  const session = {
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.11.0';
4
+ const WIKI_MANAGER_VERSION = '0.11.6';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -86,7 +86,7 @@ export function rebasePlanPatch(patch, { currentRevision = 0 } = {}) {
86
86
  }
87
87
 
88
88
  export function readyPlanTasks(plan) {
89
- const tasks = (plan ?? []).map((step, index) => normalizeTask(step, index));
89
+ const { plan: tasks } = sanitizePlanForExecution(plan);
90
90
  const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
91
91
  return tasks
92
92
  .filter((task) => task.status === 'pending')
@@ -125,6 +125,36 @@ export function normalizeTask(raw, index = 0) {
125
125
  };
126
126
  }
127
127
 
128
+ export function sanitizePlanForExecution(plan) {
129
+ const tasks = (plan ?? []).map((step, index) => normalizeTask(step, index));
130
+ const warnings = [];
131
+ const ids = new Set(tasks.map(taskId));
132
+ for (const task of tasks) {
133
+ const before = task.dependsOn;
134
+ task.dependsOn = before.filter((dep) => ids.has(String(dep)));
135
+ if (task.dependsOn.length !== before.length) {
136
+ warnings.push(`unknown dependency removed from task ${taskId(task)}`);
137
+ }
138
+ }
139
+ const pending = tasks.filter((task) => task.status === 'pending');
140
+ const done = new Set(tasks.filter((task) => task.status === 'done').map(taskId));
141
+ const terminalBlocked = new Set(
142
+ tasks
143
+ .filter((task) => ['failed', 'cancelled', 'canceled'].includes(String(task.status).toLowerCase()))
144
+ .map(taskId),
145
+ );
146
+ const hasReady = pending.some((task) => task.dependsOn.every((dep) => done.has(String(dep))));
147
+ if (hasDependencyCycle(tasks)) {
148
+ warnings.push('dependency cycle broken by sequential fallback');
149
+ return { plan: sequentialize(tasks), warnings };
150
+ }
151
+ if (pending.length > 0 && !hasReady && !pending.some((task) => task.dependsOn.some((dep) => terminalBlocked.has(String(dep))))) {
152
+ warnings.push('no ready task after dependency cleanup; using sequential fallback');
153
+ return { plan: sequentialize(tasks), warnings };
154
+ }
155
+ return { plan: tasks, warnings };
156
+ }
157
+
128
158
  function normalizePatchOperation(raw) {
129
159
  if (!raw || typeof raw !== 'object') return null;
130
160
  const op = String(raw.op ?? '');
@@ -198,6 +228,14 @@ function resequence(plan) {
198
228
  });
199
229
  }
200
230
 
231
+ function sequentialize(plan) {
232
+ const next = plan.map((task) => ({ ...task, dependsOn: [] }));
233
+ for (let index = 1; index < next.length; index += 1) {
234
+ next[index].dependsOn = [taskId(next[index - 1])];
235
+ }
236
+ return next;
237
+ }
238
+
201
239
  function hasDependencyCycle(plan) {
202
240
  const tasks = new Map(plan.map((task) => [taskId(task), task]));
203
241
  const visiting = new Set();
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch } from './planPatch.js';
3
+ import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
4
4
 
5
5
  test('applyPlanPatch adds a task and increments the plan revision', () => {
6
6
  const result = applyPlanPatch([
@@ -61,3 +61,25 @@ test('applyPlanPatch rejects dependency cycles', () => {
61
61
  assert.equal(result.ok, false);
62
62
  assert.equal(result.reason, 'dependency_cycle');
63
63
  });
64
+
65
+ test('sanitizePlanForExecution removes unknown dependencies and keeps execution sequential', () => {
66
+ const result = sanitizePlanForExecution([
67
+ { step: 1, id: 'a', description: 'A', status: 'pending', dependsOn: ['missing'] },
68
+ { step: 2, id: 'b', description: 'B', status: 'pending', dependsOn: ['a'] },
69
+ ]);
70
+
71
+ assert.deepEqual(result.plan.map((task) => task.dependsOn), [[], ['a']]);
72
+ assert.match(result.warnings.join('\n'), /unknown dependency/);
73
+ assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
74
+ });
75
+
76
+ test('sanitizePlanForExecution breaks cycles with declaration-order fallback', () => {
77
+ const result = sanitizePlanForExecution([
78
+ { step: 1, id: 'a', description: 'A', status: 'pending', dependsOn: ['b'] },
79
+ { step: 2, id: 'b', description: 'B', status: 'pending', dependsOn: ['a'] },
80
+ ]);
81
+
82
+ assert.deepEqual(result.plan.map((task) => task.dependsOn), [[], ['a']]);
83
+ assert.match(result.warnings.join('\n'), /cycle/);
84
+ assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
85
+ });
@@ -69,14 +69,19 @@ export async function stopAgents(options = {}) {
69
69
  export async function createNewWorkspace(name, targetPath) {
70
70
  try {
71
71
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
72
- const workspace = findWorkspace(name);
73
- if (workspace) initializeWorkspaceWikirc(workspace);
72
+ const workspace = finalizeCreatedWorkspace(name);
74
73
  return { output, workspace };
75
74
  } catch (err) {
76
75
  throw wrapDockerError(err);
77
76
  }
78
77
  }
79
78
 
79
+ export function finalizeCreatedWorkspace(name) {
80
+ const workspace = findWorkspace(name);
81
+ if (workspace) initializeWorkspaceWikirc(workspace);
82
+ return workspace;
83
+ }
84
+
80
85
  export function initializeWorkspaceWikirc(workspace) {
81
86
  const accessKey = workspace?.env?.WIKI_MCP_AUTH_TOKEN;
82
87
  if (!workspace?.workspacePath || !accessKey) return null;
@@ -1,11 +1,11 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import YAML from 'yaml';
7
7
  import { patchWikircProfile } from './wikirc.js';
8
- import { writeVectorConfig } from './wikiSetup.js';
8
+ import { finalizeCreatedWorkspace, writeVectorConfig } from './wikiSetup.js';
9
9
 
10
10
  test('patchWikircProfile merges keys and preserves existing values', () => {
11
11
  const root = mkdtempSync(join(tmpdir(), 'wikirc-patch-'));
@@ -109,3 +109,41 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
109
109
  assert.equal(parsed.retrieval.vector.baseUrl, 'http://localhost:7997/v1');
110
110
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
111
111
  });
112
+
113
+ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', () => {
114
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-workspace-token-'));
115
+ const registryRoot = join(root, 'registry');
116
+ const registryPath = join(registryRoot, 'demo');
117
+ const workspacePath = join(root, 'workspace');
118
+ const token = 'a'.repeat(64);
119
+ mkdirSync(registryPath, { recursive: true });
120
+ mkdirSync(workspacePath, { recursive: true });
121
+ writeFileSync(
122
+ join(registryPath, '.env'),
123
+ [
124
+ 'WORKSPACE_NAME=demo',
125
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
126
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
127
+ '',
128
+ ].join('\n'),
129
+ 'utf8',
130
+ );
131
+ writeFileSync(
132
+ join(workspacePath, '.wikirc.yaml'),
133
+ ['language: en', 'mcp:', ' # accessKey: your-secret-key', ''].join('\n'),
134
+ 'utf8',
135
+ );
136
+
137
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
138
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
139
+ try {
140
+ const workspace = finalizeCreatedWorkspace('demo');
141
+ const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
142
+
143
+ assert.equal(workspace.name, 'demo');
144
+ assert.equal(parsed.mcp.accessKey, token);
145
+ } finally {
146
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
147
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
148
+ }
149
+ });