@awak-app/simy-cli 0.7.16 → 0.7.17

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 (77) hide show
  1. package/README.md +180 -26
  2. package/package.json +3 -2
  3. package/src/agent.js +128 -7
  4. package/src/bounded-local-task-subtask-pool.js +32 -6
  5. package/src/bounded-local-task-subtasks-contract.js +1 -1
  6. package/src/cli-contract.js +1 -1
  7. package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
  8. package/src/codex-plugins/simy/hooks/README.md +8 -3
  9. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
  10. package/src/codex-plugins/simy/hooks/hooks.json +1 -1
  11. package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
  12. package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
  13. package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
  14. package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
  15. package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
  16. package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
  17. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
  18. package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
  19. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
  20. package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
  21. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
  22. package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
  23. package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
  24. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
  25. package/src/coding-run-recovery.js +4 -2
  26. package/src/delivery-hooks/claude-workflow.md +10 -7
  27. package/src/delivery-hooks/command.js +45 -10
  28. package/src/delivery-hooks/consult.js +60 -0
  29. package/src/delivery-hooks/diagnosis.js +2 -2
  30. package/src/delivery-hooks/legacy-hook-routing.js +46 -8
  31. package/src/delivery-hooks/local-runtime.js +50 -1
  32. package/src/delivery-hooks/local-supervisor.js +1 -1
  33. package/src/delivery-hooks/repair-continuation.js +80 -0
  34. package/src/delivery-hooks/repair.js +164 -0
  35. package/src/delivery-hooks/supervisor-service.js +3 -1
  36. package/src/delivery-hooks/supervisor.js +88 -12
  37. package/src/desktop-access.js +2 -1
  38. package/src/desktop-host.js +35 -2
  39. package/src/durable-local-task-worker.js +372 -58
  40. package/src/execution-capability-contract.js +15 -1
  41. package/src/index.js +3 -3
  42. package/src/local-photos-read.swift +33 -0
  43. package/src/local-photos.js +47 -0
  44. package/src/local-task-attachment-store.js +31 -0
  45. package/src/local-task-destination.js +174 -0
  46. package/src/local-task.js +119 -7
  47. package/src/native-delegation-contract.js +68 -0
  48. package/src/native-provider-identity.js +60 -0
  49. package/src/orchestrator/budget.js +22 -11
  50. package/src/orchestrator/independent-audit.js +9 -18
  51. package/src/orchestrator/loop.js +27 -59
  52. package/src/orchestrator/recovery.js +32 -2
  53. package/src/provider-delegation.js +336 -0
  54. package/src/runner.js +2 -1
  55. package/src/session-supervision/artifact-observer.js +138 -0
  56. package/src/session-supervision/codex-app-server.js +19 -5
  57. package/src/session-supervision/completion-artifacts.js +44 -0
  58. package/src/session-supervision/consistency-auditor.js +8 -11
  59. package/src/session-supervision/continuation-analysis.js +46 -28
  60. package/src/session-supervision/final-response-recovery.js +5 -3
  61. package/src/session-supervision/index.js +22 -5
  62. package/src/session-supervision/purpose-recovery.js +12 -3
  63. package/src/session-supervision/purpose-verification.js +9 -57
  64. package/src/session-supervision/recovery-frontier.js +55 -0
  65. package/src/session-supervision/stop-notifications.js +8 -8
  66. package/src/sqm/agent-skill.js +1 -1
  67. package/src/sqm/checkers.js +12 -6
  68. package/src/sqm/claude-plugin-update.js +22 -8
  69. package/src/sqm/claude-plugin.js +4 -0
  70. package/src/sqm/local-tests.js +2 -2
  71. package/src/sqm/plugin-cache.js +67 -0
  72. package/src/sqm/proof.js +25 -0
  73. package/src/sqm/report.js +1 -0
  74. package/src/sqm/workflow-onboarding.js +3 -39
  75. package/src/sqm/workflow-update.js +17 -6
  76. package/src/team-status-fence-store.js +32 -0
  77. package/src/team-threads.js +457 -0
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { decideLoop, compactWorkHistory } from './loop-kernel.mjs';
3
+ import { completionContractIdentity } from './purpose-completion-contract.mjs';
2
4
  import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
3
5
  import { MAX_STATE_BYTES } from './state-limits.mjs';
4
- import { continueResultCycle, resultEvent } from './result-cycle.mjs';
6
+ import { continueResultCycle, resultEvent, resultFollowThroughInstruction, correctiveFollowupInstruction } from './result-cycle.mjs';
5
7
  import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from './recovery-incident.mjs';
6
8
  import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
7
9
  import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
@@ -276,7 +278,16 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
276
278
  if (serialized === previousBytes) return next;
277
279
  temporary = `${file}.${randomUUID()}.tmp`;
278
280
  await writeFile(temporary, serialized, { mode: 0o600 });
279
- await rename(temporary, file);
281
+ // Windows readers or antivirus can briefly deny replacing the JSON.
282
+ // Keep the writer lock and the same complete payload while retrying;
283
+ // never rerun mutate or delete the last durable state to force a save.
284
+ for (let attempt = 0; ; attempt++) {
285
+ try { await rename(temporary, file); break; }
286
+ catch (error) {
287
+ if (error.code !== 'EPERM' || attempt >= 4) throw error;
288
+ await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
289
+ }
290
+ }
280
291
  }
281
292
  return next;
282
293
  } finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
@@ -464,7 +475,7 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { a
464
475
  const localWrite = action.kind === 'local_edit' || allowLocalTestWrites && action.kind === 'local_test' && facts.externalEffect === 'local';
465
476
  const autonomous = policy === 'autonomous' && llmDelegated;
466
477
  const external = autonomous && allowReversibleExternal && action.kind === 'external' && facts.externalEffect === 'external';
467
- let reason = null, waitKind = 'research';
478
+ let reason = null, waitKind = autonomous ? 'research' : 'human';
468
479
  if (!scopes.includes(action.scope)) { reason = 'Action scope is outside the task enrollment.'; waitKind = 'authority'; }
469
480
  else if (facts.inObjective !== true) reason = 'Establish objective fit or choose work within the original task.';
470
481
  else if (facts.reversibility === 'irreversible') { reason = 'Irreversible consequences require an exact user decision.'; waitKind = 'human'; }
@@ -495,11 +506,12 @@ export function canExecuteAutonomousExternal(managed) {
495
506
  // Shared by live Stop hooks and cold exact-session recovery. Keep the result
496
507
  // contract identical across transports; the host still verifies acceptance.
497
508
  export function assessedCheckpointContract(decision, managed = null) {
498
- const observationField = flexibleReasoning(managed) ? 'Plan diagnosis, recommendation and its reason are optional when the selected action is clear. Optional observations may be omitted; do not create evidence files, citations or extra reports. Actual tool results are captured by the controller.' : 'Include evidence:["concrete observation"] in actionResult.';
499
- 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"} for THIS selected action. ${observationField} ${actionReversibilityInstruction} 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. Completion claims go to SIMY independent inspection of the actual frozen acceptance conditions; no worker-authored proof is certification. 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.`;
509
+ const observationField = flexibleReasoning(managed) ? 'Plan diagnosis, recommendation and its reason are optional when the selected action is clear. Optional observations may be omitted; do not create evidence files, citations or extra reports. Actual tool results are captured by the controller.' : 'Return the observed result; evidence attachments and extra reports are optional.';
510
+ 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"} for THIS selected action. ${observationField} ${actionReversibilityInstruction} 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. Successful diagnosis or repair is only an action result, not completion of the original user task. If original work remains, return in_progress with remainingWork and the next assessed plan, preserving the original objective, corrections and acceptance criteria. Do not repeat effects already performed. Completion claims go to SIMY independent inspection of the actual frozen acceptance conditions; no worker-authored proof is certification. 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. ${informationWaitInstruction} external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
511
+
500
512
  }
501
513
  export function understandingCheckpointContract(decision) {
502
- 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. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. 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.`;
514
+ return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\n${correctiveFollowupInstruction}\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. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. 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.`;
503
515
  }
504
516
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
505
517
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -516,7 +528,9 @@ function boundedMatchingContext(value) {
516
528
  }
517
529
  function sessionUnderstanding(value) {
518
530
  if (!value || typeof value !== 'object' || Array.isArray(value) || !['clear', 'ambiguous', 'context_missing', 'already_done'].includes(value.state)) throw new Error('A structured session understanding is required.');
519
- const basis = Array.isArray(value.basis) && value.basis.length <= 6 ? value.basis.map(item => {
531
+ // The checkpoint already has a byte limit. Preserve valid user references
532
+ // and their indices instead of discarding all context when there are >6.
533
+ const basis = Array.isArray(value.basis) ? value.basis.map(item => {
520
534
  if (!['user', 'assistant', 'artifact'].includes(item?.source)) throw new Error('Understanding basis needs a source.');
521
535
  const turnRef = contextText(item.turnRef, 100) || null;
522
536
  const snippet = contextText(item.snippet, 240) || null;
@@ -588,11 +602,13 @@ function recordPurposeProgress(run, now, countAttempt = true) {
588
602
  }
589
603
  function recordDecision(managed, plan, now) {
590
604
  let decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now, { allowLocalTestWrites: searchEnabled(managed), llmDelegated: searchEnabled(managed), allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' });
591
- if (searchEnabled(managed) && decision.outcome === 'wait') {
605
+ const dependencyPending = option => (option?.dependsOn || []).some(id =>
606
+ !managed.purpose?.evidence?.units?.some(unit => unit.id === id && unit.status === 'verified' && unit.purposeRevision === managed.purpose.evidence.purposeRevision));
607
+ if (searchEnabled(managed) && (decision.outcome === 'wait' || dependencyPending(plan.alternatives.find(row => row.id === plan.selectedOptionId)))) {
592
608
  rememberSearchWait(managed, decision.waitKind === 'research' ? 'research' : 'authorization', { action: decision.action, reason: decision.reason }, now);
593
609
  const priorities = new Map((managed.purpose.lastReview?.priorities || []).map(row => [row.optionId, row.rank]));
594
610
  for (const candidate of [...plan.alternatives].sort((a, b) => (priorities.get(a.id) ?? 99) - (priorities.get(b.id) ?? 99))) {
595
- if (candidate.id === plan.selectedOptionId || !candidate.independent) continue;
611
+ if (candidate.id === plan.selectedOptionId || !candidate.independent || dependencyPending(candidate)) continue;
596
612
  const alternative = { ...plan, selectedOptionId: candidate.id, recommendation: { optionId: candidate.id, reason: 'Continue the highest listed eligible independent alternative.' } };
597
613
  const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now, { allowLocalTestWrites: searchEnabled(managed), llmDelegated: searchEnabled(managed), allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' });
598
614
  if (assessed.outcome !== 'wait' && !managed.rejectedActionOverflow && !managed.rejectedActionDigests?.includes(digest(JSON.stringify(assessed.action)))) {
@@ -625,8 +641,7 @@ function recordDecision(managed, plan, now) {
625
641
  writeResources: option?.writeResources || (decision.action.kind === 'read' ? [] : ['task']),
626
642
  requiredCapabilities: option?.requiredCapabilities || [decision.action.kind], requiredPermissions: option?.requiredPermissions || [decision.action.scope],
627
643
  status: decision.outcome === 'wait' ? 'waiting' : 'planned', evidenceRefs: [], purposeRevision: ledger.purposeRevision || 1, action: decision.action, createdAt: now });
628
- 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)); }
629
- if (ledger.units.length > 192) throw new Error('Work frontier needs reconciliation before more dispatch.');
644
+ compactWorkHistory(ledger);
630
645
  }
631
646
  managed.plan = plan; managed.decision = decision; managed.proposedAction = decision.action;
632
647
  managed.decisionHistory ||= [];
@@ -752,7 +767,63 @@ export function deliveryAcceptanceContext(run) {
752
767
  `${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
753
768
  ` 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.`;
754
769
  }
755
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
770
+ // CLI automation is the default for connected providers with executing hooks.
771
+ // Saved workspace overrides and revoked provider consent take precedence.
772
+ async function automaticPath(root, provider, cwd) {
773
+ if (!['codex', 'claude'].includes(provider)) throw new Error('Choose --provider codex|claude.');
774
+ cwd = await realpath(cwd);
775
+ return { cwd, file: join(root, 'automatic', `${digest(`${provider}\0${cwd}`)}.json`) };
776
+ }
777
+ async function withAutomaticLock(root, provider, cwd, operation) {
778
+ const location = await automaticPath(root, provider, cwd);
779
+ await mkdir(dirname(location.file), { recursive: true, mode: 0o700 });
780
+ const lock = `${location.file}.lock`, token = await waitForStateLock(lock, processIdentity);
781
+ try { return await operation(location); } finally { await releaseStateLock(lock, token); }
782
+ }
783
+ export async function automaticDeliveryStatus({ root = rootDirectory(), provider, cwd = process.cwd() }) {
784
+ const location = await automaticPath(root, provider, cwd);
785
+ const saved = await read(location.file);
786
+ const link = await providerLinkState(root, provider);
787
+ const enabled = link.connected && (saved === null ? !link.requiresConsent
788
+ : saved.schemaVersion === 1 && saved.provider === provider && saved.cwd === location.cwd &&
789
+ saved.enabled === true && saved.providerLinkEpoch === link.epoch);
790
+ return { provider, cwd: location.cwd, enabled: Boolean(enabled), source: saved === null ? 'cli_default' : 'workspace',
791
+ reason: enabled ? 'The next nonempty prompt in a conversation without a loop starts Delivery Loop.' : 'Automatic start is disabled by a saved override or provider consent; it will not be restored by updates.',
792
+ prerequisite: 'SIMY plugin hooks must be enabled in the provider. This setting does not grant hook trust.',
793
+ existingLoops: 'Existing loops, pauses, stops and completed tasks are preserved.' };
794
+ }
795
+ export async function setAutomaticDelivery({ root = rootDirectory(), provider, cwd = process.cwd(), enabled }) {
796
+ if (typeof enabled !== 'boolean') throw new Error('Automatic start requires a boolean choice.');
797
+ return withAutomaticLock(root, provider, cwd, async location => {
798
+ const link = await providerLinkState(root, provider);
799
+ if (enabled && !link.connected) throw new Error('Reconnect this provider to SIMY before enabling automatic start.');
800
+ const temporary = `${location.file}.${randomUUID()}.tmp`;
801
+ try {
802
+ await writeFile(temporary, JSON.stringify({ schemaVersion: 1, provider, cwd: location.cwd, enabled,
803
+ providerLinkEpoch: link.epoch }), { mode: 0o600 });
804
+ await rename(temporary, location.file);
805
+ } finally { await rm(temporary, { force: true }); }
806
+ return automaticDeliveryStatus({ root, provider, cwd: location.cwd });
807
+ });
808
+ }
809
+ function initialDeliveryRun(objective, nextStep, now, maxFailures) {
810
+ return { id: randomUUID(), objective: text(objective), state: 'active', nextStep: text(nextStep),
811
+ startedAt: now, updatedAt: now, maxFailures: positive(maxFailures, 3, 10),
812
+ maxFailuresSource: maxFailures === undefined ? 'default' : 'explicit', continuations: 0, failures: 0,
813
+ revision: 0, consumedRevision: -1, checkpoint: null, reason: 'started', managed: null };
814
+ }
815
+ function hookDeliveryContext(record, automatic = false) {
816
+ const { provider, session, run } = record;
817
+ const identity = `SIMY connected to this ${provider} conversation: ${session}.`;
818
+ if (!run && automatic && !record.automaticStartBlocked && !record.providerArchived && !record.parentSession && !(record.autoContinue === false && record.autoContinueEpoch)) return `${identity} Automatic Delivery Loop start is enabled for this workspace. The next nonempty user prompt starts checkpoint tracking in this conversation; do not issue a duplicate start. Provider permissions and user stops remain authoritative.`;
819
+ if (!run) return `${identity} Delivery Loop has not started. When the user requests it, use simy delivery start --provider ${provider} --session ${session} with the agreed objective and next step. For future conversations in this workspace, the user can enable automatic start once with simy delivery auto on --provider ${provider}. Never enable, trust, resume, or reset a loop without the user's request.`;
820
+ return `${identity} Existing loop: ${run.state}. ${deliveryAcceptanceContext(run)} ` +
821
+ (['active', 'reported_done', 'completed'].includes(run.state) ? `${correctiveFollowupInstruction} ` : '') +
822
+ (provider === 'codex' && ['completed', 'reported_done'].includes(run.state) ? `Finished run ID: ${run.id}. Its completion does not supervise later user requests. If the user explicitly requests supervision of the current goal, use simy delivery followup --provider codex --session ${session} --previous-run-id ${run.id} --objective CURRENT_GOAL. A different goal archives the old run; the same ordinary reported_done goal retains identity and budgets for read-only reconciliation. A new prompt alone is not consent to run this command. ` : '') +
823
+ (run.state === 'active' && !run.managed ? `Before ending the turn, submit an honest JSON checkpoint with simy delivery checkpoint --provider ${provider} --session ${session} --file -. Use status in_progress with summary, remainingWork and nextStep for remaining work; completed with summary, acceptanceMet:true and evidence for finished work; or blocked with summary and blocker missing_information or authorization. The first completed checkpoint requests an outcome check in this conversation; inspect the actual result and submit a fresh checkpoint. If requested work remains, perform the first authorized step instead of ending with self-evaluation or a plan. Completion is provider-reported, not independently verified. ${informationWaitInstruction} ` : '') +
824
+ `Preserve the current task and its permissions, pauses and stops; never restart or reset it automatically.`;
825
+ }
826
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, previousRunId, reconcileRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
756
827
  liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
757
828
  const link = await providerLinkState(root, provider);
758
829
  if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
@@ -777,12 +848,27 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
777
848
  }
778
849
  if ((!previous?.observedAt && !admitted) || sessionCwd !== cwd && executionCwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or explicitly bind its task execution directory.');
779
850
  const upgrading = upgradeRunId !== undefined;
780
- if (upgrading && (!canUpgradeDelivery(previous.run) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
781
- if (previous.run && !upgrading) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
851
+ const following = previousRunId !== undefined;
852
+ const reconciling = reconcileRunId !== undefined;
853
+ if (reconciling && (!upgrading || following || previous.run?.id !== reconcileRunId || upgradeRunId !== reconcileRunId || previous.run.managed || previous.run.state !== 'reported_done' || text(objective) !== previous.run.objective)) throw new Error('Reconciliation requires the exact unverified ordinary completion and its unchanged original objective.');
854
+ if (following || reconciling) {
855
+ const prior = previous.run;
856
+ if (following && (upgrading || !prior || prior.id !== previousRunId || !['completed', 'reported_done'].includes(prior.state))) throw new Error('Follow-up requires the exact finished run; active work, stops and exhausted budgets cannot be replaced.');
857
+ if (following && text(objective) === prior.objective) throw new Error('Follow-up requires a new objective; use the original task controls for unfinished work.');
858
+ if (hasPendingUserStop(root, previous) || previous.providerArchived || previous.automaticStartBlocked || previous.autoContinue === false && previous.autoContinueEpoch ||
859
+ prior.managed?.resumeLease || prior.managed?.liveWorkspaceLease || prior.managed?.outcomeAudit?.lease || prior.managed?.executionUncertain || prior.managed?.pendingApproval || prior.managed?.nativeWait ||
860
+ ['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || previous.associationTaskId) throw new Error('Resolve the existing stop, opt-out, ownership, approval or uncertain result before enrolling follow-up work.');
861
+ const gateRoot = rootFromDelivery(root), gate = readDispatchGate(gateRoot);
862
+ const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
863
+ if (fences.some(item => !readTaskAdmission({ root: gateRoot, identity: item.identity, taskId: item.taskId }).canResume)) throw new Error('Existing task admission must be reconciled before enrolling follow-up work.');
864
+ }
865
+ if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run)) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
866
+ if (previous.run && !upgrading && !following) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
782
867
  if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
783
868
  const managedConfig = managed && { ...(managed === true ? {} : managed),
784
869
  ...(continuationPolicy === undefined ? {} : { continuationPolicy }) };
785
870
  const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
871
+ if ((following || reconciling) && (provider !== 'codex' || !admitted || !managedState?.autoRecovery || managedState.capability !== 'codex_app_server' || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Follow-up requires exact-session managed supervision, a concrete goal and read-only reconciliation.');
786
872
  if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
787
873
  if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
788
874
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
@@ -794,7 +880,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
794
880
  identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
795
881
  const waiting = managedState?.decision?.outcome === 'wait';
796
882
  const queued = managedState?.capability === 'codex_app_server';
797
- const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
883
+ const run = { ...initialDeliveryRun(objective, nextStep, now, maxFailures), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active',
798
884
  ...(managedState?.purpose ? { ...(maxMinutes === undefined ? {} : { deadline: now + positive(maxMinutes, 120, 1440) * 60_000 }), ...(maxContinuations === undefined ? {} : { maxContinuations: positive(maxContinuations, 50, 1000) }) } : {}),
799
885
  maxFailures: positive(maxFailures, 3, 10), maxFailuresSource: maxFailures === undefined ? 'default' : 'explicit',
800
886
  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',
@@ -821,6 +907,15 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
821
907
  if (waiting) escalate(run, run.reason);
822
908
  const latest = await providerLinkState(root, provider);
823
909
  if (!latest.connected || latest.epoch !== link.epoch) throw Object.assign(new Error("SIMY connection changed before enrollment."), { code: "provider_link_disconnected" });
910
+ if (following || reconciling) {
911
+ const priorId = previousRunId || reconcileRunId;
912
+ if (following) run.previousRunId = priorId;
913
+ run.previousRunArchive = join('finished-runs', `${digest(`${provider}\0${session}\0${priorId}`)}.json`);
914
+ const archive = { schemaVersion: 1, provider, session, cwd: previous.cwd, run: previous.run };
915
+ const file = join(root, run.previousRunArchive), saved = readState(file);
916
+ if (saved && digest(saved) !== digest(archive)) throw new Error('Finished run archive differs; reconcile its history before enrolling follow-up work.');
917
+ if (!saved) atomicSave(file, archive);
918
+ }
824
919
  return { ...previous, run };
825
920
  });
826
921
  }
@@ -910,6 +1005,7 @@ export function queuePurposeDiagnosis(run, reason, key, now = Date.now(), recove
910
1005
  question: run.managed.understandingQuestion?.text || run.managed.userQuestion || null,
911
1006
  dependency: run.managed.dependencyWait || null };
912
1007
  recordDecision(run.managed, purposeDiagnosisPlan(run.managed, reason), now);
1008
+ purpose.diagnosisDecisionId = run.managed.decision.id;
913
1009
  if (run.managed.executionUncertain) prepareRecoveryDiagnosis(run, now);
914
1010
  if (run.managed.phase === 'needs_clarification') run.managed.phase = 'understanding';
915
1011
  run.managed.userQuestion = null; run.managed.understandingQuestion = null; run.managed.dependencyWait = null;
@@ -922,6 +1018,9 @@ export function queuePurposeDiagnosis(run, reason, key, now = Date.now(), recove
922
1018
  return true;
923
1019
  }
924
1020
 
1021
+ export const informationWaitInstruction = 'Use blocked/missing_information only with a nonempty question field containing a concrete question that requires the user\'s answer. Missing verification evidence, zero applicable SQM rules/scenarios, or unfinished checks alone are remaining work: inspect applicability and available evidence, then propose authorized repair or verification. Keep unmet acceptance criteria unverified; do not invent rules or lower acceptance. If investigation finds a real user-only choice or unavailable input, ask that specific question. Preserve native permissions, explicit stops, refusals and budgets.';
1022
+ const informationWaitDiagnosis = 'Read the current task context and available verification evidence to determine whether progress really requires a user answer. Identify an authorized next investigation or verification step, or return blocked/missing_information with the specific user question. Do not repeat prior side effects or claim that incomplete verification passed.';
1023
+
925
1024
  export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
926
1025
  if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
927
1026
  if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, sourceContext, controllerEvidence, now });
@@ -929,7 +1028,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
929
1028
  const statuses = ['in_progress', 'completed', 'blocked', 'error'];
930
1029
  if (!checkpoint || !statuses.includes(checkpoint.status)) throw new Error('Checkpoint status must be in_progress, completed, blocked, or error.');
931
1030
  if (!text(checkpoint.summary)) throw new Error('Checkpoint summary is required.');
932
- if (['in_progress', 'error'].includes(checkpoint.status) && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
1031
+ if (checkpoint.status === 'error' && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
933
1032
  if (checkpoint.status === 'blocked' && !['authorization', 'missing_information', 'external_dependency'].includes(checkpoint.blocker)) throw new Error('Specify authorization, missing_information, or external_dependency.');
934
1033
  const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
935
1034
  const settled = await updateRecord(root, provider, session, async record => {
@@ -939,10 +1038,34 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
939
1038
  let repairPlan = null;
940
1039
  if (!run || !checkpointDirectoryMatches(record, cwd)) throw new Error('No matching active Delivery Loop.');
941
1040
  if (run.managed?.phase === 'understanding' || run.managed?.phase === 'needs_clarification') throw new Error('Session understanding needs its separate structured checkpoint.');
942
- if (checkpoint.status === 'completed' && !run.managed && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
943
1041
  if (expectedLease && (run.state !== 'auto_resuming' || run.managed?.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Managed recovery lease changed; checkpoint discarded.');
944
1042
  if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
945
1043
  if (['paused', 'verification_pending'].includes(run.state)) throw new Error('Managed task is paused or awaiting acceptance verification.');
1044
+ const remainingWork = text(checkpoint.remainingWork, 4000);
1045
+ const resultDisposition = decideLoop({ completion_claim: checkpoint.status === 'completed',
1046
+ remaining_work: Boolean(remainingWork), acceptance_failed: checkpoint.acceptanceMet === false });
1047
+ const incompleteResult = !run.managed && checkpoint.status === 'completed' && resultDisposition.action === 'repair';
1048
+ const missingNextStep = checkpoint.status === 'in_progress' && !nextStep;
1049
+ if (incompleteResult || (missingNextStep && !run.managed)) {
1050
+ effectiveStatus = 'in_progress';
1051
+ nextStep ||= resultFollowThroughInstruction();
1052
+ }
1053
+ if (missingNextStep && run.managed) throw new Error('A concrete nextStep is required.');
1054
+ if (effectiveStatus === 'completed' && !run.managed && checkpoint.acceptanceMet !== true) throw new Error('Completion requires the requested outcome to be checked (acceptanceMet).');
1055
+ // A worker's unsupported information-wait claim is a recoverable reporting
1056
+ // error, not evidence that a human must act. Reuse the current executor and
1057
+ // failure budget; never resume a previously stopped/blocked task here.
1058
+ const unsupportedWait = checkpoint.status === 'blocked' && checkpoint.blocker === 'missing_information' && !text(checkpoint.question, 1000);
1059
+ if (unsupportedWait) {
1060
+ if (run.blockerSource === 'provider' || run.managed?.pendingApproval || run.managed?.nativeWait || run.managed?.purpose?.nativeBoundary) {
1061
+ throw new Error('Missing information blocker needs a specific user question; existing permission boundaries remain in force.');
1062
+ }
1063
+ effectiveStatus = 'error'; nextStep = informationWaitDiagnosis;
1064
+ if (run.managed) {
1065
+ repairPlan = purposeDiagnosisPlan(run.managed, informationWaitDiagnosis);
1066
+ repairPlan.alternatives[0].action.summary = informationWaitDiagnosis;
1067
+ }
1068
+ }
946
1069
  if (run.managed) {
947
1070
  if (searchEnabled(run.managed) && expectedLease && sourceContext) run.managed.purpose.originalContext ||= sourceContext;
948
1071
  if (searchEnabled(run.managed) && expectedLease) retainControllerEvidence(run.managed, controllerEvidence, now);
@@ -954,7 +1077,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
954
1077
  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' };
955
1078
  const capturedAction = expectedLease && ['read','local_edit','local_test'].includes(performed.action.kind) && toolEvidence.some(item => item?.source === 'codex_tool_event' && /^[a-f0-9]{64}$/.test(item.digest || '') && item.kind === 'commandExecution' && item.exitCode === 0 && (!item.status || item.status === 'completed'));
956
1079
  if (flexibleReasoning(run.managed) && capturedAction) actionResult.observationSource = 'controller_tool_result';
957
- if (result.status === 'observed' && !actionResult.evidence.length && !flexibleReasoning(run.managed)) throw new Error('Observed action result needs concrete evidence.');
1080
+ // Observation prose is optional; actual execution receipts belong to the controller.
958
1081
  // Local observations remain the worker's report until independent
959
1082
  // inspection. Missing paperwork alone is not an unknown side effect.
960
1083
  // For remote effects, keep reconciliation before any repeat operation.
@@ -980,6 +1103,9 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
980
1103
  if (searchEnabled(run.managed) && performed && actionResult) {
981
1104
  const unit = evidenceLedger(run.managed).units?.find(row => row.id === performed.id);
982
1105
  if (unit) { unit.status = actionResult.status === 'failed' ? 'failed' : ['not_run', 'unverified'].includes(actionResult.status) ? 'waiting' : 'observed'; unit.evidenceRefs = (toolEvidence || []).map(row => row.ref || row.digest); unit.updatedAt = now;
1106
+ // A returned local operation releases execution occupancy, not outcome
1107
+ // acceptance. Unknown/external effects still require reconciliation.
1108
+ if (expectedLease && actionResult.status === 'observed' && ['local_edit', 'local_test'].includes(performed.action.kind) && !run.managed.executionUncertain) unit.localExecutionSettledAt = now;
983
1109
  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';}
984
1110
  }
985
1111
  }
@@ -998,6 +1124,11 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
998
1124
  if (performed) {
999
1125
  run.managed.workHistory ||= [];
1000
1126
  run.managed.workHistory.push({ decisionId: performed.id, action: performed.action, status: checkpoint.status, summary: text(checkpoint.summary, 300), result: actionResult, verification: `${Math.max(0, Object.keys(run.managed.verified).length - previouslyVerified)} new acceptance criteria verified`, at: now });
1127
+ // A repair check is an observed read, not completion of the original task.
1128
+ if (run.managed.repairRequest?.status === 'running' && performed.action.kind === 'read' && actionResult?.status === 'observed' &&
1129
+ ['resolved', 'unresolved', 'needs_human'].includes(checkpoint.repairResult?.status) && text(checkpoint.repairResult?.summary, 600)) {
1130
+ run.managed.workHistory.at(-1).repairResult = { status: checkpoint.repairResult.status, summary: text(checkpoint.repairResult.summary, 600) };
1131
+ }
1001
1132
  run.managed.workHistory = run.managed.workHistory.slice(-10);
1002
1133
  if (performed.outcome === 'report') run.managed.notification = { key: `report:${run.revision + 1}`, kind: 'action_report', message: `Attempted ${performed.action.summary}: ${text(checkpoint.summary, 300)}. ${run.managed.workHistory.at(-1).verification}.`, at: now };
1003
1134
  if (run.managed.approvedAction?.id === performed.id) delete run.managed.approvedAction;
@@ -1006,15 +1137,15 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1006
1137
  if (run.managed.autonomyPolicy) {
1007
1138
  const plan = repairPlan || assessedPlan(checkpoint.plan, run.managed);
1008
1139
  const selected = plan.alternatives.find(item => item.id === plan.selectedOptionId);
1009
- if (checkpoint.proposedAction && JSON.stringify(actionDescriptor(checkpoint.proposedAction)) !== JSON.stringify(selected.action)) throw new Error('Proposed action differs from selected assessed option.');
1140
+ if (!unsupportedWait && checkpoint.proposedAction && JSON.stringify(actionDescriptor(checkpoint.proposedAction)) !== JSON.stringify(selected.action)) throw new Error('Proposed action differs from selected assessed option.');
1010
1141
  recordDecision(run.managed, plan, now);
1011
- if (purposeEnabled(run) && !searchEnabled(run.managed) && ['failed', 'not_run'].includes(actionResult.status)) {
1142
+ if (!unsupportedWait && purposeEnabled(run) && !searchEnabled(run.managed) && ['failed', 'not_run'].includes(actionResult.status)) {
1012
1143
  recordDecision(run.managed, purposeDiagnosisPlan(run.managed, `Investigate the ${actionResult.status} action before further changes: ${actionResult.summary}`), now);
1013
- } else if (!searchEnabled(run.managed) && (actionResult.status === 'not_run' || actionResult.status === 'failed' && run.managed.decision.action.kind !== 'read')) {
1144
+ } else if (!unsupportedWait && !searchEnabled(run.managed) && (actionResult.status === 'not_run' || actionResult.status === 'failed' && run.managed.decision.action.kind !== 'read')) {
1014
1145
  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.';
1015
1146
  run.managed.pendingApproval = { id: run.managed.decision.id, ...run.managed.decision.action, reason: run.managed.decision.reason };
1016
1147
  }
1017
- } else run.managed.proposedAction = actionDescriptor(checkpoint.proposedAction);
1148
+ } else run.managed.proposedAction = unsupportedWait ? repairPlan.alternatives[0].action : actionDescriptor(checkpoint.proposedAction);
1018
1149
  }
1019
1150
  if (checkpoint.status === 'blocked' && checkpoint.blocker === 'authorization') {
1020
1151
  const request = checkpoint.approval;
@@ -1036,8 +1167,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1036
1167
  run.managed.pendingApproval = { id: digest(`${run.managed.taskId}:${run.revision + 1}:${JSON.stringify(proposed)}`), ...proposed };
1037
1168
  }
1038
1169
  }
1039
- if (run.managed.autonomyPolicy && checkpoint.status === 'blocked' && checkpoint.blocker === 'missing_information') {
1040
- if (!text(checkpoint.question, 1000)) throw new Error('Missing information blocker needs a specific user question.');
1170
+ if (effectiveStatus === 'blocked' && checkpoint.blocker === 'missing_information') {
1041
1171
  run.managed.userQuestion = text(checkpoint.question, 1000);
1042
1172
  }
1043
1173
  }
@@ -1046,7 +1176,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1046
1176
  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.');
1047
1177
  run.managed.dependencyWait = { id: randomUUID(), condition: text(wait.condition, 1000), owner: text(wait.owner, 200), nextCheck: text(wait.nextCheck, 1000), createdAt: now };
1048
1178
  }
1049
- if (searchEnabled(run.managed) && checkpoint.status === 'blocked') {
1179
+ if (searchEnabled(run.managed) && checkpoint.status === 'blocked' && !unsupportedWait) {
1050
1180
  rememberSearchWait(run.managed, checkpoint.blocker, { reason: checkpoint.summary, question: checkpoint.question,
1051
1181
  condition: checkpoint.waitCondition?.condition, owner: checkpoint.waitCondition?.owner, action: checkpoint.approval || null }, now);
1052
1182
  if (checkpoint.plan && checkpoint.blocker !== 'authorization' && checkpoint.plan.alternatives?.find(o => o.id === checkpoint.plan.selectedOptionId)?.independent === true) recordDecision(run.managed, assessedPlan(checkpoint.plan, run.managed), now);
@@ -1074,7 +1204,12 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1074
1204
  nextStep = run.nextStep;
1075
1205
  }
1076
1206
  run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
1077
- run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
1207
+ run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep,
1208
+ blocker: unsupportedWait ? undefined : checkpoint.blocker,
1209
+ ...(unsupportedWait ? { reportedBlocker: checkpoint.blocker } : checkpoint.blocker === 'missing_information' ? { question: text(checkpoint.question, 1000) } : {}),
1210
+ ...(remainingWork ? { remainingWork } : {}), ...(incompleteResult ? { reportedStatus: checkpoint.status } : {}),
1211
+ evidence, acceptanceMet: !unsupportedWait && !incompleteResult && checkpoint.acceptanceMet === true };
1212
+ if (!run.managed && effectiveStatus !== 'completed') delete run.completionCheck;
1078
1213
  run.revision += 1; run.updatedAt = now;
1079
1214
  recordManagedOutcomeProgress(run, now);
1080
1215
  // File predicates were reread above; a prior semantic verdict can belong
@@ -1093,9 +1228,9 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1093
1228
  run.state = 'reported_done'; run.reason = 'Original session reported the goal done; no independent acceptance check was available.';
1094
1229
  }
1095
1230
  }
1096
- if (run.managed?.liveWorkspaceLease) {
1231
+ if (run.managed?.liveWorkspaceLease || expectedLease && run.managed?.executionAttempt?.id === expectedLease) {
1097
1232
  run.managed.executionAttempt = { ...run.managed.executionAttempt, phase: 'checkpointed', checkpointRevision: run.revision, endedAt: now };
1098
- run.managed.liveWorkspaceLease.checkpointRevision = run.revision;
1233
+ if (run.managed.liveWorkspaceLease) run.managed.liveWorkspaceLease.checkpointRevision = run.revision;
1099
1234
  }
1100
1235
  for (const request of run.managed?.consistencyOutbox || []) if (request.decisionId && request.decisionId === checkpoint.actionResult?.decisionId) {
1101
1236
  recordHandoffStage(request, run.managed.workHistory?.at(-1)?.result?.status === 'observed' ? 'inspected' : 'inspection_failed', now);
@@ -1130,7 +1265,10 @@ export function externalReviewIsCurrent(managed,review,now) {
1130
1265
  if (review.semanticInspection?.source === 'controller_actual_read_set') refs.push(...(review.semanticInspection.observationRefs || []).map(ref => ({ ref })));
1131
1266
  const ledger=managed.purpose.evidence;
1132
1267
  const observations=[...(ledger?.observations||[]),...(ledger?.reviewObservationActionId===(review.decisionId||managed.decision.id) ? ledger.reviewObservations||[] : [])];
1133
- return refs.every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
1268
+ const inspected = new Set(review.semanticInspection?.source === 'controller_actual_read_set' ? review.semanticInspection.observationRefs || [] : []);
1269
+ return refs.filter(link => inspected.has(link.ref) || observations.some(item => item.id === link.ref))
1270
+ .every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
1271
+
1134
1272
  }
1135
1273
  export async function settlePurposeVerification({ root, provider, session, expectedLease, review, verifySnapshot, now = Date.now() }) {
1136
1274
  const started = Date.now(), initialNow = now;
@@ -1145,8 +1283,16 @@ export async function settlePurposeVerification({ root, provider, session, expec
1145
1283
  managed.purpose.search?.verificationDecisionId !== managed.decision.id || run.revision !== managed.resumeLease.checkpointRevision || now >= run.deadline) throw new Error('Independent review lease changed.');
1146
1284
  const state = searchState(managed);
1147
1285
  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.');
1286
+ const verificationUnit = managed.purpose.evidence?.units?.find(unit => unit.id === managed.decision.id);
1287
+ if (verificationUnit?.action?.kind === 'read' && verificationUnit.status === 'dispatched') {
1288
+ verificationUnit.status = 'observed'; verificationUnit.observedAt = now;
1289
+ }
1148
1290
  retainApplicableRules(managed, review.rules || []);
1149
1291
  review = assessRuleReview(review, managed);
1292
+ if (review.contractDigest !== completionContractIdentity(managed)) {
1293
+ review = { ...review, passed: false, needsHuman: false,
1294
+ findings: [...review.findings, 'Requested behavior changed; check the current request.'] };
1295
+ }
1150
1296
  if (currentDigest !== review.workspaceDigest) {
1151
1297
  review = { ...review, passed: false, needsHuman: false, findings: [...review.findings, 'Workspace changed before review settlement; verify the new revision.'] };
1152
1298
  }
@@ -1156,45 +1302,64 @@ export async function settlePurposeVerification({ root, provider, session, expec
1156
1302
  'Required operation, effect reconciliation or original-purpose contract remains unresolved. Preserve the waiting unit and continue independent authorized work.'] };
1157
1303
  }
1158
1304
  if(!externalReviewIsCurrent(managed,review,now)) review={...review,passed:false,needsHuman:false,controllerEvidenceFailure:{code:'independent_reference_invalid',targets:['current_external_observations'],instruction:'Controller external observations expired before settlement; reacquire the same authorized observations and independently re-evaluate without replaying writes.'}};
1159
- // Validate counterevidence against a disposable ledger before mutations.
1160
- // Verifier contract failures are verifier work, never worker checkpoint errors.
1161
- if (!review.controllerEvidenceFailure) {
1162
- try { reconcileFindings(structuredClone(managed), review, now); }
1163
- catch (error) {
1164
- if (!/^(Reopening a closed finding|Finding resolution needs|Independent review left unresolved findings|A finding cannot be open and resolved\.)/.test(error.message)) throw error;
1165
- review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: {
1166
- code: 'independent_finding_evidence_invalid', targets: [...(review.resolutions || []).map(row => `resolution:${row.id}`), ...(review.findings || []).filter(row => row.reopenReason).map(row => `reopen:${row.id}`)],
1167
- instruction: error.message + ' Repeat isolated verification and reconcile existing findings with primary reads. For reopening use the exact prior closure read scope.',
1168
- requiredReads: (managed.purpose.evidence?.findings || []).filter(row => row.status !== 'open').map(row => ({ id: row.id, closedReads: row.closedReads || [] }))
1169
- } };
1170
- }
1171
- }
1172
1305
  if (review.controllerEvidenceFailure) {
1173
1306
  const prior = state.verifierFailures;
1174
1307
  const key = digest(JSON.stringify([currentDigest, review.controllerEvidenceFailure.code, review.controllerEvidenceFailure.targets]));
1175
1308
  state.verifierFailures = { key, attempts: prior?.key === key ? prior.attempts + 1 : 1, at: now, failure: review.controllerEvidenceFailure };
1176
1309
  state.review = review;
1177
1310
  run.revision += 1; run.updatedAt = now;
1178
- queuePurposeVerification(run, `${review.controllerEvidenceFailure.instruction} Invalid targets: ${(review.controllerEvidenceFailure.targets || []).join(', ')}. Use a narrow direct read containing the cited excerpt within 2000 characters.`, now);
1179
- managed.nextAttemptAt = now + Math.min(60_000, 1000 * 2 ** Math.min(state.verifierFailures.attempts - 1, 6));
1311
+ // An invalid review record is not a product defect and must not send
1312
+ // the verifier around a citation-repair loop. The normal planner selects
1313
+ // useful work or another actual outcome check under existing authority.
1314
+ const reason = 'The review record could not establish completion. Preserve existing work and inspect the original requested behavior through another available method. Repair concrete defects or continue remaining authorized work; do not generate citations, evidence files or audit forms. Unknown external effects still require reconciliation before any retry.';
1315
+ const fallbackPlan = purposeDiagnosisPlan(managed, reason);
1316
+ fallbackPlan.alternatives[0].action.summary = 'Inspect the original requested behavior using existing results or another available method; continue concrete work without audit paperwork.';
1317
+ recordDecision(managed, fallbackPlan, now);
1318
+ state.phase = 'repairing';
1319
+ delete state.verificationDecisionId;
1320
+ run.checkpoint = null; run.state = 'recovery_required';
1321
+ run.recoveryCause = 'session_supervision'; run.reason = reason;
1322
+ run.nextStep = managed.decision.action.summary;
1323
+ managed.autoRecovery = true; managed.nextAttemptAt = now;
1180
1324
  notifyState(run, now); return record;
1181
1325
  }
1182
1326
  const openFindings = reconcileFindings(managed, review, now);
1327
+ if ((review.passed || review.needsHuman) && openFindings.length) {
1328
+ review = { ...review, passed: false, needsHuman: false, findings: openFindings };
1329
+ }
1183
1330
  const units=evidenceLedger(managed).units;
1331
+ // A bound independent read can establish an earlier effect even when its
1332
+ // result is wrong. Release only its resource reservation, not its pending
1333
+ // acceptance or replay prohibition; a new assessed repair is still needed.
1334
+ for (const unit of units) if (unit.status === 'observed' && unit.criterionIds?.length &&
1335
+ unit.criterionIds.every(id => review.criteria?.some(row =>
1336
+ row.id === id && typeof row.passed === 'boolean' && row.evidenceRefs?.some(link =>
1337
+ review.capturedReads?.some(read => read.target === link.target))))) {
1338
+ unit.effectReconciledRevision = unit.purposeRevision;
1339
+ }
1340
+
1341
+ await verifyAutomaticCriteria(record, now);
1342
+ const criteria = effectiveAcceptanceCriteria(managed);
1343
+ const independentlySatisfied = id => {
1344
+ const criterion = criteria.find(row => row.id === id);
1345
+ if (!criterion) return false;
1346
+ if (criterion.check.type === 'manual') return managed.verified[id]?.source === 'user';
1347
+ if (criterion.check.type !== 'semantic') return Boolean(managed.verified[id]);
1348
+ return review.criteria.some(row => row.id === id && row.passed) || review.passed && review.inspectionPerformed === true;
1349
+ };
1184
1350
  for (const result of (review.passed || review.needsHuman) ? review.workUnits || [] : []) {
1185
1351
  const unit=units.find(row=>row.id===result.id);
1186
1352
  if (!unit || !['verified','superseded'].includes(result.status) || !text(result.result || review.summary) ||
1187
- unit.criterionIds.some(id=>!review.criteria.some(row=>row.id===id && row.passed))) throw new Error('Required unit resolution needs independent criterion-linked evidence.');
1188
- if (unit.purposeRevision !== evidenceLedger(managed).purposeRevision) throw new Error('Unit belongs to an old purpose revision.');
1353
+ unit.criterionIds.some(id=>!independentlySatisfied(id))) continue; // Incomplete optional bookkeeping cannot settle an operation or reject the review.
1354
+ if (unit.purposeRevision !== evidenceLedger(managed).purposeRevision) continue;
1189
1355
  unit.status=result.status;unit.evidenceRefs=result.evidenceRefs;unit.verifiedAt=now;
1190
1356
  }
1191
1357
  state.review = review; delete state.verificationDecisionId;
1192
1358
  run.revision += 1; run.updatedAt = now;
1193
- await verifyAutomaticCriteria(record, now);
1194
1359
  if (review.passed || review.needsHuman) {
1195
1360
  for (const criterion of effectiveAcceptanceCriteria(managed)) {
1196
- if (criterion.check.type === 'semantic' && review.criteria.some(row => row.id === criterion.id && row.passed)) {
1197
- managed.verified[criterion.id] = { source: 'independent_codex_session', checkedAt: now, workspaceDigest: review.workspaceDigest, session: review.session };
1361
+ if (criterion.check.type === 'semantic' && (review.criteria.some(row => row.id === criterion.id && row.passed) || review.passed && review.inspectionPerformed)) {
1362
+ managed.verified[criterion.id] = { source: 'independent_codex_session', checkedAt: now, workspaceDigest: review.workspaceDigest, session: review.session, assessment: review.criteria.some(row => row.id === criterion.id) ? 'criterion' : 'whole_outcome' };
1198
1363
  }
1199
1364
  }
1200
1365
  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 || []);}
@@ -1224,18 +1389,11 @@ export async function settlePurposeVerification({ root, provider, session, expec
1224
1389
  notifyState(run, now); return record;
1225
1390
  }
1226
1391
  }
1227
- if (!review.passed && !review.needsHuman && openFindings.length && openFindings.every(item => item.owner === 'controller')) {
1228
- const ledger = evidenceLedger(managed);
1229
- 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');
1230
- if (state.controllerLookup !== lookup && (ledger.baseline || ledger.receipts.length)) {
1231
- state.controllerLookup = lookup;
1232
- queuePurposeVerification(run, 'Controller retrieved existing baseline and tool receipts; reconcile the missing evidence against the supplied ledger before requesting worker repairs.', now);
1233
- notifyState(run, now); return record;
1234
- }
1235
- }
1236
1392
  if (!review.passed && !review.needsHuman) {
1237
1393
  const reason = `Outcome review found remaining work: ${[review.summary, ...openFindings.map(findingText)].join(' ').slice(0, 1800)}`;
1238
- recordDecision(managed, purposeDiagnosisPlan(managed, reason), now);
1394
+ const fallbackPlan = purposeDiagnosisPlan(managed, reason);
1395
+ fallbackPlan.alternatives[0].action.summary = 'Inspect the original requested behavior using existing results or another available method; continue concrete work without audit paperwork.';
1396
+ recordDecision(managed, fallbackPlan, now);
1239
1397
  state.phase = 'repairing'; managed.purpose.lastRecovery = { reason, at: now };
1240
1398
  run.checkpoint = null; run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
1241
1399
  run.nextStep = managed.decision.action.summary; run.reason = reason; managed.autoRecovery = true;
@@ -1290,6 +1448,13 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1290
1448
  validatePurposeReview(checkpoint.purposeReview, plan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed), now);
1291
1449
  if (searchEnabled(run.managed) && expectedLease && sourceContext) run.managed.purpose.originalContext ||= sourceContext;
1292
1450
  if (searchEnabled(run.managed) && expectedLease) retainControllerEvidence(run.managed, controllerEvidence, now);
1451
+ // This read has returned under the current lease. Release its resource
1452
+ // occupancy before selecting the next action; observation is not acceptance.
1453
+ const understandingUnit = run.managed.purpose?.evidence?.units?.find(unit => unit.id === decision.id);
1454
+ if (understandingUnit && decision.action.kind === 'read') {
1455
+ understandingUnit.status = result.status === 'observed' ? 'observed' : 'failed';
1456
+ understandingUnit.updatedAt = now;
1457
+ }
1293
1458
  run.managed.sessionUnderstanding = understanding;
1294
1459
  run.managed.understandingRevision = (run.managed.understandingRevision || 0) + 1;
1295
1460
  run.managed.understandingAnswer = null;
@@ -1448,7 +1613,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
1448
1613
  run.updatedAt = now; return record;
1449
1614
  });
1450
1615
  }
1451
- function prepareWaitingDiagnosis(run, reason, now) {
1616
+ export function prepareWaitingDiagnosis(run, reason, now) {
1452
1617
  const managed = run.managed, scope = managed.approvalScopes[0];
1453
1618
  if (!scope) throw new Error('Waiting recovery needs an enrolled scope.');
1454
1619
  const action = { kind: 'read', scope, summary: 'Inspect the resolved wait and current result before choosing new work; do not replay the prior action' };
@@ -1577,7 +1742,10 @@ export function decideStop(run, now = Date.now()) {
1577
1742
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
1578
1743
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
1579
1744
  const cp = fresh ? next.checkpoint : null;
1580
- if (cp?.status === 'completed') {
1745
+ const completionChecked = Number.isSafeInteger(next.completionCheck?.revision) &&
1746
+ next.revision > next.completionCheck.revision && next.consumedRevision >= next.completionCheck.revision;
1747
+ const inspectCompletion = !next.managed && cp?.status === 'completed' && !completionChecked;
1748
+ if (cp?.status === 'completed' && !inspectCompletion) {
1581
1749
  const pending = pendingAcceptance(next);
1582
1750
  if (pending.length) return finish('verification_pending', acceptancePendingReason(next));
1583
1751
  if (!next.managed || !next.managed.acceptanceCriteria.length) return finish('reported_done',
@@ -1629,7 +1797,7 @@ export function decideStop(run, now = Date.now()) {
1629
1797
  }
1630
1798
  const reportedStep = next.managed.associationCheckpointRevision !== next.revision && next.managed.contextBootstrap && next.managed.phase === 'active' && !next.managed.acceptanceCriteria.length &&
1631
1799
  !['session_understanding', 'action_association'].includes(cp?.kind) && cp?.status === 'in_progress' &&
1632
- next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
1800
+ next.managed.workHistory?.at(-1)?.result?.status === 'observed' && (next.managed.workHistory.at(-1).result.evidence?.length || next.managed.workHistory.at(-1).result.observationSource === 'controller_tool_result');
1633
1801
  if (next.managed.continuationPolicy === 'progress') {
1634
1802
  if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind) && (!fresh || next.managed.progressCheckpointRevision !== next.revision)) recordManagedOutcomeProgress(next, now, Boolean(fresh));
1635
1803
  } else if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
@@ -1672,8 +1840,15 @@ export function decideStop(run, now = Date.now()) {
1672
1840
  if (legacyApprovedHook) delete next.managed.approvedAction;
1673
1841
  }
1674
1842
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
1843
+ // A first standard completion is a candidate. One live continuation checks
1844
+ // the actual endpoint; only a subsequent checkpoint can report it done.
1845
+ // Missing/duplicate Stops cannot count as the result of that continuation.
1846
+ if (inspectCompletion) {
1847
+ next.completionCheck = { revision: next.revision, requestedAt: now };
1848
+ next.reason = 'check_reported_outcome';
1849
+ }
1675
1850
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
1676
- 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, next.managed)} 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}`;
1851
+ const instruction = inspectCompletion ? resultFollowThroughInstruction({ completion: true }) : 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, next.managed)} 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${informationWaitInstruction}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\n${informationWaitInstruction}\n${resultFollowThroughInstruction()}`;
1677
1852
  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.` } };
1678
1853
  }
1679
1854
  // A process signature protects against PID reuse. Never treat a stale timer alone
@@ -1786,7 +1961,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
1786
1961
  const currentLink = await providerLinkState(root, provider);
1787
1962
  if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1788
1963
  if (old && old.cwd !== cwd) return old;
1789
- const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
1964
+ const record = old || await applyArchiveFence(root, { schemaVersion: 1, provider, session: input.session_id, cwd, run: null });
1790
1965
  const stopMarker = readState(stopMarkerPath(root, provider, input.session_id));
1791
1966
  if (record.run && typeof stopMarker?.runId === 'string' && stopMarker.runId === record.run.id && stopMarker.id !== record.run.handledStopMarkerId) { record.run.state = 'stopped'; record.run.reason = 'User interrupted the provider.'; }
1792
1967
  // Inherited provider identity links an AI-launched conversation to its
@@ -1847,9 +2022,28 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
1847
2022
  if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
1848
2023
  else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
1849
2024
  const approved = record.run?.managed?.approvedAction;
1850
- output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}.${approved ? ` User approved exactly one ${approved.action.kind} action in scope ${approved.action.scope}: ${approved.action.summary}. Next step: ${approved.nextStep}. ${approved.dispatchedAt ? 'That approval was already dispatched; any repeated action needs a new approval.' : 'Resume the original conversation to dispatch this one action.'}` : ''} Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
2025
+ output = { hookSpecificOutput: { hookEventName: event, additionalContext: hookDeliveryContext(record, !env.SIMY_PARENT_SESSION && !(provider === 'claude' && env.CODEX_THREAD_ID) && (await automaticDeliveryStatus({ root, provider, cwd })).enabled) +
2026
+ (approved ? ` User approved exactly one ${approved.action.kind} action in scope ${approved.action.scope}: ${approved.action.summary}. Next step: ${approved.nextStep}. ${approved.dispatchedAt ? 'That approval was already dispatched; any repeated action needs a new approval.' : 'Resume the original conversation to dispatch this one action.'}` : '') } };
1851
2027
  }
1852
2028
  if (event === 'SessionEnd') record.hookEndedAt = now;
2029
+ if (!record.run && (['Interrupt', 'PermissionRequest'].includes(event) ||
2030
+ event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type))) record.automaticStartBlocked = event;
2031
+ if (event === 'UserPromptSubmit') {
2032
+ const automatic = await automaticDeliveryStatus({ root, provider, cwd });
2033
+ if (!record.run && !record.providerArchived && !record.automaticStartBlocked && !record.parentSession && !(record.autoContinue === false && record.autoContinueEpoch) &&
2034
+ !env.SIMY_PARENT_SESSION && !(provider === 'claude' && env.CODEX_THREAD_ID) && automatic.enabled && text(input.prompt)) {
2035
+ record.run = { ...initialDeliveryRun(input.prompt, 'Address the user request in this conversation and verify the result.', now),
2036
+ enrollmentSource: automatic.source === 'cli_default' ? 'cli_automatic' : 'workspace_automatic' };
2037
+ }
2038
+ // A completion claim made before a new user turn cannot answer that turn's
2039
+ // corrections. Keep its evidence and the original goal, but require a
2040
+ // fresh result. This does not classify the prompt or reopen finished work.
2041
+ if (record.run?.state === 'active' && !record.run.managed && text(input.prompt)) {
2042
+ delete record.run.completionCheck;
2043
+ if (record.run.checkpoint?.status === 'completed') record.run.consumedRevision = record.run.revision;
2044
+ }
2045
+ output = { hookSpecificOutput: { hookEventName: event, additionalContext: hookDeliveryContext(record, automatic.enabled && !env.SIMY_PARENT_SESSION && !(provider === 'claude' && env.CODEX_THREAD_ID)) } };
2046
+ }
1853
2047
  const run = record.run;
1854
2048
  if(run && event==='Stop') {
1855
2049
  // Recheck the actual retained task under its record lock. Lifecycle
@@ -1886,7 +2080,6 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
1886
2080
  }
1887
2081
  }
1888
2082
  if (!run || TERMINAL.has(run.state)) return record;
1889
- if (event === 'UserPromptSubmit') output = { hookSpecificOutput: { hookEventName: event, additionalContext: deliveryAcceptanceContext(run) } };
1890
2083
  if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
1891
2084
  else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
1892
2085
  // The supervisor owns this exact attempt and consumes its structured
@@ -1978,6 +2171,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
1978
2171
  });
1979
2172
  try {
1980
2173
  if (event === 'SessionStart') observed = await withSessionParentLock(root, applyObservation, inspectProcess);
2174
+ else if (event === 'UserPromptSubmit') observed = await withAutomaticLock(root, provider, cwd, applyObservation);
1981
2175
  else observed = await applyObservation();
1982
2176
  } catch (error) {
1983
2177
  if (claimedWorkspace?.release) await claimedWorkspace.release();
@@ -2244,7 +2438,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2244
2438
  const selectedKind = run.managed?.decision?.action?.kind;
2245
2439
  const approvedWait = run.managed?.decision?.outcome === 'wait' && run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
2246
2440
  const queuedAppServer = run.managed?.resumeChannel === 'codex_app_server' &&
2247
- ['read', 'local_edit', 'local_test', ...(searchEnabled(run.managed) ? ['external'] : [])].includes(selectedKind) &&
2441
+ ['read', 'local_edit', 'local_test', ...(searchEnabled(run.managed) && run.managed.autonomyPolicy === 'autonomous' && run.managed.decision?.assessment?.reversibility === 'reversible' ? ['external'] : [])].includes(selectedKind) &&
2248
2442
  (run.managed.decision.outcome !== 'wait' || approvedWait);
2249
2443
  if (queuedAppServer) run.managed.autoRecovery = true;
2250
2444
  run.state = queuedAppServer ? 'recovery_required' : 'active';