@yeaft/webchat-agent 1.0.530 → 1.0.532

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.530",
3
+ "version": "1.0.532",
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/engine.js CHANGED
@@ -2356,6 +2356,7 @@ export class Engine {
2356
2356
  const resolveCurrentActiveToolNames = () => this.#toolRegistry
2357
2357
  ? resolveActiveToolNames({
2358
2358
  toolNames: registeredToolNames,
2359
+ gitReadAlwaysVisible: this.#config?._gitReadAlwaysVisible === true,
2359
2360
  prompt,
2360
2361
  messages,
2361
2362
  collabToolPolicy: effectiveCollabToolPolicy,
@@ -143,7 +143,10 @@ export function startSubAgent(agent, deps = {}) {
143
143
  subEngine = new Engine({
144
144
  adapter: deps.adapter,
145
145
  trace: deps.trace,
146
- config: { ...deps.config, _readOnly: true },
146
+ config: {
147
+ ...deps.config, _readOnly: true,
148
+ _gitReadAlwaysVisible: (agent.personaData || getPersona(agent.persona))?.id === 'reviewer',
149
+ },
147
150
  conversationStore: null,
148
151
  memoryIndex: deps.memoryIndex || null,
149
152
  memoryStore: deps.memoryStore || null,
@@ -38,6 +38,7 @@ export const SUB_AGENT_MANAGEMENT_TOOL_NAMES = Object.freeze([
38
38
  ]);
39
39
 
40
40
  export const CONDITIONAL_BUILTIN_TOOL_NAMES = new Set([
41
+ 'GitRead',
41
42
  'HistorySearch',
42
43
  'DiskUsage',
43
44
  'ApplyPatch',
@@ -57,6 +58,7 @@ export const CONDITIONAL_BUILTIN_TOOL_NAMES = new Set([
57
58
  'ImageGeneration',
58
59
  ]);
59
60
 
61
+ const GIT_INTENT_RE = /(?:\bgit(?:read)?\b|\bdiff\b|\breview\b|\bcommit(?:s)?\b|\bbranch(?:es)?\b|\bworktree\b|\bpull request\b|\bPR\b|代码审查|审查|评审|提交|分支|工作树|工作区(?:状态|改动)|合并|变更差异)/iu;
60
62
  const HISTORY_INTENT_RE = /(?:\bhistory\b|\b(?:prior|previous) (?:chat|conversation|discussion)\b|\bprevious(?:ly)? discussed\b|\bwhat did we (?:decide|discuss|say|agree)\b|\b(?:our|the) (?:earlier|last) decision\b|历史|之前(?:的)?(?:对话|讨论|会话|决定)|过去(?:的)?会话|我们(?:之前|上次)(?:决定|讨论|说)了什么)/iu;
61
63
  const DISK_INTENT_RE = /(?:\bdisk (?:usage|space|full)\b|\bstorage (?:usage|space|full)\b|\blargest director|\benospc\b|\bno space left on device\b|磁盘(?:占用|空间|已满)|存储空间|目录占用|空间不足)/iu;
62
64
  const PATCH_INTENT_RE = /(?:\b(?:implement|refactor|fix|edit)\b|修复|重构|修改|实现|\bapply (?:a )?patch\b|\bunified diff\b|\bpatch file\b|应用补丁|统一 diff|补丁文件)/iu;
@@ -120,6 +122,7 @@ function matchedMcpTools(intentText, toolNames) {
120
122
  * activeTasks?: object[],
121
123
  * subAgentToolsActivated?: boolean,
122
124
  * imageGenerationConfigured?: boolean,
125
+ * gitReadAlwaysVisible?: boolean,
123
126
  * }} opts
124
127
  * @returns {Set<string>}
125
128
  */
@@ -131,6 +134,7 @@ export function resolveActiveToolNames({
131
134
  activeTasks = [],
132
135
  subAgentToolsActivated = false,
133
136
  imageGenerationConfigured = false,
137
+ gitReadAlwaysVisible = false,
134
138
  } = {}) {
135
139
  const registered = new Set(Array.isArray(toolNames) ? toolNames : []);
136
140
  const active = new Set(ALWAYS_VISIBLE_TOOL_NAMES.filter(name => registered.has(name)));
@@ -139,6 +143,7 @@ export function resolveActiveToolNames({
139
143
  const hasActiveTasks = tasks.length > 0;
140
144
  const hasSubAgentTask = tasks.some(task => task?.kind === 'sub_agent');
141
145
 
146
+ if (gitReadAlwaysVisible || GIT_INTENT_RE.test(intentText)) active.add('GitRead');
142
147
  if (HISTORY_INTENT_RE.test(intentText)) active.add('HistorySearch');
143
148
  if (DISK_INTENT_RE.test(intentText)) active.add('DiskUsage');
144
149
  if (PATCH_INTENT_RE.test(intentText)) active.add('ApplyPatch');
@@ -27,13 +27,13 @@ const COMMON_ARGS = Object.freeze([
27
27
  // Worktree status/diff may invoke clean/process filters even with textconv and
28
28
  // external diff disabled. Discover their keys, never values, and override them
29
29
  // for this process only. Incomplete discovery fails closed.
30
- async function filterOverrides(run, options) {
30
+ async function filterOverrides(run) {
31
31
  const result = await run('git', [
32
32
  ...COMMON_ARGS, 'config', '--null', '--name-only', '--get-regexp',
33
33
  '^filter\\..*\\.(clean|smudge|process|required)$',
34
- ], options);
35
- if (result.truncated || result.timedOut || (result.code !== 0 && result.code !== 1)) {
36
- throw new Error('Cannot safely inspect Git content filters');
34
+ ]);
35
+ if (result.truncated || result.timedOut || result.terminationError || (result.code !== 0 && result.code !== 1)) {
36
+ throw Object.assign(new Error('Cannot safely inspect Git content filters'), { result });
37
37
  }
38
38
  if (result.code === 1) return [];
39
39
  const keys = [...new Set(result.stdout.split('\0').filter(Boolean))];
@@ -47,8 +47,8 @@ async function filterOverrides(run, options) {
47
47
  }
48
48
 
49
49
  function errorOutput(message, operation) {
50
- return JSON.stringify({
51
- error: message, errorEffect: 'none', code: 'invalid_arguments',
50
+ return boundedFailure({
51
+ error: takeUtf8(message, 1024), errorEffect: 'none', code: 'invalid_arguments',
52
52
  hint: `Use only fields for the chosen operation. Minimal example: ${JSON.stringify({ operation: ['status', 'diff', 'show', 'log'].includes(operation) ? operation : 'status' })}`,
53
53
  });
54
54
  }
@@ -170,21 +170,19 @@ function takeUtf8(text, maxBytes) {
170
170
  return buffer.subarray(0, end).toString('utf8');
171
171
  }
172
172
 
173
- export function formatGitReadResult(operation, result) {
174
- const timedOut = Boolean(result.timedOut);
175
- const runnerTruncated = Boolean(result.truncated);
173
+ function formatSuccess(operation, result) {
176
174
  const sections = [];
177
175
  if (result.stdout) sections.push(`STDOUT:\n${result.stdout}`);
178
176
  if (result.stderr) sections.push(`STDERR:\n${result.stderr}`);
179
177
  const body = sections.join('\n');
180
178
  const baseHeader = truncated => [
181
179
  `operation: ${operation}`,
182
- `exitCode: ${runnerTruncated ? 'not observed (output limit reached)' : result.code}`,
183
- `timedOut: ${timedOut}`,
180
+ `exitCode: ${result.code}`,
181
+ 'timedOut: false',
184
182
  `truncated: ${truncated}`,
185
183
  ].join('\n');
186
- const initial = `${baseHeader(runnerTruncated)}\n\n${body || '(no output)'}`;
187
- if (!runnerTruncated && Buffer.byteLength(initial, 'utf8') <= MAX_RESULT_BYTES) return initial;
184
+ const initial = `${baseHeader(false)}\n\n${body || '(no output)'}`;
185
+ if (Buffer.byteLength(initial, 'utf8') <= MAX_RESULT_BYTES) return initial;
188
186
 
189
187
  const marker = '\n\n[Output truncated by GitRead; narrow the revision or paths.]';
190
188
  const header = `${baseHeader(true)}\n\n`;
@@ -195,6 +193,62 @@ export function formatGitReadResult(operation, result) {
195
193
  return header + takeUtf8(body || '(no output)', bodyBudget) + marker;
196
194
  }
197
195
 
196
+ // Keep returned errors parseable even when JSON escaping expands raw output.
197
+ // Engine uses this envelope (not text exit codes) for tool_end.isError.
198
+ function boundedFailure(fields, output = '') {
199
+ const envelope = { ...fields, ...(output ? { output: String(output) } : {}) };
200
+ // Metadata is not necessarily small: even an invalid cwd reaches spawn.
201
+ // Bound each string after allowing for JSON's worst-case 6x escaping;
202
+ // the fixed envelope fields then leave ample room for diagnostics.
203
+ for (const [key, value] of Object.entries(fields)) {
204
+ if (typeof value === 'string' && Buffer.byteLength(value, 'utf8') > 512) {
205
+ envelope[key] = takeUtf8(value, 512) + '[truncated]';
206
+ envelope.truncated = true;
207
+ }
208
+ }
209
+ let serialized = JSON.stringify(envelope);
210
+ const marker = '\n[GitRead diagnostic truncated; narrow the revision or paths.]';
211
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_RESULT_BYTES) {
212
+ envelope.truncated = true;
213
+ let low = 0;
214
+ let high = Math.min(Buffer.byteLength(output, 'utf8'), MAX_RESULT_BYTES);
215
+ while (low < high) {
216
+ const mid = Math.ceil((low + high) / 2);
217
+ envelope.output = takeUtf8(output, mid) + marker;
218
+ if (Buffer.byteLength(JSON.stringify(envelope), 'utf8') <= MAX_RESULT_BYTES) low = mid;
219
+ else high = mid - 1;
220
+ }
221
+ envelope.output = takeUtf8(output, low) + marker;
222
+ serialized = JSON.stringify(envelope);
223
+ }
224
+ return serialized;
225
+ }
226
+
227
+ export function formatGitReadResult(operation, result, { resolvedCwd, stage = operation } = {}) {
228
+ if (result.code === 0 && !result.timedOut && !result.truncated && !result.terminationError) {
229
+ return formatSuccess(operation, result);
230
+ }
231
+ const code = result.terminationError ? 'git_exit_unconfirmed'
232
+ : result.timedOut ? 'git_timeout'
233
+ : result.truncated ? 'git_output_limit'
234
+ : !Number.isInteger(result.code) ? 'git_exit_unconfirmed' : 'git_failed';
235
+ const error = {
236
+ git_exit_unconfirmed: 'Git process exit was not confirmed',
237
+ git_timeout: 'Git read timed out',
238
+ git_output_limit: 'Git read stopped at the capture limit; output is incomplete',
239
+ git_failed: `Git exited with code ${result.code}`,
240
+ }[code];
241
+ // Put stderr first so a long partial diff cannot hide Git's explanation.
242
+ const output = [result.terminationError, result.stderr && `STDERR:\n${result.stderr}`,
243
+ result.stdout && `STDOUT:\n${result.stdout}`].filter(Boolean).join('\n');
244
+ return boundedFailure({
245
+ error, errorEffect: 'none', code, operation, stage, resolvedCwd,
246
+ exitCode: result.truncated || result.terminationError ? null : (result.code ?? null),
247
+ timedOut: Boolean(result.timedOut), truncated: Boolean(result.truncated),
248
+ ...(result.terminationError || code === 'git_exit_unconfirmed' ? { terminationConfirmed: false } : {}),
249
+ }, output);
250
+ }
251
+
198
252
  const gitReadTool = defineTool({
199
253
  name: 'GitRead',
200
254
  description: {
@@ -203,19 +257,19 @@ const gitReadTool = defineTool({
203
257
  Supported operations are intentionally limited:
204
258
  - status: compact branch and working-tree status.
205
259
  - diff: tracked changes against HEAD by default, or an explicit base...head range; optional paths narrow the result.
206
- - show: one commit (HEAD by default), optionally narrowed by paths.
260
+ - show: exactly one commit (HEAD by default; tags are peeled to commits), optionally narrowed by paths. Ranges and non-commit objects are rejected.
207
261
  - log: a compact bounded commit list (20 entries by default, maximum 50).
208
262
 
209
- GitRead never fetches, writes Git state, or creates worktrees. It disables pagers, external diff, textconv, content filters, optional locks, fsmonitor, and submodule traversal. Filter-normalized files (such as LFS) show raw worktree bytes; submodule status needs separate inspection. Revisions and paths beginning with "-" are rejected. Output reports whether it was truncated.`,
263
+ GitRead never fetches, writes Git state, or creates worktrees. It disables pagers, external diff, textconv, content filters, optional locks, fsmonitor, and submodule traversal. Filter-normalized files (such as LFS) show raw worktree bytes; submodule status needs separate inspection. Revisions and paths beginning with "-" are rejected. Output reports truncation. Git failures, timeouts and capture-limit stops return an error with bounded diagnostics.`,
210
264
  zh: `有界读取本地 Git 证据,不使用 shell,也不访问网络。
211
265
 
212
266
  操作范围刻意限制为:
213
267
  - status:紧凑显示分支和工作区状态。
214
268
  - diff:默认显示相对 HEAD 的已跟踪改动,也可指定 base...head;可用 paths 缩小范围。
215
- - show:显示一个提交(默认 HEAD),可用 paths 缩小范围。
269
+ - show:显示唯一提交(默认 HEAD;tag 解析到 commit),可用 paths 缩小范围。拒绝范围及非 commit 对象。
216
270
  - log:紧凑且有界的提交列表(默认 20 条,最多 50 条)。
217
271
 
218
- GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、external diff、textconv、内容 filter、optional locks、fsmonitor 和子模块遍历。LFS 等 filter 文件显示原始工作区字节,子模块状态需单独检查。拒绝以 "-" 开头的 revision 与路径;结果明确标识是否截断。`,
272
+ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、external diff、textconv、内容 filter、optional locks、fsmonitor 和子模块遍历。LFS 等 filter 文件显示原始工作区字节,子模块状态需单独检查。拒绝以 "-" 开头的 revision 与路径;结果明确标识是否截断;Git 失败、超时及捕获上限终止返回含有界诊断的错误。`,
219
273
  },
220
274
  parameters: {
221
275
  type: 'object',
@@ -224,7 +278,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
224
278
  operation: { type: 'string', enum: ['status', 'diff', 'show', 'log'] },
225
279
  base: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; empty/omitted means working-tree changes against HEAD' },
226
280
  head: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'diff only; requires base, empty/omitted defaults to HEAD' },
227
- revision: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'show/log only; empty/omitted defaults to HEAD' },
281
+ revision: { type: 'string', maxLength: MAX_VALUE_LENGTH, description: 'show/log only; empty/omitted defaults to HEAD. show requires a single commit, not a range/tree/blob' },
228
282
  paths: {
229
283
  type: 'array',
230
284
  maxItems: MAX_PATHS,
@@ -241,7 +295,9 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
241
295
  async execute(input, ctx) {
242
296
  const built = buildGitReadArgs(input);
243
297
  if (built.error) return errorOutput(built.error, input?.operation);
298
+ input = normalizeInput(input);
244
299
  const cwd = resolve(ctx?.cwd || process.cwd());
300
+ let stage = input.operation;
245
301
  try {
246
302
  const run = ctx?.[RUN_PROCESS_OVERRIDE] || runProcess;
247
303
  const startedAt = Date.now();
@@ -250,6 +306,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
250
306
  signal: ctx?.signal,
251
307
  timeoutMs: TIMEOUT_MS,
252
308
  maxBytes: MAX_CAPTURE_BYTES,
309
+ requireExitConfirmation: true,
253
310
  env: {
254
311
  ...process.env,
255
312
  GIT_PAGER: 'cat',
@@ -261,14 +318,54 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
261
318
  NO_COLOR: '1',
262
319
  },
263
320
  };
264
- const overrides = await filterOverrides(run, options);
265
- const result = await run('git', [...overrides, ...built.args], {
266
- ...options, timeoutMs: Math.max(1, TIMEOUT_MS - (Date.now() - startedAt)),
267
- });
268
- return formatGitReadResult(input.operation, result);
321
+ const read = (command, args) => {
322
+ const remaining = TIMEOUT_MS - (Date.now() - startedAt);
323
+ if (remaining <= 0) {
324
+ throw Object.assign(new Error('Git read timed out'), { code: 'git_timeout', timedOut: true });
325
+ }
326
+ return run(command, args, { ...options, timeoutMs: remaining });
327
+ };
328
+ // Only these operations inspect worktree bytes. Object-only reads must
329
+ // not pay for filter discovery or fail on an unusable worktree filter.
330
+ const readsWorktree = input.operation === 'status'
331
+ || (input.operation === 'diff' && input.base === undefined);
332
+ let overrides = [];
333
+ if (readsWorktree) {
334
+ stage = 'filter_inspection';
335
+ overrides = await filterOverrides(read);
336
+ }
337
+ let args = built.args;
338
+ if (input.operation === 'show') {
339
+ stage = 'resolve_commit';
340
+ const resolved = await read('git', [
341
+ ...COMMON_ARGS, 'rev-parse', '--verify', '--end-of-options', `${input.revision || 'HEAD'}^{commit}`,
342
+ ]);
343
+ if (resolved.code !== 0 || resolved.truncated || resolved.timedOut || resolved.terminationError) {
344
+ return formatGitReadResult(input.operation, resolved, { resolvedCwd: cwd, stage });
345
+ }
346
+ const commit = resolved.stdout.trim();
347
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(commit)) {
348
+ throw new Error('Expected exactly one resolved commit object ID');
349
+ }
350
+ args = buildGitReadArgs({ ...input, revision: commit }).args;
351
+ }
352
+ stage = input.operation;
353
+ const result = await read('git', [...overrides, ...args]);
354
+ return formatGitReadResult(input.operation, result, { resolvedCwd: cwd, stage });
269
355
  } catch (error) {
270
356
  if (error?.name === 'AbortError') throw error;
271
- return JSON.stringify({ error: `GitRead failed: ${error?.message || String(error)}`, resolvedCwd: cwd });
357
+ if (error?.result) {
358
+ return formatGitReadResult(input.operation, error.result, { resolvedCwd: cwd, stage });
359
+ }
360
+ return boundedFailure({
361
+ error: 'GitRead failed', errorEffect: 'none',
362
+ code: error?.name === 'ProcessTerminationError' ? 'git_exit_unconfirmed'
363
+ : error?.code === 'git_timeout' ? 'git_timeout'
364
+ : stage === 'filter_inspection' ? 'git_filter_inspection_failed' : 'git_execution_error',
365
+ operation: input.operation, stage, resolvedCwd: cwd,
366
+ ...(error?.timedOut ? { timedOut: true } : {}),
367
+ ...(error?.name === 'ProcessTerminationError' ? { terminationConfirmed: false } : {}),
368
+ }, error?.message || String(error));
272
369
  }
273
370
  },
274
371
  });
@@ -20,7 +20,7 @@ let shutdownPromise = null;
20
20
  let serviceFactory = null;
21
21
 
22
22
  const BROWSER_DETAIL_OPS = new Set([
23
- 'get', 'create', 'update', 'start', 'cancel', 'resume', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
23
+ 'get', 'create', 'update', 'start', 'cancel', 'resume', 'extend_budget', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
24
24
  ]);
25
25
  const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
26
26
  // `files` is an internal server-to-Agent field. The browser relay rejects any
@@ -39,7 +39,8 @@ const BROWSER_FILE_FIELDS = Object.freeze({
39
39
  ],
40
40
  action_input: ['id', 'text', 'actionId', 'revision', 'generation', 'quote', 'files'],
41
41
  retry_action: ['id', 'actionId', 'revision', 'generation'],
42
- resume: ['id', 'revision'],
42
+ resume: ['id', 'revision', 'executionControlRevision'],
43
+ extend_budget: ['id', 'executionControlRevision', 'additions'],
43
44
  delete: ['id', 'revision'],
44
45
  guide: ['id', 'guidance', 'actionId', 'revision', 'generation', 'files'],
45
46
  get_action_messages: ['id', 'actionId', 'generation', 'cursor', 'limit'],
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Work Center reuses builtin tool implementations, but owns their lifecycle.
3
+ * Keep the executable subset and the Coordinator's capability description in
4
+ * one place. This is a host policy, not a claim that shell/MCP is sandboxed.
5
+ */
6
+ export const WORK_ITEM_TOOL_NAMES = Object.freeze([
7
+ 'FileRead', 'FileWrite', 'FileEdit', 'ApplyPatch', 'Glob', 'Grep',
8
+ 'ListDir', 'Bash', 'WebSearch', 'WebFetch', 'ViewImage', 'Skill',
9
+ ]);
10
+
11
+ export function workItemBuiltinToolNames(hasAttachments = false) {
12
+ return WORK_ITEM_TOOL_NAMES.filter(name => !hasAttachments || name !== 'Bash');
13
+ }
14
+
15
+ /** Only identities/roles needed for assignment, never VP souls or credentials. */
16
+ export function workItemCapabilityContext(vps = [], { hasAttachments = false } = {}) {
17
+ const bounded = (value, limit) => typeof value === 'string' ? value.slice(0, limit) : '';
18
+ const selected = [];
19
+ for (const vp of vps.slice(0, 48)) {
20
+ // An id must round-trip to the registry; never offer a truncated identity.
21
+ if (typeof vp.id !== 'string' || !vp.id || vp.id.length > 128) continue;
22
+ const candidate = {
23
+ id: vp.id,
24
+ name: bounded(vp.name, 120),
25
+ role: bounded(vp.role, 200),
26
+ traits: (Array.isArray(vp.traits) ? vp.traits : []).slice(0, 8).map(value => bounded(value, 80)),
27
+ };
28
+ if (Buffer.byteLength(JSON.stringify([...selected, candidate]), 'utf8') > 8 * 1024) break;
29
+ selected.push(candidate);
30
+ }
31
+ return {
32
+ executor: 'yeaft-engine',
33
+ tools: workItemBuiltinToolNames(hasAttachments),
34
+ vps: selected,
35
+ omittedVpCount: Math.max(0, vps.length - selected.length),
36
+ mcp: 'Workspace-configured MCP tools are resolved by the executor. Availability and authorization must be verified, not inferred from a VP name.',
37
+ limitations: [
38
+ 'No unmanaged background jobs, recursive sub-agents, or Session transcript access.',
39
+ 'VP roles provide expertise, not missing tools, credentials, permissions, or external environments.',
40
+ 'Use one Action for local investigation, implementation and tests when no independent boundary is needed.',
41
+ 'Missing permission or an unavailable required capability is a blocker, not a reason to create more roles.',
42
+ ],
43
+ };
44
+ }
@@ -14,20 +14,28 @@ export function normalizeContractPatch(value) {
14
14
  patch.acceptanceCriteria = criteria;
15
15
  }
16
16
  if (Object.hasOwn(value, 'deliveryTarget')) {
17
- if (!['workspace_files', 'pull_request', 'merge'].includes(value.deliveryTarget)) {
18
- throw new Error('contractPatch.deliveryTarget must be workspace_files, pull_request, or merge');
17
+ if (!['response', 'workspace_files', 'pull_request', 'merge'].includes(value.deliveryTarget)) {
18
+ throw new Error('contractPatch.deliveryTarget must be response, workspace_files, pull_request, or merge');
19
19
  }
20
20
  patch.deliveryTarget = value.deliveryTarget;
21
21
  }
22
22
  return Object.keys(patch).length > 0 ? patch : null;
23
23
  }
24
24
 
25
+ // Reject even falsy/raw patch fields before normalization can hide an attempted change.
26
+ export function assertCoordinatorContractAuthority(value, userOriginated) {
27
+ if (userOriginated === true || !value || typeof value !== 'object') return;
28
+ if (['title', 'goal', 'acceptanceCriteria', 'deliveryTarget'].some(key => Object.hasOwn(value, key))) {
29
+ throw new Error('Automatic Work Center Coordinator contract and delivery target changes are forbidden; refinement requires a user-originated turn');
30
+ }
31
+ }
32
+
25
33
  function normalizeAcceptanceChecks(value, criteria) {
26
34
  if (!Array.isArray(value) || value.length !== criteria.length) return null;
27
35
  const checks = value.map((raw, index) => {
28
36
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
29
37
  const criterion = typeof raw.criterion === 'string' ? raw.criterion.trim() : '';
30
- const status = ['passed', 'deferred', 'not_applicable'].includes(raw.status) ? raw.status : '';
38
+ const status = ['passed', 'failed', 'deferred', 'not_applicable'].includes(raw.status) ? raw.status : '';
31
39
  const evidence = typeof raw.evidence === 'string' ? raw.evidence.trim().slice(0, 1_000) : '';
32
40
  if (criterion !== criteria[index] || !status || !evidence) return null;
33
41
  return { criterion, status, evidence };
@@ -176,6 +176,13 @@ export class WorkflowController {
176
176
  return this.store.getWorkItemDetail(id);
177
177
  }
178
178
 
179
+ extendBudget(id, input = {}) {
180
+ if (!Number.isSafeInteger(input.executionControlRevision)) {
181
+ throw new Error('executionControlRevision is required to extend execution budget');
182
+ }
183
+ return this.store.extendExecutionBudget(id, input.executionControlRevision, input.additions || {});
184
+ }
185
+
179
186
  resume(id, input = {}) {
180
187
  const revision = Number(input.revision);
181
188
  if (!Number.isInteger(revision) || revision < 1) {
@@ -195,7 +202,7 @@ export class WorkflowController {
195
202
  renderSessionContextSnapshot(workItem.sessionContext),
196
203
  ),
197
204
  };
198
- });
205
+ }, input.executionControlRevision);
199
206
  if (!detail) throw new Error(`WorkItem not found: ${id}`);
200
207
  return detail;
201
208
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { resolveMaxOutputTokens } from '../models.js';
3
+ import { callCoordinatorWithResourceControl } from './resource-control.js';
3
4
  import { normalizeSessionMessageQuote, sessionMessageQuotePrompt } from '../session-message-quote.js';
4
5
  import {
5
6
  LLMAuthError,
@@ -8,8 +9,9 @@ import {
8
9
  LLMServerError,
9
10
  } from '../llm/adapter.js';
10
11
  import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
11
- import { normalizeContractPatch } from './completion-contract.js';
12
+ import { assertCoordinatorContractAuthority, normalizeContractPatch } from './completion-contract.js';
12
13
  import { normalizeOutputs } from './evidence.js';
14
+ import { deriveGoalProgress } from './goal-state.js';
13
15
  import {
14
16
  normalizeDynamicActionClosures,
15
17
  prepareDynamicActionMutation,
@@ -19,6 +21,7 @@ import { applyCoordinatorReplan } from './plan-mutation.js';
19
21
  import { buildWorkItemAttachmentContext } from './attachments.js';
20
22
  import { sanitizeDiagnosticText } from './debug-projection.js';
21
23
  import { generatedActionGraphRules } from './workflow.js';
24
+ import { workItemCapabilityContext } from './capabilities.js';
22
25
 
23
26
  const COORDINATOR_MAX_REPLY_CHARS = 8_000;
24
27
  const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
@@ -184,7 +187,7 @@ const COORDINATOR_SYSTEM_PROMPT = `You are the Work Center Coordinator. The user
184
187
 
185
188
  Your responsibilities:
186
189
  - Explain the current WorkItem state and blockers in plain language.
187
- - Keep the WorkItem title, goal, acceptance criteria, and unfinished Action graph aligned with the user's latest intent.
190
+ - Change title, goal, acceptance criteria, or delivery target only in an explicit user-originated refinement turn. Automatic advance/recovery must preserve the user contract and address its gaps, never relax it.
188
191
  - Give targeted instructions to unfinished Actions when the contract and topology do not need to change.
189
192
  - Replan unfinished work when the goal, acceptance criteria, Action purpose, dependencies, or validation strategy must change.
190
193
  - Preserve completed Action history. Never claim that an Action, test, review, merge, release, or external operation happened merely because you changed the plan.
@@ -237,14 +240,17 @@ Return exactly one JSON object and no surrounding prose:
237
240
 
238
241
  Rules:
239
242
  - answer: explain state only. Never use it for an automatic advance trigger.
243
+ - Never mutate title, goal, acceptanceCriteria, or deliveryTarget during automatic advance/recovery. contractPatch is allowed only for explicit user-originated refinement, never to make existing evidence pass. For an older WorkItem with no acceptance criteria, request_human to establish its completion condition before commissioning new work.
240
244
  - create_actions: create 1..8 currently runnable Actions. Every Action needs type, objective, approach, expectedOutcome, capability, candidateVpIds, assignmentReason, sourceActionIds, workspaceMode, and optional maxAttempts/separateFromActionTypes. sourceActionIds are context/audit references, never scheduling dependencies. Do not include dependsOnActionIds, dependsOnStageIds, stages, or a graph.
241
- - If no existing VP can execute a required capability, create one create_vp Action assigned to the existing VP best suited to author that specialist. After it completes, create the original Action with the new VP id. Never fail or retry the original Action merely because its capability label has no match.
245
+ - A missing skill/capability label is not a missing execution capability. Prefer an existing VP with a task-specific brief. Missing tools, credentials, or authorization require request_human; never expand roles as a workaround. create_vp is only appropriate when creating a persistent role is itself an explicit user deliverable.
242
246
  - closeActions may accompany create_actions. Each entry is {"actionId":"failed or waiting durable Action id","reason":"why it is no longer required"}. Close only work made obsolete by replacement evidence or a clarified contract. Closed Actions remain audit history, are never acceptance evidence, and do not block completion.
243
247
  - guide_actions: target 1..8 unfinished non-running Actions by durable actionId.
244
- - request_human: use when external information or a user decision is genuinely required. Before creating mutating or delivery Actions, ask whether the delivery boundary is files only, PR, or merge when the contract does not already say. After the user answers, persist it with contractPatch.deliveryTarget = workspace_files | pull_request | merge before creating more Actions.
248
+ - request_human: use when external information or a user decision is genuinely required. Before creating mutating or delivery Actions, ask whether the delivery boundary is a response/report, workspace files, PR, or merge when the contract does not already say. After the user answers, persist it with contractPatch.deliveryTarget = response | workspace_files | pull_request | merge before creating more Actions.
245
249
  - complete: only when every acceptance criterion has canonical completed Run evidence and there are no unfinished Actions after applying optional closeActions. Include summary, ordered acceptanceResults with evidenceRunIds, evidenceRunIds, and residualRisks. Reuse structured outputs already present on canonical Runs; do not create repetitive evidence-packaging Actions.
246
250
  - Preserve completed and closed Action history. Never claim tests, review, merge, release, or external effects without canonical Run evidence.
247
- - Action templates are reusable capabilities, not a prescribed workflow. Create the smallest useful Action boundary, not tool-call-sized work.
251
+ - Action templates are reusable capabilities, not a prescribed workflow. A simple Action includes its local tools and necessary tests; do not impose research/design/implement/test/review/deliver stages.
252
+ - Read goalProgress.remainingCriteria, delivery, and blockers first. Resource limits are shared by coordination and all execution, including retries; reserve enough for verification and delivery. Never create a new Action or role merely to evade an exhausted attempt limit. Each new Action must close a concrete current gap. Prefer optional goalRefs: {"criteria":["exact unmet criterion"],"blockerActionIds":["current blocker Action id"],"delivery":false}, plus rationale explaining why this work changes the observed state. Repeating an objective requires goalRefs and a concrete new rationale; do not package already sufficient evidence.
253
+ - response delivery is a substantive answer/report in a canonical completed Run summary with evidence and valid checks; do not invent a file, PR, or extra delivery Action.
248
254
  - Never return destructive cancellation. The user owns the explicit cancel control.`;
249
255
 
250
256
  function coordinatorSystemPrompt(language, detail) {
@@ -438,6 +444,8 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
438
444
  const source = parsed?.decision && typeof parsed.decision === 'object' && !Array.isArray(parsed.decision)
439
445
  ? parsed.decision
440
446
  : {};
447
+ const automatic = options.automatic === true || (options.recovery === true && options.userOriginated !== true);
448
+ assertCoordinatorContractAuthority(source.contractPatch, !automatic && options.userOriginated !== false);
441
449
  const dynamic = isDynamicWorkItem(detail);
442
450
  const allowedKinds = dynamic
443
451
  ? (options.automatic === true
@@ -474,9 +482,6 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
474
482
  };
475
483
  }
476
484
  if (kind === 'request_human') {
477
- if (options.automatic === true && source.contractPatch?.deliveryTarget) {
478
- throw new Error('Automatic Work Center Coordinator delivery target changes are forbidden');
479
- }
480
485
  const contractPatch = dynamic ? normalizeContractPatch(source.contractPatch) : null;
481
486
  return {
482
487
  reply,
@@ -505,9 +510,6 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
505
510
  };
506
511
  }
507
512
  if (dynamic && kind === 'create_actions') {
508
- if (options.automatic === true && source.contractPatch?.deliveryTarget) {
509
- throw new Error('Automatic Work Center Coordinator delivery target changes are forbidden');
510
- }
511
513
  const contractPatch = normalizeContractPatch(source.contractPatch);
512
514
  if (requiresDeliveryBoundaryDecision(detail, source.actions)) {
513
515
  throw new Error('Work Center delivery target is unconfirmed; the delivery boundary requires request_human before creating mutating or delivery Actions');
@@ -530,6 +532,7 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
530
532
  actions: detail.actions || [],
531
533
  decision,
532
534
  availableVpIds: options.availableVpIds,
535
+ automatic,
533
536
  }),
534
537
  };
535
538
  }
@@ -583,7 +586,7 @@ function finalizedCriteria(detail, contractPatch) {
583
586
  return criteria;
584
587
  }
585
588
 
586
- function coordinatorSnapshot(detail) {
589
+ export function coordinatorSnapshot(detail) {
587
590
  const runs = Array.isArray(detail.runs) ? detail.runs : [];
588
591
  const canonicalRunByAction = new Map();
589
592
  for (const action of detail.actions || []) {
@@ -655,8 +658,25 @@ function coordinatorSnapshot(detail) {
655
658
  throw new Error('Active Actions cannot be represented within the Coordinator snapshot budget');
656
659
  }
657
660
 
661
+ const progress = deriveGoalProgress(detail);
662
+ const goalProgress = {
663
+ contractRevision: progress.contractRevision,
664
+ completedCriteriaCount: progress.completedCriteriaCount,
665
+ totalCriteriaCount: progress.totalCriteriaCount,
666
+ remainingCriteria: boundedJsonArray(progress.remainingCriteria.map(value => truncateUtf8(value, 768)), 2 * 1024),
667
+ delivery: { ...progress.delivery, evidenceRunIds: progress.delivery.evidenceRunIds.slice(0, 24) },
668
+ criteria: boundedJsonArray(progress.criteria.map(item => ({ ...item,
669
+ criterion: truncateUtf8(item.criterion, 768), evidenceRunIds: item.evidenceRunIds.slice(0, 24),
670
+ ...(item.conflictingRunIds ? { conflictingRunIds: item.conflictingRunIds.slice(0, 24) } : {}),
671
+ })), 2 * 1024),
672
+ blockers: boundedJsonArray(progress.blockers.map(item => ({ ...item, reason: truncateUtf8(item.reason, 384) })), 1 * 1024),
673
+ };
674
+ goalProgress.omittedCriteriaCount = progress.criteria.length - goalProgress.criteria.length;
675
+ goalProgress.omittedRemainingCriteriaCount = progress.remainingCriteria.length - goalProgress.remainingCriteria.length;
676
+ goalProgress.omittedBlockerCount = progress.blockers.length - goalProgress.blockers.length;
658
677
  return {
659
678
  workItem,
679
+ goalProgress,
660
680
  actions,
661
681
  omittedCompletedActionCount: Math.max(0, completed.length - actions.filter(action => ['completed', 'closed'].includes(action.status)).length),
662
682
  conversation: coordinatorHistory(detail.messages),
@@ -772,7 +792,8 @@ export class WorkItemCoordinator {
772
792
  : detail?.actions?.find(candidate => (
773
793
  candidate.id === detail.currentActionId && candidate.status === 'failed'
774
794
  ));
775
- if (!detail || ['done', 'cancelled'].includes(detail.status) || action?.status !== 'failed') return null;
795
+ if (!detail || ['done', 'cancelled'].includes(detail.status) || action?.status !== 'failed'
796
+ || !this.store.canAutomaticallyCoordinate(id)) return null;
776
797
  const started = this.store.beginCoordinatorTurn(id, '', {
777
798
  revision: detail.revision,
778
799
  planRevision: detail.planRevision,
@@ -896,7 +917,10 @@ export class WorkItemCoordinator {
896
917
  try {
897
918
  let result;
898
919
  try {
899
- const latestMessage = `Current WorkItem snapshot:\n${snapshotText}\n\n${recovery ? 'Automatic failure recovery trigger' : 'Latest user message'}:\n${text}${attachmentContext.promptBlock}${correction}`;
920
+ const capabilities = JSON.stringify(workItemCapabilityContext(vps, {
921
+ hasAttachments: started.detail.attachments?.length > 0,
922
+ }));
923
+ const latestMessage = `Available execution capabilities (role metadata is descriptive, not authorization):\n${capabilities}\n\nCurrent WorkItem snapshot:\n${snapshotText}\n\n${recovery ? 'Automatic failure recovery trigger' : 'Latest user message'}:\n${text}${attachmentContext.promptBlock}${correction}`;
900
924
  const content = attachmentContext.promptParts.length > 0
901
925
  ? [{ type: 'text', text: latestMessage }, ...attachmentContext.promptParts]
902
926
  : latestMessage;
@@ -925,15 +949,9 @@ export class WorkItemCoordinator {
925
949
  result = providerTurn.response;
926
950
  } else {
927
951
  result = await Promise.race([
928
- runtime.adapter.call({
952
+ callCoordinatorWithResourceControl(runtime.adapter, this.store, providerTurn, claim, {
929
953
  ...requestBody,
930
954
  signal: abortController.signal,
931
- onRequestStart: () => {
932
- if (!this.store.dispatchCoordinatorProviderTurn(providerTurn.id, claim)) {
933
- abortController.abort('work_center_coordinator_dispatch_fence_lost');
934
- throw new Error('Coordinator provider turn lost its dispatch fence');
935
- }
936
- },
937
955
  }).then(response => {
938
956
  const persisted = this.store.respondCoordinatorProviderTurn(
939
957
  providerTurn.id, providerTurn.requestHash, response, claim,
@@ -956,6 +974,7 @@ export class WorkItemCoordinator {
956
974
  normalized = normalizeCoordinatorResponse(result?.text, started.detail, {
957
975
  recovery,
958
976
  automatic: started.fence.automatic === true,
977
+ userOriginated: started.fence.userOriginated === true,
959
978
  recoveryActionId: started.fence.recovery?.actionId || null,
960
979
  availableVpIds: vps.map(vp => vp.id),
961
980
  });
@@ -1037,12 +1056,14 @@ export class WorkItemCoordinator {
1037
1056
  : recovery ? 'coordinator.recovery_completed' : 'coordinator.turn_completed', detail);
1038
1057
  return detail;
1039
1058
  } catch (error) {
1059
+ if (providerTurn) this.store.settleCoordinatorRequest(providerTurn.id, null, false);
1040
1060
  if (providerTurn?.status === 'responded') {
1041
1061
  this.store.rejectCoordinatorProviderTurn(providerTurn.id, error, started.fence.claim);
1042
1062
  }
1043
1063
  const detail = this.store.failCoordinatorTurn(started.turnId, error, {
1044
1064
  ...started.fence,
1045
1065
  speaker,
1066
+ interrupted: abortController.signal.aborted || this.shuttingDown,
1046
1067
  });
1047
1068
  if (detail) {
1048
1069
  options.onUpdate?.('coordinator.turn_failed', detail);