@dotdrelle/wiki-manager 0.8.2 → 0.10.4

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.
@@ -1,10 +1,19 @@
1
1
  import { normalizeActivity } from './activity.js';
2
2
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
+ import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
+ import { projectWorkflow } from './workflow.js';
5
+ import { validateContractInDev } from '../contracts/schemas.js';
3
6
 
4
7
  const SESSION_PROJECTION_EVENTS = new Set([
5
8
  'run_started',
6
9
  'plan_set',
7
10
  'plan_step_updated',
11
+ 'control_message_received',
12
+ 'plan_patch_proposed',
13
+ 'plan_patch_approved',
14
+ 'plan_patch_applied',
15
+ 'plan_patch_rebased',
16
+ 'plan_patch_rejected',
8
17
  'activity_upserted',
9
18
  'run_evaluated',
10
19
  'run_replanned',
@@ -12,6 +21,8 @@ const SESSION_PROJECTION_EVENTS = new Set([
12
21
  'run_approved',
13
22
  'tool_pending_approval',
14
23
  'tool_approved',
24
+ 'control_enqueued',
25
+ 'control_started',
15
26
  'run_done',
16
27
  'run_error',
17
28
  'run_cancelled',
@@ -24,6 +35,7 @@ const SESSION_PROJECTION_EVENTS = new Set([
24
35
  const PLAN_MUTATING_EVENTS = new Set([
25
36
  'run_started',
26
37
  'plan_set',
38
+ 'plan_patch_applied',
27
39
  'plan_step_updated',
28
40
  'activity_upserted',
29
41
  'run_done',
@@ -34,18 +46,20 @@ export function createAgentEvent(type, {
34
46
  payload = {},
35
47
  runId = null,
36
48
  turnId = null,
49
+ taskId = null,
37
50
  workspace = null,
38
51
  } = {}) {
39
- return {
52
+ return validateContractInDev('agentRunEvent', {
40
53
  id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
41
54
  ts: new Date().toISOString(),
42
55
  type,
43
56
  origin,
44
57
  runId,
45
58
  turnId,
59
+ taskId,
46
60
  workspace,
47
61
  payload,
48
- };
62
+ });
49
63
  }
50
64
 
51
65
  export function dispatchAgentEvent(session, event) {
@@ -53,6 +67,7 @@ export function dispatchAgentEvent(session, event) {
53
67
  event.id && event.ts ? event : createAgentEvent(event.type, event),
54
68
  session,
55
69
  );
70
+ validateContractInDev('agentRunEvent', normalized);
56
71
  const tracksPlan = PLAN_MUTATING_EVENTS.has(normalized.type);
57
72
  const previousPlan = tracksPlan ? JSON.stringify(session.headlessPlan ?? null) : null;
58
73
  session.agentEvents ??= [];
@@ -77,6 +92,7 @@ function withSessionRunIdentity(event, session) {
77
92
  ...event,
78
93
  runId: event.runId ?? identity.runId ?? null,
79
94
  turnId: event.turnId ?? identity.turnId ?? null,
95
+ taskId: event.taskId ?? identity.taskId ?? null,
80
96
  workspace: event.workspace ?? identity.workspace ?? null,
81
97
  };
82
98
  }
@@ -101,13 +117,16 @@ function createProjectionState() {
101
117
  evaluation: null,
102
118
  replans: [],
103
119
  approvals: [],
120
+ planRevision: 0,
121
+ planPatches: [],
122
+ controlQueue: [],
104
123
  summary: null,
105
124
  status: 'idle',
106
125
  };
107
126
  }
108
127
 
109
128
  function publicProjection(state) {
110
- return {
129
+ const projection = {
111
130
  conversation: state.conversation.map((message) => ({ ...message })),
112
131
  chain: state.chain.map((step) => ({ ...step })),
113
132
  plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
@@ -116,14 +135,29 @@ function publicProjection(state) {
116
135
  evaluation: state.evaluation ? { ...state.evaluation } : null,
117
136
  replans: state.replans.map((replan) => ({ ...replan, plan: [...(replan.plan ?? [])] })),
118
137
  approvals: state.approvals.map((approval) => ({ ...approval })),
138
+ planRevision: state.planRevision,
139
+ planPatches: state.planPatches.map((patch) => ({
140
+ ...patch,
141
+ operations: (patch.operations ?? []).map((operation) => ({ ...operation })),
142
+ patch: patch.patch ? { ...patch.patch, operations: (patch.patch.operations ?? []).map((operation) => ({ ...operation })) } : null,
143
+ })),
144
+ controlQueue: state.controlQueue.map((item) => ({ ...item })),
119
145
  summary: state.summary,
120
146
  status: state.status,
121
147
  };
148
+ return {
149
+ ...projection,
150
+ workflow: projectWorkflow(projection),
151
+ };
122
152
  }
123
153
 
124
154
  export function applyAgentProjectionToSession(session, projection) {
125
155
  session.headlessPlan = projection.plan ? projection.plan.map((step) => ({ ...step })) : null;
126
156
  session.activities = Object.fromEntries((projection.activities ?? []).map((activity) => [activity.key, { ...activity }]));
157
+ session.controlQueue = (projection.controlQueue ?? []).map((item) => ({ ...item }));
158
+ session.planRevision = projection.planRevision ?? 0;
159
+ session.planPatches = (projection.planPatches ?? []).map((patch) => ({ ...patch }));
160
+ session.workflow = projection.workflow ? { ...projection.workflow } : null;
127
161
  const production = (projection.activities ?? []).filter((activity) => activity.source === 'production').at(-1);
128
162
  session.productionActivity = production ? {
129
163
  jobId: production.id,
@@ -138,13 +172,15 @@ function applyEvent(state, event) {
138
172
  switch (event.type) {
139
173
  case 'run_started':
140
174
  state.status = 'running';
141
- state.plan = defaultRunPlan();
175
+ state.plan = null;
142
176
  state.chain = [];
143
177
  state.activities = {};
144
178
  state.logs = [];
145
179
  state.evaluation = null;
146
180
  state.replans = [];
147
181
  state.approvals = [];
182
+ state.planRevision = 0;
183
+ state.planPatches = [];
148
184
  state.summary = null;
149
185
  return;
150
186
  case 'user_message':
@@ -174,10 +210,97 @@ function applyEvent(state, event) {
174
210
  return;
175
211
  case 'plan_set':
176
212
  state.plan = normalizePlan(event.payload?.steps, event.payload ?? {});
213
+ // A plan_set always replaces the plan wholesale (initial declaration,
214
+ // fallback extraction, or a full replan) — bump the revision so any
215
+ // patch proposed against the prior plan is detected as stale and
216
+ // rebased instead of silently applying against a structure that no
217
+ // longer matches what it was built for. An explicit payload.planRevision
218
+ // still wins, for callers that manage revisions themselves.
219
+ state.planRevision = event.payload?.planRevision != null
220
+ ? normalizePlanRevision(event.payload.planRevision)
221
+ : state.planRevision + 1;
177
222
  return;
178
223
  case 'plan_step_updated':
179
224
  updatePlanStep(state.plan, event.payload ?? {});
180
225
  return;
226
+ case 'control_message_received':
227
+ state.logs.push(`Control message: ${String(event.payload?.input ?? '')}`);
228
+ state.logs = state.logs.slice(-200);
229
+ return;
230
+ case 'plan_patch_proposed':
231
+ upsertPlanPatch(state, {
232
+ id: patchIdFromEvent(event),
233
+ status: 'proposed',
234
+ runId: event.runId ?? event.payload?.targetRunId ?? null,
235
+ workspace: event.workspace ?? null,
236
+ input: event.payload?.input ?? null,
237
+ patch: normalizePlanPatch(event.payload?.patch ?? {}, {
238
+ targetRunId: event.runId ?? event.payload?.targetRunId ?? null,
239
+ basePlanRevision: state.planRevision,
240
+ }),
241
+ createdAt: event.ts,
242
+ updatedAt: event.ts,
243
+ });
244
+ return;
245
+ case 'plan_patch_approved':
246
+ upsertPlanPatch(state, {
247
+ id: patchIdFromEvent(event),
248
+ status: 'approved',
249
+ approvedAt: event.ts,
250
+ updatedAt: event.ts,
251
+ });
252
+ return;
253
+ case 'plan_patch_rebased': {
254
+ const existing = state.planPatches.find((patch) => patch.id === patchIdFromEvent(event));
255
+ const rebased = normalizePlanPatch(event.payload?.patch ?? rebasePlanPatch(existing?.patch ?? {}, { currentRevision: state.planRevision }), {
256
+ targetRunId: event.runId ?? null,
257
+ basePlanRevision: state.planRevision,
258
+ });
259
+ upsertPlanPatch(state, {
260
+ id: patchIdFromEvent(event),
261
+ status: 'rebased',
262
+ patch: rebased,
263
+ updatedAt: event.ts,
264
+ });
265
+ return;
266
+ }
267
+ case 'plan_patch_applied': {
268
+ const patchId = patchIdFromEvent(event);
269
+ const patch = normalizePlanPatch(event.payload?.patch ?? {}, {
270
+ targetRunId: event.runId ?? event.payload?.targetRunId ?? null,
271
+ basePlanRevision: state.planRevision,
272
+ });
273
+ const applied = applyPlanPatch(state.plan, patch, { currentRevision: state.planRevision });
274
+ if (!applied.ok) {
275
+ upsertPlanPatch(state, {
276
+ id: patchId,
277
+ status: 'rejected',
278
+ rejectionReason: applied.reason,
279
+ currentRevision: applied.currentRevision,
280
+ updatedAt: event.ts,
281
+ });
282
+ return;
283
+ }
284
+ state.plan = applied.plan;
285
+ state.planRevision = applied.planRevision;
286
+ upsertPlanPatch(state, {
287
+ id: patchId,
288
+ status: 'applied',
289
+ patch,
290
+ appliedAt: event.ts,
291
+ planRevision: state.planRevision,
292
+ updatedAt: event.ts,
293
+ });
294
+ return;
295
+ }
296
+ case 'plan_patch_rejected':
297
+ upsertPlanPatch(state, {
298
+ id: patchIdFromEvent(event),
299
+ status: 'rejected',
300
+ rejectionReason: event.payload?.reason ?? null,
301
+ updatedAt: event.ts,
302
+ });
303
+ return;
181
304
  case 'run_summary':
182
305
  state.summary = String(event.payload?.content ?? '');
183
306
  if (state.summary) state.conversation.push({ role: 'assistant', content: state.summary });
@@ -226,14 +349,36 @@ function applyEvent(state, event) {
226
349
  case 'run_done':
227
350
  state.status = 'done';
228
351
  finishPendingPlanSteps(state.plan);
352
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'done', event.ts);
229
353
  return;
230
354
  case 'run_cancelled':
231
355
  state.status = 'cancelled';
232
356
  state.logs.push(String(event.payload?.message ?? 'Agent run cancelled.'));
357
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'cancelled', event.ts);
233
358
  return;
234
359
  case 'run_error':
235
360
  state.status = 'error';
236
361
  state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
362
+ finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
363
+ return;
364
+ case 'control_enqueued':
365
+ upsertControlItem(state.controlQueue, {
366
+ id: event.payload?.id ?? event.id,
367
+ workspace: event.workspace ?? event.payload?.workspace ?? null,
368
+ input: String(event.payload?.input ?? ''),
369
+ status: 'queued',
370
+ createdAt: event.payload?.createdAt ?? event.ts,
371
+ updatedAt: event.ts,
372
+ });
373
+ return;
374
+ case 'control_started':
375
+ upsertControlItem(state.controlQueue, {
376
+ id: event.payload?.id ?? null,
377
+ runId: event.runId ?? event.payload?.runId ?? null,
378
+ status: 'running',
379
+ startedAt: event.payload?.startedAt ?? event.ts,
380
+ updatedAt: event.ts,
381
+ });
237
382
  return;
238
383
  case 'runtime_log':
239
384
  state.logs.push(String(event.payload?.message ?? ''));
@@ -244,6 +389,41 @@ function applyEvent(state, event) {
244
389
  }
245
390
  }
246
391
 
392
+ function upsertById(list, next) {
393
+ const index = list.findIndex((item) => item.id === next.id);
394
+ if (index === -1) {
395
+ list.push(next);
396
+ return;
397
+ }
398
+ list[index] = {
399
+ ...list[index],
400
+ ...next,
401
+ };
402
+ }
403
+
404
+ function upsertControlItem(queue, next) {
405
+ if (!next.id) return;
406
+ upsertById(queue, next);
407
+ }
408
+
409
+ function upsertPlanPatch(state, next) {
410
+ if (!next.id) return;
411
+ upsertById(state.planPatches, next);
412
+ }
413
+
414
+ function patchIdFromEvent(event) {
415
+ return event.payload?.id ?? event.payload?.patchId ?? event.id;
416
+ }
417
+
418
+ function finishControlByRun(queue, runId, status, finishedAt) {
419
+ if (!runId) return;
420
+ const item = queue.find((entry) => entry.runId === runId && entry.status === 'running');
421
+ if (!item) return;
422
+ item.status = status;
423
+ item.finishedAt = finishedAt;
424
+ item.updatedAt = finishedAt;
425
+ }
426
+
247
427
  function appendAssistantDelta(state, delta) {
248
428
  if (!delta) return;
249
429
  const last = state.conversation.at(-1);
@@ -279,15 +459,7 @@ function finishToolCall(state, payload = {}) {
279
459
  }
280
460
 
281
461
  function upsertApproval(state, next) {
282
- const index = state.approvals.findIndex((approval) => approval.id === next.id);
283
- if (index === -1) {
284
- state.approvals.push(next);
285
- return;
286
- }
287
- state.approvals[index] = {
288
- ...state.approvals[index],
289
- ...next,
290
- };
462
+ upsertById(state.approvals, next);
291
463
  }
292
464
 
293
465
  function finishPendingPlanSteps(plan) {
@@ -323,6 +495,10 @@ function ensurePlanFromActivityProjection(state, activity) {
323
495
  id: step.id ?? null,
324
496
  description: step.label,
325
497
  status: 'pending',
498
+ dependsOn: Array.isArray(step.dependsOn) ? step.dependsOn.map(String) : [],
499
+ executor: step.executor ?? null,
500
+ executorQuery: step.executorQuery ?? null,
501
+ outputRefs: Array.isArray(step.outputRefs) ? step.outputRefs.map(String) : [],
326
502
  owner: 'activity',
327
503
  ownerActivityKey: activity.key,
328
504
  _activityKey: activity.key,
@@ -334,6 +510,10 @@ function ensurePlanFromActivityProjection(state, activity) {
334
510
  id: null,
335
511
  description: activity.label,
336
512
  status: 'pending',
513
+ dependsOn: [],
514
+ executor: null,
515
+ executorQuery: null,
516
+ outputRefs: [],
337
517
  owner: 'activity',
338
518
  ownerActivityKey: activity.key,
339
519
  _activityKey: activity.key,
@@ -344,33 +524,41 @@ function normalizePlan(steps, payload = {}) {
344
524
  if (!Array.isArray(steps)) return null;
345
525
  const owner = payload.owner ?? 'orchestrator';
346
526
  const ownerActivityKey = payload.ownerActivityKey ?? payload.activityKey ?? null;
347
- return steps.map((raw, i) => {
527
+ const plan = steps.map((raw, i) => {
348
528
  const item = typeof raw === 'string' ? { description: raw } : (raw ?? {});
349
529
  return {
350
530
  step: Number(item.step ?? i + 1),
351
531
  id: item.id ?? null,
352
532
  description: String(item.description ?? item.label ?? item.name ?? `Step ${i + 1}`),
353
533
  status: item.status ?? 'pending',
534
+ dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
535
+ executor: item.executor ?? null,
536
+ executorQuery: item.executorQuery ?? null,
537
+ outputRefs: Array.isArray(item.outputRefs) ? item.outputRefs.map(String) : [],
538
+ locks: Array.isArray(item.locks) ? item.locks.map(String) : item.locks ?? null,
539
+ writeLocks: Array.isArray(item.writeLocks) ? item.writeLocks.map(String) : item.writeLocks ?? null,
540
+ deliverableWrites: Array.isArray(item.deliverableWrites) ? item.deliverableWrites.map(String) : [],
541
+ wikiPageWrites: Array.isArray(item.wikiPageWrites) ? item.wikiPageWrites.map(String) : [],
542
+ workspaceWrite: item.workspaceWrite === true,
354
543
  owner: item.owner ?? owner,
355
544
  ownerActivityKey: item.ownerActivityKey ?? ownerActivityKey,
356
545
  _activityKey: item._activityKey ?? payload.activityKey ?? null,
357
546
  };
358
547
  });
359
- }
360
-
361
- function defaultRunPlan() {
362
- return [
363
- { step: 1, id: 'analyze', description: 'Analyze the request', status: 'running', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
364
- { step: 2, id: 'execute', description: 'Execute the required actions', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
365
- { step: 3, id: 'verify', description: 'Verify the result', status: 'pending', owner: 'orchestrator', ownerActivityKey: null, _activityKey: null },
366
- ];
548
+ return validateContractInDev('plan', plan);
367
549
  }
368
550
 
369
551
  function updatePlanStep(plan, payload) {
370
552
  if (!plan) return;
371
- const step = plan.find((item) => item.step === Number(payload.step));
553
+ const requestedTaskId = payload.taskId ?? payload.id ?? payload.targetTaskId;
554
+ const step = requestedTaskId != null
555
+ ? plan.find((item) => String(item.id ?? item.step) === String(requestedTaskId))
556
+ : plan.find((item) => item.step === Number(payload.step));
372
557
  if (!step) return;
373
- step.status = payload.status === 'failed' ? 'failed' : payload.status === 'running' ? 'running' : 'done';
558
+ if (payload.status === 'failed') step.status = 'failed';
559
+ else if (payload.status === 'running') step.status = 'running';
560
+ else if (payload.status === 'cancelled') step.status = 'cancelled';
561
+ else step.status = 'done';
374
562
  if (payload.activityKey) step.activityKey = payload.activityKey;
375
563
  }
376
564
 
@@ -22,9 +22,7 @@ test('reduceAgentEvents: run_started clears stale plan', () => {
22
22
  }),
23
23
  createAgentEvent('run_started', { origin: 'user' }),
24
24
  ]);
25
- assert.equal(projection.plan.length, 3);
26
- assert.equal(projection.plan[0].description, 'Analyze the request');
27
- assert.equal(projection.plan[0].owner, 'orchestrator');
25
+ assert.equal(projection.plan, null);
28
26
  assert.equal(projection.activities.length, 0);
29
27
  assert.equal(projection.status, 'running');
30
28
  });
@@ -95,7 +93,7 @@ test('reduceAgentEvents: activity attaches to orchestrator plan without replacin
95
93
  assert.equal(projection.plan[0].ownerActivityKey, 'cme:export-1');
96
94
  });
97
95
 
98
- test('reduceAgentEvents: run activity attaches to execution step of default plan', () => {
96
+ test('reduceAgentEvents: run activity creates activity-owned plan when no explicit plan exists', () => {
99
97
  const projection = reduceAgentEvents([
100
98
  createAgentEvent('run_started', { origin: 'runtime' }),
101
99
  createAgentEvent('activity_upserted', {
@@ -112,28 +110,34 @@ test('reduceAgentEvents: run activity attaches to execution step of default plan
112
110
  }),
113
111
  ]);
114
112
 
115
- assert.equal(projection.plan[0].status, 'done');
116
- assert.equal(projection.plan[1].status, 'running');
117
- assert.equal(projection.plan[1].activityKey, 'production:build-1');
118
- assert.equal(projection.plan[2].status, 'pending');
113
+ assert.equal(projection.plan.length, 1);
114
+ assert.equal(projection.plan[0].description, 'Production build');
115
+ assert.equal(projection.plan[0].status, 'running');
116
+ assert.equal(projection.plan[0].owner, 'activity');
119
117
  });
120
118
 
121
119
  test('reduceAgentEvents: run_done finalizes running and pending plan steps', () => {
122
120
  const projection = reduceAgentEvents([
123
- createAgentEvent('run_started', { origin: 'runtime' }),
121
+ createAgentEvent('plan_set', {
122
+ origin: 'tool',
123
+ payload: { steps: ['Analyze', 'Execute'] },
124
+ }),
124
125
  createAgentEvent('run_done', { origin: 'runtime' }),
125
126
  ]);
126
127
 
127
- assert.deepEqual(projection.plan.map((step) => step.status), ['done', 'done', 'done']);
128
+ assert.deepEqual(projection.plan.map((step) => step.status), ['done', 'done']);
128
129
  assert.equal(projection.status, 'done');
129
130
  });
130
131
 
131
132
  test('dispatchAgentEvent: run_done finalizes session plan', () => {
132
133
  const session = {};
133
- dispatchAgentEvent(session, createAgentEvent('run_started', { origin: 'runtime' }));
134
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
135
+ origin: 'tool',
136
+ payload: { steps: ['Analyze', 'Execute'] },
137
+ }));
134
138
  dispatchAgentEvent(session, createAgentEvent('run_done', { origin: 'runtime' }));
135
139
 
136
- assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
140
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
137
141
  assert.equal(session.agentProjection.status, 'done');
138
142
  });
139
143
 
@@ -210,6 +214,37 @@ test('reduceAgentEvents: approvals move from pending to approved', () => {
210
214
  assert.deepEqual(projection.approvals[0].plan, ['Build']);
211
215
  });
212
216
 
217
+ test('reduceAgentEvents: control queue is event sourced and follows run status', () => {
218
+ const projection = reduceAgentEvents([
219
+ createAgentEvent('control_enqueued', {
220
+ origin: 'runtime',
221
+ workspace: 'docs',
222
+ payload: {
223
+ id: 'control-1',
224
+ workspace: 'docs',
225
+ input: 'Run after current task',
226
+ createdAt: '2026-01-01T00:00:00.000Z',
227
+ },
228
+ }),
229
+ createAgentEvent('control_started', {
230
+ origin: 'runtime',
231
+ runId: 'run-control-1',
232
+ workspace: 'docs',
233
+ payload: { id: 'control-1', runId: 'run-control-1' },
234
+ }),
235
+ createAgentEvent('run_done', {
236
+ origin: 'runtime',
237
+ runId: 'run-control-1',
238
+ workspace: 'docs',
239
+ }),
240
+ ]);
241
+
242
+ assert.equal(projection.controlQueue.length, 1);
243
+ assert.equal(projection.controlQueue[0].id, 'control-1');
244
+ assert.equal(projection.controlQueue[0].status, 'done');
245
+ assert.equal(projection.controlQueue[0].runId, 'run-control-1');
246
+ });
247
+
213
248
  test('reduceAgentEvents: activity-owned plan is used when no orchestrator plan exists', () => {
214
249
  const projection = reduceAgentEvents([
215
250
  createAgentEvent('activity_upserted', {
@@ -273,3 +308,41 @@ test('dispatchAgentEvent: assistant deltas do not rewrite session plan or activi
273
308
  assert.equal(planUpdates, updatesAfterActivity);
274
309
  assert.equal(session.agentProjection.conversation.at(-1).content, 'bonjour');
275
310
  });
311
+
312
+ test('reduceAgentEvents: plan patches are proposed, approved and applied with revisions', () => {
313
+ // plan_set bumps planRevision (it wholesale-replaces the plan), so a patch
314
+ // proposed after it must target that new revision (1), not 0.
315
+ const patch = {
316
+ targetRunId: 'run-patch',
317
+ basePlanRevision: 1,
318
+ operations: [{ op: 'add_task', task: { id: 'task-b', description: 'B', dependsOn: ['task-a'] } }],
319
+ };
320
+ const projection = reduceAgentEvents([
321
+ createAgentEvent('run_started', { origin: 'runtime', runId: 'run-patch' }),
322
+ createAgentEvent('plan_set', {
323
+ origin: 'tool',
324
+ runId: 'run-patch',
325
+ payload: { steps: [{ id: 'task-a', description: 'A', status: 'done' }] },
326
+ }),
327
+ createAgentEvent('plan_patch_proposed', {
328
+ origin: 'runtime',
329
+ runId: 'run-patch',
330
+ payload: { id: 'patch-1', patch },
331
+ }),
332
+ createAgentEvent('plan_patch_approved', {
333
+ origin: 'runtime',
334
+ runId: 'run-patch',
335
+ payload: { patchId: 'patch-1' },
336
+ }),
337
+ createAgentEvent('plan_patch_applied', {
338
+ origin: 'runtime',
339
+ runId: 'run-patch',
340
+ payload: { patchId: 'patch-1', patch },
341
+ }),
342
+ ]);
343
+
344
+ assert.equal(projection.planRevision, 2);
345
+ assert.deepEqual(projection.plan.map((step) => step.id), ['task-a', 'task-b']);
346
+ assert.equal(projection.plan[1].status, 'pending');
347
+ assert.equal(projection.planPatches[0].status, 'applied');
348
+ });
@@ -2,6 +2,7 @@ import { buildAgentSystemPrompt, buildLimitedAgentResponse } from '../agent/grap
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
4
  import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
5
+ import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks } from './planPatch.js';
5
6
 
6
7
  export function abortError(message = 'Agent run cancelled.') {
7
8
  const err = new Error(message);
@@ -20,12 +21,14 @@ export async function runAgentTurn(agent, session, input, {
20
21
  let streamedContent = '';
21
22
  session._onStream = (delta) => { streamedContent += delta; };
22
23
  session._onStreamReset = () => { streamedContent = ''; };
24
+ if (signal) session._abortSignal = signal;
23
25
  let result;
24
26
  try {
25
- result = await agent.invoke({ input, session, messages });
27
+ result = await agent.invoke({ input, session, messages, signal });
26
28
  } finally {
27
29
  delete session._onStream;
28
30
  delete session._onStreamReset;
31
+ if (session._abortSignal === signal) delete session._abortSignal;
29
32
  }
30
33
  if (result.streamedInline) {
31
34
  return streamedContent.trim() || buildLimitedAgentResponse({ input, session }, 'LLM stream ended without content');
@@ -65,6 +68,7 @@ export async function runAgenticLoop(agent, session, initialInput, {
65
68
  onActivitiesCompleted = null,
66
69
  onMaxTurns = null,
67
70
  abortMessage = 'Agent run cancelled.',
71
+ parallelHandoff = false,
68
72
  } = {}) {
69
73
  if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
70
74
  const conversationHistory = [];
@@ -108,13 +112,25 @@ export async function runAgenticLoop(agent, session, initialInput, {
108
112
 
109
113
  const newPending = newNonTerminalActivities(snapshot, session);
110
114
  if (newPending.length === 0) {
111
- const pendingSteps = (session.headlessPlan ?? []).filter((step) => step.status === 'pending');
112
- if (pendingSteps.length === 0) {
115
+ // pending_approval is unfinished work too (a request_approval patch op
116
+ // sets it) — it must not be treated as "nothing left to do" just
117
+ // because no step is literally 'pending' anymore.
118
+ const pending = (session.headlessPlan ?? []).filter((step) => step.status === 'pending' || step.status === 'pending_approval');
119
+ const pendingSteps = readyPlanTasks(session.headlessPlan);
120
+ if (pending.length === 0) {
113
121
  onComplete?.();
114
122
  return { ok: true };
115
123
  }
124
+ if (pendingSteps.length === 0) {
125
+ const reason = pending.every((step) => step.status === 'pending_approval') ? 'awaiting_approval' : 'no_ready_plan_task';
126
+ onPendingSteps?.({ pendingSteps: pending, blocked: true });
127
+ return { ok: false, stalled: true, reason };
128
+ }
129
+ if (parallelHandoff && pendingSteps.length > 1) {
130
+ return { ok: true, handoff: true };
131
+ }
116
132
  onPendingSteps?.({ pendingSteps });
117
- currentInput = pendingStepsPrompt(initialInput, session.headlessPlan);
133
+ currentInput = pendingStepsPrompt(initialInput, session.headlessPlan, pendingSteps[0]);
118
134
  continue;
119
135
  }
120
136
 
@@ -132,6 +148,9 @@ export async function runAgenticLoop(agent, session, initialInput, {
132
148
  const completed = waitResult.completed ?? [];
133
149
  const summary = formatCompletedActivities(completed);
134
150
  onActivitiesCompleted?.({ completed, summary });
151
+ if (parallelHandoff && readyPlanTasks(session.headlessPlan).length > 1) {
152
+ return { ok: true, handoff: true };
153
+ }
135
154
  currentInput = completedActivitiesPrompt(initialInput, session.headlessPlan, summary);
136
155
  }
137
156
 
@@ -139,15 +158,18 @@ export async function runAgenticLoop(agent, session, initialInput, {
139
158
  return { ok: false, maxTurns: true };
140
159
  }
141
160
 
142
- function pendingStepsPrompt(initialInput, plan) {
161
+ function pendingStepsPrompt(initialInput, plan, readyTask) {
143
162
  return [
144
163
  'Original task:',
145
164
  initialInput,
146
165
  '',
147
166
  `Plan status:\n${formatPlanStatus(plan)}`,
148
167
  '',
168
+ formatReadyTaskPrompt(readyTask),
169
+ '',
149
170
  'No new background activity was started in the previous turn.',
150
- 'Continue the original plan. Start the next pending step only.',
171
+ 'Continue the original plan. Start exactly this next ready task only.',
172
+ 'Do not start tasks whose dependencies are not done.',
151
173
  'If required information is missing and cannot be inferred, stop with a clear blocker.',
152
174
  ].join('\n');
153
175
  }
@@ -161,7 +183,10 @@ function completedActivitiesPrompt(initialInput, plan, summary) {
161
183
  'Completed activities:',
162
184
  summary,
163
185
  '',
164
- 'Continue the original plan. Start the next required step only.',
186
+ formatReadyTaskPrompt(nextReadyPlanTask(plan)),
187
+ '',
188
+ 'Continue the original plan. Start exactly this next ready task only.',
189
+ 'Do not start tasks whose dependencies are not done.',
165
190
  'If all steps are complete, provide the final summary.',
166
191
  ].filter(Boolean).join('\n');
167
192
  }
package/src/core/mcp.js CHANGED
@@ -1,6 +1,8 @@
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.10.4';
5
+
4
6
  function envValue(key) {
5
7
  const filePath = managerEnvFile();
6
8
  if (existsSync(filePath)) {
@@ -230,7 +232,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
230
232
  params: {
231
233
  protocolVersion: '2025-06-18',
232
234
  capabilities: {},
233
- clientInfo: { name: 'wiki-manager', version: '0.8.2' },
235
+ clientInfo: { name: 'wiki-manager', version: WIKI_MANAGER_VERSION },
234
236
  },
235
237
  }),
236
238
  });
package/src/core/plan.js CHANGED
@@ -15,6 +15,10 @@ export function ensurePlanFromActivity(session, activity) {
15
15
  id: s.id ?? null,
16
16
  description: s.label,
17
17
  status: 'pending',
18
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn.map(String) : [],
19
+ executor: s.executor ?? null,
20
+ executorQuery: s.executorQuery ?? null,
21
+ outputRefs: Array.isArray(s.outputRefs) ? s.outputRefs.map(String) : [],
18
22
  owner: 'activity',
19
23
  ownerActivityKey: activity.key,
20
24
  _activityKey: activity.key,