@awak-app/simy-cli 0.5.72 → 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 (48) hide show
  1. package/README.md +84 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  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 +179 -34
  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 +14 -10
  16. package/src/desktop-host.js +8 -6
  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 +7 -0
  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/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/codex-app-server.js +3 -2
  36. package/src/session-supervision/index.js +16 -4
  37. package/src/sqm/bundle-store.js +11 -7
  38. package/src/sqm/claude-plugin.js +2 -0
  39. package/src/sqm/command.js +36 -4
  40. package/src/sqm/diagnostics.js +1 -1
  41. package/src/sqm/evidence-client.js +23 -7
  42. package/src/sqm/history-store.js +4 -0
  43. package/src/sqm/index.js +104 -14
  44. package/src/sqm/proof.js +3 -23
  45. package/src/sqm/request-recovery.js +82 -0
  46. package/src/sqm/review-worker.js +59 -12
  47. package/src/sqm/session.js +33 -3
  48. 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
@@ -198,6 +259,29 @@ that task.
198
259
 
199
260
  ## Local Tasks
200
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
+
201
285
  CLI API contract v9 promotes non-coding work to a first-class **Local Task**
202
286
  runtime. A Local Task is separate from Agentic Loop: it runs one bounded Codex
203
287
  or Claude Code session without Git branches, pull requests, audits, or automatic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.72",
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;
@@ -251,7 +265,8 @@ export async function startAgent({
251
265
  const features = contractSourceCapabilities.features;
252
266
  availableCapabilities = withCliContract({
253
267
  ...contractSourceCapabilities,
254
- ...(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")
255
270
  ? { sqm_local_review: features.sqm_local_review === true && sqmEnabled && !pendingOrganization && desktopWorkAllowed() } : {}) } } : {}),
256
271
  ...(providerStatuses ? { provider_statuses: providerStatuses } : {}),
257
272
  }, installMode, {
@@ -576,6 +591,9 @@ export async function startAgent({
576
591
  const localRun = registry.get(snapshot.id);
577
592
  localRun.session = session;
578
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;
579
597
  const remoteTime = Date.parse(snapshot.updated_at || "") || 0;
580
598
  const localTime = Date.parse(localRun.snapshot.updated_at || "") || 0;
581
599
  if (remoteTime > localTime) {
@@ -874,11 +892,33 @@ export async function startAgent({
874
892
  sqmLocalReviewWorker = createWorker({
875
893
  api: sqmApi,
876
894
  beginWork: () => acquireUpdateWork(updateManager),
877
- runReview: async (request, provider) => {
895
+ runReview: async (request, provider, context = {}) => {
878
896
  const scope = await authorizeDesktopWork();
879
- const beforeDispatch = () => authorizeDesktopWork(scope);
880
- 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
+ });
881
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])]),
882
922
  onChange: notifyDesktopStatusChange,
883
923
  getWorkspaceUrl: () => {
884
924
  const slug = desktopAccess.snapshot().organizationSlug;
@@ -1164,6 +1204,7 @@ export async function startAgent({
1164
1204
  : indexedRepository?.local_path || null,
1165
1205
  dependencies,
1166
1206
  providerStatus: (await readProviderStatuses())[backend],
1207
+ request: body,
1167
1208
  });
1168
1209
  json(res, 200, environment);
1169
1210
  return;
@@ -1271,7 +1312,8 @@ export async function startAgent({
1271
1312
  authNonce = randomBytes(16).toString("base64url");
1272
1313
  pendingOrganization = null;
1273
1314
  desktopLoginPending = false;
1274
- sqmEnabled = await readSqmEnabled(sessionRoot, apiOrigin, session);
1315
+ sqmEngineSettings = await readSqmSettings(sessionRoot, apiOrigin, session);
1316
+ sqmEnabled = sqmEngineSettings.enabled;
1275
1317
  if (changingScope) sqmLocalReviewWorker?.resetHistory?.();
1276
1318
  organizationState = "unknown";
1277
1319
  organizationItems = [];
@@ -1321,6 +1363,35 @@ export async function startAgent({
1321
1363
  .pipe(res);
1322
1364
  return;
1323
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
+ }
1324
1395
  const localTaskStreamMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/stream$/);
1325
1396
  if (req.method === "GET" && localTaskStreamMatch) {
1326
1397
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -1391,7 +1462,13 @@ export async function startAgent({
1391
1462
  json(res, 401, { error: "simy session expired; run simy again" });
1392
1463
  return;
1393
1464
  }
1465
+ const purposeStartScope = desktopScopeRevision();
1394
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
+ }
1395
1472
  const instruction = String(body.instruction || "").trim();
1396
1473
  if (!instruction) {
1397
1474
  json(res, 400, { error: "instruction is required" });
@@ -1510,11 +1587,14 @@ export async function startAgent({
1510
1587
  repository,
1511
1588
  expectedOutputs: body.expected_outputs,
1512
1589
  providerTokenBudget: body.provider_token_budget,
1590
+ continuationPolicy: body.continuation_policy,
1591
+ providerTokenCeiling: body.provider_token_ceiling,
1513
1592
  timeoutMs: body.timeout_ms,
1514
1593
  attachmentManifest: preparedAttachments,
1515
1594
  scenarioPack,
1516
1595
  workflowObservationBinding,
1517
1596
  workflowStartIntent,
1597
+ purposeVerification,
1518
1598
  });
1519
1599
  hashLocalTaskIdempotencyKey(idempotencyKey);
1520
1600
  } catch (error) {
@@ -1636,20 +1716,36 @@ export async function startAgent({
1636
1716
  expectedOutputs: body.expected_outputs,
1637
1717
  scenarioPack,
1638
1718
  providerTokenBudget: body.provider_token_budget,
1719
+ continuationPolicy: body.continuation_policy,
1720
+ providerTokenCeiling: body.provider_token_ceiling,
1639
1721
  timeoutMs: body.timeout_ms,
1640
1722
  idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
1641
1723
  requestFingerprint,
1642
1724
  workflowObservationBinding,
1643
1725
  workflowStartIntent,
1726
+ purposeVerification,
1644
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
+ } });
1645
1733
  await persistLocalTask(task);
1646
1734
  localTaskRegistry.create(task);
1647
1735
  requestClaim.complete(task);
1648
1736
  requestClaimCompleted = true;
1649
1737
  const runLocalTask =
1650
1738
  dependencies.runLocalTask ||
1651
- ((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
+ } }));
1652
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 });
1653
1749
  task.operation_promise = Promise.resolve(runLocalTask(task))
1654
1750
  .catch(async (error) => {
1655
1751
  task.status = "failed";
@@ -1917,6 +2013,10 @@ export async function startAgent({
1917
2013
  : indexedRepository?.local_path || null,
1918
2014
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
1919
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,
1920
2020
  retry_budget: body.retry_budget,
1921
2021
  token_budget: body.provider_token_budget ?? body.token_budget,
1922
2022
  ui_evidence_root:
@@ -2011,6 +2111,66 @@ export async function startAgent({
2011
2111
  return;
2012
2112
  }
2013
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
+
2014
2174
  const providerBudgetMatch = agenticLoopPath.match(
2015
2175
  /^\/v1\/agentic-loop\/([^/]+)\/provider-token-budget$/,
2016
2176
  );
@@ -2213,6 +2373,7 @@ export async function startAgent({
2213
2373
  json(res, 400, localTaskFileErrorPayload(err));
2214
2374
  return;
2215
2375
  }
2376
+ if (String(err?.code || "").startsWith("purpose_verification_")) { json(res, err.status || 409, { error: err.message, code: err.code }); return; }
2216
2377
  json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
2217
2378
  }
2218
2379
  });
@@ -2330,7 +2491,9 @@ export async function startAgent({
2330
2491
  tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
2331
2492
  organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
2332
2493
  switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" && !organizationBusy() },
2333
- 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(),
2334
2497
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2335
2498
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2336
2499
  providerPause: sqmLocalReviewWorker?.snapshot?.().provider_pause ?? null,
@@ -2382,10 +2545,22 @@ export async function startAgent({
2382
2545
  await writeSqmEnabled(sessionRoot, apiOrigin, settingsSession, enabled);
2383
2546
  if (session !== settingsSession) return;
2384
2547
  sqmEnabled = enabled;
2548
+ sqmEngineSettings = { ...sqmEngineSettings, enabled };
2549
+ sqmLocalReviewWorker?.retry?.();
2385
2550
  refreshAvailableCapabilities();
2386
2551
  notifyDesktopStatusChange();
2387
2552
  void heartbeat().catch(error => reportHeartbeatError(error, quiet));
2388
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(); },
2389
2564
  getDesktopStatus,
2390
2565
  getDesktopTask: async (key) => {
2391
2566
  if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
@@ -2831,6 +3006,7 @@ function capabilitiesFromInspections(codex, claude) {
2831
3006
  local_task_streaming: true,
2832
3007
  local_task_idempotency: true,
2833
3008
  local_task_model: true,
3009
+ progress_continuation_policy: true,
2834
3010
  local_task_reconciliation: true,
2835
3011
  local_task_control_plane: true,
2836
3012
  pipeline_local_task_artifacts: true,
@@ -2862,6 +3038,7 @@ async function inspectCodingLoopEnvironment({
2862
3038
  localPath,
2863
3039
  dependencies,
2864
3040
  providerStatus,
3041
+ request = {},
2865
3042
  }) {
2866
3043
  const checks = [
2867
3044
  {
@@ -2922,13 +3099,16 @@ async function inspectCodingLoopEnvironment({
2922
3099
  if (inspection.compatible) {
2923
3100
  checks.push({
2924
3101
  key: "provider_auth",
2925
- status: authFailure ? "failed" : "passed",
3102
+ status: authFailure ? (providerStatus?.state === "signed_out" ? "failed" : "unknown") : "passed",
2926
3103
  summary: authFailure?.summary || `${backend === "claude" ? "Claude Code" : "Codex"} is signed in.`,
2927
3104
  ...(authFailure ? { recovery: authFailure.recovery } : {}),
2928
3105
  });
2929
3106
  }
3107
+ checks.push(...startupRequirementChecks(request));
3108
+ const startup = summarizeStartupChecks(checks);
2930
3109
  return {
2931
- ready: Boolean(repositoryPath && inspection.compatible && !authFailure),
3110
+ ready: Boolean(repositoryPath && inspection.compatible && !authFailure && startup.blocker_keys.length === 0),
3111
+ startup,
2932
3112
  repository,
2933
3113
  backend,
2934
3114
  execution_target: executionTarget,
@@ -1,5 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
  import os from "node:os";
3
+ import { readdir } from "node:fs/promises";
3
4
  import { readdirSync, statSync } from "node:fs";
4
5
  import path from "node:path";
5
6
 
@@ -26,11 +27,12 @@ export async function resolveBackendExecutable(backend, options = {}) {
26
27
 
27
28
  export async function inspectBackendExecutable(
28
29
  backend,
29
- { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000, signal } = {},
30
+ { spawnImpl = spawn, candidates, timeoutMs = 5_000, signal, discoveryOptions = {} } = {},
30
31
  ) {
31
32
  const assertActive = () => { if (signal?.aborted) throw Object.assign(new Error('Backend inspection was cancelled.'), { code: 'cancelled' }); };
32
33
  assertActive();
33
34
  const policy = versionPolicy(backend);
35
+ candidates ??= [...backendCandidates(backend, discoveryOptions), ...await bundledClaudeCandidates(backend, discoveryOptions)];
34
36
  for (const candidate of [...new Set(candidates.filter(Boolean))]) {
35
37
  assertActive();
36
38
  const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs, signal });
@@ -96,6 +98,24 @@ export function backendCandidates(backend, {
96
98
  ];
97
99
  }
98
100
 
101
+ // Claude Desktop can install below <version>/<content hash>/claude.app while
102
+ // an older PATH wrapper still points at <version>/claude.app. Discover only the
103
+ // known app layout; never recursively execute arbitrary files from user folders.
104
+ export async function bundledClaudeCandidates(backend, { platform = process.platform, home = os.homedir(), readDirectory = readdir } = {}) {
105
+ if (backend !== "claude" || platform !== "darwin") return [];
106
+ const root = path.posix.join(home, "Library", "Application Support", "Claude", "claude-code");
107
+ const entries = async target => { try { return await readDirectory(target, { withFileTypes: true }); } catch { return []; } };
108
+ const versions = (await entries(root)).filter(item => item.isDirectory() && /^\d+\.\d+\.\d+$/.test(item.name))
109
+ .sort((a, b) => compareVersions(b.name, a.name)).slice(0, 8);
110
+ const result = [];
111
+ for (const version of versions) {
112
+ const base = path.posix.join(root, version.name);
113
+ const hashes = (await entries(base)).filter(item => item.isDirectory() && /^[a-f0-9]{8,128}$/i.test(item.name)).slice(0, 8);
114
+ for (const hash of hashes) result.push(path.posix.join(base, hash.name, "claude.app", "Contents", "MacOS", "claude"));
115
+ result.push(path.posix.join(base, "claude.app", "Contents", "MacOS", "claude"));
116
+ }
117
+ return result;
118
+ }
99
119
  // Desktop installs use either <version>/claude.app or <version>/<hash>/claude.app.
100
120
  // Only inspect these two known levels; never recurse through arbitrary home paths
101
121
  // or descend through version/hash directory symlinks. Existing explicit/PATH installs retain priority.
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.72";
16
+ export const CLI_VERSION = "0.5.73";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -31,6 +31,28 @@ keyed by provider and session ID and bound to the canonical working directory.
31
31
  Historical hook observations do not prove hooks are still enabled. See the
32
32
  bundled delivery-loop skill for checkpoint commands and explicit recovery.
33
33
 
34
+ Managed tasks may explicitly select `--continuation-policy progress`. Existing
35
+ tasks without that choice keep their bounded contract. Progress mode treats the
36
+ ordinary time, continuation and token thresholds as warnings, with independent
37
+ hard ceilings of 24 hours from the original start, 50 continuations and, on the
38
+ host-observed Codex channel, 15 million provider tokens. Claude live hooks expose
39
+ no trustworthy usage stream; their status explicitly reports that token limits
40
+ are unsupported. Required approvals, missing observed usage, manual stops and
41
+ unreconciled effects continue to block dispatch.
42
+
43
+ The host records passing, failed and unevaluated frozen acceptance checks.
44
+ Provider summaries, test counts, renamed evidence and restored old criteria
45
+ cannot manufacture progress. Three attempts without new verified criteria, or
46
+ 30 minutes without verified progress at a dispatch/checkpoint boundary, pause
47
+ the original run. Active tools are allowed to reach their checkpoint within the
48
+ hard ceiling; activity alone does not prove progress.
49
+
50
+ `delivery extend` raises explicitly selected ceilings within those global caps.
51
+ It records the old and new bounds, preserves run identity, usage, checkpoints,
52
+ criteria and approvals, and does not start a new provider turn. A separate resume
53
+ is required after an exhausted run becomes paused. `delivery continuation_policy`
54
+ requires an explicit choice and never infers progress consent from an extension.
55
+
34
56
  ## Project 実装状況 (dev-work) hook
35
57
 
36
58
  `dev-work.mjs` runs on Stop in both distributions. In a Git work tree with a
@@ -0,0 +1,27 @@
1
+ // Missing policy means the persisted legacy budget contract, never implicit consent.
2
+ export const CONTINUATION_POLICIES = Object.freeze(["bounded", "progress"]);
3
+ export const MAX_COMPLETION_TOKENS = 15_000_000;
4
+ export const DEFAULT_COMPLETION_ATTEMPT_CEILING = 50;
5
+
6
+ export function continuationMode(value) {
7
+ if (value === undefined || value === null) return "bounded";
8
+ if (!CONTINUATION_POLICIES.includes(value)) throw new Error("Invalid continuation policy.");
9
+ return value;
10
+ }
11
+
12
+ export function progressLimits(input = {}) {
13
+ return {
14
+ continuation_policy: continuationMode(input.continuation_policy),
15
+ provider_token_ceiling: finiteLimit(input.provider_token_ceiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS),
16
+ attempt_ceiling: finiteLimit(input.attempt_ceiling, DEFAULT_COMPLETION_ATTEMPT_CEILING, 100),
17
+ max_no_progress_attempts: finiteLimit(input.max_no_progress_attempts, 3, 20),
18
+ };
19
+ }
20
+
21
+ function finiteLimit(value, fallback, maximum) {
22
+ if (value === undefined || value === null) return fallback;
23
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
24
+ throw new Error(`Continuation ceiling must be an integer between 1 and ${maximum}.`);
25
+ }
26
+ return value;
27
+ }