@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
@@ -6,10 +6,10 @@
6
6
  * - GC failures must not throw into the controller main loop.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, unlinkSync, statSync } from 'fs';
9
+ import { existsSync, readFileSync, readdirSync, unlinkSync, statSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { ensureRepositoryControllerLayout, repositoryControllerRoot } from '../../../cli/repositories/controller-home';
12
- import { getProcessRecord, listActiveProcessIds } from './store';
12
+ import { listActiveProcessIds } from './store';
13
13
  import type { ProcessRuntimeStatus } from './types';
14
14
  import { assertRuntimeMayWrite } from '../../root/write-fence';
15
15
  import { isProcessAlive } from '../../shared/process-tree';
@@ -39,6 +39,8 @@ export interface ProcessGcResult {
39
39
  removedRecords: number;
40
40
  removedLogs: number;
41
41
  skippedActive: number;
42
+ /** Legacy/corrupt records without enough terminal proof are preserved. */
43
+ skippedInvalid?: number;
42
44
  error?: string;
43
45
  }
44
46
 
@@ -50,6 +52,54 @@ function logDir(controllerHome: string, repoId: string): string {
50
52
  return join(processesDir(controllerHome, repoId), 'logs');
51
53
  }
52
54
 
55
+ interface ProcessGcMetadata {
56
+ status?: ProcessRuntimeStatus;
57
+ finishedAt?: string;
58
+ updatedAt?: string;
59
+ identity?: { pid: number; processStartTime: string };
60
+ identityUntrusted?: boolean;
61
+ reusableCheckEvidence: boolean;
62
+ }
63
+
64
+ /**
65
+ * GC deliberately reads only the metadata needed to prove terminality. Historical
66
+ * Process records can predate today's required command descriptor; feeding those
67
+ * records through the normal recovery reader would either throw during redaction
68
+ * or, worse, require manufacturing a fake executable. A malformed/unknown record
69
+ * is skipped rather than deleted, preserving fail-closed recovery semantics.
70
+ */
71
+ function readProcessGcMetadata(path: string): ProcessGcMetadata | undefined {
72
+ try {
73
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
74
+ if (raw.schemaVersion !== 1) return undefined;
75
+ const status = typeof raw.status === 'string' && TERMINAL.has(raw.status as ProcessRuntimeStatus)
76
+ ? raw.status as ProcessRuntimeStatus
77
+ : typeof raw.status === 'string'
78
+ ? raw.status as ProcessRuntimeStatus
79
+ : undefined;
80
+ const identityRaw = raw.identity && typeof raw.identity === 'object' && !Array.isArray(raw.identity)
81
+ ? raw.identity as Record<string, unknown>
82
+ : undefined;
83
+ const pid = typeof identityRaw?.pid === 'number' && Number.isInteger(identityRaw.pid) && identityRaw.pid > 0
84
+ ? identityRaw.pid
85
+ : undefined;
86
+ const processStartTime = typeof identityRaw?.processStartTime === 'string' ? identityRaw.processStartTime : undefined;
87
+ const checkExecution = raw.checkExecution && typeof raw.checkExecution === 'object' && !Array.isArray(raw.checkExecution)
88
+ ? raw.checkExecution as Record<string, unknown>
89
+ : undefined;
90
+ return {
91
+ status,
92
+ finishedAt: typeof raw.finishedAt === 'string' ? raw.finishedAt : undefined,
93
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined,
94
+ ...(pid && processStartTime ? { identity: { pid, processStartTime } } : {}),
95
+ identityUntrusted: raw.identityUntrusted === true,
96
+ reusableCheckEvidence: status === 'succeeded' && typeof checkExecution?.cacheKey === 'string' && checkExecution.cacheKey.length > 0,
97
+ };
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
53
103
  function safeUnlink(path: string): boolean {
54
104
  try {
55
105
  if (existsSync(path)) {
@@ -89,6 +139,7 @@ export function gcTerminalProcesses(options: ProcessGcOptions): ProcessGcResult
89
139
  const active = new Set(listActiveProcessIds(options.controllerHome, options.repoId));
90
140
  const terminal: Array<{ processId: string; finishedAt: number; path: string; reusableCheckEvidence: boolean }> = [];
91
141
  let skippedActive = 0;
142
+ let skippedInvalid = 0;
92
143
 
93
144
  for (const name of readdirSync(root)) {
94
145
  if (!name.endsWith('.json') || name === 'active-index.json') continue;
@@ -97,12 +148,15 @@ export function gcTerminalProcesses(options: ProcessGcOptions): ProcessGcResult
97
148
  skippedActive += 1;
98
149
  continue;
99
150
  }
100
- const record = getProcessRecord(options.controllerHome, options.repoId, processId);
101
- if (!record) continue;
151
+ const metadata = readProcessGcMetadata(join(root, name));
152
+ if (!metadata?.status) {
153
+ skippedInvalid += 1;
154
+ continue;
155
+ }
102
156
  // Skip still-alive identity matches even if status drifted.
103
- if (record.identity && !record.identityUntrusted && !record.identity.processStartTime.startsWith('untrusted:')) {
157
+ if (metadata.identity && !metadata.identityUntrusted && !metadata.identity.processStartTime.startsWith('untrusted:')) {
104
158
  try {
105
- if (isProcessAlive(record.identity.pid)) {
159
+ if (isProcessAlive(metadata.identity.pid)) {
106
160
  skippedActive += 1;
107
161
  continue;
108
162
  }
@@ -110,15 +164,15 @@ export function gcTerminalProcesses(options: ProcessGcOptions): ProcessGcResult
110
164
  /* ignore probe failures */
111
165
  }
112
166
  }
113
- if (!TERMINAL.has(record.status)) continue;
167
+ if (!TERMINAL.has(metadata.status)) continue;
114
168
  // Do not delete terminal evidence that has never been read when maxAge is not exceeded
115
169
  // unless we are strictly over maxTerminalRecords budget (handled by sort below).
116
- const finished = Date.parse(record.finishedAt ?? record.updatedAt);
170
+ const finished = Date.parse(metadata.finishedAt ?? metadata.updatedAt ?? '');
117
171
  terminal.push({
118
172
  processId,
119
173
  finishedAt: Number.isFinite(finished) ? finished : 0,
120
174
  path: join(root, name),
121
- reusableCheckEvidence: record.status === 'succeeded' && Boolean(record.checkExecution?.cacheKey),
175
+ reusableCheckEvidence: metadata.reusableCheckEvidence,
122
176
  });
123
177
  }
124
178
 
@@ -140,7 +194,7 @@ export function gcTerminalProcesses(options: ProcessGcOptions): ProcessGcResult
140
194
  }
141
195
  }
142
196
 
143
- return { ok: true, removedRecords, removedLogs, skippedActive };
197
+ return { ok: true, removedRecords, removedLogs, skippedActive, skippedInvalid };
144
198
  } catch (error) {
145
199
  return {
146
200
  ok: false,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { existsSync } from 'fs';
3
3
  import { dirname, resolve } from 'path';
4
+ import { fileURLToPath } from 'url';
4
5
  import {
5
6
  controllerCheckExecutionIdentity,
6
7
  listControllerChecks,
@@ -37,7 +38,10 @@ export function resolvePersistedCheckCliInvocation(
37
38
  }
38
39
 
39
40
  export function resolveRuntimeCliTarget(controllerHome?: string): CliRuntimeTarget {
40
- const moduleSourceRoot = resolve(import.meta.dir, '..', '..', '..', '..');
41
+ const moduleDir = typeof import.meta.dir === 'string' && import.meta.dir
42
+ ? import.meta.dir
43
+ : dirname(fileURLToPath(import.meta.url));
44
+ const moduleSourceRoot = resolve(moduleDir, '..', '..', '..', '..');
41
45
  const sourceEntry = resolve(moduleSourceRoot, 'src', 'cli', 'index.ts');
42
46
  const source = resolveControllerRuntimeSourceRoot({
43
47
  explicitRoot: existsSync(sourceEntry) ? moduleSourceRoot : undefined,
@@ -214,7 +218,11 @@ export async function runPersistedCheckViaProcessRuntime(
214
218
  scopeKey: persistedCheckSemanticScopeKey(input, semanticCheck.reuseScope),
215
219
  };
216
220
  const cliTarget = resolveRuntimeCliTarget(input.controllerHome);
217
- const checkResultReceiptPath = allocatePersistedCheckResultReceiptPath(input.controllerHome, input.repoId);
221
+ const checkResultReceiptPath = allocatePersistedCheckResultReceiptPath(
222
+ input.controllerHome,
223
+ input.repoId,
224
+ input.requestId,
225
+ );
218
226
  const checkArgs = [
219
227
  '--repo',
220
228
  executionRoot,
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { ResourceClaimSpec } from '../jobs/types';
7
+ import { readFileSync } from 'fs';
8
+ import { join } from 'path';
7
9
  import type { ControllerCheckEffects } from '../../../cli/controller/check-runner';
8
10
  import { classifyRepositoryCommand, fixedShellWrapperCommand } from '../../../cli/repositories/command-classifier';
9
11
  import { normalizeRepositoryCommand } from '../../../cli/repositories/command-normalization';
@@ -157,7 +159,89 @@ function iosSimulatorTestCommandClaims(
157
159
  ]);
158
160
  }
159
161
 
160
- function hostOnlyCommandClaims(command: string | readonly string[]): ResourceClaimSpec[] | undefined {
162
+ function localHttpServerClaims(command: string | readonly string[]): ResourceClaimSpec[] | undefined {
163
+ const canonical = normalizeRepositoryCommand(command);
164
+ if (canonical.kind !== 'argv') return undefined;
165
+ const program = (canonical.executable ?? '').split(/[\\/]/).at(-1)?.toLowerCase();
166
+ if (program !== 'python' && program !== 'python3') return undefined;
167
+ const args = canonical.args ?? [];
168
+ if (args[0] !== '-m' || args[1] !== 'http.server' || args.includes('--cgi')) return undefined;
169
+ let bind = '0.0.0.0';
170
+ let port = '8000';
171
+ for (let index = 2; index < args.length; index += 1) {
172
+ const arg = args[index]!;
173
+ if (arg === '--bind') { bind = args[index + 1] ?? bind; index += 1; continue; }
174
+ if (arg === '--directory' || arg === '-d' || arg === '--protocol') { index += 1; continue; }
175
+ if (!arg.startsWith('-') && /^\d{1,5}$/.test(arg)) port = arg;
176
+ }
177
+ return [claimHostService(`tcp-listen:${bind}:${port}`, 'write')];
178
+ }
179
+
180
+ function loopbackCurlClaims(command: string | readonly string[], repoId: string): ResourceClaimSpec[] | undefined {
181
+ const canonical = normalizeRepositoryCommand(command);
182
+ if (canonical.kind !== 'argv') return undefined;
183
+ const program = (canonical.executable ?? '').split(/[\\/]/).at(-1)?.toLowerCase();
184
+ if (program !== 'curl') return undefined;
185
+ const args = canonical.args ?? [];
186
+ if (args.some((arg) => /^(?:-X|--request|-d|--data|--data-raw|--data-binary|-F|--form|-T|--upload-file|-o|--output|-O|--remote-name)$/i.test(arg))) return undefined;
187
+ const explicitMethodIndex = args.findIndex((arg) => arg === '-X' || arg === '--request');
188
+ if (explicitMethodIndex >= 0 && !/^(?:GET|HEAD)$/i.test(args[explicitMethodIndex + 1] ?? '')) return undefined;
189
+ const urls = args.filter((arg) => /^https?:\/\//i.test(arg));
190
+ if (urls.length === 0 || urls.some((url) => {
191
+ try {
192
+ const host = new URL(url).hostname.toLowerCase();
193
+ return !['127.0.0.1', 'localhost', '::1'].includes(host);
194
+ } catch { return true; }
195
+ })) return undefined;
196
+ return [claimNetwork(repoId, 'read')];
197
+ }
198
+
199
+ function simpleViteScript(script: string): boolean {
200
+ const normalized = script.trim();
201
+ if (!normalized || /[;&|`$<>\n\r]/.test(normalized)) return false;
202
+ return /^(?:(?:\.\/)?node_modules\/.bin\/)?vite(?:\s|$)/.test(normalized);
203
+ }
204
+
205
+ function viteServiceClaims(
206
+ command: string | readonly string[],
207
+ repoId: string,
208
+ checkoutId?: string,
209
+ repoRoot?: string,
210
+ ): ResourceClaimSpec[] | undefined {
211
+ const canonical = normalizeRepositoryCommand(command);
212
+ if (canonical.kind !== 'argv') return undefined;
213
+ const program = (canonical.executable ?? '').split(/[\\/]/).at(-1)?.toLowerCase();
214
+ const args = canonical.args ?? [];
215
+ let vite = program === 'vite';
216
+ if (!vite && (program === 'npx' || program === 'bunx') && args[0]?.toLowerCase() === 'vite') vite = true;
217
+ if (!vite && program === 'bun' && args[0]?.toLowerCase() === 'x' && args[1]?.toLowerCase() === 'vite') vite = true;
218
+ if (!vite && repoRoot && ['npm', 'pnpm', 'yarn', 'bun'].includes(program ?? '')) {
219
+ const runIndex = args[0]?.toLowerCase() === 'run' ? 1 : program === 'yarn' ? 0 : -1;
220
+ const scriptName = runIndex >= 0 ? args[runIndex]?.toLowerCase() : undefined;
221
+ if (scriptName === 'dev') {
222
+ try {
223
+ const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record<string, unknown> };
224
+ const script = typeof pkg.scripts?.dev === 'string' ? pkg.scripts.dev : '';
225
+ vite = simpleViteScript(script);
226
+ } catch {
227
+ vite = false;
228
+ }
229
+ }
230
+ }
231
+ if (!vite) return undefined;
232
+ return normalizeClaims([
233
+ claimBuildCacheWrite(repoId),
234
+ claimHostService(`vite:${checkoutScope(checkoutId)}`, 'write'),
235
+ ]);
236
+ }
237
+
238
+ function hostOnlyCommandClaims(command: string | readonly string[], repoId: string, checkoutId?: string, repoRoot?: string): ResourceClaimSpec[] | undefined {
239
+ const localServer = localHttpServerClaims(command);
240
+ if (localServer) return localServer;
241
+ const loopbackCurl = loopbackCurlClaims(command, repoId);
242
+ if (loopbackCurl) return loopbackCurl;
243
+ const vite = viteServiceClaims(command, repoId, checkoutId, repoRoot);
244
+ if (vite) return vite;
161
245
  const canonical = normalizeRepositoryCommand(command);
162
246
  if (canonical.kind === 'shell') {
163
247
  return browserAppleScriptHeredocClaims(canonical.shellCommand ?? '');
@@ -347,13 +431,14 @@ export function claimsForRepositoryCommand(
347
431
  repoId: string,
348
432
  checkoutId?: string,
349
433
  defaultBranch?: string,
434
+ repoRoot?: string,
350
435
  ): ResourceClaimSpec[] {
351
436
  const classification = classifyRepositoryCommand(command, defaultBranch);
352
437
  const canonical = normalizeRepositoryCommand(command);
353
438
  const focused = isFocusedCheckCommand(command);
354
439
  const iosSimulatorClaims = iosSimulatorTestCommandClaims(command, repoId, checkoutId);
355
440
  if (iosSimulatorClaims) return iosSimulatorClaims;
356
- const hostClaims = hostOnlyCommandClaims(command);
441
+ const hostClaims = hostOnlyCommandClaims(command, repoId, checkoutId, repoRoot);
357
442
  if (hostClaims) return hostClaims;
358
443
 
359
444
  if (classification.risk === 'readonly') {
@@ -258,8 +258,23 @@ function captureIdentity(pid: number | undefined): {
258
258
  } {
259
259
  if (!pid || pid <= 0) return {};
260
260
  const probe = defaultProcessIdentityProbe;
261
- if (!probe.isAlive(pid)) return {};
261
+ // The combined inspect probe already proves the PID exists on the normal
262
+ // POSIX path. Avoid an immediately preceding ps(stat) child process; only
263
+ // fall back to the separate alive/command/start-time probes when the combined
264
+ // observation is unavailable or incomplete.
262
265
  const inspected = probe.inspect?.(pid);
266
+ if (inspected?.command && inspected.startTime) {
267
+ return {
268
+ identity: {
269
+ pid,
270
+ processStartTime: inspected.startTime,
271
+ executableFingerprint: executableFingerprint(inspected.command),
272
+ processGroupId: process.platform !== 'win32' ? pid : undefined,
273
+ },
274
+ identityUntrusted: false,
275
+ };
276
+ }
277
+ if (!probe.isAlive(pid)) return {};
263
278
  const command = inspected?.command ?? probe.command(pid);
264
279
  const startTime = inspected?.startTime ?? probe.startTime(pid);
265
280
  if (!command || !startTime) {
@@ -308,6 +308,12 @@ function sanitizeProcessRecord(record: ManagedProcessRecord): { record: ManagedP
308
308
  function readProcessRecord(path: string): ManagedProcessRecord | undefined {
309
309
  const record = readJson<ManagedProcessRecord>(path);
310
310
  if (!record || record.schemaVersion !== 1) return undefined;
311
+ // Legacy/partial Process files can predate the required command descriptor.
312
+ // They are not executable recovery authority and must never be repaired by
313
+ // inventing a command. Treat them as unavailable to normal Runtime readers;
314
+ // bounded GC has its own metadata-only parser so one such file cannot block
315
+ // cleanup of unrelated proven terminal records.
316
+ if (!record.command || typeof record.command !== 'object' || Array.isArray(record.command)) return undefined;
311
317
  const sanitized = sanitizeProcessRecord(record);
312
318
  if (sanitized.changed) atomicWrite(path, sanitized.record);
313
319
  return sanitized.record;
@@ -659,7 +659,11 @@ function terminalCleanupOutcome(
659
659
  if (contract?.status === 'cancelled') return 'cancelled';
660
660
  const reason = `${handle.failureReason ?? ''} ${handle.finalization.lastError ?? ''}`.toLowerCase();
661
661
  if (contract?.status === 'blocked' && reason.includes('terminal')) return 'blocked_terminal';
662
- if (contract?.status === 'failed' || handle.state === 'failed' || handle.state === 'failed_terminal_cleanup') {
662
+ // `failed` is a retryable execution-handle state (its transition table allows
663
+ // failed -> validating/editing). Do not reinterpret it as terminal cleanup
664
+ // unless the durable Work itself is terminal. `failed_terminal_cleanup` is
665
+ // the explicit point of no return for resource cleanup reconciliation.
666
+ if (contract?.status === 'failed' || handle.state === 'failed_terminal_cleanup') {
663
667
  if (reason.includes('infrastructure') || reason.includes('timed out') || reason.includes('unavailable')) {
664
668
  return 'infrastructure_failed';
665
669
  }
@@ -1202,12 +1206,20 @@ async function validateWork(ctx: MultiRepositoryMcpToolContext, args: Record<str
1202
1206
  repoId: handle.repositoryId,
1203
1207
  checkoutId: handle.checkoutId,
1204
1208
  repoRoot: validated.worktreeRepository.canonicalRoot,
1205
- executionIdentity: executionIdentityForWork(validated.worktreeRepository, handle),
1209
+ executionIdentity: Object.freeze({
1210
+ ...executionIdentityForWork(validated.worktreeRepository, handle),
1211
+ expectedHead: validationHead === 'unknown' ? handle.expectedHead : validationHead,
1212
+ }),
1206
1213
  checkId,
1207
1214
  requestId: processRequestId,
1208
1215
  workId: handle.workId,
1209
1216
  commandId: processRequestId,
1210
1217
  verificationBinding: { executionSessionId: session.sessionId },
1218
+ verificationSnapshot: contract && contract.allowedPaths.length > 0 ? {
1219
+ workId: contract.workId,
1220
+ allowedPaths: contract.allowedPaths,
1221
+ forbiddenPaths: contract.forbiddenPaths,
1222
+ } : undefined,
1211
1223
  });
1212
1224
  if (executed.mode === 'durable') {
1213
1225
  checks.push({ checkId, ok: undefined, status: 'deferred', summary: executed.durable?.reason, durable: executed.durable });
@@ -1236,7 +1248,6 @@ async function validateWork(ctx: MultiRepositoryMcpToolContext, args: Record<str
1236
1248
  repoId: handle.repositoryId,
1237
1249
  checkoutId: handle.checkoutId,
1238
1250
  workId: handle.workId,
1239
- executionSessionId: session.sessionId,
1240
1251
  checkId,
1241
1252
  processId: process.processId,
1242
1253
  });
@@ -1311,21 +1322,25 @@ async function validateWork(ctx: MultiRepositoryMcpToolContext, args: Record<str
1311
1322
  failureSummary,
1312
1323
  );
1313
1324
  }
1314
- if (completed && !passed) {
1315
- const cleanup = await reconcileTerminalCleanup(
1316
- ctx,
1317
- session,
1318
- next,
1319
- args,
1320
- infrastructureFailure ? 'infrastructure_failed' : 'validation_failed',
1321
- );
1325
+ if (completed && acceptedFailure) {
1326
+ const cleanup = await reconcileTerminalCleanup(ctx, session, next, args, 'validation_failed');
1322
1327
  const value = {
1323
1328
  ...cleanup,
1324
1329
  validation: { passed, completed, checks, targeted: true, changedPaths, cleanupTriggered: true },
1325
1330
  };
1326
1331
  return makeBoundedResult(ctx, session, handle.repositoryId, handle.workId, 'validation', value);
1327
1332
  }
1328
- const value = { work: compactHandle(next), validation: { passed, completed, checks, targeted: true, changedPaths } };
1333
+ const value = {
1334
+ work: compactHandle(next),
1335
+ validation: {
1336
+ passed,
1337
+ completed,
1338
+ checks,
1339
+ targeted: true,
1340
+ changedPaths,
1341
+ ...(infrastructureFailure ? { retryable: true, cleanupTriggered: false } : {}),
1342
+ },
1343
+ };
1329
1344
  return makeBoundedResult(ctx, session, handle.repositoryId, handle.workId, 'validation', value);
1330
1345
  }
1331
1346
 
@@ -898,7 +898,7 @@ export async function routeDurableMcpCall(
898
898
  }, validation.isError === true);
899
899
  }
900
900
 
901
- const applied = await callRepositoryTool(ctx.controllerHome, name, args);
901
+ const applied = await callRepositoryTool(ctx.controllerHome, name, args, ctx);
902
902
  if (!applied || applied.isError === true) return applied;
903
903
  const patchPayload = (applied.structuredContent ?? {}) as Record<string, unknown>;
904
904
  if (patchPayload.status !== 'applied') return applied;