@awak-app/simy-cli 0.5.71 → 0.5.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +17 -12
  16. package/src/desktop-host.js +19 -8
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +18 -1
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. package/src/startup-preflight.js +44 -0
@@ -1,5 +1,7 @@
1
1
  import spawn from "cross-spawn";
2
2
  import os from "node:os";
3
+ import { readdir } from "node:fs/promises";
4
+ import { readdirSync, statSync } from "node:fs";
3
5
  import path from "node:path";
4
6
 
5
7
  const MACOS_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
@@ -25,11 +27,12 @@ export async function resolveBackendExecutable(backend, options = {}) {
25
27
 
26
28
  export async function inspectBackendExecutable(
27
29
  backend,
28
- { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000, signal } = {},
30
+ { spawnImpl = spawn, candidates, timeoutMs = 5_000, signal, discoveryOptions = {} } = {},
29
31
  ) {
30
32
  const assertActive = () => { if (signal?.aborted) throw Object.assign(new Error('Backend inspection was cancelled.'), { code: 'cancelled' }); };
31
33
  assertActive();
32
34
  const policy = versionPolicy(backend);
35
+ candidates ??= [...backendCandidates(backend, discoveryOptions), ...await bundledClaudeCandidates(backend, discoveryOptions)];
33
36
  for (const candidate of [...new Set(candidates.filter(Boolean))]) {
34
37
  assertActive();
35
38
  const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs, signal });
@@ -85,7 +88,7 @@ export function backendCandidates(backend, {
85
88
  "claude",
86
89
  // Desktop launch environments may predate the installer's PATH update.
87
90
  paths.join(userHome, ".local", "bin", platform === "win32" ? "claude.exe" : "claude"),
88
- ...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude"] : []),
91
+ ...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude", ...claudeDesktopCandidates(userHome)] : []),
89
92
  ];
90
93
  }
91
94
  return [
@@ -95,6 +98,46 @@ export function backendCandidates(backend, {
95
98
  ];
96
99
  }
97
100
 
101
+ // Claude Desktop can install below <version>/<content hash>/claude.app while
102
+ // an older PATH wrapper still points at <version>/claude.app. Discover only the
103
+ // known app layout; never recursively execute arbitrary files from user folders.
104
+ export async function bundledClaudeCandidates(backend, { platform = process.platform, home = os.homedir(), readDirectory = readdir } = {}) {
105
+ if (backend !== "claude" || platform !== "darwin") return [];
106
+ const root = path.posix.join(home, "Library", "Application Support", "Claude", "claude-code");
107
+ const entries = async target => { try { return await readDirectory(target, { withFileTypes: true }); } catch { return []; } };
108
+ const versions = (await entries(root)).filter(item => item.isDirectory() && /^\d+\.\d+\.\d+$/.test(item.name))
109
+ .sort((a, b) => compareVersions(b.name, a.name)).slice(0, 8);
110
+ const result = [];
111
+ for (const version of versions) {
112
+ const base = path.posix.join(root, version.name);
113
+ const hashes = (await entries(base)).filter(item => item.isDirectory() && /^[a-f0-9]{8,128}$/i.test(item.name)).slice(0, 8);
114
+ for (const hash of hashes) result.push(path.posix.join(base, hash.name, "claude.app", "Contents", "MacOS", "claude"));
115
+ result.push(path.posix.join(base, "claude.app", "Contents", "MacOS", "claude"));
116
+ }
117
+ return result;
118
+ }
119
+ // Desktop installs use either <version>/claude.app or <version>/<hash>/claude.app.
120
+ // Only inspect these two known levels; never recurse through arbitrary home paths
121
+ // or descend through version/hash directory symlinks. Existing explicit/PATH installs retain priority.
122
+ function claudeDesktopCandidates(home) {
123
+ const root = path.join(home, "Library", "Application Support", "Claude", "claude-code");
124
+ const directories = directory => {
125
+ try { return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name); }
126
+ catch { return []; }
127
+ };
128
+ return directories(root).filter(name => /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(name))
129
+ .sort((left, right) => compareVersions(right, left)).slice(0, 32)
130
+ .flatMap(version => {
131
+ const versionRoot = path.join(root, version);
132
+ const bundle = directory => path.join(directory, "claude.app", "Contents", "MacOS", "claude");
133
+ return [bundle(versionRoot), ...directories(versionRoot)
134
+ .filter(name => /^[a-zA-Z0-9_-]{8,128}$/.test(name)).sort().slice(0, 8)
135
+ .map(hash => bundle(path.join(versionRoot, hash)))];
136
+ }).filter(candidate => {
137
+ try { return statSync(candidate).isFile(); } catch { return false; }
138
+ });
139
+ }
140
+
98
141
  export function parseBackendVersion(output) {
99
142
  const match = String(output || "").match(/(?:^|\s|v)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$|\))/);
100
143
  return match?.[1] ?? null;
@@ -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.71";
16
+ export const CLI_VERSION = "0.5.73";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -31,6 +31,28 @@ keyed by provider and session ID and bound to the canonical working directory.
31
31
  Historical hook observations do not prove hooks are still enabled. See the
32
32
  bundled delivery-loop skill for checkpoint commands and explicit recovery.
33
33
 
34
+ Managed tasks may explicitly select `--continuation-policy progress`. Existing
35
+ tasks without that choice keep their bounded contract. Progress mode treats the
36
+ ordinary time, continuation and token thresholds as warnings, with independent
37
+ hard ceilings of 24 hours from the original start, 50 continuations and, on the
38
+ host-observed Codex channel, 15 million provider tokens. Claude live hooks expose
39
+ no trustworthy usage stream; their status explicitly reports that token limits
40
+ are unsupported. Required approvals, missing observed usage, manual stops and
41
+ unreconciled effects continue to block dispatch.
42
+
43
+ The host records passing, failed and unevaluated frozen acceptance checks.
44
+ Provider summaries, test counts, renamed evidence and restored old criteria
45
+ cannot manufacture progress. Three attempts without new verified criteria, or
46
+ 30 minutes without verified progress at a dispatch/checkpoint boundary, pause
47
+ the original run. Active tools are allowed to reach their checkpoint within the
48
+ hard ceiling; activity alone does not prove progress.
49
+
50
+ `delivery extend` raises explicitly selected ceilings within those global caps.
51
+ It records the old and new bounds, preserves run identity, usage, checkpoints,
52
+ criteria and approvals, and does not start a new provider turn. A separate resume
53
+ is required after an exhausted run becomes paused. `delivery continuation_policy`
54
+ requires an explicit choice and never infers progress consent from an extension.
55
+
34
56
  ## Project 実装状況 (dev-work) hook
35
57
 
36
58
  `dev-work.mjs` runs on Stop in both distributions. In a Git work tree with a
@@ -0,0 +1,27 @@
1
+ // Missing policy means the persisted legacy budget contract, never implicit consent.
2
+ export const CONTINUATION_POLICIES = Object.freeze(["bounded", "progress"]);
3
+ export const MAX_COMPLETION_TOKENS = 15_000_000;
4
+ export const DEFAULT_COMPLETION_ATTEMPT_CEILING = 50;
5
+
6
+ export function continuationMode(value) {
7
+ if (value === undefined || value === null) return "bounded";
8
+ if (!CONTINUATION_POLICIES.includes(value)) throw new Error("Invalid continuation policy.");
9
+ return value;
10
+ }
11
+
12
+ export function progressLimits(input = {}) {
13
+ return {
14
+ continuation_policy: continuationMode(input.continuation_policy),
15
+ provider_token_ceiling: finiteLimit(input.provider_token_ceiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS),
16
+ attempt_ceiling: finiteLimit(input.attempt_ceiling, DEFAULT_COMPLETION_ATTEMPT_CEILING, 100),
17
+ max_no_progress_attempts: finiteLimit(input.max_no_progress_attempts, 3, 20),
18
+ };
19
+ }
20
+
21
+ function finiteLimit(value, fallback, maximum) {
22
+ if (value === undefined || value === null) return fallback;
23
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
24
+ throw new Error(`Continuation ceiling must be an integer between 1 and ${maximum}.`);
25
+ }
26
+ return value;
27
+ }
@@ -8,6 +8,8 @@ import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
10
10
  import { associationContract, validateAssociationResult } from './action-association.mjs';
11
+ import { continuationMode, MAX_COMPLETION_TOKENS } from './continuation-policy.mjs';
12
+ import { evaluateOutcomeProgress } from './outcome-progress.mjs';
11
13
 
12
14
  export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
13
15
  const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
@@ -386,7 +388,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
386
388
  const criteria = config.acceptanceCriteria.map((item, index) => {
387
389
  if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
388
390
  const id = text(item.id, 80) || `criterion-${index + 1}`;
389
- if (!/^[A-Za-z0-9_-]+$/.test(id) || ['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
391
+ if (!/^[A-Za-z0-9_-]+$/.test(id) || ['USER-GOAL', '__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
390
392
  const check = item.check || { type: 'manual' };
391
393
  if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
392
394
  if (check.type !== 'manual' && (!text(check.path, 500) || isAbsolute(check.path) || check.path.split(/[\\/]/).includes('..'))) throw new Error('Acceptance file must be relative to the task directory.');
@@ -414,6 +416,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
414
416
  const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
415
417
  (appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
416
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.');
417
420
  const managed = { taskId: randomUUID(), thread: { provider, id: session },
418
421
  actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
419
422
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
@@ -421,6 +424,9 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
421
424
  maxNoProgressMinutes: positive(config.maxNoProgressMinutes, 30, 1440), lastVerifiedProgressAt: now,
422
425
  maxTokens: config.maxTokens === undefined ? null : positive(config.maxTokens, null, Number.MAX_SAFE_INTEGER), tokensUsed: 0, usageAvailable: null,
423
426
  noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
427
+ continuationPolicy: continuationMode(config.continuationPolicy),
428
+ providerTokenCeiling: appServerChannel ? positive(config.providerTokenCeiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS) : null,
429
+ budgetCapability: { time: true, continuations: true, providerTokens: appServerChannel },
424
430
  autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
425
431
  autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
426
432
  capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
@@ -430,7 +436,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
430
436
  if (initialPlan) recordDecision(managed, initialPlan, Date.now());
431
437
  return managed;
432
438
  }
433
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
439
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, managed, admission,
434
440
  liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
435
441
  cwd = await realpath(cwd);
436
442
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
@@ -446,7 +452,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
446
452
  }
447
453
  if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
448
454
  if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
449
- const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
455
+ const managedConfig = managed && { ...(managed === true ? {} : managed),
456
+ ...(continuationPolicy === undefined ? {} : { continuationPolicy }),
457
+ ...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
458
+ const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
459
+ if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
450
460
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
451
461
  if (managedState && previous.associationTaskId) {
452
462
  managedState.taskId = previous.associationTaskId;
@@ -458,6 +468,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
458
468
  const queued = managedState?.capability === 'codex_app_server';
459
469
  const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
460
470
  deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
471
+ ...(managedState?.continuationPolicy === 'progress' ? {
472
+ hardDeadline: now + positive(hardMaxMinutes ?? managedConfig.hardMaxMinutes, 1440, 1440) * 60_000,
473
+ hardMaxContinuations: positive(hardMaxContinuations ?? managedConfig.hardMaxContinuations, 50, 50),
474
+ budgetWarnings: [], budgetExtensionHistory: [],
475
+ } : {}),
461
476
  continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
462
477
  ...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
463
478
  if (waiting) escalate(run, run.reason);
@@ -552,6 +567,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
552
567
  }
553
568
  run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
554
569
  run.revision += 1; run.updatedAt = now;
570
+ recordManagedOutcomeProgress(run, now);
555
571
  if (run.managed && effectiveStatus === 'completed') {
556
572
  const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
557
573
  if (pending.length) { run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`; }
@@ -582,7 +598,7 @@ export async function checkpointActionAssociation({ root, provider, session, cwd
582
598
  let result;
583
599
  try { result = validateAssociationResult(job, checkpoint); }
584
600
  catch { result = { result: 'no_match', actionId: null, reason: 'Invalid association evidence; original work continues.', invalid: true }; }
585
- if (now >= run.deadline) result = { result: 'no_match', actionId: null, reason: 'Association budget expired.', invalid: true };
601
+ if (managedBudgetReason(run, now)) result = { result: 'no_match', actionId: null, reason: 'Association budget expired.', invalid: true };
586
602
  managed.phase = job.saved.phase; managed.plan = job.saved.plan; managed.decision = job.saved.decision;
587
603
  run.nextStep = job.saved.nextStep;
588
604
  if (association.invalidated || association.namespace !== job.namespace) result = { result: 'no_match', actionId: null, reason: 'Association account context changed.', invalid: true };
@@ -608,7 +624,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
608
624
  if (!run || record.cwd !== cwd || run.managed?.phase !== 'understanding' ||
609
625
  TERMINAL.has(run.state) || ['blocked', 'paused', 'recovery_required', 'verification_pending'].includes(run.state)) throw new Error('No active session understanding turn.');
610
626
  if (expectedLease && (run.state !== 'auto_resuming' || run.managed.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Session understanding lease changed; checkpoint discarded.');
611
- if (now >= run.deadline) throw new Error('Session understanding deadline expired.');
627
+ if (managedBudgetReason(run, now)) throw new Error('Session understanding deadline expired.');
612
628
  const decision = run.managed.decision;
613
629
  const result = checkpoint.actionResult;
614
630
  if (decision?.action.kind !== 'read' || result?.decisionId !== decision.id || !['observed', 'failed', 'not_run'].includes(result.status) || !text(result.summary, 300)) throw new Error('Understanding result must match the selected read decision.');
@@ -663,30 +679,49 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
663
679
  return record;
664
680
  });
665
681
  }
682
+ function newVerifiedCriterion(run, id, now) {
683
+ const managed = run.managed;
684
+ if (managed.continuationPolicy === 'progress') {
685
+ const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria }));
686
+ if (managed.progressObjectiveDigest !== objectiveDigest) {
687
+ managed.progressObjectiveDigest = objectiveDigest;
688
+ managed.verifiedCriterionHistory = [];
689
+ }
690
+ if (managed.verifiedCriterionHistory.includes(id)) return;
691
+ managed.verifiedCriterionHistory.push(id);
692
+ } else if (Object.hasOwn(managed.verified, id)) return;
693
+ managed.verifiedRevision += 1;
694
+ managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
695
+ }
666
696
  export async function verifyAutomaticCriteria(record, now) {
667
697
  const managed = record.run.managed;
698
+ managed.criterionObservations = {};
699
+ const observe = (criterion, passed, detail, sha256 = null) => {
700
+ managed.criterionObservations[criterion.id] = { criterion_id: criterion.id, passed,
701
+ verification_method: `host_${criterion.check.type}`, observed_result: detail,
702
+ evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
703
+ };
704
+ const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
668
705
  for (const criterion of managed.acceptanceCriteria) {
669
706
  if (criterion.check.type === 'manual') continue;
670
707
  const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
671
- if (!path) { delete managed.verified[criterion.id]; continue; }
708
+ if (!path) { failed(criterion, 'Frozen acceptance file path is absent'); continue; }
672
709
  const file = resolve(record.cwd, path);
673
710
  const rel = relative(record.cwd, file);
674
- if (rel.startsWith('..') || isAbsolute(rel)) { delete managed.verified[criterion.id]; continue; }
711
+ if (rel.startsWith('..') || isAbsolute(rel)) { failed(criterion, 'Acceptance path is outside the enrolled workspace'); continue; }
675
712
  try {
676
713
  const actual = await realpath(file);
677
714
  const actualRel = relative(record.cwd, actual);
678
- if (actualRel.startsWith('..') || isAbsolute(actualRel)) { delete managed.verified[criterion.id]; continue; }
715
+ if (actualRel.startsWith('..') || isAbsolute(actualRel)) { failed(criterion, 'Resolved acceptance path is outside the enrolled workspace'); continue; }
679
716
  const info = await stat(actual);
680
- if (!info.isFile() || info.size > 1024 * 1024) { delete managed.verified[criterion.id]; continue; }
717
+ if (!info.isFile() || info.size > 1024 * 1024) { failed(criterion, 'Acceptance file is not a bounded regular file'); continue; }
681
718
  const content = await readFile(actual);
682
- if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) { delete managed.verified[criterion.id]; continue; }
683
- if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) { delete managed.verified[criterion.id]; continue; }
684
- if (!Object.hasOwn(managed.verified, criterion.id)) {
685
- managed.verifiedRevision += 1;
686
- managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? record.run.startedAt, now);
687
- }
719
+ if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) { failed(criterion, 'Observed file lacks the required frozen content'); continue; }
720
+ if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) { failed(criterion, 'Observed file differs from the frozen exact contents'); continue; }
721
+ newVerifiedCriterion(record.run, criterion.id, now);
688
722
  managed.verified[criterion.id] = { source: criterion.check.type, checkedAt: now, path, sha256: digest(content) };
689
- } catch { delete managed.verified[criterion.id]; }
723
+ observe(criterion, true, 'Frozen file acceptance check passed', digest(content));
724
+ } catch { failed(criterion, 'Acceptance file could not be independently read'); }
690
725
  }
691
726
  }
692
727
  export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
@@ -696,10 +731,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
696
731
  if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
697
732
  const criterion = run.managed.acceptanceCriteria.find(item => item.id === criterionId);
698
733
  if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
699
- if (!Object.hasOwn(run.managed.verified, criterionId)) {
700
- run.managed.verifiedRevision += 1;
701
- run.managed.lastVerifiedProgressAt = Math.max(run.managed.lastVerifiedProgressAt ?? run.startedAt, now);
702
- }
734
+ newVerifiedCriterion(run, criterionId, now);
703
735
  run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
704
736
  if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
705
737
  await verifyAutomaticCriteria(record, now);
@@ -742,13 +774,70 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
742
774
  run.nextStep = decision.action.summary;
743
775
  return decision;
744
776
  }
745
- export function managedBudgetReason(run, now = Date.now()) {
746
- if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) return 'Original delivery time, continuation, or failure budget exhausted.';
777
+ export function deliveryBudgetState(run, now = Date.now(), { checkProgress = true } = {}) {
747
778
  const managed = run.managed;
748
- if (managed?.usageBlocked || managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return 'Provider token budget exhausted or reliable usage unavailable.';
749
- if (managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) return 'No verified acceptance progress within the configured time limit.';
750
- if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
751
- return null;
779
+ 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;
783
+ const warnings = [];
784
+ const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
785
+ if (progress) {
786
+ if (now >= run.deadline) warnings.push('soft_time_limit');
787
+ if (run.continuations >= run.maxContinuations) warnings.push('soft_continuation_limit');
788
+ if (managed.maxTokens && managed.tokensUsed >= managed.maxTokens) warnings.push('soft_token_limit');
789
+ }
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) {
796
+ 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
+ } else if (checkProgress && managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) {
802
+ reason = 'No verified acceptance progress within the configured time limit.';
803
+ } else if (checkProgress && managed && managed.noProgress >= managed.maxNoProgress) {
804
+ reason = 'No verified acceptance progress within the configured continuation limit.';
805
+ }
806
+ return { continuationPolicy: progress ? 'progress' : 'bounded', deadline, maxContinuations, tokenCeiling, warnings, reason };
807
+ }
808
+ export function managedBudgetReason(run, now = Date.now(), options) {
809
+ return deliveryBudgetState(run, now, options).reason;
810
+ }
811
+ export function managedDispatchDeadline(run) {
812
+ return deliveryBudgetState(run).deadline;
813
+ }
814
+
815
+ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
816
+ const managed = run.managed;
817
+ if (managed?.continuationPolicy !== 'progress') return;
818
+ const current = managed.acceptanceCriteria.map(criterion => ({ id: criterion.id, statement: criterion.description }));
819
+ const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria }));
820
+ const results = current.flatMap(criterion => {
821
+ const observation = managed.verified[criterion.id];
822
+ return observation ? [{ criterion_id: criterion.id, passed: true,
823
+ verification_method: observation.source === 'user' ? 'explicit_user_verification' : `host_${observation.source}`,
824
+ observed_result: `Frozen ${criterion.id} acceptance check verified`,
825
+ evidence_refs: [observation.source === 'user' ? `user:${managed.taskId}:${criterion.id}:${observation.checkedAt}`
826
+ : `host:${criterion.id}:${observation.sha256}`] }]
827
+ : managed.criterionObservations?.[criterion.id] ? [managed.criterionObservations[criterion.id]] : [];
828
+ });
829
+ managed.outcomeProgress = evaluateOutcomeProgress({
830
+ criteria: current, objectiveDigest, objectiveVersion: managed.understandingRevision || 1,
831
+ criterionResults: results, assessmentAvailable,
832
+ previous: managed.outcomeProgress, maxNoProgressAttempts: managed.maxNoProgress,
833
+ findings: run.checkpoint?.status === 'blocked' ? [{
834
+ code: run.checkpoint.blocker, blocker_kind: run.checkpoint.blocker === 'external_dependency' ? 'external' : 'human',
835
+ explanation: run.checkpoint.summary,
836
+ }] : [],
837
+ });
838
+ managed.noProgress = managed.outcomeProgress.no_progress_attempts;
839
+ if (managed.outcomeProgress.has_progress) managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
840
+ managed.progressCheckpointRevision = run.revision;
752
841
  }
753
842
  // Manual/legacy runs have no marker and retain their existing controls.
754
843
  export function automaticContinuationAllowed(record) {
@@ -759,6 +848,7 @@ export function automaticContinuationAllowed(record) {
759
848
  export function decideStop(run, now = Date.now()) {
760
849
  if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
761
850
  const next = structuredClone(run);
851
+ next.budgetWarnings = deliveryBudgetState(next, now).warnings;
762
852
  const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
763
853
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
764
854
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
@@ -776,7 +866,7 @@ export function decideStop(run, now = Date.now()) {
776
866
  } else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
777
867
  }
778
868
  const budgetReason = managedBudgetReason(next, now);
779
- if (budgetReason) return finish('budget_exhausted', budgetReason);
869
+ if (budgetReason) return finish(next.managed?.continuationPolicy === 'progress' && budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
780
870
  const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
781
871
  const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
782
872
  const understandingHook = next.managed?.capability === 'live_hook' && (next.managed.awaitingUnderstandingHook === true || next.managed.awaitingAssociationHook === true);
@@ -811,11 +901,13 @@ export function decideStop(run, now = Date.now()) {
811
901
  const reportedStep = next.managed.associationCheckpointRevision !== next.revision && next.managed.contextBootstrap && next.managed.phase === 'active' && !next.managed.acceptanceCriteria.length &&
812
902
  !['session_understanding', 'action_association'].includes(cp?.kind) && cp?.status === 'in_progress' &&
813
903
  next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
814
- if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
904
+ if (next.managed.continuationPolicy === 'progress') {
905
+ if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind) && (!fresh || next.managed.progressCheckpointRevision !== next.revision)) recordManagedOutcomeProgress(next, now, Boolean(fresh));
906
+ } else if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
815
907
  else if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind)) next.managed.noProgress += 1;
816
908
  if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
817
909
  next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
818
- if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish('failed', 'No verified acceptance progress within the configured continuation limit.'); }
910
+ if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish(next.managed.continuationPolicy === 'progress' ? 'paused' : 'failed', 'No verified acceptance progress within the configured continuation limit.'); }
819
911
  }
820
912
  next.consumedRevision = next.revision;
821
913
  if (initialHook) next.managed.awaitingInitialHook = false;
@@ -1005,7 +1097,11 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
1005
1097
  run.state === 'blocked' && managed.pendingApproval?.denied === true &&
1006
1098
  !managedBudgetReason(run, now));
1007
1099
  }
1008
- export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, questionId, waitId, answer, action, now = Date.now() }) {
1100
+ export function canChangeDeliveryContinuation(run) {
1101
+ return Boolean(run?.managed && ['paused', 'blocked', 'failed', 'budget_exhausted', 'verification_pending', 'recovery_required'].includes(run.state) &&
1102
+ !['pending', 'pending_received', 'transferred'].includes(run.managed.handoff?.status));
1103
+ }
1104
+ export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, limits, questionId, waitId, answer, action, now = Date.now() }) {
1009
1105
  for (let retry = 0; ; retry += 1) {
1010
1106
  try { return await updateRecord(root, provider, session, record => {
1011
1107
  const run = record?.run;
@@ -1013,6 +1109,52 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1013
1109
  if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
1014
1110
  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.');
1015
1111
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
1112
+ if (action === 'extend' || action === 'continuation_policy') {
1113
+ if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation bounds.');
1114
+ if (action === 'continuation_policy') {
1115
+ if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
1116
+ run.managed.continuationPolicy = continuationMode(continuationPolicy);
1117
+ if (continuationPolicy === 'progress') {
1118
+ run.hardDeadline ??= run.startedAt + 1440 * 60_000;
1119
+ run.hardMaxContinuations ??= 50;
1120
+ if (run.managed.resumeChannel === 'codex_app_server') run.managed.providerTokenCeiling ??= MAX_COMPLETION_TOKENS;
1121
+ }
1122
+ run.continuationPolicyHistory ||= [];
1123
+ run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
1124
+ } else {
1125
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits) || !Object.keys(limits).length ||
1126
+ Object.keys(limits).some(key => !['hardMaxMinutes', 'hardMaxContinuations', 'providerTokenCeiling'].includes(key))) throw new Error('Supply explicit continuation ceiling increases.');
1127
+ const progress = run.managed.continuationPolicy === 'progress';
1128
+ const before = { deadline: progress ? run.hardDeadline : run.deadline,
1129
+ maxContinuations: progress ? run.hardMaxContinuations : run.maxContinuations,
1130
+ tokens: progress ? run.managed.providerTokenCeiling : run.managed.maxTokens };
1131
+ const after = { ...before };
1132
+ if (limits.hardMaxMinutes !== undefined) {
1133
+ after.deadline = run.startedAt + positive(limits.hardMaxMinutes, null, 1440) * 60_000;
1134
+ if (after.deadline <= before.deadline) throw new Error('Extension must increase the original deadline within 24 hours of its start.');
1135
+ }
1136
+ if (limits.hardMaxContinuations !== undefined) {
1137
+ after.maxContinuations = positive(limits.hardMaxContinuations, null, 50);
1138
+ if (after.maxContinuations <= before.maxContinuations) throw new Error('Extension must increase the continuation ceiling within 50 turns.');
1139
+ }
1140
+ if (limits.providerTokenCeiling !== undefined) {
1141
+ if (run.managed.resumeChannel !== 'codex_app_server') throw new Error('Provider token ceilings require host-observed Codex usage.');
1142
+ after.tokens = positive(limits.providerTokenCeiling, null, MAX_COMPLETION_TOKENS);
1143
+ if (after.tokens <= (before.tokens || 0)) throw new Error('Extension must increase the provider token ceiling.');
1144
+ }
1145
+ if (after.deadline <= now || after.maxContinuations <= run.continuations || after.tokens && after.tokens <= run.managed.tokensUsed) throw new Error('Extended ceilings must exceed already consumed time, turns, and tokens.');
1146
+ if (progress) { run.hardDeadline = after.deadline; run.hardMaxContinuations = after.maxContinuations; run.managed.providerTokenCeiling = after.tokens; }
1147
+ else { run.deadline = after.deadline; run.maxContinuations = after.maxContinuations; run.managed.maxTokens = after.tokens; }
1148
+ run.budgetExtensionHistory ||= [];
1149
+ run.budgetExtensionHistory.push({ before, after, at: now, source: 'explicit_controller_extension' });
1150
+ }
1151
+ run.budgetWarnings = deliveryBudgetState(run, now).warnings;
1152
+ if (run.state === 'budget_exhausted' && !managedBudgetReason(run, now)) {
1153
+ run.state = 'paused'; run.reason = 'Continuation bounds changed; explicitly resume this same task.';
1154
+ }
1155
+ run.updatedAt = now;
1156
+ return record;
1157
+ }
1016
1158
  if (action === 'dependency_ready') {
1017
1159
  const wait = run.managed?.dependencyWait;
1018
1160
  if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
@@ -1166,7 +1308,14 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1166
1308
  run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
1167
1309
  }
1168
1310
  }
1169
- if (managedBudgetReason(run, now) && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) { run.state = 'budget_exhausted'; run.reason = managedBudgetReason(run, now); run.updatedAt = now; }
1311
+ const activeTurn = run.managed?.continuationPolicy === 'progress' &&
1312
+ (run.state === 'auto_resuming' || current.runtime?.isRunning === true || run.managed.executionAttempt?.phase === 'dispatching');
1313
+ const budgetState = deliveryBudgetState(run, now, { checkProgress: !activeTurn });
1314
+ run.budgetWarnings = budgetState.warnings;
1315
+ 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';
1317
+ run.reason = budgetState.reason; run.updatedAt = now;
1318
+ }
1170
1319
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
1171
1320
  run.state = 'recovery_required'; run.recoveryCause = 'process_exit'; run.reason = run.managed?.autoRecovery ? 'Observed provider process exited. Managed recovery is eligible for a bounded exact-session attempt.' : 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
1172
1321
  }