@yeaft/webchat-agent 1.0.129 → 1.0.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import { parsePatch } from '../tools/apply-patch.js';
5
5
  import { defaultRegistry } from '../vp/registry.js';
6
6
  import { NullTrace } from '../debug-trace.js';
7
7
  import { isPathInsideOrEqual } from '../tools/path-safety.js';
8
+ import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
8
9
  import { existsSync, lstatSync, realpathSync } from 'node:fs';
9
10
  import path from 'node:path';
10
11
 
@@ -54,6 +55,21 @@ function canonicalWorkDir(workDir) {
54
55
  return realpathSync(workDir);
55
56
  }
56
57
 
58
+ export function resolveWorkItemWorkDir(workItem, defaultWorkDir) {
59
+ if (typeof workItem?.workspaceKey === 'string' && workItem.workspaceKey) {
60
+ const expected = path.resolve(workItem.workspaceKey);
61
+ const actual = canonicalWorkDir(expected);
62
+ if (actual !== expected) {
63
+ throw new Error('WorkItem canonical workspace identity changed; update its workDir before retrying');
64
+ }
65
+ return expected;
66
+ }
67
+ if (typeof workItem?.workDir === 'string' && workItem.workDir.trim()) {
68
+ throw new Error('WorkItem has no canonical workspace identity; update its workDir before retrying');
69
+ }
70
+ return canonicalWorkDir(path.resolve(defaultWorkDir || process.cwd()));
71
+ }
72
+
57
73
  function assertPathInside(toolName, workDir, value) {
58
74
  const resolved = path.resolve(workDir, value);
59
75
  if (!isPathInsideOrEqual(workDir, resolved)) {
@@ -188,12 +204,6 @@ function completionContract(action) {
188
204
  }\nA model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
189
205
  }
190
206
 
191
- function resolveModel(config, vp) {
192
- if (vp.modelHint === 'fast' && config.fastModel) return config.fastModel;
193
- if (vp.modelHint === 'primary' && config.primaryModel) return config.primaryModel;
194
- return config.model || config.primaryModel || null;
195
- }
196
-
197
207
  export class WorkItemRunner {
198
208
  constructor(options) {
199
209
  this.runtimeProvider = options.runtimeProvider;
@@ -203,22 +213,39 @@ export class WorkItemRunner {
203
213
 
204
214
  async run({ workItem, action, run, signal, ownerBootId }) {
205
215
  const runtime = await this.runtimeProvider();
206
- const rawWorkDir = workItem.workDir || runtime.defaultWorkDir || process.cwd();
207
- const workDir = canonicalWorkDir(path.resolve(rawWorkDir));
208
- const vp = copyVp(this.registry.getVp(action.requiredRole));
216
+ const workDir = resolveWorkItemWorkDir(workItem, runtime.defaultWorkDir);
217
+ const priorRuns = this.store.listCompletedRuns(workItem.id);
218
+ const assignment = action.assignmentPolicy
219
+ ? selectWorkItemVp({
220
+ policy: action.assignmentPolicy,
221
+ stageType: action.type,
222
+ vps: this.registry.listVps(),
223
+ priorRuns,
224
+ })
225
+ : {
226
+ vp: this.registry.getVp(action.requiredRole),
227
+ reason: `legacy-fixed:${action.requiredRole}`,
228
+ policy: { mode: 'fixed', fixedVpId: action.requiredRole },
229
+ };
230
+ const vp = copyVp(assignment.vp);
209
231
  if (!vp) {
210
- const error = new Error(`Required Work Center role is unavailable: ${action.requiredRole}`);
232
+ const error = new Error(`Required Work Center VP is unavailable: ${action.requiredRole || '(unassigned)'}`);
211
233
  error.retryable = false;
212
234
  throw error;
213
235
  }
236
+ const resolvedModel = resolveWorkItemModel(runtime.config, vp, action.modelPolicy);
214
237
  const isRunActive = () => !signal.aborted
215
238
  && this.store.isActiveRun(run.id, ownerBootId, run.leaseEpoch);
216
239
  const toolPolicySnapshot = workItemToolPolicySnapshot(workDir);
217
240
  const toolRegistry = createWorkItemToolRegistry({ workDir, isRunActive });
218
- const model = resolveModel(runtime.config, vp);
219
241
  const config = {
220
242
  ...runtime.config,
221
- model,
243
+ model: resolvedModel.model,
244
+ // WorkItem model policy is part of the frozen execution contract. The
245
+ // Agent-level fallback would silently execute a different model while
246
+ // leaving the Run snapshot unchanged, so WorkItems must fail explicitly.
247
+ fallbackModel: null,
248
+ ...(resolvedModel.effort ? { modelEffort: resolvedModel.effort } : {}),
222
249
  _readOnly: true,
223
250
  serverMode: true,
224
251
  // WorkItem messages live in the Work Center DB. Never archive their
@@ -233,9 +260,20 @@ export class WorkItemRunner {
233
260
  ownerBootId,
234
261
  run.leaseEpoch,
235
262
  {
236
- roleSnapshot: { id: action.requiredRole, actionType: action.type },
263
+ roleSnapshot: {
264
+ id: action.stageId || action.type,
265
+ actionType: action.type,
266
+ assignmentPolicy: assignment.policy,
267
+ selectionReason: assignment.reason,
268
+ },
237
269
  vpSnapshot: vp,
238
- modelSnapshot: { id: model, provider: runtime.config.provider || null },
270
+ modelSnapshot: {
271
+ id: resolvedModel.model,
272
+ provider: resolvedModel.provider,
273
+ effort: resolvedModel.effort,
274
+ source: resolvedModel.source,
275
+ policy: resolvedModel.policy,
276
+ },
239
277
  toolPolicySnapshot,
240
278
  },
241
279
  );
@@ -260,7 +298,6 @@ export class WorkItemRunner {
260
298
  });
261
299
 
262
300
  let text = '';
263
- const toolEvidence = [];
264
301
  try {
265
302
  for await (const event of engine.query({
266
303
  prompt: `${action.instruction}${completionContract(action)}`,
@@ -275,18 +312,10 @@ export class WorkItemRunner {
275
312
  if (typeof event?.text === 'string') text += event.text;
276
313
  else if (typeof event?.delta === 'string') text += event.delta;
277
314
  else if (typeof event?.content === 'string' && event.type === 'assistant') text += event.content;
278
- if (event?.type === 'tool_end') {
279
- toolEvidence.push({
280
- tool: event.name,
281
- isError: !!event.isError,
282
- });
283
- }
284
315
  }
285
316
  } finally {
286
317
  try { engine.abort?.('work_item_run_finished'); } catch {}
287
318
  }
288
- const result = parseStructuredResult(text, action.type);
289
- result.evidence = [...result.evidence, ...toolEvidence].slice(-100);
290
- return result;
319
+ return parseStructuredResult(text, action.type);
291
320
  }
292
321
  }
@@ -3,7 +3,9 @@ import { randomUUID } from 'node:crypto';
3
3
  import { WorkItemStore } from './store.js';
4
4
  import { WorkflowController } from './controller.js';
5
5
  import { WorkItemWatcher } from './watcher.js';
6
- import { projectWorkItemSummary } from './projection.js';
6
+ import { projectWorkItemDetail, projectWorkItemSummary } from './projection.js';
7
+ import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
8
+ import { resolveWorkflowSnapshot } from './workflow.js';
7
9
 
8
10
  function requiredString(value, name) {
9
11
  if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required`);
@@ -13,6 +15,12 @@ function requiredString(value, name) {
13
15
  export class WorkCenterService {
14
16
  constructor(options) {
15
17
  const yeaftDir = requiredString(options?.yeaftDir, 'yeaftDir');
18
+ this.yeaftDir = yeaftDir;
19
+ this.settingsReader = options.settingsReader || readWorkCenterSettings;
20
+ this.settingsWriter = options.settingsWriter || writeWorkCenterSettings;
21
+ this.runtimeInfoProvider = typeof options.runtimeInfoProvider === 'function'
22
+ ? options.runtimeInfoProvider
23
+ : async () => ({ vps: [], models: [], primaryModel: null, fastModel: null });
16
24
  this.ownerBootId = options.ownerBootId || randomUUID();
17
25
  this.store = options.store || new WorkItemStore(join(yeaftDir, 'work-center', 'work-center.db'));
18
26
  this.controller = options.controller || new WorkflowController(this.store);
@@ -37,16 +45,33 @@ export class WorkCenterService {
37
45
  watcher: this.watcher.status(),
38
46
  };
39
47
  case 'get':
40
- return this.#requiredItem(payload.id);
48
+ return projectWorkItemDetail(this.#requiredItem(payload.id));
49
+ case 'get_settings': {
50
+ const settings = this.settingsReader(this.yeaftDir);
51
+ return { settings, runtime: await this.runtimeInfoProvider() };
52
+ }
53
+ case 'update_settings': {
54
+ const settings = this.settingsWriter(this.yeaftDir, payload.settings);
55
+ return { settings, runtime: await this.runtimeInfoProvider() };
56
+ }
41
57
  case 'create': {
58
+ const settings = this.settingsReader(this.yeaftDir);
59
+ const workflowTemplate = typeof payload.workflowTemplate === 'string' && payload.workflowTemplate.trim()
60
+ ? payload.workflowTemplate.trim()
61
+ : settings.defaultWorkflowId;
62
+ const workflowSnapshot = resolveWorkflowSnapshot(settings, workflowTemplate, payload.stageOverrides);
42
63
  const item = this.controller.create({
43
64
  title: requiredString(payload.title, 'title'),
44
65
  goal: requiredString(payload.goal, 'goal'),
45
66
  acceptanceCriteria: Array.isArray(payload.acceptanceCriteria)
46
67
  ? payload.acceptanceCriteria.map(value => String(value).trim()).filter(Boolean)
47
68
  : [],
48
- workflowTemplate: payload.workflowTemplate || 'software-change',
49
- workDir: typeof payload.workDir === 'string' ? payload.workDir.trim() : '',
69
+ workflowTemplate,
70
+ workflowSnapshot,
71
+ workDir: typeof payload.workDir === 'string' && payload.workDir.trim()
72
+ ? payload.workDir.trim()
73
+ : settings.defaultWorkDir,
74
+ reuseMemory: payload.reuseMemory !== false,
50
75
  origin: payload.origin && typeof payload.origin === 'object'
51
76
  ? {
52
77
  sessionId: typeof payload.origin.sessionId === 'string' ? payload.origin.sessionId : null,
@@ -57,7 +82,7 @@ export class WorkCenterService {
57
82
  linkedSessionIds: Array.isArray(payload.linkedSessionIds)
58
83
  ? [...new Set(payload.linkedSessionIds.map(value => String(value).trim()).filter(Boolean))]
59
84
  : [],
60
- start: payload.start !== false,
85
+ start: payload.start === undefined ? settings.startImmediately : payload.start !== false,
61
86
  });
62
87
  const detail = this.#requiredItem(item.id);
63
88
  this.#emit({ type: 'work_item.created', workItem: detail });
@@ -82,6 +107,17 @@ export class WorkCenterService {
82
107
  this.#emit({ type: 'work_item.cancelled', workItem: detail });
83
108
  return detail;
84
109
  }
110
+ case 'guide': {
111
+ const id = requiredString(payload.id, 'id');
112
+ const detail = this.controller.guide(id, {
113
+ guidance: typeof payload.guidance === 'string' ? payload.guidance : '',
114
+ actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
115
+ revision: payload.revision,
116
+ });
117
+ this.watcher.abortInvalidWorkItemRuns(id);
118
+ this.#emit({ type: 'action.guidance_added', workItem: detail });
119
+ return detail;
120
+ }
85
121
  case 'retry': {
86
122
  const detail = this.controller.retry(requiredString(payload.id, 'id'), {
87
123
  answer: typeof payload.answer === 'string' ? payload.answer : '',
@@ -0,0 +1,59 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { writeAtomic } from '../storage/atomic.js';
5
+ import { defaultWorkCenterSettings, normalizeWorkCenterSettings } from './workflow.js';
6
+
7
+ export const WORK_CENTER_SETTINGS_FILE = 'settings.json';
8
+ const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
9
+
10
+ function settingsPath(yeaftDir) {
11
+ return join(yeaftDir, 'work-center', WORK_CENTER_SETTINGS_FILE);
12
+ }
13
+
14
+ function withSettingsTransaction(yeaftDir, fn) {
15
+ const dir = dirname(settingsPath(yeaftDir));
16
+ mkdirSync(dir, { recursive: true });
17
+ const db = new DatabaseSync(join(dir, 'work-center.db'), { timeout: SETTINGS_LOCK_TIMEOUT_MS });
18
+ let transactionOpen = false;
19
+ try {
20
+ db.exec(`PRAGMA busy_timeout = ${SETTINGS_LOCK_TIMEOUT_MS};`);
21
+ db.exec('BEGIN IMMEDIATE');
22
+ transactionOpen = true;
23
+ const result = fn();
24
+ db.exec('COMMIT');
25
+ transactionOpen = false;
26
+ return result;
27
+ } catch (error) {
28
+ if (transactionOpen) {
29
+ try { db.exec('ROLLBACK'); } catch {}
30
+ }
31
+ throw error;
32
+ } finally {
33
+ db.close();
34
+ }
35
+ }
36
+
37
+ export function readWorkCenterSettings(yeaftDir) {
38
+ const file = settingsPath(yeaftDir);
39
+ if (!existsSync(file)) return defaultWorkCenterSettings();
40
+ try {
41
+ return normalizeWorkCenterSettings(JSON.parse(readFileSync(file, 'utf8')));
42
+ } catch (error) {
43
+ throw new Error(`Failed to read Work Center settings: ${error.message}`);
44
+ }
45
+ }
46
+
47
+ export function writeWorkCenterSettings(yeaftDir, value) {
48
+ return withSettingsTransaction(yeaftDir, () => {
49
+ const current = readWorkCenterSettings(yeaftDir);
50
+ const expectedRevision = Number(value?.revision);
51
+ if (!Number.isInteger(expectedRevision) || expectedRevision !== current.revision) {
52
+ throw new Error('Work Center settings changed elsewhere; reload before saving');
53
+ }
54
+ const normalized = normalizeWorkCenterSettings({ ...value, revision: current.revision + 1 });
55
+ const file = settingsPath(yeaftDir);
56
+ writeAtomic(file, `${JSON.stringify(normalized, null, 2)}\n`);
57
+ return normalized;
58
+ });
59
+ }
@@ -1,11 +1,17 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import { mkdirSync, realpathSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
 
7
- const SCHEMA_VERSION = 2;
7
+ const SCHEMA_VERSION = 3;
8
8
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
9
+ const MAX_REUSABLE_CONTEXT_ITEMS = 12;
10
+
11
+ function canonicalWorkspaceKey(workDir) {
12
+ if (typeof workDir !== 'string' || !workDir.trim()) return '';
13
+ try { return realpathSync(resolve(workDir.trim())); } catch { return ''; }
14
+ }
9
15
 
10
16
  function parseJson(value, fallback) {
11
17
  if (typeof value !== 'string' || !value) return fallback;
@@ -25,10 +31,13 @@ function mapWorkItem(row) {
25
31
  goal: row.goal,
26
32
  acceptanceCriteria: parseJson(row.acceptance_criteria, []),
27
33
  workflowTemplate: row.workflow_template,
34
+ workflowSnapshot: parseJson(row.workflow_snapshot, null),
28
35
  status: row.status,
29
36
  currentActionId: row.current_action_id || null,
30
37
  currentRunId: row.current_run_id || null,
31
38
  workDir: row.work_dir || '',
39
+ workspaceKey: row.workspace_key || '',
40
+ reuseMemory: row.reuse_memory !== 0,
32
41
  origin: parseJson(row.origin, null),
33
42
  linkedSessionIds: parseJson(row.linked_session_ids, []),
34
43
  createdAt: row.created_at,
@@ -43,7 +52,10 @@ function mapAction(row) {
43
52
  workItemId: row.work_item_id,
44
53
  sequence: row.sequence,
45
54
  type: row.type,
46
- requiredRole: row.required_role,
55
+ stageId: row.stage_id || row.type,
56
+ assignmentPolicy: parseJson(row.assignment_policy, null),
57
+ modelPolicy: parseJson(row.model_policy, null),
58
+ requiredRole: row.required_role || '',
47
59
  instruction: row.instruction,
48
60
  context: parseJson(row.context, []),
49
61
  contractRevision: row.contract_revision,
@@ -138,10 +150,13 @@ export class WorkItemStore {
138
150
  goal TEXT NOT NULL,
139
151
  acceptance_criteria TEXT NOT NULL,
140
152
  workflow_template TEXT NOT NULL,
153
+ workflow_snapshot TEXT,
141
154
  status TEXT NOT NULL,
142
155
  current_action_id TEXT,
143
156
  current_run_id TEXT,
144
157
  work_dir TEXT NOT NULL DEFAULT '',
158
+ workspace_key TEXT NOT NULL DEFAULT '',
159
+ reuse_memory INTEGER NOT NULL DEFAULT 1,
145
160
  origin TEXT,
146
161
  linked_session_ids TEXT NOT NULL DEFAULT '[]',
147
162
  created_at INTEGER NOT NULL,
@@ -153,6 +168,9 @@ export class WorkItemStore {
153
168
  sequence INTEGER NOT NULL,
154
169
  type TEXT NOT NULL,
155
170
  required_role TEXT NOT NULL,
171
+ stage_id TEXT,
172
+ assignment_policy TEXT,
173
+ model_policy TEXT,
156
174
  instruction TEXT NOT NULL,
157
175
  context TEXT NOT NULL DEFAULT '[]',
158
176
  contract_revision INTEGER NOT NULL DEFAULT 1,
@@ -202,7 +220,20 @@ export class WorkItemStore {
202
220
  `);
203
221
 
204
222
  // The feature shipped first as an unmerged PR, but keep the store tolerant
205
- // of a v1 database created by a review build.
223
+ // of databases created by review builds.
224
+ if (!hasColumn(this.db, 'work_items', 'workspace_key')) {
225
+ withTransaction(this.db, () => {
226
+ this.db.exec("ALTER TABLE work_items ADD COLUMN workspace_key TEXT NOT NULL DEFAULT ''");
227
+ const update = this.db.prepare('UPDATE work_items SET workspace_key = ? WHERE id = ?');
228
+ for (const row of this.db.prepare("SELECT id, work_dir FROM work_items WHERE work_dir != ''").all()) {
229
+ const workspaceKey = canonicalWorkspaceKey(row.work_dir);
230
+ if (workspaceKey) update.run(workspaceKey, row.id);
231
+ }
232
+ });
233
+ }
234
+ if (!hasColumn(this.db, 'work_items', 'reuse_memory')) {
235
+ this.db.exec('ALTER TABLE work_items ADD COLUMN reuse_memory INTEGER NOT NULL DEFAULT 1');
236
+ }
206
237
  if (!hasColumn(this.db, 'actions', 'context')) {
207
238
  this.db.exec("ALTER TABLE actions ADD COLUMN context TEXT NOT NULL DEFAULT '[]'");
208
239
  }
@@ -218,6 +249,18 @@ export class WorkItemStore {
218
249
  if (!hasColumn(this.db, 'runs', 'contract_patch')) {
219
250
  this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
220
251
  }
252
+ if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
253
+ this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
254
+ }
255
+ if (!hasColumn(this.db, 'actions', 'stage_id')) {
256
+ this.db.exec('ALTER TABLE actions ADD COLUMN stage_id TEXT');
257
+ }
258
+ if (!hasColumn(this.db, 'actions', 'assignment_policy')) {
259
+ this.db.exec('ALTER TABLE actions ADD COLUMN assignment_policy TEXT');
260
+ }
261
+ if (!hasColumn(this.db, 'actions', 'model_policy')) {
262
+ this.db.exec('ALTER TABLE actions ADD COLUMN model_policy TEXT');
263
+ }
221
264
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
222
265
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
223
266
  }
@@ -244,18 +287,22 @@ export class WorkItemStore {
244
287
  return withTransaction(this.db, () => {
245
288
  const now = this.now();
246
289
  const id = input.id || randomUUID();
290
+ const workspaceKey = canonicalWorkspaceKey(input.workDir);
247
291
  this.db.prepare(`INSERT INTO work_items
248
- (id, revision, title, goal, acceptance_criteria, workflow_template, status,
249
- current_action_id, current_run_id, work_dir, origin, linked_session_ids,
292
+ (id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
293
+ current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
250
294
  created_at, updated_at)
251
- VALUES (?, 1, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`).run(
295
+ VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`).run(
252
296
  id,
253
297
  input.title,
254
298
  input.goal,
255
299
  stringify(input.acceptanceCriteria || []),
256
300
  input.workflowTemplate || 'software-change',
301
+ stringify(input.workflowSnapshot || null),
257
302
  firstAction ? 'ready' : 'draft',
258
303
  input.workDir || '',
304
+ workspaceKey,
305
+ input.reuseMemory === false ? 0 : 1,
259
306
  stringify(input.origin || null),
260
307
  stringify(input.linkedSessionIds || []),
261
308
  now,
@@ -277,7 +324,10 @@ export class WorkItemStore {
277
324
  workItemId,
278
325
  sequence,
279
326
  type: input.type,
280
- requiredRole: input.requiredRole,
327
+ stageId: input.stageId || input.type,
328
+ assignmentPolicy: input.assignmentPolicy || null,
329
+ modelPolicy: input.modelPolicy || null,
330
+ requiredRole: input.requiredRole || '',
281
331
  instruction: input.instruction || '',
282
332
  context: Array.isArray(input.context) ? input.context : [],
283
333
  contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
@@ -290,15 +340,18 @@ export class WorkItemStore {
290
340
  updatedAt: now,
291
341
  };
292
342
  this.db.prepare(`INSERT INTO actions
293
- (id, work_item_id, sequence, type, required_role, instruction, context,
294
- contract_revision, status, attempt, max_attempts, current_run_id, lease_epoch,
295
- created_at, updated_at)
296
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
343
+ (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
344
+ instruction, context, contract_revision, status, attempt, max_attempts, current_run_id,
345
+ lease_epoch, created_at, updated_at)
346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
297
347
  action.id,
298
348
  workItemId,
299
349
  action.sequence,
300
350
  action.type,
301
351
  action.requiredRole,
352
+ action.stageId,
353
+ stringify(action.assignmentPolicy),
354
+ stringify(action.modelPolicy),
302
355
  action.instruction,
303
356
  stringify(action.context),
304
357
  action.contractRevision,
@@ -332,6 +385,16 @@ export class WorkItemStore {
332
385
  return mapRun(this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id));
333
386
  }
334
387
 
388
+ listCompletedRuns(workItemId) {
389
+ return this.db.prepare(`SELECT r.*, a.type AS action_type FROM runs r
390
+ JOIN actions a ON a.id = r.action_id
391
+ WHERE r.work_item_id = ? AND r.status != 'running'
392
+ ORDER BY r.started_at ASC`).all(workItemId).map(row => ({
393
+ ...mapRun(row),
394
+ actionType: row.action_type,
395
+ }));
396
+ }
397
+
335
398
  listWorkItems(filters = {}) {
336
399
  const where = [];
337
400
  const values = [];
@@ -366,6 +429,64 @@ export class WorkItemStore {
366
429
  };
367
430
  }
368
431
 
432
+ getReusableContext(workDir, excludeWorkItemId = null) {
433
+ const workspaceKey = canonicalWorkspaceKey(workDir);
434
+ if (!workspaceKey) return [];
435
+ const rows = this.db.prepare(`SELECT r.*, a.type AS action_type, a.required_role,
436
+ w.title AS source_title
437
+ FROM runs r
438
+ JOIN actions a ON a.id = r.action_id
439
+ JOIN work_items w ON w.id = r.work_item_id
440
+ WHERE w.workspace_key = ? AND w.reuse_memory = 1 AND w.id != ? AND w.status = 'done'
441
+ AND r.status = 'completed' AND length(trim(COALESCE(r.summary, ''))) > 0
442
+ ORDER BY r.ended_at DESC, r.started_at DESC
443
+ LIMIT ?`).all(workspaceKey, excludeWorkItemId || '', MAX_REUSABLE_CONTEXT_ITEMS);
444
+ return rows.reverse().map(row => {
445
+ const run = mapRun(row);
446
+ return {
447
+ type: row.action_type,
448
+ role: row.required_role,
449
+ summary: run.summary,
450
+ evidence: run.evidence,
451
+ reviewDecision: run.reviewDecision,
452
+ sourceTitle: row.source_title,
453
+ };
454
+ });
455
+ }
456
+
457
+ addActionGuidance(id, guidance, expected, makeAction) {
458
+ return withTransaction(this.db, () => {
459
+ const workItem = this.getWorkItem(id);
460
+ if (!workItem) return null;
461
+ if (workItem.currentActionId !== expected.actionId || workItem.revision !== expected.revision) {
462
+ throw new Error('Action changed before guidance was applied; refresh and try again');
463
+ }
464
+ if (!['ready', 'running'].includes(workItem.status)) {
465
+ throw new Error(`WorkItem in ${workItem.status} cannot accept Action guidance`);
466
+ }
467
+ const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
468
+ if (!previous || !['ready', 'running'].includes(previous.status)) {
469
+ throw new Error('WorkItem has no active Action for guidance');
470
+ }
471
+ const now = this.now();
472
+ this.#invalidateExecution(
473
+ workItem,
474
+ 'superseded',
475
+ 'superseded',
476
+ 'Action restarted after user guidance',
477
+ now,
478
+ );
479
+ const action = this.#insertAction(id, {
480
+ ...makeAction(workItem, previous),
481
+ contractRevision: workItem.revision,
482
+ }, this.#nextSequence(id), now);
483
+ this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
484
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
485
+ this.appendEvent(id, 'action.guidance_added', { guidance }, { actionId: action.id });
486
+ return this.getWorkItemDetail(id);
487
+ });
488
+ }
489
+
369
490
  #invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
370
491
  if (workItem.currentRunId) {
371
492
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
@@ -403,11 +524,12 @@ export class WorkItemStore {
403
524
  );
404
525
  }
405
526
  this.db.prepare(`UPDATE work_items SET title = ?, goal = ?, acceptance_criteria = ?,
406
- work_dir = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
527
+ work_dir = ?, workspace_key = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
407
528
  next.title,
408
529
  next.goal,
409
530
  stringify(next.acceptanceCriteria),
410
531
  next.workDir,
532
+ canonicalWorkspaceKey(next.workDir),
411
533
  revision,
412
534
  now,
413
535
  id,