@dotdrelle/wiki-manager 0.11.10 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +32 -7
  2. package/agents.docker-compose.yml +3 -0
  3. package/docker-compose.yml +1 -0
  4. package/package.json +3 -2
  5. package/src/activity/activityAggregator.js +109 -0
  6. package/src/activity/activityAggregator.test.js +60 -0
  7. package/src/activity/activityDeduplicator.js +50 -0
  8. package/src/activity/progressCalculator.js +61 -0
  9. package/src/activity/runSynthesis.js +15 -0
  10. package/src/agent/graph.js +101 -38
  11. package/src/agent/graph.test.js +75 -5
  12. package/src/cli/wiki-manager.js +22 -0
  13. package/src/contracts/schemas.js +249 -13
  14. package/src/contracts/schemas.test.js +147 -2
  15. package/src/core/activity.js +8 -5
  16. package/src/core/agentEvents.js +260 -23
  17. package/src/core/agentEvents.test.js +53 -0
  18. package/src/core/compose.js +6 -1
  19. package/src/core/dockerCompose.test.js +12 -0
  20. package/src/core/documentIntake.js +33 -38
  21. package/src/core/documentIntake.test.js +25 -0
  22. package/src/core/env.js +11 -1
  23. package/src/core/jobQueue.js +26 -2
  24. package/src/core/mcp.js +30 -3
  25. package/src/core/mcp.test.js +69 -1
  26. package/src/core/plan.js +46 -4
  27. package/src/core/plan.test.js +16 -1
  28. package/src/core/planPatch.js +64 -3
  29. package/src/core/planPatch.test.js +110 -1
  30. package/src/core/queueStore.test.js +21 -0
  31. package/src/core/runtimeLog.js +119 -0
  32. package/src/core/runtimeLog.test.js +84 -0
  33. package/src/core/wikirc.js +22 -0
  34. package/src/core/wikirc.test.js +49 -1
  35. package/src/core/workflow.js +14 -3
  36. package/src/graph/graphAggregator.js +5 -0
  37. package/src/graph/graphPatch.js +8 -0
  38. package/src/graph/graphSnapshot.js +14 -0
  39. package/src/graph/graphVisibilityPolicy.js +40 -0
  40. package/src/graph/runGraphProjector.js +87 -0
  41. package/src/graph/runGraphProjector.test.js +111 -0
  42. package/src/orchestrator/agentRegistry.js +172 -0
  43. package/src/orchestrator/agentRegistry.test.js +114 -0
  44. package/src/orchestrator/approvalPolicy.js +126 -0
  45. package/src/orchestrator/approvalPolicy.test.js +78 -0
  46. package/src/orchestrator/assignmentManager.js +80 -0
  47. package/src/orchestrator/attemptManager.js +168 -0
  48. package/src/orchestrator/attemptManager.test.js +160 -0
  49. package/src/orchestrator/budgetManager.js +127 -0
  50. package/src/orchestrator/capabilityRegistry.js +64 -0
  51. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  52. package/src/orchestrator/capabilityResolver.js +151 -0
  53. package/src/orchestrator/capabilityResolver.test.js +127 -0
  54. package/src/orchestrator/dependencyResolver.js +112 -0
  55. package/src/orchestrator/dispatcher.js +302 -0
  56. package/src/orchestrator/lockManager.js +51 -0
  57. package/src/orchestrator/planIntegrator.js +268 -0
  58. package/src/orchestrator/planIntegrator.test.js +213 -0
  59. package/src/orchestrator/planValidator.js +534 -0
  60. package/src/orchestrator/planValidator.test.js +262 -0
  61. package/src/orchestrator/resultAggregator.js +248 -0
  62. package/src/orchestrator/resultAggregator.test.js +211 -0
  63. package/src/orchestrator/scheduler.js +103 -0
  64. package/src/orchestrator/scheduler.test.js +138 -0
  65. package/src/runtime/approvals.js +130 -1
  66. package/src/runtime/controlMessages.js +43 -0
  67. package/src/runtime/controlMessages.test.js +21 -0
  68. package/src/runtime/donna-contract.test.js +296 -33
  69. package/src/runtime/recoveryManager.js +176 -0
  70. package/src/runtime/recoveryManager.test.js +162 -0
  71. package/src/runtime/runner.e2e.test.js +98 -12
  72. package/src/runtime/runner.js +261 -227
  73. package/src/runtime/runner.test.js +249 -452
  74. package/src/runtime/server.js +142 -24
  75. package/src/runtime/server.test.js +192 -62
  76. package/src/runtime/store.js +837 -2
  77. package/src/runtime/store.test.js +269 -28
  78. package/src/runtime/supervisor.js +33 -1
  79. package/src/runtime/supervisor.test.js +47 -1
  80. package/src/shell/RightPane.tsx +8 -5
  81. package/src/shell/repl.js +7 -11
  82. package/src/shell/repl.test.js +26 -5
  83. package/src/shell/tui.tsx +1 -0
  84. package/src/shell/useAgent.ts +5 -1
  85. package/src/shell/useSession.ts +75 -19
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { basename, extname, isAbsolute, join, resolve } from 'node:path';
4
+ import { basename, extname, join, resolve } from 'node:path';
5
5
  import { callMcpTool, formatMcpToolResult } from './mcp.js';
6
6
  import { parseJsonText } from './activity.js';
7
- import { userManagerDir } from './env.js';
7
+ import { resolveAgentsDataDir } from './env.js';
8
8
 
9
9
  const SUPPORTED_EXTENSIONS = new Set([
10
10
  '.txt', '.md', '.markdown', '.csv', '.json', '.xml', '.yaml', '.yml', '.html', '.htm', '.rtf',
@@ -13,17 +13,12 @@ const SUPPORTED_EXTENSIONS = new Set([
13
13
  '.pdf',
14
14
  ]);
15
15
 
16
- function agentsDataDir() {
17
- const configured = process.env.AGENTS_DATA_DIR || '.agents-data';
18
- return isAbsolute(configured) ? configured : resolve(userManagerDir(), configured);
16
+ function documentInputRoot(session = null) {
17
+ return resolve(resolveAgentsDataDir(session), 'documents', 'input');
19
18
  }
20
19
 
21
- function documentInputRoot() {
22
- return resolve(agentsDataDir(), 'documents', 'input');
23
- }
24
-
25
- function uploadsRoot() {
26
- return resolve(agentsDataDir(), 'documents', 'uploads');
20
+ function uploadsRoot(session = null) {
21
+ return resolve(resolveAgentsDataDir(session), 'documents', 'uploads');
27
22
  }
28
23
 
29
24
  function requireWorkspace(session) {
@@ -70,12 +65,12 @@ function assertSupportedFile(filename, size) {
70
65
  }
71
66
  }
72
67
 
73
- function manifestPath(workspace) {
74
- return join(uploadsRoot(), `${workspace}.jsonl`);
68
+ function manifestPath(workspace, session = null) {
69
+ return join(uploadsRoot(session), `${workspace}.jsonl`);
75
70
  }
76
71
 
77
- async function readManifest(workspace) {
78
- const file = manifestPath(workspace);
72
+ async function readManifest(workspace, session = null) {
73
+ const file = manifestPath(workspace, session);
79
74
  if (!existsSync(file)) return [];
80
75
  const raw = await readFile(file, 'utf8');
81
76
  return raw
@@ -90,26 +85,26 @@ async function readManifest(workspace) {
90
85
  });
91
86
  }
92
87
 
93
- async function writeManifest(workspace, records) {
94
- const file = manifestPath(workspace);
95
- await mkdir(uploadsRoot(), { recursive: true });
88
+ async function writeManifest(workspace, records, session = null) {
89
+ const file = manifestPath(workspace, session);
90
+ await mkdir(uploadsRoot(session), { recursive: true });
96
91
  const body = records.map((record) => JSON.stringify(record)).join('\n');
97
92
  const tmp = `${file}.tmp.${process.pid}`;
98
93
  await writeFile(tmp, body ? `${body}\n` : '', 'utf8');
99
94
  await rename(tmp, file);
100
95
  }
101
96
 
102
- async function upsertUpload(record) {
103
- const records = await readManifest(record.workspace);
97
+ async function upsertUpload(record, session = null) {
98
+ const records = await readManifest(record.workspace, session);
104
99
  const index = records.findIndex((item) => item.id === record.id);
105
100
  if (index === -1) records.unshift(record);
106
101
  else records[index] = { ...records[index], ...record };
107
- await writeManifest(record.workspace, records);
102
+ await writeManifest(record.workspace, records, session);
108
103
  return record;
109
104
  }
110
105
 
111
- async function removeExistingUploadsForFilename(workspace, filename) {
112
- const records = await readManifest(workspace);
106
+ async function removeExistingUploadsForFilename(workspace, filename, session = null) {
107
+ const records = await readManifest(workspace, session);
113
108
  const removed = records.filter((item) => item.filename === filename);
114
109
  if (removed.length === 0) return [];
115
110
 
@@ -119,7 +114,7 @@ async function removeExistingUploadsForFilename(workspace, filename) {
119
114
  if (file) await rm(file, { force: true }).catch(() => {});
120
115
  }
121
116
  }
122
- await writeManifest(workspace, keep);
117
+ await writeManifest(workspace, keep, session);
123
118
  return removed;
124
119
  }
125
120
 
@@ -159,11 +154,11 @@ export async function storeDocumentUpload(session, sourcePath) {
159
154
  const filename = sanitizeFilename(absolutePath);
160
155
  assertSupportedFile(filename, info.size);
161
156
 
162
- await removeExistingUploadsForFilename(workspace, filename);
157
+ await removeExistingUploadsForFilename(workspace, filename, session);
163
158
 
164
159
  const id = randomUUID().slice(0, 8);
165
160
  const storedFilename = `${id}-${filename}`;
166
- const workspaceInput = join(documentInputRoot(), workspace);
161
+ const workspaceInput = join(documentInputRoot(session), workspace);
167
162
  await mkdir(workspaceInput, { recursive: true });
168
163
  const storedPath = join(workspaceInput, storedFilename);
169
164
  await copyFile(absolutePath, storedPath);
@@ -184,13 +179,13 @@ export async function storeDocumentUpload(session, sourcePath) {
184
179
  createdAt: now,
185
180
  updatedAt: now,
186
181
  };
187
- await upsertUpload(record);
182
+ await upsertUpload(record, session);
188
183
  return record;
189
184
  }
190
185
 
191
186
  export async function convertStoredDocument(session, id, options = {}) {
192
187
  const workspace = requireWorkspace(session);
193
- const records = await readManifest(workspace);
188
+ const records = await readManifest(workspace, session);
194
189
  const record = records.find((item) => item.id === id);
195
190
  if (!record) throw new Error(`Unknown upload id: ${id}`);
196
191
  if (!existsSync(record.storedPath)) throw new Error(`Stored file is missing: ${record.storedPath}`);
@@ -199,7 +194,7 @@ export async function convertStoredDocument(session, id, options = {}) {
199
194
  record.provider = null;
200
195
  record.error = 'documents MCP is not connected';
201
196
  record.updatedAt = new Date().toISOString();
202
- await upsertUpload(record);
197
+ await upsertUpload(record, session);
203
198
  return { record, converted: false };
204
199
  }
205
200
 
@@ -207,7 +202,7 @@ export async function convertStoredDocument(session, id, options = {}) {
207
202
  record.provider = 'documents';
208
203
  record.error = null;
209
204
  record.updatedAt = new Date().toISOString();
210
- await upsertUpload(record);
205
+ await upsertUpload(record, session);
211
206
 
212
207
  try {
213
208
  const toolArgs = {
@@ -223,7 +218,7 @@ export async function convertStoredDocument(session, id, options = {}) {
223
218
  if (activity && !activity.terminal) {
224
219
  record.jobId = payload.jobId ?? null;
225
220
  record.updatedAt = new Date().toISOString();
226
- await upsertUpload(record);
221
+ await upsertUpload(record, session);
227
222
  return { record, converted: false, activity };
228
223
  }
229
224
 
@@ -232,13 +227,13 @@ export async function convertStoredDocument(session, id, options = {}) {
232
227
  record.method = payload.method ?? null;
233
228
  record.error = null;
234
229
  record.updatedAt = new Date().toISOString();
235
- await upsertUpload(record);
230
+ await upsertUpload(record, session);
236
231
  return { record, converted: true, payload, activity };
237
232
  } catch (err) {
238
233
  record.status = 'failed';
239
234
  record.error = err instanceof Error ? err.message : String(err);
240
235
  record.updatedAt = new Date().toISOString();
241
- await upsertUpload(record);
236
+ await upsertUpload(record, session);
242
237
  return { record, converted: false };
243
238
  }
244
239
  }
@@ -247,7 +242,7 @@ export async function storeAndMaybeConvertDocument(session, sourcePath, options
247
242
  const record = await storeDocumentUpload(session, sourcePath);
248
243
  if (!documentsConverter(session)) {
249
244
  record.error = 'documents MCP is not connected';
250
- await upsertUpload(record);
245
+ await upsertUpload(record, session);
251
246
  return { record, converted: false };
252
247
  }
253
248
  return convertStoredDocument(session, record.id, options);
@@ -255,12 +250,12 @@ export async function storeAndMaybeConvertDocument(session, sourcePath, options
255
250
 
256
251
  export async function listDocumentUploads(session) {
257
252
  const workspace = requireWorkspace(session);
258
- return (await readManifest(workspace)).map(publicRecord);
253
+ return (await readManifest(workspace, session)).map(publicRecord);
259
254
  }
260
255
 
261
256
  export async function convertPendingDocumentUploads(session, options = {}) {
262
257
  const workspace = requireWorkspace(session);
263
- const records = await readManifest(workspace);
258
+ const records = await readManifest(workspace, session);
264
259
  const pending = records.filter((record) => ['stored', 'failed'].includes(record.status));
265
260
  const results = [];
266
261
  for (const record of pending) {
@@ -281,7 +276,7 @@ function parseAgeMs(value = '30d') {
281
276
  export async function cleanDocumentUploads(session, olderThan = '30d') {
282
277
  const workspace = requireWorkspace(session);
283
278
  const cutoff = Date.now() - parseAgeMs(olderThan);
284
- const records = await readManifest(workspace);
279
+ const records = await readManifest(workspace, session);
285
280
  const keep = [];
286
281
  const removed = [];
287
282
  for (const record of records) {
@@ -295,7 +290,7 @@ export async function cleanDocumentUploads(session, olderThan = '30d') {
295
290
  keep.push(record);
296
291
  }
297
292
  }
298
- if (removed.length > 0) await writeManifest(workspace, keep);
293
+ if (removed.length > 0) await writeManifest(workspace, keep, session);
299
294
  return { removed, kept: keep };
300
295
  }
301
296
 
@@ -38,6 +38,31 @@ test('document intake stores uploads without requiring documents MCP', async ()
38
38
  }
39
39
  });
40
40
 
41
+ test('document intake falls back to workspace root agents data, not cwd', async () => {
42
+ const originalAgentsDataDir = process.env.AGENTS_DATA_DIR;
43
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-doc-intake-root-'));
44
+ const workspacesRoot = path.join(root, 'workspaces');
45
+ const workspacePath = path.join(workspacesRoot, 'my-project');
46
+ await mkdir(workspacePath, { recursive: true });
47
+ const source = path.join(root, 'rapport.pdf');
48
+ await writeFile(source, 'fake pdf content');
49
+ const session = {
50
+ workspace: 'my-project',
51
+ workspacePath,
52
+ mcp: {},
53
+ };
54
+
55
+ try {
56
+ delete process.env.AGENTS_DATA_DIR;
57
+ const { record } = await storeAndMaybeConvertDocument(session, source);
58
+ assert.equal(record.storedPath.startsWith(path.join(workspacesRoot, '.agents-data')), true);
59
+ assert.equal(await readFile(record.storedPath, 'utf8'), 'fake pdf content');
60
+ } finally {
61
+ if (originalAgentsDataDir === undefined) delete process.env.AGENTS_DATA_DIR;
62
+ else process.env.AGENTS_DATA_DIR = originalAgentsDataDir;
63
+ }
64
+ });
65
+
41
66
  test('document intake accepts quoted absolute paths with spaces', async () => {
42
67
  const originalAgentsDataDir = process.env.AGENTS_DATA_DIR;
43
68
  const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-doc-intake-'));
package/src/core/env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
 
4
4
  export function userManagerDir() {
5
5
  return process.cwd();
@@ -31,6 +31,16 @@ export function managerMcpEndpointsFile() {
31
31
  return join(managerStateDir(), 'mcp.endpoints.json');
32
32
  }
33
33
 
34
+ // Single source of truth for where `.agents-data` lives, shared by the
35
+ // manager's own document intake and by the host path it mounts into agent
36
+ // containers — keeping them in sync avoids the two silently drifting apart.
37
+ export function resolveAgentsDataDir(session = null) {
38
+ const configured = process.env.AGENTS_DATA_DIR;
39
+ if (configured) return isAbsolute(configured) ? configured : resolve(defaultRuntimeStateDir(), configured);
40
+ if (session?.workspacePath) return resolve(dirname(session.workspacePath), '.agents-data');
41
+ return resolve(defaultRuntimeStateDir(), 'agents-data');
42
+ }
43
+
34
44
  function parseEnvValue(value) {
35
45
  if (value.startsWith('"') && value.endsWith('"')) {
36
46
  return value
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
2
3
  import { extractActivity, parseJsonText, sessionActivities } from './activity.js';
3
4
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
4
5
  import { callMcpTool, formatMcpToolResult } from './mcp.js';
@@ -34,13 +35,18 @@ export function productionLockBusy(session) {
34
35
 
35
36
  export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
36
37
  const workspace = session.workspace ?? null;
38
+ const workspacePath = session.workspacePath ? resolve(session.workspacePath) : null;
39
+ if (!workspace || !workspacePath) {
40
+ throw new Error('Cannot enqueue production job: no active workspace path. Use /use <workspace> first.');
41
+ }
42
+ const normalizedArgs = normalizeProductionJobArgsForWorkspace(args, workspacePath);
37
43
  const item = {
38
44
  id: shortId(),
39
45
  workspace,
40
46
  server: 'production',
41
47
  tool: 'production_start_job',
42
- args: { ...args },
43
- lockKey: workspace ? `production:${workspace}` : 'production',
48
+ args: normalizedArgs,
49
+ lockKey: `production:${workspace}`,
44
50
  status: 'waiting',
45
51
  reason,
46
52
  createdAt: now(),
@@ -50,6 +56,24 @@ export function enqueueProductionJob(session, args = {}, reason = 'waiting') {
50
56
  return item;
51
57
  }
52
58
 
59
+ function normalizeProductionJobArgsForWorkspace(args, workspacePath) {
60
+ const normalized = { ...args };
61
+ if (!Array.isArray(normalized.inputs)) return normalized;
62
+ normalized.inputs = normalized.inputs.map((input) => normalizeWorkspaceInput(input, workspacePath));
63
+ return normalized;
64
+ }
65
+
66
+ function normalizeWorkspaceInput(input, workspacePath) {
67
+ const raw = String(input ?? '').trim();
68
+ if (!raw) return raw;
69
+ const absolute = isAbsolute(raw) ? resolve(raw) : resolve(workspacePath, raw);
70
+ const rel = relative(workspacePath, absolute);
71
+ if (rel.startsWith('..') || isAbsolute(rel)) {
72
+ throw new Error(`Cannot enqueue production job: input is outside the active workspace: ${raw}`);
73
+ }
74
+ return rel.split(sep).join('/');
75
+ }
76
+
53
77
  export function queueSummary(args = {}) {
54
78
  const parts = [
55
79
  args.type,
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.11.10';
4
+ const WIKI_MANAGER_VERSION = '0.12.1';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -174,7 +174,7 @@ function clarifyToolDescription(serverName, toolName, description) {
174
174
  if (serverName === 'production' && toolName === 'production_start_job') {
175
175
  return compactDescription([
176
176
  base,
177
- 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme_export_run instead.',
177
+ 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme__cme_export_run instead.',
178
178
  ].filter(Boolean).join(' '));
179
179
  }
180
180
  return base;
@@ -469,7 +469,9 @@ export function formatMcpToolsForAgent(mcpStatus) {
469
469
  sections.push(`${name}: connected, tools not discovered yet`);
470
470
  continue;
471
471
  }
472
- sections.push(`${name}: ${tools.map((tool) => tool.name).join(', ')}`);
472
+ // Always advertise the qualified call name (server__tool): showing bare
473
+ // tool names here is what teaches the model to emit unqualified calls.
474
+ sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
473
475
  }
474
476
  return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
475
477
  }
@@ -498,6 +500,31 @@ export function parseToolCallName(name) {
498
500
  return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
499
501
  }
500
502
 
503
+ // Deterministic recovery for unqualified tool-call names emitted by the LLM
504
+ // (e.g. "cme_status" instead of "cme__cme_status"). Exact-name match only:
505
+ // if exactly one connected server (or extra pseudo-server) exposes the bare
506
+ // tool name, route to it and report `normalized: true`; otherwise return
507
+ // `server: null` with the list of candidate servers so the caller can raise
508
+ // an explicit error. This is name normalization, never fuzzy matching — do
509
+ // not extend it to description/similarity-based selection (plan directeur
510
+ // §20 forbids that).
511
+ export function resolveToolCallName(mcpStatus, name, extraServers = {}) {
512
+ const parsed = parseToolCallName(name);
513
+ if (parsed.server) return { ...parsed, normalized: false, candidates: [] };
514
+ const candidates = [];
515
+ for (const [serverName, toolNames] of Object.entries(extraServers)) {
516
+ if (toolNames.includes(parsed.tool)) candidates.push(serverName);
517
+ }
518
+ for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
519
+ if (value.status !== 'connected') continue;
520
+ if ((value.tools ?? []).some((tool) => tool.name === parsed.tool)) candidates.push(serverName);
521
+ }
522
+ if (candidates.length === 1) {
523
+ return { server: candidates[0], tool: parsed.tool, normalized: true, candidates };
524
+ }
525
+ return { server: null, tool: parsed.tool, normalized: false, candidates };
526
+ }
527
+
501
528
  export function mcpStatusMarker(status) {
502
529
  if (status === 'connected') return '●';
503
530
  if (status === 'configured') return '◐';
@@ -3,7 +3,75 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { buildMcpStatus, callMcpTool, discoverMcpTools, resolveRetryPolicy } from './mcp.js';
6
+ import {
7
+ buildMcpStatus,
8
+ callMcpTool,
9
+ discoverMcpTools,
10
+ formatMcpToolsForAgent,
11
+ resolveRetryPolicy,
12
+ resolveToolCallName,
13
+ } from './mcp.js';
14
+
15
+ const resolveFixtureStatus = {
16
+ production: {
17
+ status: 'connected',
18
+ tools: [{ name: 'production_start_job' }, { name: 'agent_status' }],
19
+ },
20
+ cme: {
21
+ status: 'connected',
22
+ tools: [{ name: 'cme_status' }, { name: 'agent_status' }],
23
+ },
24
+ documents: {
25
+ status: 'configured', // not connected: must never be a candidate
26
+ tools: [{ name: 'cme_status' }],
27
+ },
28
+ };
29
+
30
+ test('resolveToolCallName passes qualified names through untouched', () => {
31
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme__cme_status');
32
+ assert.deepEqual(
33
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
34
+ { server: 'cme', tool: 'cme_status', normalized: false },
35
+ );
36
+ });
37
+
38
+ test('resolveToolCallName normalizes a bare name with exactly one connected match', () => {
39
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'cme_status');
40
+ assert.deepEqual(
41
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
42
+ { server: 'cme', tool: 'cme_status', normalized: true },
43
+ );
44
+ });
45
+
46
+ test('resolveToolCallName refuses ambiguous bare names and reports candidates', () => {
47
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'agent_status');
48
+ assert.equal(resolved.server, null);
49
+ assert.equal(resolved.normalized, false);
50
+ assert.deepEqual([...resolved.candidates].sort(), ['cme', 'production']);
51
+ });
52
+
53
+ test('resolveToolCallName returns no server for unknown bare names', () => {
54
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'does_not_exist');
55
+ assert.equal(resolved.server, null);
56
+ assert.deepEqual(resolved.candidates, []);
57
+ });
58
+
59
+ test('resolveToolCallName resolves internal pseudo-server tools via extraServers', () => {
60
+ const resolved = resolveToolCallName(resolveFixtureStatus, 'plan_set', { wiki: ['plan_set', 'plan_done'] });
61
+ assert.deepEqual(
62
+ { server: resolved.server, tool: resolved.tool, normalized: resolved.normalized },
63
+ { server: 'wiki', tool: 'plan_set', normalized: true },
64
+ );
65
+ });
66
+
67
+ test('formatMcpToolsForAgent advertises qualified server__tool names only', () => {
68
+ const listing = formatMcpToolsForAgent(resolveFixtureStatus);
69
+ assert.match(listing, /cme__cme_status/);
70
+ assert.match(listing, /production__production_start_job/);
71
+ // No bare tool name outside a qualified form.
72
+ assert.doesNotMatch(listing, /(?<![\w])cme_status(?![\w])/);
73
+ assert.doesNotMatch(listing, /(?<![\w])production_start_job(?![\w])/);
74
+ });
7
75
 
8
76
  test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
77
  const originalCwd = process.cwd();
package/src/core/plan.js CHANGED
@@ -54,17 +54,24 @@ export function matchCompletedToPlan(plan, completed) {
54
54
 
55
55
  export function syncActivitiesToPlan(plan, activities) {
56
56
  if (!plan) return;
57
+ const contractTaskPlan = isContractTaskPlan(plan);
57
58
  for (const activity of activities ?? []) {
58
59
  const terminal = Boolean(activity.terminal);
59
60
  const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
60
61
  const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
61
- const matched = findMatchingPlanStepByStructure(plan, activity) ?? findMatchingPlanStep(plan, activity);
62
+ const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
63
+ const matched = structuredMatch ?? findMatchingPlanStep(plan, activity);
62
64
  if (!matched) continue;
63
65
 
64
66
  if (terminal && !failed) {
67
+ const ownedSteps = actKey ? plan.filter((s) => s._activityKey === actKey) : [];
68
+ if (contractTaskPlan && structuredMatch && ownedSteps.length === 0) {
69
+ matched.status = 'done';
70
+ matched.activityKey = actKey;
71
+ continue;
72
+ }
65
73
  // Structured plan: mark all steps owned by this activity as done.
66
74
  // Legacy plan (no _activityKey): mark all steps up to matched as done (sequential assumption).
67
- const ownedSteps = actKey ? plan.filter((s) => s._activityKey === actKey) : [];
68
75
  if (ownedSteps.length > 0) {
69
76
  for (const step of ownedSteps) {
70
77
  if (step.status !== 'failed') {
@@ -85,6 +92,11 @@ export function syncActivitiesToPlan(plan, activities) {
85
92
  matched.status = 'failed';
86
93
  matched.activityKey = actKey;
87
94
  } else if (!failed) {
95
+ if (contractTaskPlan && structuredMatch) {
96
+ matched.status = 'running';
97
+ matched.activityKey = actKey;
98
+ continue;
99
+ }
88
100
  // Running: matched step is in progress; preceding pending steps are implicitly done.
89
101
  for (const step of plan) {
90
102
  if (step.status === 'failed') continue;
@@ -104,11 +116,30 @@ export function formatPlanStatus(plan) {
104
116
  return plan
105
117
  .map((s) => {
106
118
  const icon = s.status === 'done' ? '✓' : s.status === 'failed' ? '✗' : s.status === 'running' ? '…' : ' ';
107
- return `${s.step}. [${icon}] ${s.description}`;
119
+ return `${s.step}. [${icon}] ${formatPlanStep(s)}`;
108
120
  })
109
121
  .join('\n');
110
122
  }
111
123
 
124
+ export function formatPlanStep(step) {
125
+ if (step == null) return '';
126
+ if (typeof step === 'string') return step;
127
+ if (typeof step !== 'object') return String(step);
128
+ for (const key of ['description', 'label', 'id', 'name']) {
129
+ if (step[key] != null) return formatPlanStep(step[key]);
130
+ }
131
+ return '';
132
+ }
133
+
134
+ export function formatConfigValue(value) {
135
+ if (value == null) return '';
136
+ if (Array.isArray(value)) return value.map(formatConfigValue).join(', ');
137
+ if (typeof value !== 'object') return String(value);
138
+ return Object.entries(value)
139
+ .map(([key, item]) => `${key}: ${formatConfigValue(item)}`)
140
+ .join(', ');
141
+ }
142
+
112
143
  export function formatCompletedActivities(activities) {
113
144
  return activities
114
145
  .filter((a) => a.terminal)
@@ -196,7 +227,10 @@ function statusRank(status) {
196
227
  export function attachActivityToExistingPlan(plan, activity) {
197
228
  const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
198
229
  if (!actKey) return;
199
- const matched = plan.find((step) => step.activityKey === actKey)
230
+ const contractTaskPlan = isContractTaskPlan(plan);
231
+ const structuredMatch = findMatchingPlanStepByStructure(plan, activity);
232
+ const matched = structuredMatch
233
+ ?? plan.find((step) => step.activityKey === actKey)
200
234
  ?? plan.find((step) => step.ownerActivityKey === actKey)
201
235
  ?? plan.find((step) => step.status === 'pending')
202
236
  ?? plan.find((step) => step.status === 'running');
@@ -209,6 +243,10 @@ export function attachActivityToExistingPlan(plan, activity) {
209
243
  return;
210
244
  }
211
245
  if (!failed) {
246
+ if (contractTaskPlan && structuredMatch) {
247
+ matched.status = 'running';
248
+ return;
249
+ }
212
250
  for (const step of plan) {
213
251
  if (step.status === 'failed') continue;
214
252
  if (step.step < matched.step) step.status = 'done';
@@ -216,3 +254,7 @@ export function attachActivityToExistingPlan(plan, activity) {
216
254
  }
217
255
  }
218
256
  }
257
+
258
+ function isContractTaskPlan(plan) {
259
+ return (plan ?? []).some((step) => step.requiredCapability || step.operation);
260
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { ensurePlanFromActivity, syncActivitiesToPlan, extractHeadlessPlan } from './plan.js';
3
+ import { ensurePlanFromActivity, syncActivitiesToPlan, extractHeadlessPlan, formatConfigValue, formatPlanStatus, formatPlanStep } from './plan.js';
4
4
 
5
5
  test('ensurePlanFromActivity: creates multi-step plan from activity.plan.steps', () => {
6
6
  const session = { headlessPlan: null };
@@ -166,3 +166,18 @@ test('extractHeadlessPlan: parses numbered list from text (legacy)', () => {
166
166
  assert.equal(plan[0].description, 'CME export');
167
167
  assert.equal(plan[1].step, 2);
168
168
  });
169
+
170
+ test('formatPlanStep renders structured steps without object interpolation', () => {
171
+ assert.equal(formatPlanStep('Build'), 'Build');
172
+ assert.equal(formatPlanStep({ description: 'Build deliverable', label: 'Build' }), 'Build deliverable');
173
+ assert.equal(formatPlanStep({ label: 'Build' }), 'Build');
174
+ assert.equal(formatPlanStep({ id: 'build-doc' }), 'build-doc');
175
+ assert.equal(formatPlanStatus([{ step: 1, status: 'pending', description: { label: 'Build' } }]), '1. [ ] Build');
176
+ assert.doesNotMatch(formatPlanStatus([{ step: 1, status: 'pending', description: { label: 'Build' } }]), /\[object Object\]/);
177
+ });
178
+
179
+ test('formatConfigValue renders objects as key-value text', () => {
180
+ assert.equal(formatConfigValue({ requestsPerMinute: 60 }), 'requestsPerMinute: 60');
181
+ assert.equal(formatConfigValue({ limits: { requestsPerMinute: 60 } }), 'limits: requestsPerMinute: 60');
182
+ assert.doesNotMatch(formatConfigValue({ requestsPerMinute: 60 }), /\[object Object\]/);
183
+ });
@@ -112,17 +112,41 @@ export function formatReadyTaskPrompt(task) {
112
112
 
113
113
  export function normalizeTask(raw, index = 0) {
114
114
  const item = raw && typeof raw === 'object' ? raw : { description: String(raw ?? '') };
115
- return {
115
+ const label = String(item.label ?? item.description ?? item.name ?? `Step ${index + 1}`);
116
+ const task = {
116
117
  ...item,
117
118
  step: Number(item.step ?? index + 1),
118
119
  id: item.id != null ? String(item.id) : null,
119
- description: String(item.description ?? item.label ?? item.name ?? `Step ${index + 1}`),
120
+ label,
121
+ description: String(item.description ?? label),
120
122
  status: String(item.status ?? 'pending'),
121
123
  dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
124
+ requiredCapability: toNullableString(item.requiredCapability),
125
+ operation: toNullableString(item.operation),
126
+ arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments) ? { ...item.arguments } : {},
127
+ groupId: toNullableString(item.groupId),
128
+ dependsOnGroup: toNullableString(item.dependsOnGroup),
129
+ barrier: item.barrier === true,
130
+ parallelizable: item.parallelizable === true,
131
+ recommendedConcurrency: normalizeOptionalPositiveInteger(item.recommendedConcurrency),
132
+ inputRefs: normalizeReferenceArray(item.inputRefs),
133
+ expectedOutputRefs: normalizeReferenceArray(item.expectedOutputRefs),
134
+ locks: Array.isArray(item.locks) ? item.locks.map(String) : [],
135
+ requiresApproval: item.requiresApproval === true,
136
+ approvalClass: toNullableString(item.approvalClass),
137
+ approvalSummary: toNullableString(item.approvalSummary),
138
+ idempotencyKey: toNullableString(item.idempotencyKey),
139
+ progressWeight: normalizeProgressWeight(item.progressWeight),
140
+ priority: normalizeOptionalNumber(item.priority),
141
+ retryPolicy: item.retryPolicy == null ? undefined : normalizeRetryPolicy(item.retryPolicy),
122
142
  executor: item.executor ?? null,
123
143
  executorQuery: item.executorQuery ?? null,
124
- outputRefs: Array.isArray(item.outputRefs) ? item.outputRefs.map(String) : [],
144
+ outputRefs: normalizeReferenceArray(item.outputRefs),
125
145
  };
146
+ for (const key of ['recommendedConcurrency', 'priority', 'retryPolicy']) {
147
+ if (task[key] === undefined) delete task[key];
148
+ }
149
+ return task;
126
150
  }
127
151
 
128
152
  export function sanitizePlanForExecution(plan) {
@@ -162,6 +186,43 @@ function normalizePatchOperation(raw) {
162
186
  return { ...raw, op };
163
187
  }
164
188
 
189
+ function toNullableString(value) {
190
+ return value != null ? String(value) : null;
191
+ }
192
+
193
+ function normalizeReferenceArray(value) {
194
+ if (!Array.isArray(value)) return [];
195
+ return value.map((item) => {
196
+ if (item && typeof item === 'object' && !Array.isArray(item)) return { ...item };
197
+ return String(item);
198
+ });
199
+ }
200
+
201
+ function normalizeOptionalPositiveInteger(value) {
202
+ const number = Number(value);
203
+ return Number.isInteger(number) && number >= 1 ? number : undefined;
204
+ }
205
+
206
+ function normalizeOptionalNumber(value) {
207
+ const number = Number(value);
208
+ return Number.isFinite(number) ? number : undefined;
209
+ }
210
+
211
+ function normalizeProgressWeight(value) {
212
+ const number = Number(value);
213
+ return Number.isFinite(number) && number >= 0 ? number : 1;
214
+ }
215
+
216
+ function normalizeRetryPolicy(value) {
217
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
218
+ const maxAttempts = Number(value.maxAttempts);
219
+ return {
220
+ maxAttempts: Number.isInteger(maxAttempts) && maxAttempts >= 1 ? maxAttempts : 1,
221
+ retryableErrors: Array.isArray(value.retryableErrors) ? value.retryableErrors.map(String) : [],
222
+ allowAgentFallback: value.allowAgentFallback === true,
223
+ };
224
+ }
225
+
165
226
  function normalizeAddedTask(raw, index) {
166
227
  const task = normalizeTask(raw, index);
167
228
  return {