@yeaft/webchat-agent 1.0.129 → 1.0.131

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.129",
3
+ "version": "1.0.131",
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,
@@ -69,12 +71,29 @@ export class WorkflowController {
69
71
  workflowTemplate: input.workflowTemplate || 'software-change',
70
72
  acceptanceCriteria: Array.isArray(input.acceptanceCriteria) ? input.acceptanceCriteria : [],
71
73
  };
72
- const firstAction = input.start !== false ? initialActionFor(draft) : null;
74
+ let firstAction = input.start !== false ? initialActionFor(draft) : null;
75
+ if (firstAction && draft.reuseMemory !== false) {
76
+ const context = this.store.getReusableContext(draft.workDir, draft.id);
77
+ firstAction = {
78
+ ...firstAction,
79
+ context,
80
+ instruction: actionInstruction(firstAction, draft, context),
81
+ };
82
+ }
73
83
  return this.store.createWorkItem(draft, firstAction);
74
84
  }
75
85
 
76
86
  start(id) {
77
- const detail = this.store.startWorkItemAtomic(id, initialActionFor);
87
+ const detail = this.store.startWorkItemAtomic(id, workItem => {
88
+ const action = initialActionFor(workItem);
89
+ if (workItem.reuseMemory === false) return action;
90
+ const context = this.store.getReusableContext(workItem.workDir, workItem.id);
91
+ return {
92
+ ...action,
93
+ context,
94
+ instruction: actionInstruction(action, workItem, context),
95
+ };
96
+ });
78
97
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
79
98
  return detail;
80
99
  }
@@ -91,6 +110,41 @@ export class WorkflowController {
91
110
  return this.store.getWorkItemDetail(id);
92
111
  }
93
112
 
113
+ guide(id, input = {}) {
114
+ const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
115
+ if (!guidance) throw new Error('guidance is required');
116
+ const expected = {
117
+ actionId: typeof input.actionId === 'string' ? input.actionId : '',
118
+ revision: Number(input.revision),
119
+ };
120
+ if (!expected.actionId || !Number.isInteger(expected.revision)) {
121
+ throw new Error('actionId and revision are required for guidance');
122
+ }
123
+ const detail = this.store.addActionGuidance(id, guidance, expected, (workItem, previous) => {
124
+ const context = [...(previous.context || []), {
125
+ type: 'guidance',
126
+ role: 'user',
127
+ summary: guidance,
128
+ evidence: [],
129
+ }];
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
+ };
137
+ return {
138
+ ...step,
139
+ context,
140
+ instruction: actionInstruction(step, workItem, context),
141
+ maxAttempts: previous.maxAttempts || 2,
142
+ };
143
+ });
144
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
145
+ return detail;
146
+ }
147
+
94
148
  retry(id, input = {}) {
95
149
  const answer = typeof input.answer === 'string' ? input.answer.trim().slice(0, 8_000) : '';
96
150
  const detail = this.store.retryWorkItemAtomic(id, (workItem, previous, previousRun) => {
@@ -98,13 +152,21 @@ export class WorkflowController {
98
152
  throw new Error('answer is required to resume a waiting WorkItem');
99
153
  }
100
154
  const step = previous
101
- ? { 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
+ }
102
162
  : initialActionFor(workItem);
103
163
  const context = Array.isArray(previous?.context) ? [...previous.context] : [];
104
164
  if (previousRun) {
105
165
  context.push({
106
166
  type: previous.type,
107
- 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,
108
170
  summary: previousRun.summary || '',
109
171
  evidence: normalizeEvidence(previousRun.evidence),
110
172
  waitingReason: previousRun.waitingReason || null,
@@ -177,7 +239,7 @@ export class WorkflowController {
177
239
  revision: workItem.revision + 1,
178
240
  }
179
241
  : workItem;
180
- const nextStep = getNextStep(workItem.workflowTemplate, action.type, result);
242
+ const nextStep = getNextStep(workItem, action.stageId || action.type, result);
181
243
  if (!nextStep) {
182
244
  return {
183
245
  actionStatus: 'completed',
@@ -188,17 +250,12 @@ export class WorkflowController {
188
250
  };
189
251
  }
190
252
 
191
- const context = [...(action.context || []), contextEntry(action, result)];
253
+ const context = [...(action.context || []), contextEntry(action, result, activeRun)];
192
254
  return {
193
255
  actionStatus: 'completed',
194
256
  workItemStatus: 'ready',
195
257
  contractPatch,
196
- nextAction: {
197
- ...nextStep,
198
- context,
199
- instruction: actionInstruction(nextStep, effectiveWorkItem, context),
200
- maxAttempts: 2,
201
- },
258
+ nextAction: actionForStage(nextStep, effectiveWorkItem, context),
202
259
  eventType: 'action.completed',
203
260
  eventData: {
204
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,