@yeaft/webchat-agent 1.0.184 → 1.0.186

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.184",
3
+ "version": "1.0.186",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -71,12 +71,19 @@ export function selectWorkItemVp({ policy: rawPolicy, stageType, vps, priorRuns
71
71
  return { vp: fixed, reason: `fixed:${fixed.id}`, policy };
72
72
  }
73
73
 
74
- const pool = policy.mode === 'pool'
74
+ const pool = ['pool', 'planned'].includes(policy.mode)
75
75
  ? policy.candidateVpIds.map(id => byId.get(id)).filter(Boolean)
76
76
  : all;
77
77
  if (pool.length === 0) throw policyError('No configured Work Center VP candidates are available');
78
78
  const eligible = pool.filter(vp => !excluded.has(vp.id));
79
79
  if (eligible.length === 0) throw policyError('No Work Center VP satisfies the stage separation policy');
80
+ if (policy.mode === 'planned') {
81
+ return {
82
+ vp: eligible[0],
83
+ reason: `planned:${eligible[0].id}:${policy.assignmentReason || stageType}`,
84
+ policy,
85
+ };
86
+ }
80
87
 
81
88
  const ranked = eligible
82
89
  .map(vp => ({ vp, score: capabilityScore(vp, policy.capability || stageType) }))
@@ -116,6 +116,7 @@ async function createDefaultService() {
116
116
  yeaftDir,
117
117
  runner,
118
118
  runtimeInfoProvider: getSettingsRuntime,
119
+ listAvailableVpIds: () => defaultRegistry.listVps().map(vp => vp.id),
119
120
  watcherOptions: {
120
121
  concurrencyProvider: () => readWorkCenterSettings(yeaftDir).maxConcurrentActions,
121
122
  },
@@ -8,6 +8,7 @@ import {
8
8
  } from './workflow.js';
9
9
  import { renderSessionContextSnapshot } from './session-context.js';
10
10
  import { normalizeEvidence } from './evidence.js';
11
+ import { applyAdditivePlanProposal } from './plan-mutation.js';
11
12
 
12
13
  function normalizeCriteria(value) {
13
14
  if (!Array.isArray(value)) return null;
@@ -92,6 +93,10 @@ function normalizeTerminalResult(result, action) {
92
93
  acceptanceChecks: Array.isArray(result.acceptanceChecks) ? result.acceptanceChecks : [],
93
94
  checkpoint: result.checkpoint && typeof result.checkpoint === 'object'
94
95
  ? result.checkpoint : null,
96
+ planProposal: result.planProposal && typeof result.planProposal === 'object'
97
+ && !Array.isArray(result.planProposal) ? result.planProposal : null,
98
+ replanRequest: result.replanRequest && typeof result.replanRequest === 'object'
99
+ && !Array.isArray(result.replanRequest) ? result.replanRequest : null,
95
100
  };
96
101
  if (normalized.outcome === 'waiting' && !normalized.waitingReason) {
97
102
  throw new Error('waiting outcome requires waitingReason');
@@ -101,6 +106,16 @@ function normalizeTerminalResult(result, action) {
101
106
  normalized.error = 'Only triage may submit a WorkItem contractPatch';
102
107
  normalized.contractPatch = null;
103
108
  }
109
+ if (normalized.outcome !== 'completed') {
110
+ normalized.planProposal = null;
111
+ normalized.replanRequest = null;
112
+ }
113
+ if (normalized.planProposal && normalized.replanRequest) {
114
+ normalized.outcome = 'failed';
115
+ normalized.error = 'An Action cannot expand and replan the WorkItem in the same completion';
116
+ normalized.planProposal = null;
117
+ normalized.replanRequest = null;
118
+ }
104
119
  if (action.type === 'review' && normalized.outcome === 'completed' && !normalized.reviewDecision) {
105
120
  normalized.outcome = 'failed';
106
121
  normalized.error = 'Completed review requires approved or changes_requested';
@@ -121,8 +136,11 @@ function contextEntry(action, result, run) {
121
136
  }
122
137
 
123
138
  export class WorkflowController {
124
- constructor(store) {
139
+ constructor(store, options = {}) {
125
140
  this.store = store;
141
+ this.listAvailableVpIds = typeof options.listAvailableVpIds === 'function'
142
+ ? options.listAvailableVpIds
143
+ : null;
126
144
  }
127
145
 
128
146
  create(input) {
@@ -331,12 +349,45 @@ export class WorkflowController {
331
349
  }
332
350
  : current;
333
351
  try {
334
- validatedGeneratedWorkflow = applyGeneratedPlan(effective, result.plan);
352
+ const reservedStageIds = activeAction.stageId?.startsWith('replan-')
353
+ ? this.store.getWorkItemDetail(activeWorkItem.id).actions.map(action => action.stageId)
354
+ : [];
355
+ validatedGeneratedWorkflow = applyGeneratedPlan(effective, result.plan, {
356
+ availableVpIds: this.listAvailableVpIds?.(),
357
+ reservedStageIds,
358
+ });
359
+ } catch (error) {
360
+ result.outcome = 'failed';
361
+ result.error = error?.message || String(error);
362
+ }
363
+ }
364
+ let validatedPlanProposal = null;
365
+ if (result.outcome === 'completed' && result.planProposal) {
366
+ try {
367
+ validatedPlanProposal = applyAdditivePlanProposal({
368
+ workItem: activeWorkItem,
369
+ actions: this.store.getWorkItemDetail(activeWorkItem.id).actions,
370
+ proposal: result.planProposal,
371
+ availableVpIds: this.listAvailableVpIds?.(),
372
+ });
335
373
  } catch (error) {
336
374
  result.outcome = 'failed';
337
375
  result.error = error?.message || String(error);
338
376
  }
339
377
  }
378
+ if (result.outcome === 'completed' && result.replanRequest) {
379
+ const basePlanRevision = Number(result.replanRequest.basePlanRevision);
380
+ const proposalId = typeof result.replanRequest.proposalId === 'string'
381
+ ? result.replanRequest.proposalId.trim().slice(0, 128) : '';
382
+ const reason = typeof result.replanRequest.reason === 'string'
383
+ ? result.replanRequest.reason.trim().slice(0, 4_000) : '';
384
+ if (!proposalId || !reason || basePlanRevision !== activeWorkItem.planRevision) {
385
+ result.outcome = 'failed';
386
+ result.error = 'Work Center replan request is missing fields or has a stale basePlanRevision';
387
+ } else {
388
+ result.replanRequest = { proposalId, reason, basePlanRevision };
389
+ }
390
+ }
340
391
  const detail = this.store.finalizeRun(
341
392
  runId,
342
393
  ownerBootId,
@@ -394,11 +445,40 @@ export class WorkflowController {
394
445
  && workItem.workflowSnapshot?.planningMode === 'ai'
395
446
  ? validatedGeneratedWorkflow
396
447
  : null;
448
+ const planProposal = workItem.planRevision === activeWorkItem.planRevision
449
+ ? validatedPlanProposal
450
+ : null;
451
+ if (validatedPlanProposal && !planProposal) {
452
+ throw new Error('Work Center plan revision changed before finalization');
453
+ }
397
454
  const plannedWorkItem = generatedWorkflow
398
455
  ? { ...effectiveWorkItem, workflowSnapshot: generatedWorkflow }
399
- : effectiveWorkItem;
456
+ : planProposal
457
+ ? { ...effectiveWorkItem, workflowSnapshot: planProposal.workflowSnapshot }
458
+ : effectiveWorkItem;
400
459
  const context = [...(action.context || []), contextEntry(action, result, activeRun)];
401
460
  if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
461
+ if (result.replanRequest) {
462
+ const replanStage = {
463
+ ...plannedWorkItem.workflowSnapshot.stages[0],
464
+ id: `replan-${workItem.planRevision + 1}`,
465
+ name: 'Replan',
466
+ type: 'triage',
467
+ objective: 'Replan the unfinished WorkItem graph without changing completed history.',
468
+ approach: `Inspect completed evidence and this replan reason: ${result.replanRequest.reason}`,
469
+ expectedOutcome: 'A validated replacement plan for all unfinished work.',
470
+ dependsOnStageIds: [],
471
+ };
472
+ return {
473
+ actionStatus: 'completed', workItemStatus: 'ready', graphAdvance: true,
474
+ replanBarrier: {
475
+ ...result.replanRequest,
476
+ action: actionForStage(replanStage, plannedWorkItem, context),
477
+ },
478
+ eventType: 'workflow.replan_requested',
479
+ eventData: { reason: result.replanRequest.reason },
480
+ };
481
+ }
402
482
  if (action.type === 'review' && result.reviewDecision === 'changes_requested') {
403
483
  const targetStage = plannedWorkItem.workflowSnapshot.stages
404
484
  .find(stage => stage.id === action.changesRequestedStageId);
@@ -413,15 +493,19 @@ export class WorkflowController {
413
493
  }
414
494
  const nextActions = generatedWorkflow
415
495
  ? generatedWorkflow.stages.slice(1).map(stage => actionForStage(stage, plannedWorkItem, context))
416
- : [];
496
+ : (planProposal?.nextActions || []);
497
+ const workflowSnapshot = generatedWorkflow || planProposal?.workflowSnapshot || null;
417
498
  return {
418
499
  actionStatus: 'completed',
419
500
  workItemStatus: nextActions.length > 0 ? 'ready' : 'running',
420
501
  contractPatch,
421
- workflowSnapshot: generatedWorkflow,
502
+ workflowSnapshot,
503
+ expectedPlanRevision: generatedWorkflow ? workItem.planRevision : planProposal?.basePlanRevision,
504
+ proposalId: generatedWorkflow ? `initial:${runId}` : planProposal?.proposalId,
505
+ dependencyPatches: planProposal?.dependencyPatches || [],
422
506
  nextActions,
423
507
  graphAdvance: true,
424
- eventType: 'action.completed',
508
+ eventType: planProposal ? 'workflow.plan_expanded' : 'action.completed',
425
509
  eventData: { nextActionCount: nextActions.length, reviewDecision: result.reviewDecision },
426
510
  };
427
511
  }
@@ -433,6 +517,8 @@ export class WorkflowController {
433
517
  workItemStatus: 'done',
434
518
  contractPatch,
435
519
  workflowSnapshot: generatedWorkflow,
520
+ expectedPlanRevision: generatedWorkflow ? workItem.planRevision : undefined,
521
+ proposalId: generatedWorkflow ? `initial:${runId}` : undefined,
436
522
  eventType: 'work_item.completed',
437
523
  eventData: { summary: result.summary },
438
524
  };
@@ -443,6 +529,8 @@ export class WorkflowController {
443
529
  workItemStatus: 'ready',
444
530
  contractPatch,
445
531
  workflowSnapshot: generatedWorkflow,
532
+ expectedPlanRevision: generatedWorkflow ? workItem.planRevision : undefined,
533
+ proposalId: generatedWorkflow ? `initial:${runId}` : undefined,
446
534
  nextAction: actionForStage(nextStep, plannedWorkItem, context),
447
535
  eventType: 'action.completed',
448
536
  eventData: {
@@ -0,0 +1,185 @@
1
+ import {
2
+ actionForStage,
3
+ applyGeneratedPlan,
4
+ canonicalActionId,
5
+ canonicalExplicitActionId,
6
+ canonicalExplicitActionIds,
7
+ } from './workflow.js';
8
+
9
+ function cleanProposalId(value) {
10
+ const id = typeof value === 'string' ? value.trim().slice(0, 128) : '';
11
+ if (!id) throw new Error('Work Center plan proposal requires proposalId');
12
+ return id;
13
+ }
14
+
15
+ function planActionFromStage(stage) {
16
+ return {
17
+ id: stage.id,
18
+ name: stage.name,
19
+ type: stage.type,
20
+ objective: stage.objective,
21
+ approach: stage.approach,
22
+ expectedOutcome: stage.expectedOutcome,
23
+ capability: stage.assignmentPolicy?.capability,
24
+ candidateVpIds: stage.assignmentPolicy?.mode === 'planned'
25
+ ? stage.assignmentPolicy.candidateVpIds
26
+ : undefined,
27
+ assignmentReason: stage.assignmentPolicy?.assignmentReason,
28
+ separateFromActionTypes: stage.assignmentPolicy?.separateFromStageTypes,
29
+ dependsOnActionIds: stage.dependsOnStageIds || [],
30
+ workspaceMode: stage.workspaceMode,
31
+ changesRequestedActionId: stage.changesRequestedStageId,
32
+ maxAttempts: stage.maxAttempts,
33
+ };
34
+ }
35
+
36
+ function stableTopologicalActions(actions) {
37
+ const byId = new Map(actions.map((action, index) => [action.id, { action, index }]));
38
+ const incoming = new Map(actions.map(action => [action.id, 0]));
39
+ const outgoing = new Map(actions.map(action => [action.id, []]));
40
+ for (const action of actions) {
41
+ const orderingDependencies = new Set([
42
+ ...(action.dependsOnActionIds || []),
43
+ ...(action.changesRequestedActionId ? [action.changesRequestedActionId] : []),
44
+ ]);
45
+ for (const dependencyId of orderingDependencies) {
46
+ if (!byId.has(dependencyId) || dependencyId === action.id) {
47
+ throw new Error(`Work Center additive Action "${action.id}" has an invalid dependency "${dependencyId}"`);
48
+ }
49
+ incoming.set(action.id, incoming.get(action.id) + 1);
50
+ outgoing.get(dependencyId).push(action.id);
51
+ }
52
+ }
53
+ const ready = actions.filter(action => incoming.get(action.id) === 0)
54
+ .sort((left, right) => byId.get(left.id).index - byId.get(right.id).index);
55
+ const ordered = [];
56
+ while (ready.length > 0) {
57
+ const action = ready.shift();
58
+ ordered.push(action);
59
+ for (const dependentId of outgoing.get(action.id)) {
60
+ const count = incoming.get(dependentId) - 1;
61
+ incoming.set(dependentId, count);
62
+ if (count === 0) {
63
+ ready.push(byId.get(dependentId).action);
64
+ ready.sort((left, right) => byId.get(left.id).index - byId.get(right.id).index);
65
+ }
66
+ }
67
+ }
68
+ if (ordered.length !== actions.length) {
69
+ throw new Error('Work Center additive plan contains a dependency cycle');
70
+ }
71
+ return ordered;
72
+ }
73
+
74
+ function validateDependencyPatches(actions, patches, addedIds) {
75
+ const active = new Map(actions
76
+ .filter(action => !['superseded', 'cancelled'].includes(action.status))
77
+ .map(action => [action.stageId, action]));
78
+ const normalized = [];
79
+ const patchedActionIds = new Set();
80
+ for (const raw of Array.isArray(patches) ? patches : []) {
81
+ const actionId = typeof raw?.actionId === 'string' ? raw.actionId.trim() : '';
82
+ const action = actions.find(candidate => candidate.id === actionId);
83
+ if (!action || action.status !== 'ready' || action.attempt !== 0) {
84
+ throw new Error(`Work Center dependency patch target must be an unattempted ready Action: ${actionId || '(missing)'}`);
85
+ }
86
+ if (patchedActionIds.has(action.id)) {
87
+ throw new Error(`Work Center dependency patch target is duplicated: ${action.id}`);
88
+ }
89
+ patchedActionIds.add(action.id);
90
+ const addDependsOnActionIds = canonicalExplicitActionIds(
91
+ raw.addDependsOnActionIds,
92
+ 'dependency patch',
93
+ );
94
+ if (addDependsOnActionIds.length === 0) throw new Error('Work Center dependency patch must add at least one dependency');
95
+ for (const dependencyId of addDependsOnActionIds) {
96
+ if (!active.has(dependencyId) && !addedIds.has(dependencyId)) {
97
+ throw new Error(`Work Center dependency patch references missing Action: ${dependencyId}`);
98
+ }
99
+ if (dependencyId === action.stageId) throw new Error('Work Center Action cannot depend on itself');
100
+ }
101
+ normalized.push({ action, addDependsOnActionIds });
102
+ }
103
+ return normalized;
104
+ }
105
+
106
+ export function applyAdditivePlanProposal({ workItem, actions, proposal, availableVpIds = null }) {
107
+ if (workItem.workflowSnapshot?.executionMode !== 'graph') {
108
+ throw new Error('Work Center additive planning requires graph execution');
109
+ }
110
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
111
+ throw new Error('Work Center plan proposal must be an object');
112
+ }
113
+ const proposalId = cleanProposalId(proposal.proposalId);
114
+ const basePlanRevision = Number(proposal.basePlanRevision);
115
+ if (!Number.isInteger(basePlanRevision) || basePlanRevision !== workItem.planRevision) {
116
+ throw new Error('Work Center plan proposal has a stale basePlanRevision');
117
+ }
118
+ if (!Array.isArray(proposal.actions) || proposal.actions.length < 1 || proposal.actions.length > 8) {
119
+ throw new Error('Work Center additive plan requires between 1 and 8 new Actions');
120
+ }
121
+ const activeStages = workItem.workflowSnapshot.stages || [];
122
+ const existingIds = new Set(activeStages.map(stage => stage.id));
123
+ const addedIds = new Set();
124
+ const canonicalActions = proposal.actions.map(raw => {
125
+ const id = canonicalActionId(raw?.id);
126
+ if (!id || existingIds.has(id) || addedIds.has(id)) {
127
+ throw new Error(`Work Center additive Action id is missing or already exists: ${id || '(missing)'}`);
128
+ }
129
+ addedIds.add(id);
130
+ const hasReturnTarget = Object.hasOwn(raw, 'changesRequestedActionId');
131
+ return {
132
+ ...raw,
133
+ id,
134
+ dependsOnActionIds: canonicalExplicitActionIds(
135
+ raw.dependsOnActionIds,
136
+ `Action "${id}" dependencies`,
137
+ ),
138
+ changesRequestedActionId: hasReturnTarget
139
+ ? canonicalExplicitActionId(
140
+ raw.changesRequestedActionId,
141
+ `Action "${id}" review target`,
142
+ )
143
+ : undefined,
144
+ };
145
+ });
146
+ const dependencyPatches = validateDependencyPatches(actions, proposal.dependencyPatches, addedIds);
147
+ const synthetic = {
148
+ ...workItem,
149
+ workflowSnapshot: {
150
+ ...workItem.workflowSnapshot,
151
+ actionTemplates: [],
152
+ workItemType: workItem.workflowSnapshot.workItemType,
153
+ stages: [workItem.workflowSnapshot.stages[0]],
154
+ },
155
+ };
156
+ const patchByStageId = new Map(dependencyPatches.map(patch => [patch.action.stageId, patch]));
157
+ const mergedActions = [
158
+ ...activeStages.slice(1).map(stage => {
159
+ const action = planActionFromStage(stage);
160
+ const patch = patchByStageId.get(stage.id);
161
+ if (!patch) return action;
162
+ return {
163
+ ...action,
164
+ dependsOnActionIds: [...new Set([
165
+ ...(action.dependsOnActionIds || []),
166
+ ...patch.addDependsOnActionIds,
167
+ ])],
168
+ };
169
+ }),
170
+ ...canonicalActions,
171
+ ];
172
+ const rawPlan = {
173
+ workItemType: workItem.workflowSnapshot.workItemType,
174
+ actions: stableTopologicalActions(mergedActions),
175
+ };
176
+ const workflowSnapshot = applyGeneratedPlan(synthetic, rawPlan, { availableVpIds });
177
+ const addedStages = workflowSnapshot.stages.filter(stage => addedIds.has(stage.id));
178
+ return {
179
+ proposalId,
180
+ basePlanRevision,
181
+ workflowSnapshot,
182
+ nextActions: addedStages.map(stage => actionForStage(stage, { ...workItem, workflowSnapshot }, [])),
183
+ dependencyPatches,
184
+ };
185
+ }
@@ -1,5 +1,6 @@
1
1
  import { Engine } from '../engine.js';
2
2
  import { ToolRegistry } from '../tools/registry.js';
3
+ import { defineTool } from '../tools/types.js';
3
4
  import { allTools } from '../tools/index.js';
4
5
  import { parsePatch } from '../tools/apply-patch.js';
5
6
  import { defaultRegistry } from '../vp/registry.js';
@@ -30,6 +31,7 @@ import { loadMCPConfig } from '../config.js';
30
31
  import { MCPManager } from '../mcp.js';
31
32
  import { buildMcpFlattenedTools } from '../tools/mcp-tools.js';
32
33
  import { recallWorkspaceSessionContext } from './workspace-context.js';
34
+ import { BUILT_IN_ACTION_TYPES } from './workflow.js';
33
35
 
34
36
  const WORK_ITEM_TOOL_NAMES = Object.freeze([
35
37
  'FileRead',
@@ -280,7 +282,126 @@ function wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunAct
280
282
  };
281
283
  }
282
284
 
283
- export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRunActive, mcpTools = [] }) {
285
+ export function planningVpCatalog(vps) {
286
+ return vps.map(vp => ({
287
+ id: vp.id,
288
+ name: vp.name || vp.id,
289
+ nameZh: vp.nameZh || '',
290
+ role: vp.role || '',
291
+ roleZh: vp.roleZh || '',
292
+ area: vp.area || '',
293
+ traits: Array.isArray(vp.traits) ? vp.traits.slice(0, 20) : [],
294
+ }));
295
+ }
296
+
297
+ export function createSubmitWorkItemPlanTool({ vps, collector, isRunActive }) {
298
+ const vpCatalog = planningVpCatalog(vps);
299
+ const vpIds = vpCatalog.map(vp => vp.id);
300
+ const actionTypes = BUILT_IN_ACTION_TYPES.filter(type => type !== 'triage');
301
+ const catalogDescription = `Action types: ${actionTypes.join(', ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'}; ${vp.traits.join(', ') || 'no traits'})`).join('; ')}.`;
302
+ return defineTool({
303
+ name: 'SubmitWorkItemPlan',
304
+ description: `Submit the complete initial WorkItem contract and executable Action DAG. This tool records a Run-local proposal only; Work Center validates and persists it in the current Run finalization transaction. ${catalogDescription}`,
305
+ parameters: {
306
+ type: 'object',
307
+ additionalProperties: false,
308
+ required: ['summary', 'evidence', 'acceptanceChecks', 'workItemType', 'actions'],
309
+ properties: {
310
+ summary: { type: 'string', minLength: 1, maxLength: 2_000 },
311
+ evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
312
+ acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
313
+ contractPatch: { type: 'object', additionalProperties: false, properties: { goal: { type: 'string', minLength: 1, maxLength: 8_000 }, acceptanceCriteria: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 2_000 } } } },
314
+ workItemType: { type: 'string', minLength: 1, maxLength: 64 },
315
+ actions: { type: 'array', minItems: 1, maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'candidateVpIds', 'assignmentReason', 'dependsOnActionIds', 'workspaceMode'], properties: {
316
+ id: { type: 'string', minLength: 1, maxLength: 64 }, name: { type: 'string', minLength: 1, maxLength: 120 },
317
+ type: { type: 'string', enum: actionTypes }, capability: { type: 'string', maxLength: 64 },
318
+ objective: { type: 'string', minLength: 1, maxLength: 2_000 }, approach: { type: 'string', minLength: 1, maxLength: 2_000 }, expectedOutcome: { type: 'string', minLength: 1, maxLength: 2_000 },
319
+ candidateVpIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: vpIds } }, assignmentReason: { type: 'string', minLength: 1, maxLength: 1_000 },
320
+ dependsOnActionIds: { type: 'array', uniqueItems: true, items: { type: 'string' } }, workspaceMode: { type: 'string', enum: ['read', 'isolated-write', 'integrate', 'shared'] },
321
+ separateFromActionTypes: { type: 'array', uniqueItems: true, items: { type: 'string' } }, changesRequestedActionId: { type: 'string' }, maxAttempts: { type: 'integer', minimum: 1, maximum: 5 },
322
+ } } },
323
+ },
324
+ },
325
+ async execute(input, ctx = {}) {
326
+ if (!isRunActive()) throw new Error('Work Center Run is no longer active');
327
+ if (collector.value) throw new Error('WorkItem plan was already submitted for this Run');
328
+ collector.value = structuredClone(input);
329
+ ctx.requestEndTurn?.({ kind: 'work_item_plan_submitted' });
330
+ return JSON.stringify({ submitted: true, actionCount: input.actions.length });
331
+ },
332
+ isConcurrencySafe: () => false,
333
+ isReadOnly: () => false,
334
+ });
335
+ }
336
+
337
+ function terminalPlanningFields() {
338
+ return {
339
+ summary: { type: 'string', minLength: 1, maxLength: 2_000 },
340
+ evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
341
+ acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
342
+ };
343
+ }
344
+
345
+ function plannedActionSchema(vpIds, { requireCandidates = true } = {}) {
346
+ const required = ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'dependsOnActionIds', 'workspaceMode'];
347
+ if (requireCandidates) required.push('candidateVpIds', 'assignmentReason');
348
+ return { type: 'object', additionalProperties: false, required, properties: {
349
+ id: { type: 'string', minLength: 1, maxLength: 64 }, name: { type: 'string', minLength: 1, maxLength: 120 },
350
+ type: { type: 'string', enum: BUILT_IN_ACTION_TYPES.filter(type => type !== 'triage') }, capability: { type: 'string', maxLength: 64 },
351
+ objective: { type: 'string', minLength: 1, maxLength: 2_000 }, approach: { type: 'string', minLength: 1, maxLength: 2_000 }, expectedOutcome: { type: 'string', minLength: 1, maxLength: 2_000 },
352
+ candidateVpIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: vpIds } }, assignmentReason: { type: 'string', minLength: 1, maxLength: 1_000 },
353
+ dependsOnActionIds: { type: 'array', uniqueItems: true, items: { type: 'string' } }, workspaceMode: { type: 'string', enum: ['read', 'isolated-write', 'integrate', 'shared'] },
354
+ separateFromActionTypes: { type: 'array', uniqueItems: true, items: { type: 'string' } }, changesRequestedActionId: { type: 'string' }, maxAttempts: { type: 'integer', minimum: 1, maximum: 5 },
355
+ } };
356
+ }
357
+
358
+ export function createProposeWorkItemActionsTool({ vps, workItem, actions, collector, isRunActive }) {
359
+ const vpCatalog = planningVpCatalog(vps);
360
+ const vpIds = vpCatalog.map(vp => vp.id);
361
+ const existing = actions.filter(action => !['superseded', 'cancelled'].includes(action.status));
362
+ return defineTool({
363
+ name: 'ProposeWorkItemActions',
364
+ description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Existing Actions: ${existing.map(action => `${action.id}/${action.stageId} (${action.status}, attempt ${action.attempt})`).join('; ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'})`).join('; ')}. Only add new Actions and optionally add dependencies to attempt=0 ready Actions.`,
365
+ parameters: { type: 'object', additionalProperties: false,
366
+ required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'actions'],
367
+ properties: {
368
+ ...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
369
+ basePlanRevision: { type: 'integer', const: workItem.planRevision },
370
+ actions: { type: 'array', minItems: 1, maxItems: 8, items: plannedActionSchema(vpIds) },
371
+ dependencyPatches: { type: 'array', maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['actionId', 'addDependsOnActionIds'], properties: { actionId: { type: 'string', enum: existing.filter(action => action.status === 'ready' && action.attempt === 0).map(action => action.id) }, addDependsOnActionIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string' } } } } },
372
+ } },
373
+ async execute(input, ctx = {}) {
374
+ if (!isRunActive()) throw new Error('Work Center Run is no longer active');
375
+ if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
376
+ collector.value = { kind: 'expand', input: structuredClone(input) };
377
+ ctx.requestEndTurn?.({ kind: 'work_item_actions_proposed', proposalId: input.proposalId });
378
+ return JSON.stringify({ submitted: true, proposalId: input.proposalId, actionCount: input.actions.length });
379
+ },
380
+ });
381
+ }
382
+
383
+ export function createRequestWorkItemReplanTool({ workItem, collector, isRunActive }) {
384
+ return defineTool({
385
+ name: 'RequestWorkItemReplan',
386
+ description: 'Request an explicit replan barrier when additive Actions are insufficient because the contract or existing future topology must change. The current Action must still complete. Work Center will preserve completed history, fence sibling Runs, supersede only unfinished Actions, and insert a new triage/replan Action. Active integration finalization prevents the barrier.',
387
+ parameters: { type: 'object', additionalProperties: false,
388
+ required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'reason'],
389
+ properties: {
390
+ ...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
391
+ basePlanRevision: { type: 'integer', const: workItem.planRevision },
392
+ reason: { type: 'string', minLength: 1, maxLength: 4_000 },
393
+ } },
394
+ async execute(input, ctx = {}) {
395
+ if (!isRunActive()) throw new Error('Work Center Run is no longer active');
396
+ if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
397
+ collector.value = { kind: 'replan', input: structuredClone(input) };
398
+ ctx.requestEndTurn?.({ kind: 'work_item_replan_requested', proposalId: input.proposalId });
399
+ return JSON.stringify({ submitted: true, proposalId: input.proposalId });
400
+ },
401
+ });
402
+ }
403
+
404
+ export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRunActive, mcpTools = [], runTools = [] }) {
284
405
  const canonicalDir = canonicalWorkDir(path.resolve(workDir));
285
406
  const canonicalAttachmentFiles = attachmentFiles.map(file => ({
286
407
  ...file,
@@ -296,6 +417,9 @@ export function createWorkItemToolRegistry({ workDir, attachmentFiles = [], isRu
296
417
  if (!tool?.name?.startsWith('mcp__')) continue;
297
418
  registry.register(wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunActive));
298
419
  }
420
+ for (const tool of runTools) {
421
+ registry.register(wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunActive));
422
+ }
299
423
  return registry;
300
424
  }
301
425
 
@@ -347,12 +471,17 @@ function completionContract(action, workItem) {
347
471
  const planField = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
348
472
  ? ',\n "plan": { "workItemType": "specific-lowercase-slug", "actions": [{ "id": "stable-id", "name": "User-facing name", "type": "extensible-lowercase-slug (built-ins include research|design|diagnose|implement|migrate|test|review|document|operate|deliver|integrate|write|custom)", "capability": "specific executor capability", "objective": "task-specific concrete work this Action must do", "approach": "task-specific repository-aware method the executor must follow", "expectedOutcome": "task-specific verifiable result this Action must produce", "dependsOnActionIds": ["earlier Action id; [] means concurrent root"], "workspaceMode": "read|isolated-write|integrate|shared", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
349
473
  : '';
474
+ const toolSubmission = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
475
+ ? '\nSubmit the initial plan with SubmitWorkItemPlan. The legacy terminal JSON plan below exists only for compatibility; do not use it when the tool is available.'
476
+ : workItem?.workflowSnapshot?.executionMode === 'graph'
477
+ ? '\nIf execution discovered strictly additive work, use ProposeWorkItemActions. If the contract or existing unfinished topology must change, use RequestWorkItemReplan. Both tools submit the completed Action and end the turn; do not emit terminal JSON after calling one.'
478
+ : '';
350
479
  const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
351
480
  criterion,
352
481
  status: 'passed|deferred|not_applicable',
353
482
  evidence: 'specific evidence reference',
354
483
  }));
355
- return `\n\nYou are executing one Work Center Action. Before the terminal JSON, write a concise user-facing response describing what you did and the result. Do not include raw tool output or secrets. End your response with exactly one JSON object, preferably in a json code fence:\n{
484
+ return `${toolSubmission}\n\nYou are executing one Work Center Action. Before the terminal JSON, write a concise user-facing response describing what you did and the result. Do not include raw tool output or secrets. End your response with exactly one JSON object, preferably in a json code fence:\n{
356
485
  "outcome": "completed|waiting|retryable|failed",
357
486
  "summary": "short result",
358
487
  "evidence": ["test, PR, file, or other verifiable evidence"],
@@ -693,16 +822,34 @@ export class WorkItemRunner {
693
822
  && this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
694
823
  const workspaceRuntime = await this.#workspaceRuntime(workspaceDir, workDir, isRunActive);
695
824
  const mcpToolNames = workspaceRuntime.mcpTools.map(tool => tool.name);
825
+ const planCollector = { value: null };
826
+ const mutationCollector = { value: null };
827
+ const planToolEnabled = executionAction.type === 'triage'
828
+ && workItem?.workflowSnapshot?.planningMode === 'ai';
829
+ const runTools = [];
830
+ if (planToolEnabled) runTools.push(createSubmitWorkItemPlanTool({
831
+ vps: this.registry.listVps(), collector: planCollector, isRunActive,
832
+ }));
833
+ if (!planToolEnabled && workItem?.workflowSnapshot?.executionMode === 'graph') {
834
+ runTools.push(createProposeWorkItemActionsTool({
835
+ vps: this.registry.listVps(), workItem,
836
+ actions: this.store.getWorkItemDetail(workItem.id).actions,
837
+ collector: mutationCollector, isRunActive,
838
+ }));
839
+ runTools.push(createRequestWorkItemReplanTool({ workItem, collector: mutationCollector, isRunActive }));
840
+ }
841
+ const runToolNames = runTools.map(tool => tool.name);
696
842
  const toolPolicySnapshot = workItemToolPolicySnapshot(
697
843
  workDir,
698
844
  attachmentContext.files.map(file => file.ref),
699
- mcpToolNames,
845
+ [...mcpToolNames, ...runToolNames],
700
846
  );
701
847
  const toolRegistry = createWorkItemToolRegistry({
702
848
  workDir,
703
849
  attachmentFiles: attachmentContext.files,
704
850
  isRunActive,
705
851
  mcpTools: workspaceRuntime.mcpTools,
852
+ runTools,
706
853
  });
707
854
  const config = {
708
855
  ...runtime.config,
@@ -844,7 +991,36 @@ export class WorkItemRunner {
844
991
  }
845
992
  const response = publicWorkItemResponse(text);
846
993
  reportProgress(true);
847
- const parsedResult = parseStructuredResult(text, executionAction.type);
994
+ const submittedPlan = planCollector.value;
995
+ const submittedExpansion = mutationCollector.value?.kind === 'expand'
996
+ ? mutationCollector.value.input : null;
997
+ const submittedReplan = mutationCollector.value?.kind === 'replan'
998
+ ? mutationCollector.value.input : null;
999
+ const parsedResult = submittedPlan ? {
1000
+ outcome: 'completed',
1001
+ summary: submittedPlan.summary,
1002
+ evidence: submittedPlan.evidence,
1003
+ contractPatch: submittedPlan.contractPatch || null,
1004
+ plan: { workItemType: submittedPlan.workItemType, actions: submittedPlan.actions },
1005
+ acceptanceChecks: submittedPlan.acceptanceChecks,
1006
+ } : submittedExpansion ? {
1007
+ outcome: 'completed', summary: submittedExpansion.summary,
1008
+ evidence: submittedExpansion.evidence, acceptanceChecks: submittedExpansion.acceptanceChecks,
1009
+ planProposal: {
1010
+ proposalId: submittedExpansion.proposalId,
1011
+ basePlanRevision: submittedExpansion.basePlanRevision,
1012
+ actions: submittedExpansion.actions,
1013
+ dependencyPatches: submittedExpansion.dependencyPatches || [],
1014
+ },
1015
+ } : submittedReplan ? {
1016
+ outcome: 'completed', summary: submittedReplan.summary,
1017
+ evidence: submittedReplan.evidence, acceptanceChecks: submittedReplan.acceptanceChecks,
1018
+ replanRequest: {
1019
+ proposalId: submittedReplan.proposalId,
1020
+ basePlanRevision: submittedReplan.basePlanRevision,
1021
+ reason: submittedReplan.reason,
1022
+ },
1023
+ } : parseStructuredResult(text, executionAction.type);
848
1024
  if (parsedResult.outcome === 'completed' && action.workspace?.isolated) {
849
1025
  const workspace = commitActionWorktree(action.workspace, action);
850
1026
  this.store.setActionWorkspace(action.id, workspace);
@@ -64,7 +64,9 @@ export class WorkCenterService {
64
64
  this.ownerBootId = options.ownerBootId || randomUUID();
65
65
  this.attachmentRoot = options.attachmentRoot || join(yeaftDir, 'work-center', 'attachments');
66
66
  this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
67
- this.controller = options.controller || new WorkflowController(this.store);
67
+ this.controller = options.controller || new WorkflowController(this.store, {
68
+ listAvailableVpIds: options.listAvailableVpIds,
69
+ });
68
70
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
69
71
  this.watcher = new WorkItemWatcher({
70
72
  store: this.store,
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
7
 
8
- const SCHEMA_VERSION = 11;
8
+ const SCHEMA_VERSION = 12;
9
9
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
10
10
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
11
11
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -34,6 +34,7 @@ function mapWorkItem(row) {
34
34
  return {
35
35
  id: row.id,
36
36
  revision: row.revision,
37
+ planRevision: Math.max(0, Number(row.plan_revision) || 0),
37
38
  title: row.title,
38
39
  goal: row.goal,
39
40
  acceptanceCriteria: parseJson(row.acceptance_criteria, []),
@@ -181,6 +182,7 @@ export class WorkItemStore {
181
182
  CREATE TABLE IF NOT EXISTS work_items (
182
183
  id TEXT PRIMARY KEY,
183
184
  revision INTEGER NOT NULL DEFAULT 1,
185
+ plan_revision INTEGER NOT NULL DEFAULT 0,
184
186
  title TEXT NOT NULL,
185
187
  goal TEXT NOT NULL,
186
188
  acceptance_criteria TEXT NOT NULL,
@@ -266,6 +268,19 @@ export class WorkItemStore {
266
268
  data TEXT NOT NULL,
267
269
  created_at INTEGER NOT NULL
268
270
  );
271
+ CREATE TABLE IF NOT EXISTS plan_audits (
272
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
273
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
274
+ proposal_id TEXT NOT NULL,
275
+ base_plan_revision INTEGER NOT NULL,
276
+ plan_revision INTEGER NOT NULL,
277
+ kind TEXT NOT NULL,
278
+ action_id TEXT NOT NULL,
279
+ run_id TEXT NOT NULL,
280
+ data TEXT NOT NULL,
281
+ created_at INTEGER NOT NULL,
282
+ UNIQUE(work_item_id, proposal_id)
283
+ );
269
284
  CREATE INDEX IF NOT EXISTS idx_work_items_status_updated ON work_items(status, updated_at DESC);
270
285
  CREATE INDEX IF NOT EXISTS idx_actions_ready ON actions(status, updated_at, sequence);
271
286
  CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, expires_at);
@@ -362,6 +377,9 @@ export class WorkItemStore {
362
377
  if (!hasColumn(this.db, 'actions', 'workspace')) {
363
378
  this.db.exec('ALTER TABLE actions ADD COLUMN workspace TEXT');
364
379
  }
380
+ if (!hasColumn(this.db, 'work_items', 'plan_revision')) {
381
+ this.db.exec('ALTER TABLE work_items ADD COLUMN plan_revision INTEGER NOT NULL DEFAULT 0');
382
+ }
365
383
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
366
384
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
367
385
  }
@@ -1322,17 +1340,76 @@ export class WorkItemStore {
1322
1340
  nextWorkItem = this.getWorkItem(workItem.id);
1323
1341
  }
1324
1342
  if (transition.workflowSnapshot) {
1325
- this.db.prepare(`UPDATE work_items SET workflow_template = ?, workflow_snapshot = ?, updated_at = ?
1326
- WHERE id = ?`).run(
1343
+ const expectedPlanRevision = Number.isInteger(transition.expectedPlanRevision)
1344
+ ? transition.expectedPlanRevision
1345
+ : workItem.planRevision;
1346
+ const changedPlan = this.db.prepare(`UPDATE work_items SET workflow_template = ?, workflow_snapshot = ?,
1347
+ plan_revision = plan_revision + 1, updated_at = ? WHERE id = ? AND plan_revision = ?`).run(
1327
1348
  transition.workflowSnapshot.id,
1328
1349
  stringify(transition.workflowSnapshot),
1329
1350
  now,
1330
1351
  workItem.id,
1352
+ expectedPlanRevision,
1331
1353
  );
1354
+ if (Number(changedPlan.changes) !== 1) {
1355
+ throw new Error('Work Center plan revision changed before finalization');
1356
+ }
1332
1357
  nextWorkItem = this.getWorkItem(workItem.id);
1333
1358
  }
1334
1359
 
1360
+ for (const patch of Array.isArray(transition.dependencyPatches) ? transition.dependencyPatches : []) {
1361
+ const actionPatch = patch.action;
1362
+ const dependencies = [...new Set([
1363
+ ...(actionPatch.dependsOnStageIds || []),
1364
+ ...(patch.addDependsOnActionIds || []),
1365
+ ])];
1366
+ const changed = this.db.prepare(`UPDATE actions SET depends_on_stage_ids = ?, updated_at = ?
1367
+ WHERE id = ? AND work_item_id = ? AND status = 'ready' AND attempt = 0 AND current_run_id IS NULL`).run(
1368
+ stringify(dependencies), now, actionPatch.id, workItem.id,
1369
+ );
1370
+ if (Number(changed.changes) !== 1) {
1371
+ throw new Error('Work Center dependency patch lost its unattempted Action fence');
1372
+ }
1373
+ }
1374
+
1335
1375
  let nextAction = null;
1376
+ if (transition.replanBarrier) {
1377
+ const barrier = transition.replanBarrier;
1378
+ if (barrier.basePlanRevision !== workItem.planRevision) {
1379
+ throw new Error('Work Center replan request has a stale basePlanRevision');
1380
+ }
1381
+ const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
1382
+ AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItem.id).map(mapAction);
1383
+ this.#assertNoIntegrationReservation(activeActions, now);
1384
+ const unfinished = activeActions.filter(candidate => candidate.id !== action.id && candidate.status !== 'completed');
1385
+ for (const candidate of unfinished) {
1386
+ if (candidate.status === 'running' && candidate.currentRunId) {
1387
+ this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
1388
+ WHERE id = ? AND status = 'running'`).run(now, 'Superseded by Work Center replan barrier', candidate.currentRunId);
1389
+ }
1390
+ this.db.prepare(`UPDATE actions SET status = 'superseded', current_run_id = NULL,
1391
+ lease_epoch = lease_epoch + 1, updated_at = ? WHERE id = ? AND status != 'completed'`).run(now, candidate.id);
1392
+ }
1393
+ const replanWorkflow = {
1394
+ ...nextWorkItem.workflowSnapshot,
1395
+ stages: [
1396
+ ...(nextWorkItem.workflowSnapshot?.stages || []).filter(stage => activeActions
1397
+ .some(candidate => candidate.stageId === stage.id && candidate.status === 'completed')),
1398
+ { ...(nextWorkItem.workflowSnapshot?.stages?.[0] || {}), id: barrier.action.stageId, type: 'triage', name: 'Replan' },
1399
+ ],
1400
+ };
1401
+ const changedPlan = this.db.prepare(`UPDATE work_items SET workflow_snapshot = ?, plan_revision = plan_revision + 1,
1402
+ updated_at = ? WHERE id = ? AND plan_revision = ?`).run(
1403
+ stringify(replanWorkflow), now, workItem.id, barrier.basePlanRevision,
1404
+ );
1405
+ if (Number(changedPlan.changes) !== 1) throw new Error('Work Center replan barrier lost its plan revision fence');
1406
+ nextWorkItem = this.getWorkItem(workItem.id);
1407
+ nextAction = this.#insertAction(workItem.id, {
1408
+ ...barrier.action,
1409
+ contractRevision: nextWorkItem.revision,
1410
+ status: 'ready',
1411
+ }, this.#nextSequence(workItem.id), now);
1412
+ }
1336
1413
  if (transition.graphResetStageId) {
1337
1414
  nextAction = this.#resetGraphFromStage(
1338
1415
  workItem.id,
@@ -1382,8 +1459,33 @@ export class WorkItemStore {
1382
1459
  if (Number(changedWorkItem.changes) !== 1) {
1383
1460
  throw new Error('Work Center terminal transition lost the current WorkItem fence');
1384
1461
  }
1462
+ if (transition.workflowSnapshot || transition.replanBarrier) {
1463
+ const proposalId = transition.proposalId || transition.replanBarrier?.proposalId;
1464
+ if (!proposalId) throw new Error('Work Center plan mutation requires proposalId');
1465
+ try {
1466
+ this.db.prepare(`INSERT INTO plan_audits
1467
+ (work_item_id, proposal_id, base_plan_revision, plan_revision, kind, action_id, run_id, data, created_at)
1468
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1469
+ workItem.id, proposalId, workItem.planRevision, nextWorkItem.planRevision,
1470
+ transition.replanBarrier ? 'replan' : (workItem.planRevision === 0 ? 'initial' : 'expand'),
1471
+ action.id, runId, stringify(transition.eventData || {}), now,
1472
+ );
1473
+ } catch (error) {
1474
+ if (String(error?.message || '').includes('UNIQUE constraint failed')) {
1475
+ throw new Error(`Work Center plan proposal was already applied: ${proposalId}`);
1476
+ }
1477
+ throw error;
1478
+ }
1479
+ }
1385
1480
  this.onTransitionStep?.('before_event');
1386
- this.appendEvent(workItem.id, transition.eventType, transition.eventData || {}, {
1481
+ this.appendEvent(workItem.id, transition.eventType, {
1482
+ ...(transition.eventData || {}),
1483
+ ...((transition.workflowSnapshot || transition.replanBarrier) ? {
1484
+ proposalId: transition.proposalId || transition.replanBarrier?.proposalId || null,
1485
+ previousPlanRevision: workItem.planRevision,
1486
+ planRevision: nextWorkItem.planRevision,
1487
+ } : {}),
1488
+ }, {
1387
1489
  actionId: action.id,
1388
1490
  runId,
1389
1491
  });
@@ -17,7 +17,7 @@ export const BUILT_IN_ACTION_TYPES = Object.freeze([
17
17
  'custom',
18
18
  ]);
19
19
  const STAGE_TYPES = new Set(BUILT_IN_ACTION_TYPES);
20
- const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed']);
20
+ const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed', 'planned']);
21
21
  const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
22
22
  const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
23
23
  const WORKSPACE_MODES = new Set(['shared', 'read', 'isolated-write', 'integrate']);
@@ -108,11 +108,29 @@ export const RUN_OUTCOMES = Object.freeze([
108
108
  'failed',
109
109
  ]);
110
110
 
111
- function cleanId(value, fallback) {
111
+ export function canonicalActionId(value, fallback = '') {
112
112
  const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
113
113
  return normalized || fallback;
114
114
  }
115
115
 
116
+ export function canonicalExplicitActionId(value, field) {
117
+ if (typeof value !== 'string' || !value.trim()) {
118
+ throw new Error(`Work Center ${field} contains an empty Action reference`);
119
+ }
120
+ const id = canonicalActionId(value);
121
+ if (!id) {
122
+ throw new Error(`Work Center ${field} contains an invalid Action reference: ${value}`);
123
+ }
124
+ return id;
125
+ }
126
+
127
+ export function canonicalExplicitActionIds(value, field) {
128
+ if (!Array.isArray(value)) {
129
+ throw new Error(`Work Center ${field} must be an array of Action references`);
130
+ }
131
+ return [...new Set(value.map(item => canonicalExplicitActionId(item, field)))];
132
+ }
133
+
116
134
  function uniqueStrings(value) {
117
135
  if (!Array.isArray(value)) return [];
118
136
  return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))];
@@ -126,12 +144,17 @@ export function normalizeAssignmentPolicy(value, stageType = 'custom') {
126
144
  : null;
127
145
  const candidateVpIds = uniqueStrings(source.candidateVpIds);
128
146
  if (mode === 'fixed' && !fixedVpId) throw new Error('Fixed Work Center assignment requires fixedVpId');
129
- if (mode === 'pool' && candidateVpIds.length === 0) throw new Error('Work Center VP pool cannot be empty');
147
+ if (['pool', 'planned'].includes(mode) && candidateVpIds.length === 0) {
148
+ throw new Error('Work Center VP candidate list cannot be empty');
149
+ }
130
150
  return {
131
151
  mode,
132
- capability: cleanId(source.capability, stageType),
152
+ capability: canonicalActionId(source.capability, stageType),
133
153
  candidateVpIds,
134
154
  fixedVpId,
155
+ assignmentReason: typeof source.assignmentReason === 'string'
156
+ ? source.assignmentReason.trim().slice(0, 1_000)
157
+ : '',
135
158
  separateFromStageTypes: uniqueStrings(source.separateFromStageTypes),
136
159
  };
137
160
  }
@@ -173,14 +196,14 @@ function normalizeGlobalInstructions(value) {
173
196
  }
174
197
 
175
198
  function generatedActionType(value) {
176
- return cleanId(value, 'custom').slice(0, 64);
199
+ return canonicalActionId(value, 'custom').slice(0, 64);
177
200
  }
178
201
 
179
202
  export function normalizeWorkflowDefinition(value, index = 0) {
180
203
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
181
204
  throw new Error('Work Center workflow must be an object');
182
205
  }
183
- const id = cleanId(value.id, `workflow-${index + 1}`);
206
+ const id = canonicalActionId(value.id, `workflow-${index + 1}`);
184
207
  const name = String(value.name || id).trim() || id;
185
208
  if (!Array.isArray(value.stages) || value.stages.length === 0) {
186
209
  throw new Error(`Work Center workflow "${id}" requires at least one stage`);
@@ -193,7 +216,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
193
216
  const type = planningMode === 'ai'
194
217
  ? generatedActionType(source.type)
195
218
  : (STAGE_TYPES.has(source.type) ? source.type : 'custom');
196
- const stageId = cleanId(source.id, `${type}-${stageIndex + 1}`);
219
+ const stageId = canonicalActionId(source.id, `${type}-${stageIndex + 1}`);
197
220
  if (seen.has(stageId)) throw new Error(`Duplicate Work Center stage id: ${stageId}`);
198
221
  seen.add(stageId);
199
222
  const stage = {
@@ -211,7 +234,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
211
234
  maxAttempts: Math.min(Math.max(Number(source.maxAttempts) || 2, 1), 5),
212
235
  };
213
236
  if (type === 'review') {
214
- stage.changesRequestedStageId = cleanId(source.changesRequestedStageId, 'implement');
237
+ stage.changesRequestedStageId = canonicalActionId(source.changesRequestedStageId, 'implement');
215
238
  }
216
239
  return stage;
217
240
  });
@@ -233,7 +256,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
233
256
  planningMode,
234
257
  executionMode,
235
258
  workItemType: typeof value.workItemType === 'string' && value.workItemType.trim()
236
- ? cleanId(value.workItemType, 'general')
259
+ ? canonicalActionId(value.workItemType, 'general')
237
260
  : null,
238
261
  globalInstructions: normalizeGlobalInstructions(value.globalInstructions),
239
262
  modelPolicy: normalizeModelPolicy(value.modelPolicy),
@@ -324,7 +347,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
324
347
  const requestedType = typeof requestedWorkItemType === 'string'
325
348
  && requestedWorkItemType.trim()
326
349
  && requestedWorkItemType.trim().toLowerCase() !== 'auto'
327
- ? cleanId(requestedWorkItemType, '').slice(0, 64)
350
+ ? canonicalActionId(requestedWorkItemType, '').slice(0, 64)
328
351
  : '';
329
352
  const actionTemplates = normalized.workflows.map(workflow => normalizeWorkflowDefinition({
330
353
  ...workflow,
@@ -394,8 +417,9 @@ export function resolveWorkflowSnapshot(settings, workflowId, stageOverrides = {
394
417
  });
395
418
  }
396
419
 
397
- export function applyGeneratedPlan(workItem, rawPlan) {
420
+ export function applyGeneratedPlan(workItem, rawPlan, options = {}) {
398
421
  const source = workflowFrom(workItem);
422
+ const forceGraph = options.forceGraph !== false;
399
423
  if (source.planningMode !== 'ai') return source;
400
424
  if (!rawPlan || typeof rawPlan !== 'object' || Array.isArray(rawPlan)) {
401
425
  throw new Error('AI-planned triage requires a structured plan');
@@ -403,17 +427,23 @@ export function applyGeneratedPlan(workItem, rawPlan) {
403
427
  if (typeof rawPlan.workItemType !== 'string' || !rawPlan.workItemType.trim()) {
404
428
  throw new Error('AI-planned triage requires a specific workItemType');
405
429
  }
406
- const workItemType = cleanId(rawPlan.workItemType, '').slice(0, 64);
430
+ const workItemType = canonicalActionId(rawPlan.workItemType, '').slice(0, 64);
407
431
  if (!workItemType) throw new Error('AI-planned triage requires a valid workItemType');
408
432
  if (source.workItemType && workItemType !== source.workItemType) {
409
433
  throw new Error(`AI-planned triage must keep the selected workItemType "${source.workItemType}"`);
410
434
  }
435
+ const reservedStageIds = new Set((options.reservedStageIds || [])
436
+ .map(id => String(id || '').trim()).filter(Boolean));
411
437
  const reusableTemplate = source.actionTemplates.find(template => template.workItemType === workItemType);
412
438
  if (reusableTemplate) {
413
439
  const templateActions = reusableTemplate.stages.filter(stage => stage.type !== 'triage');
414
440
  if (templateActions.length === 0) {
415
441
  throw new Error(`Reusable Action template "${workItemType}" has no executable Actions`);
416
442
  }
443
+ const reusedStageId = templateActions.find(stage => reservedStageIds.has(stage.id))?.id;
444
+ if (reusedStageId) {
445
+ throw new Error(`AI-planned Action id reuses historical stage identity: ${reusedStageId}`);
446
+ }
417
447
  return normalizeWorkflowDefinition({
418
448
  ...source,
419
449
  workItemType,
@@ -424,14 +454,20 @@ export function applyGeneratedPlan(workItem, rawPlan) {
424
454
  if (!Array.isArray(rawPlan.actions) || rawPlan.actions.length < 1 || rawPlan.actions.length > 8) {
425
455
  throw new Error('AI-planned triage requires between 1 and 8 Actions when no reusable template exists');
426
456
  }
457
+ const availableVpIds = Array.isArray(options.availableVpIds)
458
+ ? new Set(options.availableVpIds.map(id => String(id || '').trim()).filter(Boolean))
459
+ : null;
427
460
  const seen = new Set(['triage']);
428
461
  let previousGeneratedId = null;
429
462
  const generated = rawPlan.actions.map((rawAction, index) => {
430
463
  const input = rawAction && typeof rawAction === 'object' && !Array.isArray(rawAction) ? rawAction : {};
431
464
  const type = generatedActionType(input.type);
432
465
  if (type === 'triage') throw new Error('AI-planned Actions cannot add another triage Action');
433
- const id = cleanId(input.id, `${type}-${index + 1}`);
466
+ const id = canonicalActionId(input.id, `${type}-${index + 1}`);
434
467
  if (seen.has(id)) throw new Error(`Duplicate AI-planned Action id: ${id}`);
468
+ if (reservedStageIds.has(id)) {
469
+ throw new Error(`AI-planned Action id reuses historical stage identity: ${id}`);
470
+ }
435
471
  seen.add(id);
436
472
  const objective = typeof input.objective === 'string' ? input.objective.trim().slice(0, 2_000) : '';
437
473
  if (!objective) throw new Error(`AI-planned Action "${id}" requires a task-specific objective`);
@@ -451,6 +487,17 @@ export function applyGeneratedPlan(workItem, rawPlan) {
451
487
  const actionInstruction = Object.hasOwn(source.actionInstructions, type)
452
488
  ? source.actionInstructions[type]
453
489
  : source.actionInstructions.custom;
490
+ const candidateVpIds = uniqueStrings(input.candidateVpIds);
491
+ if (candidateVpIds.length > 0 && availableVpIds) {
492
+ const unavailable = candidateVpIds.find(vpId => !availableVpIds.has(vpId));
493
+ if (unavailable) throw new Error(`AI-planned Action "${id}" references unavailable VP "${unavailable}"`);
494
+ }
495
+ const assignmentReason = typeof input.assignmentReason === 'string'
496
+ ? input.assignmentReason.trim().slice(0, 1_000)
497
+ : '';
498
+ if (candidateVpIds.length > 0 && !assignmentReason) {
499
+ throw new Error(`AI-planned Action "${id}" requires an assignmentReason`);
500
+ }
454
501
  const stage = {
455
502
  id,
456
503
  name: String(input.name || id).trim().slice(0, 120) || id,
@@ -459,25 +506,37 @@ export function applyGeneratedPlan(workItem, rawPlan) {
459
506
  approach,
460
507
  expectedOutcome,
461
508
  instruction: actionInstruction,
462
- assignmentPolicy: {
509
+ assignmentPolicy: candidateVpIds.length > 0 ? {
510
+ mode: 'planned',
511
+ capability: canonicalActionId(input.capability, type),
512
+ candidateVpIds,
513
+ fixedVpId: null,
514
+ assignmentReason,
515
+ separateFromStageTypes: uniqueStrings(input.separateFromActionTypes),
516
+ } : {
463
517
  mode: 'auto',
464
- capability: cleanId(input.capability, type),
518
+ capability: canonicalActionId(input.capability, type),
465
519
  candidateVpIds: [],
466
520
  fixedVpId: null,
521
+ assignmentReason: '',
467
522
  separateFromStageTypes: uniqueStrings(input.separateFromActionTypes),
468
523
  },
469
524
  modelPolicy: source.actionModelPolicies[type] || source.actionModelPolicies.custom,
470
525
  dependsOnStageIds: Object.hasOwn(input, 'dependsOnActionIds')
471
- ? uniqueStrings(input.dependsOnActionIds)
526
+ ? canonicalExplicitActionIds(
527
+ input.dependsOnActionIds,
528
+ `Action "${id}" dependencies`,
529
+ )
472
530
  : (previousGeneratedId ? [previousGeneratedId] : []),
473
531
  workspaceMode: WORKSPACE_MODES.has(input.workspaceMode) ? input.workspaceMode : 'shared',
474
532
  maxAttempts: Math.min(Math.max(Number(input.maxAttempts) || 2, 1), 5),
475
533
  };
476
534
  previousGeneratedId = id;
477
- if (type === 'review' && Object.prototype.hasOwnProperty.call(input, 'changesRequestedActionId')) {
478
- stage.changesRequestedStageId = typeof input.changesRequestedActionId === 'string'
479
- ? cleanId(input.changesRequestedActionId, '')
480
- : '';
535
+ if (type === 'review' && Object.hasOwn(input, 'changesRequestedActionId')) {
536
+ stage.changesRequestedStageId = canonicalExplicitActionId(
537
+ input.changesRequestedActionId,
538
+ `Action "${id}" review target`,
539
+ );
481
540
  }
482
541
  return stage;
483
542
  });
@@ -497,6 +556,14 @@ export function applyGeneratedPlan(workItem, rawPlan) {
497
556
  if (!stage.changesRequestedStageId) {
498
557
  throw new Error(`AI-planned review Action "${stage.id}" requires an earlier editable Action`);
499
558
  }
559
+ const returnTarget = candidates.find(candidate => candidate.id === stage.changesRequestedStageId);
560
+ stage.assignmentPolicy = {
561
+ ...stage.assignmentPolicy,
562
+ separateFromStageTypes: uniqueStrings([
563
+ ...(stage.assignmentPolicy.separateFromStageTypes || []),
564
+ returnTarget.type,
565
+ ]),
566
+ };
500
567
  }
501
568
  const hasIsolatedWrites = generated.some(stage => stage.workspaceMode === 'isolated-write');
502
569
  const integrationStages = generated.filter(stage => stage.workspaceMode === 'integrate');
@@ -530,7 +597,7 @@ export function applyGeneratedPlan(workItem, rawPlan) {
530
597
  }
531
598
  return normalizeWorkflowDefinition({
532
599
  ...source,
533
- executionMode: 'graph',
600
+ executionMode: forceGraph ? 'graph' : source.executionMode,
534
601
  workItemType,
535
602
  stages: [source.stages[0], ...generated],
536
603
  });