@awak-app/simy-cli 0.6.23 → 0.6.25

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 (64) hide show
  1. package/README.md +53 -0
  2. package/package.json +3 -3
  3. package/src/cli-contract.js +9 -1
  4. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +5 -0
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +533 -74
  6. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +63 -0
  7. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +154 -0
  8. package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +34 -0
  9. package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +65 -0
  10. package/src/codex-plugins/simy/hooks/purpose-search.mjs +123 -0
  11. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +97 -0
  12. package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +19 -5
  13. package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +19 -4
  14. package/src/delivery-hooks/command.js +48 -0
  15. package/src/delivery-hooks/consistency-monitor.js +126 -0
  16. package/src/delivery-hooks/desktop-runtime.js +70 -0
  17. package/src/delivery-hooks/desktop.js +13 -3
  18. package/src/delivery-hooks/legacy-hook-routing.js +61 -0
  19. package/src/delivery-hooks/local-runtime.js +123 -0
  20. package/src/delivery-hooks/local-supervisor.js +25 -0
  21. package/src/delivery-hooks/runtime-watchdog.js +101 -0
  22. package/src/delivery-hooks/supervisor.js +90 -22
  23. package/src/desktop-host.js +24 -1
  24. package/src/durable-local-task-worker.js +57 -12
  25. package/src/fp-narrative-image.js +180 -0
  26. package/src/index.js +1 -1
  27. package/src/local-task.js +1 -0
  28. package/src/orchestrator/audit.js +1 -1
  29. package/src/orchestrator/charter-repair.js +93 -0
  30. package/src/orchestrator/charter-validation.js +14 -6
  31. package/src/orchestrator/checklist-continuation.js +1 -1
  32. package/src/orchestrator/contract.js +1 -3
  33. package/src/orchestrator/delivery/WORKFLOW.md +33 -60
  34. package/src/orchestrator/delivery/controls/harness-controls.json +1326 -0
  35. package/src/orchestrator/delivery/references/delivery-levels.md +17 -44
  36. package/src/orchestrator/delivery/references/pr-closure.md +2 -3
  37. package/src/orchestrator/delivery/references/state-and-receipts.md +14 -5
  38. package/src/orchestrator/delivery/references/ux-specification.md +1 -2
  39. package/src/orchestrator/delivery-level.js +4 -5
  40. package/src/orchestrator/independent-audit.js +9 -5
  41. package/src/orchestrator/instruction.js +1 -0
  42. package/src/orchestrator/loop.js +55 -18
  43. package/src/orchestrator/risk.js +1 -1
  44. package/src/orchestrator/ux-specification.js +2 -2
  45. package/src/runner.js +4 -0
  46. package/src/session-supervision/analysis-manager.js +2 -2
  47. package/src/session-supervision/automatic-continuation.js +6 -5
  48. package/src/session-supervision/autonomous-upgrade.js +55 -0
  49. package/src/session-supervision/codex-app-server.js +200 -24
  50. package/src/session-supervision/consistency-auditor.js +113 -0
  51. package/src/session-supervision/index.js +38 -11
  52. package/src/session-supervision/purpose-candidate.js +44 -0
  53. package/src/session-supervision/purpose-instruction.js +47 -0
  54. package/src/session-supervision/purpose-observer.js +172 -0
  55. package/src/session-supervision/purpose-recovery.js +264 -0
  56. package/src/session-supervision/purpose-verification.js +154 -0
  57. package/src/sqm/agent-skill.js +6 -3
  58. package/src/sqm/artifact-paths.js +39 -0
  59. package/src/sqm/claude-plugin.js +7 -0
  60. package/src/sqm/command.js +95 -27
  61. package/src/sqm/history-store.js +3 -3
  62. package/src/sqm/repository.js +19 -5
  63. package/src/sqm/workflow-update.js +14 -0
  64. package/src/workflow-reporting.js +2 -0
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { retainApplicableRules } from './purpose-rule-policy.mjs';
3
+ import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
4
+ import { workspaceIdentity } from './purpose-workspace.mjs';
5
+ import { searchEnabled, failureLimitReached, searchState, recordSearchExperiment, rememberSearchWait, searchNeedsPivot, markSearchPivot, repeatedSearchAction } from './purpose-search.mjs';
2
6
  // Standalone by design: this exact module is shipped to both provider plugins.
3
7
  import { createHash, randomUUID } from 'node:crypto';
4
8
  import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
@@ -7,6 +11,9 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
11
  import { execFile } from 'node:child_process';
8
12
  import { promisify } from 'node:util';
9
13
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
14
+ import { purposeEnabled, purposeContract, purposeDiagnosisPlan, validatePurposeReview } from './purpose-cycle.mjs';
15
+ import { effectiveAcceptanceCriteria } from './acceptance-policy.mjs';
16
+ import { assessRuleReview } from './purpose-rule-review.mjs';
10
17
  import { associationContract, validateAssociationResult } from './action-association.mjs';
11
18
  import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
12
19
  import { continuationMode } from './continuation-policy.mjs';
@@ -14,7 +21,7 @@ import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome
14
21
 
15
22
  export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
16
23
  const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
17
- const MAX_BYTES = 64 * 1024;
24
+ const MAX_BYTES = 256 * 1024;
18
25
  const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
19
26
  const APPROVAL_DECISIONS = new Set(['automatic', 'proceed_and_report', 'wait']);
20
27
  const LOCAL_ACTIONS = new Set(['read', 'local_edit', 'local_test']);
@@ -190,7 +197,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
190
197
  }
191
198
  }
192
199
  const serialized = JSON.stringify(next);
193
- if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 64 KiB; reduce task details.');
200
+ if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 256 KiB; reduce task details.');
194
201
  if (serialized === previousBytes) return next;
195
202
  temporary = `${file}.${randomUUID()}.tmp`;
196
203
  await writeFile(temporary, serialized, { mode: 0o600 });
@@ -289,7 +296,12 @@ function assessedPlan(value) {
289
296
  const alternatives = value.alternatives.map(option => {
290
297
  const id = text(option?.id, 80);
291
298
  if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) throw new Error('Invalid plan option ID.');
292
- return { id, action: actionDescriptor(option.action), assessment: assessment(option.assessment), expectedVerification: text(option.expectedVerification, 1000), rollback: text(option.rollback, 1000) };
299
+ const frontier = {};
300
+ for (const key of ['criterionIds','dependsOn','readResources','writeResources','requiredCapabilities','requiredPermissions']) {
301
+ if (option[key] !== undefined && (!Array.isArray(option[key]) || option[key].length > 32 || option[key].some(value => typeof value !== 'string' || !value.trim() || value.length > 200))) throw new Error('Invalid work unit metadata.');
302
+ if (option[key]) frontier[key] = [...new Set(option[key])];
303
+ }
304
+ return { id, ...frontier, ...(option.independent === true && text(option.independenceReason, 300) ? { independent: true, independenceReason: text(option.independenceReason, 300) } : {}), action: actionDescriptor(option.action), assessment: assessment(option.assessment), expectedVerification: text(option.expectedVerification, 1000), rollback: text(option.rollback, 1000) };
293
305
  });
294
306
  if (new Set(alternatives.map(option => option.id)).size !== alternatives.length) throw new Error('Duplicate plan option ID.');
295
307
  const selectedOptionId = text(value.selectedOptionId, 80);
@@ -318,22 +330,64 @@ function rejectedActionDiagnosis(managed, rejected) {
318
330
  expectedVerification: 'Report the rejection reason and a concrete, different assessed next option.', rollback: 'No changes to undo.' }],
319
331
  selectedOptionId: 'diagnose_rejected_action', recommendation: { optionId: 'diagnose_rejected_action', reason: 'Read the recorded rejection before choosing different work.' } });
320
332
  }
321
- export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now()) {
333
+ function researchWaitDiagnosis(managed) {
334
+ const previous = managed.decision;
335
+ const action = { kind: 'read', scope: previous.action.scope, summary: 'Inspect the recorded assessment and local evidence; propose a corrected assessed plan without executing the waiting action' };
336
+ return assessedPlan({ diagnosis: `Research wait: ${previous.reason} Waiting action remains unexecuted: ${previous.action.summary}. Inspect its scope, effect boundaries and verification method before proposing another plan.`,
337
+ alternatives: [{ id: 'diagnose_research_wait', action,
338
+ assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
339
+ expectedVerification: 'Report observed assessment evidence and a concrete, corrected assessed next option.', rollback: 'No changes to undo.' }],
340
+ selectedOptionId: 'diagnose_research_wait', recommendation: { optionId: 'diagnose_research_wait', reason: 'Acquire local evidence before reassessing the waiting action.' } });
341
+ }
342
+ // This is only an unexecuted enrollment repair, not recovery of later work.
343
+ // Retain even settled attempt history as a boundary for the dedicated recovery path.
344
+ function canResearchReplan(run, provider, now) {
345
+ const m = run?.managed, d = m?.decision;
346
+ const channel = m?.resumeChannel === 'codex_app_server' && provider === 'codex' ||
347
+ m?.capability === 'live_hook' && ['codex', 'claude'].includes(provider);
348
+ return Boolean(channel && m.autonomyPolicy && m.approvalScopes?.includes(d?.action?.scope) &&
349
+ run.state === 'blocked' && run.revision === 0 && d?.outcome === 'wait' && d.waitKind === 'research' &&
350
+ LOCAL_ACTIONS.has(d.action.kind) && d.assessment?.inObjective === true && d.assessment.userChoice === false &&
351
+ d.assessment.reversibility === 'reversible' && ['none', 'local'].includes(d.assessment.externalEffect) &&
352
+ !m.pendingApproval && !m.approvedAction && !m.rejectedActionOverflow && !m.executionUncertain &&
353
+ !m.executionAttempt && !m.liveWorkspaceLease && !m.resumeLease && !m.dependencyWait &&
354
+ !m.userQuestion && !['understanding', 'needs_clarification', 'action_association'].includes(m.phase) &&
355
+ !m.nativeWait && !m.providerWarning && !m.usageBlocked && !m.purpose?.nativeBoundary && !run.blocker && !deliveryStop(run, now, { checkProgress: false }));
356
+ }
357
+ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { allowLocalTestWrites = false, llmDelegated = false, allowReversibleExternal = false } = {}) {
322
358
  const option = plan.alternatives.find(item => item.id === plan.selectedOptionId);
323
359
  const { action } = option;
324
360
  const facts = assessment(option.assessment);
325
- let reason = null;
326
- if (!scopes.includes(action.scope)) reason = 'Action scope is outside the task enrollment.';
327
- else if (facts.inObjective !== true || facts.userChoice !== false) reason = 'Objective fit or user choice needs review.';
328
- else if (Object.values(facts).includes('unknown') || facts.inObjective === null || facts.userChoice === null) reason = 'Action assessment is incomplete.';
329
- else if (!option.expectedVerification) reason = 'Expected verification is missing.';
330
- else if (!LOCAL_ACTIONS.has(action.kind) || facts.externalEffect === 'external' || facts.reversibility === 'irreversible' || facts.blastRadius === 'multi_system' || facts.risk === 'high' || facts.uncertainty === 'high') reason = 'Action has external, irreversible, broad, or high-uncertainty effects.';
331
- else if (action.kind === 'local_edit' && (!option.rollback || facts.reversibility !== 'reversible' || facts.externalEffect !== 'local')) reason = 'Local edit needs a concrete rollback and local reversible effects.';
332
- else if (action.kind !== 'local_edit' && facts.externalEffect !== 'none') reason = 'Read or test action has unexpected side effects.';
333
- else if (policy === 'strict' && (action.kind === 'local_edit' || facts.risk !== 'low' || facts.uncertainty !== 'low')) reason = 'Strict policy requires low-risk read or local test.';
334
- else if (policy === 'balanced' && (facts.risk !== 'low' || facts.uncertainty !== 'low' || action.kind === 'local_edit' && facts.blastRadius !== 'single_file')) reason = 'Balanced policy requires a low-risk, narrow action.';
335
- else if (policy === 'autonomous' && (facts.risk === 'medium' && facts.blastRadius !== 'repository' && facts.blastRadius !== 'single_file')) reason = 'Medium-risk action exceeds the repository boundary.';
336
- return { id: digest(JSON.stringify({ plan, policy })), outcome: reason ? 'wait' : policy === 'autonomous' && facts.risk === 'medium' ? 'report' : 'proceed', reason: reason || 'Selected action fits the enrolled objective, scope, and policy.', selectedOptionId: option.id, action, assessment: facts, expectedVerification: option.expectedVerification, rollback: option.rollback, policy, at: now };
361
+ const localWrite = action.kind === 'local_edit' || allowLocalTestWrites && action.kind === 'local_test' && facts.externalEffect === 'local';
362
+ const autonomous = policy === 'autonomous' && llmDelegated;
363
+ const external = autonomous && allowReversibleExternal && action.kind === 'external' && facts.externalEffect === 'external';
364
+ let reason = null, waitKind = 'research';
365
+ if (!scopes.includes(action.scope)) { reason = 'Action scope is outside the task enrollment.'; waitKind = 'authority'; }
366
+ else if (facts.inObjective !== true) reason = 'Establish objective fit or choose work within the original task.';
367
+ else if (facts.reversibility === 'irreversible') { reason = 'Irreversible consequences require an exact user decision.'; waitKind = 'human'; }
368
+ else if (facts.reversibility !== 'reversible' || facts.externalEffect === 'unknown' || facts.blastRadius === 'unknown') reason = 'Investigate effect boundaries and reversibility before execution.';
369
+ else if (!autonomous && (facts.userChoice !== false || Object.values(facts).includes('unknown') || facts.userChoice === null)) reason = 'Action assessment or user choice needs review under the selected policy.';
370
+ else if (!option.expectedVerification) reason = 'Expected verification is missing; acquire a verification method.';
371
+ else if ((!LOCAL_ACTIONS.has(action.kind) || facts.externalEffect === 'external') && !external) { reason = 'This action needs existing external authority or a reversible supported alternative.'; waitKind = 'authority'; }
372
+ else if ((localWrite || external) && (!option.rollback || external && /^(?:no changes|none|n\/a)/i.test(option.rollback.trim()))) reason = 'Effectful work needs a concrete restoration plan.';
373
+ else if (localWrite && facts.externalEffect !== 'local') reason = 'Local write has inconsistent effect boundaries.';
374
+ else if (!localWrite && !external && facts.externalEffect !== 'none') reason = 'Read or test action has unexpected side effects.';
375
+ else if (!autonomous && (facts.blastRadius === 'multi_system' || facts.risk === 'high' || facts.uncertainty === 'high')) reason = 'Selected policy requires a narrower or lower-risk action.';
376
+ else if (policy === 'strict' && (localWrite || facts.risk !== 'low' || facts.uncertainty !== 'low')) reason = 'Strict policy requires low-risk read or local test.';
377
+ else if (policy === 'balanced' && (facts.risk !== 'low' || facts.uncertainty !== 'low' || localWrite && facts.blastRadius !== 'single_file')) reason = 'Balanced policy requires a low-risk, narrow action.';
378
+ const report = external || autonomous && (facts.risk !== 'low' || facts.uncertainty !== 'low' || facts.userChoice !== false || facts.blastRadius === 'multi_system') || policy === 'autonomous' && facts.risk === 'medium';
379
+ return { id: digest(JSON.stringify({ plan, policy, llmDelegated, allowReversibleExternal })), outcome: reason ? 'wait' : report ? 'report' : 'proceed', ...(reason ? { waitKind } : {}), ...(external && !reason ? { autonomousExternal: true } : {}), reason: reason || 'AI selected reversible work within the enrolled objective and provider authority.', selectedOptionId: option.id, action, assessment: facts, expectedVerification: option.expectedVerification, rollback: option.rollback, policy, at: now };
380
+ }
381
+ // Recompute from the frozen plan, not an arbitrary checkpoint flag. Provider
382
+ // permissions are checked independently by the adapter at actual dispatch.
383
+ export function canExecuteAutonomousExternal(managed) {
384
+ if (!searchEnabled(managed) || managed.resumeChannel !== 'codex_app_server' || managed.autonomyPolicy !== 'autonomous' || managed.executionUncertain || managed.purpose?.nativeBoundary || managed.pendingApproval?.denied || managed.rejectedActionOverflow) return false;
385
+ const decision = managed.decision;
386
+ if (!decision?.autonomousExternal || decision.outcome !== 'report' || decision.action.kind !== 'external' || !managed.plan?.alternatives?.length) return false;
387
+ const selected = managed.plan.alternatives.find(row => row.id === managed.plan.selectedOptionId);
388
+ if (!selected || JSON.stringify(selected.action) !== JSON.stringify(decision.action) || selected.rollback !== decision.rollback || JSON.stringify(assessment(selected.assessment)) !== JSON.stringify(decision.assessment) || managed.rejectedActionDigests?.includes(digest(JSON.stringify(decision.action)))) return false;
389
+ const current = evaluateAssessedPlan(managed.plan, managed.autonomyPolicy, managed.approvalScopes, Date.now(), { llmDelegated: true, allowReversibleExternal: true, allowLocalTestWrites: true });
390
+ return current.autonomousExternal === true && current.outcome === 'report';
337
391
  }
338
392
  // Shared by live Stop hooks and cold exact-session recovery. Keep the result
339
393
  // contract identical across transports; the host still verifies acceptance.
@@ -341,7 +395,7 @@ export function assessedCheckpointContract(decision) {
341
395
  return `${minimalSufficientChangeInstruction({ turn: 'assessed' })}\nReturn ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
342
396
  }
343
397
  export function understandingCheckpointContract(decision) {
344
- return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; do not edit files, call external services, or start the proposed next action. Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. For semantic conditions use manual. If the complete expected file contents are not yet known from the original user request or observed evidence in this understanding turn, use manual for that criterion and choose a read as the next action to learn them. Never emit file_equals with null, omitted, guessed, or placeholder text; an exact file check requires a bounded concrete UTF-8 string now. Do not perform the proposed next action merely to fill the check, and do not claim that a later read or model report has independently verified a manual criterion. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
398
+ return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; ${decision.purpose ? "Authorized read-only API and web research is allowed within existing provider permissions; do not send private material to external services. Do not edit files, write to external services, or start the proposed next action." : "do not edit files, call external services, or start the proposed next action."} Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|semantic|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require AI investigation; reversible preferences are delegated under autonomous policy. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. ${decision.search ? "For semantic outcomes use check.type semantic; reserve manual for an explicit requirement for a human decision. Missing verification tooling is work to do, not a manual condition." : "For semantic conditions use manual."} Never emit file_equals with null, omitted, guessed, or placeholder text. If exact expected contents are unknown, choose read to learn them and use semantic for purpose search or manual otherwise. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. First recover missing context with authorized reads and infer reversible defaults from the original request. Select read investigation when facts remain unknown. Only if the original goal cannot be grounded at all or irreversible personal consequences require a decision, set one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
345
399
  }
346
400
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
347
401
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -372,12 +426,12 @@ function sessionUnderstanding(value) {
372
426
  const basisIndex = Number.isSafeInteger(item.basisIndex) && item.basisIndex >= 0 && item.basisIndex < basis.length ? item.basisIndex : null;
373
427
  if (item.source === 'user_grounded' && (basisIndex === null || basis[basisIndex].source !== 'user')) throw new Error('User-grounded criterion needs a user-message basis.');
374
428
  const check = item.check?.type || 'manual';
375
- if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check)) throw new Error('Unsupported inferred acceptance check.');
376
- if (check !== 'manual' && (!text(item.check.path, 500) || isAbsolute(item.check.path) || item.check.path.split(/[\\/]/).includes('..'))) throw new Error('Inferred file check needs a relative task path.');
429
+ if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check)) throw new Error('Unsupported inferred acceptance check.');
430
+ if (!['manual', 'semantic'].includes(check) && (!text(item.check.path, 500) || isAbsolute(item.check.path) || item.check.path.split(/[\\/]/).includes('..'))) throw new Error('Inferred file check needs a relative task path.');
377
431
  if (check === 'file_contains' && !text(item.check.text, 1000)) throw new Error('Inferred file check needs expected text.');
378
432
  if (check === 'file_equals' && exactText(item.check.text) === null) throw new Error('Inferred file_equals needs bounded exact UTF-8 text.');
379
433
  return { id: `inferred-${index + 1}`, description, source: item.source, basisIndex,
380
- check: check === 'manual' ? { type: 'manual' } : { type: check, path: text(item.check.path, 500), ...(check === 'file_contains' ? { text: contextText(item.check.text, 1000) } : {}), ...(check === 'file_equals' ? { text: exactText(item.check.text) } : {}) } };
434
+ check: ['manual', 'semantic'].includes(check) ? { type: check } : { type: check, path: text(item.check.path, 500), ...(check === 'file_contains' ? { text: contextText(item.check.text, 1000) } : {}), ...(check === 'file_equals' ? { text: exactText(item.check.text) } : {}) } };
381
435
  }) : [];
382
436
  const result = { state: value.state, objective: contextText(value.objective, 1000), constraints: boundedContextList(value.constraints),
383
437
  done: boundedContextList(value.done), remaining: boundedContextList(value.remaining), nextAction: value.nextAction ? actionDescriptor(value.nextAction) : null,
@@ -388,19 +442,92 @@ function sessionUnderstanding(value) {
388
442
  if (['ambiguous', 'context_missing'].includes(result.state) && (!result.question || result.ambiguities.length !== 1)) throw new Error('Unclear understanding needs one focused question.');
389
443
  return result;
390
444
  }
445
+ function recordPurposeReview(managed, review, now) {
446
+ const purpose = managed.purpose;
447
+ if (searchEnabled(managed)) reconcileRequirements(managed, review, now);
448
+ purpose.lastReview = { ...review, at: now, source: 'provider_reported' };
449
+ }
450
+ // Prose is planning context, never a progress receipt. Remember every attained
451
+ // criterion identity so a failure/recovery oscillation cannot renew the clock.
452
+ export function purposeProgressAt(run, now = Date.now()) {
453
+ const managed = run.managed, purpose = managed?.purpose;
454
+ if (purpose?.progressVersion === 2) return purpose.lastEvidenceAt || run.startedAt;
455
+ // Migration can retain actual controller/user acceptance receipts, but never
456
+ // a v1 provider-reported observation timestamp.
457
+ const timestamps = (managed?.acceptanceCriteria || []).flatMap(criterion => {
458
+ const receipt = managed.verified?.[criterion.id];
459
+ const expectedSource = criterion.check.type === 'manual' ? 'user' : criterion.check.type;
460
+ return receipt?.source === expectedSource && Number.isFinite(receipt.checkedAt) && receipt.checkedAt <= now
461
+ ? [receipt.checkedAt] : [];
462
+ });
463
+ return Math.max(run.startedAt, ...timestamps);
464
+ }
465
+ function recordPurposeProgress(run, now, countAttempt = true) {
466
+ if (!purposeEnabled(run)) return;
467
+ const managed = run.managed, purpose = managed.purpose;
468
+ if (purpose.progressVersion !== 2) {
469
+ const baseline = purposeProgressAt(run, now);
470
+ purpose.progressVersion = 2; purpose.noLearning = 0;
471
+ purpose.lastEvidenceAt = baseline; delete purpose.observationDigest;
472
+ }
473
+ purpose.seenVerifiedCriteria ||= [];
474
+ const fresh = managed.acceptanceCriteria.filter(criterion => Object.hasOwn(managed.verified, criterion.id))
475
+ .map(criterion => digest(JSON.stringify(criterion))).filter(key => !purpose.seenVerifiedCriteria.includes(key));
476
+ if (fresh.length && purpose.seenVerifiedCriteria.length + fresh.length <= 128) {
477
+ purpose.seenVerifiedCriteria.push(...fresh);
478
+ purpose.noLearning = 0; purpose.lastEvidenceAt = now;
479
+ } else {
480
+ if (countAttempt) purpose.noLearning += 1;
481
+ if (fresh.length) purpose.progressCapacityReached = true;
482
+ }
483
+ }
391
484
  function recordDecision(managed, plan, now) {
392
- const decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now);
485
+ let decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now, { allowLocalTestWrites: searchEnabled(managed), llmDelegated: searchEnabled(managed), allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' });
486
+ if (searchEnabled(managed) && decision.outcome === 'wait') {
487
+ rememberSearchWait(managed, 'authorization', { action: decision.action, reason: decision.reason }, now);
488
+ const priorities = new Map((managed.purpose.lastReview?.priorities || []).map(row => [row.optionId, row.rank]));
489
+ for (const candidate of [...plan.alternatives].sort((a, b) => (priorities.get(a.id) ?? 99) - (priorities.get(b.id) ?? 99))) {
490
+ if (candidate.id === plan.selectedOptionId || !candidate.independent) continue;
491
+ const alternative = { ...plan, selectedOptionId: candidate.id, recommendation: { optionId: candidate.id, reason: 'Continue the highest listed eligible independent alternative.' } };
492
+ const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now, { allowLocalTestWrites: searchEnabled(managed), llmDelegated: searchEnabled(managed), allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' });
493
+ if (assessed.outcome !== 'wait' && !managed.rejectedActionOverflow && !managed.rejectedActionDigests?.includes(digest(JSON.stringify(assessed.action)))) {
494
+ plan = alternative; decision = assessed; break;
495
+ }
496
+ }
497
+ }
393
498
  if (managed.rejectedActionOverflow || managed.rejectedActionDigests?.includes(digest(JSON.stringify(decision.action)))) {
394
499
  decision.outcome = 'wait';
395
- decision.reason = 'A previously rejected action requires a fresh exact user decision.';
500
+ decision.reason = 'A previously rejected action requires a fresh exact user decision.'; decision.waitKind = 'human';
501
+ }
502
+ if (managed.purpose?.nativeBoundary && decision.action.kind !== 'read' && decision.outcome !== 'wait') {
503
+ decision.nativeIsolationEligible = true;
504
+ decision.outcome = 'wait';
505
+ decision.reason = 'Native operation remains pending; shared writes need confirmed ownership or an authorized isolated candidate workspace.';
396
506
  }
397
507
  managed.decisionGeneration = (managed.decisionGeneration || 0) + 1;
398
508
  decision.id = digest(`${managed.taskId}:${managed.decisionGeneration}:${decision.id}`);
509
+ if (managed.purpose?.enabled) decision.purpose = true;
510
+ if (searchEnabled(managed)) decision.search = true;
511
+ if (searchEnabled(managed)) {
512
+ const ledger = evidenceLedger(managed);
513
+ ledger.units ||= [];
514
+ const option = plan.alternatives.find(row => row.id === decision.selectedOptionId);
515
+ const criterionIds = option?.criterionIds?.length ? option.criterionIds : managed.acceptanceCriteria.map(row => row.id);
516
+ if (criterionIds.some(id => !managed.acceptanceCriteria.some(row => row.id === id))) throw new Error('Work unit references unknown criterion.');
517
+ const dependsOn = option?.dependsOn || [];
518
+ if (dependsOn.some(id => !ledger.units.some(row => row.id === id && row.status === 'verified' && row.purposeRevision === ledger.purposeRevision))) { decision.outcome = 'wait'; decision.reason = 'Required work-unit dependency has not been independently verified.'; }
519
+ ledger.units.push({ id: decision.id, criterionIds, dependsOn, readResources: option?.readResources || ['task'],
520
+ writeResources: option?.writeResources || (decision.action.kind === 'read' ? [] : ['task']),
521
+ requiredCapabilities: option?.requiredCapabilities || [decision.action.kind], requiredPermissions: option?.requiredPermissions || [decision.action.scope],
522
+ status: decision.outcome === 'wait' ? 'waiting' : 'planned', evidenceRefs: [], purposeRevision: ledger.purposeRevision || 1, action: decision.action, createdAt: now });
523
+ if (ledger.units.length > 128) { ledger.unitsOmitted = true; ledger.units = ledger.units.filter(row => ['waiting','planned','unknown'].includes(row.status)).concat(ledger.units.filter(row => !['waiting','planned','unknown'].includes(row.status)).slice(-64)); }
524
+ if (ledger.units.length > 192) throw new Error('Work frontier needs reconciliation before more dispatch.');
525
+ }
399
526
  managed.plan = plan; managed.decision = decision; managed.proposedAction = decision.action;
400
527
  managed.decisionHistory ||= [];
401
528
  if (managed.decisionHistory.at(-1)?.id !== decision.id) managed.decisionHistory.push({ id: decision.id, at: now, outcome: decision.outcome, reason: decision.reason, selectedOptionId: decision.selectedOptionId, action: decision.action });
402
529
  managed.decisionHistory = managed.decisionHistory.slice(-10);
403
- managed.pendingApproval = decision.outcome === 'wait' ? { id: decision.id, ...decision.action, reason: decision.reason } : null;
530
+ managed.pendingApproval = decision.outcome === 'wait' && !(searchEnabled(managed) && decision.waitKind === 'research') ? { id: decision.id, ...decision.action, reason: decision.reason } : null;
404
531
  return decision;
405
532
  }
406
533
  function rememberRejectedAction(managed, pending) {
@@ -437,11 +564,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
437
564
  const id = text(item.id, 80) || `criterion-${index + 1}`;
438
565
  if (!/^[A-Za-z0-9_-]+$/.test(id) || ['USER-GOAL', '__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
439
566
  const check = item.check || { type: 'manual' };
440
- if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
441
- 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.');
567
+ if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
568
+ if (!['manual', 'semantic'].includes(check.type) && (!text(check.path, 500) || isAbsolute(check.path) || check.path.split(/[\\/]/).includes('..'))) throw new Error('Acceptance file must be relative to the task directory.');
442
569
  if (check.type === 'file_contains' && !text(check.text, 1000)) throw new Error('file_contains needs expected text.');
443
570
  if (check.type === 'file_equals' && exactText(check.text) === null) throw new Error('file_equals needs bounded exact UTF-8 text.');
444
- return { id, description: text(item.description, 500), check: { type: check.type, ...(check.type === 'file_equals' ? { exactPath: check.path } : check.type === 'manual' ? {} : { path: check.path }), ...(check.type === 'file_contains' ? { text: text(check.text, 1000) } : {}), ...(check.type === 'file_equals' ? { text: exactText(check.text) } : {}) } };
571
+ return { id, description: text(item.description, 500), check: { type: check.type, ...(check.type === 'file_equals' ? { exactPath: check.path } : ['manual', 'semantic'].includes(check.type) ? {} : { path: check.path }), ...(check.type === 'file_contains' ? { text: text(check.text, 1000) } : {}), ...(check.type === 'file_equals' ? { text: exactText(check.text) } : {}) } };
445
572
  });
446
573
  if (new Set(criteria.map(item => item.id)).size !== criteria.length) throw new Error('Duplicate acceptance criterion ID.');
447
574
  const approvalPolicy = config.approvalPolicy || 'wait';
@@ -462,6 +589,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
462
589
  const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
463
590
  const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
464
591
  (appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
592
+ if (config.purpose && config.maxTokens !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token budgets require the host-observed Codex app-server channel.');
465
593
  let deliveryLevel = null;
466
594
  if (config.deliveryLevel !== undefined) {
467
595
  const value = config.deliveryLevel;
@@ -470,10 +598,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
470
598
  value.numberingVersion !== 'assurance-ascending-v2' || !text(value.selectionRef, 200)) throw new Error('Delivery level needs the user selection reference, requested/effective levels 1-5, and assurance-ascending-v2 numbering.');
471
599
  deliveryLevel = { requested: value.requested, effective: value.effective, numberingVersion: value.numberingVersion, selectionRef: text(value.selectionRef, 200) };
472
600
  }
473
- const managed = { taskId: randomUUID(), deliveryLevel, thread: { provider, id: session },
601
+ const managed = { ...(config.purpose ? { purpose: structuredClone(config.purpose) } : {}), taskId: randomUUID(), deliveryLevel, thread: { provider, id: session },
474
602
  actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
475
603
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
476
604
  grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
605
+ ...(config.purpose ? { maxTokens: config.maxTokens === undefined ? null : positive(config.maxTokens, 1, 1_000_000_000), tokensUsed: 0, usageAvailable: null } : {}),
477
606
  maxNoProgressMinutes: positive(config.maxNoProgressMinutes, 30, 1440), lastVerifiedProgressAt: now,
478
607
  noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
479
608
  continuationPolicy: continuationMode(config.continuationPolicy),
@@ -488,6 +617,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
488
617
  }
489
618
  function freezeEnrollment(run, now) {
490
619
  if (!run.managed || run.managed.enrollment) return;
620
+ if (run.managed.purpose) {
621
+ const ledger = evidenceLedger(run.managed);
622
+ ledger.purposeRevision ||= 1;
623
+ ledger.charter ||= { objective: run.objective, criteria: structuredClone(run.managed.acceptanceCriteria), approvalScopes: [...run.managed.approvalScopes], frozenAt: now };
624
+ }
491
625
  run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
492
626
  deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
493
627
  }
@@ -496,7 +630,7 @@ export function pendingAcceptance(run) {
496
630
  if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
497
631
  JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
498
632
  check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
499
- return (run.managed?.acceptanceCriteria || []).filter(item => !Object.hasOwn(run.managed.verified, item.id))
633
+ return effectiveAcceptanceCriteria(run.managed).filter(item => !Object.hasOwn(run.managed.verified, item.id))
500
634
  .map(item => ({ id: item.id, description: item.description, check: item.check.type,
501
635
  detail: run.managed.criterionObservations?.[item.id]?.observed_result || (item.check.type === 'manual' ? 'Explicit user verification required' : 'Independent check not yet passed') }));
502
636
  }
@@ -508,7 +642,7 @@ export function deliveryAcceptanceContext(run) {
508
642
  `${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
509
643
  ` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
510
644
  }
511
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
645
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
512
646
  liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
513
647
  const link = await providerLinkState(root, provider);
514
648
  if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
@@ -551,7 +685,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
551
685
  const waiting = managedState?.decision?.outcome === 'wait';
552
686
  const queued = managedState?.capability === 'codex_app_server';
553
687
  const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
554
- maxFailures: positive(maxFailures, 3, 10),
688
+ ...(managedState?.purpose ? { ...(maxMinutes === undefined ? {} : { deadline: now + positive(maxMinutes, 120, 1440) * 60_000 }), ...(maxContinuations === undefined ? {} : { maxContinuations: positive(maxContinuations, 50, 1000) }) } : {}),
689
+ maxFailures: positive(maxFailures, 3, 10), maxFailuresSource: maxFailures === undefined ? 'default' : 'explicit',
555
690
  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',
556
691
  ...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
557
692
  if (executionCwd || previous.run?.executionCwd) run.executionCwd = executionCwd || previous.run.executionCwd;
@@ -579,9 +714,94 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
579
714
  return { ...previous, run };
580
715
  });
581
716
  }
582
- export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, now = Date.now() }) {
717
+ // Explicit UI opt-in upgrades the existing run without replacing its identity or budgets.
718
+ export async function enablePurposeDelivery({ root = rootDirectory(), provider, session, admission, purpose, expectedRunId, expectedRevision, now = Date.now() }) {
719
+ return updateRecord(root, provider, session, record => {
720
+ const run = record?.run;
721
+ if (!run || run.id !== expectedRunId || run.revision !== expectedRevision || provider !== 'codex' ||
722
+ admission?.source !== 'codex_app_server' || admission.session !== session || admission.cwd !== record.cwd || !admission.model ||
723
+ record.admission?.source !== admission.source || record.admission?.model !== admission.model) throw new Error('Exact session changed before automatic continuation.');
724
+ if (TERMINAL.has(run.state) || run.state === 'auto_resuming' || run.managed?.resumeLease || run.managed?.liveWorkspaceLease ||
725
+ ['pending', 'transferred', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('This task cannot change supervision while completed, executing, or handed off.');
726
+ if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted; automatic continuation cannot reset it.');
727
+ if (!run.managed) {
728
+ const plan = purposeDiagnosisPlan({ approvalScopes: ['task'] }, 'Read the original conversation, reconstruct its purpose and completion conditions, then choose necessary work.');
729
+ run.managed = managedSpec({ acceptanceCriteria: [], contextBootstrap: true, autonomyPolicy: 'autonomous', approvalScopes: ['task'], approvalPolicy: 'wait',
730
+ initialPlan: plan, initialAction: plan.alternatives[0].action, autoRecovery: true, resumeChannel: 'codex_app_server', resumeModel: admission.model, purpose },
731
+ session, provider, record.process, admission, now);
732
+ run.managed.purpose.previousCheckpoint = run.checkpoint;
733
+ run.checkpoint = null;
734
+ } else {
735
+ if (run.managed.resumeChannel !== 'codex_app_server' || run.managed.resumeModel !== admission.model) throw new Error('Automatic continuation requires the original verified provider channel.');
736
+ run.managed.purpose = { ...run.managed.purpose, ...purpose };
737
+ run.managed.autonomyPolicy = 'autonomous';
738
+ if (run.managed.plan && !run.managed.pendingApproval) recordDecision(run.managed, run.managed.plan, now);
739
+ }
740
+ run.managed.purpose.enabled = true;
741
+ // Preserve pending approvals/questions. The supervisor may investigate them,
742
+ // but enabling this switch does not grant external effects or native rights.
743
+ if (!run.managed.pendingApproval && !run.managed.dependencyWait && run.managed.phase !== 'needs_clarification' && !run.managed.userQuestion) {
744
+ if (run.managed.executionUncertain) prepareRecoveryDiagnosis(run, now);
745
+ run.managed.autoRecovery = true; run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
746
+ run.reason = 'Purpose-driven automatic continuation is enabled.';
747
+ } else if (run.state === 'paused') { run.state = 'blocked'; }
748
+ run.updatedAt = now;
749
+ return record;
750
+ });
751
+ }
752
+
753
+ // Called by the owning supervisor, never by an LLM. Each unchanged obstacle gets
754
+ // one bounded diagnostic attempt. It is not permission to replay the old action.
755
+ export function queuePurposeDiagnosis(run, reason, key, now = Date.now()) {
756
+ if (!purposeEnabled(run) || run.managed.resumeChannel !== 'codex_app_server' ||
757
+ ['paused', 'stopped', 'completed', 'budget_exhausted'].includes(run.state) || managedBudgetReason(run, now) ||
758
+ (!searchEnabled(run.managed) && run.managed.pendingApproval?.denied) || run.managed.providerWarning && !run.managed.nativeWait || run.managed.usageBlocked ||
759
+ ['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) return false;
760
+ const purpose = run.managed.purpose;
761
+ purpose.recoveries ||= [];
762
+ if (searchEnabled(run.managed)) {
763
+ // Back off the same obstacle; a newly observed obstacle or independently
764
+ // verified progress should not inherit an unrelated fifteen-minute delay.
765
+ const retryKey = digest(JSON.stringify([key, run.managed.verifiedRevision]));
766
+ if (purpose.searchRetryKey !== retryKey) {
767
+ purpose.searchRetryKey = retryKey;
768
+ purpose.searchRetries = 0;
769
+ purpose.nextSearchAt = 0;
770
+ }
771
+ if (now < (purpose.nextSearchAt || 0)) return false;
772
+ rememberSearchWait(run.managed, run.blocker || 'verification', {
773
+ action: run.managed.pendingApproval ? { kind: run.managed.pendingApproval.kind, scope: run.managed.pendingApproval.scope, summary: run.managed.pendingApproval.summary } : null,
774
+ reason, question: run.managed.userQuestion, condition: run.managed.dependencyWait?.condition,
775
+ owner: run.managed.dependencyWait?.owner,
776
+ }, now);
777
+ purpose.nextSearchAt = now + Math.min(15 * 60_000, 15_000 * 2 ** Math.min(6, purpose.searchRetries || 0));
778
+ purpose.searchRetries = (purpose.searchRetries || 0) + 1;
779
+ } else {
780
+ if (purpose.recoveries.includes(key)) return false;
781
+ if (purpose.recoveries.length >= 20) return false;
782
+ }
783
+ purpose.recoveries.push(key);
784
+ purpose.recoveries = purpose.recoveries.slice(-20);
785
+ if (run.managed.nativeWait) purpose.nativeBoundary ||= { ...run.managed.nativeWait, provider: run.managed.thread?.provider, state: 'pending', writer: 'unknown' };
786
+ purpose.lastRecovery = { reason: contextText(reason, 1000), at: now, previousBlocker: run.blocker || null,
787
+ pendingApproval: run.managed.pendingApproval ? { ...run.managed.pendingApproval } : null,
788
+ question: run.managed.understandingQuestion?.text || run.managed.userQuestion || null,
789
+ dependency: run.managed.dependencyWait || null };
790
+ recordDecision(run.managed, purposeDiagnosisPlan(run.managed, reason), now);
791
+ if (run.managed.executionUncertain) prepareRecoveryDiagnosis(run, now);
792
+ if (run.managed.phase === 'needs_clarification') run.managed.phase = 'understanding';
793
+ run.managed.userQuestion = null; run.managed.understandingQuestion = null; run.managed.dependencyWait = null;
794
+ run.managed.autoRecovery = true; run.managed.nextAttemptAt = now;
795
+ run.blocker = null; run.blockerSource = null;
796
+ run.checkpoint = null; run.consumedRevision = run.revision;
797
+ run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
798
+ run.nextStep = run.managed.decision.action.summary; run.reason = reason; run.updatedAt = now;
799
+ return true;
800
+ }
801
+
802
+ export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
583
803
  if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
584
- if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, now });
804
+ if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, sourceContext, controllerEvidence, now });
585
805
  try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
586
806
  const statuses = ['in_progress', 'completed', 'blocked', 'error'];
587
807
  if (!checkpoint || !statuses.includes(checkpoint.status)) throw new Error('Checkpoint status must be in_progress, completed, blocked, or error.');
@@ -601,6 +821,8 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
601
821
  if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
602
822
  if (['paused', 'verification_pending'].includes(run.state)) throw new Error('Managed task is paused or awaiting acceptance verification.');
603
823
  if (run.managed) {
824
+ if (searchEnabled(run.managed) && expectedLease && sourceContext) run.managed.purpose.originalContext ||= sourceContext;
825
+ if (searchEnabled(run.managed) && expectedLease) retainControllerEvidence(run.managed, controllerEvidence, now);
604
826
  const performed = run.managed.autonomyPolicy ? structuredClone(run.managed.decision) : null;
605
827
  let actionResult = null;
606
828
  if (performed) {
@@ -609,11 +831,29 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
609
831
  actionResult = { decisionId: performed.id, status: result.status, summary: text(result.summary, 300), evidence: Array.isArray(result.evidence) ? result.evidence.map(item => text(item, 300)).filter(Boolean).slice(0, 5) : [], source: 'provider_reported' };
610
832
  if (result.status === 'observed' && !actionResult.evidence.length) throw new Error('Observed action result needs concrete evidence.');
611
833
  }
834
+ if (purposeEnabled(run)) {
835
+ const workerPlan = checkpoint.plan ? assessedPlan(checkpoint.plan) : null;
836
+ const review = validatePurposeReview(checkpoint.purposeReview, workerPlan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed);
837
+ recordPurposeReview(run.managed, review, now);
838
+ if (!searchEnabled(run.managed) && checkpoint.status === 'completed' && review.rules.some(rule => ['failed', 'unverified'].includes(rule.status))) {
839
+ effectiveStatus = 'in_progress';
840
+ repairPlan = purposeDiagnosisPlan(run.managed, 'Completion is unproven: resolve failed/unverified workflow rules against the original purpose.');
841
+ nextStep = repairPlan.alternatives[0].action.summary;
842
+ }
843
+ }
612
844
  const previouslyVerified = Object.keys(run.managed.verified).length;
613
845
  run.managed.remainingWork = text(checkpoint.remainingWork, 4000) || run.managed.remainingWork;
614
846
  await verifyAutomaticCriteria(record, now);
847
+ recordPurposeProgress(run, now);
848
+ if (searchEnabled(run.managed) && performed && actionResult) {
849
+ const unit = evidenceLedger(run.managed).units?.find(row => row.id === performed.id);
850
+ if (unit) { unit.status = actionResult.status === 'failed' ? 'failed' : actionResult.status === 'not_run' ? 'waiting' : 'observed'; unit.evidenceRefs = (toolEvidence || []).map(row => row.ref || row.digest); unit.updatedAt = now;
851
+ if (unit.status === 'observed' && unit.criterionIds.length && unit.criterionIds.every(id=>run.managed.verified[id] && !['semantic','manual'].includes(run.managed.acceptanceCriteria.find(row=>row.id===id)?.check.type))) {unit.status='verified';unit.verificationSource='controller_automatic_criteria';}
852
+ }
853
+ }
854
+ if (searchEnabled(run.managed) && performed && actionResult) recordSearchExperiment(run, performed, actionResult, run.managed.purpose.lastReview, expectedLease ? toolEvidence : [], now);
615
855
  if (run.managed.autonomyPolicy && checkpoint.status === 'completed') {
616
- const missing = run.managed.acceptanceCriteria.filter(item => item.check.type !== 'manual' && !Object.hasOwn(run.managed.verified, item.id));
856
+ const missing = run.managed.acceptanceCriteria.filter(item => !['manual', 'semantic'].includes(item.check.type) && !Object.hasOwn(run.managed.verified, item.id));
617
857
  if (missing.length) {
618
858
  effectiveStatus = 'in_progress';
619
859
  repairPlan = checkpoint.plan && nextStep ? assessedPlan(checkpoint.plan) : missingAcceptanceDiagnosis(run.managed, missing);
@@ -633,7 +873,9 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
633
873
  const selected = plan.alternatives.find(item => item.id === plan.selectedOptionId);
634
874
  if (checkpoint.proposedAction && JSON.stringify(actionDescriptor(checkpoint.proposedAction)) !== JSON.stringify(selected.action)) throw new Error('Proposed action differs from selected assessed option.');
635
875
  recordDecision(run.managed, plan, now);
636
- if (actionResult.status === 'not_run' || actionResult.status === 'failed' && run.managed.decision.action.kind !== 'read') {
876
+ if (purposeEnabled(run) && !searchEnabled(run.managed) && ['failed', 'not_run'].includes(actionResult.status)) {
877
+ recordDecision(run.managed, purposeDiagnosisPlan(run.managed, `Investigate the ${actionResult.status} action before further changes: ${actionResult.summary}`), now);
878
+ } else if (!searchEnabled(run.managed) && (actionResult.status === 'not_run' || actionResult.status === 'failed' && run.managed.decision.action.kind !== 'read')) {
637
879
  run.managed.decision.outcome = 'wait'; run.managed.decision.reason = actionResult.status === 'not_run' ? 'Previous selected action was not run.' : 'Failed action requires a read-only diagnosis before another change.';
638
880
  run.managed.pendingApproval = { id: run.managed.decision.id, ...run.managed.decision.action, reason: run.managed.decision.reason };
639
881
  }
@@ -648,8 +890,12 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
648
890
  const plan = assessedPlan(checkpoint.plan);
649
891
  if (JSON.stringify(plan.alternatives.find(item => item.id === plan.selectedOptionId).action) !== JSON.stringify(proposed)) throw new Error('Authorization request must match the selected assessed option.');
650
892
  const decision = recordDecision(run.managed, plan, now);
651
- decision.outcome = 'wait'; decision.reason = 'Provider authorization requires an exact user decision.';
652
- run.managed.pendingApproval = { id: decision.id, ...proposed, reason: decision.reason };
893
+ if (purposeEnabled(run) && run.managed.autonomyPolicy === 'autonomous' && decision.outcome !== 'wait') {
894
+ effectiveStatus = 'in_progress';
895
+ } else {
896
+ decision.outcome = 'wait'; decision.reason = 'Provider authorization requires an exact user decision.';
897
+ run.managed.pendingApproval = { id: decision.id, ...proposed, reason: decision.reason };
898
+ }
653
899
  } else {
654
900
  run.managed.proposedAction = proposed;
655
901
  run.managed.pendingApproval = { id: digest(`${run.managed.taskId}:${run.revision + 1}:${JSON.stringify(proposed)}`), ...proposed };
@@ -665,10 +911,22 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
665
911
  if (!text(wait?.condition, 1000) || !text(wait?.owner, 200) || !text(wait?.nextCheck, 1000) || !nextStep) throw new Error('External dependency needs waitCondition {condition, owner, nextCheck} and a nextStep.');
666
912
  run.managed.dependencyWait = { id: randomUUID(), condition: text(wait.condition, 1000), owner: text(wait.owner, 200), nextCheck: text(wait.nextCheck, 1000), createdAt: now };
667
913
  }
914
+ if (searchEnabled(run.managed) && checkpoint.status === 'blocked') {
915
+ rememberSearchWait(run.managed, checkpoint.blocker, { reason: checkpoint.summary, question: checkpoint.question,
916
+ condition: checkpoint.waitCondition?.condition, owner: checkpoint.waitCondition?.owner, action: checkpoint.approval || null }, now);
917
+ if (checkpoint.plan && checkpoint.blocker !== 'authorization' && checkpoint.plan.alternatives?.find(o => o.id === checkpoint.plan.selectedOptionId)?.independent === true) recordDecision(run.managed, assessedPlan(checkpoint.plan), now);
918
+ if (checkpoint.plan && run.managed.decision?.outcome !== 'wait' && (checkpoint.blocker === 'authorization' || run.managed.plan.alternatives.find(o => o.id === run.managed.plan.selectedOptionId)?.independent)) {
919
+ effectiveStatus = 'in_progress'; nextStep = run.managed.decision.action.summary;
920
+ run.managed.userQuestion = null; run.managed.dependencyWait = null;
921
+ }
922
+ }
923
+ run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
668
924
  run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
669
925
  run.revision += 1; run.updatedAt = now;
670
926
  recordManagedOutcomeProgress(run, now);
671
- if (run.managed && effectiveStatus === 'completed') {
927
+ if (searchEnabled(run.managed) && effectiveStatus === 'completed') {
928
+ queuePurposeVerification(run, checkpoint.summary, now);
929
+ } else if (run.managed && effectiveStatus === 'completed') {
672
930
  const pending = pendingAcceptance(run);
673
931
  if (pending.length) { run.state = 'verification_pending'; run.reason = acceptancePendingReason(run); }
674
932
  else if (run.managed.contextBootstrap && !run.managed.acceptanceCriteria.length) {
@@ -684,11 +942,121 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
684
942
  delete run.managed.executionUncertain;
685
943
  delete run.managed.recoveryDiagnosisDecisionId;
686
944
  }
945
+ for (const request of run.managed?.consistencyOutbox || []) if (request.decisionId && request.decisionId === checkpoint.actionResult?.decisionId) {
946
+ request.stage = checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed';
947
+ request.observedAt = now; request.result = checkpoint.actionResult.status;
948
+ }
687
949
  notifyState(run, now);
688
950
  return record;
689
951
  });
690
952
  return settled;
691
953
  }
954
+ export function queuePurposeVerification(run, claim, now) {
955
+ const managed = run.managed, state = searchState(managed);
956
+ recordDecision(managed, purposeDiagnosisPlan(managed, 'Independently inspect the requested outcome and return specific findings.'), now);
957
+ state.verificationDecisionId = managed.decision.id;
958
+ state.completionClaim = text(claim, 1000);
959
+ state.phase = 'verifying';
960
+ run.checkpoint = null; run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
961
+ run.nextStep = 'Independently verify the requested outcome.';
962
+ run.reason = run.nextStep; managed.autoRecovery = true;
963
+ }
964
+ function externalReviewIsCurrent(managed,review,now) {
965
+ const refs=[...(review.criteria||[]).filter(row=>row.passed),...(review.rules||[]).filter(row=>row.status==='satisfied'),...(review.workUnits||[])].flatMap(row=>row.evidenceRefs||[]).filter(link=>/^[a-f0-9]{64}$/.test(link.ref));
966
+ const ledger=managed.purpose.evidence;
967
+ const observations=[...(ledger?.observations||[]),...(ledger?.reviewObservationActionId===(review.decisionId||managed.decision.id) ? ledger.reviewObservations||[] : [])];
968
+ return refs.every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
969
+ }
970
+ export async function settlePurposeVerification({ root, provider, session, expectedLease, review, verifySnapshot, now = Date.now() }) {
971
+ const started = Date.now(), initialNow = now;
972
+ const snapshot = (await listRecords(root)).find(record => record.provider === provider && record.session === session);
973
+ if (!snapshot || snapshot.run?.state !== 'auto_resuming' || snapshot.run.managed?.resumeLease?.id !== expectedLease) throw new Error('Independent review lease changed.');
974
+ // Keep potentially large IO outside the state lock so pause/stop can persist.
975
+ const currentDigest = await (verifySnapshot ? verifySnapshot(snapshot) : workspaceIdentity(taskDirectory(snapshot), root, undefined, { excludedPaths: review?.manifest?.source === 'controller_certified_independent_proposal' ? review.manifest.exclusions.map(row => row.path) : [] }));
976
+ return updateRecord(root, provider, session, async record => {
977
+ now = initialNow + Math.max(0, Date.now() - started);
978
+ const run = record?.run, managed = run?.managed;
979
+ if (!searchEnabled(managed) || !expectedLease || run.state !== 'auto_resuming' || managed.resumeLease?.id !== expectedLease ||
980
+ managed.purpose.search?.verificationDecisionId !== managed.decision.id || run.revision !== managed.resumeLease.checkpointRevision || now >= run.deadline) throw new Error('Independent review lease changed.');
981
+ const state = searchState(managed);
982
+ if (review?.source !== 'independent_codex_session' || !review.session || review.session === session || !review.workspaceDigest || typeof review.passed !== 'boolean') throw new Error('Independent review provenance is missing.');
983
+ retainApplicableRules(managed, review.rules || []);
984
+ review = assessRuleReview(review, managed);
985
+ if (currentDigest !== review.workspaceDigest) {
986
+ review = { ...review, passed: false, needsHuman: false, findings: [...review.findings, 'Workspace changed before review settlement; verify the new revision.'] };
987
+ }
988
+ const nativeResultReview=managed.purpose.nativeBoundary?.state==='writer_completed_requires_verification' && managed.purpose.nativeBoundary.completedTurnId===managed.purpose.nativeBoundary.turnId;
989
+ if ((managed.purpose.nativeBoundary && !nativeResultReview) || managed.executionUncertain || pendingAcceptance(run).some(row => row.id === 'enrollment')) {
990
+ review = { ...review, passed: false, needsHuman: false, findings: [...review.findings,
991
+ 'Required operation, effect reconciliation or original-purpose contract remains unresolved. Preserve the waiting unit and continue independent authorized work.'] };
992
+ }
993
+ if(!externalReviewIsCurrent(managed,review,now)) review={...review,passed:false,needsHuman:false,findings:[...review.findings,'Controller external evidence expired before settlement; reacquire the same authorized observations and independently re-evaluate without replaying writes.']};
994
+ const openFindings = reconcileFindings(managed, review, now);
995
+ const units=evidenceLedger(managed).units;
996
+ for (const result of (review.passed || review.needsHuman) ? review.workUnits || [] : []) {
997
+ const unit=units.find(row=>row.id===result.id);
998
+ if (!unit || !['verified','superseded'].includes(result.status) || !result.evidenceRefs?.length ||
999
+ unit.criterionIds.some(id=>!review.criteria.some(row=>row.id===id && row.passed))) throw new Error('Required unit resolution needs independent criterion-linked evidence.');
1000
+ if (unit.purposeRevision !== evidenceLedger(managed).purposeRevision) throw new Error('Unit belongs to an old purpose revision.');
1001
+ unit.status=result.status;unit.evidenceRefs=result.evidenceRefs;unit.verifiedAt=now;
1002
+ }
1003
+ state.review = review; delete state.verificationDecisionId;
1004
+ run.revision += 1; run.updatedAt = now;
1005
+ await verifyAutomaticCriteria(record, now);
1006
+ if (review.passed || review.needsHuman) {
1007
+ for (const criterion of effectiveAcceptanceCriteria(managed)) {
1008
+ if (criterion.check.type === 'semantic' && review.criteria.some(row => row.id === criterion.id && row.passed)) {
1009
+ managed.verified[criterion.id] = { source: 'independent_codex_session', checkedAt: now, workspaceDigest: review.workspaceDigest, session: review.session };
1010
+ }
1011
+ }
1012
+ for (const unit of units) if (unit.id.startsWith('criterion:') && unit.criterionIds.every(id=>managed.verified[id])) {unit.status='verified';unit.evidenceRefs=review.criteria.filter(row=>unit.criterionIds.includes(row.id)).flatMap(row=>row.evidenceRefs || []);}
1013
+ const requiredPending=units.filter(row=>row.required && row.purposeRevision === evidenceLedger(managed).purposeRevision && !['verified','superseded'].includes(row.status));
1014
+ const pending = effectiveAcceptanceCriteria(managed).filter(c => !Object.hasOwn(managed.verified, c.id));
1015
+ if (requiredPending.length && (!pending.every(c=>c.check.type==='manual') || !pending.length)) review={...review,passed:false,needsHuman:false,findings:[...review.findings,'Required work units still waiting: '+requiredPending.map(row=>row.id).join(', ')]};
1016
+ if((review.passed || review.needsHuman) && nativeResultReview){delete managed.purpose.nativeBoundary;managed.nativeWait=null;}
1017
+ if (review.passed && !pending.length && !managed.purpose.nativeBoundary && !managed.executionUncertain && pendingAcceptance(run).length === 0) {
1018
+ for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
1019
+ run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
1020
+ run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
1021
+ managed.autoRecovery = false;
1022
+ for (const request of managed.consistencyOutbox || []) {
1023
+ request.stage = 'resolved'; request.verifiedAt = now; request.reviewerSession = review.session;
1024
+ request.workspaceDigest = review.workspaceDigest;
1025
+ }
1026
+ } else if (pending.length && pending.every(c => c.check.type === 'manual')) {
1027
+ run.state = 'verification_pending'; run.reason = 'Only explicitly human acceptance remains.'; state.phase = 'waiting';
1028
+ run.checkpoint = { status: 'completed', summary: review.summary }; managed.autoRecovery = false;
1029
+ } else review = { ...review, passed: false, needsHuman: false, findings: ['Local acceptance changed during review; investigate and recheck.'] };
1030
+ }
1031
+ state.review={...review,decisionId:managed.decision.id};
1032
+ if (!review.passed && !review.needsHuman && openFindings.some(item => item.observation)) {
1033
+ const ledger = evidenceLedger(managed);
1034
+ const lookup = digest(JSON.stringify(openFindings.filter(item => item.observation).map(item => [item.id, item.observation])));
1035
+ if (state.observerLookup !== lookup) {
1036
+ state.observerLookup = lookup;
1037
+ queuePurposeVerification(run, 'Acquire the requested scoped external observations and independently re-evaluate the outcome.', now);
1038
+ notifyState(run, now); return record;
1039
+ }
1040
+ }
1041
+ if (!review.passed && !review.needsHuman && openFindings.length && openFindings.every(item => item.owner === 'controller')) {
1042
+ const ledger = evidenceLedger(managed);
1043
+ const lookup = createHash('sha256').update(JSON.stringify({ findings: openFindings.map(item => item.id).sort(), receipts: ledger.receipts.map(item => item.id), baseline: ledger.baseline?.digest, workspace: review.workspaceDigest })).digest('hex');
1044
+ if (state.controllerLookup !== lookup && (ledger.baseline || ledger.receipts.length)) {
1045
+ state.controllerLookup = lookup;
1046
+ queuePurposeVerification(run, 'Controller retrieved existing baseline and tool receipts; reconcile the missing evidence against the supplied ledger before requesting worker repairs.', now);
1047
+ notifyState(run, now); return record;
1048
+ }
1049
+ }
1050
+ if (!review.passed && !review.needsHuman) {
1051
+ const reason = `Outcome review found remaining work: ${[review.summary, ...openFindings.map(findingText)].join(' ').slice(0, 1800)}`;
1052
+ recordDecision(managed, purposeDiagnosisPlan(managed, reason), now);
1053
+ state.phase = 'repairing'; managed.purpose.lastRecovery = { reason, at: now };
1054
+ run.checkpoint = null; run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
1055
+ run.nextStep = managed.decision.action.summary; run.reason = reason; managed.autoRecovery = true;
1056
+ }
1057
+ notifyState(run, now); return record;
1058
+ });
1059
+ }
692
1060
  export async function checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now = Date.now() }) {
693
1061
  cwd = await realpath(cwd);
694
1062
  return updateRecord(root, provider, session, record => {
@@ -711,12 +1079,12 @@ export async function checkpointActionAssociation({ root, provider, session, cwd
711
1079
  run.checkpoint = job.saved.checkpoint || null;
712
1080
  run.revision += 1; run.updatedAt = now; managed.associationCheckpointRevision = run.revision;
713
1081
  association.previousCheckpoint = job.saved.checkpoint || null;
714
- if (expectedLease) run.continuations += 1;
1082
+ if (expectedLease && !searchEnabled(run.managed)) run.continuations += 1;
715
1083
  run.reason = 'Action matching finished; original selected work remains unchanged.';
716
1084
  return record;
717
1085
  });
718
1086
  }
719
- async function checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, now }) {
1087
+ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, sourceContext, controllerEvidence, now }) {
720
1088
  try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
721
1089
  if (!text(checkpoint.summary, 1000)) throw new Error('Understanding checkpoint needs a summary.');
722
1090
  return updateRecord(root, provider, session, async record => {
@@ -732,6 +1100,10 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
732
1100
  if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
733
1101
  const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan) : null;
734
1102
  if (plan && JSON.stringify(plan.alternatives.find(item => item.id === plan.selectedOptionId).action) !== JSON.stringify(understanding.nextAction)) throw new Error('Understood next action differs from selected assessed option.');
1103
+ if (purposeEnabled(run)) recordPurposeReview(run.managed,
1104
+ validatePurposeReview(checkpoint.purposeReview, plan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed), now);
1105
+ if (searchEnabled(run.managed) && expectedLease && sourceContext) run.managed.purpose.originalContext ||= sourceContext;
1106
+ if (searchEnabled(run.managed) && expectedLease) retainControllerEvidence(run.managed, controllerEvidence, now);
735
1107
  run.managed.sessionUnderstanding = understanding;
736
1108
  run.managed.understandingRevision = (run.managed.understandingRevision || 0) + 1;
737
1109
  run.managed.understandingAnswer = null;
@@ -742,9 +1114,9 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
742
1114
  run.managed.workHistory = run.managed.workHistory.slice(-10);
743
1115
  run.checkpoint = { kind: 'session_understanding', status: 'in_progress', summary: contextText(checkpoint.summary, 1000), evidence: [], acceptanceMet: false };
744
1116
  run.revision += 1; run.updatedAt = now;
745
- if (expectedLease) run.continuations += 1;
1117
+ if (expectedLease && !searchEnabled(run.managed)) run.continuations += 1;
746
1118
  if (result.status !== 'observed') run.failures += 1;
747
- if (run.failures >= run.maxFailures) {
1119
+ if (failureLimitReached(run)) {
748
1120
  run.state = 'failed'; run.reason = 'Repeated session understanding failures; inspect the original conversation.';
749
1121
  notifyState(run, now); return record;
750
1122
  }
@@ -769,6 +1141,12 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
769
1141
  run.managed.phase = 'active';
770
1142
  run.state = 'reported_done';
771
1143
  run.reason = 'Original conversation reports no remaining work; independent completion was not verified.';
1144
+ if (purposeEnabled(run)) {
1145
+ run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded').map(item => ({ id: item.id, description: item.description,
1146
+ check: item.check.type === 'file_equals' ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
1147
+ if (searchEnabled(run.managed)) queuePurposeVerification(run, 'Original conversation reports completion.', now);
1148
+ else queuePurposeDiagnosis(run, 'The conversation reports completion. Check the original purpose against actual artifacts and outcomes before accepting that claim.', 'reported_completion', now);
1149
+ }
772
1150
  } else {
773
1151
  run.managed.phase = 'needs_clarification';
774
1152
  run.managed.understandingQuestion = { id: randomUUID(), text: understanding.question, revision: run.managed.understandingRevision };
@@ -806,7 +1184,11 @@ export async function verifyAutomaticCriteria(record, now) {
806
1184
  };
807
1185
  const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
808
1186
  for (const criterion of managed.acceptanceCriteria) {
809
- if (criterion.check.type === 'manual') continue;
1187
+ if (criterion.check.type === 'manual') {
1188
+ if (managed.verified[criterion.id]?.source !== 'user') delete managed.verified[criterion.id];
1189
+ continue;
1190
+ }
1191
+ if (criterion.check.type === 'semantic') continue;
810
1192
  const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
811
1193
  if (!path) { failed(criterion, 'Frozen acceptance file path is absent'); continue; }
812
1194
  const directory = taskDirectory(record);
@@ -828,7 +1210,17 @@ export async function verifyAutomaticCriteria(record, now) {
828
1210
  } catch { failed(criterion, 'Acceptance file could not be independently read'); }
829
1211
  }
830
1212
  }
1213
+ function purposeCompletionBarrier(run) {
1214
+ const managed=run.managed;
1215
+ if(!searchEnabled(managed)) return null;
1216
+ if(managed.purpose.nativeBoundary || managed.executionUncertain) return 'Required original operation or effect reconciliation is still unresolved.';
1217
+ const ledger=evidenceLedger(managed);
1218
+ for(const unit of ledger.units) if(unit.purposeRevision===ledger.purposeRevision && unit.id.startsWith('criterion:') && unit.criterionIds.every(id=>managed.verified[id])) unit.status='verified';
1219
+ const pending=ledger.units.filter(unit=>unit.required && unit.purposeRevision===ledger.purposeRevision && !['verified','superseded'].includes(unit.status));
1220
+ return pending.length ? 'Required work units still waiting: '+pending.map(unit=>unit.id).join(', ') : null;
1221
+ }
831
1222
  export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
1223
+ const started=Date.now(), initialNow=now;
832
1224
  return updateRecord(root, provider, session, async record => {
833
1225
  const run = record?.run;
834
1226
  if (!run?.managed || run.state !== 'verification_pending') throw new Error('Managed task is not awaiting acceptance verification.');
@@ -837,10 +1229,22 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
837
1229
  if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
838
1230
  newVerifiedCriterion(run, criterionId, now);
839
1231
  run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
1232
+ recordPurposeProgress(run, now, false);
1233
+ if (searchEnabled(run.managed)) {
1234
+ const review = run.managed.purpose.search?.review;
1235
+ const currentDigest = await workspaceIdentity(taskDirectory(record), root, undefined, { excludedPaths: review?.manifest?.source === 'controller_certified_independent_proposal' ? review.manifest.exclusions.map(row => row.path) : [] }).catch(() => null);
1236
+ now=initialNow+Math.max(0,Date.now()-started);
1237
+ if (review?.source !== 'independent_codex_session' || !review.session || review.session===session || (!review.passed && !review.needsHuman) || !review.workspaceDigest || currentDigest !== review.workspaceDigest || !externalReviewIsCurrent(run.managed,review,now)) {
1238
+ for (const c of run.managed.acceptanceCriteria) if (c.check.type === 'semantic') delete run.managed.verified[c.id];
1239
+ queuePurposeVerification(run, 'Human decision recorded; recheck the changed workspace before completing.', now);
1240
+ }
1241
+ }
840
1242
  if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
841
1243
  await verifyAutomaticCriteria(record, now);
842
1244
  if (!pendingAcceptance(run).length) {
843
- run.state = 'completed'; run.reason = run.checkpoint.summary;
1245
+ const barrier=purposeCompletionBarrier(run);
1246
+ if(barrier) queuePurposeVerification(run, 'Human decision recorded; '+barrier+' Independently reconcile the required operation before completing.', now);
1247
+ else {run.state = 'completed'; run.reason = run.checkpoint.summary;}
844
1248
  }
845
1249
  }
846
1250
  notifyState(run, now);
@@ -884,13 +1288,14 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
884
1288
  export const STOP_CODES = Object.freeze({ failureLimit: 'failure_limit', noProgressTime: 'no_progress_time', noProgressAttempts: 'no_progress_attempts' });
885
1289
  export function deliveryStop(run, now = Date.now(), { checkProgress = true } = {}) {
886
1290
  const managed = run.managed;
887
- if (run.failures >= run.maxFailures) return { code: STOP_CODES.failureLimit,
1291
+ if (failureLimitReached(run)) return { code: STOP_CODES.failureLimit,
888
1292
  reason: `Stopped after ${run.failures} failed attempts. Open the original conversation to see what went wrong.` };
889
- if (!checkProgress || !managed) return null;
1293
+ if (purposeEnabled(run) && (now >= run.deadline || run.continuations >= run.maxContinuations || managed.usageBlocked || managed.maxTokens && managed.tokensUsed >= managed.maxTokens)) return { code: 'purpose_budget', reason: 'Original purpose continuation budget exhausted.' };
1294
+ if (!checkProgress || !managed || searchEnabled(managed)) return null;
890
1295
  const minutes = managed.maxNoProgressMinutes ?? 30;
891
- if (now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= minutes * 60_000) return { code: STOP_CODES.noProgressTime,
892
- reason: `Paused because no progress was confirmed in ${minutes} minutes. Review the original conversation, then resume.` };
893
- if (managed.noProgress >= managed.maxNoProgress) return { code: STOP_CODES.noProgressAttempts,
1296
+ if (now - (purposeEnabled(run) ? purposeProgressAt(run, now) : (managed.lastVerifiedProgressAt ?? run.startedAt)) >= minutes * 60_000) return { code: STOP_CODES.noProgressTime,
1297
+ reason: purposeEnabled(run) ? `No verified acceptance progress in ${minutes} minutes. Review the original conversation, then resume.` : `Paused because no progress was confirmed in ${minutes} minutes. Review the original conversation, then resume.` };
1298
+ if ((purposeEnabled(run) ? managed.purpose.noLearning || 0 : managed.noProgress) >= managed.maxNoProgress) return { code: STOP_CODES.noProgressAttempts,
894
1299
  reason: `Paused after ${managed.noProgress} steps without confirmed progress. Review the original conversation, then resume.` };
895
1300
  return null;
896
1301
  }
@@ -920,7 +1325,7 @@ function continueAfterHumanDecision(run, now) {
920
1325
  run.managed.outcomeProgress = restartOutcomeProgressWindow(run.managed.outcomeProgress);
921
1326
  run.managed.lastVerifiedProgressAt = now;
922
1327
  }
923
- const stoppedState = stop => stop.code === STOP_CODES.failureLimit ? 'budget_exhausted' : 'paused';
1328
+ const stoppedState = stop => [STOP_CODES.failureLimit, 'purpose_budget'].includes(stop.code) ? 'budget_exhausted' : 'paused';
924
1329
  // Context-first tasks usually have no host-checkable acceptance, so worker
925
1330
  // reports can never reset noProgress. Reaching that limit pauses for review
926
1331
  // instead of failing permanently and ending Auto-continue for the conversation.
@@ -956,7 +1361,7 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
956
1361
  }
957
1362
  // Manual/legacy runs have no marker and retain their existing controls.
958
1363
  export function automaticContinuationAllowed(record) {
959
- if (record?.providerArchived === true) return false;
1364
+ if (record?.providerArchived === true || record?.run?.managed?.purpose && record.autoContinue === false) return false;
960
1365
  const epoch = record?.run?.managed?.autoContinueEpoch;
961
1366
  return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
962
1367
  }
@@ -1002,7 +1407,7 @@ export function decideStop(run, now = Date.now()) {
1002
1407
  if (!fresh || cp.status === 'error') next.failures += 1;
1003
1408
  else next.failures = 0;
1004
1409
  }
1005
- if (next.failures >= next.maxFailures) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
1410
+ if (failureLimitReached(next)) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
1006
1411
  if (next.managed) {
1007
1412
  if (next.managed.autonomyPolicy && next.managed.decision?.outcome === 'wait' && !approvedHook) {
1008
1413
  escalate(next, next.managed.decision.reason);
@@ -1027,7 +1432,20 @@ export function decideStop(run, now = Date.now()) {
1027
1432
  else if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind)) next.managed.noProgress += 1;
1028
1433
  if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
1029
1434
  next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
1030
- if (next.managed.noProgress >= next.managed.maxNoProgress) {
1435
+ const repeated = repeatedSearchAction(next);
1436
+ if (repeated && !next.managed.pendingApproval && !next.managed.executionUncertain &&
1437
+ queuePurposeDiagnosis(next, `This action failed ${repeated.failures} times against the same observed workspace and acceptance state. Investigate the failure, choose a different method or acquire new evidence before retrying. Prior result: ${repeated.finding}`, `repeated:${repeated.actionKey}:${repeated.context}`, now)) {
1438
+ markSearchPivot(next.managed, 'Repeated failed experiment; select a discriminating alternative.', now);
1439
+ return { run: next, output: {} };
1440
+ }
1441
+ if (searchNeedsPivot(next, now)) {
1442
+ const reason = 'Change the search strategy: review experiments, identify what was ruled out and select a different discriminating method. Lack of final acceptance is not failure.';
1443
+ markSearchPivot(next.managed, reason, now);
1444
+ next.managed.purpose.lastRecovery = { reason, at: now };
1445
+ }
1446
+ if (!searchEnabled(next.managed) && purposeEnabled(next) && next.managed.purpose.noLearning === next.managed.maxNoProgress - 1 &&
1447
+ queuePurposeDiagnosis(next, 'No acceptance progress: revisit the purpose, gather discriminating evidence and choose a different method.', `progress:${next.managed.verifiedRevision}`, now)) return { run: next, output: {} };
1448
+ if (!searchEnabled(next.managed) && next.managed.noProgress >= next.managed.maxNoProgress) {
1031
1449
  const stop = deliveryStop(next, now);
1032
1450
  escalate(next, stop.reason);
1033
1451
  return finish(pausesWithoutProgress(next.managed) ? 'paused' : 'failed', stop.reason);
@@ -1038,7 +1456,7 @@ export function decideStop(run, now = Date.now()) {
1038
1456
  if (replanHook) next.managed.awaitingReplanHook = false;
1039
1457
  if (understandingHook) { next.managed.awaitingUnderstandingHook = false; next.managed.awaitingAssociationHook = false; }
1040
1458
  if (cp?.nextStep) next.nextStep = cp.nextStep;
1041
- if (!(next.managed?.resumeChannel === 'codex_app_server' && (next.managed.associationCheckpointRevision === next.revision || next.managed.contextBootstrap && cp?.kind === 'session_understanding'))) next.continuations += 1;
1459
+ if (!searchEnabled(next.managed) && !(next.managed?.resumeChannel === 'codex_app_server' && (next.managed.associationCheckpointRevision === next.revision || next.managed.contextBootstrap && cp?.kind === 'session_understanding'))) next.continuations += 1;
1042
1460
  next.updatedAt = now; next.state = 'active';
1043
1461
  if (approvedHook || legacyApprovedHook) {
1044
1462
  const approval = next.managed.approvedAction;
@@ -1051,7 +1469,7 @@ export function decideStop(run, now = Date.now()) {
1051
1469
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
1052
1470
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
1053
1471
  const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? `${minimalSufficientChangeInstruction()}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\nCheck the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
1054
- return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
1472
+ return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\n${purposeContract(next.managed)}\nStay within the original request and permissions. If one action lacks authorization or information, preserve that dependency and select independent authorized work. Resume this same task; do not start a replacement loop.` } };
1055
1473
  }
1056
1474
  // A process signature protects against PID reuse. Never treat a stale timer alone
1057
1475
  // as a crash; an idle provider may be waiting for its user.
@@ -1112,7 +1530,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1112
1530
  if (!TERMINAL.has(record.run.state) && !['paused', 'verification_pending'].includes(record.run.state)) {
1113
1531
  if (!(record.run.state === 'blocked' && record.run.managed.pendingApproval)) record.run.state = 'recovery_required';
1114
1532
  record.run.recoveryCause = 'hook_owner_mismatch';
1115
- record.run.reason = 'A different or unverified Claude process reported this session. Inspect and explicitly resume in the original conversation.';
1533
+ record.run.reason = 'A different or unverified provider process reported this session. Inspect and explicitly resume in the original conversation.';
1116
1534
  record.run.managed.autoRecovery = false; record.run.updatedAt = now; notifyState(record.run, now);
1117
1535
  }
1118
1536
  return record;
@@ -1179,7 +1597,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1179
1597
  run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
1180
1598
  } else if (event === 'StopFailure') {
1181
1599
  run.failures += 1;
1182
- run.state = run.failures >= run.maxFailures ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
1600
+ run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
1183
1601
  run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
1184
1602
  run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
1185
1603
  } else if (event === 'Stop') {
@@ -1256,7 +1674,7 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
1256
1674
  const managed = run?.managed;
1257
1675
  const supported = managed?.resumeChannel === 'codex_app_server' && provider === 'codex' ||
1258
1676
  managed?.capability === 'live_hook' && provider === 'claude' && Boolean(managed.liveHookOwner?.identity);
1259
- return Boolean(supported && managed.autonomyPolicy && managed.approvalScopes?.length && !managed.rejectedActionOverflow &&
1677
+ return canResearchReplan(run, provider, now) || Boolean(supported && managed.autonomyPolicy && managed.approvalScopes?.length && !managed.rejectedActionOverflow &&
1260
1678
  run.state === 'blocked' && managed.pendingApproval?.denied === true &&
1261
1679
  !deliveryStopBlocksResume(run, now));
1262
1680
  }
@@ -1265,7 +1683,7 @@ export function canChangeDeliveryContinuation(run) {
1265
1683
  !['pending', 'pending_received', 'transferred'].includes(run.managed.handoff?.status));
1266
1684
  }
1267
1685
  function reopenLegacyLimitStop(run, now) {
1268
- if (run?.state !== 'budget_exhausted' ||
1686
+ if (purposeEnabled(run) || run?.state !== 'budget_exhausted' ||
1269
1687
  deliveryStop(run, now)?.code === STOP_CODES.failureLimit ||
1270
1688
  // Reasons persisted by older versions for stops that were not obsolete caps.
1271
1689
  /No verified|failure limit/i.test(run.reason || '') ||
@@ -1277,9 +1695,9 @@ function reopenLegacyLimitStop(run, now) {
1277
1695
  }
1278
1696
  run.state = 'paused'; run.reason = 'SIMY no longer limits time or turns. Resume this task to continue.';
1279
1697
  }
1280
- export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, action, now = Date.now() }) {
1698
+ export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, action, inspectProcess = processIdentity, now = Date.now() }) {
1281
1699
  for (let retry = 0; ; retry += 1) {
1282
- try { return await updateRecord(root, provider, session, record => {
1700
+ try { return await updateRecord(root, provider, session, async record => {
1283
1701
  const run = record?.run;
1284
1702
  if (!run) throw new Error('Delivery Loop not found.');
1285
1703
  if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
@@ -1287,16 +1705,44 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1287
1705
  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.');
1288
1706
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
1289
1707
  reopenLegacyLimitStop(run, now);
1290
- if (action === 'continuation_policy') {
1708
+ if (action === 'revise_purpose') {
1709
+ if (!searchEnabled(run.managed) || run.managed.resumeLease || run.state !== 'paused' || !text(purposeUpdate?.sourceRef, 500) || !text(purposeUpdate?.objective, 2000)) throw new Error('Pause the task and supply an explicit user purpose revision with sourceRef, objective and acceptanceCriteria.');
1710
+ const revised=managedSpec({acceptanceCriteria:purposeUpdate.acceptanceCriteria,approvalScopes:run.managed.approvalScopes,initialAction:{kind:'read',scope:run.managed.approvalScopes[0],summary:'Reassess the explicitly revised purpose'}},session,provider,null,null,now);
1711
+ const ledger=evidenceLedger(run.managed);
1712
+ ledger.purposeRevisions ||= [];
1713
+ if (ledger.purposeRevisions.length >= 8) throw new Error('Purpose revision history requires archival before another revision.');
1714
+ ledger.purposeRevisions.push({revision:ledger.purposeRevision,charter:ledger.charter,sourceRef:text(purposeUpdate.sourceRef,500),changedAt:now});
1715
+ if(run.managed.purpose.candidate){
1716
+ run.managed.purpose.retainedCandidates ||= [];
1717
+ if(run.managed.purpose.retainedCandidates.length>=8) throw new Error('Retained candidates require explicit archival before another purpose revision.');
1718
+ run.managed.purpose.retainedCandidates.push({...run.managed.purpose.candidate,status:'retained_old_purpose',purposeRevision:ledger.purposeRevision});
1719
+ delete run.managed.purpose.candidate;
1720
+ }
1721
+ ledger.purposeRevision++;
1722
+ for(const unit of ledger.units) if (!['verified','superseded'].includes(unit.status)) unit.status='superseded_by_user_revision';
1723
+ ledger.requirements=ledger.requirements.filter(row=>row.id.startsWith('rule:'));
1724
+ ledger.findings.forEach(row=>{row.status='superseded_by_user_revision';});
1725
+ run.objective=text(purposeUpdate.objective,2000);run.managed.acceptanceCriteria=revised.acceptanceCriteria;run.managed.verified={};
1726
+ ledger.charter={objective:run.objective,criteria:structuredClone(revised.acceptanceCriteria),approvalScopes:[...run.managed.approvalScopes],frozenAt:now,sourceRef:text(purposeUpdate.sourceRef,500)};
1727
+ delete run.managed.enrollment;freezeEnrollment(run,now);
1728
+ run.managed.pendingApproval=null;delete run.managed.approvedAction;delete run.managed.purpose.lastReview;
1729
+ run.managed.purpose.search={cycle:0,pivots:0,experiments:[],waits:[],observed:[],lastPivotCycle:0};
1730
+ run.revision++;run.updatedAt=now;run.reason='User purpose revision recorded; prior evidence and exact approvals require re-evaluation. Resume explicitly within the existing budget.';
1731
+ run.managed.autoRecovery=false;return record;
1732
+ } else if (action === 'continuation_policy') {
1291
1733
  if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation policy.');
1292
1734
  if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
1293
1735
  run.managed.continuationPolicy = continuationMode(continuationPolicy);
1294
1736
  run.continuationPolicyHistory ||= [];
1295
1737
  run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
1738
+ run.updatedAt = now; return record;
1739
+ } else if (action === 'disable_auto_continue') {
1740
+ if (!run.managed?.purpose) throw new Error('Purpose continuation is not enabled.');
1741
+ run.managed.purpose.enabled = false; run.managed.autoRecovery = false;
1742
+ if (!TERMINAL.has(run.state)) { run.state = 'paused'; run.reason = 'Automatic continuation paused by user.'; }
1296
1743
  run.updatedAt = now;
1297
1744
  return record;
1298
- }
1299
- if (action === 'dependency_ready') {
1745
+ } else if (action === 'dependency_ready') {
1300
1746
  const wait = run.managed?.dependencyWait;
1301
1747
  if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
1302
1748
  typeof answer !== 'string' || !text(answer, 1000) || answer.length > 1000) throw new Error('Dependency wait changed or confirmation is empty; refresh its status.');
@@ -1340,6 +1786,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1340
1786
  else { run.state = 'recovery_required'; run.recoveryCause = 'policy_change'; run.reason = 'Policy changed; resume the original conversation for the selected action.'; run.managed.autoRecovery = false; }
1341
1787
  }
1342
1788
  else if (action === 'stop') {
1789
+ run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
1343
1790
  if (TERMINAL.has(run.state)) return record;
1344
1791
  run.state = 'stopped'; run.reason = 'Stopped by user.';
1345
1792
  }
@@ -1349,34 +1796,44 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1349
1796
  if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer) throw new Error('Only a running or queued managed task can pause.');
1350
1797
  if (queuedAppServer || run.state === 'auto_resuming' && run.managed.resumeChannel === 'codex_app_server') run.managed.pausedRecoveryCause = run.recoveryCause || 'session_supervision';
1351
1798
  run.state = 'paused'; run.reason = 'Paused by user.';
1799
+ run.stopReason = { actor: 'user', code: 'explicit_pause', at: now };
1352
1800
  }
1353
1801
  else if (action === 'replan') {
1354
- if (!canReplanDelivery(run, provider, now)) throw new Error('Replanning needs a rejected assessed approval, a supported original-session channel, and fewer failures than the limit.');
1355
- continueAfterHumanDecision(run, now);
1802
+ if (!canReplanDelivery(run, provider, now)) throw new Error('Replanning needs a supported original-session channel, a rejected assessed approval or safe research wait, and available budget.');
1803
+ const research = canResearchReplan(run, provider, now);
1804
+ if (research && run.managed.capability === 'live_hook') {
1805
+ const owner = run.managed.liveHookOwner || record.hookSessionProcess;
1806
+ if (run.managed.thread?.provider !== provider || run.managed.thread.id !== session ||
1807
+ !owner?.identity || !record.hookStartedAt || record.hookEndedAt ||
1808
+ record.process?.pid !== owner.pid || record.process?.identity !== owner.identity ||
1809
+ await inspectProcess(owner.pid) !== owner.identity) throw new Error('Research replanning requires the original verified live hook owner.');
1810
+ run.managed.liveHookOwner ||= { ...owner, hookStartedAt: record.hookStartedAt };
1811
+ }
1812
+ if (!research) continueAfterHumanDecision(run, now);
1356
1813
  const rejected = run.managed.pendingApproval;
1357
- // Also cover denied records written by earlier versions before rejection
1358
- // fingerprints existed; never re-authorize that exact action on upgrade.
1359
- rememberRejectedAction(run.managed, rejected);
1360
- const plan = rejectedActionDiagnosis(run.managed, rejected);
1814
+ // Denied records remain denied; research waits never create an approval.
1815
+ if (!research) rememberRejectedAction(run.managed, rejected);
1816
+ const plan = research ? researchWaitDiagnosis(run.managed) : rejectedActionDiagnosis(run.managed, rejected);
1361
1817
  const decision = recordDecision(run.managed, plan, now);
1362
- if (decision.outcome !== 'proceed' || decision.action.kind !== 'read') throw new Error('Safe read diagnosis is unavailable; keep the rejected action blocked.');
1818
+ if (decision.outcome !== 'proceed' || decision.action.kind !== 'read') throw new Error('Safe read diagnosis is unavailable; keep the waiting action blocked.');
1363
1819
  delete run.managed.approvedAction;
1364
1820
  run.managed.userQuestion = null;
1365
1821
  run.managed.escalation = null;
1366
1822
  run.blocker = null;
1367
- run.nextStep = 'Review the recorded rejection and propose a different assessed option without executing it.';
1823
+ run.nextStep = research ? 'Inspect local assessment evidence and propose a corrected assessed option without executing the waiting action.' : 'Review the recorded rejection and propose a different assessed option without executing it.';
1368
1824
  run.checkpoint = null; run.consumedRevision = run.revision;
1369
1825
  if (run.managed.resumeChannel === 'codex_app_server') {
1370
1826
  run.managed.autoRecovery = true;
1371
1827
  run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
1372
- run.reason = 'Rejected action remains denied; safe read diagnosis is queued in the exact Codex session.';
1828
+ run.reason = research ? 'Research wait remains unexecuted; safe read diagnosis is queued in the exact Codex session.' : 'Rejected action remains denied; safe read diagnosis is queued in the exact Codex session.';
1373
1829
  } else {
1374
1830
  run.managed.awaitingReplanHook = true;
1375
1831
  run.state = 'active';
1376
- run.reason = 'Rejected action remains denied; safe read diagnosis awaits the original Claude Stop boundary.';
1832
+ run.reason = research ? 'Research wait remains unexecuted; safe read diagnosis awaits the original provider Stop boundary.' : 'Rejected action remains denied; safe read diagnosis awaits the original Claude Stop boundary.';
1377
1833
  }
1378
1834
  }
1379
1835
  else if (action === 'resume') {
1836
+ delete run.stopReason;
1380
1837
  if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
1381
1838
  if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
1382
1839
  if (!['blocked', 'recovery_required', 'paused'].includes(run.state)) throw new Error('Only a blocked, paused, or recoverable loop can resume.');
@@ -1397,7 +1854,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1397
1854
  const selectedKind = run.managed?.decision?.action?.kind;
1398
1855
  const approvedWait = run.managed?.decision?.outcome === 'wait' && run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
1399
1856
  const queuedAppServer = run.managed?.resumeChannel === 'codex_app_server' &&
1400
- ['read', 'local_edit', 'local_test'].includes(selectedKind) &&
1857
+ ['read', 'local_edit', 'local_test', ...(searchEnabled(run.managed) ? ['external'] : [])].includes(selectedKind) &&
1401
1858
  (run.managed.decision.outcome !== 'wait' || approvedWait);
1402
1859
  if (queuedAppServer) run.managed.autoRecovery = true;
1403
1860
  run.state = queuedAppServer ? 'recovery_required' : 'active';
@@ -1418,6 +1875,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1418
1875
  run.checkpoint = { status: 'in_progress', summary: run.reason, nextStep: run.nextStep, evidence: [], acceptanceMet: false };
1419
1876
  run.revision += 1;
1420
1877
  run.managed.approvedAction = { id: pending.id, nextStep: run.nextStep, action: { kind: pending.kind, scope: pending.scope, summary: pending.summary } };
1878
+ if (searchEnabled(run.managed) && run.managed.resumeChannel === 'codex_app_server' && pending.kind === 'external' && run.managed.decision?.assessment?.reversibility === 'reversible') run.managed.autoRecovery = true;
1421
1879
  } else {
1422
1880
  pending.denied = true; run.reason = `User rejected ${pending.scope}.`; run.blocker = 'authorization';
1423
1881
  rememberRejectedAction(run.managed, pending);
@@ -1457,6 +1915,7 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1457
1915
  const stop = deliveryStop(run, now, { checkProgress: !activeTurn });
1458
1916
  if (stop && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) {
1459
1917
  run.state = stoppedState(stop);
1918
+ run.stopReason = { actor: 'system', code: stop.code, at: now, evidence: stop.reason };
1460
1919
  run.reason = stop.reason; run.updatedAt = now;
1461
1920
  }
1462
1921
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {