@awak-app/simy-cli 0.5.72 → 0.5.74

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 (51) hide show
  1. package/README.md +97 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +14 -10
  16. package/src/desktop-host.js +9 -6
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +72 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +7 -0
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/analysis-manager.js +45 -0
  36. package/src/session-supervision/codex-app-server.js +3 -2
  37. package/src/session-supervision/continuation-analysis.js +1 -1
  38. package/src/session-supervision/index.js +16 -4
  39. package/src/sqm/bundle-store.js +11 -7
  40. package/src/sqm/claude-plugin.js +2 -0
  41. package/src/sqm/command.js +36 -4
  42. package/src/sqm/diagnostics.js +1 -1
  43. package/src/sqm/evidence-client.js +23 -7
  44. package/src/sqm/history-store.js +9 -0
  45. package/src/sqm/index.js +104 -14
  46. package/src/sqm/proof.js +3 -23
  47. package/src/sqm/provider-diagnostics.js +35 -0
  48. package/src/sqm/request-recovery.js +82 -0
  49. package/src/sqm/review-worker.js +218 -112
  50. package/src/sqm/session.js +33 -3
  51. package/src/startup-preflight.js +44 -0
@@ -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,17 +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()) {
777
+ export function deliveryBudgetState(run, now = Date.now(), { checkProgress = true } = {}) {
778
+ const managed = run.managed;
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 = [];
746
784
  const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
747
- if (now >= run.deadline) return `Delivery time limit exhausted.${checkpointDetail}`;
748
- if (run.continuations >= run.maxContinuations) return `Delivery continuation limit exhausted (${run.continuations}/${run.maxContinuations}).${checkpointDetail}`;
749
- if (run.failures >= run.maxFailures) return `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
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) {
750
816
  const managed = run.managed;
751
- if (managed?.usageBlocked) return `Reliable provider token usage is unavailable; budgeted continuation is blocked.${checkpointDetail}`;
752
- if (managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return `Provider token budget exhausted (${managed.tokensUsed}/${managed.maxTokens}).${checkpointDetail}`;
753
- if (managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) return 'No verified acceptance progress within the configured time limit.';
754
- if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
755
- return null;
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;
756
841
  }
757
842
  // Manual/legacy runs have no marker and retain their existing controls.
758
843
  export function automaticContinuationAllowed(record) {
@@ -763,6 +848,7 @@ export function automaticContinuationAllowed(record) {
763
848
  export function decideStop(run, now = Date.now()) {
764
849
  if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
765
850
  const next = structuredClone(run);
851
+ next.budgetWarnings = deliveryBudgetState(next, now).warnings;
766
852
  const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
767
853
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
768
854
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
@@ -780,7 +866,7 @@ export function decideStop(run, now = Date.now()) {
780
866
  } else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
781
867
  }
782
868
  const budgetReason = managedBudgetReason(next, now);
783
- if (budgetReason) return finish('budget_exhausted', budgetReason);
869
+ if (budgetReason) return finish(next.managed?.continuationPolicy === 'progress' && budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
784
870
  const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
785
871
  const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
786
872
  const understandingHook = next.managed?.capability === 'live_hook' && (next.managed.awaitingUnderstandingHook === true || next.managed.awaitingAssociationHook === true);
@@ -815,11 +901,13 @@ export function decideStop(run, now = Date.now()) {
815
901
  const reportedStep = next.managed.associationCheckpointRevision !== next.revision && next.managed.contextBootstrap && next.managed.phase === 'active' && !next.managed.acceptanceCriteria.length &&
816
902
  !['session_understanding', 'action_association'].includes(cp?.kind) && cp?.status === 'in_progress' &&
817
903
  next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
818
- 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;
819
907
  else if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind)) next.managed.noProgress += 1;
820
908
  if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
821
909
  next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
822
- 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.'); }
823
911
  }
824
912
  next.consumedRevision = next.revision;
825
913
  if (initialHook) next.managed.awaitingInitialHook = false;
@@ -1009,7 +1097,11 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
1009
1097
  run.state === 'blocked' && managed.pendingApproval?.denied === true &&
1010
1098
  !managedBudgetReason(run, now));
1011
1099
  }
1012
- 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() }) {
1013
1105
  for (let retry = 0; ; retry += 1) {
1014
1106
  try { return await updateRecord(root, provider, session, record => {
1015
1107
  const run = record?.run;
@@ -1017,6 +1109,52 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1017
1109
  if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
1018
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.');
1019
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
+ }
1020
1158
  if (action === 'dependency_ready') {
1021
1159
  const wait = run.managed?.dependencyWait;
1022
1160
  if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
@@ -1170,7 +1308,14 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1170
1308
  run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
1171
1309
  }
1172
1310
  }
1173
- 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
+ }
1174
1319
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
1175
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;
1176
1321
  }
@@ -0,0 +1,129 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ function text(value) {
4
+ return typeof value === "string" ? value.trim() : "";
5
+ }
6
+
7
+ function observed(row) {
8
+ return typeof row?.passed === "boolean" && Boolean(text(row.verification_method)) &&
9
+ Boolean(text(row.observed_result)) && Array.isArray(row.evidence_refs) &&
10
+ row.evidence_refs.some((ref) => Boolean(text(ref)));
11
+ }
12
+
13
+ function classifyBlockers(findings) {
14
+ const blockers = { machine: [], human: [], external: [] };
15
+ for (const finding of findings || []) {
16
+ if (!finding || finding.passed === true) continue;
17
+ const kind = ["machine", "human", "external"].includes(finding.blocker_kind)
18
+ ? finding.blocker_kind
19
+ : finding.repairability === "external" ? "external"
20
+ : finding.repairability === "manual" ? "human" : "machine";
21
+ blockers[kind].push({
22
+ code: text(finding.code) || "OUTCOME_UNPROVEN",
23
+ target: text(finding.target) || null,
24
+ explanation: text(finding.explanation) || text(finding.detail) || null,
25
+ });
26
+ }
27
+ return blockers;
28
+ }
29
+
30
+ /**
31
+ * Compare controller-owned independent observations against one frozen objective.
32
+ * Evidence references identify proof; changing their paths/hashes is not progress.
33
+ * This measures continuation only and never replaces the completion gates.
34
+ */
35
+ export function evaluateOutcomeProgress({
36
+ criteria,
37
+ objectiveDigest = null,
38
+ objectiveVersion = null,
39
+ criterionResults = [],
40
+ userOutcome = null,
41
+ assessmentAvailable = false,
42
+ assessmentReason = null,
43
+ findings = [],
44
+ previous = null,
45
+ maxNoProgressAttempts = 3,
46
+ }) {
47
+ const rows = Array.isArray(criteria) ? criteria : [];
48
+ if (rows.some((row) => !text(row?.id) || row.id === "USER-GOAL") ||
49
+ new Set(rows.map((row) => row.id)).size !== rows.length) {
50
+ throw new Error("Outcome progress requires unique, non-reserved criterion IDs");
51
+ }
52
+ if (!Number.isInteger(maxNoProgressAttempts) || maxNoProgressAttempts < 1) {
53
+ throw new Error("maxNoProgressAttempts must be a positive integer");
54
+ }
55
+ const scopeDigest = createHash("sha256").update(JSON.stringify({
56
+ objectiveDigest, objectiveVersion, criteria: rows,
57
+ })).digest("hex");
58
+ const comparable = previous?.scope_digest === scopeDigest;
59
+ const requiredIds = [...rows.map((row) => row.id), "USER-GOAL"];
60
+ const results = Array.isArray(criterionResults) ? criterionResults : [];
61
+ const criterionStates = requiredIds.map((id) => {
62
+ const matches = id === "USER-GOAL" ? (userOutcome ? [userOutcome] : [])
63
+ : results.filter((row) => row?.criterion_id === id);
64
+ const row = matches[0];
65
+ const evaluated = assessmentAvailable && matches.length === 1 && observed(row);
66
+ return {
67
+ criterion_id: id,
68
+ state: evaluated ? row.passed ? "verified" : "unproven" : "unassessed",
69
+ reason: evaluated ? null : !assessmentAvailable ? assessmentReason || "independent_assessment_unavailable"
70
+ : matches.length > 1 ? "duplicate_independent_results" : "independent_observation_missing",
71
+ };
72
+ });
73
+ const idsWithState = (state) => criterionStates.filter((row) => row.state === state)
74
+ .map((row) => row.criterion_id).sort();
75
+ const verified = idsWithState("verified");
76
+ const unproven = idsWithState("unproven");
77
+ const unassessed = idsWithState("unassessed");
78
+ // History is only a comparison baseline, never carried into current proof.
79
+ const historicalRows = comparable ? previous.verified_history_ids || previous.verified_ids : [];
80
+ const historical = Array.isArray(historicalRows)
81
+ ? historicalRows.filter((id) => requiredIds.includes(id)) : [];
82
+ const newlyVerified = verified.filter((id) => !historical.includes(id));
83
+ const hasProgress = newlyVerified.length > 0;
84
+ const previousNoProgress = Number.isInteger(previous?.no_progress_attempts) && previous.no_progress_attempts >= 0
85
+ ? previous.no_progress_attempts : 0;
86
+ const noProgressAttempts = comparable
87
+ ? hasProgress ? 0 : previousNoProgress + 1
88
+ : verified.length > 0 ? 0 : 1;
89
+ const blockers = classifyBlockers(findings);
90
+ const complete = rows.length > 0 && unproven.length === 0 && unassessed.length === 0;
91
+ const stalled = !complete && noProgressAttempts >= maxNoProgressAttempts;
92
+ const evaluationStatus = unassessed.length === 0 ? "assessed"
93
+ : verified.length + unproven.length > 0 ? "partial" : "unavailable";
94
+ const nextActions = [];
95
+ if (blockers.machine.length || unproven.length || unassessed.length) nextActions.push("repair_or_verify");
96
+ if (blockers.human.length) nextActions.push("request_human_input");
97
+ if (blockers.external.length) nextActions.push("wait_for_external_dependency");
98
+ if (complete) nextActions.push("check_completion_gates");
99
+ if (stalled) nextActions.push("reassess_approach");
100
+ return {
101
+ version: 2,
102
+ objective_digest: objectiveDigest,
103
+ objective_version: objectiveVersion,
104
+ scope_digest: scopeDigest,
105
+ objective_changed: Boolean(previous && !comparable),
106
+ evaluation_status: evaluationStatus,
107
+ status: evaluationStatus === "assessed" ? "assessed" : "insufficient_evidence",
108
+ criterion_states: criterionStates,
109
+ verified_ids: verified,
110
+ assessed_ids: verified,
111
+ evaluated_ids: [...verified, ...unproven].sort(),
112
+ unproven_ids: [...unproven, ...unassessed].sort(),
113
+ failed_ids: unproven,
114
+ unassessed_ids: unassessed,
115
+ newly_verified_ids: newlyVerified,
116
+ verified_history_ids: [...new Set([...historical, ...verified])].sort(),
117
+ regressed_ids: comparable && Array.isArray(previous.verified_ids)
118
+ ? unproven.filter((id) => previous.verified_ids.includes(id)) : [],
119
+ has_progress: hasProgress,
120
+ no_progress_attempts: noProgressAttempts,
121
+ max_no_progress_attempts: maxNoProgressAttempts,
122
+ stalled,
123
+ stall_reason: stalled ? evaluationStatus === "unavailable"
124
+ ? "independent_assessment_repeatedly_unavailable" : "no_new_verified_outcome" : null,
125
+ blockers,
126
+ next_actions: nextActions,
127
+ next_action: stalled ? "reassess_approach" : nextActions[0] || "repair_or_verify",
128
+ };
129
+ }
@@ -0,0 +1,88 @@
1
+ import { redactExecutionValue } from "./orchestrator/execution-io.js";
2
+ import { objectiveValueDigest, verifyObjective } from "./orchestrator/outcome-monitor.js";
3
+
4
+ // Redaction changes acceptance text, so its integrity chain must undergo the
5
+ // same transformation. Never legitimize an already edited objective here.
6
+ export function recoverySnapshot(snapshot) {
7
+ const verified = snapshot?.charter && verifyObjective(snapshot.charter);
8
+ const sanitized = redactExecutionValue(snapshot);
9
+ const contract = sanitized?.charter?.thread_state?.objective_contract;
10
+ const objectiveDigest = value => objectiveValueDigest(value, contract?.digest_algorithm);
11
+ if (!contract || verified.legacy || !verified.valid) return sanitized;
12
+ if (objectiveDigest(contract.current) === contract.current_digest) return sanitized;
13
+ for (const revision of contract.revisions) {
14
+ revision.previous_digest = objectiveDigest(revision.previous);
15
+ revision.next_digest = objectiveDigest(revision.next);
16
+ }
17
+ const previousDigest = contract.current_digest;
18
+ contract.current_digest = objectiveDigest(contract.current);
19
+ if (!verifyObjective(sanitized.charter).valid) {
20
+ throw new Error("The redacted objective cannot be preserved for recovery.");
21
+ }
22
+ sanitized.metadata ??= {};
23
+ sanitized.metadata.recovery_migrations ??= [];
24
+ sanitized.metadata.recovery_migrations.push({
25
+ kind: "objective_redaction", version: 1,
26
+ previous_digest: previousDigest, current_digest: contract.current_digest,
27
+ });
28
+ return sanitized;
29
+ }
30
+
31
+ export function recoveryActorBinding(session, apiOrigin, request) {
32
+ return {
33
+ device_id: session?.device_id || request?.execution_device_id || null,
34
+ account_id: session?.account_id || session?.auth_user_id || null,
35
+ organization_id: session?.organization_id || session?.org_id || null,
36
+ api_origin: apiOrigin || null,
37
+ };
38
+ }
39
+
40
+ export function assertRecoveryActor(run) {
41
+ const saved = run.snapshot.metadata?.recovery_actor || {
42
+ device_id: run.snapshot.metadata?.execution_device_id,
43
+ };
44
+ if (!saved) return;
45
+ const current = recoveryActorBinding(run.session, run.apiOrigin, run.request);
46
+ for (const field of ["device_id", "account_id", "organization_id", "api_origin"]) {
47
+ if (saved[field] && saved[field] !== current[field]) {
48
+ const error = new Error(`The saved run belongs to another ${field}; restore its original authorized session.`);
49
+ error.code = "recovery_actor_mismatch";
50
+ throw error;
51
+ }
52
+ }
53
+ }
54
+
55
+ // Current presentation can change; historical attempts and the corresponding
56
+ // pause audit remain available for investigation.
57
+ export function refreshRecoveryBudgetAudit(snapshot, budget) {
58
+ const stale = (finding) =>
59
+ (finding.code === "TOKEN_BUDGET_EXHAUSTED" && !budget.token_exhausted) ||
60
+ (finding.code === "RETRY_BUDGET_EXHAUSTED" && !budget.retry_exhausted);
61
+ const changed = refreshRecoveryAudit(snapshot, { reason: "budget_changed", stale,
62
+ summary: "The budget permits continuation; remaining verification or approval is still required." });
63
+ if (changed && snapshot.final_audit) snapshot.final_audit.budget = budget;
64
+ return changed;
65
+ }
66
+
67
+ export function refreshRecoveryAudit(snapshot, { reason, stale, summary }) {
68
+ const audit = snapshot.final_audit;
69
+ if (!audit) return false;
70
+ const findings = Array.isArray(audit.findings) ? audit.findings : [];
71
+ if (!findings.some(stale)) return false;
72
+ snapshot.metadata ??= {};
73
+ snapshot.metadata.recovery_audit_history ??= [];
74
+ snapshot.metadata.recovery_audit_history.push({
75
+ reason, recorded_at: new Date().toISOString(),
76
+ audit: structuredClone(audit),
77
+ });
78
+ const remaining = findings.filter((finding) => !stale(finding));
79
+ snapshot.final_audit = remaining.length ? {
80
+ ...audit, findings: remaining, passed: false,
81
+ auto_repairable: remaining.every(finding => finding.repairability === "auto"),
82
+ requires_human: remaining.some(finding => finding.repairability !== "auto"),
83
+ summary,
84
+ } : null;
85
+ snapshot.done_gate_passed = false;
86
+ snapshot.implementation_gate_passed = false;
87
+ return true;
88
+ }
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
- import { redactExecutionValue } from "./orchestrator/execution-io.js";
6
+ import { recoverySnapshot } from "./coding-run-recovery.js";
7
7
 
8
8
  const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
9
9
  const writes = new Map();
@@ -16,7 +16,7 @@ export async function saveCodingRunSnapshot(root, snapshot) {
16
16
  const id = String(snapshot?.id || "");
17
17
  if (!RUN_ID.test(id)) throw new Error("Invalid coding run ID for durable storage.");
18
18
  // Capture at enqueue time: later mutations must not rewrite a queued version.
19
- snapshot = redactExecutionValue(snapshot);
19
+ snapshot = recoverySnapshot(snapshot);
20
20
  const directory = path.resolve(root);
21
21
  const key = path.join(directory, id);
22
22
  const previous = writes.get(key) || Promise.resolve();
@@ -0,0 +1,2 @@
1
+ // The installed provider plugins use the same continuation policy validation.
2
+ export * from "./codex-plugins/simy/hooks/continuation-policy.mjs";