@dotdrelle/wiki-manager 0.15.54 → 0.15.57

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.
@@ -29,7 +29,7 @@
29
29
  "chatAccess": {
30
30
  "maxToolIterations": 8,
31
31
  "servers": {
32
- "llm-wiki": { "allow": ["help_list", "help_read", "help_search", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source", "wiki_outline", "template_read", "template_write", "build_context_write"] },
32
+ "llm-wiki": { "allow": ["help_list", "help_read", "help_search", "wiki_workspace_status", "wiki_list_pages", "wiki_read_page", "wiki_read_pages", "wiki_search_context", "wiki_collect_context", "wiki_read_ingested_source", "wiki_outline", "template_read", "template_write", "build_context_write", "wiki_read_deliverable"] },
33
33
  "wiki-production": { "allow": ["production_job_status", "production_jobs_list"] },
34
34
  "cme": { "allow": ["cme_status", "cme_sources_list", "cme_export_status"] }
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.54",
3
+ "version": "0.15.57",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "start": "bun ./bin/wiki-manager.js",
22
- "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.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/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
22
+ "test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.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/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js",
23
23
  "check-versions": "node scripts/check-versions.js",
24
24
  "prepack": "node scripts/check-versions.js",
25
25
  "prepublishOnly": "node scripts/check-versions.js",
@@ -25,6 +25,7 @@ import { extractActivity, formatActivitySummary, parseJsonText, sessionActivitie
25
25
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
26
26
  import { enqueueProductionJob, ensureJobQueue, formatQueue, productionLockBusy } from '../core/jobQueue.js';
27
27
  import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/profile.js';
28
+ import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
28
29
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
29
30
  import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
30
31
  import { controlLanguage } from '../runtime/controlMessages.js';
@@ -1025,11 +1026,18 @@ export function connectorConfigurationTarget(session, objective) {
1025
1026
  .filter((message) => message?.role === 'user')
1026
1027
  .map((message) => String(message?.content ?? ''))
1027
1028
  .join(' ');
1028
- const text = `${recentContext} ${String(objective ?? '')}`.trim().toLowerCase();
1029
- // `connect` used to match the noun "connector" as a substring. Production
1030
- // skills mention an optional messaging connector, so that broad match could
1031
- // misclassify a business run as connector setup and reject delegation.
1032
- if (!/(?:configur|\bconnect(?:ed|ing|ion|ions)?\b|authent|oauth|setup|sign[ -]?in|\bpat\b|api[ _-]?token|credential|identifiant|mot de passe|password)/i.test(text)) return null;
1029
+ const objectiveText = String(objective ?? '').trim().toLowerCase();
1030
+ // The configuration keyword must describe the objective being delegated NOW,
1031
+ // not something the user said earlier in the session. Matching it against
1032
+ // recent conversation context made a stale "configurer le CME" message poison
1033
+ // every later delegation — an unrelated `/wiki-ingest` objective was rejected
1034
+ // as connector setup. Recent context is used only to resolve WHICH connector
1035
+ // the current objective refers to. (`connect` used to match the noun
1036
+ // "connector" as a substring, and production skills mention an optional
1037
+ // messaging connector; the narrowed word-boundary form keeps that from
1038
+ // misclassifying a business run as connector setup.)
1039
+ if (!/(?:configur|\bconnect(?:ed|ing|ion|ions)?\b|authent|oauth|setup|sign[ -]?in|\bpat\b|api[ _-]?token|credential|identifiant|mot de passe|password)/i.test(objectiveText)) return null;
1040
+ const contextText = `${recentContext} ${objectiveText}`.trim().toLowerCase();
1033
1041
  for (const [serverName, server] of Object.entries(session?.mcp ?? {})) {
1034
1042
  if (server?.status !== 'connected' || !Array.isArray(server.tools) || server.tools.length === 0) continue;
1035
1043
  const genericAliasParts = new Set([
@@ -1042,7 +1050,7 @@ export function connectorConfigurationTarget(session, objective) {
1042
1050
  ]
1043
1051
  .flatMap((value) => String(value).toLowerCase().split(/[^a-z0-9]+/))
1044
1052
  .filter((part) => part.length >= 3 && !genericAliasParts.has(part));
1045
- if (!aliases.some((alias) => text.includes(alias))) continue;
1053
+ if (!aliases.some((alias) => contextText.includes(alias))) continue;
1046
1054
  const setupTool = server.tools.find((tool) => {
1047
1055
  const name = String(tool?.name ?? '').toLowerCase();
1048
1056
  const description = String(tool?.description ?? '').toLowerCase();
@@ -1181,7 +1189,7 @@ export function buildAgentSystemPrompt(state) {
1181
1189
  skills,
1182
1190
  '</skill_catalog>',
1183
1191
  runningSkillStack.length > 0
1184
- ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. Execute the objective in the current user message with the available direct tools${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' or capability delegation'}. Do not select or call that skill again, with or without a leading slash. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done.`
1192
+ ? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
1185
1193
  : null,
1186
1194
  'In interactive agent mode, call only tools actually provided to you. Any directly offered tool stays direct; never substitute an orchestration-contract tool yourself.',
1187
1195
  'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
@@ -1218,6 +1226,7 @@ export function buildAgentSystemPrompt(state) {
1218
1226
  'When an action fails or is refused for lack of an authorization grant or scope (rather than a missing capability), say exactly that and name the primitive that grants it. Do not describe the feature as unavailable.',
1219
1227
  'For an action with no matching direct tool, call runtime__delegate with the user objective only. The runtime chooses the capability, operation, agent and plan, including a validated single task for executor-only agents. Never choose those identifiers yourself. Never call <provider>__agent_plan, <provider>__agent_execute, legacy production__production_start_job, wiki__plan_set, or wiki__plan_done from interactive chat.',
1220
1228
  'Do not ask the user which sources, files, connectors, or templates to use for an ingest, build, or export: the specialized agent discovers them from the workspace. When the objective is clear (e.g. "lance une ingestion"), delegate it as stated, without a clarifying question.',
1229
+ 'Templates are instruction-only specs and deliverables are regenerated from them. When asked to change what a generated document says, edit the underlying wiki content (wiki_write_page) or the template\'s [[INSTRUCTION: ...]] sections — never write finished prose into a template, because a build copies it verbatim and it can no longer be refreshed from the wiki. template_write refuses prose outside an instruction block, so keep every sentence inside one.',
1221
1230
  'Promise only what the resolved capability actually exposes in its declared contract (the input schema the specialized agent publishes for that capability). When the user requests an execution parameter — a batch or chunk size, a count "N at a time", concurrency, ordering, priority, or any tuning knob — apply it only if that parameter exists in the target capability\'s published input schema. Otherwise do not confirm or promise it: delegate the objective, and if the user explicitly asked for that parameter, say plainly in one line that you started the work but do not control that aspect (the runtime and the specialized agent decide it). Never state or imply a parameter was applied when the agent contract cannot enforce it.',
1222
1231
  'If runtime__delegate returns a blocker or no specialized provider is available, report only that concrete blocker concisely. Never replace the missing execution path with a suggested slash command, skill, MCP tool name, manual file move, administrator escalation, or alternative workflow unless the user explicitly asks for alternatives.',
1223
1232
  'For workspace inventory and page listings, use the connected wiki MCP read tools. Never invent or call a /wiki shell command through shell__run_command. Use /workspace init <name> [path] for low-level non-interactive workspace creation; in the interactive TUI, /new <name> opens the setup wizard.',
@@ -1235,6 +1244,7 @@ export function buildAgentSystemPrompt(state) {
1235
1244
  workspaceProfile
1236
1245
  ? `Workspace profile (.wiki/profile.md) — durable user preferences, apply these to every reply (tone, tutoiement/vouvoiement, formatting, etc.):\n${workspaceProfile}`
1237
1246
  : null,
1247
+ currentArtifactPromptLine(currentArtifactFor(state.session)),
1238
1248
  'Runtime control: you have runtime__status, runtime__cancel, runtime__kill and runtime__enqueue. When the user asks to stop, remove, clean or kill the current run, its jobs or the queue ("supprime le job et la queue", "arr\u00eate tout"), call runtime__kill (or runtime__cancel for a soft stop of just the run) and confirm what was stopped. When the user explicitly asks to delete, reset, abandon or replace the current plan, call runtime__kill with purge=true; never set purge=true for a simple stop. For questions about what is running or queued, call runtime__status and answer from its data. You have no approval tool: a pending approval is granted only by the user through the approval button or the /approve command. Never grant, claim or report an approval yourself; when the user asks to proceed with pending mutations, tell them to use those controls. When the user asks for a NEW action while a run is active, do not execute it: propose runtime__enqueue (run it after) or, if they insist it replaces the current work, runtime__kill then the new action.',
1239
1249
  'When the user asks to refresh, show, or update the displayed plan or status, call runtime__status. This is a state refresh request, not a new business capability, and must never be delegated.',
1240
1250
  'Report every runtime control outcome exactly as the tool returned it \u2014 never embellish. If runtime__kill reports 0 run(s)/0 task(s)/0 purged, say there was nothing active to stop or purge; do NOT claim a run, plan, pending approval or queue item was removed. If runtime__status returns an error or could not be read, say the runtime state could not be retrieved and do not describe a state you never obtained. Never assert that something was cleaned, cancelled, approved or purged unless that specific tool result confirms it.',
@@ -1940,6 +1950,10 @@ export function createAgentGraph(options = {}) {
1940
1950
  args = withActiveWorkspaceForExternalTool(state.session, server, tool, args);
1941
1951
  const result = await callMcpTool(state.session.mcp, server, tool, args, state.session._abortSignal);
1942
1952
  resultText = formatMcpToolResult(result);
1953
+ const artifact = artifactFromToolCall(tool, args);
1954
+ if (artifact) {
1955
+ rememberArtifact(state.session, artifact);
1956
+ }
1943
1957
  }
1944
1958
  }
1945
1959
  {
@@ -62,6 +62,27 @@ test('a connection problem still routes to connector configuration', () => {
62
62
  assert.deepEqual(target, { serverName: 'acme', setupTool: 'acme_auth' });
63
63
  });
64
64
 
65
+ test('a stale configure-connector message does not poison a later business delegation', () => {
66
+ const target = connectorConfigurationTarget({
67
+ agentProjection: {
68
+ conversation: [
69
+ { role: 'user', content: 'donne moi la config du cme' },
70
+ { role: 'user', content: 'liste les pages wiki configurer pour l’agent cme' },
71
+ ],
72
+ },
73
+ mcp: {
74
+ cme: {
75
+ status: 'connected',
76
+ tools: [
77
+ { name: 'cme_setup', description: 'Configure Confluence credentials.' },
78
+ { name: 'cme_export_run', description: 'Run export.' },
79
+ ],
80
+ },
81
+ },
82
+ }, 'Ingest pending staged Markdown files from raw/untracked into the wiki.');
83
+ assert.equal(target, null);
84
+ });
85
+
65
86
  test('Donna cannot answer an explicit action with manual instructions instead of delegating', async () => {
66
87
  const originalFetch = globalThis.fetch;
67
88
  let delegated = false;
@@ -32,6 +32,7 @@ import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
32
32
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
33
33
  import { listWorkspaces } from '../core/workspaces.js';
34
34
  import { findSkill } from '../core/skills.js';
35
+ import { rememberArtifact } from '../core/currentArtifact.js';
35
36
  // Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
36
37
  // They are imported dynamically so the shell / TUI path never loads them.
37
38
 
@@ -245,6 +246,21 @@ export function missingRequiredArguments(schema, args) {
245
246
  });
246
247
  }
247
248
 
249
+ // After a delegated document.build, remember the targeted template as the
250
+ // current artifact so the next "améliore cette slide" / "relance le build"
251
+ // resolves the file without re-discovery. Best effort: a build of every
252
+ // template (no `templates` argument) leaves the artifact untouched.
253
+ function rememberBuildTemplateArtifact(session, selection, args) {
254
+ if (selection?.capability !== 'document.build') return;
255
+ const templates = Array.isArray(args?.templates) ? args.templates : [];
256
+ if (templates.length !== 1) return;
257
+ const name = String(templates[0] ?? '').trim().replace(/\\/g, '/');
258
+ if (!name) return;
259
+ const withExt = name.toLowerCase().endsWith('.md') ? name : `${name}.md`;
260
+ const pathValue = withExt.startsWith('templates/') ? withExt : `templates/${withExt.replace(/^\.?\//, '')}`;
261
+ rememberArtifact(session, { path: pathValue, kind: 'template' });
262
+ }
263
+
248
264
  function safeParseArgumentObject(text) {
249
265
  const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
250
266
  if (!cleaned) return null;
@@ -297,7 +313,7 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
297
313
  'workspace', 'workspacePath', 'workspaceEnvFile', 'workspaceEnv',
298
314
  'wikirc', 'wikircConfig', 'language', 'llm', 'mcp', 'commands',
299
315
  'packageJson', 'queueStore', 'systemPrompt',
300
- '_runSkillWithinRun',
316
+ '_runSkillWithinRun', 'currentArtifact',
301
317
  ]) {
302
318
  if (source[key] !== undefined) session[key] = source[key];
303
319
  }
@@ -1249,6 +1265,7 @@ async function runRuntime(argv, agent) {
1249
1265
  workspace: session.workspace ?? context.workspace ?? '',
1250
1266
  signal: session._abortSignal,
1251
1267
  });
1268
+ rememberBuildTemplateArtifact(session, selection, extractedArguments);
1252
1269
  planResult = await callMcpTool(
1253
1270
  session.mcp,
1254
1271
  provider.serverName,
@@ -1649,6 +1666,12 @@ async function runRuntime(argv, agent) {
1649
1666
  } else {
1650
1667
  response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
1651
1668
  }
1669
+ // Persist the artifact the turn may have opened/edited (template_write,
1670
+ // template_read, …) back onto the long-lived session, so the next /turn —
1671
+ // chat or agent — sees it. The ephemeral session is otherwise discarded.
1672
+ if (ephemeral.currentArtifact) {
1673
+ context.session.currentArtifact = ephemeral.currentArtifact;
1674
+ }
1652
1675
  ensureInteractiveAssistantMessage(ephemeral, response, {
1653
1676
  turnId,
1654
1677
  workspace: context.workspace ?? null,
@@ -29,6 +29,7 @@ import { createWorkspace, findWorkspace, listWorkspaces } from '../core/workspac
29
29
  import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
30
30
  import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
31
31
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
+ import { emitRuntimeLog } from '../runtime/supervisor.js';
32
33
  import {
33
34
  cancelQueueItem,
34
35
  clearFinishedQueueItems,
@@ -612,20 +613,53 @@ function publishDocumentActivity(session, activity) {
612
613
  return publishPayloadActivity(session, { _activity: activity }, { server: 'documents', tool: 'documents_convert_to_markdown' });
613
614
  }
614
615
 
615
- export async function refreshMcpRuntimeStatus(session) {
616
- session.mcp = buildMcpStatus(session);
617
- if (!session.workspacePath) return null;
616
+ export async function refreshMcpRuntimeStatus(session, deps = {}) {
617
+ const getStates = deps.serviceStates ?? serviceStates;
618
+ const discover = deps.discoverMcpTools ?? discoverMcpTools;
619
+ const previousMcp = session.mcp;
620
+ const base = buildMcpStatus(session);
621
+ if (!session.workspacePath) {
622
+ session.mcp = base;
623
+ return null;
624
+ }
625
+ // Build the next status in local variables and assign `session.mcp` only at
626
+ // the end. Assigning `buildMcpStatus` first (status "configured", no tools)
627
+ // and then awaiting `serviceStates`/`discoverMcpTools` exposed a window in
628
+ // which a background re-scan had downgraded `production` to "configured",
629
+ // and the dispatcher — reading `session.mcp` mid-refresh — refused the next
630
+ // `agent_execute` with "MCP is not connected: production" while the agent
631
+ // was actually up.
618
632
  try {
619
- const states = await serviceStates(session);
620
- session.mcp = applyMcpRuntimeStatus(session.mcp, states);
621
- session.mcp = await discoverMcpTools(session.mcp);
633
+ const states = await getStates(session);
634
+ session.mcp = await discover(applyMcpRuntimeStatus(base, states), previousMcp);
635
+ reportNewlyDegradedMcp(session, previousMcp);
622
636
  return states;
623
637
  } catch {
624
- session.mcp = await discoverMcpTools(session.mcp);
638
+ session.mcp = await discover(base, previousMcp);
639
+ reportNewlyDegradedMcp(session, previousMcp);
625
640
  return null;
626
641
  }
627
642
  }
628
643
 
644
+ /**
645
+ * "Degraded" (`discoverMcpTools`, mcp.js) means an endpoint is reporting
646
+ * "connected" only because a prior cycle was, not because Docker or the
647
+ * latest probe confirm it now — the preservation that endpoint's
648
+ * `keepConnected` branch exists for, deliberately not reverted here since a
649
+ * transient probe blip must not flip a live run's endpoint away. But
650
+ * preserving it in silence is exactly the "stale status kept forever"
651
+ * pattern already fixed once in agentRegistry.js: report it, edge-triggered
652
+ * on the transition into the degraded state, so it does not repeat every
653
+ * re-scan while it persists.
654
+ */
655
+ function reportNewlyDegradedMcp(session, previousMcp) {
656
+ for (const [name, entry] of Object.entries(session.mcp ?? {})) {
657
+ if (!entry?.degraded || previousMcp?.[name]?.degraded) continue;
658
+ emitRuntimeLog(session, `mcp: ${name} still reports "connected" only from a prior probe`
659
+ + ` (${entry.toolError ?? 'no detail'}); Docker/the latest probe no longer confirm it.`);
660
+ }
661
+ }
662
+
629
663
  async function statusText(session) {
630
664
  const states = await refreshMcpRuntimeStatus(session);
631
665
  const workspaceStats = collectWorkspaceStats(session);
@@ -1557,7 +1591,12 @@ export async function handleSlashCommand(line, context) {
1557
1591
  const wikiArgs = args.slice(2);
1558
1592
  if (wikiArgs.length === 0) return { output: 'Usage: /wiki run <args...>' };
1559
1593
  step(`Wiki: running ${wikiArgs.join(' ')}…`);
1594
+ // Steps that drive LLM work (taxonomy, build, ingest, export, polish)
1595
+ // legitimately run for minutes: the production agent gives them a
1596
+ // 600s LLM timeout, so the raw hatch must not cut them at 180s.
1597
+ const longRunning = new Set(['taxonomy', 'build', 'ingest', 'export', 'polish', 'pipeline']);
1560
1598
  const output = await runWikiCli(context.session, wikiArgs, {
1599
+ timeout: longRunning.has(wikiArgs[0] ?? '') ? 600_000 : 180_000,
1561
1600
  onOutput: (line) => step(`Wiki: ${line}`),
1562
1601
  });
1563
1602
  const activity = formatActivitySummary('wiki', wikiArgs[0] ?? 'run', output);
@@ -4,7 +4,7 @@ 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 { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult } from './slash.js';
7
+ import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
10
  test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
@@ -437,3 +437,59 @@ test('/queue cancel reports unknown ids that are not runtime-managed', async ()
437
437
  });
438
438
  assert.match(result.output ?? '', /Unknown queue item/i);
439
439
  });
440
+
441
+ test('refreshMcpRuntimeStatus does not expose an intermediate configured status', async () => {
442
+ const session = {
443
+ workspacePath: '/tmp/ws',
444
+ workspaceEnv: { PRODUCTION_MCP_PORT: '3202', PRODUCTION_MCP_AUTH_TOKEN: 'token' },
445
+ wikircConfig: {},
446
+ mcp: { production: { status: 'connected', tools: [{ name: 'production__agent_execute' }] } },
447
+ };
448
+ const originalMcp = session.mcp;
449
+ let resolveStates;
450
+ const statesPromise = new Promise((resolve) => { resolveStates = resolve; });
451
+ const pending = refreshMcpRuntimeStatus(session, {
452
+ serviceStates: () => statesPromise,
453
+ discoverMcpTools: async (mcp) => mcp,
454
+ });
455
+ // While serviceStates is still in flight, the dispatcher-facing status must
456
+ // keep the previous connected snapshot — never the fresh "configured" base.
457
+ assert.equal(session.mcp, originalMcp, 'session.mcp reassigned mid-refresh');
458
+ resolveStates({ 'production-mcp': { running: true } });
459
+ await pending;
460
+ assert.equal(session.mcp.production.status, 'connected');
461
+ });
462
+
463
+ test('refreshMcpRuntimeStatus reports a degraded MCP endpoint once, not on every re-scan', async () => {
464
+ const session = {
465
+ workspacePath: '/tmp/ws',
466
+ workspaceEnv: { PRODUCTION_MCP_PORT: '3202', PRODUCTION_MCP_AUTH_TOKEN: 'token' },
467
+ wikircConfig: {},
468
+ mcp: { production: { status: 'connected', tools: [] } },
469
+ };
470
+ const degradedRuns = (n) => async () => ({
471
+ production: { status: 'connected', tools: [], toolError: `probe ${n} failed`, degraded: true },
472
+ });
473
+ const deps = { serviceStates: async () => ({}) };
474
+
475
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(1) });
476
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(2) });
477
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(3) });
478
+
479
+ const reports = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
480
+ && String(event.payload?.message ?? '').includes('mcp: production'));
481
+ assert.equal(reports.length, 1, 'a degraded endpoint is reported once, not once per re-scan');
482
+
483
+ // Recovers, then degrades again: reported a second time.
484
+ await refreshMcpRuntimeStatus(session, {
485
+ ...deps,
486
+ discoverMcpTools: async () => ({ production: { status: 'connected', tools: [], degraded: false } }),
487
+ });
488
+ await refreshMcpRuntimeStatus(session, { ...deps, discoverMcpTools: degradedRuns(4) });
489
+ assert.equal(
490
+ (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
491
+ && String(event.payload?.message ?? '').includes('mcp: production')).length,
492
+ 2,
493
+ );
494
+ });
495
+
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.54",
3
- "commit": "7a84126"
2
+ "version": "0.15.57",
3
+ "commit": "8c83b75"
4
4
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Le « contexte d'artefact courant » — la boucle documentaire continue.
3
+ *
4
+ * Quand Donna édite ou ouvre un artefact éditable (template, build-context,
5
+ * page wiki), on mémorise ce chemin au niveau de la session, comme la
6
+ * conversation, pour qu'un tour suivant puisse dire « améliore cette slide »
7
+ * sans que le modèle doive deviner quel fichier est concerné.
8
+ *
9
+ * Seuls les artefacts *éditables* sont suivis : templates/, build-context/ et
10
+ * wiki/. Les livrables générés (deliverables/) ne le sont pas — on les régénère,
11
+ * on ne les édite pas.
12
+ */
13
+
14
+ const ARTIFACT_KIND_BY_TOOL = {
15
+ template_write: 'template',
16
+ template_read: 'template',
17
+ build_context_write: 'build-context',
18
+ wiki_write_page: 'wiki page',
19
+ };
20
+
21
+ export function artifactFromToolCall(tool, args) {
22
+ const kind = ARTIFACT_KIND_BY_TOOL[String(tool ?? '')];
23
+ if (!kind) return null;
24
+ const pathValue = args && typeof args === 'object' ? args.path : null;
25
+ if (typeof pathValue !== 'string' || !pathValue.trim()) return null;
26
+ return { path: pathValue.trim(), kind };
27
+ }
28
+
29
+ export function currentArtifactFor(session) {
30
+ const artifact = session?.currentArtifact;
31
+ if (!artifact || !artifact.path) return null;
32
+ if (artifact.workspace && session?.workspace && artifact.workspace !== session.workspace) return null;
33
+ return artifact;
34
+ }
35
+
36
+ export function rememberArtifact(session, { path, kind }) {
37
+ if (!session || typeof path !== 'string' || !path.trim()) return;
38
+ session.currentArtifact = {
39
+ workspace: session.workspace ?? null,
40
+ path: path.trim(),
41
+ kind,
42
+ at: Date.now(),
43
+ };
44
+ }
45
+
46
+ export function currentArtifactPromptLine(artifact) {
47
+ if (!artifact) return null;
48
+ return `Current artifact: the user is working on ${artifact.path} (${artifact.kind}). When the user refers to "this document", "this slide", "the template", "the page", or the document without naming the file, treat ${artifact.path} as the artifact being edited: read it first, then apply the requested edit and save it with the matching write tool.`;
49
+ }
@@ -0,0 +1,62 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ artifactFromToolCall,
5
+ currentArtifactFor,
6
+ currentArtifactPromptLine,
7
+ rememberArtifact,
8
+ } from './currentArtifact.js';
9
+
10
+ test('artifactFromToolCall records editable write tools with a path', () => {
11
+ assert.deepEqual(artifactFromToolCall('template_write', { path: 'templates/notes/basic.md', confirm: true }), {
12
+ path: 'templates/notes/basic.md',
13
+ kind: 'template',
14
+ });
15
+ assert.deepEqual(artifactFromToolCall('build_context_write', { path: 'build-context/rules/citations.md' }), {
16
+ path: 'build-context/rules/citations.md',
17
+ kind: 'build-context',
18
+ });
19
+ assert.deepEqual(artifactFromToolCall('wiki_write_page', { path: 'wiki/flux/ingestion.md' }), {
20
+ path: 'wiki/flux/ingestion.md',
21
+ kind: 'wiki page',
22
+ });
23
+ });
24
+
25
+ test('artifactFromToolCall records template_read only when a path is given', () => {
26
+ assert.deepEqual(artifactFromToolCall('template_read', { path: 'templates/notes/basic.md' }), {
27
+ path: 'templates/notes/basic.md',
28
+ kind: 'template',
29
+ });
30
+ assert.equal(artifactFromToolCall('template_read', {}), null);
31
+ });
32
+
33
+ test('artifactFromToolCall ignores read tools and tools without a path', () => {
34
+ assert.equal(artifactFromToolCall('wiki_read_page', { path: 'wiki/page.md' }), null);
35
+ assert.equal(artifactFromToolCall('wiki_search_context', { query: 'x' }), null);
36
+ assert.equal(artifactFromToolCall('template_write', {}), null);
37
+ assert.equal(artifactFromToolCall('unknown_tool', { path: 'templates/x.md' }), null);
38
+ });
39
+
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);
43
+ assert.equal(currentArtifactFor({ workspace: 'other', currentArtifact: artifact }), null);
44
+ assert.equal(currentArtifactFor({ workspace: 'acpi' }), null);
45
+ });
46
+
47
+ test('currentArtifactPromptLine names the artifact for follow-up edits', () => {
48
+ const line = currentArtifactPromptLine({ path: 'templates/presentation/presentation.md', kind: 'template' });
49
+ assert.match(line, /templates\/presentation\/presentation\.md/);
50
+ assert.match(line, /this slide/);
51
+ assert.equal(currentArtifactPromptLine(null), null);
52
+ });
53
+
54
+ test('rememberArtifact records a workspace-scoped artifact and ignores empty paths', () => {
55
+ const session = { workspace: 'acpi' };
56
+ rememberArtifact(session, { path: 'templates/notes/basic.md', kind: 'template' });
57
+ assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
58
+ assert.equal(session.currentArtifact.workspace, 'acpi');
59
+ assert.equal(session.currentArtifact.kind, 'template');
60
+ rememberArtifact(session, { path: ' ', kind: 'template' });
61
+ assert.equal(session.currentArtifact.path, 'templates/notes/basic.md');
62
+ });
package/src/core/env.js CHANGED
@@ -9,6 +9,7 @@ const LEGACY_DEFAULT_WIKI_CHAT_TOOLS = [
9
9
  ];
10
10
  const TEMPLATE_AUTHORING_CHAT_TOOLS = [
11
11
  'wiki_outline', 'template_read', 'template_write', 'build_context_write',
12
+ 'wiki_read_deliverable',
12
13
  ];
13
14
 
14
15
  export function userManagerDir() {
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.54';
4
+ const WIKI_MANAGER_VERSION = '0.15.57';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -585,7 +585,7 @@ function retryDelay(ms, signal) {
585
585
  });
586
586
  }
587
587
 
588
- export async function discoverMcpTools(mcpStatus) {
588
+ export async function discoverMcpTools(mcpStatus, previous = null) {
589
589
  const next = {};
590
590
  await Promise.all(Object.entries(mcpStatus ?? {}).map(async ([name, value]) => {
591
591
  if (value.status === 'missing') {
@@ -607,11 +607,26 @@ export async function discoverMcpTools(mcpStatus) {
607
607
  };
608
608
  } catch (err) {
609
609
  const message = err instanceof Error ? err.message : String(err);
610
+ // A transient probe failure during a background re-scan must not degrade
611
+ // a live endpoint. Docker service state (applyMcpRuntimeStatus) is the
612
+ // authority on whether the container is up; this probe is only a tool
613
+ // refresh, so keep last-known-good status and tools instead of flipping
614
+ // an in-flight run's endpoint to "not connected".
615
+ const prior = previous?.[name];
616
+ const freshlyConnected = value.status === 'connected';
617
+ const keepConnected = freshlyConnected || prior?.status === 'connected';
618
+ // `degraded` marks the case Docker itself no longer confirms as
619
+ // connected (freshlyConnected is false) and only prior history keeps
620
+ // this endpoint reporting "connected" — the preservation this whole
621
+ // branch exists for, worth surfacing rather than masking indefinitely.
622
+ // `refreshMcpRuntimeStatus` (slash.js) owns the edge-triggered log, by
623
+ // comparing this flag against the previous cycle's.
610
624
  next[name] = {
611
625
  ...value,
612
- status: value.status === 'connected' ? 'configured' : value.status,
613
- tools: [],
626
+ status: keepConnected ? 'connected' : value.status,
627
+ tools: keepConnected ? (prior?.tools ?? []) : [],
614
628
  toolError: message,
629
+ degraded: keepConnected && !freshlyConnected,
615
630
  };
616
631
  }
617
632
  }));
@@ -501,7 +501,7 @@ test('resolveRetryPolicy supports endpoint and tool overrides', () => {
501
501
  assert.deepEqual(policy, { maxAttempts: 4, backoffMs: 100 });
502
502
  });
503
503
 
504
- test('discoverMcpTools downgrades connected endpoint when tool discovery fails', async () => {
504
+ test('discoverMcpTools keeps a connected endpoint connected when tool discovery fails', async () => {
505
505
  const originalFetch = globalThis.fetch;
506
506
  globalThis.fetch = async () => ({
507
507
  ok: false,
@@ -519,7 +519,7 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
519
519
  },
520
520
  });
521
521
 
522
- assert.equal(status.wiki.status, 'configured');
522
+ assert.equal(status.wiki.status, 'connected');
523
523
  assert.equal(status.wiki.tools.length, 0);
524
524
  assert.match(status.wiki.toolError, /401/);
525
525
  } finally {
@@ -527,6 +527,68 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
527
527
  }
528
528
  });
529
529
 
530
+ test('discoverMcpTools preserves last-known-good status and tools across a failed re-probe', async () => {
531
+ const originalFetch = globalThis.fetch;
532
+ globalThis.fetch = async () => ({
533
+ ok: false,
534
+ status: 502,
535
+ headers: { get: () => null },
536
+ text: async () => 'Bad gateway',
537
+ });
538
+
539
+ try {
540
+ const status = await discoverMcpTools(
541
+ {
542
+ production: {
543
+ status: 'configured',
544
+ url: 'http://127.0.0.1:3202/mcp/',
545
+ token: 'token',
546
+ },
547
+ },
548
+ {
549
+ production: {
550
+ status: 'connected',
551
+ tools: [{ name: 'production__agent_execute' }, { name: 'production__agent_status' }],
552
+ },
553
+ },
554
+ );
555
+
556
+ assert.equal(status.production.status, 'connected');
557
+ assert.deepEqual(status.production.tools.map((tool) => tool.name), [
558
+ 'production__agent_execute',
559
+ 'production__agent_status',
560
+ ]);
561
+ assert.match(status.production.toolError, /502/);
562
+ // Docker (`value.status`) no longer confirms this endpoint; only prior
563
+ // history keeps it "connected". `degraded` is what lets the caller
564
+ // (refreshMcpRuntimeStatus) tell this apart from a genuinely fresh probe
565
+ // success, so it can report the preservation instead of masking it.
566
+ assert.equal(status.production.degraded, true);
567
+ } finally {
568
+ globalThis.fetch = originalFetch;
569
+ }
570
+ });
571
+
572
+ test('discoverMcpTools does not mark a fresh probe failure on an already-connected endpoint as degraded', async () => {
573
+ const originalFetch = globalThis.fetch;
574
+ globalThis.fetch = async () => ({
575
+ ok: false,
576
+ status: 401,
577
+ headers: { get: () => null },
578
+ text: async () => '{"error":"invalid or missing bearer token"}',
579
+ });
580
+
581
+ try {
582
+ const status = await discoverMcpTools({
583
+ wiki: { status: 'connected', url: 'http://127.0.0.1:3201/mcp', token: 'token' },
584
+ });
585
+ assert.equal(status.wiki.status, 'connected');
586
+ assert.equal(status.wiki.degraded, false);
587
+ } finally {
588
+ globalThis.fetch = originalFetch;
589
+ }
590
+ });
591
+
530
592
  // A stateful server may reject any cold request, and every SDK words that
531
593
  // rejection differently ("No valid session" for the Node SDK, "Missing session
532
594
  // ID" for the Python one CME runs). The client must never read that prose: it