@dotdrelle/wiki-manager 0.11.9 → 0.12.0

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 (81) 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 +65 -30
  11. package/src/agent/graph.test.js +28 -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 +145 -0
  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 +1 -1
  25. package/src/core/plan.js +46 -4
  26. package/src/core/plan.test.js +16 -1
  27. package/src/core/planPatch.js +64 -3
  28. package/src/core/planPatch.test.js +110 -1
  29. package/src/core/queueStore.test.js +21 -0
  30. package/src/core/runtimeLog.js +119 -0
  31. package/src/core/runtimeLog.test.js +84 -0
  32. package/src/core/wikirc.js +22 -0
  33. package/src/core/wikirc.test.js +49 -1
  34. package/src/core/workflow.js +14 -3
  35. package/src/graph/graphAggregator.js +5 -0
  36. package/src/graph/graphPatch.js +8 -0
  37. package/src/graph/graphSnapshot.js +14 -0
  38. package/src/graph/graphVisibilityPolicy.js +40 -0
  39. package/src/graph/runGraphProjector.js +87 -0
  40. package/src/graph/runGraphProjector.test.js +111 -0
  41. package/src/orchestrator/agentRegistry.js +172 -0
  42. package/src/orchestrator/agentRegistry.test.js +114 -0
  43. package/src/orchestrator/approvalPolicy.js +126 -0
  44. package/src/orchestrator/approvalPolicy.test.js +78 -0
  45. package/src/orchestrator/assignmentManager.js +80 -0
  46. package/src/orchestrator/attemptManager.js +168 -0
  47. package/src/orchestrator/attemptManager.test.js +160 -0
  48. package/src/orchestrator/budgetManager.js +127 -0
  49. package/src/orchestrator/capabilityRegistry.js +64 -0
  50. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  51. package/src/orchestrator/capabilityResolver.js +151 -0
  52. package/src/orchestrator/capabilityResolver.test.js +127 -0
  53. package/src/orchestrator/dependencyResolver.js +112 -0
  54. package/src/orchestrator/dispatcher.js +302 -0
  55. package/src/orchestrator/lockManager.js +51 -0
  56. package/src/orchestrator/planIntegrator.js +268 -0
  57. package/src/orchestrator/planIntegrator.test.js +213 -0
  58. package/src/orchestrator/planValidator.js +534 -0
  59. package/src/orchestrator/planValidator.test.js +262 -0
  60. package/src/orchestrator/resultAggregator.js +248 -0
  61. package/src/orchestrator/resultAggregator.test.js +211 -0
  62. package/src/orchestrator/scheduler.js +103 -0
  63. package/src/orchestrator/scheduler.test.js +138 -0
  64. package/src/runtime/approvals.js +130 -1
  65. package/src/runtime/donna-contract.test.js +293 -30
  66. package/src/runtime/recoveryManager.js +176 -0
  67. package/src/runtime/recoveryManager.test.js +162 -0
  68. package/src/runtime/runner.e2e.test.js +98 -12
  69. package/src/runtime/runner.js +261 -227
  70. package/src/runtime/runner.test.js +249 -452
  71. package/src/runtime/server.js +137 -20
  72. package/src/runtime/server.test.js +137 -7
  73. package/src/runtime/store.js +837 -2
  74. package/src/runtime/store.test.js +245 -4
  75. package/src/runtime/supervisor.js +33 -1
  76. package/src/runtime/supervisor.test.js +47 -1
  77. package/src/shell/RightPane.tsx +8 -5
  78. package/src/shell/repl.js +4 -9
  79. package/src/shell/repl.test.js +25 -4
  80. package/src/shell/tui.tsx +1 -0
  81. package/src/shell/useSession.ts +38 -10
@@ -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.9';
4
+ const WIKI_MANAGER_VERSION = '0.12.0';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
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 {
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { applyPlanPatch, nextReadyPlanTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
3
+ import { applyPlanPatch, nextReadyPlanTask, normalizeTask, readyPlanTasks, rebasePlanPatch, sanitizePlanForExecution } from './planPatch.js';
4
4
 
5
5
  test('applyPlanPatch adds a task and increments the plan revision', () => {
6
6
  const result = applyPlanPatch([
@@ -83,3 +83,112 @@ test('sanitizePlanForExecution breaks cycles with declaration-order fallback', (
83
83
  assert.match(result.warnings.join('\n'), /cycle/);
84
84
  assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['a']);
85
85
  });
86
+
87
+ test('normalizeTask carries task contract fields with safe defaults', () => {
88
+ const task = normalizeTask({
89
+ id: 'ingest-a',
90
+ label: 'Ingest A',
91
+ requiredCapability: 'knowledge.update',
92
+ operation: 'ingest',
93
+ arguments: { files: ['raw/untracked/a.md'] },
94
+ groupId: 'ingest',
95
+ dependsOn: ['scan'],
96
+ dependsOnGroup: 'sources',
97
+ barrier: true,
98
+ parallelizable: true,
99
+ recommendedConcurrency: 3,
100
+ inputRefs: [{ type: 'file', ref: 'raw/untracked/a.md' }],
101
+ expectedOutputRefs: ['wiki/a.md'],
102
+ locks: ['workspace:wiki'],
103
+ requiresApproval: true,
104
+ approvalClass: 'mutation',
105
+ approvalSummary: 'Update wiki',
106
+ idempotencyKey: 'idem-1',
107
+ progressWeight: 2,
108
+ priority: 5,
109
+ retryPolicy: {
110
+ maxAttempts: 2,
111
+ retryableErrors: ['timeout'],
112
+ allowAgentFallback: true,
113
+ },
114
+ }, 0);
115
+
116
+ assert.equal(task.description, 'Ingest A');
117
+ assert.equal(task.parallelizable, true);
118
+ assert.deepEqual(task.inputRefs, [{ type: 'file', ref: 'raw/untracked/a.md' }]);
119
+ assert.deepEqual(task.expectedOutputRefs, ['wiki/a.md']);
120
+ assert.deepEqual(task.locks, ['workspace:wiki']);
121
+ assert.equal(task.requiresApproval, true);
122
+ assert.equal(task.progressWeight, 2);
123
+ assert.deepEqual(task.retryPolicy, {
124
+ maxAttempts: 2,
125
+ retryableErrors: ['timeout'],
126
+ allowAgentFallback: true,
127
+ });
128
+ });
129
+
130
+ test('normalizeTask supplies safe defaults for new execution fields', () => {
131
+ const task = normalizeTask({ id: 'legacy', description: 'Legacy task' }, 0);
132
+
133
+ assert.equal(task.label, 'Legacy task');
134
+ assert.equal(task.requiredCapability, null);
135
+ assert.equal(task.operation, null);
136
+ assert.deepEqual(task.arguments, {});
137
+ assert.equal(task.parallelizable, false);
138
+ assert.deepEqual(task.inputRefs, []);
139
+ assert.deepEqual(task.locks, []);
140
+ assert.equal(task.requiresApproval, false);
141
+ assert.equal(task.idempotencyKey, null);
142
+ assert.equal(task.progressWeight, 1);
143
+ });
144
+
145
+ test('sanitizePlanForExecution preserves task contract fields through cleanup', () => {
146
+ const result = sanitizePlanForExecution([
147
+ {
148
+ id: 'scan',
149
+ label: 'Scan inputs',
150
+ requiredCapability: 'knowledge.update',
151
+ operation: 'scan',
152
+ dependsOn: [],
153
+ parallelizable: false,
154
+ inputRefs: [{ type: 'directory', ref: 'raw/untracked' }],
155
+ locks: [],
156
+ requiresApproval: false,
157
+ idempotencyKey: null,
158
+ progressWeight: 1,
159
+ },
160
+ {
161
+ id: 'ingest',
162
+ label: 'Ingest inputs',
163
+ requiredCapability: 'knowledge.update',
164
+ operation: 'ingest',
165
+ dependsOn: ['scan', 'missing'],
166
+ parallelizable: true,
167
+ inputRefs: ['raw/untracked/a.md'],
168
+ locks: ['workspace:wiki'],
169
+ requiresApproval: false,
170
+ idempotencyKey: null,
171
+ progressWeight: 3,
172
+ },
173
+ ]);
174
+
175
+ assert.deepEqual(result.plan[1].dependsOn, ['scan']);
176
+ assert.equal(result.plan[1].requiredCapability, 'knowledge.update');
177
+ assert.equal(result.plan[1].operation, 'ingest');
178
+ assert.equal(result.plan[1].parallelizable, true);
179
+ assert.deepEqual(result.plan[1].inputRefs, ['raw/untracked/a.md']);
180
+ assert.deepEqual(result.plan[1].locks, ['workspace:wiki']);
181
+ assert.equal(result.plan[1].progressWeight, 3);
182
+ });
183
+
184
+ test('legacy event plan replay remains readable after task contract extension', () => {
185
+ const result = sanitizePlanForExecution([
186
+ { step: 1, id: 'a', description: 'Old A', status: 'done' },
187
+ { step: 2, id: 'b', description: 'Old B', status: 'pending', dependsOn: ['a'] },
188
+ ]);
189
+
190
+ assert.deepEqual(result.warnings, []);
191
+ assert.deepEqual(result.plan.map((task) => task.id), ['a', 'b']);
192
+ assert.deepEqual(result.plan.map((task) => task.label), ['Old A', 'Old B']);
193
+ assert.deepEqual(readyPlanTasks(result.plan).map((task) => task.id), ['b']);
194
+ });