@moretea-labs/forge 1.5.1 → 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 (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  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/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -32,6 +32,10 @@ function gitText(root: string, args: string[]): string | undefined {
32
32
  : undefined;
33
33
  }
34
34
 
35
+ function gitSucceeds(root: string, args: string[]): boolean {
36
+ return spawnSync('git', ['-C', root, ...args], { stdio: 'ignore', timeout: 10_000 }).status === 0;
37
+ }
38
+
35
39
  function fail(code: string, message: string): never {
36
40
  throw new Error(`${code}: ${message}`);
37
41
  }
@@ -44,7 +48,7 @@ export function assertWorkHandleLifecycle(handle: WorkHandleState, operation: 'i
44
48
  const allowed: Record<typeof operation, readonly WorkHandleState['state'][]> = {
45
49
  inspect: ['prepared', 'editing', 'validating', 'committed', 'merged', 'failed', 'failed_terminal_cleanup', 'cleaned'],
46
50
  execute: ['prepared', 'editing'],
47
- validate: ['prepared', 'editing', 'validating', 'committed', 'merged'],
51
+ validate: ['prepared', 'editing', 'validating', 'committed', 'merged', 'failed'],
48
52
  finalize: ['prepared', 'editing', 'validating', 'committed', 'merged', 'failed_terminal_cleanup', 'cleaned', 'failed'],
49
53
  };
50
54
  if (!allowed[operation].includes(handle.state)) {
@@ -99,7 +103,16 @@ export function validateWorkHandle(
99
103
  const currentBranch = gitText(root, ['branch', '--show-current']);
100
104
  const currentHead = gitText(root, ['rev-parse', '--verify', 'HEAD']);
101
105
  if (currentBranch !== handle.branch) fail('WORK_HANDLE_BRANCH_CHANGED', `expected ${handle.branch}, found ${currentBranch ?? 'detached'}`);
102
- if (handle.expectedHead && currentHead !== handle.expectedHead) fail('WORK_HANDLE_HEAD_CHANGED', `expected ${handle.expectedHead}, found ${currentHead ?? 'missing'}`);
106
+ const managedWorkProgress = (operation === 'validate' || operation === 'finalize')
107
+ && handle.managedWorktree
108
+ && Boolean(handle.expectedHead && currentHead)
109
+ && gitSucceeds(root, ['merge-base', '--is-ancestor', handle.expectedHead!, currentHead!]);
110
+ if (handle.expectedHead && currentHead !== handle.expectedHead && !managedWorkProgress) {
111
+ fail('WORK_HANDLE_HEAD_CHANGED', `expected ${handle.expectedHead}, found ${currentHead ?? 'missing'}`);
112
+ }
113
+ if (managedWorkProgress && currentHead !== handle.expectedHead) {
114
+ warnings.push('Managed Worktree HEAD advanced from the prepared revision by Work-owned descendant commits; validation/finalization will use the current Work state.');
115
+ }
103
116
 
104
117
  if (level === 'full') {
105
118
  const validation = validateRepository(repository.repoId, controllerHome);
@@ -544,6 +544,38 @@ export async function cleanupTerminalWork(input: TerminalWorkCleanupInput): Prom
544
544
  current = persist(input.controllerHome, current, receipt);
545
545
  if (receipt.blockers.length > 0) return { handle: current, receipt };
546
546
 
547
+ // A Work running in the selected/current checkout owns lifecycle and process
548
+ // leases, but it does not own the checkout/branch as disposable resources.
549
+ // Closing that Work therefore releases ownership without attempting git
550
+ // worktree removal, branch deletion, checkout-registry removal, or prune.
551
+ if (!current.managedWorktree) {
552
+ receipt.worktree.status = 'already_removed';
553
+ receipt.worktree.reason = 'Not applicable: Work did not create a managed worktree.';
554
+ receipt.branchCleanup.status = 'retained';
555
+ receipt.branchCleanup.reason = 'Current checkout branch is repository-owned, not Work-owned.';
556
+ receipt.checkoutRegistry.status = 'already_removed';
557
+ receipt.checkoutRegistry.reason = 'Not applicable: current checkout registration is retained.';
558
+ receipt.prune.status = 'done';
559
+ receipt.prune.reason = 'Not applicable: no managed worktree was removed.';
560
+ receipt.complete = true;
561
+ receipt.partial = false;
562
+ receipt.completedAt = receipt.completedAt ?? nowIso();
563
+ const finalization = {
564
+ ...current.finalization,
565
+ commit: current.finalization.commit === 'pending' ? 'skipped' as const : current.finalization.commit,
566
+ merge: current.finalization.merge === 'pending' ? 'skipped' as const : current.finalization.merge,
567
+ branchCleanup: 'skipped' as const,
568
+ worktreeCleanup: 'skipped' as const,
569
+ lastError: preservedFailure,
570
+ };
571
+ current = transitionWorkHandle(input.controllerHome, current, 'cleaned', {
572
+ failureReason: landed ? undefined : preservedFailure,
573
+ cleanupReceipt: receipt,
574
+ finalization,
575
+ });
576
+ return { handle: current, receipt };
577
+ }
578
+
547
579
  const target = selectTerminalCleanupTarget(repository, current);
548
580
  let registeredPath: string | undefined;
549
581
  try {
@@ -98,10 +98,10 @@ export function projectWorkValidationOutcome(
98
98
  }
99
99
 
100
100
  transitionWorkContractPhase(options, contractId, {
101
- phase: 'cleanup',
102
- status: 'failed',
103
- state: 'failed',
104
- summary: summary ?? 'Validation infrastructure failed; terminal cleanup is required without treating this as an acceptance failure.',
101
+ phase: 'verification',
102
+ status: 'running',
103
+ state: 'blocked',
104
+ summary: summary ?? 'Validation infrastructure failed; retain the finite Work for retry without treating this as an acceptance failure.',
105
105
  evidenceRefs: contract.evidenceRefs,
106
106
  });
107
107
  updateWorkContract(options, contractId, {
@@ -127,8 +127,8 @@ function settleInfrastructureFailure(
127
127
  * Converge a persisted Work validation run from durable Process receipts.
128
128
  *
129
129
  * No command is launched here. Missing/running bindings stay pending; timeout,
130
- * cancellation, missing process state, or malformed receipt makes the Work
131
- * terminally failed for cleanup without classifying it as an acceptance failure.
130
+ * cancellation, missing process state, or malformed receipt makes the execution
131
+ * attempt retryable without classifying it as an acceptance failure or destroying Work.
132
132
  */
133
133
  export function reconcileWorkValidation(
134
134
  controllerHome: string,
@@ -173,7 +173,6 @@ export function reconcileWorkValidation(
173
173
  repoId: handle.repositoryId,
174
174
  checkoutId: handle.checkoutId,
175
175
  workId: handle.workId,
176
- executionSessionId: handle.sessionId,
177
176
  checkId,
178
177
  processId: binding.processId,
179
178
  ...(currentIdentity ? {
@@ -18,7 +18,7 @@ import { globMatches, normalizeMcpRelativePath } from '../../../cli/mcp/paths';
18
18
  import { sanitizeFileComponent } from '../../shared/json-files';
19
19
 
20
20
  const STALE_SNAPSHOT_MS = 6 * 60 * 60_000;
21
- const SNAPSHOT_MARKER = '.forge-work-verification-snapshot.json';
21
+ const SNAPSHOT_MARKER = '.ai/harness/controller/work-verification-snapshot.json';
22
22
 
23
23
  export interface WorkVerificationSnapshotScope {
24
24
  workId: string;
@@ -156,6 +156,7 @@ export function materializeWorkVerificationSnapshot(input: {
156
156
  allowedPaths: [...input.scope.allowedPaths].sort(),
157
157
  forbiddenPaths: [...input.scope.forbiddenPaths].sort(),
158
158
  })).digest('hex');
159
+ mkdirSync(dirname(join(root, SNAPSHOT_MARKER)), { recursive: true });
159
160
  writeFileSync(join(root, SNAPSHOT_MARKER), `${JSON.stringify({ schemaVersion: 1, ...input.scope, sourceHead, includedPaths, excludedPaths, ownershipDigest }, null, 2)}\n`);
160
161
  return { root, sourceHead, includedPaths, excludedPaths, ownershipDigest };
161
162
  } catch (error) {
@@ -150,7 +150,7 @@ const CORE_CAPABILITIES: CapabilityDescriptor[] = [
150
150
  risk: 'unknown',
151
151
  exposedVia: 'rh_work',
152
152
  schemaExposure: 'stable_static',
153
- summary: 'Use typed browser target, snapshot, domain grant, and plugin actions when browser capability is configured.',
153
+ summary: 'Use typed HTTP(S) browser navigation, snapshot, and interaction actions when browser capability is configured.',
154
154
  },
155
155
  {
156
156
  capabilityId: 'platform.ios',
@@ -68,8 +68,10 @@ function claimedSession(
68
68
  const leaseExpiresAt = new Date(
69
69
  Date.parse(claimedAt) + Math.max(1_000, Math.min(input.leaseMs ?? 300_000, 3_600_000)),
70
70
  ).toISOString();
71
+ const previousPrincipal = previous?.principalId?.trim() || previous?.controllerId;
72
+ const inputPrincipal = input.principalId?.trim() || input.controllerId;
71
73
  const sameOwner = previous?.controllerId === input.controllerId
72
- && previous.sessionId === input.sessionId
74
+ && previousPrincipal === inputPrincipal
73
75
  && (previous.controllerInstanceId ?? '') === (input.controllerInstanceId?.trim() ?? '');
74
76
  const claimGeneration = sameOwner
75
77
  ? Math.max(1, previous?.claimGeneration ?? 1)
@@ -116,6 +118,11 @@ export function getControllerSession(options: ControllerSessionStoreOptions, wor
116
118
  return activeSession(read(options), workId);
117
119
  }
118
120
 
121
+ export function listControllerSessions(options: ControllerSessionStoreOptions): ControllerSession[] {
122
+ const at = Date.now();
123
+ return read(options).sessions.filter((session) => Date.parse(session.leaseExpiresAt) > at);
124
+ }
125
+
119
126
  /**
120
127
  * Strict claim used for first ownership acquisition and explicit release/reclaim flows.
121
128
  * A live claim with any different controller or transport session remains fenced.
@@ -142,10 +149,10 @@ export function claimControllerSession(
142
149
  }
143
150
 
144
151
  /**
145
- * Resume a claim after transport replacement without allowing same-epoch stealing.
146
- * The authenticated principal must remain identical, and a different session id
147
- * is accepted only when the previous execution session is gone/invalidated or
148
- * belongs to a different Controller instance (the normal rollout boundary).
152
+ * Resume a claim after transport replacement. Durable Work ownership belongs to
153
+ * the authenticated principal/controller epoch, not to one MCP transport session.
154
+ * A same-principal session rotation therefore preserves ownership generation;
155
+ * a controller-epoch change is recovery and advances the generation fence.
149
156
  */
150
157
  export function resumeControllerSession(
151
158
  options: ControllerSessionStoreOptions,
@@ -172,14 +179,6 @@ export function resumeControllerSession(
172
179
  if (current.controllerId !== input.controllerId) {
173
180
  throw new Error(`WORK_ALREADY_CLAIMED: ${input.workId} is owned by ${current.controllerId}`);
174
181
  }
175
- if (current.sessionId !== input.sessionId) {
176
- const priorInstance = current.controllerInstanceId?.trim() || priorExecution?.controllerInstanceId?.trim();
177
- const priorSessionUnavailable = !priorExecution || Boolean(priorExecution.invalidatedAt);
178
- const crossedControllerEpoch = Boolean(priorInstance && priorInstance !== input.controllerInstanceId.trim());
179
- if (!priorSessionUnavailable && !crossedControllerEpoch) {
180
- throw new Error(`WORK_ALREADY_CLAIMED: ${input.workId} has an active session ${current.sessionId}`);
181
- }
182
- }
183
182
  return persistClaim(options, store, input, current);
184
183
  },
185
184
  );
@@ -161,9 +161,14 @@ export function runGoalWorkloop(
161
161
  secretAccess: args.secret_access === true,
162
162
  risk: typeof args.risk === 'string' ? args.risk as CapabilityRisk : undefined,
163
163
  },
164
- requestedBy: args.requested_by === 'user' || args.requested_by === 'system' || args.requested_by === 'scheduler'
164
+ requestedBy: args.requested_by === 'chatgpt' || args.requested_by === 'user' || args.requested_by === 'system' || args.requested_by === 'scheduler'
165
165
  ? args.requested_by
166
- : 'chatgpt',
166
+ : 'user',
167
+ relatedWorkId: typeof args.related_work_id === 'string' ? args.related_work_id : undefined,
168
+ workRelation: args.work_relation === 'continue' || args.work_relation === 'extend' || args.work_relation === 'parallel' || args.work_relation === 'new_goal'
169
+ ? args.work_relation
170
+ : undefined,
171
+ requirementId: typeof args.requirement_id === 'string' ? args.requirement_id : undefined,
167
172
  taskId: typeof args.task_id === 'string' ? args.task_id : undefined,
168
173
  issueId: typeof args.issue_id === 'string' ? args.issue_id : undefined,
169
174
  approvalConfirmed: args.approval_confirmed === true,
@@ -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
  }