@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.
- package/CHANGELOG.md +20 -0
- package/DESIGN.md +7 -2
- package/README.md +4 -4
- package/README.zh-CN.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/tutorials/01-install-and-start.md +1 -1
- package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +29 -19
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/transports/http.ts +25 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/release-materialize.ts +143 -19
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- 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
|
-
|
|
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: '
|
|
102
|
-
status: '
|
|
103
|
-
state: '
|
|
104
|
-
summary: summary ?? 'Validation infrastructure failed;
|
|
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
|
|
131
|
-
*
|
|
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 = '.
|
|
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
|
|
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
|
-
&&
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
: '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
497
|
-
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
}
|