@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.
- package/CHANGELOG.md +28 -1
- package/DESIGN.md +7 -2
- package/README.md +7 -7
- package/README.zh-CN.md +5 -3
- 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/forge-chatgpt-mcp-setup.md +27 -3
- 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/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +6 -4
- package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- 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/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- 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-assets.ts +17 -0
- 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 +31 -20
- 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/auth.ts +2 -0
- 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/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +52 -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/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +161 -18
- 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
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { getEditSession, listEditSessions, type EditSession } from '../../../cli/editing/edit-session';
|
|
3
|
+
import { globMatches } from '../../../cli/mcp/paths';
|
|
3
4
|
import { runProcess } from '../../../effects/process-runner';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { completeRequirementFromWork } from '../persistence/requirement-store';
|
|
6
|
+
import { getWorkContract, recordWorkCompletionReceipt, updateWorkContract } from '../facade/work-contract-store';
|
|
7
|
+
import { isDirectEditWorkCompletionReceipt, isTerminalWorkContractStatus, type DirectEditWorkCompletionReceipt, type WorkReconciliationRecord } from '../facade/types';
|
|
8
|
+
import { effectiveVerificationEvidence } from './verification-evidence';
|
|
6
9
|
|
|
7
10
|
export interface DirectEditWorkCompletionReconciliation {
|
|
8
11
|
completedWorkIds: string[];
|
|
@@ -149,3 +152,155 @@ export function reconcileFinalizedDirectEditWorksAfterCommit(input: {
|
|
|
149
152
|
|
|
150
153
|
return { completedWorkIds, examinedSessionIds, skipped, targetBranch, targetRevision };
|
|
151
154
|
}
|
|
155
|
+
|
|
156
|
+
export interface ReviewedDirectEditWorkReconciliationInput {
|
|
157
|
+
controllerHome: string;
|
|
158
|
+
repoId: string;
|
|
159
|
+
checkoutId: string;
|
|
160
|
+
repoRoot: string;
|
|
161
|
+
workId: string;
|
|
162
|
+
targetBranch: string;
|
|
163
|
+
targetRevision: string;
|
|
164
|
+
comparedPaths: string[];
|
|
165
|
+
reviewer: string;
|
|
166
|
+
rationale: string;
|
|
167
|
+
cleanupOwnershipProof: string;
|
|
168
|
+
reviewedAt?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function exactCommit(repoRoot: string, revision: string, label: string): string {
|
|
172
|
+
const result = git(repoRoot, ['rev-parse', '--verify', `${revision}^{commit}`]);
|
|
173
|
+
if (!result.ok || !result.stdout.trim()) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_${label}_INVALID`);
|
|
174
|
+
return result.stdout.trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function comparedRevisionPaths(repoRoot: string, baseRevision: string, targetRevision: string): string[] {
|
|
178
|
+
const result = git(repoRoot, ['diff', '--name-only', '-z', baseRevision, targetRevision]);
|
|
179
|
+
if (!result.ok) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_CHANGED_PATHS_UNAVAILABLE');
|
|
180
|
+
return [...new Set(result.stdout.split('\0').filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizedComparedPaths(paths: string[]): string[] {
|
|
184
|
+
const normalized = [...new Set(paths.map((path) => path.trim()))].sort((left, right) => left.localeCompare(right));
|
|
185
|
+
if (normalized.length === 0 || normalized.length > 100 || normalized.some((path) => !path || path.startsWith('/') || path.includes('\0') || path.split('/').some((part) => part === '' || part === '.' || part === '..'))) {
|
|
186
|
+
throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_PATHS_INVALID');
|
|
187
|
+
}
|
|
188
|
+
return normalized;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Explicitly close a historically delivered Direct Edit Work whose original
|
|
193
|
+
* edit-session binding was missed. This is never inferred: the caller must
|
|
194
|
+
* supply the exact accepted revision and the complete reviewed path set.
|
|
195
|
+
*/
|
|
196
|
+
export function acceptReviewedDirectEditWorkReconciliation(input: ReviewedDirectEditWorkReconciliationInput): {
|
|
197
|
+
workId: string;
|
|
198
|
+
reconciliation: WorkReconciliationRecord;
|
|
199
|
+
receipt: DirectEditWorkCompletionReceipt;
|
|
200
|
+
} {
|
|
201
|
+
const work = getWorkContract({ controllerHome: input.controllerHome, repoId: input.repoId }, input.workId);
|
|
202
|
+
if (!work || work.repoId !== input.repoId) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_WORK_NOT_FOUND: ${input.workId}`);
|
|
203
|
+
if (work.checkoutId && work.checkoutId !== input.checkoutId) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_CHECKOUT_MISMATCH: ${input.workId}`);
|
|
204
|
+
if (work.completionReceipt) {
|
|
205
|
+
const completionReceipt = work.completionReceipt;
|
|
206
|
+
if (isDirectEditWorkCompletionReceipt(completionReceipt) && completionReceipt.reconciliationId) {
|
|
207
|
+
const existing = work.reconciliations.find((entry) => entry.reconciliationId === completionReceipt.reconciliationId);
|
|
208
|
+
if (existing) return { workId: work.workId, reconciliation: existing, receipt: completionReceipt };
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_WORK_TERMINAL: ${input.workId}`);
|
|
211
|
+
}
|
|
212
|
+
if (isTerminalWorkContractStatus(work.status) || work.workKind !== 'repository_change') {
|
|
213
|
+
throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_WORK_NOT_ELIGIBLE: ${input.workId}`);
|
|
214
|
+
}
|
|
215
|
+
if (!work.baseRevision?.trim()) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_BASE_REVISION_MISSING');
|
|
216
|
+
|
|
217
|
+
const baseRevision = exactCommit(input.repoRoot, work.baseRevision, 'BASE_REVISION');
|
|
218
|
+
const targetRevision = exactCommit(input.repoRoot, input.targetRevision.trim(), 'TARGET_REVISION');
|
|
219
|
+
const targetBranch = input.targetBranch.trim();
|
|
220
|
+
if (!targetBranch) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_TARGET_BRANCH_REQUIRED');
|
|
221
|
+
if (!git(input.repoRoot, ['merge-base', '--is-ancestor', baseRevision, targetRevision]).ok) {
|
|
222
|
+
throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_TARGET_NOT_DESCENDANT');
|
|
223
|
+
}
|
|
224
|
+
if (!git(input.repoRoot, ['merge-base', '--is-ancestor', targetRevision, targetBranch]).ok) {
|
|
225
|
+
throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_TARGET_UNREACHABLE');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const comparedPaths = normalizedComparedPaths(input.comparedPaths);
|
|
229
|
+
const actualPaths = comparedRevisionPaths(input.repoRoot, baseRevision, targetRevision);
|
|
230
|
+
if (comparedPaths.length !== actualPaths.length || comparedPaths.some((path, index) => path !== actualPaths[index])) {
|
|
231
|
+
throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_PATH_COMPARISON_MISMATCH');
|
|
232
|
+
}
|
|
233
|
+
for (const path of comparedPaths) {
|
|
234
|
+
if (work.forbiddenPaths.some((pattern) => globMatches(pattern, path))) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_FORBIDDEN_PATH: ${path}`);
|
|
235
|
+
if (work.allowedPaths.length > 0 && !work.allowedPaths.some((pattern) => globMatches(pattern, path))) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_PATH_OUT_OF_SCOPE: ${path}`);
|
|
236
|
+
}
|
|
237
|
+
const dirty = git(input.repoRoot, ['status', '--porcelain=v1', '--', ...comparedPaths]);
|
|
238
|
+
if (!dirty.ok || dirty.stdout.trim()) throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_OWNED_PATHS_DIRTY');
|
|
239
|
+
|
|
240
|
+
let verifiedAt = new Date().toISOString();
|
|
241
|
+
for (const checkId of work.checks) {
|
|
242
|
+
const current = effectiveVerificationEvidence(work.checkRefs, {
|
|
243
|
+
sourceRevision: targetRevision,
|
|
244
|
+
checkId,
|
|
245
|
+
requestedChecks: work.checks,
|
|
246
|
+
}).find((entry) => entry.current && entry.record.outcome === 'valid_pass');
|
|
247
|
+
if (!current) throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_CHECK_EVIDENCE_STALE: ${checkId}`);
|
|
248
|
+
verifiedAt = current.record.completedAt ?? current.record.recordedAt ?? verifiedAt;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const reviewer = input.reviewer.trim();
|
|
252
|
+
const rationale = input.rationale.trim();
|
|
253
|
+
const cleanupOwnershipProof = input.cleanupOwnershipProof.trim();
|
|
254
|
+
const reviewedAt = input.reviewedAt ?? new Date().toISOString();
|
|
255
|
+
if (!reviewer || !rationale || !cleanupOwnershipProof || Number.isNaN(Date.parse(reviewedAt))) {
|
|
256
|
+
throw new Error('DIRECT_EDIT_WORK_RECONCILIATION_REVIEW_METADATA_INVALID');
|
|
257
|
+
}
|
|
258
|
+
const reconciliationId = `RECNC-${createHash('sha256').update([input.repoId, input.workId, baseRevision, targetRevision, comparedPaths.join('\0'), reviewer, reviewedAt].join('\0')).digest('hex').slice(0, 16)}`;
|
|
259
|
+
const reconciliation: WorkReconciliationRecord = {
|
|
260
|
+
schemaVersion: 1,
|
|
261
|
+
reconciliationId,
|
|
262
|
+
originalExpectedRevision: targetRevision,
|
|
263
|
+
observedTargetRevision: targetRevision,
|
|
264
|
+
baseRevision,
|
|
265
|
+
targetBranch,
|
|
266
|
+
reachable: true,
|
|
267
|
+
method: 'owned_path_tree',
|
|
268
|
+
comparedPaths,
|
|
269
|
+
reviewer: reviewer.slice(0, 200),
|
|
270
|
+
reviewedAt,
|
|
271
|
+
unrecoverableStages: ['receipt'],
|
|
272
|
+
cleanupOwnershipProof: cleanupOwnershipProof.slice(0, 2_000),
|
|
273
|
+
rationale: rationale.slice(0, 2_000),
|
|
274
|
+
outcome: 'accepted_equivalence',
|
|
275
|
+
};
|
|
276
|
+
updateWorkContract({ controllerHome: input.controllerHome, repoId: input.repoId }, input.workId, {
|
|
277
|
+
reconciliations: [reconciliation, ...work.reconciliations.filter((entry) => entry.reconciliationId !== reconciliationId)],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const recordedAt = new Date().toISOString();
|
|
281
|
+
const receipt: DirectEditWorkCompletionReceipt = {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
receiptId: `REC-direct-edit-reconciled-${createHash('sha256').update(`${input.repoId}\0${input.workId}\0${reconciliationId}\0${targetRevision}`).digest('hex').slice(0, 20)}`,
|
|
284
|
+
source: 'direct_edit_work',
|
|
285
|
+
workId: input.workId,
|
|
286
|
+
reconciliationId,
|
|
287
|
+
targetBranch,
|
|
288
|
+
targetRevision,
|
|
289
|
+
sourceRevision: targetRevision,
|
|
290
|
+
baseRevision,
|
|
291
|
+
changedPaths: comparedPaths,
|
|
292
|
+
delivery: { kind: 'commit', status: 'integrated', strategy: 'already_integrated', reachable: true, recordedAt },
|
|
293
|
+
cleanup: { status: 'complete', warnings: [], blockers: [], recordedAt },
|
|
294
|
+
verifiedAt,
|
|
295
|
+
recordedAt,
|
|
296
|
+
};
|
|
297
|
+
const recorded = recordWorkCompletionReceipt(
|
|
298
|
+
{ controllerHome: input.controllerHome, repoId: input.repoId },
|
|
299
|
+
input.workId,
|
|
300
|
+
receipt,
|
|
301
|
+
'completed_changed',
|
|
302
|
+
'repository_change',
|
|
303
|
+
);
|
|
304
|
+
if (recorded.requirementId) completeRequirementFromWork({ controllerHome: input.controllerHome }, { requirementId: recorded.requirementId, work: recorded });
|
|
305
|
+
return { workId: input.workId, reconciliation, receipt };
|
|
306
|
+
}
|
|
@@ -134,6 +134,53 @@ function gitPath(root: string, args: string[]): string | undefined {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
interface GitExecutionSnapshot {
|
|
138
|
+
topLevel?: string;
|
|
139
|
+
commonDirectory?: string;
|
|
140
|
+
branch?: string;
|
|
141
|
+
head?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolvedGitPath(root: string, value: string | undefined): string | undefined {
|
|
145
|
+
if (!value) return undefined;
|
|
146
|
+
try {
|
|
147
|
+
return realpathSync(isAbsolute(value) ? value : resolve(root, value));
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read the checkout identity in one Git child process on the normal committed
|
|
155
|
+
* path. This preserves the same fail-closed fields as the former six probes;
|
|
156
|
+
* unborn repositories fall back to the individual observations because HEAD is
|
|
157
|
+
* intentionally absent there.
|
|
158
|
+
*/
|
|
159
|
+
function gitExecutionSnapshot(root: string): GitExecutionSnapshot {
|
|
160
|
+
const combined = gitText(root, ['rev-parse', '--show-toplevel', '--git-common-dir', 'HEAD', '--abbrev-ref', 'HEAD']);
|
|
161
|
+
if (combined) {
|
|
162
|
+
const lines = combined.split('\n').map((line) => line.trim());
|
|
163
|
+
if (lines.length >= 4) {
|
|
164
|
+
return {
|
|
165
|
+
topLevel: resolvedGitPath(root, lines[0]),
|
|
166
|
+
commonDirectory: resolvedGitPath(root, lines[1]),
|
|
167
|
+
head: lines[2] || undefined,
|
|
168
|
+
branch: lines[3] && lines[3] !== 'HEAD' ? lines[3] : undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Rare unborn/failure fallback keeps legacy behavior rather than turning a
|
|
173
|
+
// missing HEAD into a false workspace identity failure.
|
|
174
|
+
const topLevel = gitPath(root, ['rev-parse', '--show-toplevel']);
|
|
175
|
+
const commonDirectory = gitPath(root, ['rev-parse', '--git-common-dir']);
|
|
176
|
+
return {
|
|
177
|
+
topLevel,
|
|
178
|
+
commonDirectory,
|
|
179
|
+
branch: topLevel ? gitText(topLevel, ['branch', '--show-current']) : undefined,
|
|
180
|
+
head: topLevel ? gitText(topLevel, ['rev-parse', '--verify', 'HEAD']) : undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
137
184
|
export function classifyGitCommonDirectoryDrift(input: {
|
|
138
185
|
repoId: string;
|
|
139
186
|
checkoutId: string;
|
|
@@ -304,18 +351,15 @@ export function assertExecutionIdentity(input: {
|
|
|
304
351
|
actual: resolvedCwd,
|
|
305
352
|
});
|
|
306
353
|
}
|
|
307
|
-
const
|
|
308
|
-
const gitCommonDirectory = gitPath(resolvedCwd, ['rev-parse', '--git-common-dir']);
|
|
309
|
-
const currentBranch = gitTopLevel ? gitText(gitTopLevel, ['branch', '--show-current']) : undefined;
|
|
310
|
-
const currentHead = gitTopLevel ? gitText(gitTopLevel, ['rev-parse', '--verify', 'HEAD']) : undefined;
|
|
354
|
+
const gitSnapshot = gitExecutionSnapshot(resolvedCwd);
|
|
311
355
|
return Object.freeze({
|
|
312
356
|
...identity,
|
|
313
357
|
canonicalRoot: workspaceRoot,
|
|
314
358
|
resolvedCwd,
|
|
315
|
-
gitTopLevel,
|
|
316
|
-
gitCommonDirectory,
|
|
317
|
-
currentBranch,
|
|
318
|
-
currentHead,
|
|
359
|
+
gitTopLevel: gitSnapshot.topLevel,
|
|
360
|
+
gitCommonDirectory: gitSnapshot.commonDirectory,
|
|
361
|
+
currentBranch: gitSnapshot.branch,
|
|
362
|
+
currentHead: gitSnapshot.head,
|
|
319
363
|
});
|
|
320
364
|
}
|
|
321
365
|
|
|
@@ -377,7 +421,8 @@ export function assertExecutionIdentity(input: {
|
|
|
377
421
|
});
|
|
378
422
|
}
|
|
379
423
|
|
|
380
|
-
const
|
|
424
|
+
const gitSnapshot = gitExecutionSnapshot(resolvedCwd);
|
|
425
|
+
const gitTopLevel = gitSnapshot.topLevel;
|
|
381
426
|
if (!gitTopLevel || !samePath(gitTopLevel, registeredRoot)) {
|
|
382
427
|
fail('GIT_TOPLEVEL_MISMATCH', 'Git top-level differs from expected checkout root', {
|
|
383
428
|
repoId: identity.repositoryId,
|
|
@@ -387,9 +432,15 @@ export function assertExecutionIdentity(input: {
|
|
|
387
432
|
});
|
|
388
433
|
}
|
|
389
434
|
|
|
390
|
-
const gitCommonDirectory =
|
|
391
|
-
|
|
392
|
-
|
|
435
|
+
const gitCommonDirectory = gitSnapshot.commonDirectory;
|
|
436
|
+
// Once the observed top-level is proven equal to registeredRoot, querying the
|
|
437
|
+
// same checkout again cannot add identity evidence. Only a linked worktree
|
|
438
|
+
// needs one extra canonical-repository common-dir observation.
|
|
439
|
+
const registeredCommonDirectory = gitCommonDirectory;
|
|
440
|
+
const repositoryRoot = realpathOrFail(registered.canonicalRoot, 'WORKTREE_MISSING', 'repository canonical root');
|
|
441
|
+
const repositoryCommonDirectory = samePath(repositoryRoot, registeredRoot)
|
|
442
|
+
? gitCommonDirectory
|
|
443
|
+
: gitPath(repositoryRoot, ['rev-parse', '--git-common-dir']);
|
|
393
444
|
const commonDirectoryDrift = classifyGitCommonDirectoryDrift({
|
|
394
445
|
repoId: identity.repositoryId,
|
|
395
446
|
checkoutId: identity.checkoutId,
|
|
@@ -423,8 +474,8 @@ export function assertExecutionIdentity(input: {
|
|
|
423
474
|
});
|
|
424
475
|
}
|
|
425
476
|
|
|
426
|
-
const currentBranch =
|
|
427
|
-
const currentHead =
|
|
477
|
+
const currentBranch = gitSnapshot.branch;
|
|
478
|
+
const currentHead = gitSnapshot.head;
|
|
428
479
|
if (identity.branch && currentBranch !== identity.branch) {
|
|
429
480
|
fail('WORK_HANDLE_BRANCH_CHANGED', 'branch drifted from execution identity', {
|
|
430
481
|
repoId: identity.repositoryId,
|
|
@@ -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,
|