@yeaft/webchat-agent 1.0.156 → 1.0.158

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.156",
3
+ "version": "1.0.158",
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",
@@ -41,6 +41,7 @@ import {
41
41
  } from './adapter.js';
42
42
  import {
43
43
  normalizeEffort,
44
+ getModelEffortOptions,
44
45
  getThinkingCapability,
45
46
  mapEffortToOpenAIReasoning,
46
47
  } from '../models.js';
@@ -57,10 +58,9 @@ function thinkingV1Enabled() {
57
58
  }
58
59
 
59
60
  /**
60
- * Translate a normalised effort ('minimal'|'low'|'medium'|'high'|'xhigh'|'max') into the value
61
- * accepted by the OpenAI Responses `reasoning.effort` field. Responses today
62
- * accepts 'minimal'|'low'|'medium'|'high'|'xhigh'. Unsupported Anthropic-only
63
- * adaptive efforts must be dropped, not downgraded.
61
+ * Translate a normalised effort into the OpenAI Responses `reasoning.effort`
62
+ * field. Model capability filtering happens at the call site because `max` is
63
+ * supported by GPT-5.6 but not by older OpenAI reasoning models.
64
64
  */
65
65
  function effortForResponses(effort) {
66
66
  return mapEffortToOpenAIReasoning(effort);
@@ -248,7 +248,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
248
248
  // ─── Streaming ──────────────────────────────────────────
249
249
 
250
250
  /**
251
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
251
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'minimal'|'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void, onRequestStart?: () => void }} params
252
252
  *
253
253
  * NOTE on `extraBody`: any keys you spread here are merged verbatim into
254
254
  * the wire body and — because the verbatim debug feature is intentionally
@@ -257,7 +257,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
257
257
  * `api-key` headers are auto-redacted (see `redactRawRequest` in
258
258
  * `adapter.js`); request-body fields are caller-controlled.
259
259
  */
260
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, extraBody, signal, onRawExchange, onRequestStart }) {
260
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, signal, onRawExchange, onRequestStart }) {
261
261
  if (signal?.aborted) throw new LLMAbortError();
262
262
 
263
263
  const body = {
@@ -276,8 +276,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
276
276
  // Unknown / unsupported models silently drop the field.
277
277
  const normEffort = normalizeEffort(effort);
278
278
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
279
- const cap = getThinkingCapability(model);
280
- if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
279
+ const cap = getThinkingCapability(model, effortContext);
280
+ const supportedEfforts = getModelEffortOptions(model, effortContext);
281
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
281
282
  const wireEffort = effortForResponses(normEffort);
282
283
  if (wireEffort) body.reasoning = { effort: wireEffort };
283
284
  }
@@ -517,7 +518,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
517
518
  * expose them, mirror the stream() instrumentation. Parity with
518
519
  * anthropic.js's `call()`.
519
520
  */
520
- async call({ model, system, messages, maxTokens = 4096, effort, effortSource, extraBody, signal, onRequestStart }) {
521
+ async call({ model, system, messages, maxTokens = 4096, effort, effortSource, effortContext = {}, extraBody, signal, onRequestStart }) {
521
522
  if (signal?.aborted) throw new LLMAbortError();
522
523
 
523
524
  const body = {
@@ -530,8 +531,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
530
531
  // Mirror stream()'s thinking injection for non-streaming side queries.
531
532
  const normEffort = normalizeEffort(effort);
532
533
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
533
- const cap = getThinkingCapability(model);
534
- if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
534
+ const cap = getThinkingCapability(model, effortContext);
535
+ const supportedEfforts = getModelEffortOptions(model, effortContext);
536
+ if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning' && supportedEfforts.includes(normEffort)) {
535
537
  const wireEffort = effortForResponses(normEffort);
536
538
  if (wireEffort) body.reasoning = { effort: wireEffort };
537
539
  }
package/yeaft/models.js CHANGED
@@ -34,7 +34,7 @@ import { lookupModelLimitSync } from './llm/models-dev.js';
34
34
  * @property {'anthropic' | 'anthropic-adaptive' | 'openai-reasoning' | 'none'} [thinkingProtocol] — task-327a:
35
35
  * 'anthropic' → thinking:{type:'enabled', budget_tokens:N}
36
36
  * 'anthropic-adaptive' → thinking:{type:'adaptive'} + output_config:{effort}
37
- * 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'}
37
+ * 'openai-reasoning' → reasoning:{effort:'minimal'|'low'|'medium'|'high'|'xhigh'|'max'}
38
38
  * 'none' (default) → parameter silently dropped by router
39
39
  * @property {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null} [defaultEffort] — adapter-level default
40
40
  * when caller doesn't specify effort (null = no default / decision-tree decides).
@@ -405,12 +405,12 @@ export const ANTHROPIC_THINKING_BUDGETS = {
405
405
  };
406
406
 
407
407
  /**
408
- * Map a Yeaft effort level to the OpenAI reasoning.effort enum. Current
409
- * OpenAI APIs expose xhigh, but not Anthropic's 'max'. Unsupported values are
410
- * dropped by returning null; the adapter MUST NOT error.
408
+ * Map a Yeaft effort level to the OpenAI reasoning.effort enum. Supported
409
+ * values are model-dependent; capability filtering happens before this wire
410
+ * translation. Unsupported values are dropped; the adapter MUST NOT error.
411
411
  *
412
412
  * @param {Effort} effort
413
- * @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | null}
413
+ * @returns {'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null}
414
414
  */
415
415
  export function mapEffortToOpenAIReasoning(effort) {
416
416
  if (!effort) return null;
@@ -420,11 +420,13 @@ export function mapEffortToOpenAIReasoning(effort) {
420
420
  case 'medium': return 'medium';
421
421
  case 'high': return 'high';
422
422
  case 'xhigh': return 'xhigh';
423
+ case 'max': return 'max';
423
424
  default: return null;
424
425
  }
425
426
  }
426
427
 
427
428
  export const OPENAI_REASONING_EFFORT_OPTIONS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
429
+ export const OPENAI_MAX_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
428
430
  export const ANTHROPIC_MANUAL_EFFORT_OPTIONS = ['low', 'medium', 'high'];
429
431
  export const ANTHROPIC_ADAPTIVE_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
430
432
  export const ANTHROPIC_ADAPTIVE_MAX_EFFORT_OPTIONS = ['low', 'medium', 'high', 'max'];
@@ -454,6 +456,19 @@ function inferThinkingCapability(model) {
454
456
  const id = parseModelRef(model).modelId.toLowerCase();
455
457
  if (!id) return null;
456
458
 
459
+ // GPT-5.6 adds the model-specific `max` tier. Keep this ahead of the generic
460
+ // GPT inference so older OpenAI models do not advertise a value they reject.
461
+ // Provider suffixes such as gpt-5.6-sol are variants of the same model family.
462
+ if (/^gpt-5\.6($|[-.])/.test(id)) {
463
+ return {
464
+ supportsThinking: true,
465
+ thinkingProtocol: 'openai-reasoning',
466
+ defaultEffort: null,
467
+ maxBudgetTokens: null,
468
+ effortOptions: OPENAI_MAX_REASONING_EFFORT_OPTIONS,
469
+ };
470
+ }
471
+
457
472
  if (/^(gpt-5|o1|o3|o4|chatgpt-|codex-)/.test(id)) {
458
473
  return { supportsThinking: true, thinkingProtocol: 'openai-reasoning', defaultEffort: null, maxBudgetTokens: null };
459
474
  }
@@ -32,6 +32,10 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
32
32
  items: { type: 'string' },
33
33
  description: { en: 'Verifiable completion criteria', zh: '可验证的完成条件' },
34
34
  },
35
+ workItemType: {
36
+ type: 'string',
37
+ description: { en: 'Optional explicit Work Item type; omit or use auto for LLM inference', zh: '可选的工作项类型;省略或填写 auto 时由 LLM 推断' },
38
+ },
35
39
  workDir: {
36
40
  type: 'string',
37
41
  description: { en: 'Existing project directory for execution', zh: '执行时使用的已存在项目目录' },
@@ -59,6 +63,7 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
59
63
  title,
60
64
  goal,
61
65
  acceptanceCriteria: cleanCriteria(input.acceptanceCriteria),
66
+ workItemType: typeof input.workItemType === 'string' ? input.workItemType.trim() : 'auto',
62
67
  workDir: typeof input.workDir === 'string' ? input.workDir.trim() : (ctx.cwd || ''),
63
68
  // The Agent-local Work Center settings choose the default workflow and
64
69
  // freeze its policy snapshot. Tool callers create the contract; they do
@@ -22,7 +22,7 @@ const BROWSER_DETAIL_OPS = new Set(['get', 'create', 'update', 'start', 'cancel'
22
22
  // client-supplied value and only emits files resolved from owned upload ids.
23
23
  const BROWSER_FILE_FIELDS = Object.freeze({
24
24
  create: [
25
- 'title', 'goal', 'acceptanceCriteria', 'workDir', 'reuseMemory', 'origin',
25
+ 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'origin',
26
26
  'linkedSessionIds', 'files', 'start',
27
27
  ],
28
28
  guide: ['id', 'guidance', 'actionId', 'revision', 'files'],
@@ -195,6 +195,7 @@ export class WorkflowController {
195
195
  assignmentPolicy: previous.assignmentPolicy,
196
196
  modelPolicy: previous.modelPolicy,
197
197
  requiredRole: previous.requiredRole,
198
+ brief: previous.brief,
198
199
  };
199
200
  return {
200
201
  ...step,
@@ -220,6 +221,7 @@ export class WorkflowController {
220
221
  assignmentPolicy: previous.assignmentPolicy,
221
222
  modelPolicy: previous.modelPolicy,
222
223
  requiredRole: previous.requiredRole,
224
+ brief: previous.brief,
223
225
  }
224
226
  : initialActionFor(workItem);
225
227
  const context = Array.isArray(previous?.context) ? [...previous.context] : [];
@@ -1,3 +1,5 @@
1
+ import { normalizeActionBrief } from './workflow.js';
2
+
1
3
  function currentAction(detail) {
2
4
  if (!detail?.currentActionId || !Array.isArray(detail.actions)) return null;
3
5
  return detail.actions.find(action => action.id === detail.currentActionId) || null;
@@ -43,6 +45,7 @@ function actionExecution(action, runs) {
43
45
  ...executionStats(action),
44
46
  response: '',
45
47
  progressRevision: 0,
48
+ messages: [],
46
49
  };
47
50
  }
48
51
  const stats = sumExecutionStats(matchingRuns);
@@ -50,10 +53,21 @@ function actionExecution(action, runs) {
50
53
  count(right.progressRevision) - count(left.progressRevision)
51
54
  || count(right.startedAt) - count(left.startedAt)
52
55
  ))[0];
56
+ const messages = [...matchingRuns]
57
+ .sort((left, right) => count(left.startedAt) - count(right.startedAt))
58
+ .map((run, index) => ({
59
+ id: `${action.id}:${index + 1}`,
60
+ status: run.status || 'running',
61
+ text: typeof run.response === 'string' ? run.response.trim().slice(0, 16_000) : '',
62
+ createdAt: count(run.startedAt),
63
+ updatedAt: count(run.endedAt || run.startedAt),
64
+ }))
65
+ .filter(message => message.text);
53
66
  return {
54
67
  ...stats,
55
68
  response: typeof latest?.response === 'string' ? latest.response : '',
56
69
  progressRevision: count(latest?.progressRevision),
70
+ messages,
57
71
  };
58
72
  }
59
73
 
@@ -87,12 +101,14 @@ function projectAction(action, runs) {
87
101
  stageId: action.stageId || action.type,
88
102
  assignmentPolicy: projectAssignmentPolicy(action.assignmentPolicy),
89
103
  requiredRole: action.requiredRole || '',
104
+ brief: normalizeActionBrief(action.brief, action.type),
90
105
  status: action.status,
91
106
  executionStats: executionStats(execution),
92
107
  loopCount: execution.loopCount,
93
108
  toolCount: execution.toolCount,
94
109
  response: execution.response,
95
110
  progressRevision: execution.progressRevision,
111
+ messages: execution.messages,
96
112
  };
97
113
  }
98
114
 
@@ -108,6 +124,7 @@ function projectActionStats(detail) {
108
124
  toolCount: projected.toolCount,
109
125
  response: projected.response,
110
126
  progressRevision: projected.progressRevision,
127
+ messages: projected.messages,
111
128
  };
112
129
  });
113
130
  }
@@ -145,6 +162,10 @@ export function projectWorkItemDetail(detail) {
145
162
  attachments: projectAttachments(detail.attachments),
146
163
  createdAt: detail.createdAt,
147
164
  updatedAt: detail.updatedAt,
165
+ actionCount: Array.isArray(detail.actions) ? detail.actions.length : 0,
166
+ actionSummary: Array.isArray(detail.actions)
167
+ ? detail.actions.map(action => action.type).filter(Boolean).join(' → ')
168
+ : '',
148
169
  actions: Array.isArray(detail.actions)
149
170
  ? detail.actions.map(action => projectAction(action, detail.runs))
150
171
  : [],
@@ -315,7 +315,7 @@ function completionContract(action, workItem) {
315
315
  ? ',\n "contractPatch": { "goal": "optional refined goal", "acceptanceCriteria": ["optional refined criterion"] }'
316
316
  : '';
317
317
  const planField = action.type === 'triage' && workItem?.workflowSnapshot?.planningMode === 'ai'
318
- ? ',\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|write|custom)", "capability": "specific executor capability", "objective": "independently executable and verifiable Action objective", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
318
+ ? ',\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|write|custom)", "capability": "specific executor capability", "objective": "what this Action must do", "approach": "how this Action should do it", "expectedOutcome": "verifiable result this Action must produce", "separateFromActionTypes": ["optional prior Action type"], "changesRequestedActionId": "for review: optional earlier editable Action id; omit to use nearest", "maxAttempts": 2 }] }'
319
319
  : '';
320
320
  const acceptanceChecks = (workItem?.acceptanceCriteria || []).map(criterion => ({
321
321
  criterion,
@@ -15,6 +15,7 @@ import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
15
15
  import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
16
16
  import {
17
17
  defaultWorkCenterStageInstructions,
18
+ listWorkItemTypeTemplates,
18
19
  resolvePlanningWorkflowSnapshot,
19
20
  resolveWorkflowSnapshot,
20
21
  } from './workflow.js';
@@ -45,10 +46,14 @@ export class WorkCenterService {
45
46
  this.runtimeInfoProvider = typeof options.runtimeInfoProvider === 'function'
46
47
  ? options.runtimeInfoProvider
47
48
  : async () => ({ vps: [], models: [], primaryModel: null, fastModel: null });
48
- this.runtimeInfo = async () => ({
49
- ...(await this.runtimeInfoProvider()),
50
- defaultStageInstructions: defaultWorkCenterStageInstructions(),
51
- });
49
+ this.runtimeInfo = async () => {
50
+ const settings = this.settingsReader(this.yeaftDir);
51
+ return {
52
+ ...(await this.runtimeInfoProvider()),
53
+ defaultStageInstructions: defaultWorkCenterStageInstructions(),
54
+ workItemTypes: listWorkItemTypeTemplates(settings),
55
+ };
56
+ };
52
57
  this.ownerBootId = options.ownerBootId || randomUUID();
53
58
  this.attachmentRoot = options.attachmentRoot || join(yeaftDir, 'work-center', 'attachments');
54
59
  this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
@@ -92,7 +97,7 @@ export class WorkCenterService {
92
97
  const workflowTemplate = explicitWorkflow || 'ai-planned';
93
98
  const workflowSnapshot = explicitWorkflow
94
99
  ? resolveWorkflowSnapshot(settings, explicitWorkflow, payload.stageOverrides)
95
- : resolvePlanningWorkflowSnapshot(settings);
100
+ : resolvePlanningWorkflowSnapshot(settings, payload.workItemType);
96
101
  const runtime = !Object.hasOwn(payload, 'workDir') && !settings.defaultWorkDir
97
102
  ? await this.runtimeInfo()
98
103
  : null;
@@ -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 = 7;
8
+ const SCHEMA_VERSION = 8;
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;
@@ -75,6 +75,7 @@ function mapAction(row) {
75
75
  modelPolicy: parseJson(row.model_policy, null),
76
76
  requiredRole: row.required_role || '',
77
77
  instruction: row.instruction,
78
+ brief: parseJson(row.brief, null),
78
79
  context: parseJson(row.context, []),
79
80
  contractRevision: row.contract_revision,
80
81
  status: row.status,
@@ -202,6 +203,7 @@ export class WorkItemStore {
202
203
  assignment_policy TEXT,
203
204
  model_policy TEXT,
204
205
  instruction TEXT NOT NULL,
206
+ brief TEXT,
205
207
  context TEXT NOT NULL DEFAULT '[]',
206
208
  contract_revision INTEGER NOT NULL DEFAULT 1,
207
209
  status TEXT NOT NULL,
@@ -275,6 +277,9 @@ export class WorkItemStore {
275
277
  if (!hasColumn(this.db, 'work_items', 'reuse_memory')) {
276
278
  this.db.exec('ALTER TABLE work_items ADD COLUMN reuse_memory INTEGER NOT NULL DEFAULT 1');
277
279
  }
280
+ if (!hasColumn(this.db, 'actions', 'brief')) {
281
+ this.db.exec('ALTER TABLE actions ADD COLUMN brief TEXT');
282
+ }
278
283
  if (!hasColumn(this.db, 'actions', 'context')) {
279
284
  this.db.exec("ALTER TABLE actions ADD COLUMN context TEXT NOT NULL DEFAULT '[]'");
280
285
  }
@@ -401,6 +406,7 @@ export class WorkItemStore {
401
406
  modelPolicy: input.modelPolicy || null,
402
407
  requiredRole: input.requiredRole || '',
403
408
  instruction: input.instruction || '',
409
+ brief: input.brief && typeof input.brief === 'object' ? input.brief : null,
404
410
  context: Array.isArray(input.context) ? input.context : [],
405
411
  contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
406
412
  status: input.status || 'ready',
@@ -413,9 +419,9 @@ export class WorkItemStore {
413
419
  };
414
420
  this.db.prepare(`INSERT INTO actions
415
421
  (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
416
- instruction, context, contract_revision, status, attempt, max_attempts, current_run_id,
422
+ instruction, brief, context, contract_revision, status, attempt, max_attempts, current_run_id,
417
423
  lease_epoch, created_at, updated_at)
418
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
424
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
419
425
  action.id,
420
426
  workItemId,
421
427
  action.sequence,
@@ -425,6 +431,7 @@ export class WorkItemStore {
425
431
  stringify(action.assignmentPolicy),
426
432
  stringify(action.modelPolicy),
427
433
  action.instruction,
434
+ stringify(action.brief),
428
435
  stringify(action.context),
429
436
  action.contractRevision,
430
437
  action.status,
@@ -34,6 +34,38 @@ const DEFAULT_STAGE_INSTRUCTIONS = Object.freeze({
34
34
  custom: 'Complete the Action objective using repository facts and the WorkItem contract. State the approach, handle relevant risks and boundary conditions, produce the requested artifact or change, verify the result, and return concrete evidence plus any residual uncertainty.',
35
35
  });
36
36
 
37
+ const DEFAULT_ACTION_BRIEFS = Object.freeze({
38
+ triage: ['Turn the request into a precise, executable Work Item contract and plan.', 'Inspect the relevant facts, resolve scope and risks, then select the smallest reliable Action sequence.', 'A frozen Work Item type, validated contract, and executable Action plan.'],
39
+ research: ['Resolve the question or uncertainty named by this Action with verifiable evidence.', 'Inspect repository facts and authoritative sources, separating observations from inference.', 'A concise evidence-backed conclusion that the next Action can use.'],
40
+ design: ['Define the smallest maintainable solution for this Action.', 'Inspect existing architecture, data flow, compatibility constraints, and failure boundaries.', 'Concrete implementation guidance with explicit trade-offs and risks.'],
41
+ diagnose: ['Establish the root cause of the reported behavior.', 'Reproduce the behavior, trace the responsible path, and distinguish evidence from hypotheses.', 'A proven root cause, minimal correction, and regression-test plan.'],
42
+ implement: ['Implement the required change with the smallest correct diff.', 'Follow repository conventions, handle relevant boundaries, and add focused tests while making the change.', 'A maintainable implementation with focused verification evidence.'],
43
+ migrate: ['Move existing data or behavior to the required shape without losing semantics.', 'Fence compatibility and rollback boundaries, then validate legacy and already-current states.', 'A repeatable or safely fenced migration with representative evidence.'],
44
+ test: ['Verify the current result against the Work Item acceptance criteria.', 'Run focused checks first, then broader risk-appropriate tests including failure and compatibility cases.', 'Reproducible pass/fail evidence for every applicable acceptance criterion.'],
45
+ review: ['Review the current result independently against the Work Item contract.', 'Prioritize correctness, security, data loss, compatibility, and missing tests over style preferences.', 'An approval or concrete change request supported by evidence.'],
46
+ document: ['Update the documentation required by this Action.', 'Align terminology and examples with actual behavior and verify links, commands, and language requirements.', 'Accurate, usable documentation that matches the delivered behavior.'],
47
+ operate: ['Perform the requested operational change safely.', 'Check preconditions, apply safety fences, preserve rollback options, and verify authoritative state.', 'A verified operational postcondition with handoff and rollback evidence.'],
48
+ deliver: ['Deliver the approved Work Item result using repository release policy.', 'Recheck immutable reviewed state, run final gates, and publish only the requested artifacts.', 'A traceable delivery with commit, artifact, deployment, and residual-risk evidence.'],
49
+ write: ['Produce the written deliverable requested by this Action.', 'Use available evidence, the intended audience, and the required terminology and structure.', 'A complete written artifact verified against the acceptance criteria.'],
50
+ custom: ['Complete the domain-specific objective defined for this Action.', 'Use repository facts, handle relevant risks and boundaries, and verify the produced result.', 'The requested artifact or change with concrete evidence and residual uncertainty.'],
51
+ });
52
+
53
+ function defaultActionBrief(type) {
54
+ const [objective, approach, expectedOutcome] = DEFAULT_ACTION_BRIEFS[STAGE_TYPES.has(type) ? type : 'custom'];
55
+ return { objective, approach, expectedOutcome };
56
+ }
57
+
58
+ export function normalizeActionBrief(value, type) {
59
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
60
+ const defaults = defaultActionBrief(type);
61
+ return Object.fromEntries(Object.keys(defaults).map(key => [
62
+ key,
63
+ typeof source[key] === 'string' && source[key].trim()
64
+ ? source[key].trim().slice(0, 2_000)
65
+ : defaults[key],
66
+ ]));
67
+ }
68
+
37
69
  const DEFAULT_SOFTWARE_CHANGE_STAGES = Object.freeze([
38
70
  {
39
71
  id: 'triage', name: 'Triage', type: 'triage',
@@ -145,6 +177,7 @@ export function normalizeWorkflowDefinition(value, index = 0) {
145
177
  id: stageId,
146
178
  name: String(source.name || stageId).trim() || stageId,
147
179
  type,
180
+ ...normalizeActionBrief(source, type),
148
181
  instruction: typeof source.instruction === 'string' && source.instruction.trim()
149
182
  ? source.instruction.trim()
150
183
  : defaultWorkCenterStageInstruction(type),
@@ -179,6 +212,9 @@ export function normalizeWorkflowDefinition(value, index = 0) {
179
212
  globalInstructions: normalizeGlobalInstructions(value.globalInstructions),
180
213
  modelPolicy: normalizeModelPolicy(value.modelPolicy),
181
214
  actionInstructions: normalizeActionInstructions(value.actionInstructions),
215
+ actionTemplates: Array.isArray(value.actionTemplates)
216
+ ? value.actionTemplates.map(template => normalizeWorkflowDefinition(template))
217
+ : [],
182
218
  stages,
183
219
  };
184
220
  }
@@ -243,20 +279,52 @@ export function normalizeWorkCenterSettings(value) {
243
279
  };
244
280
  }
245
281
 
246
- export function resolvePlanningWorkflowSnapshot(settings) {
282
+ export function listWorkItemTypeTemplates(settings) {
247
283
  const normalized = normalizeWorkCenterSettings(settings);
248
- const triageInstruction = `${normalized.actionInstructions.triage}\n\nChoose a specific workItemType and the smallest reliable sequence of 1 to 8 Actions for this WorkItem. Action types are extensible lowercase slugs: use a built-in type when its reusable policy fits, or define a precise domain type when it does not. For every Action, provide an objective that is independently executable and verifiable, plus the capability needed to select the right VP. Add research, design, diagnosis, migration, documentation, operations, testing, independent review, or delivery only when the task requires them. Do not copy a generic workflow.`;
284
+ return normalized.workflows.map(workflow => ({
285
+ id: workflow.workItemType || workflow.id,
286
+ name: workflow.name,
287
+ actionCount: workflow.stages.length,
288
+ }));
289
+ }
290
+
291
+ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType = null) {
292
+ const normalized = normalizeWorkCenterSettings(settings);
293
+ const requestedType = typeof requestedWorkItemType === 'string'
294
+ && requestedWorkItemType.trim()
295
+ && requestedWorkItemType.trim().toLowerCase() !== 'auto'
296
+ ? cleanId(requestedWorkItemType, '').slice(0, 64)
297
+ : '';
298
+ const actionTemplates = normalized.workflows.map(workflow => normalizeWorkflowDefinition({
299
+ ...workflow,
300
+ workItemType: workflow.workItemType || workflow.id,
301
+ }));
302
+ const matchingTemplate = requestedType
303
+ ? actionTemplates.find(template => template.workItemType === requestedType)
304
+ : null;
305
+ if (matchingTemplate) return matchingTemplate;
306
+
307
+ const catalog = actionTemplates
308
+ .map(template => `${template.workItemType}: ${template.name} (${template.stages.map(stage => stage.type).join(' -> ')})`)
309
+ .join('\n');
310
+ const typeInstruction = requestedType
311
+ ? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
312
+ : 'Infer one specific workItemType from the contract.';
313
+ const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReusable Action templates:\n${catalog || '(none)'}\nIf the final type matches a reusable template, return its workItemType and omit actions so Work Center can apply that frozen template. Otherwise generate the smallest reliable sequence of 1 to 8 Actions. Action types are extensible lowercase slugs: use a built-in type when its reusable policy fits, or define a precise domain type when it does not. Every generated Action must state objective (what to do), approach (how to do it), expectedOutcome (the verifiable result), and capability. Add only Actions required by this task. Do not copy a generic workflow.`;
249
314
  return normalizeWorkflowDefinition({
250
315
  id: 'ai-planned',
251
316
  name: 'AI planned',
252
317
  planningMode: 'ai',
318
+ workItemType: requestedType || null,
253
319
  globalInstructions: normalized.globalInstructions,
254
320
  modelPolicy: normalized.modelPolicy,
255
321
  actionInstructions: normalized.actionInstructions,
322
+ actionTemplates,
256
323
  stages: [{
257
324
  id: 'triage',
258
325
  name: 'Triage',
259
326
  type: 'triage',
327
+ ...defaultActionBrief('triage'),
260
328
  instruction: triageInstruction,
261
329
  assignmentPolicy: {
262
330
  mode: 'auto', capability: 'triage', candidateVpIds: [], fixedVpId: null,
@@ -305,8 +373,24 @@ export function applyGeneratedPlan(workItem, rawPlan) {
305
373
  }
306
374
  const workItemType = cleanId(rawPlan.workItemType, '').slice(0, 64);
307
375
  if (!workItemType) throw new Error('AI-planned triage requires a valid workItemType');
376
+ if (source.workItemType && workItemType !== source.workItemType) {
377
+ throw new Error(`AI-planned triage must keep the selected workItemType "${source.workItemType}"`);
378
+ }
379
+ const reusableTemplate = source.actionTemplates.find(template => template.workItemType === workItemType);
380
+ if (reusableTemplate) {
381
+ const templateActions = reusableTemplate.stages.filter(stage => stage.type !== 'triage');
382
+ if (templateActions.length === 0) {
383
+ throw new Error(`Reusable Action template "${workItemType}" has no executable Actions`);
384
+ }
385
+ return normalizeWorkflowDefinition({
386
+ ...source,
387
+ workItemType,
388
+ actionTemplates: [],
389
+ stages: [source.stages[0], ...templateActions],
390
+ });
391
+ }
308
392
  if (!Array.isArray(rawPlan.actions) || rawPlan.actions.length < 1 || rawPlan.actions.length > 8) {
309
- throw new Error('AI-planned triage requires between 1 and 8 Actions');
393
+ throw new Error('AI-planned triage requires between 1 and 8 Actions when no reusable template exists');
310
394
  }
311
395
  const seen = new Set(['triage']);
312
396
  const generated = rawPlan.actions.map((rawAction, index) => {
@@ -318,6 +402,13 @@ export function applyGeneratedPlan(workItem, rawPlan) {
318
402
  seen.add(id);
319
403
  const objective = typeof input.objective === 'string' ? input.objective.trim().slice(0, 2_000) : '';
320
404
  if (!objective) throw new Error(`AI-planned Action "${id}" requires an objective`);
405
+ const defaults = defaultActionBrief(type);
406
+ const approach = typeof input.approach === 'string' && input.approach.trim()
407
+ ? input.approach.trim().slice(0, 2_000)
408
+ : defaults.approach;
409
+ const expectedOutcome = typeof input.expectedOutcome === 'string' && input.expectedOutcome.trim()
410
+ ? input.expectedOutcome.trim().slice(0, 2_000)
411
+ : defaults.expectedOutcome;
321
412
  const actionInstruction = Object.hasOwn(source.actionInstructions, type)
322
413
  ? source.actionInstructions[type]
323
414
  : source.actionInstructions.custom;
@@ -325,7 +416,10 @@ export function applyGeneratedPlan(workItem, rawPlan) {
325
416
  id,
326
417
  name: String(input.name || id).trim().slice(0, 120) || id,
327
418
  type,
328
- instruction: `${actionInstruction}\n\nAction type: ${type}\nAction objective for this WorkItem:\n${objective}`,
419
+ objective,
420
+ approach,
421
+ expectedOutcome,
422
+ instruction: actionInstruction,
329
423
  assignmentPolicy: {
330
424
  mode: 'auto',
331
425
  capability: cleanId(input.capability, type),
@@ -448,7 +542,10 @@ function renderContext(context = []) {
448
542
  export function actionInstruction(stage, workItem, context = []) {
449
543
  const criteria = (workItem.acceptanceCriteria || []).map(item => `- ${item}`).join('\n') || '- No explicit criteria';
450
544
  const common = `WorkItem: ${workItem.title}\nGoal: ${workItem.goal}\nAcceptance criteria:\n${criteria}${renderContext(context)}`;
451
- return `${common}\n\n${stage.instruction || defaultWorkCenterStageInstruction(stage.type)}`;
545
+ const policy = stage.instruction || defaultWorkCenterStageInstruction(stage.type);
546
+ const brief = normalizeActionBrief(stage.brief || stage, stage.type);
547
+ const contract = `Action type: ${stage.type}\nWhat to do:\n${brief.objective}\n\nHow to do it:\n${brief.approach}\n\nExpected result:\n${brief.expectedOutcome}`;
548
+ return `${common}\n\n${policy}\n\n${contract}`;
452
549
  }
453
550
 
454
551
  export function actionForStage(stage, workItem, context = []) {
@@ -459,6 +556,7 @@ export function actionForStage(stage, workItem, context = []) {
459
556
  modelPolicy: stage.modelPolicy,
460
557
  // Storage compatibility for databases created before assignment policies.
461
558
  requiredRole: stage.assignmentPolicy.mode === 'fixed' ? stage.assignmentPolicy.fixedVpId : '',
559
+ brief: normalizeActionBrief(stage, stage.type),
462
560
  context,
463
561
  instruction: actionInstruction(stage, workItem, context),
464
562
  maxAttempts: stage.maxAttempts,