@yeaft/webchat-agent 1.0.130 → 1.0.132

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.130",
3
+ "version": "1.0.132",
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",
package/yeaft/config.js CHANGED
@@ -454,11 +454,14 @@ export function loadConfig(overrides = {}) {
454
454
  const p = normalizeKnownProviderForRuntime(rawProvider);
455
455
  const normalized = normalizeProviderModels(p);
456
456
  for (const m of normalized) {
457
- // Avoid duplicates (first provider wins)
458
- if (!config.availableModels.some(am => am.id === m.id)) {
457
+ const ref = p.name ? `${p.name}/${m.id}` : m.id;
458
+ // A model id is only unique inside its provider. Keep provider-qualified
459
+ // duplicates so an explicit `provider/model` never disappears from the
460
+ // runtime catalog just because another provider exposes the same id.
461
+ if (!config.availableModels.some(am => am.ref === ref)) {
459
462
  const entry = {
460
463
  id: m.id,
461
- ref: p.name ? `${p.name}/${m.id}` : m.id,
464
+ ref,
462
465
  provider: p.name,
463
466
  label: m.id,
464
467
  };
@@ -60,7 +60,9 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
60
60
  goal,
61
61
  acceptanceCriteria: cleanCriteria(input.acceptanceCriteria),
62
62
  workDir: typeof input.workDir === 'string' ? input.workDir.trim() : (ctx.cwd || ''),
63
- workflowTemplate: 'software-change',
63
+ // The Agent-local Work Center settings choose the default workflow and
64
+ // freeze its policy snapshot. Tool callers create the contract; they do
65
+ // not get to smuggle a different dispatch policy into it.
64
66
  origin: {
65
67
  sessionId,
66
68
  messageId: ctx.inboundEnvelope?.msgId || null,
@@ -0,0 +1,135 @@
1
+ import { parseModelRef } from '../models.js';
2
+ import { normalizeAssignmentPolicy, normalizeModelPolicy } from './workflow.js';
3
+
4
+ function policyError(message) {
5
+ const error = new Error(message);
6
+ error.retryable = false;
7
+ return error;
8
+ }
9
+
10
+ const CAPABILITY_TERMS = Object.freeze({
11
+ triage: ['triage', 'requirement', 'flow', 'product', 'strategy', 'cross-domain', 'analysis'],
12
+ implement: ['implement', 'engineer', 'developer', 'engineering', 'systems', 'code', 'execution'],
13
+ test: ['test', 'testing', 'quality', 'qa', 'verification', 'reliability'],
14
+ review: ['review', 'reviewer', 'refactor', 'architecture', 'code-smells', 'readability', 'maintainability'],
15
+ deliver: ['deliver', 'release', 'ship', 'git', 'engineering', 'systems', 'execution'],
16
+ research: ['research', 'science', 'analysis', 'evidence', 'investigation'],
17
+ write: ['write', 'writer', 'writing', 'documentation', 'editor', 'communication'],
18
+ });
19
+
20
+ function vpSearchText(vp) {
21
+ return [
22
+ vp.id,
23
+ vp.name,
24
+ vp.nameZh,
25
+ vp.role,
26
+ vp.roleZh,
27
+ vp.area,
28
+ ...(Array.isArray(vp.aliases) ? vp.aliases : []),
29
+ ...(Array.isArray(vp.traits) ? vp.traits : []),
30
+ ].filter(Boolean).join(' ').toLowerCase();
31
+ }
32
+
33
+ function capabilityScore(vp, capability) {
34
+ const needle = String(capability || '').trim().toLowerCase();
35
+ if (!needle) return 0;
36
+ const text = vpSearchText(vp);
37
+ const terms = CAPABILITY_TERMS[needle] || [needle];
38
+ let score = 0;
39
+ for (const term of terms) {
40
+ if (text.includes(term)) score += term === needle ? 6 : 2;
41
+ }
42
+ if (String(vp.area || '').toLowerCase() === needle) score += 4;
43
+ return score;
44
+ }
45
+
46
+ function priorVpIdsForSeparation(policy, priorRuns) {
47
+ const separatedTypes = new Set(policy.separateFromStageTypes || []);
48
+ if (separatedTypes.size === 0) return new Set();
49
+ return new Set((priorRuns || [])
50
+ .filter(run => separatedTypes.has(run.actionType) || separatedTypes.has(run.roleSnapshot?.actionType))
51
+ .map(run => run.vpSnapshot?.id)
52
+ .filter(Boolean));
53
+ }
54
+
55
+ export function selectWorkItemVp({ policy: rawPolicy, stageType, vps, priorRuns = [] }) {
56
+ const policy = normalizeAssignmentPolicy(rawPolicy, stageType);
57
+ const all = Array.isArray(vps) ? vps.filter(vp => vp?.id) : [];
58
+ if (all.length === 0) throw policyError('Work Center has no available VPs');
59
+ const byId = new Map(all.map(vp => [vp.id, vp]));
60
+ const excluded = priorVpIdsForSeparation(policy, priorRuns);
61
+
62
+ if (policy.mode === 'fixed') {
63
+ const fixed = byId.get(policy.fixedVpId);
64
+ if (!fixed) throw policyError(`Fixed Work Center VP is unavailable: ${policy.fixedVpId}`);
65
+ if (excluded.has(fixed.id)) throw policyError(`Work Center separation policy excludes VP: ${fixed.id}`);
66
+ return { vp: fixed, reason: `fixed:${fixed.id}`, policy };
67
+ }
68
+
69
+ const pool = policy.mode === 'pool'
70
+ ? policy.candidateVpIds.map(id => byId.get(id)).filter(Boolean)
71
+ : all;
72
+ if (pool.length === 0) throw policyError('No configured Work Center VP candidates are available');
73
+ const eligible = pool.filter(vp => !excluded.has(vp.id));
74
+ if (eligible.length === 0) throw policyError('No Work Center VP satisfies the stage separation policy');
75
+
76
+ const ranked = eligible
77
+ .map(vp => ({ vp, score: capabilityScore(vp, policy.capability || stageType) }))
78
+ .sort((left, right) => right.score - left.score || left.vp.id.localeCompare(right.vp.id));
79
+ if (ranked[0].score === 0 && policy.mode === 'auto') {
80
+ throw policyError(`No Work Center VP matches capability: ${policy.capability || stageType}`);
81
+ }
82
+ return {
83
+ vp: ranked[0].vp,
84
+ reason: `${policy.mode}:${policy.capability || stageType}:score=${ranked[0].score}`,
85
+ policy,
86
+ };
87
+ }
88
+
89
+ function availableModel(config, ref) {
90
+ const models = Array.isArray(config.availableModels) ? config.availableModels : [];
91
+ if (models.length === 0) return null;
92
+ const parsed = parseModelRef(ref);
93
+ return models.find(model => model.ref === ref)
94
+ || models.find(model => !parsed.providerName && model.id === parsed.modelId)
95
+ || null;
96
+ }
97
+
98
+ export function resolveWorkItemModel(config, vp, rawPolicy) {
99
+ const policy = normalizeModelPolicy(rawPolicy);
100
+ let model;
101
+ let source;
102
+ if (policy.mode === 'specific') {
103
+ model = policy.model;
104
+ source = 'stage-specific';
105
+ } else if (policy.mode === 'primary') {
106
+ model = config.primaryModel || config.model || null;
107
+ source = 'agent-primary';
108
+ } else if (policy.mode === 'fast') {
109
+ model = config.fastModel || null;
110
+ source = 'agent-fast';
111
+ } else if (vp.modelHint === 'fast') {
112
+ model = config.fastModel || config.primaryModel || config.model || null;
113
+ source = config.fastModel ? 'vp-fast' : 'vp-fast-primary-fallback';
114
+ } else {
115
+ model = config.primaryModel || config.model || null;
116
+ source = vp.modelHint === 'primary' ? 'vp-primary' : 'agent-default';
117
+ }
118
+ if (!model) throw policyError(`Work Center model policy "${policy.mode}" has no configured model`);
119
+ const available = availableModel(config, model);
120
+ if (Array.isArray(config.availableModels) && config.availableModels.length > 0 && !available) {
121
+ throw policyError(`Configured Work Center model is unavailable: ${model}`);
122
+ }
123
+ const effortOptions = Array.isArray(available?.effortOptions) ? available.effortOptions : [];
124
+ if (policy.effort && !effortOptions.includes(policy.effort)) {
125
+ throw policyError(`Configured Work Center effort is unsupported by ${model}: ${policy.effort}`);
126
+ }
127
+ const parsed = parseModelRef(model);
128
+ return {
129
+ model,
130
+ effort: policy.effort || null,
131
+ provider: available?.provider || parsed.providerName || null,
132
+ source,
133
+ policy,
134
+ };
135
+ }
@@ -1,11 +1,13 @@
1
1
  import ctx from '../../context.js';
2
2
  import { sendToServer } from '../../connection/buffer.js';
3
- import { ensureSessionLoaded } from '../web-bridge.js';
3
+ import { ensureSessionLoaded, resetYeaftSession } from '../web-bridge.js';
4
4
  import { defaultRegistry } from '../vp/registry.js';
5
5
  import { scanVpLibrary } from '../vp/vp-store.js';
6
6
  import { WorkCenterService } from './service.js';
7
7
  import { WorkItemRunner } from './runner.js';
8
- import { projectWorkCenterEvent } from './projection.js';
8
+ import { projectWorkCenterEvent, projectWorkItemDetail } from './projection.js';
9
+ import { previewWorkCenterPlan } from './planner.js';
10
+ import { readWorkCenterSettings } from './settings.js';
9
11
  import { join } from 'node:path';
10
12
 
11
13
  let service = null;
@@ -14,6 +16,8 @@ let shuttingDown = false;
14
16
  let shutdownPromise = null;
15
17
  let serviceFactory = null;
16
18
 
19
+ const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel', 'guide', 'retry']);
20
+
17
21
  function send(msg) {
18
22
  sendToServer(msg);
19
23
  }
@@ -29,7 +33,7 @@ async function createDefaultService() {
29
33
  runtimeProvider: async () => {
30
34
  const runtime = await getRuntime();
31
35
  if (defaultRegistry.vpCount() === 0) {
32
- for (const vp of scanVpLibrary({ dir: join(runtime.yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
36
+ for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
33
37
  }
34
38
  return {
35
39
  ...runtime,
@@ -42,6 +46,27 @@ async function createDefaultService() {
42
46
  const created = new WorkCenterService({
43
47
  yeaftDir,
44
48
  runner,
49
+ runtimeInfoProvider: async () => {
50
+ const runtime = await getRuntime();
51
+ if (defaultRegistry.vpCount() === 0) {
52
+ for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
53
+ }
54
+ return {
55
+ vps: defaultRegistry.listVps().map(vp => ({
56
+ id: vp.id,
57
+ name: vp.name || vp.id,
58
+ nameZh: vp.nameZh || '',
59
+ role: vp.role || '',
60
+ roleZh: vp.roleZh || '',
61
+ area: vp.area || '',
62
+ traits: Array.isArray(vp.traits) ? vp.traits : [],
63
+ modelHint: vp.modelHint || null,
64
+ })),
65
+ models: Array.isArray(runtime.config.availableModels) ? runtime.config.availableModels : [],
66
+ primaryModel: runtime.config.primaryModel || runtime.config.model || null,
67
+ fastModel: runtime.config.fastModel || null,
68
+ };
69
+ },
45
70
  onEvent(event) {
46
71
  send({ type: 'work_center_event', event: projectWorkCenterEvent(event) });
47
72
  },
@@ -85,7 +110,28 @@ export async function handleWorkCenterRequest(msg) {
85
110
  const op = typeof msg.op === 'string' ? msg.op : '';
86
111
  try {
87
112
  const workCenter = await ensureWorkCenter();
88
- const data = await workCenter.handle(op, msg.payload || {});
113
+ let data;
114
+ if (op === 'preview') {
115
+ const runtime = await getRuntime();
116
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
117
+ if (!yeaftDir) throw new Error('Work Center requires a configured Yeaft directory');
118
+ if (defaultRegistry.vpCount() === 0) {
119
+ for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
120
+ }
121
+ data = previewWorkCenterPlan({
122
+ settings: readWorkCenterSettings(yeaftDir),
123
+ workflowId: msg.payload?.workflowTemplate,
124
+ stageOverrides: msg.payload?.stageOverrides,
125
+ registry: defaultRegistry,
126
+ config: runtime.config,
127
+ });
128
+ } else if (op === 'refresh_runtime') {
129
+ await resetYeaftSession();
130
+ data = await workCenter.handle('get_settings', {});
131
+ } else {
132
+ data = await workCenter.handle(op, msg.payload || {});
133
+ }
134
+ if (BROWSER_DETAIL_OPS.has(op)) data = projectWorkItemDetail(data);
89
135
  send({
90
136
  type: 'work_center_response',
91
137
  requestId,
@@ -1,4 +1,4 @@
1
- import { actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
1
+ import { actionForStage, actionInstruction, getNextStep, initialActionFor, RUN_OUTCOMES } from './workflow.js';
2
2
  import { normalizeEvidence } from './evidence.js';
3
3
 
4
4
  function normalizeCriteria(value) {
@@ -48,10 +48,12 @@ function normalizeTerminalResult(result, action) {
48
48
  return normalized;
49
49
  }
50
50
 
51
- function contextEntry(action, result) {
51
+ function contextEntry(action, result, run) {
52
52
  return {
53
53
  type: action.type,
54
- role: action.requiredRole,
54
+ stageId: action.stageId || action.type,
55
+ vpId: run?.vpSnapshot?.id || action.requiredRole || null,
56
+ role: run?.roleSnapshot?.id || action.requiredRole || null,
55
57
  summary: result.summary || '',
56
58
  evidence: result.evidence || [],
57
59
  reviewDecision: result.reviewDecision || null,
@@ -125,7 +127,13 @@ export class WorkflowController {
125
127
  summary: guidance,
126
128
  evidence: [],
127
129
  }];
128
- const step = { type: previous.type, requiredRole: previous.requiredRole };
130
+ const step = {
131
+ type: previous.type,
132
+ stageId: previous.stageId || previous.type,
133
+ assignmentPolicy: previous.assignmentPolicy,
134
+ modelPolicy: previous.modelPolicy,
135
+ requiredRole: previous.requiredRole,
136
+ };
129
137
  return {
130
138
  ...step,
131
139
  context,
@@ -144,13 +152,21 @@ export class WorkflowController {
144
152
  throw new Error('answer is required to resume a waiting WorkItem');
145
153
  }
146
154
  const step = previous
147
- ? { type: previous.type, requiredRole: previous.requiredRole }
155
+ ? {
156
+ type: previous.type,
157
+ stageId: previous.stageId || previous.type,
158
+ assignmentPolicy: previous.assignmentPolicy,
159
+ modelPolicy: previous.modelPolicy,
160
+ requiredRole: previous.requiredRole,
161
+ }
148
162
  : initialActionFor(workItem);
149
163
  const context = Array.isArray(previous?.context) ? [...previous.context] : [];
150
164
  if (previousRun) {
151
165
  context.push({
152
166
  type: previous.type,
153
- role: previous.requiredRole,
167
+ stageId: previous.stageId || previous.type,
168
+ vpId: previousRun.vpSnapshot?.id || previous.requiredRole || null,
169
+ role: previousRun.roleSnapshot?.id || previous.requiredRole || null,
154
170
  summary: previousRun.summary || '',
155
171
  evidence: normalizeEvidence(previousRun.evidence),
156
172
  waitingReason: previousRun.waitingReason || null,
@@ -223,7 +239,7 @@ export class WorkflowController {
223
239
  revision: workItem.revision + 1,
224
240
  }
225
241
  : workItem;
226
- const nextStep = getNextStep(workItem.workflowTemplate, action.type, result);
242
+ const nextStep = getNextStep(workItem, action.stageId || action.type, result);
227
243
  if (!nextStep) {
228
244
  return {
229
245
  actionStatus: 'completed',
@@ -234,17 +250,12 @@ export class WorkflowController {
234
250
  };
235
251
  }
236
252
 
237
- const context = [...(action.context || []), contextEntry(action, result)];
253
+ const context = [...(action.context || []), contextEntry(action, result, activeRun)];
238
254
  return {
239
255
  actionStatus: 'completed',
240
256
  workItemStatus: 'ready',
241
257
  contractPatch,
242
- nextAction: {
243
- ...nextStep,
244
- context,
245
- instruction: actionInstruction(nextStep, effectiveWorkItem, context),
246
- maxAttempts: 2,
247
- },
258
+ nextAction: actionForStage(nextStep, effectiveWorkItem, context),
248
259
  eventType: 'action.completed',
249
260
  eventData: {
250
261
  nextActionType: nextStep.type,
@@ -0,0 +1,66 @@
1
+ import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
2
+ import { resolveWorkflowSnapshot } from './workflow.js';
3
+
4
+ function publicVp(vp) {
5
+ return vp ? {
6
+ id: vp.id,
7
+ name: vp.name || vp.id,
8
+ nameZh: vp.nameZh || '',
9
+ role: vp.role || '',
10
+ roleZh: vp.roleZh || '',
11
+ area: vp.area || '',
12
+ traits: Array.isArray(vp.traits) ? vp.traits : [],
13
+ modelHint: vp.modelHint || null,
14
+ } : null;
15
+ }
16
+
17
+ export function previewWorkCenterPlan({ settings, workflowId, stageOverrides, registry, config }) {
18
+ const workflow = resolveWorkflowSnapshot(settings, workflowId, stageOverrides);
19
+ const vps = registry.listVps();
20
+ const syntheticRuns = [];
21
+ const stages = workflow.stages.map(stage => {
22
+ try {
23
+ const assignment = selectWorkItemVp({
24
+ policy: stage.assignmentPolicy,
25
+ stageType: stage.type,
26
+ vps,
27
+ priorRuns: syntheticRuns,
28
+ });
29
+ const model = resolveWorkItemModel(config, assignment.vp, stage.modelPolicy);
30
+ syntheticRuns.push({
31
+ actionType: stage.type,
32
+ roleSnapshot: { actionType: stage.type },
33
+ vpSnapshot: { id: assignment.vp.id },
34
+ });
35
+ return {
36
+ id: stage.id,
37
+ name: stage.name,
38
+ type: stage.type,
39
+ assignmentPolicy: stage.assignmentPolicy,
40
+ modelPolicy: stage.modelPolicy,
41
+ selectedVp: publicVp(assignment.vp),
42
+ selectionReason: assignment.reason,
43
+ model: {
44
+ id: model.model,
45
+ provider: model.provider,
46
+ effort: model.effort,
47
+ source: model.source,
48
+ },
49
+ error: null,
50
+ };
51
+ } catch (error) {
52
+ return {
53
+ id: stage.id,
54
+ name: stage.name,
55
+ type: stage.type,
56
+ assignmentPolicy: stage.assignmentPolicy,
57
+ modelPolicy: stage.modelPolicy,
58
+ selectedVp: null,
59
+ selectionReason: null,
60
+ model: null,
61
+ error: error?.message || String(error),
62
+ };
63
+ }
64
+ });
65
+ return { workflow, stages, valid: stages.every(stage => !stage.error) };
66
+ }
@@ -3,10 +3,104 @@ function currentAction(detail) {
3
3
  return detail.actions.find(action => action.id === detail.currentActionId) || null;
4
4
  }
5
5
 
6
+ function projectAction(action) {
7
+ if (!action) return null;
8
+ return {
9
+ id: action.id,
10
+ workItemId: action.workItemId,
11
+ sequence: action.sequence,
12
+ type: action.type,
13
+ stageId: action.stageId || action.type,
14
+ assignmentPolicy: action.assignmentPolicy || null,
15
+ modelPolicy: action.modelPolicy || null,
16
+ requiredRole: action.requiredRole || '',
17
+ status: action.status,
18
+ attempt: action.attempt,
19
+ maxAttempts: action.maxAttempts,
20
+ currentRunId: action.currentRunId || null,
21
+ createdAt: action.createdAt,
22
+ updatedAt: action.updatedAt,
23
+ };
24
+ }
25
+
26
+ function projectRun(run) {
27
+ if (!run) return null;
28
+ return {
29
+ id: run.id,
30
+ actionId: run.actionId,
31
+ workItemId: run.workItemId,
32
+ status: run.status,
33
+ startedAt: run.startedAt,
34
+ expiresAt: run.expiresAt,
35
+ endedAt: run.endedAt || null,
36
+ summary: run.summary || '',
37
+ evidence: Array.isArray(run.evidence) ? run.evidence : [],
38
+ waitingReason: run.waitingReason || '',
39
+ error: run.error || '',
40
+ reviewDecision: run.reviewDecision || null,
41
+ roleSnapshot: run.roleSnapshot ? {
42
+ id: run.roleSnapshot.id,
43
+ actionType: run.roleSnapshot.actionType,
44
+ selectionReason: run.roleSnapshot.selectionReason,
45
+ } : null,
46
+ vpSnapshot: run.vpSnapshot ? {
47
+ id: run.vpSnapshot.id,
48
+ name: run.vpSnapshot.name || run.vpSnapshot.id,
49
+ nameZh: run.vpSnapshot.nameZh || '',
50
+ role: run.vpSnapshot.role || '',
51
+ roleZh: run.vpSnapshot.roleZh || '',
52
+ } : null,
53
+ modelSnapshot: run.modelSnapshot ? {
54
+ id: run.modelSnapshot.id,
55
+ provider: run.modelSnapshot.provider || null,
56
+ effort: run.modelSnapshot.effort || null,
57
+ source: run.modelSnapshot.source || null,
58
+ } : null,
59
+ };
60
+ }
61
+
62
+ function projectEvent(event) {
63
+ if (!event) return null;
64
+ return {
65
+ id: event.id,
66
+ workItemId: event.workItemId,
67
+ actionId: event.actionId || null,
68
+ runId: event.runId || null,
69
+ type: event.type,
70
+ createdAt: event.createdAt,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Authenticated browser detail DTO. Execution-only snapshots, VP persona,
76
+ * tool policy, prompts, event data, and local filesystem paths never cross the wire.
77
+ */
78
+ export function projectWorkItemDetail(detail) {
79
+ if (!detail) return null;
80
+ return {
81
+ id: detail.id,
82
+ revision: detail.revision,
83
+ title: detail.title,
84
+ goal: detail.goal,
85
+ acceptanceCriteria: Array.isArray(detail.acceptanceCriteria) ? detail.acceptanceCriteria : [],
86
+ workflowTemplate: detail.workflowTemplate,
87
+ status: detail.status,
88
+ currentActionId: detail.currentActionId || null,
89
+ currentRunId: detail.currentRunId || null,
90
+ reuseMemory: detail.reuseMemory !== false,
91
+ origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
92
+ linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
93
+ createdAt: detail.createdAt,
94
+ updatedAt: detail.updatedAt,
95
+ actions: Array.isArray(detail.actions) ? detail.actions.map(projectAction) : [],
96
+ runs: Array.isArray(detail.runs) ? detail.runs.map(projectRun) : [],
97
+ events: Array.isArray(detail.events) ? detail.events.map(projectEvent) : [],
98
+ };
99
+ }
100
+
6
101
  /**
7
102
  * Browser event projection. This deliberately excludes local filesystem paths,
8
103
  * Run evidence/tool output, prompts, model snapshots, and execution errors.
9
- * Clients fetch the selected detail explicitly through the authenticated get op.
10
104
  */
11
105
  export function projectWorkItemSummary(detail) {
12
106
  if (!detail) return null;
@@ -36,7 +130,8 @@ export function projectWorkItemSummary(detail) {
36
130
  currentAction: action ? {
37
131
  id: action.id,
38
132
  type: action.type,
39
- requiredRole: action.requiredRole,
133
+ stageId: action.stageId || action.type,
134
+ assignmentMode: action.assignmentPolicy?.mode || (action.requiredRole ? 'fixed' : null),
40
135
  status: action.status,
41
136
  } : null,
42
137
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
@@ -5,6 +5,7 @@ import { parsePatch } from '../tools/apply-patch.js';
5
5
  import { defaultRegistry } from '../vp/registry.js';
6
6
  import { NullTrace } from '../debug-trace.js';
7
7
  import { isPathInsideOrEqual } from '../tools/path-safety.js';
8
+ import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
8
9
  import { existsSync, lstatSync, realpathSync } from 'node:fs';
9
10
  import path from 'node:path';
10
11
 
@@ -203,12 +204,6 @@ function completionContract(action) {
203
204
  }\nA model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
204
205
  }
205
206
 
206
- function resolveModel(config, vp) {
207
- if (vp.modelHint === 'fast' && config.fastModel) return config.fastModel;
208
- if (vp.modelHint === 'primary' && config.primaryModel) return config.primaryModel;
209
- return config.model || config.primaryModel || null;
210
- }
211
-
212
207
  export class WorkItemRunner {
213
208
  constructor(options) {
214
209
  this.runtimeProvider = options.runtimeProvider;
@@ -219,20 +214,38 @@ export class WorkItemRunner {
219
214
  async run({ workItem, action, run, signal, ownerBootId }) {
220
215
  const runtime = await this.runtimeProvider();
221
216
  const workDir = resolveWorkItemWorkDir(workItem, runtime.defaultWorkDir);
222
- const vp = copyVp(this.registry.getVp(action.requiredRole));
217
+ const priorRuns = this.store.listCompletedRuns(workItem.id);
218
+ const assignment = action.assignmentPolicy
219
+ ? selectWorkItemVp({
220
+ policy: action.assignmentPolicy,
221
+ stageType: action.type,
222
+ vps: this.registry.listVps(),
223
+ priorRuns,
224
+ })
225
+ : {
226
+ vp: this.registry.getVp(action.requiredRole),
227
+ reason: `legacy-fixed:${action.requiredRole}`,
228
+ policy: { mode: 'fixed', fixedVpId: action.requiredRole },
229
+ };
230
+ const vp = copyVp(assignment.vp);
223
231
  if (!vp) {
224
- const error = new Error(`Required Work Center role is unavailable: ${action.requiredRole}`);
232
+ const error = new Error(`Required Work Center VP is unavailable: ${action.requiredRole || '(unassigned)'}`);
225
233
  error.retryable = false;
226
234
  throw error;
227
235
  }
236
+ const resolvedModel = resolveWorkItemModel(runtime.config, vp, action.modelPolicy);
228
237
  const isRunActive = () => !signal.aborted
229
238
  && this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
230
239
  const toolPolicySnapshot = workItemToolPolicySnapshot(workDir);
231
240
  const toolRegistry = createWorkItemToolRegistry({ workDir, isRunActive });
232
- const model = resolveModel(runtime.config, vp);
233
241
  const config = {
234
242
  ...runtime.config,
235
- model,
243
+ model: resolvedModel.model,
244
+ // WorkItem model policy is part of the frozen execution contract. The
245
+ // Agent-level fallback would silently execute a different model while
246
+ // leaving the Run snapshot unchanged, so WorkItems must fail explicitly.
247
+ fallbackModel: null,
248
+ ...(resolvedModel.effort ? { modelEffort: resolvedModel.effort } : {}),
236
249
  _readOnly: true,
237
250
  serverMode: true,
238
251
  // WorkItem messages live in the Work Center DB. Never archive their
@@ -247,9 +260,20 @@ export class WorkItemRunner {
247
260
  ownerBootId,
248
261
  run.leaseEpoch,
249
262
  {
250
- roleSnapshot: { id: action.requiredRole, actionType: action.type },
263
+ roleSnapshot: {
264
+ id: action.stageId || action.type,
265
+ actionType: action.type,
266
+ assignmentPolicy: assignment.policy,
267
+ selectionReason: assignment.reason,
268
+ },
251
269
  vpSnapshot: vp,
252
- modelSnapshot: { id: model, provider: runtime.config.provider || null },
270
+ modelSnapshot: {
271
+ id: resolvedModel.model,
272
+ provider: resolvedModel.provider,
273
+ effort: resolvedModel.effort,
274
+ source: resolvedModel.source,
275
+ policy: resolvedModel.policy,
276
+ },
253
277
  toolPolicySnapshot,
254
278
  },
255
279
  );
@@ -3,7 +3,9 @@ import { randomUUID } from 'node:crypto';
3
3
  import { WorkItemStore } from './store.js';
4
4
  import { WorkflowController } from './controller.js';
5
5
  import { WorkItemWatcher } from './watcher.js';
6
- import { projectWorkItemSummary } from './projection.js';
6
+ import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
7
+ import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
8
+ import { resolveWorkflowSnapshot } from './workflow.js';
7
9
 
8
10
  function requiredString(value, name) {
9
11
  if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required`);
@@ -13,6 +15,12 @@ function requiredString(value, name) {
13
15
  export class WorkCenterService {
14
16
  constructor(options) {
15
17
  const yeaftDir = requiredString(options?.yeaftDir, 'yeaftDir');
18
+ this.yeaftDir = yeaftDir;
19
+ this.settingsReader = options.settingsReader || readWorkCenterSettings;
20
+ this.settingsWriter = options.settingsWriter || writeWorkCenterSettings;
21
+ this.runtimeInfoProvider = typeof options.runtimeInfoProvider === 'function'
22
+ ? options.runtimeInfoProvider
23
+ : async () => ({ vps: [], models: [], primaryModel: null, fastModel: null });
16
24
  this.ownerBootId = options.ownerBootId || randomUUID();
17
25
  this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
18
26
  this.controller = options.controller || new WorkflowController(this.store);
@@ -37,16 +45,32 @@ export class WorkCenterService {
37
45
  watcher: this.watcher.status(),
38
46
  };
39
47
  case 'get':
40
- return this.#requiredItem(payload.id);
48
+ return projectWorkItemDetail(this.#requiredItem(payload.id));
49
+ case 'get_settings': {
50
+ const settings = this.settingsReader(this.yeaftDir);
51
+ return { settings, runtime: await this.runtimeInfoProvider() };
52
+ }
53
+ case 'update_settings': {
54
+ const settings = this.settingsWriter(this.yeaftDir, payload.settings);
55
+ return { settings, runtime: await this.runtimeInfoProvider() };
56
+ }
41
57
  case 'create': {
58
+ const settings = this.settingsReader(this.yeaftDir);
59
+ const workflowTemplate = typeof payload.workflowTemplate === 'string' && payload.workflowTemplate.trim()
60
+ ? payload.workflowTemplate.trim()
61
+ : settings.defaultWorkflowId;
62
+ const workflowSnapshot = resolveWorkflowSnapshot(settings, workflowTemplate, payload.stageOverrides);
42
63
  const item = this.controller.create({
43
64
  title: requiredString(payload.title, 'title'),
44
65
  goal: requiredString(payload.goal, 'goal'),
45
66
  acceptanceCriteria: Array.isArray(payload.acceptanceCriteria)
46
67
  ? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean)
47
68
  : [],
48
- workflowTemplate: payload.workflowTemplate || 'software-change',
49
- workDir: typeof payload.workDir === 'string' ? payload.workDir.trim() : '',
69
+ workflowTemplate,
70
+ workflowSnapshot,
71
+ workDir: typeof payload.workDir === 'string' && payload.workDir.trim()
72
+ ? payload.workDir.trim()
73
+ : settings.defaultWorkDir,
50
74
  reuseMemory: payload.reuseMemory !== false,
51
75
  origin: payload.origin && typeof payload.origin === 'object'
52
76
  ? {
@@ -58,7 +82,7 @@ export class WorkCenterService {
58
82
  linkedSessionIds: Array.isArray(payload.linkedSessionIds)
59
83
  ? [...new Set(payload.linkedSessionIds.map(value => String(value).trim()).filter(Boolean))]
60
84
  : [],
61
- start: payload.start !== false,
85
+ start: payload.start === undefined ? settings.startImmediately : payload.start !== false,
62
86
  });
63
87
  const detail = this.#requiredItem(item.id);
64
88
  this.#emit({ type: 'work_item.created', workItem: detail });
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { writeAtomic } from '../storage/atomic.js';
5
+ import { defaultWorkCenterSettings, normalizeWorkCenterSettings } from './workflow.js';
6
+
7
+ export const WORK_CENTER_SETTINGS_FILE = 'settings.json';
8
+ const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
9
+
10
+ function settingsPath(yeaftDir) {
11
+ return join(yeaftDir, 'work-center', WORK_CENTER_SETTINGS_FILE);
12
+ }
13
+
14
+ function withSettingsTransaction(yeaftDir, fn) {
15
+ const dir = dirname(settingsPath(yeaftDir));
16
+ mkdirSync(dir, { recursive: true });
17
+ const db = new DatabaseSync(join(dir, 'work-center.db'), { timeout: SETTINGS_LOCK_TIMEOUT_MS });
18
+ let transactionOpen = false;
19
+ try {
20
+ db.exec(`PRAGMA busy_timeout = ${SETTINGS_LOCK_TIMEOUT_MS};`);
21
+ db.exec('BEGIN IMMEDIATE');
22
+ transactionOpen = true;
23
+ const result = fn();
24
+ db.exec('COMMIT');
25
+ transactionOpen = false;
26
+ return result;
27
+ } catch (error) {
28
+ if (transactionOpen) {
29
+ try { db.exec('ROLLBACK'); } catch {}
30
+ }
31
+ throw error;
32
+ } finally {
33
+ db.close();
34
+ }
35
+ }
36
+
37
+ export function readWorkCenterSettings(yeaftDir) {
38
+ const file = settingsPath(yeaftDir);
39
+ if (!existsSync(file)) return defaultWorkCenterSettings();
40
+ try {
41
+ return normalizeWorkCenterSettings(JSON.parse(readFileSync(file, 'utf8')));
42
+ } catch (error) {
43
+ throw new Error(`Failed to read Work Center settings: ${error.message}`);
44
+ }
45
+ }
46
+
47
+ export function writeWorkCenterSettings(yeaftDir, value) {
48
+ return withSettingsTransaction(yeaftDir, () => {
49
+ const current = readWorkCenterSettings(yeaftDir);
50
+ const expectedRevision = Number(value?.revision);
51
+ if (!Number.isInteger(expectedRevision) || expectedRevision !== current.revision) {
52
+ throw new Error('Work Center settings changed elsewhere; reload before saving');
53
+ }
54
+ const normalized = normalizeWorkCenterSettings({ ...value, revision: current.revision + 1 });
55
+ const file = settingsPath(yeaftDir);
56
+ writeAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`);
57
+ return normalized;
58
+ });
59
+ }
@@ -31,6 +31,7 @@ function mapWorkItem(row) {
31
31
  goal: row.goal,
32
32
  acceptanceCriteria: parseJson(row.acceptance_criteria, []),
33
33
  workflowTemplate: row.workflow_template,
34
+ workflowSnapshot: parseJson(row.workflow_snapshot, null),
34
35
  status: row.status,
35
36
  currentActionId: row.current_action_id || null,
36
37
  currentRunId: row.current_run_id || null,
@@ -51,7 +52,10 @@ function mapAction(row) {
51
52
  workItemId: row.work_item_id,
52
53
  sequence: row.sequence,
53
54
  type: row.type,
54
- requiredRole: row.required_role,
55
+ stageId: row.stage_id || row.type,
56
+ assignmentPolicy: parseJson(row.assignment_policy, null),
57
+ modelPolicy: parseJson(row.model_policy, null),
58
+ requiredRole: row.required_role || '',
55
59
  instruction: row.instruction,
56
60
  context: parseJson(row.context, []),
57
61
  contractRevision: row.contract_revision,
@@ -146,6 +150,7 @@ export class WorkItemStore {
146
150
  goal TEXT NOT NULL,
147
151
  acceptance_criteria TEXT NOT NULL,
148
152
  workflow_template TEXT NOT NULL,
153
+ workflow_snapshot TEXT,
149
154
  status TEXT NOT NULL,
150
155
  current_action_id TEXT,
151
156
  current_run_id TEXT,
@@ -163,6 +168,9 @@ export class WorkItemStore {
163
168
  sequence INTEGER NOT NULL,
164
169
  type TEXT NOT NULL,
165
170
  required_role TEXT NOT NULL,
171
+ stage_id TEXT,
172
+ assignment_policy TEXT,
173
+ model_policy TEXT,
166
174
  instruction TEXT NOT NULL,
167
175
  context TEXT NOT NULL DEFAULT '[]',
168
176
  contract_revision INTEGER NOT NULL DEFAULT 1,
@@ -241,6 +249,18 @@ export class WorkItemStore {
241
249
  if (!hasColumn(this.db, 'runs', 'contract_patch')) {
242
250
  this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
243
251
  }
252
+ if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
253
+ this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
254
+ }
255
+ if (!hasColumn(this.db, 'actions', 'stage_id')) {
256
+ this.db.exec('ALTER TABLE actions ADD COLUMN stage_id TEXT');
257
+ }
258
+ if (!hasColumn(this.db, 'actions', 'assignment_policy')) {
259
+ this.db.exec('ALTER TABLE actions ADD COLUMN assignment_policy TEXT');
260
+ }
261
+ if (!hasColumn(this.db, 'actions', 'model_policy')) {
262
+ this.db.exec('ALTER TABLE actions ADD COLUMN model_policy TEXT');
263
+ }
244
264
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
245
265
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
246
266
  }
@@ -269,15 +289,16 @@ export class WorkItemStore {
269
289
  const id = input.id || randomUUID();
270
290
  const workspaceKey = canonicalWorkspaceKey(input.workDir);
271
291
  this.db.prepare(`INSERT INTO work_items
272
- (id, revision, title, goal, acceptance_criteria, workflow_template, status,
292
+ (id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
273
293
  current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
274
294
  created_at, updated_at)
275
- VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
295
+ VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
276
296
  id,
277
297
  input.title,
278
298
  input.goal,
279
299
  stringify(input.acceptanceCriteria || []),
280
300
  input.workflowTemplate || 'software-change',
301
+ stringify(input.workflowSnapshot || null),
281
302
  firstAction ? 'ready' : 'draft',
282
303
  input.workDir || '',
283
304
  workspaceKey,
@@ -303,7 +324,10 @@ export class WorkItemStore {
303
324
  workItemId,
304
325
  sequence,
305
326
  type: input.type,
306
- requiredRole: input.requiredRole,
327
+ stageId: input.stageId || input.type,
328
+ assignmentPolicy: input.assignmentPolicy || null,
329
+ modelPolicy: input.modelPolicy || null,
330
+ requiredRole: input.requiredRole || '',
307
331
  instruction: input.instruction || '',
308
332
  context: Array.isArray(input.context) ? input.context : [],
309
333
  contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
@@ -316,15 +340,18 @@ export class WorkItemStore {
316
340
  updatedAt: now,
317
341
  };
318
342
  this.db.prepare(`INSERT INTO actions
319
- (id, work_item_id, sequence, type, required_role, instruction, context,
320
- contract_revision, status, attempt, max_attempts, current_run_id, lease_epoch,
321
- created_at, updated_at)
322
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
343
+ (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
344
+ instruction, context, contract_revision, status, attempt, max_attempts, current_run_id,
345
+ lease_epoch, created_at, updated_at)
346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
323
347
  action.id,
324
348
  workItemId,
325
349
  action.sequence,
326
350
  action.type,
327
351
  action.requiredRole,
352
+ action.stageId,
353
+ stringify(action.assignmentPolicy),
354
+ stringify(action.modelPolicy),
328
355
  action.instruction,
329
356
  stringify(action.context),
330
357
  action.contractRevision,
@@ -358,6 +385,16 @@ export class WorkItemStore {
358
385
  return mapRun(this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
359
386
  }
360
387
 
388
+ listCompletedRuns(workItemId) {
389
+ return this.db.prepare(`SELECT r.*, a.type AS action_type FROM runs r
390
+ JOIN actions a ON a.id = r.action_id
391
+ WHERE r.work_item_id = ? AND r.status != 'running'
392
+ ORDER BY r.started_at ASC`).all(workItemId).map(row => ({
393
+ ...mapRun(row),
394
+ actionType: row.action_type,
395
+ }));
396
+ }
397
+
361
398
  listWorkItems(filters = {}) {
362
399
  const where = [];
363
400
  const values = [];
@@ -1,13 +1,35 @@
1
- const SOFTWARE_CHANGE_STEPS = Object.freeze([
2
- { type: 'triage', requiredRole: 'omni' },
3
- { type: 'implement', requiredRole: 'linus' },
4
- { type: 'review', requiredRole: 'martin' },
5
- { type: 'deliver', requiredRole: 'linus' },
6
- ]);
1
+ const STAGE_TYPES = new Set(['triage', 'implement', 'test', 'review', 'deliver', 'research', 'write', 'custom']);
2
+ const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed']);
3
+ const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
4
+ const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
7
5
 
8
- export const WORKFLOW_TEMPLATES = Object.freeze({
9
- 'software-change': SOFTWARE_CHANGE_STEPS,
10
- });
6
+ const DEFAULT_SOFTWARE_CHANGE_STAGES = Object.freeze([
7
+ {
8
+ id: 'triage', name: 'Triage', type: 'triage',
9
+ assignmentPolicy: { mode: 'auto', capability: 'triage', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
10
+ modelPolicy: { mode: 'inherit', model: null, effort: null },
11
+ maxAttempts: 2,
12
+ },
13
+ {
14
+ id: 'implement', name: 'Implement', type: 'implement',
15
+ assignmentPolicy: { mode: 'auto', capability: 'implement', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
16
+ modelPolicy: { mode: 'inherit', model: null, effort: null },
17
+ maxAttempts: 2,
18
+ },
19
+ {
20
+ id: 'review', name: 'Review', type: 'review',
21
+ assignmentPolicy: { mode: 'auto', capability: 'review', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: ['implement'] },
22
+ modelPolicy: { mode: 'inherit', model: null, effort: null },
23
+ maxAttempts: 2,
24
+ changesRequestedStageId: 'implement',
25
+ },
26
+ {
27
+ id: 'deliver', name: 'Deliver', type: 'deliver',
28
+ assignmentPolicy: { mode: 'auto', capability: 'deliver', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
29
+ modelPolicy: { mode: 'inherit', model: null, effort: null },
30
+ maxAttempts: 2,
31
+ },
32
+ ]);
11
33
 
12
34
  export const RUN_OUTCOMES = Object.freeze([
13
35
  'completed',
@@ -16,23 +38,204 @@ export const RUN_OUTCOMES = Object.freeze([
16
38
  'failed',
17
39
  ]);
18
40
 
19
- export function getWorkflowSteps(template = 'software-change') {
20
- const steps = WORKFLOW_TEMPLATES[template];
21
- if (!steps) throw new Error(`Unsupported Work Center workflow: ${template}`);
22
- return steps;
41
+ function cleanId(value, fallback) {
42
+ const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
43
+ return normalized || fallback;
44
+ }
45
+
46
+ function uniqueStrings(value) {
47
+ if (!Array.isArray(value)) return [];
48
+ return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))];
49
+ }
50
+
51
+ export function normalizeAssignmentPolicy(value, stageType = 'custom') {
52
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
53
+ const mode = ASSIGNMENT_MODES.has(source.mode) ? source.mode : 'auto';
54
+ const fixedVpId = typeof source.fixedVpId === 'string' && source.fixedVpId.trim()
55
+ ? source.fixedVpId.trim()
56
+ : null;
57
+ const candidateVpIds = uniqueStrings(source.candidateVpIds);
58
+ if (mode === 'fixed' && !fixedVpId) throw new Error('Fixed Work Center assignment requires fixedVpId');
59
+ if (mode === 'pool' && candidateVpIds.length === 0) throw new Error('Work Center VP pool cannot be empty');
60
+ return {
61
+ mode,
62
+ capability: cleanId(source.capability, stageType),
63
+ candidateVpIds,
64
+ fixedVpId,
65
+ separateFromStageTypes: uniqueStrings(source.separateFromStageTypes),
66
+ };
67
+ }
68
+
69
+ export function normalizeModelPolicy(value) {
70
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
71
+ const mode = MODEL_MODES.has(source.mode) ? source.mode : 'inherit';
72
+ const model = typeof source.model === 'string' && source.model.trim() ? source.model.trim() : null;
73
+ const effort = MODEL_EFFORTS.has(source.effort) ? source.effort : null;
74
+ if (mode === 'specific' && !model) throw new Error('Specific Work Center model policy requires a model');
75
+ return { mode, model, effort };
76
+ }
77
+
78
+ export function normalizeWorkflowDefinition(value, index = 0) {
79
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
80
+ throw new Error('Work Center workflow must be an object');
81
+ }
82
+ const id = cleanId(value.id, `workflow-${index + 1}`);
83
+ const name = String(value.name || id).trim() || id;
84
+ if (!Array.isArray(value.stages) || value.stages.length === 0) {
85
+ throw new Error(`Work Center workflow "${id}" requires at least one stage`);
86
+ }
87
+ const seen = new Set();
88
+ const stages = value.stages.map((rawStage, stageIndex) => {
89
+ const source = rawStage && typeof rawStage === 'object' ? rawStage : {};
90
+ const type = STAGE_TYPES.has(source.type) ? source.type : 'custom';
91
+ const stageId = cleanId(source.id, `${type}-${stageIndex + 1}`);
92
+ if (seen.has(stageId)) throw new Error(`Duplicate Work Center stage id: ${stageId}`);
93
+ seen.add(stageId);
94
+ const stage = {
95
+ id: stageId,
96
+ name: String(source.name || stageId).trim() || stageId,
97
+ type,
98
+ instruction: typeof source.instruction === 'string' ? source.instruction.trim() : '',
99
+ assignmentPolicy: normalizeAssignmentPolicy(source.assignmentPolicy, type),
100
+ modelPolicy: normalizeModelPolicy(source.modelPolicy),
101
+ maxAttempts: Math.min(Math.max(Number(source.maxAttempts) || 2, 1), 5),
102
+ };
103
+ if (type === 'review') {
104
+ stage.changesRequestedStageId = cleanId(source.changesRequestedStageId, 'implement');
105
+ }
106
+ return stage;
107
+ });
108
+ for (const [stageIndex, stage] of stages.entries()) {
109
+ if (stage.type !== 'review') continue;
110
+ const targetIndex = stages.findIndex(candidate => candidate.id === stage.changesRequestedStageId);
111
+ if (targetIndex === -1) {
112
+ throw new Error(`Review stage "${stage.id}" points to missing stage "${stage.changesRequestedStageId}"`);
113
+ }
114
+ const target = stages[targetIndex];
115
+ if (targetIndex >= stageIndex || target.type === 'review' || target.type === 'deliver') {
116
+ throw new Error(`Review stage "${stage.id}" must return to an earlier editable stage`);
117
+ }
118
+ }
119
+ return { version: 1, id, name, stages };
120
+ }
121
+
122
+ export function defaultWorkCenterSettings() {
123
+ return {
124
+ version: 1,
125
+ revision: 1,
126
+ defaultWorkflowId: 'software-change',
127
+ startImmediately: true,
128
+ defaultWorkDir: '',
129
+ workflows: [normalizeWorkflowDefinition({
130
+ id: 'software-change',
131
+ name: 'Software change',
132
+ stages: DEFAULT_SOFTWARE_CHANGE_STAGES,
133
+ })],
134
+ };
23
135
  }
24
136
 
25
- export function getStep(template, type) {
26
- return getWorkflowSteps(template).find(step => step.type === type) || null;
137
+ export function normalizeWorkCenterSettings(value) {
138
+ const defaults = defaultWorkCenterSettings();
139
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
140
+ const workflows = Array.isArray(source.workflows) && source.workflows.length > 0
141
+ ? source.workflows.map(normalizeWorkflowDefinition)
142
+ : defaults.workflows;
143
+ const workflowIds = new Set();
144
+ for (const workflow of workflows) {
145
+ if (workflowIds.has(workflow.id)) throw new Error(`Duplicate Work Center workflow id: ${workflow.id}`);
146
+ workflowIds.add(workflow.id);
147
+ }
148
+ const defaultWorkflowId = workflowIds.has(source.defaultWorkflowId)
149
+ ? source.defaultWorkflowId
150
+ : workflows[0].id;
151
+ const revision = Number.isInteger(source.revision) && source.revision > 0 ? source.revision : 1;
152
+ return {
153
+ version: 1,
154
+ revision,
155
+ defaultWorkflowId,
156
+ startImmediately: source.startImmediately !== false,
157
+ defaultWorkDir: typeof source.defaultWorkDir === 'string' ? source.defaultWorkDir.trim() : '',
158
+ workflows,
159
+ };
27
160
  }
28
161
 
29
- export function getNextStep(template, type, result = {}) {
30
- const steps = getWorkflowSteps(template);
31
- const index = steps.findIndex(step => step.type === type);
32
- if (index === -1) throw new Error(`Action type ${type} is not in workflow ${template}`);
33
- if (type === 'review') {
162
+ export function resolveWorkflowSnapshot(settings, workflowId, stageOverrides = {}) {
163
+ const normalized = normalizeWorkCenterSettings(settings);
164
+ const id = workflowId || normalized.defaultWorkflowId;
165
+ const workflow = normalized.workflows.find(item => item.id === id);
166
+ if (!workflow) throw new Error(`Unsupported Work Center workflow: ${id}`);
167
+ const overrides = stageOverrides && typeof stageOverrides === 'object' && !Array.isArray(stageOverrides)
168
+ ? stageOverrides
169
+ : {};
170
+ return normalizeWorkflowDefinition({
171
+ ...workflow,
172
+ stages: workflow.stages.map(stage => {
173
+ const override = overrides[stage.id];
174
+ if (!override || typeof override !== 'object' || Array.isArray(override)) return stage;
175
+ return {
176
+ ...stage,
177
+ assignmentPolicy: override.assignmentPolicy
178
+ ? { ...stage.assignmentPolicy, ...override.assignmentPolicy }
179
+ : stage.assignmentPolicy,
180
+ modelPolicy: override.modelPolicy
181
+ ? { ...stage.modelPolicy, ...override.modelPolicy }
182
+ : stage.modelPolicy,
183
+ };
184
+ }),
185
+ });
186
+ }
187
+
188
+ const LEGACY_SOFTWARE_CHANGE_VPS = Object.freeze({
189
+ triage: 'omni',
190
+ implement: 'linus',
191
+ review: 'martin',
192
+ deliver: 'linus',
193
+ });
194
+
195
+ function workflowFrom(source = 'software-change') {
196
+ if (source && typeof source === 'object' && source.workflowSnapshot?.stages) {
197
+ return normalizeWorkflowDefinition(source.workflowSnapshot);
198
+ }
199
+ if (source && typeof source === 'object' && source.stages) return normalizeWorkflowDefinition(source);
200
+ const settings = defaultWorkCenterSettings();
201
+ const workflow = resolveWorkflowSnapshot(settings, typeof source === 'string' ? source : 'software-change');
202
+ // Existing WorkItems did not persist a workflow snapshot. Preserve their
203
+ // historical deterministic assignments; only newly created WorkItems use
204
+ // pool selection. This avoids silently changing in-flight durable work.
205
+ return {
206
+ ...workflow,
207
+ stages: workflow.stages.map(stage => ({
208
+ ...stage,
209
+ assignmentPolicy: {
210
+ ...stage.assignmentPolicy,
211
+ mode: 'fixed',
212
+ fixedVpId: LEGACY_SOFTWARE_CHANGE_VPS[stage.type],
213
+ },
214
+ })),
215
+ };
216
+ }
217
+
218
+ export function getWorkflowSteps(source = 'software-change') {
219
+ return workflowFrom(source).stages;
220
+ }
221
+
222
+ export function getStep(source, stageIdOrType) {
223
+ return getWorkflowSteps(source).find(stage => stage.id === stageIdOrType)
224
+ || getWorkflowSteps(source).find(stage => stage.type === stageIdOrType)
225
+ || null;
226
+ }
227
+
228
+ export function getNextStep(source, stageIdOrType, result = {}) {
229
+ const steps = getWorkflowSteps(source);
230
+ const index = steps.findIndex(stage => stage.id === stageIdOrType)
231
+ !== -1
232
+ ? steps.findIndex(stage => stage.id === stageIdOrType)
233
+ : steps.findIndex(stage => stage.type === stageIdOrType);
234
+ if (index === -1) throw new Error(`Work Center stage ${stageIdOrType} is not in the workflow snapshot`);
235
+ const current = steps[index];
236
+ if (current.type === 'review') {
34
237
  if (result.reviewDecision === 'changes_requested') {
35
- return steps.find(step => step.type === 'implement') || null;
238
+ return steps.find(stage => stage.id === current.changesRequestedStageId) || null;
36
239
  }
37
240
  if (result.reviewDecision !== 'approved') {
38
241
  throw new Error('Completed review requires approved or changes_requested');
@@ -55,44 +258,61 @@ function renderContext(context = []) {
55
258
  const waitingReason = entry.waitingReason ? `\nWaiting reason: ${entry.waitingReason}` : '';
56
259
  const answer = entry.answer ? `\nUser answer: ${entry.answer}` : '';
57
260
  const source = entry.sourceTitle ? ` from ${entry.sourceTitle}` : '';
58
- return `### ${entry.type}${source} (${entry.role || 'unknown role'})\n${entry.summary || '(no summary)'}${decision}${waitingReason}${answer}${evidence}`;
261
+ return `### ${entry.type}${source} (${entry.vpId || entry.role || 'unknown VP'})\n${entry.summary || '(no summary)'}${decision}${waitingReason}${answer}${evidence}`;
59
262
  });
60
263
  return `\n\nReusable Work Center context and prior Action results:\n${blocks.join('\n\n')}`;
61
264
  }
62
265
 
63
- export function actionInstruction(step, workItem, context = []) {
266
+ export function actionInstruction(stage, workItem, context = []) {
64
267
  const criteria = (workItem.acceptanceCriteria || []).map(item => `- ${item}`).join('\n') || '- No explicit criteria';
65
268
  const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${renderContext(context)}`;
66
- switch (step.type) {
269
+ if (stage.instruction) return `${common}\n\n${stage.instruction}`;
270
+ switch (stage.type) {
67
271
  case 'triage':
68
272
  return `${common}\n\nAnalyze the request, verify scope and risks, and make the contract executable. Do not implement yet. If the goal or acceptance criteria need refinement, submit a contractPatch.`;
69
273
  case 'implement':
70
274
  return `${common}\n\nImplement the smallest correct change in the supplied work directory. Add and run relevant tests. Use the prior triage/review findings. Do not approve your own work.`;
275
+ case 'test':
276
+ return `${common}\n\nVerify the implementation against the acceptance criteria. Run focused tests and report reproducible evidence. Do not modify unrelated code.`;
71
277
  case 'review':
72
278
  return `${common}\n\nReview the implementation and evidence independently. Return approved or changes_requested with concrete findings.`;
73
279
  case 'deliver':
74
280
  return `${common}\n\nDeliver the approved change using the repository release policy. Verify the final remote state and provide evidence.`;
281
+ case 'research':
282
+ return `${common}\n\nResearch the question using verifiable sources. Separate evidence from inference and return a concise synthesis.`;
283
+ case 'write':
284
+ return `${common}\n\nProduce the requested written deliverable, then verify it against every acceptance criterion.`;
75
285
  default:
76
- return common;
286
+ return `${common}\n\nComplete this stage and return verifiable evidence.`;
77
287
  }
78
288
  }
79
289
 
80
- export function initialActionFor(workItem) {
81
- const step = getWorkflowSteps(workItem.workflowTemplate)[0];
290
+ export function actionForStage(stage, workItem, context = []) {
82
291
  return {
83
- ...step,
84
- context: [],
85
- instruction: actionInstruction(step, workItem, []),
86
- maxAttempts: 2,
292
+ type: stage.type,
293
+ stageId: stage.id,
294
+ assignmentPolicy: stage.assignmentPolicy,
295
+ modelPolicy: stage.modelPolicy,
296
+ // Storage compatibility for databases created before assignment policies.
297
+ requiredRole: stage.assignmentPolicy.mode === 'fixed' ? stage.assignmentPolicy.fixedVpId : '',
298
+ context,
299
+ instruction: actionInstruction(stage, workItem, context),
300
+ maxAttempts: stage.maxAttempts,
87
301
  };
88
302
  }
89
303
 
304
+ export function initialActionFor(workItem) {
305
+ const stage = getWorkflowSteps(workItem)[0];
306
+ return actionForStage(stage, workItem, []);
307
+ }
308
+
90
309
  export function actionContextFromRuns(runs = []) {
91
310
  return runs
92
311
  .filter(run => run && run.summary)
93
312
  .map(run => ({
94
313
  type: run.actionType || 'action',
95
- role: run.roleSnapshot?.id || run.vpSnapshot?.id || null,
314
+ vpId: run.vpSnapshot?.id || null,
315
+ role: run.roleSnapshot?.id || null,
96
316
  summary: run.summary,
97
317
  evidence: Array.isArray(run.evidence) ? run.evidence : [],
98
318
  reviewDecision: run.reviewDecision || null,