@moretea-labs/forge 1.5.0 → 1.6.0

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/DESIGN.md +7 -2
  3. package/README.md +7 -7
  4. package/README.zh-CN.md +5 -3
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/forge-chatgpt-mcp-setup.md +27 -3
  22. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  23. package/docs/operations/controller-browser-plugin.md +12 -16
  24. package/docs/operations/features.md +1 -1
  25. package/docs/operations/homebrew.md +2 -2
  26. package/docs/public-usage-guide.md +1 -1
  27. package/docs/public-usage-guide.zh-CN.md +1 -1
  28. package/docs/tutorials/01-install-and-start.md +6 -4
  29. package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
  30. package/docs/tutorials/02-connect-chatgpt.md +8 -2
  31. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
  32. package/docs/wiki/Installation.md +1 -1
  33. package/docs/wiki/Quick-Start.md +1 -1
  34. package/package.json +1 -1
  35. package/scripts/check-public-docs.sh +14 -1
  36. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  37. package/scripts/sprint-backlog.sh +1 -1
  38. package/src/cli/commands/chatgpt.ts +31 -2
  39. package/src/cli/commands/init-hook.ts +3 -1
  40. package/src/cli/commands/mcp.ts +2 -0
  41. package/src/cli/commands/plugin.ts +36 -9
  42. package/src/cli/commands/runtime.ts +1 -0
  43. package/src/cli/commands/setup-profile.ts +49 -18
  44. package/src/cli/controller/context-pack.ts +158 -6
  45. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  46. package/src/cli/hook/prompt-intents.ts +1 -1
  47. package/src/cli/local-bridge/console-assets.ts +17 -0
  48. package/src/cli/local-bridge/console-automations.ts +205 -23
  49. package/src/cli/local-bridge/console-view-models.ts +2 -0
  50. package/src/cli/local-bridge/facade-api.ts +2 -0
  51. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  52. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  53. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  54. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  55. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  56. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  57. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  58. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  59. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  60. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  61. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  62. package/src/cli/local-bridge/server.ts +31 -20
  63. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  64. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  65. package/src/cli/mcp/auth.ts +2 -0
  66. package/src/cli/mcp/paths.ts +8 -2
  67. package/src/cli/mcp/repository-tools.ts +124 -9
  68. package/src/cli/mcp/server.ts +164 -19
  69. package/src/cli/mcp/setup.ts +9 -1
  70. package/src/cli/mcp/transports/http.ts +52 -2
  71. package/src/cli/mcp/transports/session-registry.ts +24 -0
  72. package/src/cli/repositories/command-classifier.ts +113 -0
  73. package/src/cli/repositories/safe-patch.ts +6 -4
  74. package/src/cli/repositories/structured-git.ts +49 -7
  75. package/src/cli/repository/inspector.ts +16 -3
  76. package/src/runtime/assistant/intent.ts +3 -3
  77. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  78. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  79. package/src/runtime/control-plane/execution/validation.ts +15 -2
  80. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  81. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  82. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  83. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  84. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  85. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  86. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  87. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  88. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  89. package/src/runtime/control-plane/facade/types.ts +33 -6
  90. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  91. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  92. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  93. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  94. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  95. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  96. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  97. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  98. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  99. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  100. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  101. package/src/runtime/execution/process-runtime/store.ts +6 -0
  102. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  103. package/src/runtime/gateway/mcp/router.ts +1 -1
  104. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  105. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  106. package/src/runtime/plugins/browser-adapter.ts +19 -72
  107. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  108. package/src/runtime/plugins/browser-handoff.ts +0 -3
  109. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  110. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  111. package/src/runtime/plugins/external-registration.ts +84 -5
  112. package/src/runtime/plugins/first-party-registry.ts +2 -2
  113. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  114. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  115. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  116. package/src/runtime/plugins/store.ts +107 -5
  117. package/src/runtime/plugins/types.ts +4 -0
  118. package/src/runtime/recovery/actions.ts +0 -10
  119. package/src/runtime/recovery/classifier.ts +0 -9
  120. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  121. package/src/runtime/recovery/probe.ts +0 -1
  122. package/src/runtime/recovery/types.ts +0 -1
  123. package/src/runtime/root/package-connector-service.ts +136 -0
  124. package/src/runtime/root/package-runtime-service.ts +25 -12
  125. package/src/runtime/root/release-materialize.ts +161 -18
  126. package/src/runtime/root/service.ts +6 -4
  127. package/src/runtime/safe-tooling/index.ts +0 -1
  128. package/src/runtime/safe-tooling/types.ts +0 -30
  129. package/src/runtime/standalone-recovery/core.ts +2 -2
  130. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  131. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  132. package/src/runtime/workflow/schedules/engine.ts +357 -64
  133. package/src/runtime/workflow/schedules/types.ts +5 -0
  134. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  135. package/bin/forge-desktop-helper.mjs +0 -204
  136. package/docs/operations/controller-desktop-plugin.md +0 -112
  137. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  138. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -9,6 +9,7 @@ import {
9
9
  appendWorkHandoffRef,
10
10
  createWorkContract,
11
11
  getWorkContract,
12
+ listWorkContracts,
12
13
  summarizeWorkContract,
13
14
  transitionWorkContractPhase,
14
15
  updateWorkContract,
@@ -17,6 +18,7 @@ import {
17
18
  import {
18
19
  claimPlanStepForWork,
19
20
  completePlanStepForWork,
21
+ getPlanContract,
20
22
  type PlanContractStoreOptions,
21
23
  } from './plan-contract-store';
22
24
  import {
@@ -57,8 +59,11 @@ export interface GoalWorkloopContext {
57
59
  controllerInstanceId?: string;
58
60
  workspaceFingerprint?: string;
59
61
  now?: () => string;
62
+ materializeIsolatedWorkspace?: (input: { workId: string; title: string; baseRef?: string }) => { checkoutId: string; root: string; baseRevision?: string | null; managed: true };
60
63
  }
61
64
 
65
+ export type WorkAdmissionRelation = 'continue' | 'extend' | 'parallel' | 'new_goal';
66
+
62
67
  export interface GoalWorkloopStartInput {
63
68
  objective: string;
64
69
  acceptanceCriteria?: string[];
@@ -68,6 +73,9 @@ export interface GoalWorkloopStartInput {
68
73
  constraints?: WorkContract['constraints'];
69
74
  modeInput: ExecutionModeSelectionInput;
70
75
  requestedBy?: WorkContract['requestedBy'];
76
+ relatedWorkId?: string;
77
+ workRelation?: WorkAdmissionRelation;
78
+ requirementId?: string;
71
79
  taskId?: string;
72
80
  issueId?: string;
73
81
  approvalConfirmed?: boolean;
@@ -280,11 +288,33 @@ export function routeWorkStart(
280
288
  selectedMode = selectExecutionMode({ ...effectiveModeInput, approvalConfirmed: true });
281
289
  policy = evaluateAccessPolicy(selectedMode);
282
290
  }
283
- const mode = applyForcedMode(selectedMode);
291
+ let mode = applyForcedMode(selectedMode);
292
+
293
+ // Route Policy is the sole execution-depth authority. Existing Work is
294
+ // ownership context, not a reason to upgrade an otherwise Direct operation
295
+ // into Durable Work. Explicit Work/Plan ownership is resolved only after a
296
+ // durable route has already been selected; checkout writer conflicts affect
297
+ // placement, not semantic task identity.
284
298
 
285
299
  if (mode.mode === 'direct_control') {
286
300
  const available = ctx.availableChecks ?? [];
287
301
  const normalized = normalizeCheckIds(input.checks ?? [], available);
302
+ // Explicit ownership may annotate a Direct operation, but it never changes
303
+ // the RouteDecision. The actual repository mutation can pass this Work id
304
+ // to repository_safe_patch_apply / repository_command_execute for evidence
305
+ // attribution while staying on the Direct/Process path.
306
+ const directOwnershipCandidates = input.relatedWorkId || (input.planId && input.planStepId)
307
+ ? listWorkContracts({ ...ctx.workStore, status: 'active', limit: 100 })
308
+ .filter((candidate) => (candidate.lifecycleRole ?? 'primary') === 'primary')
309
+ : [];
310
+ const directPlan = input.planId && ctx.planStore ? getPlanContract(ctx.planStore, input.planId) : undefined;
311
+ const directPlanStep = directPlan && input.planStepId ? directPlan.steps.find((step) => step.id === input.planStepId) : undefined;
312
+ const directOwner = (input.relatedWorkId
313
+ ? directOwnershipCandidates.find((candidate) => candidate.workId === input.relatedWorkId)
314
+ : undefined)
315
+ ?? (directPlanStep?.workId
316
+ ? directOwnershipCandidates.find((candidate) => candidate.workId === directPlanStep.workId)
317
+ : undefined);
288
318
  const suggested = validateSuggestedNextActions([
289
319
  {
290
320
  label: 'Apply bounded direct edit',
@@ -315,6 +345,13 @@ export function routeWorkStart(
315
345
  directControlPreserved: true,
316
346
  objective: input.objective.slice(0, 1_000),
317
347
  normalizedChecks: normalized,
348
+ ...(directOwner ? {
349
+ ownership: {
350
+ workId: directOwner.workId,
351
+ relation: input.workRelation ?? 'continue',
352
+ executionDepthPreserved: true,
353
+ },
354
+ } : {}),
318
355
  },
319
356
  warnings: [...policy.warnings, ...normalized.warnings],
320
357
  suggestedNextActions: suggested,
@@ -437,12 +474,189 @@ export function startGoalWorkloop(
437
474
  ): FacadeResult {
438
475
  const at = nowIso(ctx);
439
476
  const available = ctx.availableChecks ?? [];
440
- const normalized = normalizeCheckIds(input.checks ?? [], available);
441
477
  const workspaceMode = input.constraints?.workspaceMode ?? 'auto';
478
+ const activeWorks = listWorkContracts({ ...ctx.workStore, status: 'active', limit: 100 })
479
+ .filter((candidate) => (candidate.lifecycleRole ?? 'primary') === 'primary');
480
+ const workspaceOwner = ctx.checkoutId
481
+ ? activeWorks.find((candidate) => candidate.checkoutId === ctx.checkoutId
482
+ && candidate.worktreePolicy.required !== true
483
+ && !candidate.worktreeRef)
484
+ : undefined;
485
+ const explicitRelatedWork = input.relatedWorkId
486
+ ? activeWorks.find((candidate) => candidate.workId === input.relatedWorkId)
487
+ : undefined;
488
+ const plan = input.planId && ctx.planStore ? getPlanContract(ctx.planStore, input.planId) : undefined;
489
+ const planStep = plan && input.planStepId ? plan.steps.find((step) => step.id === input.planStepId) : undefined;
490
+ const sameStringSet = (provided: string[] | undefined, frozen: string[]): boolean => {
491
+ if (provided === undefined) return true;
492
+ const actual = [...new Set(provided)].sort();
493
+ const expected = [...new Set(frozen)].sort();
494
+ return actual.length === expected.length && actual.every((value, index) => value === expected[index]);
495
+ };
496
+ if (planStep) {
497
+ const mismatches = [
498
+ !sameStringSet(input.acceptanceCriteria, planStep.acceptanceCriteria) && 'acceptance_criteria',
499
+ !sameStringSet(input.allowedPaths, planStep.allowedPaths) && 'allowed_paths',
500
+ !sameStringSet(input.forbiddenPaths, planStep.forbiddenPaths) && 'forbidden_paths',
501
+ !sameStringSet(input.checks, planStep.checks) && 'check_ids',
502
+ ].filter((value): value is string => Boolean(value));
503
+ if (mismatches.length > 0) {
504
+ return buildFacadeResult({
505
+ status: 'blocked',
506
+ summary: `PLAN_STEP_WORK_CONTRACT_MISMATCH: ${input.planId}/${input.planStepId} conflicts with frozen Plan step field(s): ${mismatches.join(', ')}. Replan instead of widening or narrowing Work admission.`,
507
+ data: { executionStarted: false, workContractCreated: false, planId: input.planId, planStepId: input.planStepId, mismatches },
508
+ });
509
+ }
510
+ }
511
+ const effectiveAcceptanceCriteria = planStep?.acceptanceCriteria ?? input.acceptanceCriteria ?? [];
512
+ const effectiveAllowedPaths = planStep?.allowedPaths ?? input.allowedPaths ?? [];
513
+ const effectiveForbiddenPaths = planStep?.forbiddenPaths ?? input.forbiddenPaths ?? [];
514
+ const effectiveChecks = planStep?.checks ?? input.checks ?? [];
515
+ const normalized = normalizeCheckIds(effectiveChecks, available);
516
+ const planStepWork = planStep?.workId ? activeWorks.find((candidate) => candidate.workId === planStep.workId) : undefined;
517
+ const requirementWorks = input.requirementId
518
+ ? activeWorks.filter((candidate) => candidate.requirementId === input.requirementId)
519
+ : [];
520
+ const deterministicTarget = explicitRelatedWork ?? planStepWork ?? (requirementWorks.length === 1 ? requirementWorks[0] : undefined);
521
+ const requestedRelation = input.workRelation ?? (input.modeInput.requiresParallelism === true ? 'parallel' : undefined);
522
+ // Only strong semantic bindings participate in ownership resolution. An
523
+ // unrelated active Work or a checkout writer is a placement fact, not a
524
+ // semantic candidate for continue/extend/parallel/new_goal.
525
+ const candidateWorks = [...new Map(
526
+ [explicitRelatedWork, planStepWork, ...requirementWorks]
527
+ .filter((candidate): candidate is WorkContract => Boolean(candidate))
528
+ .map((candidate) => [candidate.workId, candidate]),
529
+ ).values()].slice(0, 8);
530
+
531
+ const resolutionRequired = (reason: string, target = deterministicTarget): FacadeResult => buildFacadeResult({
532
+ status: 'ok',
533
+ summary: reason,
534
+ data: {
535
+ executionStarted: false,
536
+ workContractCreated: false,
537
+ admissionDecision: 'resolution_required',
538
+ resolutionRequired: true,
539
+ requestedBy: input.requestedBy ?? 'chatgpt',
540
+ ...(target ? { recommendedWork: summarizeWorkContract(target) } : {}),
541
+ candidates: candidateWorks.map(summarizeWorkContract),
542
+ allowedRelations: ['continue', 'extend', 'parallel', 'new_goal'],
543
+ },
544
+ evidenceRefs: target?.evidenceRefs ?? [],
545
+ suggestedNextActions: target
546
+ ? [{
547
+ label: 'Continue existing Work',
548
+ tool: 'rh_work',
549
+ operation: 'continue',
550
+ payload: { work_id: target.workId },
551
+ risk: 'readonly',
552
+ confidence: planStepWork || explicitRelatedWork ? 'high' : 'medium',
553
+ reason: 'Resolve intent before execution; do not create a second Work until the relationship is explicit.',
554
+ }]
555
+ : [],
556
+ rawAvailable: false,
557
+ });
558
+
559
+ if ((input.requestedBy ?? 'chatgpt') === 'scheduler') {
560
+ return resolutionRequired(
561
+ deterministicTarget
562
+ ? `SCHEDULER_CONTINUATION_REQUIRED: scheduler-triggered execution must continue its bound Work ${deterministicTarget.workId}; it must not create a new durable Work.`
563
+ : 'SCHEDULER_WORK_BINDING_REQUIRED: scheduler-triggered execution must bind and wake an existing durable Work; it must not invent a new Work from a prompt.',
564
+ );
565
+ }
566
+
567
+ if (planStepWork && !requestedRelation) {
568
+ return buildFacadeResult({
569
+ status: 'ok',
570
+ summary: `PLAN_STEP_REUSES_ACTIVE_WORK: ${input.planId}/${input.planStepId} already executes as ${planStepWork.workId}; reuse that Work instead of creating another.`,
571
+ data: {
572
+ executionStarted: false,
573
+ workContractCreated: false,
574
+ admissionDecision: 'reuse_existing',
575
+ resolutionRequired: false,
576
+ work: summarizeWorkContract(planStepWork),
577
+ },
578
+ evidenceRefs: planStepWork.evidenceRefs,
579
+ suggestedNextActions: [{ label: 'Continue existing Work', tool: 'rh_work', operation: 'continue', payload: { work_id: planStepWork.workId }, risk: 'readonly', confidence: 'high' }],
580
+ rawAvailable: false,
581
+ });
582
+ }
583
+
584
+ if (requestedRelation === 'continue' || requestedRelation === 'extend') {
585
+ if (!deterministicTarget) {
586
+ return resolutionRequired(`${requestedRelation.toUpperCase()}_TARGET_REQUIRED: select related_work_id or bind the request to an active Plan/Requirement before execution.`);
587
+ }
588
+ if (requestedRelation === 'extend' && deterministicTarget.planId) {
589
+ return resolutionRequired(
590
+ `PLAN_EXTENSION_REQUIRES_REPLAN: Work ${deterministicTarget.workId} is governed by Plan ${deterministicTarget.planId}; extend the authoritative Plan/Requirement before continuing rather than silently widening Work scope.`,
591
+ deterministicTarget,
592
+ );
593
+ }
594
+ const selected = requestedRelation === 'extend'
595
+ ? updateWorkContract(ctx.workStore, deterministicTarget.workId, {
596
+ acceptanceCriteria: [...new Set([...deterministicTarget.acceptanceCriteria, ...(input.acceptanceCriteria ?? [])])].slice(0, 30),
597
+ allowedPaths: [...new Set([...deterministicTarget.allowedPaths, ...(input.allowedPaths ?? [])])].slice(0, 50),
598
+ forbiddenPaths: [...new Set([...deterministicTarget.forbiddenPaths, ...(input.forbiddenPaths ?? [])])].slice(0, 50),
599
+ checks: [...new Set([...deterministicTarget.checks, ...normalized.validCheckIds])].slice(0, 30),
600
+ scopeSummary: `Extended serially: ${input.objective}`.slice(0, 500),
601
+ continuationPrompt: `Continue work ${ctx.repoId}: ${deterministicTarget.objective.slice(0, 160)}. Additional requested scope: ${input.objective.slice(0, 500)}`,
602
+ })
603
+ : deterministicTarget;
604
+ return buildFacadeResult({
605
+ status: 'ok',
606
+ summary: `${requestedRelation === 'extend' ? 'Existing Work extended' : 'Existing Work selected'}: ${selected.workId}. No new WorkContract was created.`,
607
+ data: {
608
+ executionStarted: false,
609
+ workContractCreated: false,
610
+ admissionDecision: requestedRelation === 'extend' ? 'extend_existing' : 'reuse_existing',
611
+ resolutionRequired: false,
612
+ work: summarizeWorkContract(selected),
613
+ },
614
+ evidenceRefs: selected.evidenceRefs,
615
+ suggestedNextActions: [{ label: 'Continue existing Work', tool: 'rh_work', operation: 'continue', payload: { work_id: selected.workId }, risk: 'readonly', confidence: 'high' }],
616
+ rawAvailable: false,
617
+ });
618
+ }
619
+
620
+ if (!requestedRelation && candidateWorks.length > 0) {
621
+ return resolutionRequired(
622
+ `WORK_ADMISSION_RESOLUTION_REQUIRED: ${candidateWorks.length} active Work candidate(s) exist. Classify this request as continue, extend, parallel, or new_goal before any durable Work is created.`,
623
+ );
624
+ }
625
+
626
+ const placementConflict = Boolean(workspaceOwner);
442
627
  const needsWorktree = executionMode === 'goal_workloop' && (input.constraints?.requireWorktree
443
628
  ?? (workspaceMode === 'isolated'
629
+ || placementConflict
444
630
  || (workspaceMode === 'auto' && routeDecision.requiresIsolation === true)));
631
+ if (!needsWorktree && workspaceOwner) {
632
+ return buildFacadeResult({
633
+ status: 'blocked',
634
+ summary: `WORKSPACE_OWNERSHIP_INVARIANT: unresolved admission reached checkout ${ctx.checkoutId} while active Work ${workspaceOwner.workId} owns it. This is an internal routing invariant, not a normal fallback path.`,
635
+ data: {
636
+ executionStarted: false,
637
+ workContractCreated: false,
638
+ conflictType: 'workspace_single_writer_invariant',
639
+ existingWork: summarizeWorkContract(workspaceOwner),
640
+ },
641
+ evidenceRefs: workspaceOwner.evidenceRefs,
642
+ rawAvailable: false,
643
+ });
644
+ }
445
645
  const generatedWorkId = workIdFor(input.objective);
646
+ let isolatedWorkspace: ReturnType<NonNullable<GoalWorkloopContext['materializeIsolatedWorkspace']>> | undefined;
647
+ if (needsWorktree) {
648
+ if (!ctx.materializeIsolatedWorkspace) {
649
+ return buildFacadeResult({ status: 'blocked', summary: 'ISOLATED_WORKSPACE_MATERIALIZER_REQUIRED: isolated Work cannot enter running state without a concrete managed checkout.', data: { executionStarted: false, workContractCreated: false, worktreeRequired: true } });
650
+ }
651
+ try {
652
+ isolatedWorkspace = ctx.materializeIsolatedWorkspace({ workId: generatedWorkId, title: input.objective, baseRef: ctx.sourceRevision });
653
+ if (!isolatedWorkspace.checkoutId || !isolatedWorkspace.root || isolatedWorkspace.checkoutId === ctx.checkoutId) {
654
+ throw new Error('ISOLATED_WORKSPACE_NOT_DISTINCT');
655
+ }
656
+ } catch (error) {
657
+ return buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? `ISOLATED_WORKSPACE_MATERIALIZATION_FAILED: ${error.message}` : 'ISOLATED_WORKSPACE_MATERIALIZATION_FAILED', data: { executionStarted: false, workContractCreated: false, worktreeRequired: true } });
658
+ }
659
+ }
446
660
  if (input.planId || input.planStepId) {
447
661
  if (!input.planId || !input.planStepId || !ctx.planStore || !ctx.sourceRevision) {
448
662
  return buildFacadeResult({ status: 'blocked', summary: 'PLAN_CONTEXT_REQUIRED: plan_id, plan_step_id and a current source revision are required.', data: { executionStarted: false } });
@@ -473,28 +687,32 @@ export function startGoalWorkloop(
473
687
  const work = createWorkContract(ctx.workStore, {
474
688
  workId: generatedWorkId,
475
689
  repoId: ctx.repoId,
476
- checkoutId: ctx.checkoutId,
690
+ checkoutId: isolatedWorkspace?.checkoutId ?? ctx.checkoutId,
477
691
  principalId: ctx.principalId,
478
692
  controllerInstanceId: ctx.controllerInstanceId,
479
- baseRevision: ctx.sourceRevision,
480
- workspaceFingerprint: ctx.workspaceFingerprint,
693
+ baseRevision: isolatedWorkspace?.baseRevision ?? ctx.sourceRevision,
694
+ workspaceFingerprint: isolatedWorkspace ? undefined : ctx.workspaceFingerprint,
481
695
  routeDecisionFingerprint: routeDecision.inputFingerprint,
482
696
  routeDecision,
483
697
  mode: executionMode,
484
698
  objective: input.objective,
485
- acceptanceCriteria: input.acceptanceCriteria ?? [],
699
+ acceptanceCriteria: effectiveAcceptanceCriteria,
486
700
  constraints: input.constraints ?? { requireHandoffOnAmbiguity: true },
487
701
  risk: workRiskFor(input),
702
+ workKind: input.modeInput.requiresExternalEffect === true && input.modeInput.remoteWrite === true
703
+ ? 'remote_effect'
704
+ : undefined,
488
705
  status: 'running',
489
706
  phase: 'implementation',
490
707
  issueId: input.issueId,
491
708
  taskId: input.taskId,
709
+ requirementId: input.requirementId,
492
710
  planId: input.planId,
493
711
  planStepId: input.planStepId,
494
712
  planSourceRevision: input.planId ? ctx.sourceRevision : undefined,
495
713
  scopeSummary: input.modeInput.scopeClear ? 'scope declared at start' : 'scope incomplete',
496
- allowedPaths: input.allowedPaths ?? [],
497
- forbiddenPaths: input.forbiddenPaths ?? [],
714
+ allowedPaths: effectiveAllowedPaths,
715
+ forbiddenPaths: effectiveForbiddenPaths,
498
716
  checks: normalized.validCheckIds,
499
717
  driver: {
500
718
  preferred: executionMode === 'direct_control'
@@ -509,6 +727,7 @@ export function startGoalWorkloop(
509
727
  ? 'Isolation was explicitly requested or required for parallel execution.'
510
728
  : 'Current workspace is the stability-first default; isolation remains opt-in.',
511
729
  },
730
+ worktreeRef: isolatedWorkspace?.root,
512
731
  evidencePolicy: {
513
732
  defaultDetailLevel: 'summary',
514
733
  allowRawOptIn: true,
@@ -1115,6 +1334,9 @@ export function stopGoalWorkloop(ctx: GoalWorkloopContext, input: GoalWorkloopSt
1115
1334
  // cleanup handler verifies ownership, cleanliness and successful removal.
1116
1335
  worktreeRef: work.worktreeRef,
1117
1336
  });
1337
+ const plan = updated.planId && updated.planStepId && ctx.planStore
1338
+ ? completePlanStepForWork(ctx.planStore, { planId: updated.planId, stepId: updated.planStepId, work: updated })
1339
+ : undefined;
1118
1340
 
1119
1341
  return buildFacadeResult({
1120
1342
  status: 'ok',
@@ -1126,6 +1348,7 @@ export function stopGoalWorkloop(ctx: GoalWorkloopContext, input: GoalWorkloopSt
1126
1348
  worktreeDeleted: false,
1127
1349
  cleanupPending: destructiveCleanup && Boolean(work.worktreeRef),
1128
1350
  destructiveCleanupAuthorized: destructiveCleanup,
1351
+ ...(plan ? { plan } : {}),
1129
1352
  },
1130
1353
  evidenceRefs: work.evidenceRefs.slice(0, 5),
1131
1354
  suggestedNextActions: [
@@ -376,28 +376,48 @@ export function completePlanStepForWork(
376
376
  if (stepIndex < 0) throw new Error(`PLAN_STEP_NOT_FOUND: ${input.stepId}`);
377
377
  const step = current.steps[stepIndex];
378
378
  if (step.workId !== input.work.workId) throw new Error(`PLAN_STEP_WORK_MISMATCH: ${input.stepId}`);
379
- const completed = input.work.status === 'completed'
379
+ if (step.status === 'completed') return current;
380
+ const delivered = input.work.status === 'completed'
380
381
  && input.work.phase === 'cleanup'
381
382
  && input.work.evidenceState === 'valid'
382
383
  && Boolean(input.work.completionOutcome && input.work.completionOutcome !== 'superseded')
383
384
  && Boolean(input.work.completionReceipt);
384
385
  const failed = input.work.status === 'failed' || input.work.status === 'cancelled';
385
- if (!completed && !failed) throw new Error(`PLAN_STEP_WORK_NOT_TERMINAL: ${input.work.workId}`);
386
+ if (!delivered && !failed) throw new Error(`PLAN_STEP_WORK_NOT_TERMINAL: ${input.work.workId}`);
386
387
  const at = nowIso(options);
387
388
  const steps = [...current.steps];
388
389
  steps[stepIndex] = {
389
390
  ...step,
390
- status: completed ? 'completed' : 'validating',
391
- evidenceRefs: input.work.evidenceRefs.length > 0
392
- ? input.work.evidenceRefs.slice(0, 20)
393
- : step.evidenceRefs,
391
+ // Work completion proves machine delivery only. The Plan step remains in
392
+ // semantic validation until an explicit Controller acceptance records it.
393
+ // Failed/cancelled Work returns the slice to ready for replanning.
394
+ status: delivered ? 'validating' : 'ready',
395
+ evidenceRefs: input.work.evidenceRefs.length > 0 ? input.work.evidenceRefs.slice(0, 20) : step.evidenceRefs,
394
396
  };
395
- const allCompleted = steps.every((candidate) => candidate.status === 'completed');
396
- return {
397
- ...current,
398
- status: completed ? (allCompleted ? 'ready_to_finalize' : 'executing') : 'replanning',
399
- steps,
400
- updatedAt: at,
397
+ return { ...current, status: delivered ? 'verifying' : 'replanning', steps, updatedAt: at };
398
+ });
399
+ }
400
+
401
+ export function acceptPlanStepEvidence(
402
+ options: PlanContractStoreOptions,
403
+ input: { planId: string; stepId: string; reviewer: string; rationale: string },
404
+ ): PlanContract {
405
+ const reviewer = input.reviewer.trim();
406
+ const rationale = input.rationale.trim();
407
+ if (!reviewer || !rationale) throw new Error('PLAN_STEP_SEMANTIC_ACCEPTANCE_METADATA_REQUIRED');
408
+ return updatePlanContract(options, input.planId, (current) => {
409
+ const stepIndex = current.steps.findIndex((step) => step.id === sanitizeFileComponent(input.stepId));
410
+ if (stepIndex < 0) throw new Error(`PLAN_STEP_NOT_FOUND: ${input.stepId}`);
411
+ const step = current.steps[stepIndex];
412
+ if (step.status === 'completed') return current;
413
+ if (step.status !== 'validating') throw new Error(`PLAN_STEP_NOT_READY_FOR_SEMANTIC_ACCEPTANCE: ${step.id} is ${step.status}`);
414
+ const steps = [...current.steps];
415
+ steps[stepIndex] = {
416
+ ...step,
417
+ status: 'completed',
418
+ evidenceRefs: [{ title: 'semantic acceptance', summary: `${reviewer}: ${rationale}`, detailLevel: 'summary' as const }, ...step.evidenceRefs].slice(0, 20),
401
419
  };
420
+ const allCompleted = steps.every((candidate) => candidate.status === 'completed');
421
+ return { ...current, status: allCompleted ? 'finalized' : 'executing', steps, updatedAt: nowIso(options) };
402
422
  });
403
423
  }
@@ -28,7 +28,9 @@ export interface RequirementBoardItem {
28
28
  maintenanceSummary?: string;
29
29
  blocker?: string;
30
30
  requiredUserDecision?: string;
31
+ /** Compatibility projection for older UI clients; derived from activePlanIds. */
31
32
  activePlanId?: string;
33
+ activePlanIds: string[];
32
34
  latestDelivery?: {
33
35
  workId: string;
34
36
  receiptId: string;
@@ -55,10 +57,14 @@ function explicitUserDecision(requirement: Requirement): string | undefined {
55
57
  return decision || undefined;
56
58
  }
57
59
 
58
- function projectedRequirementState(requirement: Requirement): RequirementState {
60
+ function activePlansForRequirement(requirementId: string, plans: readonly PlanContract[]): PlanContract[] {
61
+ return plans.filter((plan) => plan.requirementId === requirementId);
62
+ }
63
+
64
+ function projectedRequirementState(requirement: Requirement, activePlans: readonly PlanContract[]): RequirementState {
59
65
  if (requirement.state !== 'waiting_for_user') return requirement.state;
60
66
  if (explicitUserDecision(requirement)) return 'waiting_for_user';
61
- return requirement.activePlanId ? 'active' : 'planned';
67
+ return activePlans.length > 0 ? 'active' : 'planned';
62
68
  }
63
69
 
64
70
  function loadScopedWork(options: RequirementBoardOptions): WorkContract[] {
@@ -92,8 +98,10 @@ function latestDelivery(requirementId: string, work: readonly WorkContract[]): R
92
98
  };
93
99
  }
94
100
 
95
- function projectRequirement(requirement: Requirement, work: readonly WorkContract[]): RequirementBoardItem {
96
- const state = projectedRequirementState(requirement);
101
+ function projectRequirement(requirement: Requirement, work: readonly WorkContract[], plans: readonly PlanContract[] = []): RequirementBoardItem {
102
+ const activePlans = activePlansForRequirement(requirement.requirementId, plans);
103
+ const activePlanIds = activePlans.map((plan) => plan.planId);
104
+ const state = projectedRequirementState(requirement, activePlans);
97
105
  const requiredUserDecision = explicitUserDecision(requirement);
98
106
  const maintenanceSummary = requirement.needsAttention
99
107
  ? boundedText(requirement.attentionSummary, 1_000) || 'This Requirement has a maintenance finding.'
@@ -108,7 +116,8 @@ function projectRequirement(requirement: Requirement, work: readonly WorkContrac
108
116
  maintenanceSummary,
109
117
  blocker: requirement.needsAttention && state !== 'waiting_for_user' ? maintenanceSummary : undefined,
110
118
  requiredUserDecision,
111
- activePlanId: requirement.activePlanId,
119
+ activePlanId: activePlanIds[0],
120
+ activePlanIds,
112
121
  latestDelivery: latestDelivery(requirement.requirementId, work),
113
122
  updatedAt: requirement.updatedAt,
114
123
  detailPointer: {
@@ -132,7 +141,10 @@ function requirementCounts(requirements: readonly RequirementBoardItem[]): Recor
132
141
 
133
142
  export function buildRequirementBoard(options: RequirementBoardOptions): Record<string, unknown> {
134
143
  const work = loadScopedWork(options);
135
- const allRequirements = loadRequirements(options).map((requirement) => projectRequirement(requirement, work));
144
+ const activePlans = options.repoId?.trim()
145
+ ? listPlanContracts({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
146
+ : [];
147
+ const allRequirements = loadRequirements(options).map((requirement) => projectRequirement(requirement, work, activePlans));
136
148
  const requirements = allRequirements.slice(0, DEFAULT_REQUIREMENT_LIMIT);
137
149
  const counts = requirementCounts(allRequirements);
138
150
  return {
@@ -229,9 +241,13 @@ export function buildExecutionDiagnostics(
229
241
  : [];
230
242
  const scopedPlans = options.requirementId
231
243
  ? allPlans.filter((plan) => plan.requirementId === options.requirementId
232
- || selectedRequirements.some((requirement) => requirement.activePlanId === plan.planId))
244
+ // Legacy migration compatibility only: current Plans own the forward
245
+ // requirementId relation, so the persisted Requirement pointer is not an
246
+ // execution/lifecycle authority.
247
+ || (!plan.requirementId && selectedRequirements.some((requirement) => requirement.activePlanId === plan.planId)))
233
248
  : allPlans;
234
- const requirements = selectedRequirements.map((requirement) => projectRequirement(requirement, allWork));
249
+ const activePlans = allPlans.filter((plan) => !['finalized', 'superseded', 'cancelled', 'invalidated_by_drift'].includes(plan.status));
250
+ const requirements = selectedRequirements.map((requirement) => projectRequirement(requirement, allWork, activePlans));
235
251
  const works = scopedWork.slice(0, limit).map(summarizeDiagnosticWork);
236
252
  const plans = scopedPlans.slice(0, limit).map((plan) => summarizeDiagnosticPlan(plan, options.detailLevel));
237
253
  const maintenanceFindings = requirements
@@ -156,18 +156,35 @@ export interface LocalEffectCompletionReceipt {
156
156
  recordedAt: string;
157
157
  }
158
158
 
159
+ /** Durable completion authority for one typed remote plugin effect. */
160
+ export interface RemoteEffectCompletionReceipt {
161
+ schemaVersion: 1;
162
+ /** Reuse the authoritative PLG-* receipt id rather than manufacturing parallel evidence. */
163
+ receiptId: string;
164
+ source: 'remote_effect';
165
+ workId: string;
166
+ pluginId: string;
167
+ actionId: string;
168
+ requestId: string;
169
+ semanticKey: string;
170
+ resultDigest: string;
171
+ recordedAt: string;
172
+ }
173
+
159
174
  /**
160
175
  * Completion authority for a standalone Direct Edit that is not bound to the
161
- * retired Issue/Task completion model. The receipt proves that one finalized
162
- * edit session is present at an exact reachable Git revision with clean owned
163
- * paths, while keeping the WorkContract as the canonical lifecycle authority.
176
+ * retired Issue/Task completion model. Normal completion proves one finalized
177
+ * edit session at an exact reachable Git revision. Historical completion may
178
+ * instead reference one explicit reviewed reconciliation after exact revision,
179
+ * path-scope, verification, reachability, and cleanup proof checks.
164
180
  */
165
181
  export interface DirectEditWorkCompletionReceipt {
166
182
  schemaVersion: 1;
167
183
  receiptId: string;
168
184
  source: 'direct_edit_work';
169
185
  workId: string;
170
- editSessionId: string;
186
+ editSessionId?: string;
187
+ reconciliationId?: string;
171
188
  targetBranch: string;
172
189
  targetRevision: string;
173
190
  sourceRevision?: string;
@@ -179,12 +196,18 @@ export interface DirectEditWorkCompletionReceipt {
179
196
  recordedAt: string;
180
197
  }
181
198
 
182
- export type WorkCompletionReceipt = RepositoryCompletionReceipt | DirectEditWorkCompletionReceipt | LocalEffectCompletionReceipt;
199
+ export type WorkCompletionReceipt = RepositoryCompletionReceipt | DirectEditWorkCompletionReceipt | LocalEffectCompletionReceipt | RemoteEffectCompletionReceipt;
183
200
 
184
201
  export function isRepositoryCompletionReceipt(
185
202
  receipt: WorkCompletionReceipt,
186
203
  ): receipt is RepositoryCompletionReceipt {
187
- return receipt.source !== 'local_effect' && receipt.source !== 'direct_edit_work';
204
+ return receipt.source !== 'local_effect' && receipt.source !== 'remote_effect' && receipt.source !== 'direct_edit_work';
205
+ }
206
+
207
+ export function isRemoteEffectCompletionReceipt(
208
+ receipt: WorkCompletionReceipt,
209
+ ): receipt is RemoteEffectCompletionReceipt {
210
+ return receipt.source === 'remote_effect';
188
211
  }
189
212
 
190
213
  export function isDirectEditWorkCompletionReceipt(
@@ -431,6 +454,10 @@ export interface WorkContract {
431
454
  phaseEvidence: WorkPhaseEvidenceMap;
432
455
  /** Explicit v2 execution semantics. `status` is retained for compatibility. */
433
456
  workKind: WorkKind;
457
+ /** Primary is an objective-level business execution lane; execution_child is a resumable low-level operation handle owned by a primary Work or standalone caller. */
458
+ lifecycleRole?: 'primary' | 'execution_child';
459
+ /** Optional objective-level parent when this Work is only an execution child. */
460
+ parentWorkId?: string;
434
461
  dispatchState: DispatchState;
435
462
  evidenceState: EvidenceState;
436
463
  completionOutcome?: CompletionOutcome;
@@ -32,6 +32,7 @@ import {
32
32
  type WorkPhaseEvidenceState,
33
33
  type WorkContractStore,
34
34
  isDirectEditWorkCompletionReceipt,
35
+ isRemoteEffectCompletionReceipt,
35
36
  isRepositoryCompletionReceipt,
36
37
  isTerminalWorkContractStatus,
37
38
  } from './types';
@@ -195,7 +196,7 @@ function legacyPhaseEvidence(
195
196
  state,
196
197
  source: completedByReceipt ? 'recorded' : source,
197
198
  summary: completedByReceipt
198
- ? `Phase ${phase} accepted by Work completion receipt ${contract.completionReceipt!.receiptId}.`
199
+ ? `Phase ${phase} satisfied by Work completion receipt ${contract.completionReceipt!.receiptId}.`
199
200
  : index < currentIndex
200
201
  ? `Legacy Work advanced beyond ${phase}.`
201
202
  : `Legacy Work phase ${phase} is ${state}.`,
@@ -285,13 +286,20 @@ function validateWorkSemantics(contract: WorkContract): WorkContract {
285
286
  if (receipt.delivery.status !== 'integrated' || !receipt.delivery.reachable) throw new Error('WORK_COMPLETION_RECEIPT_DELIVERY_NOT_PROVEN');
286
287
  if (!['complete', 'maintenance_warning'].includes(receipt.cleanup.status) || receipt.cleanup.blockers.length > 0) throw new Error('WORK_COMPLETION_RECEIPT_CLEANUP_NOT_PROVEN');
287
288
  } else if (isDirectEditWorkCompletionReceipt(receipt)) {
288
- if (!receipt.editSessionId.trim()) throw new Error('WORK_COMPLETION_RECEIPT_DIRECT_EDIT_SESSION_REQUIRED');
289
+ const directEditAuthorityCount = Number(Boolean(receipt.editSessionId?.trim())) + Number(Boolean(receipt.reconciliationId?.trim()));
290
+ if (directEditAuthorityCount !== 1) throw new Error('WORK_COMPLETION_RECEIPT_DIRECT_EDIT_AUTHORITY_REQUIRED');
289
291
  if (!receipt.targetBranch.trim() || !receipt.targetRevision.trim()) throw new Error('WORK_COMPLETION_RECEIPT_TARGET_REQUIRED');
290
292
  if (receipt.delivery.status !== 'integrated' || !receipt.delivery.reachable) throw new Error('WORK_COMPLETION_RECEIPT_DELIVERY_NOT_PROVEN');
291
293
  if (!['complete', 'maintenance_warning'].includes(receipt.cleanup.status) || receipt.cleanup.blockers.length > 0) throw new Error('WORK_COMPLETION_RECEIPT_CLEANUP_NOT_PROVEN');
292
294
  if (!['repository_change', 'completed_no_change'].includes(contract.workKind)) throw new Error('WORK_COMPLETION_RECEIPT_DIRECT_EDIT_KIND_REQUIRED');
293
295
  const expectedOutcome = contract.workKind === 'completed_no_change' ? 'completed_no_change' : 'completed_changed';
294
296
  if (contract.completionOutcome !== expectedOutcome) throw new Error('WORK_COMPLETION_RECEIPT_DIRECT_EDIT_OUTCOME_REQUIRED');
297
+ } else if (isRemoteEffectCompletionReceipt(receipt)) {
298
+ if (contract.workKind !== 'remote_effect') throw new Error('WORK_COMPLETION_RECEIPT_REMOTE_EFFECT_KIND_REQUIRED');
299
+ if (contract.completionOutcome !== 'completed_remote') throw new Error('WORK_COMPLETION_RECEIPT_REMOTE_EFFECT_OUTCOME_REQUIRED');
300
+ if (!receipt.pluginId.trim() || !receipt.actionId.trim() || !receipt.requestId.trim() || !receipt.semanticKey.trim() || !receipt.resultDigest.trim()) {
301
+ throw new Error('WORK_COMPLETION_RECEIPT_REMOTE_EFFECT_IDENTITY_REQUIRED');
302
+ }
295
303
  } else {
296
304
  if (contract.workKind !== 'local_effect') throw new Error('WORK_COMPLETION_RECEIPT_LOCAL_EFFECT_KIND_REQUIRED');
297
305
  if (contract.completionOutcome !== 'completed_local') throw new Error('WORK_COMPLETION_RECEIPT_LOCAL_EFFECT_OUTCOME_REQUIRED');
@@ -335,6 +343,9 @@ function validateWorkSemantics(contract: WorkContract): WorkContract {
335
343
  if (outcome === 'completed_remote' && contract.workKind !== 'remote_effect') {
336
344
  throw new Error('WORK_SEMANTICS_INVALID: completed_remote requires remote_effect WorkKind');
337
345
  }
346
+ if (outcome === 'completed_remote' && contract.completionReceipt?.source !== 'remote_effect') {
347
+ throw new Error('WORK_SEMANTICS_INVALID: completed_remote requires a remote_effect completion receipt');
348
+ }
338
349
  if (outcome === 'superseded' && contract.workKind !== 'superseded') {
339
350
  throw new Error('WORK_SEMANTICS_INVALID: superseded requires superseded WorkKind');
340
351
  }
@@ -372,6 +383,12 @@ function validateWorkSemanticTransition(current: WorkContract, next: WorkContrac
372
383
  if (current.completionOutcome && current.completionOutcome !== next.completionOutcome) {
373
384
  throw new Error(`WORK_SEMANTICS_TRANSITION_INVALID: completion outcome ${current.completionOutcome} is immutable`);
374
385
  }
386
+ if ((current.lifecycleRole ?? 'primary') !== (next.lifecycleRole ?? 'primary')) {
387
+ throw new Error('WORK_SEMANTICS_TRANSITION_INVALID: lifecycleRole is immutable');
388
+ }
389
+ if (current.parentWorkId !== next.parentWorkId) {
390
+ throw new Error('WORK_SEMANTICS_TRANSITION_INVALID: parentWorkId is immutable');
391
+ }
375
392
  return next;
376
393
  }
377
394
 
@@ -544,6 +561,8 @@ export function createWorkContract(options: WorkContractStoreOptions, input: Cre
544
561
  constraints: input.constraints ?? { requireHandoffOnAmbiguity: true },
545
562
  risk: input.risk ?? 'medium',
546
563
  workKind: input.workKind ?? 'repository_change',
564
+ lifecycleRole: input.lifecycleRole ?? 'primary',
565
+ parentWorkId: input.parentWorkId?.trim() || undefined,
547
566
  dispatchState: input.dispatchState ?? inferredDispatchState(input.status ?? 'open'),
548
567
  evidenceState: input.evidenceState ?? inferredEvidenceState(input.status ?? 'open'),
549
568
  completionOutcome: input.completionOutcome,
@@ -631,6 +650,7 @@ function workRequestIndexPath(controllerHome: string, requestId: string): string
631
650
  export interface AcceptSubmittedWorkInput {
632
651
  requestId: string;
633
652
  repoId: string;
653
+ parentWorkId?: string;
634
654
  semanticKey: string;
635
655
  operation: SubmittedWorkOperation;
636
656
  objective?: string;
@@ -690,6 +710,13 @@ export function acceptSubmittedWorkContract(
690
710
  if (!input.operation?.name?.trim()) {
691
711
  throw new Error('INVALID_ARGUMENT: work_submit is missing required argument(s): operation');
692
712
  }
713
+ const parentWorkId = input.parentWorkId?.trim() || undefined;
714
+ if (parentWorkId) {
715
+ const parent = getWorkContract({ controllerHome: home, repoId: input.repoId, now: options.now }, parentWorkId);
716
+ if (!parent) throw new Error(`PARENT_WORK_NOT_FOUND: ${parentWorkId}`);
717
+ if (isTerminalWorkContractStatus(parent.status)) throw new Error(`PARENT_WORK_TERMINAL: ${parentWorkId}:${parent.status}`);
718
+ if ((parent.lifecycleRole ?? 'primary') !== 'primary') throw new Error(`PARENT_WORK_NOT_PRIMARY: ${parentWorkId}`);
719
+ }
693
720
 
694
721
  const requestLockId = createHash('sha256').update(normalizedRequestId).digest('hex').slice(0, 24);
695
722
  return withControllerLock(home, { scope: 'global', resource: `work-request-${requestLockId}` }, `accept-work:${normalizedRequestId}`, () => {
@@ -715,6 +742,8 @@ export function acceptSubmittedWorkContract(
715
742
  principalId: input.principalId,
716
743
  controllerInstanceId: input.controllerInstanceId,
717
744
  mode: input.mode ?? 'direct_control',
745
+ lifecycleRole: 'execution_child',
746
+ parentWorkId,
718
747
  objective: (input.objective ?? `Accepted operation ${input.operation.name}`).slice(0, 2_000),
719
748
  acceptanceCriteria: input.acceptanceCriteria ?? [],
720
749
  workKind: input.workKind,