@dotdrelle/wiki-manager 0.15.50 → 0.15.53

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.
@@ -78,5 +78,24 @@
78
78
  # resources:
79
79
  # limits:
80
80
  # cpus: '2.0'
81
+ #
82
+ # ── Host timezone (opt-in) ────────────────────────────────────────────────────
83
+ #
84
+ # Containers default to UTC, so runtime/production logs can read an hour or two
85
+ # off the host's wall clock. Mount the host's timezone to make them agree. This
86
+ # is deliberately NOT in the packaged file: the mount assumes /etc/localtime
87
+ # exists on the host (true on most Linux/macOS hosts, but not all), and a
88
+ # timezone is a property of the machine, not of the workspace.
89
+ #
90
+ # services:
91
+ # serve:
92
+ # volumes:
93
+ # - /etc/localtime:/etc/localtime:ro
94
+ # mcp-http:
95
+ # volumes:
96
+ # - /etc/localtime:/etc/localtime:ro
97
+ # production-mcp:
98
+ # volumes:
99
+ # - /etc/localtime:/etc/localtime:ro
81
100
 
82
101
  services: {}
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.50",
3
+ "version": "0.15.53",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/dotdrelle/llm-wiki-manager.git"
8
+ },
9
+ "homepage": "https://github.com/dotdrelle/llm-wiki-manager#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/dotdrelle/llm-wiki-manager/issues"
12
+ },
5
13
  "license": "PolyForm-Noncommercial-1.0.0",
6
- "author": "dotrelle",
14
+ "author": "dotdrelle",
7
15
  "type": "module",
8
16
  "bin": {
9
17
  "wiki-manager": "bin/wiki-manager",
@@ -70,7 +70,7 @@ const SHELL_RUN_COMMAND_TOOL = {
70
70
  description: [
71
71
  'Run a deterministic wiki-manager slash command inside the current shell session.',
72
72
  'Allowed commands: /workspace list, /workspace init <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, /skills run <name>, /upload <path>, /upload convert <id|pending>.',
73
- 'Do not use for arbitrary system shell commands, /workspace delete, /mcp call, /wiki run, /start, /stop, /logs, or /exit.',
73
+ 'Do not use for arbitrary system shell commands, /workspace delete, /wiki run, /start, /stop, /logs, or /exit.',
74
74
  ].join(' '),
75
75
  parameters: {
76
76
  type: 'object',
@@ -522,6 +522,27 @@ function delegationBlockerForDonna(rawFailure) {
522
522
  });
523
523
  }
524
524
 
525
+ function isUnresolvedTargetFailure(rawFailure) {
526
+ return /file does not exist|does not exist|no files match/i.test(rawFailure);
527
+ }
528
+
529
+ function unresolvedTargetForDonna(rawFailure) {
530
+ const cleaned = String(rawFailure ?? '')
531
+ .replace(/\b(?:provider|endpoint)=[^\s,]+/g, '')
532
+ .replace(/\[[^\]]*\.(?:agent_plan|agent_execute)\]/g, '')
533
+ .replace(/\s*Available capabilities:\s*[\s\S]*$/i, '')
534
+ .replace(/\s*<-\s*[\s\S]*$/s, '')
535
+ .replace(/\s{2,}/g, ' ')
536
+ .trim();
537
+ return JSON.stringify({
538
+ delegated: false,
539
+ blocker: 'unresolved_target',
540
+ reason: cleaned,
541
+ instruction:
542
+ 'The target the user named does not match an existing file. Look up the available targets with the read-only list tools, then retry the delegation with the exact resolved path, or ask the user to confirm which target they meant. Never widen to an all-targets operation, and never expose exception names, tool names, or internal routing details.',
543
+ });
544
+ }
545
+
525
546
  function summarizeToolArguments(rawArguments) {
526
547
  if (!rawArguments || rawArguments === '{}') return '';
527
548
  try {
@@ -869,17 +890,13 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
869
890
  409 garde tout son sens.
870
891
  */
871
892
  if (typeof session?._delegateWithinRun === 'function') {
872
- try {
873
- const inRun = await session._delegateWithinRun(objective);
874
- return JSON.stringify({
875
- delegated: true,
876
- runId: inRun.runId,
877
- summary: inRun.summary ?? null,
878
- message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
879
- });
880
- } catch (err) {
881
- return `Délégation refusée : ${err instanceof Error ? err.message : String(err)}`;
882
- }
893
+ const inRun = await session._delegateWithinRun(objective);
894
+ return JSON.stringify({
895
+ delegated: true,
896
+ runId: inRun.runId,
897
+ summary: inRun.summary ?? null,
898
+ message: `Action lancée (${String(inRun.runId).slice(0, 8)}) après validation du plan réel : ${inRun.summary?.tasks ?? 0} tâche(s), ${inRun.summary?.agent ?? 'agent résolu'}. Exécution en cours.`,
899
+ });
883
900
  }
884
901
  const result = await postRuntimeDelegate(objective, { url, workspace });
885
902
  return result?.runId
@@ -1877,7 +1894,8 @@ export function createAgentGraph(options = {}) {
1877
1894
  if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
1878
1895
  const delegationFailure = resultText
1879
1896
  .replace(/^Runtime control error \(delegate\):\s*/i, '')
1880
- .replace(/^Delegation failed during objective_resolution:\s*/i, '');
1897
+ .replace(/^Delegation failed during objective_resolution:\s*/i, '')
1898
+ .replace(/^Delegation failed during agent_plan:\s*/i, '');
1881
1899
  const needsInput = delegationFailure.match(/^Delegation requires input:\s*(.+)$/i);
1882
1900
  if (needsInput) {
1883
1901
  // Missing provider-required fields are a conversational blocker,
@@ -1889,6 +1907,11 @@ export function createAgentGraph(options = {}) {
1889
1907
  missingRequiredFields: needsInput[1].split(',').map((item) => item.trim()).filter(Boolean),
1890
1908
  instruction: 'Ask the user for the missing required information. Do not expose internal validation details.',
1891
1909
  });
1910
+ } else if (isUnresolvedTargetFailure(delegationFailure)) {
1911
+ // A named target that resolves to nothing is not an unsupported
1912
+ // action: Donna can look it up and retry (or ask), so the turn
1913
+ // must not be marked terminal here.
1914
+ resultText = unresolvedTargetForDonna(delegationFailure);
1892
1915
  } else {
1893
1916
  terminalFailure = delegationFailure;
1894
1917
  resultText = delegationBlockerForDonna(delegationFailure);
@@ -476,7 +476,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
476
476
  mainCalls += 1;
477
477
  if (mainCalls === 1) return {
478
478
  content: null, message: { role: 'assistant', content: null },
479
- tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"template":"Quarterly report"},"selectionKind":"explicit_name"}' } }],
479
+ tool_calls: [{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"deliver","arguments":{"deliverable":"Quarterly report"},"selectionKind":"explicit_name"}' } }],
480
480
  };
481
481
  return { content: 'Skill mis en file.', message: { role: 'assistant', content: 'Skill mis en file.' }, tool_calls: null };
482
482
  },
@@ -489,7 +489,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
489
489
  // quelles compétences sont déjà ouvertes au-dessus de lui.
490
490
  assert.deepEqual(calls, [[
491
491
  'deliver',
492
- { template: 'Quarterly report' },
492
+ { deliverable: 'Quarterly report' },
493
493
  { selectionKind: 'explicit_name', turnId: 'turn-skill-1', skillStack: [] },
494
494
  ]]);
495
495
  });
@@ -1334,6 +1334,42 @@ test('a delegation missing required provider inputs returns to Donna for clarifi
1334
1334
  }
1335
1335
  });
1336
1336
 
1337
+ test('a delegation whose named target does not resolve returns to Donna to resolve, not as a terminal refusal', async () => {
1338
+ let calls = 0;
1339
+ const session = sessionBase({
1340
+ runtime: { url: 'http://runtime.test' },
1341
+ _delegateWithinRun: async () => {
1342
+ throw new Error('Delegation failed during agent_plan: provider=production endpoint=http://127.0.0.1:3000/mcp/ templates file does not exist: basic note');
1343
+ },
1344
+ llm: {
1345
+ async completeWithTools() {
1346
+ calls += 1;
1347
+ if (calls === 1) {
1348
+ return {
1349
+ content: null,
1350
+ message: { role: 'assistant', content: null },
1351
+ tool_calls: [{
1352
+ id: 'delegate-target',
1353
+ type: 'function',
1354
+ function: { name: 'runtime__delegate', arguments: '{"objective":"build basic note"}' },
1355
+ }],
1356
+ };
1357
+ }
1358
+ return {
1359
+ content: 'Je n’ai trouvé aucun template « basic note ».',
1360
+ message: { role: 'assistant', content: 'Je n’ai trouvé aucun template « basic note ».' },
1361
+ tool_calls: null,
1362
+ };
1363
+ },
1364
+ },
1365
+ });
1366
+
1367
+ const result = await createAgentGraph().invoke({ input: 'build basic note', session });
1368
+ assert.equal(result.terminalToolFailure, false);
1369
+ assert.equal(result.response, 'Je n’ai trouvé aucun template « basic note ».');
1370
+ assert.doesNotMatch(result.response, /provider=|endpoint=|file does not exist|agent_plan/i);
1371
+ });
1372
+
1337
1373
  // Guard: the system prompt must never show a connected tool's bare name
1338
1374
  // outside its qualified server__tool form. Bare mentions are what teach the
1339
1375
  // model to emit unqualified tool calls (the cme_status incident). The bare
@@ -109,12 +109,20 @@ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
109
109
  }
110
110
 
111
111
  /**
112
- * Fill a single-task executor's arguments from the natural-language objective,
112
+ * Fill a task's structured arguments from the natural-language objective,
113
113
  * generically — against the capability's own declared `inputSchema`, with no
114
114
  * per-agent or per-provider knowledge in the manager. This lets Donna honour
115
115
  * stated constraints ("les 10 derniers mails", "de LinkedIn") while keeping
116
116
  * `runtime__delegate` agnostic (it still only carries the objective).
117
117
  *
118
+ * Used on BOTH delegation branches:
119
+ * - executor-only (`singleTaskOnly`) agents: the extracted arguments become the
120
+ * single task's `arguments`;
121
+ * - planner (`canPlan`) agents: the extracted arguments are forwarded to
122
+ * `agent_plan` as its `arguments`, so a targeted selector (a template, a
123
+ * deliverable, a source) reaches the plan instead of widening to "all"
124
+ * (a `/wiki-build <template>` that built every template).
125
+ *
118
126
  * Degrades gracefully (cf. provider compatibility): forced tool_choice first,
119
127
  * then a JSON-text completion, then no arguments — the executor uses its own
120
128
  * defaults. It never throws and never invents identifiers.
@@ -1234,6 +1242,13 @@ async function runRuntime(argv, agent) {
1234
1242
  let fragment;
1235
1243
  if (canPlan) {
1236
1244
  try {
1245
+ const extractedArguments = await resolveExecutorArguments({
1246
+ llm: session.llm,
1247
+ objective,
1248
+ capability: provider.capability,
1249
+ workspace: session.workspace ?? context.workspace ?? '',
1250
+ signal: session._abortSignal,
1251
+ });
1237
1252
  planResult = await callMcpTool(
1238
1253
  session.mcp,
1239
1254
  provider.serverName,
@@ -1242,6 +1257,9 @@ async function runRuntime(argv, agent) {
1242
1257
  capability: selection.capability,
1243
1258
  operation: selection.operation,
1244
1259
  objective,
1260
+ ...(extractedArguments && Object.keys(extractedArguments).length > 0
1261
+ ? { arguments: extractedArguments }
1262
+ : {}),
1245
1263
  workspace: { revision: String(Date.now()) },
1246
1264
  constraints: {
1247
1265
  maxConcurrency: resolveCapabilityConcurrency(
@@ -126,6 +126,37 @@ test('argument extraction falls back to a JSON-text completion', async () => {
126
126
  assert.deepEqual(args, { query: 'from:linkedin.com' });
127
127
  });
128
128
 
129
+ const BUILD_CAPABILITY = {
130
+ description: 'Build llm-wiki deliverables from templates in templates/.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ additionalProperties: true,
134
+ properties: {
135
+ templates: { type: 'array', items: { type: 'string' } },
136
+ stabilize: { type: 'boolean' },
137
+ },
138
+ },
139
+ };
140
+
141
+ test('argument extraction maps a skill template selector to the templates array', async () => {
142
+ // Regression: a skill carries its `template` parameter as a natural-language
143
+ // "User parameters:" block. The delegation path must turn it back into the
144
+ // structured `templates` array, or a targeted build widens to every template.
145
+ const llm = {
146
+ completeWithTools: async () => ({
147
+ tool_calls: [{
148
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ templates: ['overview'] }) },
149
+ }],
150
+ }),
151
+ };
152
+ const args = await resolveExecutorArguments({
153
+ llm,
154
+ objective: 'Build deliverables from the current wiki within the exact scope requested by the template parameter.\n\nUser parameters:\ntemplate: overview',
155
+ capability: BUILD_CAPABILITY,
156
+ });
157
+ assert.deepEqual(args, { templates: ['overview'] });
158
+ });
159
+
129
160
  test('argument extraction stays agnostic and safe when it cannot extract', async () => {
130
161
  // No inputSchema → no extraction attempted at all.
131
162
  assert.deepEqual(
@@ -32,9 +32,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
32
  import {
33
33
  cancelQueueItem,
34
34
  clearFinishedQueueItems,
35
- enqueueProductionJob,
36
35
  formatQueue,
37
- productionLockBusy,
38
36
  } from '../core/jobQueue.js';
39
37
  import {
40
38
  listWikircProfiles,
@@ -599,11 +597,6 @@ async function createWorkspaceCommand(context, workspaceName, targetPath) {
599
597
  }
600
598
 
601
599
 
602
- function formatMcpCallActivity(serverName, toolName, resultText) {
603
- if (serverName === 'production') return null;
604
- return formatActivitySummary(serverName, toolName, resultText);
605
- }
606
-
607
600
  function publishPayloadActivity(session, payload, context = {}) {
608
601
  const activity = extractActivity(payload, context);
609
602
  if (!activity) return null;
@@ -748,7 +741,7 @@ ${helpPair('/stop [all|everything|service|agents]', 'Stop service(s)', '/logs <s
748
741
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
749
742
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
750
743
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
751
- ${helpPair('/mcp tools [mcp]', 'MCP tools', '/mcp call ...', 'Call MCP tool')}
744
+ ${helpPair('/mcp tools [mcp]', 'MCP tools', '', '')}
752
745
  ${helpPair('/connector list', 'Connector auth status', '/connector auth <n>', 'Authorize connector')}
753
746
  ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
754
747
  ${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
@@ -1218,40 +1211,7 @@ export async function handleSlashCommand(line, context) {
1218
1211
  }
1219
1212
  return { output: formatMcpTools(context.session.mcp, filterName) };
1220
1213
  }
1221
- if (subcommand === 'call') {
1222
- const serverName = args[2];
1223
- const toolName = args[3];
1224
- if (!serverName || !toolName) {
1225
- return { output: 'Usage: /mcp call <mcp> <tool> [json]' };
1226
- }
1227
- try {
1228
- const rawArgs = args.slice(4).join(' ');
1229
- let toolArgs = rawArgs ? JSON.parse(rawArgs) : {};
1230
- if (serverName === 'production' && toolName === 'production_start_job' && context.session.workspace && !toolArgs.callerLabel) {
1231
- toolArgs = { ...toolArgs, callerLabel: `${context.session.workspace}/wiki-manager` };
1232
- }
1233
- if (serverName === 'production' && toolName === 'production_start_job' && productionLockBusy(context.session)) {
1234
- const item = enqueueProductionJob(context.session, toolArgs, 'production lock busy');
1235
- return { output: `Queued ${item.id}: waiting ${item.workspace ?? 'no-workspace'} ${item.tool}` };
1236
- }
1237
- step(`MCP: calling ${serverName}.${toolName}…`);
1238
- const result = await callMcpTool(context.session.mcp, serverName, toolName, toolArgs);
1239
- const output = formatMcpToolResult(result);
1240
- const payload = parseJsonText(output);
1241
- if (serverName === 'production' && toolName === 'production_start_job' && payload?.ok === false && payload?.error === 'workspace_busy') {
1242
- const item = enqueueProductionJob(context.session, toolArgs, 'workspace_busy');
1243
- return { output: `Queued ${item.id}: waiting for production lock (${payload.activeJobId ?? 'active job'})` };
1244
- }
1245
- const activity = formatMcpCallActivity(serverName, toolName, output);
1246
- if (activity) step(activity);
1247
- return rawCommandResult(`/mcp call ${serverName} ${toolName}`, output);
1248
- } catch (err) {
1249
- const message = err instanceof Error ? err.message : String(err);
1250
- step(formatActivityError(serverName, toolName, err));
1251
- return { output: message };
1252
- }
1253
- }
1254
- return { output: 'Usage: /mcp <status|endpoints|tools|call> [mcp]' };
1214
+ return { output: 'Usage: /mcp <status|endpoints|tools> [mcp]' };
1255
1215
  }
1256
1216
  case 'connector': {
1257
1217
  const subcommand = args[1] ?? 'list';
@@ -34,6 +34,15 @@ test('/status MCP overview contains only connector name, port and status', () =>
34
34
  assert.doesNotMatch(output, /tools|error|detail|http/i);
35
35
  });
36
36
 
37
+ test('/mcp exposes diagnostics only and cannot directly execute arbitrary MCP tools', async () => {
38
+ const result = await handleSlashCommand('/mcp call production production_start_job {"step":"ingest"}', {
39
+ packageJson: { version: 'test' },
40
+ session: { mcp: {} },
41
+ });
42
+
43
+ assert.equal(result.output, 'Usage: /mcp <status|endpoints|tools> [mcp]');
44
+ });
45
+
37
46
  test('/status base URL displays only its domain while retaining the full link', () => {
38
47
  assert.equal(
39
48
  compactBaseUrl('https://albert.api.etalab.gouv.fr/v1'),
@@ -2,7 +2,7 @@ import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
3
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
4
  import { formatRuntimeLogPayload } from './runtimeLog.js';
5
- import { projectSkillChains } from './skillChainView.js';
5
+ import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
6
6
  import { projectWorkflow } from './workflow.js';
7
7
  import { validateContractInDev } from '../contracts/schemas.js';
8
8
  import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
@@ -269,6 +269,7 @@ function applyEvent(state, event) {
269
269
  state.planRevision = 0;
270
270
  state.planPatches = [];
271
271
  state.summary = null;
272
+ pruneTerminalControlItems(state.controlQueue);
272
273
  return;
273
274
  case 'user_message':
274
275
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
@@ -711,6 +712,44 @@ function finishControlByRun(queue, runId, status, finishedAt) {
711
712
  item.updatedAt = finishedAt;
712
713
  }
713
714
 
715
+ /*
716
+ A new run makes the previous control items history.
717
+
718
+ `run_started` already resets plan, activities and logs, but the control queue
719
+ was left to accumulate: a cancelled or failed chain stayed in the CHAIN panel
720
+ across the next plan, and its terminal items kept counting in the queue ("Queue
721
+ (14)" over 4 live items). Prune here, not in the UI, so both projections agree.
722
+
723
+ A chain is dropped only once EVERY item is terminal — the active chain always
724
+ has a running/queued item and is therefore never pruned mid-flight. Standalone
725
+ control items (no chainId) are dropped as soon as they are terminal.
726
+ */
727
+ function pruneTerminalControlItems(queue) {
728
+ const byChain = new Map();
729
+ const standalone = [];
730
+ for (const item of queue) {
731
+ if (item.chainId) {
732
+ if (!byChain.has(item.chainId)) byChain.set(item.chainId, []);
733
+ byChain.get(item.chainId).push(item);
734
+ } else {
735
+ standalone.push(item);
736
+ }
737
+ }
738
+ const drop = new Set();
739
+ for (const items of byChain.values()) {
740
+ if (items.every((item) => CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase()))) {
741
+ for (const item of items) drop.add(item.id);
742
+ }
743
+ }
744
+ for (const item of standalone) {
745
+ if (CONTROL_TERMINAL_STATUSES.has(String(item.status ?? '').toLowerCase())) drop.add(item.id);
746
+ }
747
+ if (!drop.size) return;
748
+ for (let i = queue.length - 1; i >= 0; i--) {
749
+ if (drop.has(queue[i].id)) queue.splice(i, 1);
750
+ }
751
+ }
752
+
714
753
  function appendAssistantDelta(state, delta) {
715
754
  if (!delta) return;
716
755
  const last = state.conversation.at(-1);
@@ -401,6 +401,35 @@ test('reduceAgentEvents: control queue is event sourced and follows run status',
401
401
  assert.equal(projection.controlQueue[1].status, 'cancelled');
402
402
  });
403
403
 
404
+ test('reduceAgentEvents: run_started prunes terminal control items and fully terminal chains', () => {
405
+ const ts = '2026-01-01T00:00:00.000Z';
406
+ const enqueue = (id, extra = {}) => createAgentEvent('control_enqueued', {
407
+ origin: 'runtime',
408
+ workspace: 'docs',
409
+ payload: { id, workspace: 'docs', input: 'objective', createdAt: ts, ...extra },
410
+ });
411
+ const projection = reduceAgentEvents([
412
+ // Fully terminal chain (done + skipped): becomes history, pruned.
413
+ enqueue('chain-old-1', { chainId: 'chain-old', chainSequence: 1 }),
414
+ enqueue('chain-old-2', { chainId: 'chain-old', chainSequence: 2 }),
415
+ createAgentEvent('control_started', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs', payload: { id: 'chain-old-1', runId: 'run-old-1' } }),
416
+ createAgentEvent('run_done', { origin: 'runtime', runId: 'run-old-1', workspace: 'docs' }),
417
+ createAgentEvent('control_skipped', { origin: 'runtime', workspace: 'docs', payload: { id: 'chain-old-2', reason: 'required_predecessor_failed' } }),
418
+ // Standalone terminal item: pruned.
419
+ enqueue('standalone-old'),
420
+ createAgentEvent('control_cancelled', { origin: 'runtime', workspace: 'docs', payload: { id: 'standalone-old' } }),
421
+ // Active chain (done + queued): must survive the prune.
422
+ enqueue('chain-active-1', { chainId: 'chain-active', chainSequence: 1 }),
423
+ enqueue('chain-active-2', { chainId: 'chain-active', chainSequence: 2 }),
424
+ createAgentEvent('control_started', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs', payload: { id: 'chain-active-1', runId: 'run-active-1' } }),
425
+ createAgentEvent('run_done', { origin: 'runtime', runId: 'run-active-1', workspace: 'docs' }),
426
+ // A new run starts: terminal relics are history, the active chain is not.
427
+ createAgentEvent('run_started', { origin: 'runtime', runId: 'run-new', workspace: 'docs' }),
428
+ ]);
429
+
430
+ assert.deepEqual(projection.controlQueue.map((item) => item.id), ['chain-active-1', 'chain-active-2']);
431
+ });
432
+
404
433
  test('reduceAgentEvents: control_enqueued preserves a structured capabilityPlan across replay', () => {
405
434
  const capabilityPlan = {
406
435
  capability: 'workspace.restore',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.50",
3
- "commit": "844cf7e"
2
+ "version": "0.15.53",
3
+ "commit": "43997b0"
4
4
  }
@@ -1,13 +1,23 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { readFileSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
6
6
 
7
+ // `agent-connectors` n'est pas cloné par le CI du manager, qui ne tire que
8
+ // llm-wiki, agent-wiki-production, agent-cme et agent-wiki-documents (voir
9
+ // check-versions.js). Les contrôles de cohérence croisée ci-dessous lisent la
10
+ // source de l'agent connectors : sans elle, on les saute plutôt que d'échouer
11
+ // sur un ENOENT. Le flux de release complet (build-and-push.sh) la fournit,
12
+ // donc le contrôle y reste effectif.
13
+ const connectorsPresent = existsSync(
14
+ fileURLToPath(new URL('../../../agent-external/agent-connectors', import.meta.url)),
15
+ );
16
+
7
17
  const connectorsSrc = (file) =>
8
18
  readFileSync(fileURLToPath(new URL(`../../../agent-external/agent-connectors/src/${file}`, import.meta.url)), 'utf8');
9
19
 
10
- test('the grant names mirror the agent, spelling included', () => {
20
+ test('the grant names mirror the agent, spelling included', { skip: !connectorsPresent }, () => {
11
21
  // `modify` est le nom de Google (scope gmail.modify) et celui de l'agent.
12
22
  // Un synonyme côté manager créerait une troisième orthographe à tenir à jour
13
23
  // — le travers qui avait déjà donné une seconde paire de variables OAuth.
@@ -26,7 +36,7 @@ test('every grant is described in plain words, never left as a bare token', () =
26
36
  }
27
37
  });
28
38
 
29
- test('the default asks for everything the agent can actually do', () => {
39
+ test('the default asks for everything the agent can actually do', { skip: !connectorsPresent }, () => {
30
40
  // Un défaut plus étroit promet des actions que l'autorisation ne couvre pas :
31
41
  // `/connector auth google` ne demandait que `read`, et l'envoi comme le
32
42
  // marquage échouaient après coup, en ressemblant à des fonctions absentes.
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.50';
4
+ const WIKI_MANAGER_VERSION = '0.15.53';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -65,7 +65,7 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
65
65
  // lines ended up visually glued at the bottom of Logs/Trace, out of
66
66
  // chronology with the shell's own timestamped lines.
67
67
  if (payload?.message != null && !payload.event) {
68
- return [timeLabel(ts), String(payload.message)].filter(Boolean).join(' ');
68
+ return [timeLabel(ts), shortenUuids(String(payload.message))].filter(Boolean).join(' ');
69
69
  }
70
70
  const time = timeLabel(ts);
71
71
  const event = eventLabel(payload.event);
@@ -75,10 +75,26 @@ export function formatRuntimeLogPayload(payload = {}, ts = null) {
75
75
  if (payload.status != null) fields.push(formatField('status', payload.status));
76
76
  if (payload.percent != null) fields.push(formatField('percent', payload.percent));
77
77
  if (payload.outputs != null) fields.push(formatField('outputs', payload.outputs));
78
- if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(payload.detail)}`);
78
+ if (payload.detail != null && payload.detail !== '') fields.push(`detail=${quoteIfNeeded(shortenUuids(payload.detail))}`);
79
79
  return [time, event, ...fields].filter(Boolean).join(' ');
80
80
  }
81
81
 
82
+ // Runtime/agent ids are long UUIDs (run, task, attempt, agent instance). A full
83
+ // UUID pushed the readable fields off the line and wrapped mid-id, which is what
84
+ // made the Logs/Trace panel illegible. Collapse the UUID to its first 8 hex
85
+ // characters — the same disambiguating prefix every UI already shows — and cap
86
+ // over-long slugs so a single field never monopolises the line.
87
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
88
+
89
+ function shortenUuids(text) {
90
+ return String(text ?? '').replace(UUID_RE, (uuid) => `${uuid.slice(0, 8)}…`);
91
+ }
92
+
93
+ export function shortLogId(value, { maxLength = 40 } = {}) {
94
+ const shortened = shortenUuids(value);
95
+ return shortened.length > maxLength ? `${shortened.slice(0, maxLength - 1)}…` : shortened;
96
+ }
97
+
82
98
  export function runtimeLogMatchesFilter(line, filter = '') {
83
99
  const query = String(filter ?? '').trim();
84
100
  if (!query) return true;
@@ -122,7 +138,7 @@ function eventLabel(event) {
122
138
 
123
139
  function formatField(key, value) {
124
140
  if (value == null || value === '') return null;
125
- return `${key}=${quoteIfNeeded(value)}`;
141
+ return `${key}=${quoteIfNeeded(shortenUuids(value))}`;
126
142
  }
127
143
 
128
144
  function quoteIfNeeded(value) {
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
5
- import { compactRuntimeLogForDisplay, formatRuntimeLogPayload } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -98,3 +98,29 @@ test('runtime display compaction leaves other log entries unchanged', () => {
98
98
  const line = '09:25:35 trace: ERROR retrieval failed message="broken"';
99
99
  assert.equal(compactRuntimeLogForDisplay(line), line);
100
100
  });
101
+
102
+ test('long UUIDs collapse to a short prefix so log lines stay on one line', () => {
103
+ const uuid = '7fadad27-0be6-4d08-96e5-664fe7ee841e';
104
+ const line = formatRuntimeLogPayload({
105
+ event: 'task.ready',
106
+ runId: uuid,
107
+ taskId: `${uuid}:taxonomy-synthesis`,
108
+ attemptId: `attempt-${uuid}`,
109
+ agentInstanceId: `production-${uuid}`,
110
+ capability: 'document.build',
111
+ operation: 'build',
112
+ }, '2026-07-08T14:42:18.000Z');
113
+
114
+ assert.match(line, /run=7fadad27…/);
115
+ assert.match(line, /task=7fadad27…:taxonomy-synthesis/);
116
+ assert.match(line, /attempt=attempt-7fadad27…/);
117
+ assert.match(line, /agentInstance=production-7fadad27…/);
118
+ assert.doesNotMatch(line, /7fadad27-0be6-4d08-96e5-664fe7ee841e/);
119
+ });
120
+
121
+ test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
122
+ const long = `${'x'.repeat(48)}-deadbeef`;
123
+ assert.match(shortLogId(long), /…$/);
124
+ assert.ok(shortLogId(long).length <= 40);
125
+ assert.equal(shortLogId('7fadad27-0be6-4d08-96e5-664fe7ee841e'), '7fadad27…');
126
+ });