@awak-app/simy-cli 0.5.75 → 0.5.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,38 +29,25 @@ actions together. Environment-only calls remain supported. Task checks reuse the
29
29
  execution charter policy; model entitlement, external test resources and assurance
30
30
  selection evidence are not guessed from a successful login or recommendation.
31
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.
32
+ ### Local continuation
33
+
34
+ The recorded **Bounded** and **Progress** policy fields remain readable for
35
+ compatibility. Independently verified acceptance criteria permit further work. Local token usage, elapsed time
36
+ and attempt counters do not stop execution, including old tasks restored from
37
+ bounded policies. Repeated work without new proof pauses the same task. Approval
38
+ requests, explicit stops and authorization failures still pause execution.
39
+ Provider text claiming success does not count as verification. Token records
40
+ remain available for history and usage warnings. The Web controller supports
41
+ connected local Codex/Claude engines; unsupported cloud execution is refused
42
+ before dispatch.
56
43
 
57
44
  For interrupted Agentic work, `GET /v1/agentic-loop/:id/recovery` provides a
58
45
  read-only objective and checkout fingerprint. Reconcile an uncertain side effect
59
46
  with `POST` to that endpoint (`action: reconcile`, authoritative
60
47
  `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
48
+ run retains the legacy metadata endpoint
49
+ `POST /v1/agentic-loop/:id/continuation-ceilings` for compatibility. These fields
50
+ no longer impose local limits and changing them never resets usage or resumes work. Both routes require the original authorized
64
51
  account, organization and device throughout the request.
65
52
 
66
53
  ## Apple Silicon Mac app
@@ -186,7 +173,7 @@ tasks are not restarted. Missing or disabled plugins are not enabled automatical
186
173
  Failed refreshes retry on the next check without blocking daemon startup.
187
174
  The desktop Sessions page discovers existing provider conversations and lets users
188
175
  choose monitoring or supported session supervision with a goal, acceptance
189
- criteria, an autonomy policy, and finite budgets. Codex enrollment uses an exact
176
+ criteria and an autonomy policy. Local time, token and continuation counters do not stop work. Codex enrollment uses an exact
190
177
  app-server session and checks writer ownership before each continuation. Claude
191
178
  can continue through a verified live SIMY hook in its original process; sessions
192
179
  without that hook support monitoring only, and cold takeover remains unavailable. See [session supervision](docs/session-supervision.md).
@@ -259,8 +246,7 @@ chat message creates the remote audit ledger and starts the local Agentic Loop
259
246
  state machine; SIMY Web mirrors the lifecycle but is not required as the task
260
247
  entry point. The CLI also accepts backend-issued launch challenges for Web-led
261
248
  runs. It builds the requirement charter, runs Codex or Claude Code, audits
262
- structured completion evidence, and re-instructs the executor within the
263
- configured attempt budget.
249
+ structured completion evidence, and re-instructs the executor while verification shows useful progress.
264
250
 
265
251
  Before Web creates an Agentic Loop, it classifies whether the request actually
266
252
  needs a managed PR, verification, evidence, and merge lifecycle. A bounded
@@ -321,8 +307,8 @@ Local Tasks provide:
321
307
  - `GET /v1/local-tasks/<id>/stream` for live Server-Sent Events;
322
308
  - `POST /v1/local-tasks/<id>/control` with `{"action":"stop"}`;
323
309
  - `GET /v1/local-tasks/<id>/artifacts/<artifact-id>` for a verified output;
324
- - one Provider invocation, a configurable Provider-token safety budget, and a
325
- bounded runtime timeout;
310
+ - one Provider invocation with recorded token usage; local token, elapsed-time
311
+ and attempt counters do not interrupt execution, including restored tasks;
326
312
  - restart-safe terminal persistence. An interrupted active task fails closed
327
313
  after CLI restart and is never replayed automatically.
328
314
 
@@ -706,9 +692,9 @@ evaluated action and marks evidence pending when the code or runtime context
706
692
  changes. A non-green table is a checkpoint, not completion: the executor must
707
693
  perform feasible authorized repairs, missing verification and bounded waits,
708
694
  then re-observe and update the same criteria until the requested endpoint passes.
709
- A genuine safety/authority/dependency/budget stop remains explicitly unfinished
710
- with affected IDs and a concrete resumption condition; no automatic budget increase
711
- or background monitor is implied. Both executor and independent auditor receive
695
+ A genuine safety, authority, dependency or no-progress stop remains explicitly
696
+ unfinished with affected IDs and a concrete resumption condition. No background
697
+ monitor is implied. Both executor and independent auditor receive
712
698
  this continuation contract. The [acceptance evidence guide](src/orchestrator/delivery/references/acceptance-evidence.md)
713
699
  documents the offline validator shipped inside the CLI runtime. Its result establishes
714
700
  record completeness and artifact integrity, not overall delivery or deployment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.75",
3
+ "version": "0.5.76",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.75";
16
+ export const CLI_VERSION = "0.5.76";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -415,8 +415,6 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
415
415
  const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
416
416
  const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
417
417
  (appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
418
- if (config.maxTokens !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token budgets require the host-observed Codex app-server channel.');
419
- if (config.providerTokenCeiling !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token ceilings require the host-observed Codex app-server channel.');
420
418
  const managed = { taskId: randomUUID(), thread: { provider, id: session },
421
419
  actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
422
420
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
@@ -426,7 +424,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
426
424
  noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
427
425
  continuationPolicy: continuationMode(config.continuationPolicy),
428
426
  providerTokenCeiling: appServerChannel ? positive(config.providerTokenCeiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS) : null,
429
- budgetCapability: { time: true, continuations: true, providerTokens: appServerChannel },
427
+ budgetCapability: { time: false, continuations: false, providerTokens: appServerChannel, localExecutionLimits: false },
430
428
  autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
431
429
  autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
432
430
  capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
@@ -777,9 +775,11 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
777
775
  export function deliveryBudgetState(run, now = Date.now(), { checkProgress = true } = {}) {
778
776
  const managed = run.managed;
779
777
  const progress = managed?.continuationPolicy === 'progress';
780
- const deadline = progress ? run.hardDeadline ?? run.startedAt + 1440 * 60_000 : run.deadline;
781
- const maxContinuations = progress ? run.hardMaxContinuations ?? 50 : run.maxContinuations;
782
- const tokenCeiling = progress ? managed.providerTokenCeiling : managed?.maxTokens;
778
+ // Historical policies/counters remain immutable, but local elapsed time,
779
+ // turn count and token usage no longer limit either new or restored tasks.
780
+ const deadline = Infinity;
781
+ const maxContinuations = Infinity;
782
+ const tokenCeiling = null;
783
783
  const warnings = [];
784
784
  const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
785
785
  if (progress) {
@@ -788,22 +788,14 @@ export function deliveryBudgetState(run, now = Date.now(), { checkProgress = tru
788
788
  if (managed.maxTokens && managed.tokensUsed >= managed.maxTokens) warnings.push('soft_token_limit');
789
789
  }
790
790
  let reason = null;
791
- if (now >= deadline) {
792
- reason = progress ? 'Delivery hard time ceiling reached.' : `Delivery time limit exhausted.${checkpointDetail}`;
793
- } else if (run.continuations >= maxContinuations) {
794
- reason = progress ? 'Delivery hard continuation ceiling reached.' : `Delivery continuation limit exhausted (${run.continuations}/${maxContinuations}).${checkpointDetail}`;
795
- } else if (run.failures >= run.maxFailures) {
791
+ if (run.failures >= run.maxFailures) {
796
792
  reason = `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
797
- } else if (managed?.usageBlocked) {
798
- reason = `Reliable provider token usage is unavailable; budgeted continuation is blocked.${checkpointDetail}`;
799
- } else if (tokenCeiling && managed.tokensUsed >= tokenCeiling) {
800
- reason = progress ? 'Provider hard token ceiling reached.' : `Provider token budget exhausted (${managed.tokensUsed}/${tokenCeiling}).${checkpointDetail}`;
801
793
  } else if (checkProgress && managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) {
802
794
  reason = 'No verified acceptance progress within the configured time limit.';
803
795
  } else if (checkProgress && managed && managed.noProgress >= managed.maxNoProgress) {
804
796
  reason = 'No verified acceptance progress within the configured continuation limit.';
805
797
  }
806
- return { continuationPolicy: progress ? 'progress' : 'bounded', deadline, maxContinuations, tokenCeiling, warnings, reason };
798
+ return { continuationPolicy: progress ? 'progress' : 'bounded', localLimitsRemoved: true, deadline, maxContinuations, tokenCeiling, warnings, reason };
807
799
  }
808
800
  export function managedBudgetReason(run, now = Date.now(), options) {
809
801
  return deliveryBudgetState(run, now, options).reason;
@@ -866,7 +858,7 @@ export function decideStop(run, now = Date.now()) {
866
858
  } else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
867
859
  }
868
860
  const budgetReason = managedBudgetReason(next, now);
869
- if (budgetReason) return finish(next.managed?.continuationPolicy === 'progress' && budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
861
+ if (budgetReason) return finish(budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
870
862
  const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
871
863
  const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
872
864
  const understandingHook = next.managed?.capability === 'live_hook' && (next.managed.awaitingUnderstandingHook === true || next.managed.awaitingAssociationHook === true);
@@ -927,7 +919,7 @@ export function decideStop(run, now = Date.now()) {
927
919
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
928
920
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
929
921
  const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
930
- return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations}/${next.maxContinuations}). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Do not reset budgets or start a replacement loop.` } };
922
+ return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
931
923
  }
932
924
  // A process signature protects against PID reuse. Never treat a stale timer alone
933
925
  // as a crash; an idle provider may be waiting for its user.
@@ -1101,6 +1093,18 @@ export function canChangeDeliveryContinuation(run) {
1101
1093
  return Boolean(run?.managed && ['paused', 'blocked', 'failed', 'budget_exhausted', 'verification_pending', 'recovery_required'].includes(run.state) &&
1102
1094
  !['pending', 'pending_received', 'transferred'].includes(run.managed.handoff?.status));
1103
1095
  }
1096
+ function reopenLegacyLimitStop(run, now) {
1097
+ if (run?.state !== 'budget_exhausted' ||
1098
+ managedBudgetReason(run, now, { checkProgress: false }) ||
1099
+ /No verified|failure limit/i.test(run.reason || '') ||
1100
+ run.managed?.noProgress >= run.managed?.maxNoProgress) return;
1101
+ if (run.managed) {
1102
+ run.managed.localLimitMigration ??= { at: now, previousLastVerifiedProgressAt: run.managed.lastVerifiedProgressAt };
1103
+ // Time spent stopped at an obsolete cap is not failed execution progress.
1104
+ run.managed.lastVerifiedProgressAt = now;
1105
+ }
1106
+ run.state = 'paused'; run.reason = 'Local limits removed; resume this same task.';
1107
+ }
1104
1108
  export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, limits, questionId, waitId, answer, action, now = Date.now() }) {
1105
1109
  for (let retry = 0; ; retry += 1) {
1106
1110
  try { return await updateRecord(root, provider, session, record => {
@@ -1109,6 +1113,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1109
1113
  if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
1110
1114
  if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
1111
1115
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
1116
+ reopenLegacyLimitStop(run, now);
1112
1117
  if (action === 'extend' || action === 'continuation_policy') {
1113
1118
  if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation bounds.');
1114
1119
  if (action === 'continuation_policy') {
@@ -1297,10 +1302,11 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1297
1302
  // Paused runs have no reconciliation transition: budget enforcement excludes
1298
1303
  // them, process-exit checks only apply to active runs, and no notification
1299
1304
  // is generated. Resume/control still reread and validate the exact record.
1300
- if (!record.run || TERMINAL.has(record.run.state) || record.run.state === 'paused') { reconciled.push(record); continue; }
1305
+ if (!record.run || TERMINAL.has(record.run.state) && record.run.state !== 'budget_exhausted' || record.run.state === 'paused') { reconciled.push(record); continue; }
1301
1306
  const observed = record.process ? await inspectProcess(record.process.pid) : null;
1302
1307
  const next = await updateRecord(root, record.provider, record.session, async current => {
1303
- if (!current?.run || TERMINAL.has(current.run.state)) return current;
1308
+ reopenLegacyLimitStop(current?.run, now);
1309
+ if (!current?.run || TERMINAL.has(current.run.state) || current.run.state === 'paused') return current;
1304
1310
  const run = current.run;
1305
1311
  if (run.state === 'verification_pending' && run.managed) {
1306
1312
  await verifyAutomaticCriteria(current, now);
@@ -1308,12 +1314,12 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1308
1314
  run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
1309
1315
  }
1310
1316
  }
1311
- const activeTurn = run.managed?.continuationPolicy === 'progress' &&
1317
+ const activeTurn = run.managed &&
1312
1318
  (run.state === 'auto_resuming' || current.runtime?.isRunning === true || run.managed.executionAttempt?.phase === 'dispatching');
1313
1319
  const budgetState = deliveryBudgetState(run, now, { checkProgress: !activeTurn });
1314
1320
  run.budgetWarnings = budgetState.warnings;
1315
1321
  if (budgetState.reason && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) {
1316
- run.state = run.managed?.continuationPolicy === 'progress' && budgetState.reason.startsWith('No verified') ? 'paused' : 'budget_exhausted';
1322
+ run.state = budgetState.reason.startsWith('No verified') ? 'paused' : 'budget_exhausted';
1317
1323
  run.reason = budgetState.reason; run.updatedAt = now;
1318
1324
  }
1319
1325
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
@@ -24,8 +24,8 @@ For missing information use `blocker: "missing_information"`. For a failed attem
24
24
 
25
25
  For completed work use `{"status":"completed","summary":"Actual result","acceptanceMet":true,"evidence":["Exact checks and observed results"]}` only after checking the user's acceptance criteria. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
26
26
 
27
- 4. The Stop hook evaluates checkpoints and supplies the next instruction within the original budgets. Missing checkpoints consume the failure budget. A human interrupt stops the loop. Approval/input events block it. Provider errors/exits may require reopening the existing conversation; these hooks cannot restart a dead provider process.
28
- 5. After the user explicitly resolves the blocker and requests continuation, `simy delivery resume --provider <provider> --session <id>` retains all budgets. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
27
+ 4. The Stop hook evaluates checkpoints and supplies the next instruction within the original scope while progress is verified. Missing checkpoints count toward the repeated-failure circuit. A human interrupt stops the loop. Approval/input events block it. Provider errors/exits may require reopening the existing conversation; these hooks cannot restart a dead provider process.
28
+ 5. After the user explicitly resolves the blocker and requests continuation, `simy delivery resume --provider <provider> --session <id>` retains usage, failure and progress history; local time, turn and token counters do not stop work. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
29
29
 
30
30
  If the user selected **Continue this conversation** in SIMY desktop, the exact session already has a managed task. Do not run `delivery start` or ask the user to restate its goal. Follow the Stop hook's distinct `session_understanding` contract first: inspect only the original conversation context, report the original user goal and grounded progress, and submit the JSON checkpoint via `--file -`. Do not execute the next assessed work action during understanding. If context is materially unclear, report one focused question and wait for a matching `session_answer`; generic Resume is insufficient. A model-reported finished goal without an independent check is shown as `reported_done`, not verified `completed`.
31
31
 
@@ -41,6 +41,6 @@ File checks prove only the stated file predicate: `file_equals` compares complet
41
41
 
42
42
  Cold recovery is **off by default**. Only when the user explicitly authorizes exact-session Codex CLI recovery, add `"autoRecovery":true,"resumeChannel":"codex_exec","resumeModel":"<exact model>","autoRecoverySandbox":"read-only"` (or an explicitly authorized `workspace-write`). An exact Codex UUID and observed process identity are also required; otherwise recovery stays manual. The recovered process returns a structured final checkpoint that SIMY validates and persists. It must not edit SIMY state directly. Claude and ordinary SessionEnd remain manual paths. Token usage is not measured by these hooks.
43
43
 
44
- For an explicitly requested autonomous managed task, also enroll `"autonomyPolicy":"strict"|"balanced"|"autonomous"` and an `initialPlan` before work begins. Example initialPlan: `{"diagnosis":"Inspect input before repair","alternatives":[{"id":"inspect","action":{"kind":"read","scope":"repository","summary":"Inspect input.json"},"assessment":{"risk":"low","reversibility":"reversible","externalEffect":"none","blastRadius":"single_file","uncertainty":"low","inObjective":true,"userChoice":false},"expectedVerification":"Observe ready field","rollback":"No changes"}],"selectedOptionId":"inspect","recommendation":{"optionId":"inspect","reason":"Need evidence before editing"}}`. `initialAction` must exactly match the selected option and its scope must be enrolled in `approvalScopes`. For each subsequent action, report `actionResult:{decisionId,status:"observed"|"failed"|"not_run",summary,evidence}` for the current decision and a `plan` with diagnosis, one to five assessed alternatives, selectedOptionId, and recommendation for the **next** action. Never execute the next alternative in the current turn. Use only action kinds `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, `unknown`; use `local_edit` for writing local files. Missing assessment facts, external/destructive effects, user choice, or unapproved scope wait. A recommendation is advisory, not an authorization. Worker reports are not independent acceptance verification. If an automatic file criterion is missing after claimed completion, SIMY schedules bounded read-only diagnosis; manual criteria still require the user. Task policy can be changed with `simy delivery set_policy --provider ... --session ... --task-id ... --autonomy-policy strict|balanced|autonomous`, which preserves budgets and scopes. Provider permissions remain authoritative.
44
+ For an explicitly requested autonomous managed task, also enroll `"autonomyPolicy":"strict"|"balanced"|"autonomous"` and an `initialPlan` before work begins. Example initialPlan: `{"diagnosis":"Inspect input before repair","alternatives":[{"id":"inspect","action":{"kind":"read","scope":"repository","summary":"Inspect input.json"},"assessment":{"risk":"low","reversibility":"reversible","externalEffect":"none","blastRadius":"single_file","uncertainty":"low","inObjective":true,"userChoice":false},"expectedVerification":"Observe ready field","rollback":"No changes"}],"selectedOptionId":"inspect","recommendation":{"optionId":"inspect","reason":"Need evidence before editing"}}`. `initialAction` must exactly match the selected option and its scope must be enrolled in `approvalScopes`. For each subsequent action, report `actionResult:{decisionId,status:"observed"|"failed"|"not_run",summary,evidence}` for the current decision and a `plan` with diagnosis, one to five assessed alternatives, selectedOptionId, and recommendation for the **next** action. Never execute the next alternative in the current turn. Use only action kinds `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, `unknown`; use `local_edit` for writing local files. Missing assessment facts, external/destructive effects, user choice, or unapproved scope wait. A recommendation is advisory, not an authorization. Worker reports are not independent acceptance verification. If an automatic file criterion is missing after claimed completion, SIMY schedules bounded read-only diagnosis; manual criteria still require the user. Task policy can be changed with `simy delivery set_policy --provider ... --session ... --task-id ... --autonomy-policy strict|balanced|autonomous`, which preserves usage history, approvals and scopes. Provider permissions remain authoritative.
45
45
 
46
46
  In assessed mode, a blocked authorization checkpoint must also include an assessed `plan` whose selected action exactly matches `approval:{kind,scope,summary}` and a concrete `nextStep`. A missing-information blocker must include a specific `question` for the user. Do not turn a provider approval prompt into an automatic scheduling grant, even for a normally low-risk action. Resume only after the exact user decision; low-risk enrolled actions already allowed by the policy do not need an additional SIMY approval.
@@ -991,9 +991,8 @@ function buildRunDetailRows(run, width) {
991
991
  };
992
992
 
993
993
  const attemptNumber = run.snapshot.attempts.length + (run.child ? 1 : 0);
994
- const maxAttempts = run.snapshot.charter?.max_attempts;
995
994
  add(
996
- `${executorLabel(run.request.backend)} · Attempt ${attemptNumber}${maxAttempts ? `/${maxAttempts}` : ""} · PID ${run.child?.pid ?? "—"}`,
995
+ `${executorLabel(run.request.backend)} · Attempt ${attemptNumber} · PID ${run.child?.pid ?? "—"}`,
997
996
  { dimColor: true },
998
997
  );
999
998
  add(`${run.request.repository} · ${run.request.base_branch || run.snapshot.charter?.base_branch || "dev"}`, {
@@ -22,7 +22,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
22
22
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
23
23
  output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
24
24
  output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
25
- output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original budgets.');
25
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original task identity, usage history and approvals; local time, turn and token counters do not stop work.');
26
26
  return 0;
27
27
  }
28
28
  const options = {};
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canChangeDeliveryContinuation, canReplanDelivery, deliveryBudgetState, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, deliveryBudgetState, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -31,12 +31,13 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
31
31
  runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
33
  objective: r.run.objective, state: r.run.state, reason: r.run.reason,
34
- continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
34
+ continuations: r.run.continuations, maxContinuations: null, deadline: null,
35
+ localExecutionLimits: false,
35
36
  continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
36
37
  startedAt: r.run.startedAt,
37
38
  continuationLimits: deliveryBudgetState(r.run, now),
38
- canChangeContinuation: canChangeDeliveryContinuation(r.run),
39
- hardDeadline: r.run.hardDeadline || null, hardMaxContinuations: r.run.hardMaxContinuations || null,
39
+ canChangeContinuation: false,
40
+ hardDeadline: null, hardMaxContinuations: null,
40
41
  budgetWarnings: deliveryBudgetState(r.run, now).warnings,
41
42
  budgetCapability: r.run.managed?.budgetCapability || null,
42
43
  outcomeProgress: r.run.managed?.outcomeProgress || null,
@@ -29,7 +29,9 @@ function watchLease(root, provider, session, inspect, onFailure) {
29
29
  if (!stopped) inspect(record);
30
30
  }).catch(() => { if (!stopped) onFailure(); }).finally(() => { reading = false; });
31
31
  }, 500);
32
- timer.unref();
32
+ // An owned turn must stay alive until it settles or the lease cancels it.
33
+ // This watch is cleared in the execution's finally block; unlike the removed
34
+ // budget deadline, it does not impose a time limit on active work.
33
35
  return () => { stopped = true; clearInterval(timer); };
34
36
  }
35
37
  async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
@@ -79,7 +81,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
79
81
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
80
82
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
81
83
  budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
82
- softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
84
+ localExecutionLimits: false } };
83
85
  const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
84
86
  const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
85
87
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
@@ -87,7 +89,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
87
89
  if (currentTime() >= managedDispatchDeadline(run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
88
90
  await beforeDispatch();
89
91
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
90
- if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
92
+
91
93
  return new Promise((resolve, reject) => {
92
94
  const child = spawn(binary, args, { cwd: record.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
93
95
  env: { ...process.env, SIMY_HOME: dirname(root) } });
@@ -196,7 +198,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
196
198
  if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
197
199
  const budgetReason = managedBudgetReason(live, currentTime());
198
200
  if (budgetReason) {
199
- live.state = 'budget_exhausted'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
201
+ live.state = budgetReason.startsWith('No verified') ? 'paused' : 'failed'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
200
202
  }
201
203
  live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
202
204
  owner: { pid: process.pid, identity: ownerIdentity } };
@@ -216,13 +218,11 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
216
218
  let resource;
217
219
  let lastActivityAt = currentTime();
218
220
  let usage = null;
219
- let tokenBudgetExceeded = false;
220
221
  let dispatched = false, waitingForTool = false;
221
222
  const controller = new AbortController();
222
223
  const abort = () => controller.abort();
223
224
  if (signal?.aborted) controller.abort();
224
225
  else signal?.addEventListener('abort', abort, { once: true });
225
- const timeout = setTimeout(abort, Math.max(1, managedDispatchDeadline(claimed.run) - currentTime()));
226
226
  const stopWatch = watchLease(root, record.provider, record.session, current => {
227
227
  if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
228
228
  managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
@@ -264,10 +264,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
264
264
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
265
265
  onUsage: observed => {
266
266
  usage = observed;
267
- const tokenCeiling = deliveryBudgetState(claimed.run).tokenCeiling;
268
- if (tokenCeiling && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= tokenCeiling) {
269
- tokenBudgetExceeded = true; controller.abort();
270
- }
267
+
271
268
  },
272
269
  beforeDispatch: async providerSandbox => {
273
270
  await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
@@ -299,7 +296,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
299
296
  ? { code: -3, busy: true, accessDenied: true }
300
297
  : { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
301
298
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
302
- finally { clearTimeout(timeout); stopWatch(); signal?.removeEventListener('abort', abort); }
299
+ finally { stopWatch(); signal?.removeEventListener('abort', abort); }
303
300
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
304
301
  if (deliveryBudgetState(claimed.run).tokenCeiling && !usage && !result.busy && dispatched) {
305
302
  result = { ...result, usageUnavailable: true };
@@ -330,6 +327,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
330
327
  const live = current?.run;
331
328
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
332
329
  delete live.managed.resumeLease;
330
+ delete live.managed.usageBlocked;
333
331
  if (usage) {
334
332
  live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
335
333
  live.managed.usageAvailable = true;
@@ -360,14 +358,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
360
358
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
361
359
  }
362
360
  live.budgetWarnings = deliveryBudgetState(live).warnings;
363
- const tokenCeiling = deliveryBudgetState(live).tokenCeiling;
364
- if (tokenBudgetExceeded || tokenCeiling && live.managed.tokensUsed >= tokenCeiling) {
365
- live.state = 'budget_exhausted'; live.managed.autoRecovery = false;
366
- live.reason = 'Observed provider token budget exhausted; inspect any interrupted action in the original conversation.';
367
- } else if (live.managed.usageBlocked) {
368
- live.state = 'recovery_required'; live.managed.autoRecovery = false;
369
- live.reason = 'Provider usage was unavailable; token-budgeted continuation cannot safely start another turn.';
370
- } else if (result.busy) {
361
+ if (result.busy) {
371
362
  if (result.accessDenied && dispatched) {
372
363
  // The final guard rejected before the provider call. Keep its one-shot approval unused.
373
364
  if (live.managed.approvedAction?.id === live.managed.decision?.id) delete live.managed.approvedAction.dispatchedAt;
package/src/local-task.js CHANGED
@@ -568,7 +568,6 @@ export async function startLocalTask(
568
568
 
569
569
  return new Promise((resolve, reject) => {
570
570
  let settled = false;
571
- let timeout = null;
572
571
  const rawStdout = [];
573
572
  const tokenRecords = [];
574
573
  let child;
@@ -617,21 +616,12 @@ export async function startLocalTask(
617
616
  continuation_policy: task.continuation_policy || "bounded",
618
617
  provider_token_ceiling: localTaskTokenCeiling(task),
619
618
  });
620
- if (task.continuation_policy === "progress" && task.provider_tokens_used >= task.provider_token_budget && !task.token_warning_emitted) {
619
+ if (task.provider_tokens_used >= task.provider_token_budget && !task.token_warning_emitted) {
621
620
  task.token_warning_emitted = true;
622
- task.warnings.push("Provider token notification threshold reached. Work continues within the approved task and hard safety ceiling.");
621
+ task.warnings.push("Provider token notification threshold reached. Work continues within the approved task; local usage does not stop execution.");
623
622
  emitTaskEvent(task, "budget_warning", { provider_tokens_used: task.provider_tokens_used, provider_token_budget: task.provider_token_budget, provider_token_ceiling: localTaskTokenCeiling(task) });
624
623
  }
625
- if (
626
- localTaskTokensExhausted(task) &&
627
- !task.stop_requested
628
- ) {
629
- task.reason_code = "provider_token_budget_exhausted";
630
- task.error = `Provider token ${task.continuation_policy === "progress" ? "hard ceiling" : "budget"} exceeded (${task.provider_tokens_used} / ${localTaskTokenCeiling(task)}).`;
631
- task.stop_requested = true;
632
- task.control_state = "stopping";
633
- signalExecutorProcess(child, "SIGINT");
634
- }
624
+
635
625
  };
636
626
  const stdoutDecoder = createProviderStreamDecoder({
637
627
  backend: task.backend,
@@ -656,28 +646,12 @@ export async function startLocalTask(
656
646
  });
657
647
  child.stderr?.on("data", (chunk) => stderrDecoder.push(chunk.toString("utf8")));
658
648
 
659
- timeout = setTimeout(() => {
660
- if (settled || task.stop_requested) return;
661
- task.reason_code = "provider_timeout";
662
- task.error = `Local Task exceeded its ${task.timeout_ms}ms timeout.`;
663
- task.stop_requested = true;
664
- task.control_state = "stopping";
665
- emitTaskEvent(task, "status", {
666
- state: "stopping",
667
- reason_code: task.reason_code,
668
- message: "The Local Task timed out and is stopping.",
669
- });
670
- signalExecutorProcess(child, "SIGINT");
671
- setTimeout(() => {
672
- if (!settled && task.child === child) signalExecutorProcess(child, "SIGTERM");
673
- }, 1_500).unref?.();
674
- }, task.timeout_ms);
675
- timeout.unref?.();
649
+ // timeout_ms is retained as legacy request metadata. Elapsed local execution
650
+ // no longer cancels a task; explicit user cancellation remains authoritative.
676
651
 
677
652
  const finish = async ({ exitCode = null, error = null } = {}) => {
678
653
  if (settled) return;
679
654
  settled = true;
680
- if (timeout) clearTimeout(timeout);
681
655
  stdoutDecoder.flush();
682
656
  stderrDecoder.flush();
683
657
  task.child = null;
@@ -740,8 +714,7 @@ export async function startLocalTask(
740
714
  const failed =
741
715
  Boolean(error) ||
742
716
  exitCode !== 0 ||
743
- Boolean(task.error) ||
744
- localTaskTokensExhausted(task);
717
+ Boolean(task.error);
745
718
  task.status = manuallyStopped ? "stopped" : failed ? "failed" : "succeeded";
746
719
  task.control_state = task.status === "stopped" ? "stopped" : "complete";
747
720
  task.error ||= error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
@@ -1299,13 +1272,8 @@ function normalizeExpectedOutputs(value) {
1299
1272
  }));
1300
1273
  }
1301
1274
 
1302
- function localTaskTokenCeiling(task) {
1303
- return task.continuation_policy === "progress" ? normalizeTokenBudget(task.provider_token_ceiling ?? MAX_LOCAL_TASK_TOKEN_BUDGET) : task.provider_token_budget;
1304
- }
1305
-
1306
- function localTaskTokensExhausted(task) {
1307
- const ceiling = localTaskTokenCeiling(task);
1308
- return task.continuation_policy === "progress" ? task.provider_tokens_used >= ceiling : task.provider_tokens_used > ceiling;
1275
+ function localTaskTokenCeiling() {
1276
+ return null;
1309
1277
  }
1310
1278
 
1311
1279
  function normalizeTokenBudget(value) {
@@ -67,7 +67,7 @@ export function extendProgressCeilings(snapshot, changes = {}) {
67
67
  const provided = Object.entries(changes).filter(([, value]) => value !== undefined);
68
68
  if (!provided.length) throw new Error("Explicitly increase at least one hard ceiling.");
69
69
  for (const [field, value] of provided) {
70
- const previous = field === "provider_token_ceiling" ? current.hard_token_ceiling : current.hard_attempt_ceiling;
70
+ const previous = field === "provider_token_ceiling" ? snapshot.charter.provider_token_ceiling : snapshot.charter.attempt_ceiling;
71
71
  const used = field === "provider_token_ceiling" ? current.tokens_used : (snapshot.attempts || []).length;
72
72
  if (!Number.isSafeInteger(value) || value <= previous || value <= used) {
73
73
  throw new Error("Each requested hard ceiling must be an integer higher than its current limit and recorded usage.");
@@ -97,11 +97,12 @@ export function budgetState(snapshot, pendingAttempt = null) {
97
97
  const attemptsUsed = attempts.length;
98
98
  const retriesUsed = Math.max(attemptsUsed - 1, 0);
99
99
  const policy = progressLimits(snapshot?.charter);
100
- const progress = policy.continuation_policy === "progress";
101
- const hardTokenCeiling = progress ? policy.provider_token_ceiling : tokenBudget;
102
- const hardAttemptCeiling = progress ? policy.attempt_ceiling : retryBudget + 1;
103
- const tokenExhausted = tokensUsed >= hardTokenCeiling;
104
- const retryExhausted = attemptsUsed >= hardAttemptCeiling;
100
+ // Persisted thresholds remain readable for old contracts and usage reports.
101
+ // Local work stops on failed progress/approval, never on consumption counters.
102
+ const hardTokenCeiling = null;
103
+ const hardAttemptCeiling = null;
104
+ const tokenExhausted = false;
105
+ const retryExhausted = false;
105
106
  return {
106
107
  schema_version: 1,
107
108
  token_budget: tokenBudget,
@@ -119,11 +120,12 @@ export function budgetState(snapshot, pendingAttempt = null) {
119
120
  ? "retry_budget_exhausted"
120
121
  : null,
121
122
  continuation_policy: policy.continuation_policy,
122
- token_warning: progress && tokensUsed >= tokenBudget,
123
- retry_warning: progress && attemptsUsed >= retryBudget + 1,
123
+ local_limits_removed: true,
124
+ token_warning: tokensUsed >= tokenBudget,
125
+ retry_warning: attemptsUsed >= retryBudget + 1,
124
126
  hard_token_ceiling: hardTokenCeiling,
125
127
  hard_attempt_ceiling: hardAttemptCeiling,
126
- hard_tokens_remaining: Math.max(hardTokenCeiling - tokensUsed, 0),
128
+ hard_tokens_remaining: null,
127
129
  };
128
130
  }
129
131
 
@@ -144,19 +146,12 @@ export function refreshBudgetState(snapshot, pendingAttempt = null) {
144
146
  return state;
145
147
  }
146
148
 
147
- export function canStartProvider(snapshot, { attemptNumber, phase }) {
148
- const state = refreshBudgetState(snapshot);
149
- if (state.token_exhausted) {
150
- return { allowed: false, reason: "token_budget_exhausted", budget: state };
151
- }
152
- if (phase === "executor" && attemptNumber > state.hard_attempt_ceiling) {
153
- return { allowed: false, reason: "retry_budget_exhausted", budget: state };
154
- }
155
- return { allowed: true, reason: null, budget: state };
149
+ export function canStartProvider(snapshot) {
150
+ return { allowed: true, reason: null, budget: refreshBudgetState(snapshot) };
156
151
  }
157
152
 
158
- export function codingAttemptCeiling(snapshot) {
159
- return snapshot?.charter?.continuation_policy === "progress" ? budgetState(snapshot).hard_attempt_ceiling : snapshot?.charter?.max_attempts;
153
+ export function codingAttemptCeiling() {
154
+ return Infinity;
160
155
  }
161
156
 
162
157
  export function tokenUsageTotal(value) {
@@ -10,10 +10,10 @@ export function buildChecklistContinuationInstruction({ auditor = false } = {})
10
10
  `The table is a progress checkpoint, not the deliverable or a stopping condition. Read: ${ACCEPTANCE_EVIDENCE_PATH}`,
11
11
  "Maintain the complete 依頼チェックリスト with columns #, ご依頼, 現状, 判定 in the evidence record after chartering, each evaluated attempt, and in the final receipt. User-facing reporting follows workflow_reporting_contract; show the full table only when requested or when it is the most useful requested answer, never as mandatory commentary. Preserve stable criterion IDs, all rows and both applicable evidence layers. ✅ requires current valid passing evidence for every applicable layer; ❌ means an observed failure; 🟡 means pending, missing, stale, blocked or unverifiable evidence. Retain counts and highest proven state in that record.",
12
12
  "After observing any non-green row, classify its cause and immediately take the next safe authorized action: implement missing behavior, repair a failure, exercise an untested path, collect missing evidence, or rerun stale verification. Re-observe the same criterion against the current candidate, run the validator, update the table, and repeat until every applicable requested outcome and gate for the authorized endpoint is verified. A next-action list without performing feasible actions is not completion. Never require another continue request merely because a table was shown.",
13
- "For CI or other in-progress dependencies, use the available bounded wait/recheck or approved monitoring mechanism and maintain progress communication. Unchanged external state is not itself a blocker. Do not start duplicate jobs or busy-poll. Recheck current revision/results, repair authorized failures, and keep waiting only within the existing time, attempt and cost limits. If waiting cannot continue, report the concrete limit and explicit resumption condition; do not silently abandon it or promise an unscheduled follow-up.",
14
- "If a row genuinely requires new authority, credentials, a user/product decision, an unavailable dependency or an exhausted safety budget, retain it as non-green and record affected IDs, evidence of the blocker, safe alternatives attempted, exact missing input, owner and resumption trigger. Continue independent in-scope work. Stop with blocked, best_available, failed or cancelled as appropriate, explicitly unfinished, not verified_complete. Never raise budgets, bypass a stop, or expand authority to force green.",
13
+ "For CI or other in-progress dependencies, use the available bounded wait/recheck or approved monitoring mechanism and maintain progress communication. Unchanged external state is not itself a blocker. Do not start duplicate jobs or busy-poll. Recheck current revision/results, repair authorized failures, and keep waiting only within the authorized scope and external service availability. If waiting cannot continue, report the concrete limit and explicit resumption condition; do not silently abandon it or promise an unscheduled follow-up.",
14
+ "If a row genuinely requires new authority, credentials, a user/product decision, an unavailable dependency or a repeated-failure or no-progress safety circuit, retain it as non-green and record affected IDs, evidence of the blocker, safe alternatives attempted, exact missing input, owner and resumption trigger. Continue independent in-scope work. Stop with blocked, best_available, failed or cancelled as appropriate, explicitly unfinished, not verified_complete. Never bypass a manual stop, approval, or expand authority to force green.",
15
15
  "Do not end successfully while a safe authorized repair, verification or bounded wait remains. All-green applies to the frozen authorized endpoint: PR-only work does not authorize merge/deploy, but a PR URL, an improvement PR, an issue, a narrower test or a future plan does not satisfy an unmet outcome. Never drop/merge away rows, weaken criteria, relabel applicable checks as not_applicable, recolor unknowns, or copy new digests onto old evidence to force green. Do not change the requested endpoint to match the work already completed.",
16
- "Before the final response, independently reconcile the original request with all criterion IDs and current evidence, check remaining endpoint gates, and preserve the full table and explicit disposition in the evidence record. Distinguish a partial artifact from verified completion and disclose material remaining outcomes in the requested response format; keep yellow/red counts in the record. A pr_ready_for_review or blocked controller state is not proof of overall completion. These instructions strengthen continuation and reporting; they do not add a background scheduler or remove controller limits.",
16
+ "Before the final response, independently reconcile the original request with all criterion IDs and current evidence, check remaining endpoint gates, and preserve the full table and explicit disposition in the evidence record. Distinguish a partial artifact from verified completion and disclose material remaining outcomes in the requested response format; keep yellow/red counts in the record. A pr_ready_for_review or blocked controller state is not proof of overall completion. These instructions strengthen continuation and reporting; they do not add a background scheduler or remove approval, manual-stop and progress protections.",
17
17
  auditor
18
18
  ? "Remain read-only. Check that the executor performed feasible follow-up actions after non-green tables. Report premature stopping, omitted rows, unsupported green claims, or a blocker without evidence as findings, alongside exact criterion IDs and the necessary next action. Do not approve from a table's colors or the executor's completion wording."
19
19
  : "Execute the continuation within this attempt instead of merely returning a yellow table or delegating feasible remaining work to the requester. Return control honestly at a genuine boundary; never fabricate completion evidence.",
@@ -119,8 +119,9 @@ Before material action, record:
119
119
  - exclusions and authority boundaries, especially commit, push, Draft PR
120
120
  creation or update, Ready transitions, merge, deployment, production writes,
121
121
  paid APIs, external messages, and fixture cleanup;
122
- - attempt, time, and cost budgets. Default to at most three evaluated repair
123
- attempts when the user has not set a budget.
122
+ - recorded usage and any external service limits. Local execution has no token,
123
+ elapsed-time or attempt ceiling, including restored tasks. Continue while
124
+ making verified progress; respect approvals, manual stops and failure circuits.
124
125
 
125
126
  Do not silently weaken explicit conditions. Add discoveries as a charter patch.
126
127
  A scope or success-condition change needs user direction when it materially
@@ -194,7 +195,7 @@ JSON authority or evidence. Finish the visible table with counts for green,
194
195
  yellow, and red rows plus the highest proven state. In a terminal
195
196
  receipt, add the controller's disposition immediately below it.
196
197
  Treat making every applicable row green as the repair target within the frozen
197
- scope and attempt budget, but never recolor, omit, merge away, or relax a row to
198
+ scope and permissions, but never recolor, omit, merge away, or relax a row to
198
199
  make the presentation green.
199
200
 
200
201
  ### Continue after the table; do not stop at yellow
@@ -212,19 +213,19 @@ not completion; do not wait for the requester to say “continue” after each t
212
213
 
213
214
  For in-progress CI or dependencies, use available bounded wait/recheck or an
214
215
  approved monitor, with progress updates and no duplicate jobs or busy-polling.
215
- Unchanged external state alone is not a blocker. Honor existing time, attempt and
216
- cost limits; record a reached limit and resumption condition instead of promising
216
+ Unchanged external state alone is not a blocker. Local token, elapsed-time and attempt counters do not stop work. Respect
217
+ external service availability and record any actual blocker and resumption condition instead of promising
217
218
  an unscheduled follow-up. This workflow does not itself install a scheduler.
218
219
 
219
- When genuinely blocked by authority, access, a decision, dependency or safety
220
- budget, preserve each affected ID, observed blocker, attempted safe alternatives,
220
+ When genuinely blocked by authority, access, a decision, dependency or triggered no-progress/repeated-failure
221
+ circuit, preserve each affected ID, observed blocker, attempted safe alternatives,
221
222
  exact missing input, owner and resumption trigger. Continue independent authorized
222
223
  work. Any terminal non-green table is explicitly unfinished with a justified
223
224
  blocked/best_available/failed/cancelled disposition, never verified_complete.
224
225
  Do not use best_available merely because a table, PR or plan now exists. A new
225
226
  remediation PR transfers ownership, not acceptance; the original unmet row stays
226
227
  non-green. All-green targets only the authorized endpoint, not unrequested merge
227
- or deployment. Never weaken criteria or raise budgets to force green.
228
+ or deployment. Never weaken acceptance criteria to force green. Local token, time and attempt counters do not stop work; continue while verified progress improves.
228
229
 
229
230
  Before the final response, reconcile original requirements, all IDs, current
230
231
  evidence and endpoint gates; retain the full table, counts, highest proven state
@@ -454,7 +455,7 @@ For a Codex Desktop task whose requested outcome must be checked after the
454
455
  conversation ends, register a Codex app heartbeat against that task before
455
456
  claiming it is monitored. Preserve the original outcome, acceptance evidence,
456
457
  the exact task or operation identity, the requested completion boundary, and
457
- the response preferences, cadence, time/attempt/cost limits, and stop or
458
+ the response preferences, cadence, progress protections and external service limits, and stop or
458
459
  ask-user conditions in the heartbeat prompt. Verify that the automation
459
460
  is active and bound to the intended task. On each run, use the Codex app's
460
461
  supported task status and wait/read operations to distinguish a queued creation
@@ -464,7 +465,7 @@ operation before any retry; do not create a replacement while its fate is
464
465
  unknown. Report an actionable blocker if identity, access, or evidence cannot
465
466
  be resolved. Stay quiet on unchanged non-actionable state, and stop the
466
467
  heartbeat when the accepted outcome is proved or monitoring is explicitly
467
- cancelled. On authoritative failure, exhausted limits, or an unresolved
468
+ cancelled. On authoritative failure, external service limits or repeated failures, or an unresolved
468
469
  terminal identity/access blocker, report the evidence and pause or stop the
469
470
  heartbeat pending a human decision. This is a separately registered,
470
471
  task-scoped Codex app monitor;
@@ -495,7 +496,7 @@ For each attempt:
495
496
  6. **Reduce:** append a typed receipt and atomically advance the snapshot.
496
497
 
497
498
  An LLM may propose hypotheses, repairs, or verdicts, but it must not directly
498
- override state transitions, retry budgets, authorization, or evidence gates.
499
+ override state transitions, progress protections, authorization, or evidence gates.
499
500
  Only the controller promotes a candidate or terminalizes the run.
500
501
 
501
502
  Read [state and receipts](references/state-and-receipts.md) when the task has
@@ -712,13 +713,13 @@ action planning belongs to a separate workflow.
712
713
  Use exactly one disposition:
713
714
 
714
715
  - `verified_complete`: every applicable gate passed with evidence;
715
- - `best_available`: the bounded loop produced a safe useful result but named
716
+ - `best_available`: the loop produced a safe useful result but named
716
717
  criteria remain unmet;
717
718
  - `blocked`: missing authority, input, dependency, or safety precondition
718
719
  prevents progress;
719
- - `failed`: no safe useful candidate remains within budget;
720
+ - `failed`: no safe useful candidate can make further verified progress;
720
721
  - `cancelled`: the user stopped or replaced the task.
721
722
 
722
- Stop after the attempt budget, two consecutive non-improving candidates, or an
723
+ Stop after two consecutive non-improving candidates or an
723
724
  A-B-A failure pattern without a materially different safe repair. Deliver the
724
725
  best verified candidate, not merely the latest attempt.
@@ -80,16 +80,16 @@ After every evaluated action (test, repair, review or changed inputs):
80
80
 
81
81
  Yellow and red tables are progress, not successful delivery. For in-progress
82
82
  dependencies use available bounded waits/rechecks or approved monitoring without
83
- busy-polling or duplicate jobs. Preserve time/attempt/cost limits and keep the
83
+ busy-polling or duplicate jobs. Preserve authorization and progress checks; local time/attempt/token counters are informational and keep the
84
84
  requester informed. If progress genuinely requires new authority, access, a
85
- decision, dependency availability or budget, retain affected rows and record
85
+ decision, dependency availability or a triggered no-progress/failure circuit, retain affected rows and record
86
86
  observed blocker, safe alternatives tried, exact missing input, owner and resume
87
87
  trigger. Continue independent work; end only with an explicitly unfinished
88
88
  disposition when no further safe in-scope action remains or a safety/user stop
89
89
  applies. Do not require another “continue” for already authorized feasible work.
90
90
  An issue or remediation PR does not satisfy the row it tracks. Do not weaken
91
- criteria, hide rows, invent evidence, change the endpoint or raise budgets to
92
- force green. Reconcile the final full table against the original request and all
91
+ criteria, hide rows, invent evidence or change the endpoint to
92
+ force green. Local execution counters do not impose a completion limit. Reconcile the final full table against the original request and all
93
93
  current endpoint gates before reporting verified_complete.
94
94
 
95
95
  All rows, including pending/failed ones, stay visible. A green matrix cannot advance
@@ -101,7 +101,7 @@ Codex may orchestrate the full sequence in one task:
101
101
  queue/worker activity, and artifact hashes.
102
102
  7. Evaluate the evidence against the frozen charter.
103
103
  8. On failure, classify the boundary, add a pre-fix regression, patch the
104
- smallest surface, rebuild changed images, and repeat within budget.
104
+ smallest surface, rebuild changed images, and repeat within the authorized scope while verification improves.
105
105
 
106
106
  The same Codex task may perform actor and verifier phases, but the verifier must
107
107
  use recorded observable evidence rather than accepting the actor's completion
@@ -14,7 +14,7 @@ registration. A scheduler firing is an observation, not delivery progress.
14
14
  | Pending creation remains unresolved after the provider's own timeout, or two scheduled observations when no timeout is available | The original operation still has no authoritative resource mapping. | Report unresolved identity and the owner; keep the original operation for recovery. Do not create another task on an absent list entry. |
15
15
  | A previously documented external wait changes or reaches its deadline | Recheck the exact dependency and its owner/resumption condition. | Resume only when the dependency is cleared and the original authority permits it; otherwise report a material blocker once. Unchanged waiting is quiet. |
16
16
  | Task reaches a terminal turn or a claimed result | Check the result and original acceptance evidence independently. | Continue authorized unmet work, report a concrete blocker, or stop monitoring only after the accepted outcome is proven. |
17
- | Monitor or authority limit reached | Check scheduler status, task identity, retry/time/cost budget, and any stop request. | Pause or stop and report what remains. The monitor cannot detect its own missed scheduler runs without another external observer. |
17
+ | Monitor or authority limit reached | Check scheduler status, task identity, no-progress/failure state, observation allowance, and any stop request. Local task usage counters do not impose execution ceilings. | Pause or stop and report what remains. The monitor cannot detect its own missed scheduler runs without another external observer. |
18
18
 
19
19
  For an hourly heartbeat, the first confirmed idle observation is actionable;
20
20
  the active/no-progress comparison needs two consecutive hourly snapshots.
@@ -125,7 +125,7 @@ execution remains subject to existing runner/host ownership controls.
125
125
  PR recheck clears prior completion flags and reruns the floor. Missing evidence
126
126
  never promotes an old successful audit. The old history is retained; do not rewrite
127
127
  it as verified under the new floor. Fix the named criterion, collect fresh evidence
128
- and resume within the retry budget. Reconcile ambiguous external side effects
128
+ and resume with the same task identity and progress checks. Reconcile ambiguous external side effects
129
129
  before retrying. The minimum pass is a prerequisite for existing CI/review/authority
130
130
  gates, not merge, deployment or a complete production delivery.
131
131
 
@@ -39,14 +39,10 @@ export function buildCodingInstruction(
39
39
  `Risk level: ${charter.risk.level}`,
40
40
  `Design summary: ${charter.design_review.summary || "not required"}`,
41
41
  `Design approved by: ${charter.design_review.approved_by || "not required"}`,
42
- `Attempt: ${attemptNumber}/${budget?.hard_attempt_ceiling ?? charter.max_attempts}`,
43
- `Retry budget: ${Math.max(attemptNumber - 1, 0)}/${charter.retry_budget}`,
44
- `Token budget remaining: ${budget?.tokens_remaining ?? charter.token_budget}/${charter.token_budget}`,
45
- ...(charter.continuation_policy === "progress" ? [
46
- "Continuation policy: verified acceptance progress. Token/retry thresholds notify; they do not relax acceptance or authorize new work.",
47
- `Hard Provider token ceiling: ${budget?.hard_token_ceiling}; remaining: ${budget?.hard_tokens_remaining}. Hard attempt ceiling: ${budget?.hard_attempt_ceiling}.`,
48
- "Report human/external blockers as soon as observed; continue independent work within the approved objective. Changed metadata alone is not progress.",
49
- ] : []),
42
+ `Attempt: ${attemptNumber}`,
43
+ `Provider tokens recorded: ${budget?.tokens_used ?? 0}`,
44
+ "Local execution has no token, elapsed-time or attempt cap, including restored tasks. Continue while verified acceptance improves; stop for unresolved approval, missing input, repeated identical failure or absent progress.",
45
+ "Report human/external blockers as soon as observed; continue independent work within the approved objective. Changed metadata alone is not progress.",
50
46
  `Local evidence root: ${charter.ui_evidence_root || "not required"}`,
51
47
  ].join("\n"),
52
48
  ),
@@ -77,8 +77,7 @@ async function runCodingLoopOwned({
77
77
 
78
78
  let previousFindings = resume ? snapshot.final_audit?.findings || [] : [];
79
79
  const firstAttempt = resume ? snapshot.attempts.length + 1 : 1;
80
- const progressPolicy = snapshot.charter.continuation_policy === "progress";
81
- const lastAttempt = progressPolicy ? codingAttemptCeiling(snapshot) : resume ? firstAttempt : snapshot.charter.max_attempts;
80
+ const lastAttempt = codingAttemptCeiling(snapshot);
82
81
  for (
83
82
  let attemptNumber = firstAttempt;
84
83
  attemptNumber <= lastAttempt;
@@ -133,8 +133,8 @@ export function assessOutcomeProgress(charter, attempt, previousAttempt = null,
133
133
  previous: previousAttempt?.outcome_progress || null,
134
134
  maxNoProgressAttempts: options.maxNoProgressAttempts ?? 3,
135
135
  });
136
- // Existing runs keep their original stopping policy until the controller opts
137
- // into progress continuation. The richer measurement is persisted in both.
136
+ // Legacy runs retain their stricter independently assessed two-candidate
137
+ // circuit, plus the shared no-progress circuit when verification is missing.
138
138
  if (options.policy !== "progress") {
139
139
  const criteria = acceptanceCriterionRows(charter);
140
140
  const rows = Array.isArray(assessment?.criterion_results) ? assessment.criterion_results : [];
@@ -143,7 +143,7 @@ export function assessOutcomeProgress(charter, attempt, previousAttempt = null,
143
143
  rows.filter((row) => row.criterion_id === criterion.id).length === 1);
144
144
  const previous = previousAttempt?.outcome_progress;
145
145
  result.status = completeAssessment ? "assessed" : "insufficient_evidence";
146
- result.stalled = Boolean(completeAssessment && previous?.status === "assessed" &&
146
+ result.stalled = (!completeAssessment && result.stalled) || Boolean(completeAssessment && previous?.status === "assessed" &&
147
147
  previous.objective_digest === result.objective_digest && result.unproven_ids.length > 0 &&
148
148
  result.assessed_ids.every((id) => previous.assessed_ids.includes(id)) &&
149
149
  result.unproven_ids.every((id) => previous.unproven_ids.includes(id)));
@@ -13,7 +13,7 @@ export function buildPrClosureInstruction({ auditor = false } = {}) {
13
13
  `Before a closure decision, read the packaged Web/CLI procedure: ${PR_CLOSURE_GUIDE_PATH}`,
14
14
  "Freeze the original outcome criteria and exact old head/base, current target base, successor heads, integrated candidate and deployed identity. Preserve intended, observed and unknown behavior separately.",
15
15
  "Persist a criterion-ID before/after table: old PR, current base alone, integrated successors and deployed target. Account for all changed files and unintended added behavior; file counts, ancestry and green CI are not UX equivalence.",
16
- "For every unresolved yellow/red row, create a new remediation PR within explicit implementation/commit/push/PR authority, grouping only coherent changes. Reuse an exact existing open remediation PR rather than duplicating it. Map every criterion ID to a real PR URL, owner and acceptance tests; a broad successor link or uncreated PR plan is insufficient for transfer_ready. Missing authority, unresolved product decisions or budget blocks remain explicit blockers. PR creation alone never makes a row green.",
16
+ "For every unresolved yellow/red row, create a new remediation PR within explicit implementation/commit/push/PR authority, grouping only coherent changes. Reuse an exact existing open remediation PR rather than duplicating it. Map every criterion ID to a real PR URL, owner and acceptance tests; a broad successor link or uncreated PR plan is insufficient for transfer_ready. Missing authority, unresolved product decisions or repeated-failure/no-progress blocks remain explicit blockers. PR creation alone never makes a row green.",
17
17
  "Verify normal production reachability and the complete trigger-to-usable-result path, with independent technical postconditions and recovery. Code surviving only in fixtures is not inherited UX. Distinguish reuse, composition, conversational design, immediate ad-hoc work and background learning, including the no-workflow/no-match fallback.",
18
18
  "Unknown, failed or stale applicable evidence blocks close_ready. Require independent review of original requirements and raw evidence before an authorized close. Preserve initial failures; tests changed to pass do not validate the unchanged original candidate.",
19
19
  "Unfinished successors require transfer_ready with explicit informed administrative-close authorization, accepted owner, open tracking and remaining gates; never claim full inheritance. Intentional removal requires an explicit retirement decision and safe data/migration handling.",
@@ -12,7 +12,6 @@ export function summarizeCodingLoopEvent(snapshot, event) {
12
12
 
13
13
  const charter = objectValue(snapshot?.charter);
14
14
  const attemptNumber = numberValue(detail.attempt_number) || latestAttemptNumber(snapshot);
15
- const maxAttempts = numberValue(charter.max_attempts) || 1;
16
15
  const executor = executorLabel(detail.backend || charter.backend);
17
16
  const repository = cleanString(charter.repository) || "the selected repository";
18
17
  const branch = cleanString(detail.branch_name || charter.base_branch) || "the selected branch";
@@ -42,17 +41,17 @@ export function summarizeCodingLoopEvent(snapshot, event) {
42
41
  return {
43
42
  summary: `Execution scope set for: ${requirement}`,
44
43
  result: `${countLabel(charter.acceptance_criteria, "acceptance criterion")}, ${countLabel(charter.expected_tests, "expected test")}, and ${countLabel(charter.expected_evidence, "evidence requirement")}.`,
45
- next: `Attempt 1 of ${maxAttempts} will be handed to ${executor}.`,
44
+ next: `Attempt 1 will be handed to ${executor}.`,
46
45
  };
47
46
  case "dispatching":
48
47
  return {
49
- summary: `Preparing ${executor} attempt ${attemptNumber}/${maxAttempts} for ${branch}.`,
48
+ summary: `Preparing ${executor} attempt ${attemptNumber} for ${branch}.`,
50
49
  result: "The executor instruction now includes the task, constraints, checks, and prior findings.",
51
50
  next: `${executor} will start the local coding process.`,
52
51
  };
53
52
  case "coding":
54
53
  return {
55
- summary: `${executor} is implementing attempt ${attemptNumber}/${maxAttempts} on ${branch}.`,
54
+ summary: `${executor} is implementing attempt ${attemptNumber} on ${branch}.`,
56
55
  result: "Live provider output is shown below as the local process works.",
57
56
  next: "When execution finishes, SIMY will collect code, test, PR, and UI evidence.",
58
57
  };
@@ -84,7 +83,7 @@ export function summarizeCodingLoopEvent(snapshot, event) {
84
83
  return {
85
84
  summary: `Attempt ${attemptNumber} needs another coding pass before review.`,
86
85
  result: findingResult(findings),
87
- next: `SIMY will give the findings to ${executor} for attempt ${Math.min(attemptNumber + 1, maxAttempts)}/${maxAttempts}.`,
86
+ next: `SIMY will give the findings to ${executor} for attempt ${attemptNumber + 1}.`,
88
87
  };
89
88
  case "pr_ready_for_review":
90
89
  return {
@@ -106,7 +105,7 @@ export function summarizeCodingLoopEvent(snapshot, event) {
106
105
  };
107
106
  case "blocked":
108
107
  return {
109
- summary: "The run cannot continue automatically with its current token, retry, or evidence budget.",
108
+ summary: "The run cannot continue automatically with its remaining evidence or approval requirements.",
110
109
  result: findings.length ? findingResult(findings) : cleanString(event?.message),
111
110
  next: "Review the findings, adjust the request or evidence, and resume the run.",
112
111
  };
package/src/runner.js CHANGED
@@ -239,7 +239,7 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
239
239
  restoredSnapshot.charter.retry_budget = run.snapshot.charter.retry_budget;
240
240
  restoredSnapshot.charter.token_budget = run.snapshot.charter.token_budget;
241
241
  restoredSnapshot.charter.max_attempts = run.snapshot.charter.max_attempts;
242
- refreshBudgetState(restoredSnapshot);
242
+ refreshRecoveryBudgetAudit(restoredSnapshot, refreshBudgetState(restoredSnapshot));
243
243
  const lifecycleState = restoredSnapshot.metadata?.pr_lifecycle_state;
244
244
  const persistedState =
245
245
  typeof lifecycleState === "string" && RESTORABLE_STATES.has(lifecycleState)
@@ -418,12 +418,8 @@ function assertRunCanContinue(run) {
418
418
  }
419
419
 
420
420
  const budget = refreshBudgetState(run.snapshot);
421
- if (budget.token_exhausted) {
422
- throw new Error("The Agentic Loop run used its token budget. Start a new run to continue.");
423
- }
424
- if (budget.retry_exhausted) {
425
- throw new Error("The Agentic Loop run used its retry budget. Start a new run to continue.");
426
- }
421
+ refreshRecoveryBudgetAudit(run.snapshot, budget);
422
+
427
423
  }
428
424
 
429
425
  export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
@@ -1051,20 +1047,7 @@ export async function executeProcessAttempt({
1051
1047
  if (run.localStateRoot) checkpointWrites.push(saveCodingRunSnapshot(run.localStateRoot, run.snapshot).catch(() => {
1052
1048
  emitOutput(run, "The Provider usage checkpoint could not be persisted; recovery usage remains unverified.", { backend });
1053
1049
  }));
1054
- if (currentUsed < initialBudget.hard_token_ceiling || terminalErrorCode || run.stopRequested) return;
1055
- terminalErrorCode = "TOKEN_BUDGET_EXHAUSTED";
1056
- terminalError = `Provider token hard ceiling reached (${currentUsed} / ${initialBudget.hard_token_ceiling}). Extend the same run's ceiling explicitly before continuing.`;
1057
- appendEvent(run.snapshot, run.status, terminalError, {
1058
- code: "provider_token_ceiling_reached", phase, attempt_number: attemptNumber,
1059
- provider_tokens_used: currentUsed, provider_token_ceiling: initialBudget.hard_token_ceiling,
1060
- });
1061
- if (!settled) {
1062
- run.controlState = "stopping";
1063
- void signalAndWaitForClose(child, "SIGINT", 1_500).then(async result => {
1064
- if (result === "timeout" && !settled && run.child === child) result = await signalAndWaitForClose(child, "SIGTERM", 1_500);
1065
- if (result === "timeout" && !settled && run.child === child) await signalAndWaitForClose(child, "SIGKILL", 750);
1066
- }).catch(() => {});
1067
- }
1050
+
1068
1051
  },
1069
1052
  });
1070
1053
  const stderrDecoder = createProviderStreamDecoder({
@@ -423,7 +423,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
423
423
  lastDependencyResolution: run.managed.lastDependencyResolution,
424
424
  ...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
425
425
  budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
426
- softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
426
+ localExecutionLimits: false } };
427
427
  const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
428
428
  const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
429
429
  const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
@@ -9,8 +9,8 @@ import { readCodexTitles, sessionTitle } from './session-titles.js';
9
9
  function autoContinueState(record, isRunning, supported) {
10
10
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
11
11
  const state = record.run?.state;
12
- if (state === 'budget_exhausted') return { autoContinueStatus: 'limit', autoContinueReason: 'This conversation reached its continuation limit. Its budgets cannot be reset.' };
13
- if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Bounded continuation finished for this conversation.' };
12
+ if (state === 'budget_exhausted') return { autoContinueStatus: 'needs_user', autoContinueReason: 'Local execution limits have been removed. Resume this same conversation to continue.' };
13
+ if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Continuation finished for this conversation.' };
14
14
  const managed = record.run?.managed;
15
15
  const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
16
16
  if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause || managed.pendingApproval)) return {
@@ -19,7 +19,7 @@ function autoContinueState(record, isRunning, supported) {
19
19
  if (['paused', 'blocked', 'stopped', 'failed', 'verification_pending'].includes(state)) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.' };
20
20
  if (!supported) return { autoContinueStatus: 'unavailable', autoContinueReason: 'The original provider continuation channel is unavailable.' };
21
21
  return { autoContinueStatus: record.run ? 'supervising' : isRunning ? 'waiting' : 'watching',
22
- autoContinueReason: record.run ? 'Continuing within the existing task budget.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
22
+ autoContinueReason: record.run ? 'Continuing the original task with progress and approval checks.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
23
23
  }
24
24
 
25
25
  function initialDiagnosisPlan(objective, contextBootstrap = false) {
@@ -147,7 +147,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
147
147
  return updateRecord(root, provider, session, async previous => {
148
148
  if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
149
149
  if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
150
- if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('Bounded continuation finished or reached its limit; budgets cannot be reset.');
150
+ if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('This conversation is settled. Resume an eligible existing task after refreshing its status.');
151
151
  if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
152
152
  if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
153
153
  { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
@@ -224,7 +224,6 @@ export async function enrollSession({ root, provider, session, objective, accept
224
224
  maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, autoContinueEpoch, openClient = openCodexAppServer,
225
225
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
226
226
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
227
- if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
228
227
  if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
229
228
  if (!['autonomous', 'balanced', 'strict'].includes(autonomyPolicy)) throw new Error('Choose an autonomy policy.');
230
229
  let metadata;
@@ -206,6 +206,7 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
206
206
  const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
207
207
  error.status = response.status;
208
208
  Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
209
+ reasonCode: parsed?.error?.reason_code || parsed?.detail?.reason_code,
209
210
  requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
210
211
  error.fallbackAllowed = response.status >= 500 && response.status <= 599;
211
212
  throw error;
@@ -8,16 +8,30 @@ export function safeSqmDiagnostic(error, stage = "check", code = "sqm_check_fail
8
8
  return {
9
9
  stage: machine(value.stage ?? value.errorStage ?? value.error_stage, stage),
10
10
  code: machine(value.code ?? value.errorCode ?? value.error_code, code),
11
+ ...(value.reasonCode || value.reason_code ? { reasonCode: machine(value.reasonCode ?? value.reason_code, "access_denied") } : {}),
11
12
  ...(Number.isInteger(status) && status >= 400 && status <= 599 ? { status } : {}),
12
13
  ...(typeof requestId === "string" && /^(?:[a-f0-9]{16,64}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(requestId) ? { requestId } : {}),
13
14
  };
14
15
  }
15
16
 
17
+ const ACCESS_HINTS = {
18
+ entitlement_denied: "The selected organization lacks an active SQM entitlement. Ask its administrator to check the subscription and IT Engineer package.",
19
+ organization_inactive: "The selected organization is inactive. Ask its administrator to restore it or select an active organization.",
20
+ membership_required: "Your account is not an active member of the selected organization. Ask its administrator to restore membership.",
21
+ membership_inactive: "Your membership in the selected organization is inactive. Ask its administrator to reactivate it.",
22
+ permission_denied: "Your organization role does not permit SQM. Ask its administrator to grant SQM access.",
23
+ tenant_mismatch: "This resource belongs to another organization. Select the resource's organization before retrying.",
24
+ actor_forbidden: "Your account cannot use SQM in the selected organization. Ask its administrator to verify membership and permissions.",
25
+ resource_scope_mismatch: "The resource belongs to another organization. Select its original organization; do not retry under the current scope.",
26
+ };
27
+
16
28
  export function formatSqmDiagnostic(value) {
17
- const hint = value.status === 401 ? "Reconnect your SIMY session."
18
- : value.status === 403 ? "Check access to SQM in the selected organization."
19
- : value.code === "repository_scope_unresolved" ? "Connect this repository to a system in SIMY."
29
+ const hint = ACCESS_HINTS[value.reasonCode] || (value.status === 401 ? "Reconnect your SIMY session."
30
+ : value.code === "sqm_entitlement_required" ? ACCESS_HINTS.entitlement_denied
31
+ : value.code === "repository_scope_unresolved" ? "SQM cannot resolve this repository to a connected system in the selected organization. Check the repository remote, select the correct organization and ask its administrator to connect the repository in SQM Systems. Then run min-check again."
32
+ : value.status === 403 ? "SQM access was denied in the selected organization. Ask its administrator to check active membership, organization status and SQM entitlement."
20
33
  : value.code === "knowledge_bundle_changed" || value.code === "repository_identity_changed" ? "Run min-check again for the current repository and knowledge version."
21
- : "Inspect the command diagnostics for this stage before retrying.";
22
- return `${value.stage} · ${value.code}${value.status ? ` · HTTP ${value.status}` : ""}${value.requestId ? ` · Request ${value.requestId}` : ""}\n${hint}`;
34
+ : value.status === 409 ? "The SQM request conflicts with the current repository, task or lease state. Refresh its current status before retrying."
35
+ : "Inspect the command diagnostics for this stage before retrying.");
36
+ return `${value.stage} · ${value.code}${value.reasonCode ? ` · ${value.reasonCode}` : ""}${value.status ? ` · HTTP ${value.status}` : ""}${value.requestId ? ` · Request ${value.requestId}` : ""}\n${hint}`;
23
37
  }
@@ -22,6 +22,7 @@ export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence
22
22
  error.stage = "evidence_upload";
23
23
  error.retryable = false;
24
24
  Object.assign(error, safeSqmDiagnostic({ status: response.status, code: payload?.error?.code,
25
+ reasonCode: payload?.error?.reason_code,
25
26
  requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
26
27
  throw error;
27
28
  }
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
6
7
  import { inspectProviderStatus } from "../desktop-status.js";
7
8
  import { inspectDesktopExecutor } from "../desktop-executor.js";
8
9
  import { safeProviderDiagnostic, PROVIDER_FAILURE_HINTS } from "./provider-diagnostics.js";
@@ -22,11 +23,12 @@ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
22
23
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
23
24
 
24
25
  export class SqmLocalReviewError extends Error {
25
- constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "", providerDiagnostic } = {}) {
26
+ constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "", providerDiagnostic, reasonCode } = {}) {
26
27
  super(message);
27
28
  this.name = "SqmLocalReviewError";
28
29
  this.status = status;
29
30
  this.code = code;
31
+ if (reasonCode) this.reasonCode = safeSqmDiagnostic({ reasonCode }).reasonCode;
30
32
  this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
31
33
  this.validationFeedback = typeof validationFeedback === "string" ? validationFeedback.slice(0, 2000) : "";
32
34
  if (providerDiagnostic) this.providerDiagnostic = safeProviderDiagnostic(providerDiagnostic);
@@ -75,7 +77,8 @@ export function createSqmLocalReviewApi({
75
77
  if (!response.ok) {
76
78
  throw new SqmLocalReviewError(webApiErrorMessage(payload, response.status), {
77
79
  status: response.status,
78
- code: machineCode(payload?.code || payload?.detail?.code),
80
+ code: machineCode(payload?.code || payload?.error?.code || payload?.detail?.code),
81
+ reasonCode: payload?.error?.reason_code || payload?.detail?.reason_code,
79
82
  stage: payload?.detail?.stage, jobId: payload?.detail?.job_id, failureRecorded: payload?.detail?.failure_recorded === true,
80
83
  validationFeedback: payload?.detail?.validation_feedback,
81
84
  });
@@ -703,14 +706,15 @@ function reviewFailure(error, stage, provider) {
703
706
  else if (providerSignInRequired(message)) code = "provider_sign_in_required";
704
707
  else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
705
708
  }
706
- return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
709
+ return { ...safeSqmDiagnostic(error), stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
707
710
  status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null,
708
711
  providerDiagnostic: safeProviderDiagnostic(error?.providerDiagnostic) };
709
712
  }
710
713
 
711
714
  function formatFailure(failure, failureReported = null) {
712
715
  if (!failure) return "SQM analysis failed. No failure diagnostics were recorded.";
713
- const hint = failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
716
+ const hint = [403, 409].includes(failure.status) ? formatSqmDiagnostic(failure).split("\n").slice(1).join("\n")
717
+ : failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
714
718
  : failure.code === "provider_model_unsupported" ? "The local Codex account rejected the configured SQM models. Update Codex or check this account's available models."
715
719
  : failure.code === "analysis_timeout" ? "The local AI analysis exceeded its time limit."
716
720
  : failure.code === "sqm_source_loading_failed" ? "SIMY could not load evidence for this analysis."