@dotdrelle/wiki-manager 0.15.74 → 0.15.78

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
@@ -23,11 +23,10 @@ deterministic dispatcher executes bounded tasks with idempotency, bounded
23
23
  approvals, per-run budgets and automatic recovery after restart. The chat
24
24
  stays available while runs execute; additional requests are queued.
25
25
 
26
- Scope note: this is a single-user deployment baseline. The multi-user model is
27
- specified in `llm-wiki/docs/industrialisation.md` and planned next. Until
28
- then, do not expose the runtime as a shared write surface; it binds to
29
- `127.0.0.1` by default, and `--host 0.0.0.0` must be an explicit deployment
30
- choice with bearer-token and network protection.
26
+ Scope note: this is a single-user deployment baseline. Do not expose the
27
+ runtime as a shared write surface; it binds to `127.0.0.1` by default, and
28
+ `--host 0.0.0.0` must be an explicit deployment choice with bearer-token and
29
+ network protection.
31
30
 
32
31
  ---
33
32
 
@@ -170,7 +169,7 @@ Projects compared: [OpenWiki](https://github.com/langchain-ai/openwiki),
170
169
  territory.
171
170
  - Serve a true multi-user instance with per-user identity and an attributed
172
171
  audit trail. This is a single-user deployment baseline (see the scope note
173
- above); multi-user is specified and planned next.
172
+ above).
174
173
  - Expose a graph-query API over the corpus the way GraphRAG does; retrieval is
175
174
  BM25 plus a vector index feeding generation.
176
175
  - Ship or host the multi-provider AI gateway — routing to several providers is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.74",
3
+ "version": "0.15.78",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -702,8 +702,8 @@ async function runHeadless(argv, agent) {
702
702
  session._onStep = step;
703
703
 
704
704
  let input = prompt;
705
- // Plan §24: headless must resolve executable skills through the same
706
- // runtime resolver as the Shell and serve. Injecting the skill body into a
705
+ // Headless must resolve executable skills through the same runtime resolver
706
+ // as the Shell and serve. Injecting the skill body into a
707
707
  // local prompt bypasses the compiler, so a multi-capability skill such as
708
708
  // wiki-sync would collapse into a single run here while producing two
709
709
  // everywhere else — and the rewritten bodies are business intentions, not
@@ -396,8 +396,8 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
396
396
  });
397
397
 
398
398
  test('headless waits for every run of a skill chain, not just the first', async () => {
399
- // wiki-sync compiles into two sequential runs: returning as soon as the
400
- // export finishes would report success before the ingest had started.
399
+ // A multi-step user skill compiles into sequential runs: returning as soon as
400
+ // the first finishes would report success before the rest had started.
401
401
  const client = chainClient([
402
402
  { controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
403
403
  { controlQueue: [chainItem(0, 'done'), chainItem(1, 'running', { runId: 'run-b' })] },
@@ -894,7 +894,7 @@ function componentInstallAction(missingImages) {
894
894
  return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
895
895
  }
896
896
 
897
- export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null }) {
897
+ export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null, style = 'compact' }) {
898
898
  const facts = JSON.stringify({
899
899
  operation,
900
900
  target,
@@ -902,15 +902,21 @@ export function localizedOperationResult({ operation, target, status = 'succeede
902
902
  ...(componentAction ? { componentAction, images } : {}),
903
903
  ...(detail ? { detail } : {}),
904
904
  });
905
+ const instructions = style === 'report'
906
+ ? [
907
+ 'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
908
+ "Décris ce qui a été fait : ce qui a été démarré, et l'état actuel de chaque service (en marche, en échec ou inconnu).",
909
+ 'Réponds en deux ou trois phrases. Ne cite aucune commande, syntaxe shell, chemin de fichier, sortie docker ou identifiant.',
910
+ ]
911
+ : [
912
+ 'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
913
+ 'Réponds par une seule phrase humaine et naturelle.',
914
+ 'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
915
+ ];
905
916
  return {
906
917
  output: facts,
907
918
  rawOutput: true,
908
- agentTrigger: [
909
- 'Formule le résultat structuré suivant dans la langue et le ton demandés par le profil du workspace.',
910
- 'Réponds par une seule phrase humaine et naturelle.',
911
- 'Ne mentionne aucune commande, syntaxe shell, étape suivante ou détail technique.',
912
- `Résultat: ${facts}`,
913
- ].join('\n'),
919
+ agentTrigger: [...instructions, `Résultat: ${facts}`].join('\n'),
914
920
  };
915
921
  }
916
922
 
@@ -1197,6 +1203,7 @@ export async function handleSlashCommand(line, context) {
1197
1203
  const startsAgents = service === 'all';
1198
1204
  const target = service === 'services' ? undefined : service;
1199
1205
  try {
1206
+ let agentsResult = null;
1200
1207
  if (startsAgents) {
1201
1208
  // Validate the workspace half before mutating the global agents
1202
1209
  // stack. Otherwise `/start all` with no active workspace starts the
@@ -1204,18 +1211,48 @@ export async function handleSlashCommand(line, context) {
1204
1211
  if (!context.session.workspace || !context.session.workspacePath || !context.session.workspaceEnv?.WORKSPACE_NAME) {
1205
1212
  throw new Error('No workspace loaded. Use /use <workspace>.');
1206
1213
  }
1207
- const agentsResult = await runAgentCommand(startAgents, 'start');
1214
+ agentsResult = await runAgentCommand(startAgents, 'start');
1208
1215
  if (agentsResult?.failed) return agentsResult;
1209
1216
  }
1210
1217
  step(`Services: starting ${target ?? 'workspace services'}…`);
1211
- const missingImages = await collectMissingImages(step, (opts) => startService(context.session, target, opts));
1218
+ const started = [];
1219
+ const missingImages = await collectMissingImages(step, async (opts) => {
1220
+ const result = await startService(context.session, target, opts);
1221
+ if (Array.isArray(result?.targets)) started.push(...result.targets);
1222
+ });
1212
1223
  step('Services: refreshing MCP runtime…');
1213
1224
  await refreshMcpRuntimeStatus(context.session);
1225
+ // The report is about what was done, so it names what started and how
1226
+ // each service ended up. A failed state check says so instead of
1227
+ // leaving every state "unknown" without a word.
1228
+ let states = null;
1229
+ try {
1230
+ states = await serviceStates(context.session);
1231
+ } catch (err) {
1232
+ step(`Services: state check after start unavailable — ${rawFailureText(err)}`);
1233
+ }
1234
+ const agentsFacts = agentsResult ? parseJsonText(agentsResult.output) : null;
1235
+ const detail = {
1236
+ ...(agentsFacts && typeof agentsFacts === 'object' && !Array.isArray(agentsFacts) ? { agents: agentsFacts } : {}),
1237
+ ...(started.length > 0
1238
+ ? {
1239
+ services: {
1240
+ started,
1241
+ states: started.map((name) => ({
1242
+ service: name,
1243
+ running: states?.[name] ? states[name].running : null,
1244
+ })),
1245
+ },
1246
+ }
1247
+ : {}),
1248
+ };
1214
1249
  return localizedOperationResult({
1215
1250
  operation: 'start',
1216
1251
  target: startsAgents ? 'all-services-and-agents' : (target || 'workspace-services'),
1217
1252
  componentAction: componentInstallAction(missingImages),
1218
1253
  images: missingImages,
1254
+ detail: Object.keys(detail).length > 0 ? detail : null,
1255
+ style: 'report',
1219
1256
  });
1220
1257
  } catch (err) {
1221
1258
  step(formatActivityError('services', 'start', err));
@@ -128,6 +128,34 @@ test('a started agent stack reloads the env and the MCP endpoints the script rew
128
128
  assert.match(runAgent, /await refreshMcpRuntimeStatus\(context\.session\)/);
129
129
  });
130
130
 
131
+ test('start report style asks Donna to describe what was done, not a bare sentence', () => {
132
+ const result = localizedOperationResult({
133
+ operation: 'start',
134
+ target: 'wiki',
135
+ style: 'report',
136
+ detail: { services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] } },
137
+ });
138
+ assert.deepEqual(JSON.parse(result.output).detail, {
139
+ services: { started: ['wiki'], states: [{ service: 'wiki', running: true }] },
140
+ });
141
+ assert.match(result.agentTrigger, /Décris ce qui a été fait/);
142
+ assert.match(result.agentTrigger, /en marche, en échec ou inconnu/);
143
+ assert.doesNotMatch(result.agentTrigger, /une seule phrase/);
144
+ assert.doesNotMatch(result.agentTrigger, /\/start|Docker|compose/);
145
+ });
146
+
147
+ test('/start captures what actually started and their resulting states', async () => {
148
+ const { readFile } = await import('node:fs/promises');
149
+ const source = await readFile(new URL('./slash.js', import.meta.url), 'utf8');
150
+ const start = source.slice(source.indexOf("case 'start': {"), source.indexOf("case 'stop': {"));
151
+
152
+ assert.match(start, /const started = \[\];/);
153
+ assert.match(start, /result\?\.targets/);
154
+ assert.match(start, /states = await serviceStates\(context\.session\)/);
155
+ assert.match(start, /running: states\?\.\[name\] \? states\[name\]\.running : null/);
156
+ assert.match(start, /style: 'report'/);
157
+ });
158
+
131
159
  test('/start completes to the three documented targets', async () => {
132
160
  const { completionDescription } = await import('../shell/repl.js');
133
161
  const { matches } = completionContext('/start ', {});
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.74",
3
- "commit": "4ca493b"
2
+ "version": "0.15.78",
3
+ "commit": "6c0c150"
4
4
  }
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.74';
4
+ const WIKI_MANAGER_VERSION = '0.15.78';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -272,7 +272,14 @@ function clarifyToolDescription(_serverName, _toolName, description) {
272
272
 
273
273
  async function listMcpTools(endpoint) {
274
274
  if (!endpoint.url) throw new Error('missing endpoint URL');
275
- const payload = await mcpRequest(endpoint, 'tools/list', {});
275
+ // Opt-in only, unlike callMcpTool: this probe runs inside discoverMcpTools's
276
+ // Promise.all on every re-scan/delegation, so an endpoint with no explicit
277
+ // `retry` in mcp.endpoints.json must keep costing exactly one 8s timeout
278
+ // when unreachable, not silently inherit the global 2-attempt default and
279
+ // double that cost for every unconfigured server. Only an endpoint that
280
+ // explicitly declares `retry` gets more than one attempt here.
281
+ const retry = endpoint.retry ? resolveRetryPolicy(endpoint) : { maxAttempts: 1, backoffMs: 0 };
282
+ const payload = await withRetry(() => mcpRequest(endpoint, 'tools/list', {}), retry);
276
283
  return payload?.result?.tools ?? [];
277
284
  }
278
285
 
@@ -756,8 +763,7 @@ export function parseToolCallName(name) {
756
763
  // tool name, route to it and report `normalized: true`; otherwise return
757
764
  // `server: null` with the list of candidate servers so the caller can raise
758
765
  // an explicit error. This is name normalization, never fuzzy matching — do
759
- // not extend it to description/similarity-based selection (plan directeur
760
- // §20 forbids that).
766
+ // not extend it to description/similarity-based selection.
761
767
  export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
762
768
  const parsed = parseToolCallName(name);
763
769
  if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
@@ -177,6 +177,17 @@ export function isDispatchPlumbingLine(line) {
177
177
  return DISPATCH_PLUMBING_LINE.test(String(line ?? '').replace(/^runtime\s+/, ''));
178
178
  }
179
179
 
180
+ // The agent's own reasoning traces — "Agent: planning next action…",
181
+ // "Agent: classified input as …", "Agent: streaming final answer…" — describe
182
+ // how the agent works, not what the business run does. They belong with the
183
+ // dispatch plumbing in the Agent status tab; the Runtime tab keeps the
184
+ // business flow (plan, task transitions, failures).
185
+ const AGENT_TRACE_LINE = /^(?:runtime\s+)?(?:\d{1,2}:\d{2}(?::\d{2})?\s*(?:·\s*)?)?Agent:\s/;
186
+
187
+ export function isAgentTraceLine(line) {
188
+ return AGENT_TRACE_LINE.test(String(line ?? ''));
189
+ }
190
+
180
191
  export function runtimeLogMatchesFilter(line, filter = '') {
181
192
  const query = String(filter ?? '').trim();
182
193
  if (!query) return true;
@@ -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, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
5
+ import { compactRuntimeLogForDisplay, formatRuntimeLogPayload, isAgentTraceLine, isDispatchPlumbingLine, shortLogId } from './runtimeLog.js';
6
6
  import { emitRuntimeLog } from '../runtime/supervisor.js';
7
7
 
8
8
  const CYCLE_EVENTS = [
@@ -156,6 +156,34 @@ test('isDispatchPlumbingLine recognises the shell-tagged "runtime " lines for th
156
156
  assert.equal(isDispatchPlumbingLine('runtime 14:42:22 Plan validated for run r1'), false);
157
157
  });
158
158
 
159
+ test('isAgentTraceLine recognises the "Agent:" traces for the Agent status tab', () => {
160
+ // The agent's own reasoning traces were rendered in the Runtime tab,
161
+ // indistinguishable from the business flow. They describe the agent's
162
+ // working, so the Agent status tab shows them with the dispatch plumbing.
163
+ for (const line of [
164
+ '14:42:18 Agent: planning next action…',
165
+ '14:42:19 Agent: classified input as enqueue',
166
+ '14:42:20 Agent: streaming final answer…',
167
+ 'runtime 14:42:18 Agent: planning next action…',
168
+ ]) {
169
+ assert.equal(isAgentTraceLine(line), true, `expected an agent trace: ${line}`);
170
+ }
171
+ });
172
+
173
+ test('isAgentTraceLine leaves every other line for the Runtime tab', () => {
174
+ for (const line of [
175
+ '14:42:18 ▸ Polish proposition — started (knowledge.polish → agent-production)',
176
+ '14:42:19 ✓ Polish proposition — done (1 output)',
177
+ '14:42:21 Run failed: No agent provides capability workspace.restore.',
178
+ '14:42:22 Plan validated for run r1',
179
+ '14:42:23 Agentic runtime: no capabilities available',
180
+ '14:42:24 Plan: 3 task(s) declared from production fragment',
181
+ 'runtime 14:42:21 Run failed: Agent was not ready',
182
+ ]) {
183
+ assert.equal(isAgentTraceLine(line), false, `expected business flow: ${line}`);
184
+ }
185
+ });
186
+
159
187
  test('shortLogId caps an over-long task slug while shortening embedded UUIDs', () => {
160
188
  const long = `${'x'.repeat(48)}-deadbeef`;
161
189
  assert.match(shortLogId(long), /…$/);
@@ -30,8 +30,8 @@ test('validation rejects technical routing details', () => {
30
30
  assert.throws(() => validateCompiledObjectives([{ text: 'agent: cme' }]), { code: 'skill_compile_failed' });
31
31
  });
32
32
 
33
- test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
34
- const expected = { pipeline: 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
33
+ test('every shipped scaffold skill compiles to a single intention', async () => {
34
+ const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
35
35
  for (const [name, count] of Object.entries(expected)) {
36
36
  const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
37
37
  const { meta, body } = parseFrontmatter(raw);
@@ -53,7 +53,7 @@ test('every objective of a chain carries the user parameters, not just the last'
53
53
  // Appending the parameters before splitting attached `source` to the ingest
54
54
  // step and left the export step — the one that consumes it — without it.
55
55
  const skill = {
56
- name: 'wiki-sync',
56
+ name: 'collect-then-ingest',
57
57
  params: ['source'],
58
58
  body: 'Export the requested source.\n\nThen ingest what was exported.',
59
59
  };
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
8
  import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
9
- import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
9
+ import { buildInheritedWikircPatch } from './workspaceInherit.js';
10
10
  import { resolveAgentsComposeContext } from './agentsCompose.js';
11
11
  import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
12
12
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
@@ -245,9 +245,11 @@ export async function createNewWorkspace(name, targetPath, options = {}) {
245
245
  }
246
246
 
247
247
  /**
248
- * @param options.inheritFrom name of the workspace to copy LLM config and CME
249
- * credentials from — normally the session's current workspace. Omitted, or
250
- * unknown, means "scaffold defaults only", the previous behaviour.
248
+ * @param options.inheritFrom name of the workspace to copy LLM config from —
249
+ * normally the session's current workspace. Omitted, or unknown, means
250
+ * "scaffold defaults only", the previous behaviour. Confluence credentials
251
+ * are NOT inherited: agent-cme stores them agent-wide (shared across all
252
+ * workspaces), so a new workspace already sees them.
251
253
  */
252
254
  export async function finalizeCreatedWorkspace(name, options = {}) {
253
255
  const workspace = findWorkspace(name);
@@ -282,13 +284,6 @@ export async function inheritWorkspaceSetup(workspace, sourceName) {
282
284
  // Unreadable or absent profile on either side — nothing to inherit.
283
285
  }
284
286
 
285
- try {
286
- const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
287
- if (copied) inherited.push('cme.app_data.json');
288
- } catch {
289
- // Credentials are a convenience, not a prerequisite.
290
- }
291
-
292
287
  return inherited;
293
288
  }
294
289
 
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, 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';
@@ -300,6 +300,9 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
300
300
  '',
301
301
  ].join('\n'));
302
302
 
303
+ // Confluence credentials are agent-wide now — a stale per-workspace
304
+ // app_data.json from a legacy layout must NOT be copied into the new
305
+ // workspace, and must not be reported as inherited.
303
306
  mkdirSync(join(agentsData, 'cme', 'acme', 'cme'), { recursive: true });
304
307
  writeFileSync(join(agentsData, 'cme', 'acme', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
308
 
@@ -318,11 +321,8 @@ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async
318
321
  // The workspace keeps ITS own MCP credential, written just before.
319
322
  assert.equal(parsed.mcp.accessKey, token);
320
323
  assert.ok(inherited.includes('llm.baseUrl'));
321
- assert.ok(inherited.includes('cme.app_data.json'));
322
- assert.equal(
323
- readFileSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json'), 'utf8'),
324
- '{"pat":"x"}',
325
- );
324
+ assert.ok(!inherited.includes('cme.app_data.json'));
325
+ assert.equal(existsSync(join(agentsData, 'cme', 'fresh', 'cme', 'app_data.json')), false);
326
326
  } finally {
327
327
  if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
328
328
  else process.env.WIKI_WORKSPACES_DIR = previousDir;
@@ -14,13 +14,11 @@ import { isTerminal } from '../orchestrator/taskStatuses.js';
14
14
 
15
15
  const RUNNING_STATUSES = new Set(['running', 'starting', 'queued', 'waiting', 'pending_approval']);
16
16
 
17
- // Canonical workflow projection for 0.9.6.
17
+ // Canonical workflow projection.
18
18
  //
19
- // Decision: projectWorkflow consumes the existing event-sourced agentProjection
20
- // instead of replacing it in this release. agentProjection remains the
21
- // compatibility reducer/hydration format; workflow is the canonical read model
22
- // for Serve and ShellTUI. Future releases can move the reducer internals behind
23
- // this module without changing UI contracts.
19
+ // projectWorkflow consumes the existing event-sourced agentProjection instead
20
+ // of replacing it: agentProjection remains the compatibility reducer/hydration
21
+ // format; workflow is the canonical read model for Serve and ShellTUI.
24
22
  export function projectWorkflow(state = {}, events = []) {
25
23
  const run = currentRun(state, events);
26
24
  const plan = Array.isArray(state.plan) ? state.plan : [];
@@ -1,5 +1,3 @@
1
- import { copyFile, mkdir } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
1
  import { join } from 'node:path';
4
2
 
5
3
  /**
@@ -121,29 +119,3 @@ export function buildInheritedWikircPatch(sourceConfig, targetConfig) {
121
119
  if (Object.keys(vector).length > 0) patch.retrieval = { vector };
122
120
  return { patch, inherited };
123
121
  }
124
-
125
- export function cmeCredentialsPath(agentsDataDir, workspaceName) {
126
- return join(agentsDataDir, 'cme', workspaceName, 'cme', 'app_data.json');
127
- }
128
-
129
- /**
130
- * Carry the Confluence credentials of an existing workspace over to a new one.
131
- *
132
- * `app_data.json` only — NOT `sources-manifest.yaml`. The credentials are a
133
- * property of the operator's Confluence account and are identical everywhere;
134
- * which spaces and pages a workspace exports is precisely what makes it a
135
- * different workspace, and copying that would silently re-export someone
136
- * else's scope on the first run.
137
- */
138
- export async function copyCmeCredentials(agentsDataDir, sourceWorkspace, targetWorkspace) {
139
- if (!agentsDataDir || !sourceWorkspace || !targetWorkspace) return null;
140
- if (sourceWorkspace === targetWorkspace) return null;
141
- const from = cmeCredentialsPath(agentsDataDir, sourceWorkspace);
142
- const to = cmeCredentialsPath(agentsDataDir, targetWorkspace);
143
- if (!existsSync(from)) return null;
144
- // An existing target file is a real configuration; never clobber it.
145
- if (existsSync(to)) return null;
146
- await mkdir(join(agentsDataDir, 'cme', targetWorkspace, 'cme'), { recursive: true });
147
- await copyFile(from, to);
148
- return to;
149
- }
@@ -1,14 +1,10 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
6
4
  import { fileURLToPath } from 'node:url';
7
5
  import {
8
6
  PLACEHOLDER_VALUE_RE,
9
7
  buildInheritedWikircPatch,
10
- cmeCredentialsPath,
11
- copyCmeCredentials,
12
8
  isRealValue,
13
9
  } from './workspaceInherit.js';
14
10
 
@@ -144,38 +140,14 @@ test('nothing to inherit yields an empty patch', () => {
144
140
  assert.deepEqual(inherited, []);
145
141
  });
146
142
 
147
- test('CME credentials are copied, and the source manifest is not', async () => {
148
- const root = mkdtempSync(join(tmpdir(), 'cme-inherit-'));
149
- const sourceDir = join(root, 'cme', 'acme', 'cme');
150
- mkdirSync(sourceDir, { recursive: true });
151
- writeFileSync(join(sourceDir, 'app_data.json'), '{"auth":{"pat":"secret"}}', 'utf8');
152
- // Export scope is what makes a workspace different — it must NOT travel.
153
- writeFileSync(join(root, 'cme', 'acme', 'sources-manifest.yaml'), 'sources: []\n', 'utf8');
154
-
155
- const copied = await copyCmeCredentials(root, 'acme', 'fresh');
156
-
157
- assert.equal(copied, cmeCredentialsPath(root, 'fresh'));
158
- assert.equal(readFileSync(copied, 'utf8'), '{"auth":{"pat":"secret"}}');
159
- assert.equal(existsSync(join(root, 'cme', 'fresh', 'sources-manifest.yaml')), false);
160
- });
161
-
162
- test('existing CME credentials on the target are never clobbered', async () => {
163
- const root = mkdtempSync(join(tmpdir(), 'cme-inherit-keep-'));
164
- mkdirSync(join(root, 'cme', 'acme', 'cme'), { recursive: true });
165
- mkdirSync(join(root, 'cme', 'fresh', 'cme'), { recursive: true });
166
- writeFileSync(join(root, 'cme', 'acme', 'cme', 'app_data.json'), '{"from":"source"}', 'utf8');
167
- writeFileSync(join(root, 'cme', 'fresh', 'cme', 'app_data.json'), '{"from":"target"}', 'utf8');
168
-
169
- assert.equal(await copyCmeCredentials(root, 'acme', 'fresh'), null);
170
- assert.equal(
171
- readFileSync(cmeCredentialsPath(root, 'fresh'), 'utf8'),
172
- '{"from":"target"}',
143
+ test('Confluence credentials are not part of workspace inheritance', () => {
144
+ // agent-cme stores credentials agent-wide since the shared-config change:
145
+ // a new workspace sees them without any copy. Inheritance must not smuggle
146
+ // an app_data.json (or a sources manifest) into per-workspace state.
147
+ const { patch, inherited } = buildInheritedWikircPatch(
148
+ { llm: WORKING_LLM },
149
+ { llm: SCAFFOLD_LLM },
173
150
  );
174
- });
175
-
176
- test('copying is a no-op without a source, a target, or a source file', async () => {
177
- const root = mkdtempSync(join(tmpdir(), 'cme-inherit-noop-'));
178
- assert.equal(await copyCmeCredentials(root, 'absent', 'fresh'), null);
179
- assert.equal(await copyCmeCredentials(root, null, 'fresh'), null);
180
- assert.equal(await copyCmeCredentials(root, 'acme', 'acme'), null);
151
+ assert.equal(patch['cme.app_data.json'], undefined);
152
+ assert.ok(!inherited.some((key) => key.toLowerCase().includes('cme')));
181
153
  });
@@ -72,8 +72,19 @@ export function createAgentRegistry({
72
72
  const discovered = [];
73
73
  const endpoints = Object.entries(session?.mcp ?? {});
74
74
  const activeServers = new Set(endpoints.map(([serverName]) => serverName));
75
- for (const [serverName, endpoint] of endpoints) {
76
- const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
75
+ // Each probe is an independent network call (agent_describe against one
76
+ // server); none reads another server's result. Probing them concurrently
77
+ // turns the wall-clock cost from the SUM of every server's probe latency
78
+ // into the latency of the SLOWEST one — the sequential version made every
79
+ // delegation get linearly slower as more MCP servers were connected.
80
+ // registerAgent still runs afterward in the original endpoint order, one
81
+ // at a time, so event ordering and the shared-map mutations it performs
82
+ // are unchanged.
83
+ const probed = await Promise.all(
84
+ endpoints.map(([serverName, endpoint]) =>
85
+ discoverServerAgent(session, serverName, endpoint, { callTool, signal, now })),
86
+ );
87
+ for (const agent of probed) {
77
88
  discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }));
78
89
  }
79
90
  for (const [serverName, instanceId] of instanceByServer) {
@@ -92,7 +92,7 @@ test('resolveObjective disambiguates "export" of a Confluence source via alias,
92
92
  assert.equal(result.provider.agentInstanceId, 'cme-1');
93
93
  });
94
94
 
95
- test('resolveObjective resolves the ingest step of wiki-sync deterministically despite notification and guardrails', async () => {
95
+ test('resolveObjective resolves an ingest objective deterministically despite notification and guardrails', async () => {
96
96
  const session = sessionWith([
97
97
  provider('production-1', knowledge),
98
98
  provider('production-2', publish),
@@ -22,35 +22,37 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
22
22
  const providers = Array.isArray(runtimeProviders)
23
23
  ? runtimeProviders
24
24
  : (runtimeProviders?.list?.() ?? []);
25
- const agents = [];
26
- const unavailable = [];
27
- // Configured-but-not-served capabilities, per runtime (deepagents providers
28
- // fill `lastDiscovery`). A drift is not an outage: the runtime stays
29
- // available with the capabilities it really serves, and the difference is
30
- // reported so an operator can see WHY agent.research is not routable.
31
- const drift = [];
32
25
 
33
- for (const entry of providers) {
26
+ // One provider's describe()/discoverCapabilities() never reads another
27
+ // provider's result, so probe them concurrently (same reasoning as
28
+ // agentRegistry.discover()) instead of paying the sum of every runtime's
29
+ // latency in sequence. Each entry still produces its own ordered
30
+ // {agents, unavailable, drift} slice, flattened below in the original
31
+ // `providers` order so output ordering is unchanged.
32
+ const results = await Promise.all(providers.map(async (entry) => {
34
33
  const provider = entry?.provider ?? entry;
35
34
  const runtimeId = String(entry?.id ?? provider?.runtime ?? 'external-runtime');
35
+ const agents = [];
36
+ const unavailable = [];
37
+ const drift = [];
36
38
  let description;
37
39
  try {
38
40
  assertRuntimeProvider(provider);
39
41
  description = await provider.describe();
40
42
  } catch (error) {
41
43
  unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
42
- continue;
44
+ return { agents, unavailable, drift };
43
45
  }
44
46
  if (description?.health === 'unavailable') {
45
47
  unavailable.push({ runtimeId, error: description?.error ?? 'runtime reports unavailable' });
46
- continue;
48
+ return { agents, unavailable, drift };
47
49
  }
48
50
  let capabilities;
49
51
  try {
50
52
  capabilities = await provider.discoverCapabilities();
51
53
  } catch (error) {
52
54
  unavailable.push({ runtimeId, error: error instanceof Error ? error.message : String(error) });
53
- continue;
55
+ return { agents, unavailable, drift };
54
56
  }
55
57
  const health = ['available', 'degraded'].includes(description?.health)
56
58
  ? description.health
@@ -62,7 +64,16 @@ export async function discoverRuntimeProviderAgents(runtimeProviders) {
62
64
  if (discovery && Array.isArray(discovery.missing) && discovery.missing.length > 0) {
63
65
  drift.push({ runtimeId, missing: [...discovery.missing], served: [...(discovery.served ?? [])] });
64
66
  }
65
- }
67
+ return { agents, unavailable, drift };
68
+ }));
69
+
70
+ // Configured-but-not-served capabilities, per runtime (deepagents providers
71
+ // fill `lastDiscovery`). A drift is not an outage: the runtime stays
72
+ // available with the capabilities it really serves, and the difference is
73
+ // reported so an operator can see WHY agent.research is not routable.
74
+ const agents = results.flatMap((r) => r.agents);
75
+ const unavailable = results.flatMap((r) => r.unavailable);
76
+ const drift = results.flatMap((r) => r.drift);
66
77
 
67
78
  return { agents, unavailable, drift };
68
79
  }
@@ -1422,9 +1422,8 @@ function rejectPlanPatch(context, store, patchId, reason) {
1422
1422
  };
1423
1423
  }
1424
1424
 
1425
- // Classifier for control §4.2 of the plan directeur. The plan expects an
1426
- // LLM-backed classification — "the classification LLM se trompera" — and this
1427
- // is that, now: the only deterministic matches left are the runtime's own
1425
+ // Classifier for the control lane's free-text messages. The classification is
1426
+ // LLM-backed: the only deterministic matches left are the runtime's own
1428
1427
  // control verbs (cancel, an explicit "later/queue", status and plan-change
1429
1428
  // wording). Deciding "is this a NEW task to queue vs plain conversation" is a
1430
1429
  // semantic judgement about the workspace's domain, so it is never a keyword
@@ -1606,7 +1606,7 @@ test('runtime server handle drains a pre-existing hydrated control request', asy
1606
1606
  test('POST /run compiles a workspace skill into a sequential runtime chain', async (t) => {
1607
1607
  const root = mkdtempSync(join(tmpdir(), 'runtime-skill-'));
1608
1608
  mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
1609
- writeFileSync(join(root, '.wiki', 'skills', 'wiki-sync.md'), '---\nname: wiki-sync\nparams:\n - source\n---\nExport the source.\n\nThen ingest the files.');
1609
+ writeFileSync(join(root, '.wiki', 'skills', 'sync-ingest.md'), '---\nname: sync-ingest\nparams:\n - source\n---\nExport the source.\n\nThen ingest the files.');
1610
1610
  const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
1611
1611
  const context = { workspace: 'acme', session, running: false, currentAbortController: null };
1612
1612
  let startedBody = null;
@@ -1623,7 +1623,7 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
1623
1623
  throw err;
1624
1624
  }
1625
1625
  try {
1626
- const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/wiki-sync docs' }) });
1626
+ const response = await fetch(`http://127.0.0.1:${handle.port}/run?workspace=acme`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: '/sync-ingest docs' }) });
1627
1627
  const body = await response.json();
1628
1628
  assert.equal(response.status, 202);
1629
1629
  assert.equal(body.kind, 'skill_chain');
@@ -1633,15 +1633,15 @@ test('POST /run compiles a workspace skill into a sequential runtime chain', asy
1633
1633
  assert.equal(session.controlQueue[1].status, 'queued');
1634
1634
  assert.equal(session.controlQueue[0].chainId, session.controlQueue[1].chainId);
1635
1635
  assert.equal('capabilityPlan' in session.controlQueue[0], false);
1636
- assert.equal(session.controlQueue[0].input, '/wiki-sync source="docs"');
1636
+ assert.equal(session.controlQueue[0].input, '/sync-ingest source="docs"');
1637
1637
  assert.equal(session.controlQueue[0].skillExecution, 'orchestrated');
1638
1638
  assert.match(startedBody.input, /Export the source/);
1639
- assert.equal(startedBody.publicInput, '/wiki-sync source="docs"');
1639
+ assert.equal(startedBody.publicInput, '/sync-ingest source="docs"');
1640
1640
  assert.equal(startedBody.skillChain.execution, 'orchestrated');
1641
1641
  assert.equal(startedBody.requireApproval, true);
1642
1642
  assert.notEqual(startedBody.autoApprove, true);
1643
1643
  assert.deepEqual(session.agentProjection.conversation, [
1644
- { role: 'user', content: '/wiki-sync docs' },
1644
+ { role: 'user', content: '/sync-ingest docs' },
1645
1645
  ]);
1646
1646
  assert.doesNotMatch(JSON.stringify(session.agentEvents), /Export the source|Then ingest the files/);
1647
1647
  } finally {
@@ -132,26 +132,31 @@ test('E2E-001 pipeline: one objective, one run, internal plan left untouched', a
132
132
  assert.equal(env.chain().length, 1);
133
133
  });
134
134
 
135
- test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (t) => {
136
- const env = await harness(t, { skills: { 'wiki-sync': null } });
135
+ test('E2E-002 multi-step skill: two objectives, two ordered runs, one chainId', async (t) => {
136
+ // No shipped skill splits any more (each is one intention); a user-authored
137
+ // body with a strong connector still must.
138
+ const env = await harness(t, {
139
+ skills: {
140
+ 'sync-ingest': '---\nname: sync-ingest\nparams: []\n---\nExport every configured Confluence source.\n\nThen ingest the exported Markdown into the wiki.',
141
+ },
142
+ });
137
143
  if (!env) return;
138
144
 
139
- const { body } = await env.post('/run?workspace=acme', { input: '/wiki-sync' });
145
+ const { body } = await env.post('/run?workspace=acme', { input: '/sync-ingest' });
140
146
  await env.settle();
141
147
 
142
148
  assert.equal(body.objectives, 2);
143
149
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
150
  assert.match(env.runs[0].input, /^Export every configured Confluence source/);
145
- assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
146
- // CME first, Production second — the skill carries no source parameter, so
147
- // no run may receive a source selector (the export step must stay "all").
151
+ assert.match(env.runs[1].input, /^Ingest the exported Markdown/);
152
+ // The skill carries no parameter, so no run may receive a selector.
148
153
  for (const run of env.runs) assert.doesNotMatch(run.input, /User parameters:/);
149
154
  const items = env.chain();
150
155
  assert.equal(items.length, 2);
151
156
  assert.equal(items[0].chainId, items[1].chainId);
152
157
  assert.equal(items[0].chainId, body.chainId);
153
158
  assert.deepEqual(items.map((item) => item.status), ['done', 'done']);
154
- assert.deepEqual(items.map((item) => item.skillName), ['wiki-sync', 'wiki-sync']);
159
+ assert.deepEqual(items.map((item) => item.skillName), ['sync-ingest', 'sync-ingest']);
155
160
  });
156
161
 
157
162
  test('E2E-002b parameters reach every objective of a multi-step skill', async (t) => {
@@ -220,13 +225,13 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
220
225
  // that silently fragments would show up as extra runs, not as extra objectives.
221
226
  const PERFORMANCE_TABLE = {
222
227
  pipeline: 1,
228
+ 'wiki-sync': 1,
223
229
  'wiki-ingest': 1,
224
230
  'wiki-build': 1,
225
231
  deliver: 1,
226
232
  diagnose: 1,
227
233
  status: 1,
228
234
  'new-template': 1,
229
- 'wiki-sync': 2,
230
235
  };
231
236
 
232
237
  for (const [name, expected] of Object.entries(PERFORMANCE_TABLE)) {
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { createMemo, createSignal, Index, Show } from 'solid-js';
3
- import { compactRuntimeLogForDisplay, filterRuntimeLogs, isDispatchPlumbingLine } from '../core/runtimeLog.js';
3
+ import { compactRuntimeLogForDisplay, filterRuntimeLogs, isAgentTraceLine, isDispatchPlumbingLine } from '../core/runtimeLog.js';
4
4
  import { fit } from './textFit';
5
5
 
6
6
  type PlanStep = { step: number; description: string; status: string };
@@ -409,13 +409,14 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
409
409
  const [activeLogTab, setActiveLogTab] = createSignal<'flow' | 'agent-status'>('flow');
410
410
  const lineWidth = () => Math.max(8, props.width - 2);
411
411
  // "Agent status" collects the dispatch plumbing — capability resolution,
412
- // agent selection, agent_execute/agent_status polling, job acceptance — so
413
- // the "Runtime" tab is left with the readable business flow (plan + the
414
- // ▸/✓/✗/↻ task lines). isDispatchPlumbingLine (shared with agentEvents'
415
- // dedup) recognises the formatRuntimeLogPayload shape structurally; the old
416
- // token enumeration only ever matched agent_status/agent_execute and missed
417
- // every dotted event ('job.accepted' → 'ACCEPTED', …).
418
- const isAgentStatus = (line: string) => isDispatchPlumbingLine(line);
412
+ // agent selection, agent_execute/agent_status polling, job acceptance — and
413
+ // the agent's own reasoning traces ("Agent: …"), so the "Runtime" tab is
414
+ // left with the readable business flow (plan + the ▸/✓/✗/↻ task lines).
415
+ // isDispatchPlumbingLine (shared with agentEvents' dedup) recognises the
416
+ // formatRuntimeLogPayload shape structurally; the old token enumeration
417
+ // only ever matched agent_status/agent_execute and missed every dotted
418
+ // event ('job.accepted' → 'ACCEPTED', …).
419
+ const isAgentStatus = (line: string) => isDispatchPlumbingLine(line) || isAgentTraceLine(line);
419
420
  // Filtering preserves the runtime history order. logRenderLines performs
420
421
  // the single block-level reversal shared by both tabs.
421
422
  const filteredLogs = () => filterRuntimeLogs(props.logs, props.filter ?? '')
@@ -101,6 +101,13 @@ test('ShellUI renders newest-first order in both Runtime and Agent status', asyn
101
101
  assert.match(filteredLogs, /activeLogTab\(\) === 'agent-status'/);
102
102
  assert.match(filteredLogs, /isAgentStatus\(line\)/);
103
103
  assert.doesNotMatch(filteredLogs, /\.reverse\(\)/);
104
+ const logPanelBody = source.slice(
105
+ source.indexOf('export function LogPanel'),
106
+ source.indexOf('const filteredLogs ='),
107
+ );
108
+ // Agent reasoning traces route to the Agent status tab with the dispatch
109
+ // plumbing — the Runtime tab keeps the business flow.
110
+ assert.match(logPanelBody, /isDispatchPlumbingLine\(line\) \|\| isAgentTraceLine\(line\)/);
104
111
  const renderedLogs = source.slice(
105
112
  source.indexOf('function logRenderLines'),
106
113
  source.indexOf('function logEntryLines'),