@yeaft/webchat-agent 1.0.360 → 1.0.362

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.360"}
1
+ {"version":"1.0.362"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.360",
3
+ "version": "1.0.362",
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
@@ -2158,8 +2158,12 @@ export class Engine {
2158
2158
  const executionOrigin = inboundEnvelope?.msg?.meta?.injectedBy === 'route_forward'
2159
2159
  ? 'route_forward'
2160
2160
  : null;
2161
- const queryTurnId = randomUUID();
2162
- this.#currentQueryTurnId = vpTurnId || queryTurnId;
2161
+ // The bridge-provided VP turn id is also persisted on assistant messages and
2162
+ // is therefore the identity the UI sends back when opening turn debug. Keep
2163
+ // the engine event/trace id identical; a second random id makes the trace
2164
+ // impossible to retrieve from a rendered assistant turn.
2165
+ const queryTurnId = vpTurnId || randomUUID();
2166
+ this.#currentQueryTurnId = queryTurnId;
2163
2167
  // Bind the live query scope before tools can register async work. The
2164
2168
  // constructor's Session id is only guaranteed for bridge-owned engines;
2165
2169
  // standalone/CLI callers pass it per query.
@@ -1035,18 +1035,19 @@ async function waitForRoutePromises(msgId) {
1035
1035
  }
1036
1036
 
1037
1037
  /**
1038
- * Drop the cached coordinator + router for a group AND abort/clear any
1039
- * in-flight VP turns belonging to it. Call this from every CRUD handler
1040
- * that mutates the group's roster, meta, or lifecycle state on disk
1041
- * the cached coord holds a closed `sessionHandle`, so without invalidation
1042
- * later route_forward / ingest calls would read zombie meta (stale
1043
- * roster, pre-rename announcement, kicked members still routable).
1038
+ * Drop the cached coordinator + router for a Session. Metadata changes must
1039
+ * not abort in-flight work: a rename, announcement sync, or default-VP update
1040
+ * can race any provider request, and the running coordinator already owns a
1041
+ * consistent snapshot for that turn. Future turns rebuild from disk.
1042
+ *
1043
+ * Destructive lifecycle operations opt into runtime teardown.
1044
1044
  *
1045
1045
  * Idempotent — safe to call when no entry exists.
1046
1046
  */
1047
- function invalidateGroupContext(sessionId) {
1047
+ function invalidateGroupContext(sessionId, { abortRuntime = false } = {}) {
1048
1048
  if (!sessionId) return;
1049
1049
  sessionContexts.delete(sessionId);
1050
+ if (!abortRuntime) return;
1050
1051
  const prefix = `${sessionId}::`;
1051
1052
  for (const [k, ctrl] of vpAborts) {
1052
1053
  if (!k.startsWith(prefix)) continue;
@@ -1066,7 +1067,7 @@ function invalidateGroupContext(sessionId) {
1066
1067
  if (k.startsWith(prefix)) vpThreads.delete(k);
1067
1068
  }
1068
1069
  // Reap per-(group,vp) TodoWrite snapshots for this group so a
1069
- // deleted/renamed group doesn't pin a stale checklist forever.
1070
+ // deleted/archived group doesn't pin a stale checklist forever.
1070
1071
  for (const k of vpCurrentTodos.keys()) {
1071
1072
  if (k.startsWith(prefix)) vpCurrentTodos.delete(k);
1072
1073
  }
@@ -3438,7 +3439,7 @@ export function handleYeaftArchiveSession(msg) {
3438
3439
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3439
3440
  const result = archiveSession(yeaftDir, sessionId);
3440
3441
  projectContextBySession.delete(sessionId);
3441
- invalidateGroupContext(sessionId);
3442
+ invalidateGroupContext(sessionId, { abortRuntime: true });
3442
3443
  sendSessionCrudResult({
3443
3444
  op: 'archive',
3444
3445
  requestId,
@@ -3478,7 +3479,7 @@ export function handleYeaftDeleteSession(msg) {
3478
3479
  // turns for the deleted group. Engines for the deleted group are
3479
3480
  // also dropped — unlike rename/announcement updates, the group is
3480
3481
  // gone for good and there's nothing to preserve.
3481
- invalidateGroupContext(sessionId);
3482
+ invalidateGroupContext(sessionId, { abortRuntime: true });
3482
3483
  const prefix = `${sessionId}::`;
3483
3484
  for (const k of Array.from(vpEngines.keys())) {
3484
3485
  if (k.startsWith(prefix)) {
@@ -380,6 +380,10 @@ export class WorkflowController {
380
380
  actions: this.store.getWorkItemDetail(activeWorkItem.id).actions,
381
381
  proposal: result.planProposal,
382
382
  availableVpIds: this.listAvailableVpIds?.(),
383
+ reviewAction: activeAction.type === 'review'
384
+ && result.reviewDecision === 'changes_requested'
385
+ ? activeAction
386
+ : null,
383
387
  });
384
388
  } catch (error) {
385
389
  result.outcome = 'failed';
@@ -546,7 +550,7 @@ export class WorkflowController {
546
550
  eventData: { reason: result.replanRequest.reason },
547
551
  };
548
552
  }
549
- if (action.type === 'review' && result.reviewDecision === 'changes_requested') {
553
+ if (action.type === 'review' && result.reviewDecision === 'changes_requested' && !planProposal) {
550
554
  const targetStage = plannedWorkItem.workflowSnapshot.stages
551
555
  .find(stage => stage.id === action.changesRequestedStageId);
552
556
  if (!targetStage) throw new Error('Work Center review return target is missing');
@@ -12,6 +12,7 @@ import { normalizeContractPatch } from './completion-contract.js';
12
12
  import { applyCoordinatorReplan } from './plan-mutation.js';
13
13
  import { buildWorkItemAttachmentContext } from './attachments.js';
14
14
  import { sanitizeDiagnosticText } from './debug-projection.js';
15
+ import { generatedActionGraphRules } from './workflow.js';
15
16
 
16
17
  const COORDINATOR_MAX_REPLY_CHARS = 8_000;
17
18
  const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
@@ -181,8 +182,8 @@ Decision rules:
181
182
  - answer: use for explanation or status questions. Do not include contractPatch, guidance, or actions.
182
183
  - guide_actions: use only when the contract and graph stay valid. guidance must contain one or more {"stageId":"existing unfinished stage id","instruction":"specific next instruction"}. Do not include contractPatch or actions.
183
184
  - replan: use when the WorkItem contract or unfinished topology changes. contractPatch may be null or contain title, goal, and/or acceptanceCriteria. actions must be the COMPLETE desired unfinished Action graph after this decision; omit completed Actions. Each Action requires id, name, type, objective, approach, expectedOutcome, capability, candidateVpIds, assignmentReason, dependsOnActionIds, workspaceMode, and may include separateFromActionTypes, changesRequestedActionId, maxAttempts. Dependencies may reference immutable completed stage ids or earlier Actions in this actions array.
185
+ - Replan graph contract: ${generatedActionGraphRules()}
184
186
  - request_human: use only during automatic failure recovery, and only when no safe retry, guidance, or replan can be decided without human information. Set question to the exact information or decision required. Do not include contractPatch, guidance, or actions.
185
- - Every replan must keep exactly one final acceptance gate: normally one deliver Action, or one terminal review when no delivery operation is required. It must be the unique graph sink and transitively depend on all other Actions.
186
187
  - Action references are stage ids, never internal database Action ids.
187
188
  - Stage ids in the snapshot may be bounded aliases. Echo them exactly; the runtime resolves them to durable identities.
188
189
  - Never return destructive cancellation. Tell the user to use the explicit cancel control instead.`;
@@ -313,8 +314,8 @@ function permanentRecoveryDecision(error, language) {
313
314
  function coordinatorDecisionError(error, language) {
314
315
  const diagnostic = sanitizeDiagnosticText(error?.message || String(error || ''), 2_000);
315
316
  const wrapped = new Error(coordinatorLanguage(language) === 'zh'
316
- ? 'Work Center Coordinator 未能生成有效回复。你的消息已经保留,请重试。'
317
- : 'Work Center Coordinator could not produce a valid response. Your message was preserved; try again.');
317
+ ? 'Work Center Coordinator 生成的操作方案未通过校验。你的消息已经保留;重试会重新生成方案。'
318
+ : 'The Work Center Coordinator proposal did not pass validation. Your message was preserved; retry to generate a new proposal.');
318
319
  wrapped.coordinatorClassified = true;
319
320
  wrapped.coordinatorRetryable = false;
320
321
  wrapped.coordinatorPhase = 'decision';
@@ -411,9 +412,12 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
411
412
  };
412
413
  }
413
414
  const contractPatch = normalizeContractPatch(source.contractPatch);
414
- if (!Array.isArray(source.actions) || source.actions.length < 1 || source.actions.length > 8) {
415
+ if (!Array.isArray(source.actions)) {
415
416
  throw new Error('Work Center Coordinator replan requires the complete unfinished Action graph');
416
417
  }
418
+ if (source.actions.length < 1 || source.actions.length > 8) {
419
+ throw new Error('Work Center Coordinator replan requires between 1 and 8 unfinished Actions');
420
+ }
417
421
  return {
418
422
  reply,
419
423
  decision: {
@@ -42,6 +42,46 @@ function planActionFromStage(stage) {
42
42
  };
43
43
  }
44
44
 
45
+ function dependencyAncestors(stageId, stagesById) {
46
+ const ancestors = new Set();
47
+ const visit = id => {
48
+ if (ancestors.has(id)) return;
49
+ ancestors.add(id);
50
+ for (const dependencyId of stagesById.get(id)?.dependsOnStageIds || []) visit(dependencyId);
51
+ };
52
+ for (const dependencyId of stagesById.get(stageId)?.dependsOnStageIds || []) visit(dependencyId);
53
+ return ancestors;
54
+ }
55
+
56
+ function validateReviewRemediationGate(workflowSnapshot, reviewAction, addedIds) {
57
+ if (reviewAction?.type !== 'review') return;
58
+ const stages = (workflowSnapshot.stages || []).filter(stage => stage.type !== 'triage');
59
+ const byId = new Map(stages.map(stage => [stage.id, stage]));
60
+ const freshReviews = stages.filter(stage => (
61
+ stage.type === 'review'
62
+ && addedIds.has(stage.id)
63
+ && addedIds.has(stage.changesRequestedStageId)
64
+ && dependencyAncestors(stage.id, byId).has(reviewAction.stageId)
65
+ ));
66
+ const gate = stages.find(stage => stage.type === 'deliver')
67
+ || stages.find(stage => stage.type === 'review'
68
+ && !stages.some(candidate => (candidate.dependsOnStageIds || []).includes(stage.id)));
69
+ const gateAncestors = gate ? dependencyAncestors(gate.id, byId) : new Set();
70
+ const gatedReviews = freshReviews.filter(review => (
71
+ gate?.id === review.id || gateAncestors.has(review.id)
72
+ ));
73
+ const addedWork = stages.filter(stage => (
74
+ addedIds.has(stage.id) && stage.type !== 'review' && stage.type !== 'deliver'
75
+ ));
76
+ const uncovered = addedWork.filter(stage => (
77
+ !gatedReviews.some(review => dependencyAncestors(review.id, byId).has(stage.id))
78
+ ));
79
+ if (addedWork.length === 0 || gatedReviews.length === 0 || uncovered.length > 0) {
80
+ const suffix = uncovered.length > 0 ? `; uncovered Actions: ${uncovered.map(stage => stage.id).join(', ')}` : '';
81
+ throw new Error(`Work Center changes_requested additive plan requires remediation and a fresh review covering every added Action before delivery${suffix}`);
82
+ }
83
+ }
84
+
45
85
  function stableTopologicalActions(actions) {
46
86
  const byId = new Map(actions.map((action, index) => [action.id, { action, index }]));
47
87
  const incoming = new Map(actions.map(action => [action.id, 0]));
@@ -112,7 +152,9 @@ function validateDependencyPatches(actions, patches, addedIds) {
112
152
  return normalized;
113
153
  }
114
154
 
115
- export function applyAdditivePlanProposal({ workItem, actions, proposal, availableVpIds = null }) {
155
+ export function applyAdditivePlanProposal({
156
+ workItem, actions, proposal, availableVpIds = null, reviewAction = null,
157
+ }) {
116
158
  if (workItem.workflowSnapshot?.executionMode !== 'graph') {
117
159
  throw new Error('Work Center additive planning requires graph execution');
118
160
  }
@@ -144,12 +186,14 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
144
186
  raw.dependsOnActionIds,
145
187
  `Action "${id}" dependencies`,
146
188
  ),
147
- changesRequestedActionId: hasReturnTarget
148
- ? canonicalExplicitActionId(
149
- raw.changesRequestedActionId,
150
- `Action "${id}" review target`,
151
- )
152
- : undefined,
189
+ ...(hasReturnTarget
190
+ ? {
191
+ changesRequestedActionId: canonicalExplicitActionId(
192
+ raw.changesRequestedActionId,
193
+ `Action "${id}" review target`,
194
+ ),
195
+ }
196
+ : {}),
153
197
  };
154
198
  });
155
199
  const dependencyPatches = validateDependencyPatches(actions, proposal.dependencyPatches, addedIds);
@@ -192,6 +236,7 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
192
236
  availableVpIds,
193
237
  maxActions: MAX_WORK_ITEM_ACTIONS,
194
238
  });
239
+ validateReviewRemediationGate(workflowSnapshot, reviewAction, addedIds);
195
240
  const addedStages = workflowSnapshot.stages.filter(stage => addedIds.has(stage.id));
196
241
  return {
197
242
  proposalId,
@@ -253,9 +298,14 @@ export function applyCoordinatorReplan({ workItem, actions, proposal, availableV
253
298
  ...raw,
254
299
  id,
255
300
  dependsOnActionIds,
256
- changesRequestedActionId: Object.hasOwn(raw, 'changesRequestedActionId')
257
- ? canonicalExplicitActionId(raw.changesRequestedActionId, `Coordinator Action "${id}" review target`)
258
- : undefined,
301
+ ...(Object.hasOwn(raw, 'changesRequestedActionId')
302
+ ? {
303
+ changesRequestedActionId: canonicalExplicitActionId(
304
+ raw.changesRequestedActionId,
305
+ `Coordinator Action "${id}" review target`,
306
+ ),
307
+ }
308
+ : {}),
259
309
  };
260
310
  });
261
311
 
@@ -363,9 +413,14 @@ export function applyReplanMutation({ workItem, action, actions, proposal, avail
363
413
  ...raw,
364
414
  id,
365
415
  dependsOnActionIds: canonicalExplicitActionIds(raw.dependsOnActionIds, `Action "${id}" dependencies`),
366
- changesRequestedActionId: Object.hasOwn(raw, 'changesRequestedActionId')
367
- ? canonicalExplicitActionId(raw.changesRequestedActionId, `Action "${id}" review target`)
368
- : undefined,
416
+ ...(Object.hasOwn(raw, 'changesRequestedActionId')
417
+ ? {
418
+ changesRequestedActionId: canonicalExplicitActionId(
419
+ raw.changesRequestedActionId,
420
+ `Action "${id}" review target`,
421
+ ),
422
+ }
423
+ : {}),
369
424
  };
370
425
  };
371
426
  const retainedInputs = retained.map(entry => canonicalFuture(entry.input, entry.action.stageId));
@@ -413,14 +413,32 @@ export function createSubmitWorkItemPlanTool({
413
413
  });
414
414
  }
415
415
 
416
- function terminalPlanningFields() {
416
+ function terminalPlanningFields(options = {}) {
417
417
  return {
418
418
  summary: { type: 'string', minLength: 1, maxLength: 2_000 },
419
419
  evidence: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string', minLength: 1, maxLength: 1_000 } },
420
420
  acceptanceChecks: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['criterion', 'status', 'evidence'], properties: { criterion: { type: 'string' }, status: { type: 'string', enum: ['passed', 'deferred', 'not_applicable'] }, evidence: { type: 'string', minLength: 1, maxLength: 1_000 } } } },
421
+ ...(options.review === true ? {
422
+ reviewDecision: { type: 'string', const: 'changes_requested' },
423
+ } : {}),
421
424
  };
422
425
  }
423
426
 
427
+ function reviewPlanningRequirements(action) {
428
+ return action?.type === 'review' ? ['reviewDecision'] : [];
429
+ }
430
+
431
+ function assertReviewPlanningDecision(input, action) {
432
+ if (action?.type !== 'review') return;
433
+ if (input?.reviewDecision !== 'changes_requested') {
434
+ throw new Error('Review planning controls require reviewDecision "changes_requested"');
435
+ }
436
+ }
437
+
438
+ function reviewPlanningResult(input, action) {
439
+ return action?.type === 'review' ? { reviewDecision: input.reviewDecision } : {};
440
+ }
441
+
424
442
  function plannedActionSchema(vpIds, { requireCandidates = true } = {}) {
425
443
  const required = ['id', 'name', 'type', 'objective', 'approach', 'expectedOutcome', 'dependsOnActionIds', 'workspaceMode'];
426
444
  if (requireCandidates) required.push('candidateVpIds', 'assignmentReason');
@@ -445,11 +463,15 @@ export function createProposeWorkItemActionsTool({
445
463
  : '';
446
464
  return defineTool({
447
465
  name: 'ProposeWorkItemActions',
448
- description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Use stable stageId values in dependsOnActionIds, changesRequestedActionId, and dependencyPatches[].addDependsOnActionIds. The only internal id field is dependencyPatches[].actionId, which must use the displayed internalActionId of an eligible ready attempt=0 target.${currentIdentity} Existing Actions: ${existing.map(action => `stageId=${action.stageId} (internalActionId=${action.id}, ${action.status}, attempt ${action.attempt})`).join('; ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'})`).join('; ')}. Only add new Actions and optionally add dependencies to attempt=0 ready Actions. This tool validates the complete additive DAG immediately; if validation fails, correct the proposal in the same turn.`,
466
+ description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Use stable stageId values in dependsOnActionIds, changesRequestedActionId, and dependencyPatches[].addDependsOnActionIds. The only internal id field is dependencyPatches[].actionId, which must use the displayed internalActionId of an eligible ready attempt=0 target.${currentIdentity} Existing Actions: ${existing.map(action => `stageId=${action.stageId} (internalActionId=${action.id}, ${action.status}, attempt ${action.attempt})`).join('; ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'})`).join('; ')}. Only add new Actions and optionally add dependencies to attempt=0 ready Actions. A Review submitting changes_requested must add remediation followed by a fresh Review and make delivery depend on that fresh approval gate; otherwise request a replan. This tool validates the complete additive DAG immediately; if validation fails, correct the proposal in the same turn.`,
449
467
  parameters: { type: 'object', additionalProperties: false,
450
- required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'actions'],
468
+ required: [
469
+ 'summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'actions',
470
+ ...reviewPlanningRequirements(currentAction),
471
+ ],
451
472
  properties: {
452
- ...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
473
+ ...terminalPlanningFields({ review: currentAction?.type === 'review' }),
474
+ proposalId: { type: 'string', minLength: 1, maxLength: 128 },
453
475
  basePlanRevision: { type: 'integer', const: workItem.planRevision },
454
476
  actions: { type: 'array', minItems: 1, maxItems: 8, items: plannedActionSchema(vpIds) },
455
477
  dependencyPatches: { type: 'array', maxItems: 8, items: { type: 'object', additionalProperties: false, required: ['actionId', 'addDependsOnActionIds'], properties: { actionId: { type: 'string', enum: existing.filter(action => action.status === 'ready' && action.attempt === 0).map(action => action.id) }, addDependsOnActionIds: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string' } } } } },
@@ -457,14 +479,22 @@ export function createProposeWorkItemActionsTool({
457
479
  async execute(input, ctx = {}) {
458
480
  if (!isRunActive()) throw new Error('Work Center Run is no longer active');
459
481
  if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
482
+ assertReviewPlanningDecision(input, currentAction);
460
483
  applyAdditivePlanProposal({
461
484
  workItem,
462
485
  actions,
463
486
  proposal: input,
464
487
  availableVpIds: vpIds,
488
+ reviewAction: currentAction,
465
489
  });
466
490
  if (!isRunActive()) throw new Error('Work Center Run is no longer active');
467
- collector.value = { kind: 'expand', input: structuredClone(input) };
491
+ collector.value = {
492
+ kind: 'expand',
493
+ input: {
494
+ ...structuredClone(input),
495
+ ...reviewPlanningResult(input, currentAction),
496
+ },
497
+ };
468
498
  ctx.requestEndTurn?.({ kind: 'work_item_actions_proposed', proposalId: input.proposalId });
469
499
  return JSON.stringify({ submitted: true, proposalId: input.proposalId, actionCount: input.actions.length });
470
500
  },
@@ -472,21 +502,34 @@ export function createProposeWorkItemActionsTool({
472
502
  });
473
503
  }
474
504
 
475
- export function createRequestWorkItemReplanTool({ workItem, collector, isRunActive }) {
505
+ export function createRequestWorkItemReplanTool({
506
+ workItem, collector, isRunActive, currentAction = null,
507
+ }) {
476
508
  return defineTool({
477
509
  name: 'RequestWorkItemReplan',
478
510
  description: 'Request an explicit replan barrier when additive Actions are insufficient because the contract or existing future topology must change. The current Action must still complete. Work Center will preserve completed history, fence sibling Runs, supersede only unfinished Actions, and insert a new triage/replan Action. Active integration finalization prevents the barrier.',
479
511
  parameters: { type: 'object', additionalProperties: false,
480
- required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'reason'],
512
+ required: [
513
+ 'summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'reason',
514
+ ...reviewPlanningRequirements(currentAction),
515
+ ],
481
516
  properties: {
482
- ...terminalPlanningFields(), proposalId: { type: 'string', minLength: 1, maxLength: 128 },
517
+ ...terminalPlanningFields({ review: currentAction?.type === 'review' }),
518
+ proposalId: { type: 'string', minLength: 1, maxLength: 128 },
483
519
  basePlanRevision: { type: 'integer', const: workItem.planRevision },
484
520
  reason: { type: 'string', minLength: 1, maxLength: 4_000 },
485
521
  } },
486
522
  async execute(input, ctx = {}) {
487
523
  if (!isRunActive()) throw new Error('Work Center Run is no longer active');
488
524
  if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
489
- collector.value = { kind: 'replan', input: structuredClone(input) };
525
+ assertReviewPlanningDecision(input, currentAction);
526
+ collector.value = {
527
+ kind: 'replan',
528
+ input: {
529
+ ...structuredClone(input),
530
+ ...reviewPlanningResult(input, currentAction),
531
+ },
532
+ };
490
533
  ctx.requestEndTurn?.({ kind: 'work_item_replan_requested', proposalId: input.proposalId });
491
534
  return JSON.stringify({ submitted: true, proposalId: input.proposalId });
492
535
  },
@@ -1082,7 +1125,12 @@ export class WorkItemRunner {
1082
1125
  actions: this.store.getWorkItemDetail(workItem.id).actions,
1083
1126
  collector: mutationCollector, isRunActive, currentAction: executionAction,
1084
1127
  }));
1085
- runTools.push(createRequestWorkItemReplanTool({ workItem, collector: mutationCollector, isRunActive }));
1128
+ runTools.push(createRequestWorkItemReplanTool({
1129
+ workItem,
1130
+ collector: mutationCollector,
1131
+ isRunActive,
1132
+ currentAction: executionAction,
1133
+ }));
1086
1134
  }
1087
1135
  const runToolNames = runTools.map(tool => tool.name);
1088
1136
  const toolPolicySnapshot = workItemToolPolicySnapshot(
@@ -1389,6 +1437,7 @@ export class WorkItemRunner {
1389
1437
  } : submittedExpansion ? {
1390
1438
  outcome: 'completed', summary: submittedExpansion.summary,
1391
1439
  evidence: submittedExpansion.evidence, acceptanceChecks: submittedExpansion.acceptanceChecks,
1440
+ ...reviewPlanningResult(submittedExpansion, executionAction),
1392
1441
  planProposal: {
1393
1442
  proposalId: submittedExpansion.proposalId,
1394
1443
  basePlanRevision: submittedExpansion.basePlanRevision,
@@ -1398,6 +1447,7 @@ export class WorkItemRunner {
1398
1447
  } : submittedReplan ? {
1399
1448
  outcome: 'completed', summary: submittedReplan.summary,
1400
1449
  evidence: submittedReplan.evidence, acceptanceChecks: submittedReplan.acceptanceChecks,
1450
+ ...reviewPlanningResult(submittedReplan, executionAction),
1401
1451
  replanRequest: {
1402
1452
  proposalId: submittedReplan.proposalId,
1403
1453
  basePlanRevision: submittedReplan.basePlanRevision,
@@ -362,6 +362,23 @@ export function listWorkItemTypeTemplates(settings) {
362
362
  }));
363
363
  }
364
364
 
365
+ export const MAX_INITIAL_PLAN_ACTIONS = 8;
366
+ export const MAX_WORK_ITEM_ACTIONS = 64;
367
+
368
+ export function generatedActionGraphRules(options = {}) {
369
+ const maxActions = Math.min(
370
+ Math.max(Number(options.maxActions) || MAX_INITIAL_PLAN_ACTIONS, 1),
371
+ MAX_WORK_ITEM_ACTIONS,
372
+ );
373
+ const concurrency = Number.isInteger(Number(options.maxConcurrentActions))
374
+ ? Math.min(Math.max(Number(options.maxConcurrentActions), 1), 12)
375
+ : null;
376
+ const concurrencyRule = concurrency
377
+ ? ` The scheduler can run up to ${concurrency} Actions concurrently.`
378
+ : '';
379
+ return `Always submit the smallest reliable graph of 1 to ${maxActions} task-specific Actions; never omit Actions or copy template brief text. Every graph must end in exactly one final acceptance gate: normally one deliver Action, or one terminal review when no delivery operation is required. The final gate must be the unique graph sink and every other Action must be its transitive dependency, so final acceptance cannot run before required evidence.${concurrencyRule} Before submitting, compare each pair of Actions and add a dependency only when one consumes a concrete result or side effect of the other; ordering by narrative, phase name, or list position is not a dependency. Split independent analysis, verification, and repository changes into sibling Actions so the scheduler can use concurrency. Use workspaceMode read only for Actions guaranteed not to mutate files, Git state, services, or external systems; use isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. An Action with type integrate must use workspaceMode integrate, and type integrate is only valid when combining isolated-write Actions. 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 consume those writes through the integration Action. Non-Git or dirty workspaces are serialized automatically; do not fake parallelism by marking a mutating Action as read. Every review Action must depend directly or transitively on the editable non-review, non-deliver Action it reviews, so the scheduler cannot claim the Review before that result exists. Set changesRequestedActionId to a non-empty dependency ancestor Action id, or omit the property to use the nearest eligible dependency ancestor; never send null or an empty string. 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.`;
380
+ }
381
+
365
382
  export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType = null) {
366
383
  const normalized = normalizeWorkCenterSettings(settings);
367
384
  const requestedType = typeof requestedWorkItemType === 'string'
@@ -379,7 +396,9 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
379
396
  const typeInstruction = requestedType
380
397
  ? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
381
398
  : 'Infer one specific workItemType from the contract.';
382
- 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. Every graph must end in exactly one final acceptance gate: normally one deliver Action, or one terminal review when no delivery operation is required. The final gate must be the unique graph sink and every other Action must be its transitive dependency, so final acceptance cannot run before required evidence. The scheduler can run up to ${normalized.maxConcurrentActions} Actions concurrently. Before submitting, compare each pair of Actions and add a dependency only when one consumes a concrete result or side effect of the other; ordering by narrative, phase name, or list position is not a dependency. Split independent analysis, verification, and repository changes into sibling Actions so the scheduler can use that concurrency. Use workspaceMode read only for Actions guaranteed not to mutate files, Git state, services, or external systems; use 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; do not fake parallelism by marking a mutating Action as read. 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.`;
399
+ 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. ${generatedActionGraphRules({
400
+ maxConcurrentActions: normalized.maxConcurrentActions,
401
+ })}`;
383
402
  return normalizeWorkflowDefinition({
384
403
  id: 'ai-planned',
385
404
  name: 'AI planned',
@@ -466,9 +485,6 @@ export function validateGeneratedCompletionGate(stages) {
466
485
  }
467
486
  }
468
487
 
469
- export const MAX_INITIAL_PLAN_ACTIONS = 8;
470
- export const MAX_WORK_ITEM_ACTIONS = 64;
471
-
472
488
  export function applyGeneratedPlan(workItem, rawPlan, options = {}) {
473
489
  const source = workflowFrom(workItem);
474
490
  const forceGraph = options.forceGraph !== false;
@@ -582,23 +598,42 @@ export function applyGeneratedPlan(workItem, rawPlan, options = {}) {
582
598
  }
583
599
  return stage;
584
600
  });
601
+ const generatedById = new Map(generated.map(stage => [stage.id, stage]));
602
+ const dependencyAncestors = stage => {
603
+ const ancestors = new Set();
604
+ const visit = stageId => {
605
+ if (ancestors.has(stageId)) return;
606
+ ancestors.add(stageId);
607
+ for (const dependencyId of generatedById.get(stageId)?.dependsOnStageIds || []) visit(dependencyId);
608
+ };
609
+ for (const dependencyId of stage.dependsOnStageIds) visit(dependencyId);
610
+ return ancestors;
611
+ };
585
612
  for (const [index, stage] of generated.entries()) {
586
613
  if (stage.type !== 'review') continue;
614
+ const ancestors = dependencyAncestors(stage);
587
615
  const candidates = generated.slice(0, index)
588
- .filter(candidate => candidate.type !== 'review' && candidate.type !== 'deliver');
616
+ .filter(candidate => (
617
+ candidate.type !== 'review'
618
+ && candidate.type !== 'deliver'
619
+ && ancestors.has(candidate.id)
620
+ ));
589
621
  if (Object.prototype.hasOwnProperty.call(stage, 'changesRequestedStageId')) {
590
- const requested = candidates.find(candidate => candidate.id === stage.changesRequestedStageId);
591
- if (!requested) {
622
+ const requested = generatedById.get(stage.changesRequestedStageId);
623
+ if (!requested || requested.type === 'review' || requested.type === 'deliver') {
592
624
  throw new Error(`AI-planned review Action "${stage.id}" points to an invalid return Action`);
593
625
  }
626
+ if (!ancestors.has(requested.id)) {
627
+ throw new Error(`AI-planned review Action "${stage.id}" review target "${requested.id}" must be a dependency ancestor`);
628
+ }
594
629
  stage.changesRequestedStageId = requested.id;
595
630
  } else {
596
631
  stage.changesRequestedStageId = candidates.at(-1)?.id || '';
597
632
  }
598
633
  if (!stage.changesRequestedStageId) {
599
- throw new Error(`AI-planned review Action "${stage.id}" requires an earlier editable Action`);
634
+ throw new Error(`AI-planned review Action "${stage.id}" requires an editable dependency ancestor`);
600
635
  }
601
- const returnTarget = candidates.find(candidate => candidate.id === stage.changesRequestedStageId);
636
+ const returnTarget = generatedById.get(stage.changesRequestedStageId);
602
637
  stage.assignmentPolicy = {
603
638
  ...stage.assignmentPolicy,
604
639
  separateFromStageTypes: uniqueStrings([