@yeaft/webchat-agent 1.0.201 → 1.0.203

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.
@@ -1 +1 @@
1
- {"version":"1.0.201"}
1
+ {"version":"1.0.203"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.201",
3
+ "version": "1.0.203",
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
@@ -32,7 +32,7 @@ import { archiveTurn } from './archive/turn-archive.js';
32
32
  import { archiveToolResults } from './archive/tool-results.js';
33
33
  import { readSummary as readScopeSummary } from './memory/store.js';
34
34
  import { runAdjust } from './memory/adjust.js';
35
- import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
35
+ import { cleanMemoryPromptText, isMemoryPromptRelevant } from './memory/prompt-cleanup.js';
36
36
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
37
37
  import { runStopHooks } from './stop-hooks.js';
38
38
  import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
@@ -388,6 +388,20 @@ export function shouldAllowGroupReflection({
388
388
  * }} args
389
389
  * @returns {Array<{scope: string, summary: string}>}
390
390
  */
391
+ export function selectResidentTopicScopes(topicScopes, recallEntries, userMsg = '') {
392
+ const recalledTopicScopes = new Set((recallEntries || [])
393
+ .map(entry => entry?.scope)
394
+ .filter(scope => typeof scope === 'string' && /^sessions\/[^/]+\/topic\//.test(scope)));
395
+ return (Array.isArray(topicScopes) ? topicScopes : [])
396
+ .filter(scope => recalledTopicScopes.has(scope) || isTopicScopeRelevant(scope, userMsg));
397
+ }
398
+
399
+ function isTopicScopeRelevant(scope, userMsg) {
400
+ if (!userMsg || typeof scope !== 'string') return false;
401
+ const label = scope.replace(/^sessions\/[^/]+\/topic\//, '').replace(/[/-]+/g, ' ');
402
+ return isMemoryPromptRelevant(label, userMsg);
403
+ }
404
+
391
405
  export function buildResidentEntries(args) {
392
406
  const summaries = (args && args.summaries) || {};
393
407
  const out = [];
@@ -873,6 +887,7 @@ export class Engine {
873
887
  * ownVpId?: string|null,
874
888
  * summaries: { user?: string, session?: string, vp?: string },
875
889
  * recallEntries: object[],
890
+ * userMsg?: string,
876
891
  * }} args
877
892
  * @returns {{
878
893
  * ams: import('./memory/ams.js').ActiveMemorySet,
@@ -912,7 +927,7 @@ export class Engine {
912
927
  ams.setOnDemand(segs);
913
928
 
914
929
  // (c) Snapshot — render the AMS layers as a single prompt block.
915
- const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en');
930
+ const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en', args.userMsg || '');
916
931
 
917
932
  const scopes = buildRelevantScopes({
918
933
  sessionId: args.sessionId,
@@ -929,10 +944,11 @@ export class Engine {
929
944
  *
930
945
  * @param {import('./memory/ams.js').ActiveMemorySet} ams
931
946
  * @param {string} [language]
947
+ * @param {string} [userMsg]
932
948
  * @returns {string}
933
949
  */
934
- #renderAmsSnapshot(ams, language = 'en') {
935
- const snap = ams.snapshot();
950
+ #renderAmsSnapshot(ams, language = 'en', userMsg = '') {
951
+ const snap = ams.snapshot({ userMsg });
936
952
  if (!snap) return '';
937
953
  const parts = [];
938
954
  if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
@@ -1906,6 +1922,11 @@ export class Engine {
1906
1922
  if (recallEntryCount > 0) {
1907
1923
  yield { type: 'recall', entryCount: recallEntryCount, cached: false, threadId };
1908
1924
  }
1925
+ const topicScopesForResident = selectResidentTopicScopes(
1926
+ topicScopesForMemory,
1927
+ recallResult?.entries || [],
1928
+ prompt,
1929
+ );
1909
1930
 
1910
1931
  // Layer-A summaries — same scopes AMS Resident will surface, loaded
1911
1932
  // here so we can pass them into #prepareAms. (Rolling per-scope
@@ -1916,7 +1937,7 @@ export class Engine {
1916
1937
  ? vpPersona.vpId
1917
1938
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
1918
1939
  language: this.#config.language || 'en',
1919
- topicScopes: topicScopesForMemory,
1940
+ topicScopes: topicScopesForResident,
1920
1941
  });
1921
1942
 
1922
1943
  // ─── AMS: populate + snapshot ───────────────────────────────
@@ -1935,6 +1956,7 @@ export class Engine {
1935
1956
  ownVpId: ownVpIdForAms,
1936
1957
  summaries,
1937
1958
  recallEntries: recallResult ? (recallResult.entries || []) : [],
1959
+ userMsg: prompt,
1938
1960
  });
1939
1961
  if (amsContext && amsContext.snapshotBlock) {
1940
1962
  memoryInjection = amsContext.snapshotBlock;
@@ -17,7 +17,12 @@
17
17
  */
18
18
 
19
19
  import { approxTokens } from './budget.js';
20
- import { cleanMemoryPromptText, isDuplicateMemoryText, rememberMemoryText } from './prompt-cleanup.js';
20
+ import {
21
+ cleanMemoryPromptText,
22
+ filterMemoryPromptTextForPrompt,
23
+ isDuplicateMemoryText,
24
+ rememberMemoryText,
25
+ } from './prompt-cleanup.js';
21
26
  import { isVpForeign } from './store.js';
22
27
 
23
28
  const RECENT_DEFAULT_CAPACITY = 64;
@@ -144,16 +149,18 @@ export class ActiveMemorySet {
144
149
  * system prompt. Each layer is greedily packed within its budget;
145
150
  * overflow is dropped from this turn but not from disk.
146
151
  *
152
+ * @param {{ userMsg?: string }} [opts]
147
153
  * @returns {AmsSnapshot}
148
154
  */
149
- snapshot() {
155
+ snapshot(opts = {}) {
156
+ const userMsg = typeof opts.userMsg === 'string' ? opts.userMsg : '';
150
157
  const seenPromptText = new Set();
151
158
 
152
159
  // Resident: pack scopes by priority order (caller provides via insert
153
160
  // order — current group's own vp first, then user, etc.).
154
161
  const { picked: resPicked, cost: resCost } = pickMemoryItems({
155
162
  items: [...this._resident.entries()].map(([scope, summary]) => ({
156
- scope, summary: cleanMemoryPromptText(summary),
163
+ scope, summary: filterMemoryPromptTextForPrompt(summary, userMsg),
157
164
  })),
158
165
  budget: this.budget.resident,
159
166
  seen: seenPromptText,
@@ -165,7 +172,7 @@ export class ActiveMemorySet {
165
172
  const { picked: recPicked, cost: recCost } = pickMemoryItems({
166
173
  items: [...this._recent.values()]
167
174
  .reverse()
168
- .map(e => ({ ...e.seg, body: cleanMemoryPromptText(e.seg?.body) })),
175
+ .map(e => ({ ...e.seg, body: filterMemoryPromptTextForPrompt(e.seg?.body, userMsg) })),
169
176
  budget: this.budget.recent,
170
177
  seen: seenPromptText,
171
178
  textOf: seg => seg.body,
@@ -175,7 +182,7 @@ export class ActiveMemorySet {
175
182
  // OnDemand: insertion order from caller (already FTS-ranked).
176
183
  const { picked: odPicked, cost: odCost } = pickMemoryItems({
177
184
  items: [...this._onDemand.values()]
178
- .map(seg => ({ ...seg, body: cleanMemoryPromptText(seg?.body) })),
185
+ .map(seg => ({ ...seg, body: filterMemoryPromptTextForPrompt(seg?.body, userMsg) })),
179
186
  budget: this.budget.onDemand,
180
187
  seen: seenPromptText,
181
188
  textOf: seg => seg.body,
@@ -8,6 +8,20 @@
8
8
 
9
9
  const DREAM_STATE_BLOCK_RE = /<!--\s*dream-state\s*-->[\s\S]*?<!--\s*\/dream-state\s*-->/gi;
10
10
 
11
+ const TRANSIENT_MEMORY_RE = /\b(work\s*item|current\s+(?:state|work|task)|in[_ -]?progress|todo|next\s+step|blocker|blocked|pr\s*#?\d+|pull\s+request|review|merge\s+commit|release\s+tag|tag\s+v\d|v\d+\.\d+\.\d+)\b|(?:工作项|当前(?:状态|任务|工作)|正在|待办|下一步|阻塞|评审|合并|发布|标签|已推|已合并|已完成)/i;
12
+ const ASCII_WORD_RE = /[a-z0-9_]{3,}/gi;
13
+ const CJK_RUN_RE = /[\u4e00-\u9fff]{2,}/g;
14
+ const MARKDOWN_LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
15
+ const COMMON_INTENT_TOKENS = new Set([
16
+ 'current', 'state', 'work', 'task', 'item', 'items', 'todo', 'todos',
17
+ 'next', 'step', 'steps', 'done', 'completed', 'finish', 'finished',
18
+ 'merge', 'merged', 'review', 'reviewed', 'release', 'tag', 'tags',
19
+ 'pr', 'pull', 'request', 'issue', 'fix', 'feat', 'test', 'tests',
20
+ 'dream', 'memory', 'session', 'topic', 'status', 'blocker', 'blocked',
21
+ '当前', '状态', '任务', '工作', '工作项', '待办', '下一步', '完成', '已完成',
22
+ '合并', '评审', '发布', '标签', '记忆', '主题', '阻塞', '正在',
23
+ ]);
24
+
11
25
  /**
12
26
  * Remove Dream scheduler metadata blocks from memory text.
13
27
  *
@@ -31,6 +45,134 @@ export function cleanMemoryPromptText(text) {
31
45
  .trim();
32
46
  }
33
47
 
48
+ /**
49
+ * Whether a memory item describes short-lived execution state rather than a
50
+ * stable preference / project fact. Transient memory is still valid on disk, but
51
+ * should only enter the prompt when it is related to the current user turn.
52
+ *
53
+ * @param {string} text
54
+ * @returns {boolean}
55
+ */
56
+ export function isTransientMemoryText(text) {
57
+ return TRANSIENT_MEMORY_RE.test(cleanMemoryPromptText(text));
58
+ }
59
+
60
+ /**
61
+ * Conservative prompt relevance gate for transient Dream memories. Stable
62
+ * memories are handled by the caller; this function only answers whether a
63
+ * known-transient item has lexical overlap with the current user request.
64
+ *
65
+ * @param {string} memoryText
66
+ * @param {string} userText
67
+ * @returns {boolean}
68
+ */
69
+ export function isTransientMemoryRelevant(memoryText, userText) {
70
+ return isMemoryPromptRelevant(memoryText, userText);
71
+ }
72
+
73
+ /**
74
+ * Conservative lexical relevance check used only at prompt assembly time. It is
75
+ * deliberately not semantic clustering: if there is no concrete token overlap,
76
+ * the memory should win again through FTS when the user asks for it explicitly.
77
+ *
78
+ * @param {string} memoryText
79
+ * @param {string} userText
80
+ * @returns {boolean}
81
+ */
82
+ export function isMemoryPromptRelevant(memoryText, userText) {
83
+ const memoryTokens = promptRelevanceTokens(memoryText);
84
+ const userTokens = promptRelevanceTokens(userText);
85
+ if (memoryTokens.size === 0 || userTokens.size === 0) return false;
86
+ for (const token of memoryTokens) {
87
+ if (userTokens.has(token)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * Drop irrelevant transient paragraphs while preserving stable memory text. This
94
+ * keeps disk memory intact; it only trims what enters the current prompt.
95
+ *
96
+ * @param {string} text
97
+ * @param {string} userText
98
+ * @returns {string}
99
+ */
100
+ export function filterMemoryPromptTextForPrompt(text, userText) {
101
+ const cleaned = cleanMemoryPromptText(text);
102
+ if (!cleaned || !userText) return cleaned;
103
+ const kept = [];
104
+ for (const chunk of splitMemoryPromptChunks(cleaned)) {
105
+ if (!isTransientMemoryText(chunk) || isTransientMemoryRelevant(chunk, userText)) {
106
+ kept.push(chunk);
107
+ }
108
+ }
109
+ return joinMemoryPromptChunks(kept).trim();
110
+ }
111
+
112
+ function splitMemoryPromptChunks(text) {
113
+ const chunks = [];
114
+ for (const block of String(text || '').split(/\n{2,}/)) {
115
+ const trimmed = block.trim();
116
+ if (!trimmed) continue;
117
+ chunks.push(...splitListBlock(trimmed));
118
+ }
119
+ return chunks;
120
+ }
121
+
122
+ function splitListBlock(block) {
123
+ const lines = block.split('\n');
124
+ if (!lines.some(line => MARKDOWN_LIST_ITEM_RE.test(line))) return [block];
125
+
126
+ const chunks = [];
127
+ let current = [];
128
+ for (const line of lines) {
129
+ if (MARKDOWN_LIST_ITEM_RE.test(line) && current.length > 0) {
130
+ chunks.push(current.join('\n').trim());
131
+ current = [];
132
+ }
133
+ current.push(line);
134
+ }
135
+ if (current.length > 0) chunks.push(current.join('\n').trim());
136
+ return chunks.filter(Boolean);
137
+ }
138
+
139
+ function joinMemoryPromptChunks(chunks) {
140
+ const out = [];
141
+ let previousWasList = false;
142
+ for (const chunk of chunks) {
143
+ const isList = MARKDOWN_LIST_ITEM_RE.test(chunk);
144
+ if (out.length > 0 && !(previousWasList && isList)) out.push('');
145
+ out.push(chunk);
146
+ previousWasList = isList;
147
+ }
148
+ return out.join('\n');
149
+ }
150
+
151
+ /**
152
+ * @param {string} text
153
+ * @returns {Set<string>}
154
+ */
155
+ export function promptRelevanceTokens(text) {
156
+ const cleaned = cleanMemoryPromptText(text).toLowerCase();
157
+ const out = new Set();
158
+ for (const match of cleaned.matchAll(ASCII_WORD_RE)) {
159
+ const token = match[0];
160
+ if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
161
+ }
162
+ for (const match of cleaned.matchAll(CJK_RUN_RE)) {
163
+ for (const token of cjkBigrams(match[0])) {
164
+ if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function cjkBigrams(text) {
171
+ const out = [];
172
+ for (let i = 0; i < text.length - 1; i += 1) out.push(text.slice(i, i + 2));
173
+ return out;
174
+ }
175
+
34
176
  /**
35
177
  * Normalized key for conservative prompt dedupe. This is intentionally simple:
36
178
  * exact semantic clustering belongs in Dream; prompt assembly only removes
@@ -0,0 +1,53 @@
1
+ function normalizeCriteria(value) {
2
+ if (!Array.isArray(value)) return null;
3
+ return value.map(item => String(item).trim()).filter(Boolean);
4
+ }
5
+
6
+ export function normalizeContractPatch(value) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
8
+ const patch = {};
9
+ if (typeof value.goal === 'string' && value.goal.trim()) patch.goal = value.goal.trim();
10
+ if (Object.hasOwn(value, 'acceptanceCriteria')) {
11
+ const criteria = normalizeCriteria(value.acceptanceCriteria);
12
+ if (!criteria) throw new Error('contractPatch.acceptanceCriteria must be an array');
13
+ patch.acceptanceCriteria = criteria;
14
+ }
15
+ return Object.keys(patch).length > 0 ? patch : null;
16
+ }
17
+
18
+ function normalizeAcceptanceChecks(value, criteria) {
19
+ if (!Array.isArray(value) || value.length !== criteria.length) return null;
20
+ const checks = value.map((raw, index) => {
21
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
22
+ const criterion = typeof raw.criterion === 'string' ? raw.criterion.trim() : '';
23
+ const status = ['passed', 'deferred', 'not_applicable'].includes(raw.status) ? raw.status : '';
24
+ const evidence = typeof raw.evidence === 'string' ? raw.evidence.trim().slice(0, 1_000) : '';
25
+ if (criterion !== criteria[index] || !status || !evidence) return null;
26
+ return { criterion, status, evidence };
27
+ });
28
+ return checks.every(Boolean) ? checks : null;
29
+ }
30
+
31
+ export function validateCompletedResult(result, action, workItem) {
32
+ if (result.outcome !== 'completed') return;
33
+ if (result.evidence.length === 0) {
34
+ result.outcome = 'failed';
35
+ result.error = 'Completed Action requires at least one concrete evidence item';
36
+ return;
37
+ }
38
+ const criteria = result.contractPatch?.acceptanceCriteria
39
+ ?? (Array.isArray(workItem.acceptanceCriteria) ? workItem.acceptanceCriteria : []);
40
+ const checks = normalizeAcceptanceChecks(result.acceptanceChecks, criteria);
41
+ if (!checks) {
42
+ result.outcome = 'failed';
43
+ result.error = 'Completed Action requires one ordered acceptance check with evidence for every acceptance criterion';
44
+ return;
45
+ }
46
+ const mustVerify = action.type === 'test'
47
+ || action.type === 'deliver'
48
+ || (action.type === 'review' && result.reviewDecision === 'approved');
49
+ if (mustVerify && checks.some(check => check.status !== 'passed')) {
50
+ result.outcome = 'failed';
51
+ result.error = `${action.type} Action requires every acceptance check to pass`;
52
+ }
53
+ }
@@ -9,60 +9,7 @@ import {
9
9
  import { renderSessionContextSnapshot } from './session-context.js';
10
10
  import { normalizeEvidence } from './evidence.js';
11
11
  import { applyAdditivePlanProposal } from './plan-mutation.js';
12
-
13
- function normalizeCriteria(value) {
14
- if (!Array.isArray(value)) return null;
15
- return value.map(item => String(item).trim()).filter(Boolean);
16
- }
17
-
18
- function normalizeContractPatch(value) {
19
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
20
- const patch = {};
21
- if (typeof value.goal === 'string' && value.goal.trim()) patch.goal = value.goal.trim();
22
- if (Object.prototype.hasOwnProperty.call(value, 'acceptanceCriteria')) {
23
- const criteria = normalizeCriteria(value.acceptanceCriteria);
24
- if (!criteria) throw new Error('contractPatch.acceptanceCriteria must be an array');
25
- patch.acceptanceCriteria = criteria;
26
- }
27
- return Object.keys(patch).length > 0 ? patch : null;
28
- }
29
-
30
- function normalizeAcceptanceChecks(value, criteria) {
31
- if (!Array.isArray(value) || value.length !== criteria.length) return null;
32
- const checks = value.map((raw, index) => {
33
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
34
- const criterion = typeof raw.criterion === 'string' ? raw.criterion.trim() : '';
35
- const status = ['passed', 'deferred', 'not_applicable'].includes(raw.status) ? raw.status : '';
36
- const evidence = typeof raw.evidence === 'string' ? raw.evidence.trim().slice(0, 1_000) : '';
37
- if (criterion !== criteria[index] || !status || !evidence) return null;
38
- return { criterion, status, evidence };
39
- });
40
- return checks.every(Boolean) ? checks : null;
41
- }
42
-
43
- function validateCompletedResult(result, action, workItem) {
44
- if (result.outcome !== 'completed') return;
45
- if (result.evidence.length === 0) {
46
- result.outcome = 'failed';
47
- result.error = 'Completed Action requires at least one concrete evidence item';
48
- return;
49
- }
50
- const criteria = result.contractPatch?.acceptanceCriteria
51
- ?? (Array.isArray(workItem.acceptanceCriteria) ? workItem.acceptanceCriteria : []);
52
- const checks = normalizeAcceptanceChecks(result.acceptanceChecks, criteria);
53
- if (!checks) {
54
- result.outcome = 'failed';
55
- result.error = 'Completed Action requires one ordered acceptance check with evidence for every acceptance criterion';
56
- return;
57
- }
58
- const mustVerify = action.type === 'test'
59
- || action.type === 'deliver'
60
- || (action.type === 'review' && result.reviewDecision === 'approved');
61
- if (mustVerify && checks.some(check => check.status !== 'passed')) {
62
- result.outcome = 'failed';
63
- result.error = `${action.type} Action requires every acceptance check to pass`;
64
- }
65
- }
12
+ import { normalizeContractPatch, validateCompletedResult } from './completion-contract.js';
66
13
 
67
14
  function normalizeTerminalResult(result, action) {
68
15
  if (!result || !RUN_OUTCOMES.includes(result.outcome)) {
@@ -31,7 +31,9 @@ import { loadMCPConfig } from '../config.js';
31
31
  import { MCPManager } from '../mcp.js';
32
32
  import { buildMcpFlattenedTools } from '../tools/mcp-tools.js';
33
33
  import { recallWorkspaceSessionContext } from './workspace-context.js';
34
- import { BUILT_IN_ACTION_TYPES } from './workflow.js';
34
+ import { applyGeneratedPlan, BUILT_IN_ACTION_TYPES } from './workflow.js';
35
+ import { normalizeContractPatch, validateCompletedResult } from './completion-contract.js';
36
+ import { normalizeEvidence } from './evidence.js';
35
37
  import {
36
38
  MAINLINE_CONTEXT_HARD_LIMIT_BYTES,
37
39
  buildMainlineContextSnapshot,
@@ -300,14 +302,20 @@ export function planningVpCatalog(vps) {
300
302
  }));
301
303
  }
302
304
 
303
- export function createSubmitWorkItemPlanTool({ vps, collector, isRunActive }) {
305
+ export function createSubmitWorkItemPlanTool({
306
+ vps,
307
+ workItem,
308
+ collector,
309
+ isRunActive,
310
+ reservedStageIds = [],
311
+ }) {
304
312
  const vpCatalog = planningVpCatalog(vps);
305
313
  const vpIds = vpCatalog.map(vp => vp.id);
306
314
  const actionTypes = BUILT_IN_ACTION_TYPES.filter(type => type !== 'triage');
307
315
  const catalogDescription = `Action types: ${actionTypes.join(', ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'}; ${vp.traits.join(', ') || 'no traits'})`).join('; ')}.`;
308
316
  return defineTool({
309
317
  name: 'SubmitWorkItemPlan',
310
- description: `Submit the complete initial WorkItem contract and executable Action DAG. Every Action must describe this WorkItem's concrete objective, repository-aware approach, and verifiable expected outcome; never copy generic Action-type text. The reference workflow catalog does not replace this Action list. This tool records a Run-local proposal only; Work Center validates and persists it in the current Run finalization transaction. ${catalogDescription}`,
318
+ description: `Submit the complete initial WorkItem contract and executable Action DAG. Every Action must describe this WorkItem's concrete objective, repository-aware approach, and verifiable expected outcome; never copy generic Action-type text. The reference workflow catalog does not replace this Action list. If any Action uses isolated-write workspace mode, the plan must contain exactly one integrate Action in integrate workspace mode; that Action must depend directly on every isolated-write Action, and all later Actions must consume those writes through it. This tool validates the proposal immediately so you can correct an invalid graph in the same triage loop; Work Center persists only a valid proposal in the current Run finalization transaction. ${catalogDescription}`,
311
319
  parameters: {
312
320
  type: 'object',
313
321
  additionalProperties: false,
@@ -331,6 +339,28 @@ export function createSubmitWorkItemPlanTool({ vps, collector, isRunActive }) {
331
339
  async execute(input, ctx = {}) {
332
340
  if (!isRunActive()) throw new Error('Work Center Run is no longer active');
333
341
  if (collector.value) throw new Error('WorkItem plan was already submitted for this Run');
342
+ const contractPatch = normalizeContractPatch(input.contractPatch);
343
+ const proposedResult = {
344
+ outcome: 'completed',
345
+ evidence: normalizeEvidence(input.evidence),
346
+ contractPatch,
347
+ acceptanceChecks: input.acceptanceChecks,
348
+ };
349
+ validateCompletedResult(proposedResult, { type: 'triage' }, workItem);
350
+ if (proposedResult.outcome !== 'completed') throw new Error(proposedResult.error);
351
+ const effectiveWorkItem = contractPatch ? {
352
+ ...workItem,
353
+ goal: contractPatch.goal ?? workItem.goal,
354
+ acceptanceCriteria: contractPatch.acceptanceCriteria ?? workItem.acceptanceCriteria,
355
+ } : workItem;
356
+ applyGeneratedPlan(effectiveWorkItem, {
357
+ workItemType: input.workItemType,
358
+ actions: input.actions,
359
+ }, {
360
+ availableVpIds: vpIds,
361
+ reservedStageIds,
362
+ });
363
+ if (!isRunActive()) throw new Error('Work Center Run is no longer active');
334
364
  collector.value = structuredClone(input);
335
365
  ctx.requestEndTurn?.({ kind: 'work_item_plan_submitted' });
336
366
  return JSON.stringify({ submitted: true, actionCount: input.actions.length });
@@ -860,7 +890,13 @@ export class WorkItemRunner {
860
890
  && workItem?.workflowSnapshot?.planningMode === 'ai';
861
891
  const runTools = [];
862
892
  if (planToolEnabled) runTools.push(createSubmitWorkItemPlanTool({
863
- vps: this.registry.listVps(), collector: planCollector, isRunActive,
893
+ vps: this.registry.listVps(),
894
+ workItem,
895
+ collector: planCollector,
896
+ isRunActive,
897
+ reservedStageIds: executionAction.stageId?.startsWith('replan-')
898
+ ? this.store.getWorkItemDetail(workItem.id).actions.map(item => item.stageId)
899
+ : [],
864
900
  }));
865
901
  if (!planToolEnabled && workItem?.workflowSnapshot?.executionMode === 'graph') {
866
902
  runTools.push(createProposeWorkItemActionsTool({
@@ -373,7 +373,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
373
373
  const typeInstruction = requestedType
374
374
  ? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
375
375
  : 'Infer one specific workItemType from the contract.';
376
- const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReference workflow catalog:\n${catalog || '(none)'}\nUse the catalog only to understand established task categories and sequencing patterns. Always submit the smallest reliable graph of 1 to 8 task-specific Actions; never omit Actions or copy template brief text. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. The objective, approach, and expectedOutcome must be specific to this WorkItem and that Action: describe the concrete work, the repository-aware execution method, and the verifiable result that will guide the executor. Generic Action-type boilerplate is invalid. Add only Actions required by this task. Do not copy a generic workflow.`;
376
+ const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReference workflow catalog:\n${catalog || '(none)'}\nUse the catalog only to understand established task categories and sequencing patterns. Always submit the smallest reliable graph of 1 to 8 task-specific Actions; never omit Actions or copy template brief text. Split independent work into separate Actions and declare dependsOnActionIds. Use workspaceMode read for analysis, isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. If any Action uses isolated-write, add exactly one Action with type integrate and workspaceMode integrate; it must depend directly on every isolated-write Action, and all later Actions must depend on the integration result rather than an isolated-write Action. Non-Git or dirty workspaces are serialized automatically. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. The objective, approach, and expectedOutcome must be specific to this WorkItem and that Action: describe the concrete work, the repository-aware execution method, and the verifiable result that will guide the executor. Generic Action-type boilerplate is invalid. Add only Actions required by this task. Do not copy a generic workflow.`;
377
377
  return normalizeWorkflowDefinition({
378
378
  id: 'ai-planned',
379
379
  name: 'AI planned',