@awak-app/simy-cli 0.5.71 → 0.5.73

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 (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +17 -12
  16. package/src/desktop-host.js +19 -8
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +18 -1
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. package/src/startup-preflight.js +44 -0
package/README.md CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  Local execution agent for SIMY Local Tasks and Agentic Loop runs.
4
4
 
5
+ `simy --version` prints the installed CLI version without starting an agent or
6
+ checking for updates. `simy doctor` performs read-only local connection and
7
+ provider login checks and prints a JSON diagnostic report: installed/running
8
+ versions, stale discovery records, session expiry, provider readiness, and recent
9
+ structured SQM failures. It does not run a model, renew login, or restart tasks.
10
+ Provider login is separate from model access; unverified model access remains
11
+ `unknown`. Diagnostic journals retain stage/code/HTTP status only, use private
12
+ files scoped to the Web/account/organization, rotate each writer's four 64 KiB
13
+ segments, and keep lifetime failure counts after event rotation.
14
+
15
+ The organization menu's **Allow Codex / Claude fallback** setting defaults to
16
+ off and grants only read-only SQM analysis permission to use the other signed-in
17
+ local provider. It is saved per Web/account/organization. Switching requires an
18
+ explicit native model rejection or unavailable executable, a server that supports
19
+ actual-provider provenance, and fresh actor/settings/lease checks. Authentication,
20
+ quota, malformed output, arbitrary failures and coding tasks do not switch engines.
21
+ Both providers share one analysis deadline. A deterministic provider failure pauses
22
+ new claims and retains failed history until environment/settings change or **Retry
23
+ analysis** is selected.
24
+
25
+ Agentic Loop preflight accepts the task's existing requirement, acceptance
26
+ criteria, required checks and charter context alongside the repository/provider.
27
+ Its `startup` summary lists all blockers, unverified conditions and recovery
28
+ actions together. Environment-only calls remain supported. Task checks reuse the
29
+ execution charter policy; model entitlement, external test resources and assurance
30
+ selection evidence are not guessed from a successful login or recommendation.
31
+
32
+ ### Explicit progress continuation
33
+
34
+ Existing tasks use **Bounded** continuation. Select **Progress** before starting
35
+ an Agentic Loop or Local Task, or when enrolling an App session, to treat the
36
+ ordinary token/attempt/time limits as warning thresholds. New independently
37
+ verified acceptance criteria permit further work within finite hard ceilings;
38
+ repeated work without new proof pauses the same task. Approval requests, explicit
39
+ stops and authorization failures still pause execution. Provider text claiming
40
+ success does not count as verification.
41
+
42
+ Local Tasks retain valid output after a soft token warning and stop at their
43
+ explicit hard ceiling (at most 10 million reported provider tokens). The Agentic
44
+ controller limits a whole progress run to its approved allocation, at most
45
+ 15 million tokens, and persists the allocation with canonical receipts. Missing
46
+ reliable usage is charged conservatively against the reserved allocation. The
47
+ Web controller supports this mode for the connected local Codex/Claude engines;
48
+ unsupported cloud execution is refused before dispatch.
49
+
50
+ In the App, pause a managed Delivery task before changing its continuation
51
+ policy or extending its bounds. Extension keeps usage, task identity, acceptance
52
+ and approvals, and requires a separate resume action. Delivery hard ceilings are
53
+ 24 hours and 50 continuations; host-observed Codex usage can also impose a token
54
+ ceiling. A Claude live-hook session without reliable token telemetry explicitly
55
+ uses time/continuation bounds instead of inventing usage.
56
+
57
+ For interrupted Agentic work, `GET /v1/agentic-loop/:id/recovery` provides a
58
+ read-only objective and checkout fingerprint. Reconcile an uncertain side effect
59
+ with `POST` to that endpoint (`action: reconcile`, authoritative
60
+ `operation_status`, `observation`, `evidence_refs`) before resuming. A paused
61
+ progress run accepts explicit increases through
62
+ `POST /v1/agentic-loop/:id/continuation-ceilings`; it never resets already used
63
+ tokens or automatically resumes. Both routes require the original authorized
64
+ account, organization and device throughout the request.
65
+
5
66
  ## Apple Silicon Mac app
6
67
 
7
68
  The macOS installer includes a native status window and menu bar app. Open
@@ -30,9 +91,20 @@ current analysis finishes and submits its result. The toggle does not grant SQM
30
91
  subscription or organization permissions. First-time repository history discovery
31
92
  still starts from the web workspace.
32
93
 
33
- **SQM Tasks** shows local SQM reviews from the current agent session, separately
34
- from ordinary **Tasks**. Up to 50 recent reviews are shown; changing organization
35
- clears the local view. The web SQM workspace retains the authoritative history.
94
+ **Quality checks** shows local analysis and check history separately from ordinary
95
+ **Tasks**. Recent records survive restart and are scoped to the Web environment,
96
+ account, and organization. Failed checks retain safe diagnostic codes, stages,
97
+ HTTP status, and request identifiers when available. The web SQM workspace
98
+ retains the authoritative analysis history. Persistent provider login, model or
99
+ access errors pause new local claims with a visible recovery hint; refreshing
100
+ after reconnecting permits another probe, with bounded automatic cooldowns.
101
+
102
+ Detached daemon and desktop-host diagnostics are saved to
103
+ `$SIMY_HOME/logs/daemon.log` and `$SIMY_HOME/logs/desktop-host.log`
104
+ (`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
105
+ backups; credentials, Cookie headers and URL queries are redacted. Installer
106
+ handoff diagnostics remain in `~/.simy/installer/desktop-update.log`.
107
+ `simy --version` or `simy -v` prints the package version without starting work.
36
108
 
37
109
  Closing the window keeps the agent running. Open the app again or choose
38
110
  **Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
@@ -187,6 +259,29 @@ that task.
187
259
 
188
260
  ## Local Tasks
189
261
 
262
+ Purpose runs may opt into independent local evidence by freezing user-approved
263
+ `verification_recipes` in the server claim. `capabilities.purpose_verification`
264
+ advertises the device's Ed25519 public key; the server pins that key for the
265
+ attempt. Older clients without this capability cannot supply this evidence.
266
+ `POST /v1/local-tasks/:id/verify-purpose` with `{}` checks the original plan only
267
+ after the task succeeds. GET only reads the cached `task.host_verification`.
268
+ File recipes independently check bounded regular files and optional JSON format;
269
+ command recipes execute approved structured argv for Node tests or package
270
+ `test`, `build`, `check`, and `lint` scripts. Commands require write permission,
271
+ share a five-minute deadline, and retain only output hashes and exit status.
272
+ Optional `protected_paths` bind check definitions to their pre-execution hashes.
273
+
274
+ A receipt binds the actor, device, attempt, objective, recipe plan, local task,
275
+ and before/after repository identity. Changed repositories, stopped or uncertain
276
+ tasks, unknown prior check effects, and changed keys fail closed. Fresh POSTs are
277
+ idempotent; an expired confirmed receipt may be refreshed explicitly on the same
278
+ task, with at most three check rounds and no new Provider invocation. Checks
279
+ never upload source files or raw command output. Exit zero proves the specified
280
+ command's result; it does not prove product acceptance or that a package script
281
+ has no external effects. This host evidence boundary does not isolate a malicious
282
+ process running as the same OS user.
283
+
284
+
190
285
  CLI API contract v9 promotes non-coding work to a first-class **Local Task**
191
286
  runtime. A Local Task is separate from Agentic Loop: it runs one bounded Codex
192
287
  or Claude Code session without Git branches, pull requests, audits, or automatic
@@ -411,6 +506,9 @@ SQM deliberately has two identity-bound stages:
411
506
  The full-check summary reports applicable modules, evaluated rules, scenario
412
507
  statuses and blocking/advisory scope. `passed` with `no_applicable_rules` or zero
413
508
  scenario evaluations does not establish feature or historical incident coverage.
509
+ An empty system/repository bundle adds `no_system_knowledge` and an explicit
510
+ zero-case warning; knowledge that exists but does not match this change uses a
511
+ separate warning. Both retain the existing `passed` outcome.
414
512
  Scenario statuses come from deterministic rule evaluation, not browser/provider
415
513
  execution. Keep concrete regression and delivery acceptance evidence separately.
416
514
  If knowledge is unavailable, the command returns exit 2 with the service cause
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.71",
3
+ "version": "0.5.73",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@
42
42
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
43
43
  "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
44
44
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
45
- "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
45
+ "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
46
46
  "check:package": "node ./scripts/check-package-contents.js",
47
47
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
48
48
  "build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
package/src/agent.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { normalizePurposeVerification, assertPurposeActor, preparePurposeVerification, verifyPurposeLocalTask, purposeSigningKeyPath, purposeVerificationCapability, purposeWorkspaceBusy } from "./purpose-local-proof.js";
2
+ import { startupRequirementChecks, summarizeStartupChecks } from "./startup-preflight.js";
1
3
  import { BROWSER_HANDOFF_TRANSPORT, browserHandoffPage, decryptBrowserHandoff } from "./browser-handoff.js";
2
4
  import { createAuthorizationWindow } from "./browser-authorization.js";
3
5
  import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
4
6
  import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
5
- import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
7
+ import { readSqmEnabled, writeSqmEnabled, readSqmSettings, writeSqmEngineFallback } from "./desktop-sqm-settings.js";
6
8
  import { createServer } from "node:http";
7
9
  import { createHash, randomBytes } from "node:crypto";
8
10
  import { createReadStream } from "node:fs";
@@ -14,10 +16,13 @@ import {
14
16
  continueLocalCodingRunAfterRepositoryApproval,
15
17
  createRun,
16
18
  increaseLocalCodingRunProviderTokenBudget,
19
+ extendLocalCodingRunCeilings,
17
20
  LocalRunRegistry,
18
21
  markCodingRunMonitorFailure,
19
22
  isLocalRepositoryApprovalPending,
20
23
  pauseLocalCodingRun,
24
+ prepareLocalCodingRunRecovery,
25
+ reconcileLocalCodingRun,
21
26
  queueLocalCodingGuidance,
22
27
  recheckLocalCodingRun,
23
28
  publishRestoredLocalCodingRun,
@@ -30,6 +35,7 @@ import {
30
35
  toCompatibleCodingLoopState,
31
36
  toLedgerSnapshot,
32
37
  } from "./runner.js";
38
+ import { assertRecoveryActor } from "./coding-run-recovery.js";
33
39
  import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
34
40
  import {
35
41
  defaultSessionRoot,
@@ -196,7 +202,15 @@ export async function startAgent({
196
202
  // as its device (see /v1/auth/complete), so keep it after it is cleared.
197
203
  let reclaimableSession = null;
198
204
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
199
- let sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
205
+ const purposeKeyPath = dependencies.purposeSigningKeyPath || purposeSigningKeyPath(sessionRoot);
206
+ let purposeSigner = null;
207
+ const refreshPurposeSigner = async () => {
208
+ try { purposeSigner = await purposeVerificationCapability(purposeKeyPath, { create: true }); }
209
+ catch { purposeSigner = null; } // Invalid/private-key state fails closed without blocking ordinary tasks.
210
+ };
211
+ await refreshPurposeSigner();
212
+ let sqmEngineSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
213
+ let sqmEnabled = sqmEngineSettings.enabled;
200
214
  let organizationItems = [];
201
215
  let organizationState = "unknown";
202
216
  let organizationRefresh = null;
@@ -246,11 +260,13 @@ export async function startAgent({
246
260
  );
247
261
  let availableCapabilities;
248
262
  let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
263
+ const providerRecoveryRevisions = { codex: 0, claude: 0 };
249
264
  const refreshAvailableCapabilities = () => {
250
265
  const features = contractSourceCapabilities.features;
251
266
  availableCapabilities = withCliContract({
252
267
  ...contractSourceCapabilities,
253
- ...(features ? { features: { ...features, ...(Object.hasOwn(features, "sqm_local_review")
268
+ ...(purposeSigner ? { purpose_verification: purposeSigner } : {}),
269
+ ...(features ? { features: { ...features, purpose_host_verification: Boolean(purposeSigner) && isSessionValid(session, Date.now(), apiOrigin), ...(Object.hasOwn(features, "sqm_local_review")
254
270
  ? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization && desktopWorkAllowed() } : {}) } } : {}),
255
271
  ...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
256
272
  }, installMode, {
@@ -423,6 +439,7 @@ export async function startAgent({
423
439
  providerStatuses = { ...providerStatuses, [provider]: next };
424
440
  refreshAvailableCapabilities();
425
441
  if (previous[provider]?.version !== next.version || previous[provider]?.state !== next.state) {
442
+ providerRecoveryRevisions[provider] += 1;
426
443
  notifyDesktopStatusChange();
427
444
  }
428
445
  }));
@@ -574,6 +591,9 @@ export async function startAgent({
574
591
  const localRun = registry.get(snapshot.id);
575
592
  localRun.session = session;
576
593
  localRun.apiOrigin = apiOrigin;
594
+ // A newer ledger is not permission to replace an active local operation.
595
+ // Keep the owner, child, checkpoint and approval state until it settles.
596
+ if (localRun.operation || localRun.child) continue;
577
597
  const remoteTime = Date.parse(snapshot.updated_at || "") || 0;
578
598
  const localTime = Date.parse(localRun.snapshot.updated_at || "") || 0;
579
599
  if (remoteTime > localTime) {
@@ -872,17 +892,40 @@ export async function startAgent({
872
892
  sqmLocalReviewWorker = createWorker({
873
893
  api: sqmApi,
874
894
  beginWork: () => acquireUpdateWork(updateManager),
875
- runReview: async (request, provider) => {
895
+ runReview: async (request, provider, context = {}) => {
876
896
  const scope = await authorizeDesktopWork();
877
- const beforeDispatch = () => authorizeDesktopWork(scope);
878
- return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch, readOnlyAnalysis: true });
897
+ const actor = JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]);
898
+ const settings = await readSqmSettings(sessionRoot, apiOrigin, session);
899
+ const engineSettings = JSON.stringify([settings.enable_engine_fallback, settings.allowed_fallback_providers]);
900
+ const beforeDispatch = async () => {
901
+ await authorizeDesktopWork(scope);
902
+ const current = await readSqmSettings(sessionRoot, apiOrigin, session);
903
+ if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
904
+ engineSettings !== JSON.stringify([current.enable_engine_fallback, current.allowed_fallback_providers])) {
905
+ throw Object.assign(new Error("SQM actor or fallback settings changed."), { code: "desktop_access_required" });
906
+ }
907
+ await context.beforeLeaseDispatch?.();
908
+ await authorizeDesktopWork(scope);
909
+ const finalSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
910
+ if (actor !== JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id]) ||
911
+ engineSettings !== JSON.stringify([finalSettings.enable_engine_fallback, finalSettings.allowed_fallback_providers])) {
912
+ throw Object.assign(new Error("SQM actor or fallback settings changed."), { code: "desktop_access_required" });
913
+ }
914
+ };
915
+ return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, {
916
+ beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
917
+ allowedFallbackProviders: settings.allowed_fallback_providers, providerReportingSupported: context.providerReportingSupported === true,
918
+ });
879
919
  },
920
+ getEnvironmentKey: () => JSON.stringify([apiOrigin, session?.account_id, session?.organization_id, session?.device_id,
921
+ session?.expires_at, sqmEnabled, sqmEngineSettings, ["codex", "claude"].map(provider => [providerStatuses[provider]?.state, providerStatuses[provider]?.version])]),
880
922
  onChange: notifyDesktopStatusChange,
881
923
  getWorkspaceUrl: () => {
882
924
  const slug = desktopAccess.snapshot().organizationSlug;
883
925
  return slug ? new URL(`/org/${encodeURIComponent(slug)}/quality-monitor/`, apiOrigin).toString() : null;
884
926
  },
885
927
  getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
928
+ getRecoveryKey: (provider) => providerRecoveryRevisions[provider] ?? 0,
886
929
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
887
930
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
888
931
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
@@ -1161,6 +1204,7 @@ export async function startAgent({
1161
1204
  : indexedRepository?.local_path || null,
1162
1205
  dependencies,
1163
1206
  providerStatus: (await readProviderStatuses())[backend],
1207
+ request: body,
1164
1208
  });
1165
1209
  json(res, 200, environment);
1166
1210
  return;
@@ -1268,7 +1312,8 @@ export async function startAgent({
1268
1312
  authNonce = randomBytes(16).toString("base64url");
1269
1313
  pendingOrganization = null;
1270
1314
  desktopLoginPending = false;
1271
- sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1315
+ sqmEngineSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
1316
+ sqmEnabled = sqmEngineSettings.enabled;
1272
1317
  if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1273
1318
  organizationState = "unknown";
1274
1319
  organizationItems = [];
@@ -1318,6 +1363,35 @@ export async function startAgent({
1318
1363
  .pipe(res);
1319
1364
  return;
1320
1365
  }
1366
+ const purposeVerifyMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/verify-purpose$/);
1367
+ if (req.method === "POST" && purposeVerifyMatch) {
1368
+ const actorScope = desktopScopeRevision();
1369
+ const actorSession = session;
1370
+ const task = localTaskRegistry.get(decodeURIComponent(purposeVerifyMatch[1]));
1371
+ if (!task) { json(res, 404, { error: "Local Task not found" }); return; }
1372
+ const body = await readBoundedJson(req, 1024).catch(() => { throw Object.assign(new Error("Purpose verification body must be an empty JSON object."), { code: "purpose_verification_plan_invalid", status: 400 }); });
1373
+ if (!body || Array.isArray(body) || typeof body !== "object" || Object.keys(body).length) {
1374
+ json(res, 400, { error: "Verification uses only the frozen task plan.", code: "purpose_verification_plan_invalid" }); return;
1375
+ }
1376
+ const selected = await ensureRepositoryIndexed(task.repository);
1377
+ const approvedInventoryPath = findRepository(repositoryInventory, task.repository)?.local_path;
1378
+ const authorize = async () => {
1379
+ if (actorScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin) || shuttingDown ||
1380
+ selected?.local_path !== task.workspace_path || findRepository(repositoryInventory, task.repository)?.local_path !== approvedInventoryPath ||
1381
+ registry.list().some(run => run.request?.repository === task.repository && (run.operation || run.child || ["queued", "dispatching", "coding"].includes(run.status))) ||
1382
+ localTaskRegistry.list().some(other => other !== task && other.workspace_path === task.workspace_path && ["queued", "running", "stopping"].includes(other.status))) {
1383
+ throw Object.assign(new Error("Purpose verification scope or workspace changed."), { code: "purpose_verification_scope_changed", status: 403 });
1384
+ }
1385
+ await authorizeDesktopWork(requestAccessScope);
1386
+ if (actorScope !== desktopScopeRevision()) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed", status: 403 });
1387
+ };
1388
+ await authorize();
1389
+ await withUpdateWork(updateManager, async () => verifyPurposeLocalTask(task, {
1390
+ session: actorSession, apiOrigin, keyPath: purposeKeyPath, authorize, persist: persistLocalTask,
1391
+ }));
1392
+ json(res, 200, { ok: true, task: localTaskSnapshot(task) });
1393
+ return;
1394
+ }
1321
1395
  const localTaskStreamMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/stream$/);
1322
1396
  if (req.method === "GET" && localTaskStreamMatch) {
1323
1397
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -1388,7 +1462,13 @@ export async function startAgent({
1388
1462
  json(res, 401, { error: "simy session expired; run simy again" });
1389
1463
  return;
1390
1464
  }
1465
+ const purposeStartScope = desktopScopeRevision();
1391
1466
  const { body, attachments } = await readCodingLoopStart(req);
1467
+ const purposeVerification = normalizePurposeVerification(body.purpose_verification);
1468
+ if (purposeVerification) {
1469
+ assertPurposeActor(purposeVerification, session, apiOrigin, null, String(req.headers["idempotency-key"] || body.client_request_id || "").trim());
1470
+ if (purposeStartScope !== desktopScopeRevision() || !purposeSigner || purposeSigner.key_id !== purposeVerification.key_id) throw Object.assign(new Error("Purpose verification signer or actor changed."), { code: "purpose_verification_scope_changed", status: 403 });
1471
+ }
1392
1472
  const instruction = String(body.instruction || "").trim();
1393
1473
  if (!instruction) {
1394
1474
  json(res, 400, { error: "instruction is required" });
@@ -1507,11 +1587,14 @@ export async function startAgent({
1507
1587
  repository,
1508
1588
  expectedOutputs: body.expected_outputs,
1509
1589
  providerTokenBudget: body.provider_token_budget,
1590
+ continuationPolicy: body.continuation_policy,
1591
+ providerTokenCeiling: body.provider_token_ceiling,
1510
1592
  timeoutMs: body.timeout_ms,
1511
1593
  attachmentManifest: preparedAttachments,
1512
1594
  scenarioPack,
1513
1595
  workflowObservationBinding,
1514
1596
  workflowStartIntent,
1597
+ purposeVerification,
1515
1598
  });
1516
1599
  hashLocalTaskIdempotencyKey(idempotencyKey);
1517
1600
  } catch (error) {
@@ -1633,20 +1716,36 @@ export async function startAgent({
1633
1716
  expectedOutputs: body.expected_outputs,
1634
1717
  scenarioPack,
1635
1718
  providerTokenBudget: body.provider_token_budget,
1719
+ continuationPolicy: body.continuation_policy,
1720
+ providerTokenCeiling: body.provider_token_ceiling,
1636
1721
  timeoutMs: body.timeout_ms,
1637
1722
  idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
1638
1723
  requestFingerprint,
1639
1724
  workflowObservationBinding,
1640
1725
  workflowStartIntent,
1726
+ purposeVerification,
1641
1727
  });
1728
+ if (purposeVerification) await preparePurposeVerification(task, { session, apiOrigin, clientRequestId: idempotencyKey, keyPath: purposeKeyPath,
1729
+ authorize: async () => {
1730
+ await authorizeDesktopWork(requestAccessScope);
1731
+ if (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed", status: 403 });
1732
+ } });
1642
1733
  await persistLocalTask(task);
1643
1734
  localTaskRegistry.create(task);
1644
1735
  requestClaim.complete(task);
1645
1736
  requestClaimCompleted = true;
1646
1737
  const runLocalTask =
1647
1738
  dependencies.runLocalTask ||
1648
- ((localTask) => startLocalTask(localTask, { session, apiOrigin }));
1739
+ ((localTask) => startLocalTask(localTask, { session, apiOrigin, beforeSpawn: async () => {
1740
+ if (purposeWorkspaceBusy(localTask.workspace_path)) throw Object.assign(new Error("Independent verification owns this workspace."), { code: "purpose_verification_workspace_busy" });
1741
+ if (purposeVerification) {
1742
+ await authorizeDesktopWork(requestAccessScope);
1743
+ assertPurposeActor(purposeVerification, session, apiOrigin, localTask, idempotencyKey);
1744
+ if (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed" });
1745
+ }
1746
+ } }));
1649
1747
  await authorizeDesktopWork(requestAccessScope);
1748
+ if (purposeVerification && (purposeStartScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin))) throw Object.assign(new Error("Purpose verification actor changed."), { code: "purpose_verification_scope_changed", status: 403 });
1650
1749
  task.operation_promise = Promise.resolve(runLocalTask(task))
1651
1750
  .catch(async (error) => {
1652
1751
  task.status = "failed";
@@ -1914,6 +2013,10 @@ export async function startAgent({
1914
2013
  : indexedRepository?.local_path || null,
1915
2014
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
1916
2015
  max_attempts: body.max_attempts,
2016
+ continuation_policy: body.continuation_policy,
2017
+ provider_token_ceiling: body.provider_token_ceiling,
2018
+ attempt_ceiling: body.attempt_ceiling,
2019
+ max_no_progress_attempts: body.max_no_progress_attempts,
1917
2020
  retry_budget: body.retry_budget,
1918
2021
  token_budget: body.provider_token_budget ?? body.token_budget,
1919
2022
  ui_evidence_root:
@@ -2008,6 +2111,66 @@ export async function startAgent({
2008
2111
  return;
2009
2112
  }
2010
2113
 
2114
+ const recoveryMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/recovery$/);
2115
+ if (recoveryMatch && ["GET", "POST"].includes(req.method)) {
2116
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
2117
+ json(res, 401, { error: "Restore the authorized SIMY session before recovering this run." }); return;
2118
+ }
2119
+ const run = registry.get(decodeURIComponent(recoveryMatch[1]));
2120
+ if (!run) { json(res, 404, { error: "run not found" }); return; }
2121
+ try {
2122
+ const recoveryScope = desktopScopeRevision();
2123
+ const authorizeRecovery = async () => {
2124
+ await authorizeDesktopWork(requestAccessScope);
2125
+ if (recoveryScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) {
2126
+ throw Object.assign(new Error('The authorized desktop session changed.'), { code: 'desktop_access_required' });
2127
+ }
2128
+ assertRecoveryActor({ ...run, session, apiOrigin });
2129
+ };
2130
+ assertRecoveryActor({ ...run, session, apiOrigin });
2131
+ if (req.method === "GET") {
2132
+ const recovery = await prepareLocalCodingRunRecovery(run);
2133
+ await authorizeRecovery();
2134
+ json(res, 200, { recovery });
2135
+ } else {
2136
+ const body = await readJson(req);
2137
+ await authorizeRecovery();
2138
+ if (body.action !== "reconcile") { json(res, 400, { error: "Choose reconcile after inspecting the interrupted operation." }); return; }
2139
+ const recovery = await reconcileLocalCodingRun(run, {
2140
+ operationStatus: body.operation_status, observation: body.observation, evidenceRefs: body.evidence_refs, authorize: authorizeRecovery,
2141
+ });
2142
+ json(res, 200, { recovery, run_id: run.id, resumed: false });
2143
+ }
2144
+ } catch (error) {
2145
+ json(res, 409, { error: error instanceof Error ? error.message : "Recovery could not be verified.", code: error.code || "recovery_rejected" });
2146
+ }
2147
+ return;
2148
+ }
2149
+
2150
+ const ceilingMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/continuation-ceilings$/);
2151
+ if (req.method === "POST" && ceilingMatch) {
2152
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
2153
+ json(res, 401, { error: "Restore the authorized SIMY session before extending this run." }); return;
2154
+ }
2155
+ const run = registry.get(decodeURIComponent(ceilingMatch[1]));
2156
+ if (!run) { json(res, 404, { error: "run not found" }); return; }
2157
+ try {
2158
+ const recoveryScope = desktopScopeRevision();
2159
+ assertRecoveryActor({ ...run, session, apiOrigin });
2160
+ const body = await readJson(req);
2161
+ await authorizeDesktopWork(requestAccessScope);
2162
+ if (recoveryScope !== desktopScopeRevision() || !isSessionValid(session, Date.now(), apiOrigin)) {
2163
+ throw Object.assign(new Error('The authorized desktop session changed.'), { code: 'desktop_access_required' });
2164
+ }
2165
+ assertRecoveryActor({ ...run, session, apiOrigin });
2166
+ const budget = await extendLocalCodingRunCeilings(run, body);
2167
+ json(res, 200, { run_id: run.id, budget, resumed: false });
2168
+ } catch (error) {
2169
+ json(res, 409, { error: error instanceof Error ? error.message : "Continuation ceilings could not be extended.", code: error.code || "continuation_ceiling_rejected" });
2170
+ }
2171
+ return;
2172
+ }
2173
+
2011
2174
  const providerBudgetMatch = agenticLoopPath.match(
2012
2175
  /^\/v1\/agentic-loop\/([^/]+)\/provider-token-budget$/,
2013
2176
  );
@@ -2210,6 +2373,7 @@ export async function startAgent({
2210
2373
  json(res, 400, localTaskFileErrorPayload(err));
2211
2374
  return;
2212
2375
  }
2376
+ if (String(err?.code || "").startsWith("purpose_verification_")) { json(res, err.status || 409, { error: err.message, code: err.code }); return; }
2213
2377
  json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
2214
2378
  }
2215
2379
  });
@@ -2327,9 +2491,12 @@ export async function startAgent({
2327
2491
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
2328
2492
  organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
2329
2493
  switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
2330
- sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
2494
+ sqm: { enabled: sqmEnabled, engineFallbackEnabled: sqmEngineSettings.enable_engine_fallback,
2495
+ allowedFallbackProviders: sqmEngineSettings.allowed_fallback_providers,
2496
+ paused: sqmLocalReviewWorker?.snapshot?.().paused === true, pauseReason: sqmLocalReviewWorker?.snapshot?.().pause_reason ?? null, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
2331
2497
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2332
2498
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2499
+ providerPause: sqmLocalReviewWorker?.snapshot?.().provider_pause ?? null,
2333
2500
  providerReady: [codex, claude].some(provider => provider?.state === "ready") },
2334
2501
  ...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
2335
2502
  update: updateManager?.snapshot?.() ?? null,
@@ -2378,10 +2545,22 @@ export async function startAgent({
2378
2545
  await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
2379
2546
  if (session !== settingsSession) return;
2380
2547
  sqmEnabled = enabled;
2548
+ sqmEngineSettings = { ...sqmEngineSettings, enabled };
2549
+ sqmLocalReviewWorker?.retry?.();
2381
2550
  refreshAvailableCapabilities();
2382
2551
  notifyDesktopStatusChange();
2383
2552
  void heartbeat().catch(error => reportHeartbeatError(error, quiet));
2384
2553
  },
2554
+ async setSqmEngineFallback({ enabled, allowedProviders }) {
2555
+ await authorizeDesktopWork();
2556
+ if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
2557
+ const settingsSession = session;
2558
+ await writeSqmEngineFallback(sessionRoot, apiOrigin, settingsSession, { enabled, allowedProviders });
2559
+ if (session !== settingsSession) return;
2560
+ sqmEngineSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
2561
+ sqmLocalReviewWorker?.retry?.(); notifyDesktopStatusChange();
2562
+ },
2563
+ async retrySqmEnvironment() { await authorizeDesktopWork(); sqmLocalReviewWorker?.retry?.(); notifyDesktopStatusChange(); },
2385
2564
  getDesktopStatus,
2386
2565
  getDesktopTask: async (key) => {
2387
2566
  if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
@@ -2400,7 +2579,16 @@ export async function startAgent({
2400
2579
  return getDesktopStatus();
2401
2580
  },
2402
2581
  refreshCodexStatus: () => codexStatus({ force: true }),
2403
- refreshProviderStatus: () => readProviderStatuses({ force: true }),
2582
+ refreshProviderStatus: async () => {
2583
+ const statuses = await readProviderStatuses({ force: true });
2584
+ // Explicit reconnect/refresh permits one recovery attempt. Routine status
2585
+ // polling must not defeat the worker's persistent-failure cooldown.
2586
+ for (const provider of ["codex", "claude"]) {
2587
+ if (statuses[provider]?.state === "ready") providerRecoveryRevisions[provider] += 1;
2588
+ }
2589
+ notifyDesktopStatusChange();
2590
+ return statuses;
2591
+ },
2404
2592
  workspace,
2405
2593
  repositoryScanRoot,
2406
2594
  get capabilities() {
@@ -2818,6 +3006,7 @@ function capabilitiesFromInspections(codex, claude) {
2818
3006
  local_task_streaming: true,
2819
3007
  local_task_idempotency: true,
2820
3008
  local_task_model: true,
3009
+ progress_continuation_policy: true,
2821
3010
  local_task_reconciliation: true,
2822
3011
  local_task_control_plane: true,
2823
3012
  pipeline_local_task_artifacts: true,
@@ -2849,6 +3038,7 @@ async function inspectCodingLoopEnvironment({
2849
3038
  localPath,
2850
3039
  dependencies,
2851
3040
  providerStatus,
3041
+ request = {},
2852
3042
  }) {
2853
3043
  const checks = [
2854
3044
  {
@@ -2909,13 +3099,16 @@ async function inspectCodingLoopEnvironment({
2909
3099
  if (inspection.compatible) {
2910
3100
  checks.push({
2911
3101
  key: "provider_auth",
2912
- status: authFailure ? "failed" : "passed",
3102
+ status: authFailure ? (providerStatus?.state === "signed_out" ? "failed" : "unknown") : "passed",
2913
3103
  summary: authFailure?.summary || `${backend === "claude" ? "Claude Code" : "Codex"} is signed in.`,
2914
3104
  ...(authFailure ? { recovery: authFailure.recovery } : {}),
2915
3105
  });
2916
3106
  }
3107
+ checks.push(...startupRequirementChecks(request));
3108
+ const startup = summarizeStartupChecks(checks);
2917
3109
  return {
2918
- ready: Boolean(repositoryPath && inspection.compatible && !authFailure),
3110
+ ready: Boolean(repositoryPath && inspection.compatible && !authFailure && startup.blocker_keys.length === 0),
3111
+ startup,
2919
3112
  repository,
2920
3113
  backend,
2921
3114
  execution_target: executionTarget,