@awak-app/simy-cli 0.7.27 → 0.7.29

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 (34) hide show
  1. package/package.json +3 -3
  2. package/src/cli-contract.js +1 -1
  3. package/src/cli-observability.js +1 -1
  4. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +229 -22
  5. package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
  6. package/src/codex-plugins/simy/hooks/result-cycle.mjs +8 -1
  7. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
  8. package/src/delivery-hooks/command.js +4 -0
  9. package/src/delivery-hooks/lifecycle-recovery.js +1 -1
  10. package/src/delivery-hooks/supervisor.js +14 -2
  11. package/src/desktop-executor.js +6 -0
  12. package/src/orchestrator/budget.js +24 -0
  13. package/src/orchestrator/charter-repair.js +1 -1
  14. package/src/orchestrator/independent-audit.js +13 -6
  15. package/src/orchestrator/loop.js +34 -4
  16. package/src/provider-stream.js +9 -2
  17. package/src/runner.js +42 -20
  18. package/src/session-supervision/automatic-continuation.js +8 -1
  19. package/src/session-supervision/claude-outcome-review.js +132 -0
  20. package/src/session-supervision/claude-review-definition.js +5 -0
  21. package/src/session-supervision/claude-sessions.js +67 -6
  22. package/src/session-supervision/codex-app-server.js +170 -35
  23. package/src/session-supervision/codex-native-connection.js +101 -0
  24. package/src/session-supervision/continuation-analysis.js +1 -1
  25. package/src/session-supervision/index.js +5 -2
  26. package/src/session-supervision/native-handoff.js +251 -0
  27. package/src/session-supervision/purpose-recovery.js +1 -0
  28. package/src/session-supervision/purpose-verification.js +103 -9
  29. package/src/sqm/agent-skill.js +3 -1
  30. package/src/sqm/claude-plugin.js +2 -0
  31. package/src/sqm/command.js +17 -1
  32. package/src/sqm/hypothesis-knowledge.js +31 -10
  33. package/src/sqm/knowledge-recall.js +66 -0
  34. package/src/sqm/review-worker.js +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,11 +39,11 @@
39
39
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
40
40
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
41
41
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
42
- "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js && node --check ./src/session-supervision/remote-managed-delivery.js",
42
+ "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/claude-outcome-review.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js && node --check ./src/session-supervision/remote-managed-delivery.js",
43
43
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
44
44
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
45
45
  "check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
46
- "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
46
+ "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/claude-delivery-fault-live-e2e.mjs && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
47
47
  "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/login-shell-path.js && node --check ./src/hook-runtime.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/delivery-hooks/recovery.js && node --check ./src/delivery-hooks/supervisor-owner.js && node --check ./src/delivery-hooks/supervisor-watchdog.js && node --check ./src/delivery-hooks/supervisor-service.js && node --check ./scripts/supervisor-watchdog-live-e2e.js && node --check ./src/codex-plugins/simy/hooks/recovery-incident.mjs && node --check ./src/session-supervision/automatic-continuation.js && node --check ./src/session-supervision/stop-notifications.js",
48
48
  "check:package": "node ./scripts/check-package-contents.js",
49
49
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.27";
17
+ export const CLI_VERSION = "0.7.29";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -7,7 +7,7 @@ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { webApiHeaders, webApiUrl } from './web-api.js';
9
9
  // Keep this leaf module package-safe: hooks load it before the full CLI contract.
10
- const CLI_VERSION = '0.7.27';
10
+ const CLI_VERSION = '0.7.29';
11
11
 
12
12
  const STORE = 'cli-observability-state.json';
13
13
  const LEGACY_CONFIG = 'cli-observability.json';
@@ -432,14 +432,14 @@ function assessedPlan(value, managed = null) {
432
432
  if (!alternatives.some(option => option.id === recommendation.optionId) || (!flexible && !recommendation.reason)) throw new Error('Plan recommendation must identify an assessed option and reason.');
433
433
  return { diagnosis: text(value.diagnosis, 2000), alternatives, selectedOptionId, recommendation, userQuestion: text(value.userQuestion, 1000) || null };
434
434
  }
435
- function missingAcceptanceDiagnosis(managed, missing) {
435
+ export function missingAcceptanceDiagnosis(managed, missing) {
436
436
  const names = missing.map(item => item.id).join(', ');
437
437
  const scope = managed.decision.action.scope;
438
438
  const action = { kind: 'read', scope, summary: `Inspect missing acceptance checks: ${names}` };
439
- return assessedPlan({ diagnosis: `Worker claimed completion, but independent file checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
439
+ return assessedPlan({ diagnosis: `Worker claimed completion, but independent acceptance checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
440
440
  alternatives: [{ id: 'inspect_missing_acceptance', action,
441
441
  assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
442
- expectedVerification: `Report observed file state for ${names}`, rollback: 'No changes to undo' }],
442
+ expectedVerification: `Report actual outcome and unmet conditions for ${names}`, rollback: 'No changes to undo' }],
443
443
  selectedOptionId: 'inspect_missing_acceptance', recommendation: { optionId: 'inspect_missing_acceptance', reason: 'Independent checks have not verified completion.' } });
444
444
  }
445
445
  function rejectedActionDiagnosis(managed, rejected) {
@@ -503,6 +503,18 @@ export function canResearchReplan(run, provider, now) {
503
503
  !m.userQuestion && !['understanding', 'needs_clarification', 'action_association'].includes(m.phase) &&
504
504
  !m.nativeWait && !m.providerWarning && !m.usageBlocked && !m.purpose?.nativeBoundary && !run.blocker && !deliveryStop(run, now, { checkProgress: false }));
505
505
  }
506
+ // A legacy worker can mislabel a filesystem write as effect-free. Keep that
507
+ // write held; only diagnose its assessment after the original turn has settled.
508
+ function canDiagnoseLegacyLocalEffect(run, provider, now) {
509
+ const m = run?.managed, d = m?.decision, pending = m?.pendingApproval;
510
+ if (!['active', 'blocked'].includes(run?.state) || searchEnabled(m) || m?.capability !== 'live_hook' || m.autonomyPolicy !== 'autonomous' ||
511
+ d?.action?.kind !== 'local_edit' || d.assessment?.externalEffect !== 'none' ||
512
+ d.assessment.reversibility !== 'reversible' || d.reason !== 'Local write has inconsistent effect boundaries.' ||
513
+ pending?.id !== d.id || pending.denied || m.plan?.userQuestion ||
514
+ m.rejectedActionDigests?.includes(digest(JSON.stringify(d.action)))) return false;
515
+ return canResearchReplan({ ...run, state: 'blocked', managed: { ...m, pendingApproval: null,
516
+ decision: { ...d, waitKind: 'research' } } }, provider, now);
517
+ }
506
518
  export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { allowLocalTestWrites = false, llmDelegated = false, allowReversibleExternal = false } = {}) {
507
519
  const option = plan.alternatives.find(item => item.id === plan.selectedOptionId);
508
520
  const { action } = option;
@@ -514,9 +526,10 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { a
514
526
  if (!scopes.includes(action.scope)) { reason = 'Action scope is outside the task enrollment.'; waitKind = 'authority'; }
515
527
  else if (facts.inObjective !== true) reason = 'Establish objective fit or choose work within the original task.';
516
528
  else if (facts.reversibility === 'irreversible') { reason = 'Irreversible consequences require an exact user decision.'; waitKind = 'human'; }
517
- else if (facts.reversibility !== 'reversible' || facts.externalEffect === 'unknown' || facts.blastRadius === 'unknown') reason = 'Investigate effect boundaries and reversibility before execution.';
529
+ else if (facts.reversibility !== 'reversible' || facts.externalEffect === 'unknown' || facts.blastRadius === 'unknown') reason = `Investigate unresolved assessment fields before execution: ${['reversibility', 'externalEffect', 'blastRadius'].filter(key => key === 'reversibility' ? facts[key] !== 'reversible' : facts[key] === 'unknown').map(key => `${key}=${facts[key]}`).join(', ')}. Inspect the actual target and operation contract; preserve facts that remain unknown.`;
518
530
  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.';
519
531
  else if (!option.expectedVerification) reason = 'Expected verification is missing; acquire a verification method.';
532
+ else if (autonomous && allowReversibleExternal && action.kind === 'external' && facts.externalEffect !== 'external') reason = 'External action and declared effects disagree. Inspect the target and correct the assessment without executing it; API/service mutations have external effects even for a loopback service, while local filesystem work uses a local action.';
520
533
  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'; }
521
534
  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.';
522
535
  else if (localWrite && facts.externalEffect !== 'local') reason = 'Local write has inconsistent effect boundaries.';
@@ -542,11 +555,11 @@ export function canExecuteAutonomousExternal(managed) {
542
555
  // contract identical across transports; the host still verifies acceptance.
543
556
  export function assessedCheckpointContract(decision, managed = null) {
544
557
  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.';
545
- 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.`;
558
+ 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. When the requested outcome is a conversational answer or correction, put the actual answer text in summary. Saying it was prepared or delivered is insufficient; do not place the answer only in an undeclared JSON field. For an answer-only request, returning that text here is the conversational result: do not invent a separate message-delivery action unless the original user requested a separate destination. This does not verify implementation or external effects. 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. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. 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. Report completed only when the original requested outcome is achieved; keep unfinished checks as remaining work. Do not add audit reports, certification, or evidence-submission tasks. 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.`;
546
559
 
547
560
  }
548
561
  export function understandingCheckpointContract(decision) {
549
- 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.`;
562
+ 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 blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. 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. If and only if the original user requests ONLY a conversational answer or design (no implementation, files, tests, deployment or external effects), no independent review/audit or human acceptance is requested anywhere in the user context, and the existing answer already satisfies every requested point, you may include sessionUnderstanding.conversationOutcome:{kind:"answer_only",independentReviewRequested:false,requestTurnRefs:["exact user turn refs"],answerTurnRefs:["exact assistant answer turn refs"]}. Include the latest user and latest answer references, and exact user excerpts in the criteria basis. Read and judge the actual answer, including factual errors and unsupported claims; its mere existence or a worker saying done is insufficient. If the answer is wrong or incomplete, use clear with the next read action to formulate the missing/corrected answer, not already_done. Omit conversationOutcome for uncertain scope, missing context or requested effects; a design that describes unimplemented work is complete only when the user asked for design alone. This optional classification ends only an initially uncontracted conversational task as reported_done; it does not waive a frozen acceptance contract or assert 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.`;
550
563
  }
551
564
  function boundedContextList(value, maxItems = 8, maxLength = 300) {
552
565
  if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
@@ -588,13 +601,40 @@ function sessionUnderstanding(value) {
588
601
  }) : [];
589
602
  const result = { state: value.state, objective: contextText(value.objective, 1000), constraints: boundedContextList(value.constraints),
590
603
  done: boundedContextList(value.done), remaining: boundedContextList(value.remaining), nextAction: value.nextAction ? actionDescriptor(value.nextAction) : null,
591
- criteria, matchingContext: value.matchingContext && typeof value.matchingContext === 'object' ? boundedMatchingContext(value.matchingContext) : null, ambiguities: boundedContextList(value.ambiguities, 3), question: contextText(value.question, 500) || null, basis };
604
+ criteria, conversationOutcome: normalizeConversationOutcome(value.conversationOutcome), matchingContext: value.matchingContext && typeof value.matchingContext === 'object' ? boundedMatchingContext(value.matchingContext) : null, ambiguities: boundedContextList(value.ambiguities, 3), question: contextText(value.question, 500) || null, basis };
592
605
  if (result.state === 'clear' && (!result.objective || !result.remaining.length || !result.nextAction || result.ambiguities.length || result.question)) throw new Error('Clear understanding needs an objective, remaining work, and an unambiguous next action.');
593
606
  if (result.state === 'already_done' && (!result.objective || result.remaining.length || result.nextAction)) throw new Error('Already-finished understanding must have no remaining action.');
594
607
  if (['clear', 'already_done'].includes(result.state) && !basis.some(item => item.source === 'user')) throw new Error('Inferred session goal needs a user-message basis.');
595
608
  if (['ambiguous', 'context_missing'].includes(result.state) && (!result.question || result.ambiguities.length !== 1)) throw new Error('Unclear understanding needs one focused question.');
596
609
  return result;
597
610
  }
611
+ function normalizeConversationOutcome(value) {
612
+ if (value?.kind !== 'answer_only' || value.independentReviewRequested !== false) return null;
613
+ const refs = key => Array.isArray(value[key]) && value[key].length > 0 && value[key].length <= 32 &&
614
+ value[key].every(ref => typeof ref === 'string' && ref.length > 0 && ref.length <= 160) ? [...new Set(value[key])] : null;
615
+ const requestTurnRefs = refs('requestTurnRefs'), answerTurnRefs = refs('answerTurnRefs');
616
+ return requestTurnRefs && answerTurnRefs ? { kind: 'answer_only', independentReviewRequested: false, requestTurnRefs, answerTurnRefs } : null;
617
+ }
618
+ function conversationUnderstandingComplete(run, understanding, context) {
619
+ const managed = run.managed, outcome = understanding.conversationOutcome;
620
+ if (!managed.contextBootstrap || managed.acceptanceCriteria.length || managed.independentReviewRequired ||
621
+ understanding.state !== 'already_done' || !outcome || context?.proseComplete !== true || context.proseOnly !== true || context.incomplete || context.awaitingTurn ||
622
+ !understanding.criteria.length || understanding.criteria.some(c => c.source !== 'user_grounded' || c.check.type !== 'semantic') ||
623
+ managed.executionUncertain || managed.purpose?.nativeBoundary) return false;
624
+ if ([...(managed.workHistory || []), ...(managed.purpose?.evidence?.units || [])].some(row => row.action?.kind && row.action.kind !== 'read') ||
625
+ managed.purpose?.evidence?.receipts?.some(row => row.kind === 'fileChange')) return false;
626
+ const messages = context.messages || [], latestUser = messages.findLastIndex(row => row.role === 'user');
627
+ const latestAnswer = messages.findLastIndex(row => row.role === 'assistant');
628
+ if (latestUser < 0 || latestAnswer <= latestUser || !outcome.requestTurnRefs.includes(messages[latestUser].turnRef) ||
629
+ !outcome.answerTurnRefs.includes(messages[latestAnswer].turnRef)) return false;
630
+ if (outcome.requestTurnRefs.some(ref => !messages.some(row => row.role === 'user' && row.turnRef === ref)) ||
631
+ outcome.answerTurnRefs.some(ref => !messages.some((row, index) => index > latestUser && row.role === 'assistant' && row.turnRef === ref && row.text?.trim()))) return false;
632
+ return understanding.criteria.every(criterion => {
633
+ const basis = understanding.basis[criterion.basisIndex];
634
+ return basis?.source === 'user' && outcome.requestTurnRefs.includes(basis.turnRef) && basis.snippet &&
635
+ messages.some(row => row.role === 'user' && row.turnRef === basis.turnRef && row.text.includes(basis.snippet));
636
+ });
637
+ }
598
638
  function recordPurposeReview(managed, review, now) {
599
639
  const purpose = managed.purpose;
600
640
  if (searchEnabled(managed)) reconcileRequirements(managed, review, now);
@@ -790,15 +830,83 @@ export function pendingAcceptance(run) {
790
830
  if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
791
831
  JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
792
832
  check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
793
- return effectiveAcceptanceCriteria(run.managed).filter(item => !Object.hasOwn(run.managed.verified, item.id))
833
+ const pending = effectiveAcceptanceCriteria(run.managed).filter(item => !Object.hasOwn(run.managed.verified, item.id))
794
834
  .map(item => ({ id: item.id, description: item.description, check: item.check.type,
795
835
  detail: run.managed.criterionObservations?.[item.id]?.observed_result || (item.check.type === 'manual' ? 'Explicit user verification required' : 'Independent check not yet passed') }));
836
+ // File predicates and a worker claim cannot waive an explicit review.
837
+ // The host checkpoint path supplies an independently bound result when available.
838
+ if (run.managed?.independentReviewRequired && !searchEnabled(run.managed) && run.managed.liveOutcomeReview?.passed !== true) pending.push({
839
+ id: 'independent-review', description: 'Required independent outcome review', check: 'semantic',
840
+ detail: 'Independent outcome review has not passed; file checks alone cannot complete this task' });
841
+ return pending;
796
842
  }
797
843
  function acceptancePendingReason(run) {
798
844
  return `Acceptance verification pending: ${pendingAcceptance(run).map(item => `${item.id} (${item.description}): ${item.detail}`).join('; ')}`;
799
845
  }
846
+ // Only the host review executor calls this; worker checkpoints cannot supply a
847
+ // passing verdict. Recheck the enrollment, holds, and actual workspace at commit.
848
+ export async function settleLiveOutcomeReview({ root, provider, session, snapshot, review }) {
849
+ return updateRecord(root, provider, session, async record => {
850
+ if (!liveOutcomeReviewEligible(record) || record.run.id !== snapshot.run.id || record.run.revision !== snapshot.run.revision || record.run.controlRevision !== snapshot.run.controlRevision ||
851
+ JSON.stringify(record.run.managed.enrollment) !== JSON.stringify(snapshot.run.managed.enrollment) ||
852
+ hasPendingUserStop(root, record) || !(await providerLinkState(root, provider)).connected) return record;
853
+ const run = record.run, managed = run.managed;
854
+ const owner = managed.liveHookOwner;
855
+ if (owner && await processIdentity(owner.pid) !== owner.identity) return record;
856
+ if (review.inspectionAvailable !== false && await workspaceIdentity(taskDirectory(record)).catch(() => null) !== review.workspaceDigest)
857
+ review = { passed: false, inspectionAvailable: false, summary: 'Workspace changed during independent review; reread the current outcome before retrying.', findings: [], source: 'controller_review_unavailable', checkedAt: Date.now() };
858
+ managed.liveOutcomeReview = review;
859
+ if (review.nativeRequest) {
860
+ managed.nativeOutcomeReview = review.nativeRequest;
861
+ const plan = missingAcceptanceDiagnosis(managed, [{ id: 'native-independent-review', description: review.summary }]);
862
+ plan.alternatives[0].action.summary = `Use native Agent ${review.nativeRequest.agentType} with the exact managed.nativeOutcomeReview.prompt from simy delivery status. No extra context, fork, resume, model override or tools. Wait for its result, then submit a completed checkpoint. Keep the original budget. If unavailable, report not_run; do not retry or launch another Claude process.`;
863
+ plan.diagnosis = review.summary;
864
+ recordDecision(managed, plan, Date.now());
865
+ run.state = 'active';
866
+ run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false, nextStep: plan.alternatives[0].action.summary };
867
+ run.nextStep = run.checkpoint.nextStep;
868
+ return record;
869
+ }
870
+ if (review.budgetBoundary) {
871
+ // Do not spend the original worker allowance on retrying an executor that
872
+ // cannot share its cap. Keep acceptance pending at the genuine Stop.
873
+ run.reason = review.summary;
874
+ return record;
875
+ }
876
+ if (review.passed) {
877
+ for (const criterion of effectiveAcceptanceCriteria(managed)) if (criterion.check.type === 'semantic')
878
+ managed.verified[criterion.id] = { source: 'independent_claude_review', checkedAt: Date.now(), workspaceDigest: review.workspaceDigest };
879
+ await verifyAutomaticCriteria(record, Date.now());
880
+ // Keep the original writer lease until its genuine Stop boundary.
881
+ if (!pendingAcceptance(run).length) { run.state = 'active'; run.reason = 'Independent review passed; awaiting original Stop readback.'; }
882
+ } else if (managed.autonomyPolicy && managed.decision) {
883
+ const missing = [{ id: 'independent-review', description: review.summary }];
884
+ const plan = missingAcceptanceDiagnosis(managed, missing);
885
+ plan.diagnosis = [review.summary, ...(review.findings || [])].join(' ').slice(0, 4000);
886
+ if (review.inspectionAvailable === false) {
887
+ plan.alternatives[0].action.summary = 'Inspect independent review availability and missing observations without changing the deliverable';
888
+ plan.alternatives[0].expectedVerification = 'Identify the concrete observation or provider failure and an authorized way to retry verification of the original outcome.';
889
+ plan.diagnosis = `Independent inspection unavailable; no product defect has been established. ${plan.diagnosis}`;
890
+ run.failures += 1;
891
+ }
892
+ recordDecision(managed, plan, Date.now());
893
+ run.state = 'active';
894
+ run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false, nextStep: plan.alternatives[0].action.summary };
895
+ run.nextStep = run.checkpoint.nextStep; managed.remainingWork = plan.diagnosis;
896
+ }
897
+ return record;
898
+ });
899
+ }
900
+ export function liveOutcomeReviewEligible(record) {
901
+ const run = record?.run, m = run?.managed;
902
+ return Boolean(record?.provider === 'claude' && m?.capability === 'live_hook' && !searchEnabled(m) &&
903
+ run.state === 'verification_pending' && run.checkpoint?.status === 'completed' &&
904
+ (m.independentReviewRequired || effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic')) &&
905
+ !m.liveOutcomeReview && !m.pendingApproval && !m.nativeWait && !m.executionUncertain && !m.purpose?.nativeBoundary &&
906
+ !managedBudgetReason(run) && automaticContinuationAllowed(record));
907
+ }
800
908
  export function deliveryAcceptanceContext(run) {
801
- return `Original objective: ${run.managed?.enrollment?.objective || run.objective}. Mode: ${run.managed ? 'managed acceptance tracking' : 'standard checkpoint tracking; completion reports are not verified acceptance'}.` +
909
+ return `${run.managed?.capability === 'live_hook' && run.managed?.thread?.provider === 'claude' ? 'When the requested artifact is ready or repaired, submit a completed work checkpoint to request independent review. This does not grant acceptance. If nativeOutcomeReview.prompt is absent, submit that checkpoint first to obtain a fresh review prompt; never reuse a pre-repair prompt. ' : ''}Original objective: ${run.managed?.enrollment?.objective || run.objective}. Mode: ${run.managed ? 'managed acceptance tracking' : 'standard checkpoint tracking; completion reports are not verified acceptance'}.` +
802
910
  `${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
803
911
  ` 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.`;
804
912
  }
@@ -1065,7 +1173,8 @@ export function queuePurposeDiagnosis(run, reason, key, now = Date.now(), recove
1065
1173
  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.';
1066
1174
  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.';
1067
1175
 
1068
- export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
1176
+ export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, expectedNativeHandoff, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
1177
+ if (expectedNativeHandoff && checkpoint?.kind) throw new Error('Native handoff requires an ordinary action checkpoint.');
1069
1178
  if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
1070
1179
  if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, sourceContext, controllerEvidence, now });
1071
1180
  try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
@@ -1081,6 +1190,16 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1081
1190
  let nextStep = text(checkpoint.nextStep);
1082
1191
  let repairPlan = null;
1083
1192
  if (!run || !checkpointDirectoryMatches(record, cwd)) throw new Error('No matching active Delivery Loop.');
1193
+ if (expectedNativeHandoff) {
1194
+ const h = run.managed?.nativeHandoff, expected = expectedNativeHandoff;
1195
+ if (h?.id !== expected.id || h.turnId !== expected.turnId || h.phase !== 'finished' || h.turnStatus !== 'completed' || run.id !== expected.runId ||
1196
+ h.decisionId !== expected.decisionId || h.decisionId !== run.managed.decision?.id || run.revision !== expected.revision ||
1197
+ run.controlRevision !== expected.controlRevision || record.autoContinueEpoch !== expected.epoch ||
1198
+ !record.autoContinue || !automaticContinuationAllowed(record) || hasPendingUserStop(root, record) ||
1199
+ run.stopReason || run.state !== 'recovery_required') throw new Error('Native handoff changed; checkpoint discarded.');
1200
+ if (h.checkpointRevision !== undefined) return record;
1201
+ run.state = 'active';
1202
+ }
1084
1203
  if (run.managed?.phase === 'understanding' || run.managed?.phase === 'needs_clarification') throw new Error('Session understanding needs its separate structured checkpoint.');
1085
1204
  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.');
1086
1205
  if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
@@ -1266,6 +1385,10 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1266
1385
  ...(remainingWork ? { remainingWork } : {}), ...(incompleteResult ? { reportedStatus: checkpoint.status } : {}),
1267
1386
  evidence, acceptanceMet: !unsupportedWait && !incompleteResult && checkpoint.acceptanceMet === true };
1268
1387
  if (!run.managed && effectiveStatus !== 'completed') delete run.completionCheck;
1388
+ if (run.managed?.liveOutcomeReview) {
1389
+ delete run.managed.liveOutcomeReview;
1390
+ for (const criterion of effectiveAcceptanceCriteria(run.managed)) if (criterion.check.type === 'semantic') delete run.managed.verified[criterion.id];
1391
+ }
1269
1392
  run.revision += 1; run.updatedAt = now;
1270
1393
  recordManagedOutcomeProgress(run, now);
1271
1394
  // File predicates were reread above; a prior semantic verdict can belong
@@ -1296,6 +1419,14 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1296
1419
  sourceId: expectedLease || `checkpoint:${run.revision}`, attemptId: expectedLease,
1297
1420
  resultRef: `checkpoint:${run.revision}`, now }));
1298
1421
  notifyState(run, now);
1422
+ if (expectedNativeHandoff) {
1423
+ run.managed.nativeHandoff.checkpointRevision = run.revision;
1424
+ const attempt = run.managed.executionAttempt;
1425
+ if (attempt?.decisionId === expectedNativeHandoff.decisionId) {
1426
+ attempt.phase = 'checkpointed'; attempt.checkpointRevision = run.revision;
1427
+ }
1428
+ if (run.state === 'active') { run.state = 'recovery_required'; run.recoveryCause = 'session_supervision'; }
1429
+ }
1299
1430
  return record;
1300
1431
  });
1301
1432
  return settled;
@@ -1326,6 +1457,16 @@ export function externalReviewIsCurrent(managed,review,now) {
1326
1457
  .every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
1327
1458
 
1328
1459
  }
1460
+ function verifiedConversationResult(managed, review) {
1461
+ const inspection = review.semanticInspection;
1462
+ if (inspection?.source !== 'controller_conversation_answer' || inspection.digest !== review.answerContext?.digest ||
1463
+ !/^[a-f0-9]{64}$/.test(inspection.digest || '') || !review.inspectionPerformed) return null;
1464
+ const answer = (managed.purpose?.evidence?.receipts || []).filter(row => row.kind === 'agentMessage' && row.ref?.startsWith('answer:')).at(-1);
1465
+ if (!answer || answer.ref !== inspection.answerRefs?.at(-1) || answer.id !== inspection.answerDigest ||
1466
+ answer.observation?.truncated || answer.observation?.redacted || typeof answer.observation?.text !== 'string' ||
1467
+ createHash('sha256').update(answer.observation.text).digest('hex') !== inspection.answerDigest) return null;
1468
+ return answer.observation.text;
1469
+ }
1329
1470
  export async function settlePurposeVerification({ root, provider, session, expectedLease, review, verifySnapshot, now = Date.now() }) {
1330
1471
  const started = Date.now(), initialNow = now;
1331
1472
  const snapshot = (await listRecords(root)).find(record => record.provider === provider && record.session === session);
@@ -1426,7 +1567,7 @@ export async function settlePurposeVerification({ root, provider, session, expec
1426
1567
  if (review.passed && !pending.length && !managed.purpose.nativeBoundary && !managed.executionUncertain && pendingAcceptance(run).length === 0) {
1427
1568
  for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
1428
1569
  run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
1429
- run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
1570
+ run.checkpoint = { status: 'completed', summary: verifiedConversationResult(managed, review) || review.summary, evidence: review.criteria.map(c => c.evidence) };
1430
1571
  bindControllerCompletion(run, now);
1431
1572
  managed.autoRecovery = false;
1432
1573
  settleCompletionHandoffs(run, now, review);
@@ -1497,6 +1638,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1497
1638
  const result = checkpoint.actionResult;
1498
1639
  if (decision?.action.kind !== 'read' || result?.decisionId !== decision.id || !['observed', 'failed', 'not_run'].includes(result.status) || !text(result.summary, 300)) throw new Error('Understanding result must match the selected read decision.');
1499
1640
  const understanding = sessionUnderstanding(checkpoint.sessionUnderstanding);
1641
+ const conversationalCompletion = Boolean(expectedLease) && conversationUnderstandingComplete(run, understanding, sourceContext);
1500
1642
  if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
1501
1643
  const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan, run.managed) : null;
1502
1644
  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.');
@@ -1548,8 +1690,16 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1548
1690
  run.managed.phase = 'active';
1549
1691
  run.state = 'reported_done';
1550
1692
  run.reason = 'Original conversation reports no remaining work; independent completion was not verified.';
1551
- if (purposeEnabled(run)) {
1552
- run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded').map(item => ({ id: item.id, description: item.description,
1693
+ if (conversationalCompletion) {
1694
+ // These are inferred expectations, not a user-enrolled verification
1695
+ // contract. Preserve them in sessionUnderstanding without promoting
1696
+ // them into new audit obligations or claiming independent acceptance.
1697
+ run.reason = 'The requested conversational answer was checked against its user context; no independent acceptance was requested.';
1698
+ run.managed.autoRecovery = false;
1699
+ run.checkpoint = { ...run.checkpoint, status: 'completed', summary: checkpoint.summary };
1700
+ bindControllerCompletion(run, now);
1701
+ } else if (purposeEnabled(run)) {
1702
+ if (!run.managed.acceptanceCriteria.length) run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded').map(item => ({ id: item.id, description: item.description,
1553
1703
  check: item.check.type === 'file_equals' ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
1554
1704
  if (searchEnabled(run.managed)) queuePurposeVerification(run, 'Original conversation reports completion.', now);
1555
1705
  else queuePurposeDiagnosis(run, 'The conversation reports completion. Check the original purpose against actual artifacts and outcomes before accepting that claim.', 'reported_completion', now);
@@ -1583,6 +1733,11 @@ function newVerifiedCriterion(run, id, now) {
1583
1733
  }
1584
1734
  export async function verifyAutomaticCriteria(record, now) {
1585
1735
  const managed = record.run.managed;
1736
+ if (managed.liveOutcomeReview?.passed && managed.liveOutcomeReview.workspaceDigest !== await workspaceIdentity(taskDirectory(record)).catch(() => null)) {
1737
+ managed.liveOutcomeReview = { ...managed.liveOutcomeReview, passed: false, summary: 'Workspace changed after independent review; inspect the current result.' };
1738
+ delete managed.nativeOutcomeReview;
1739
+ for (const criterion of effectiveAcceptanceCriteria(managed)) if (criterion.check.type === 'semantic') delete managed.verified[criterion.id];
1740
+ }
1586
1741
  managed.criterionObservations = {};
1587
1742
  const observe = (criterion, passed, detail, sha256 = null) => {
1588
1743
  managed.criterionObservations[criterion.id] = { criterion_id: criterion.id, passed,
@@ -2154,20 +2309,52 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2154
2309
  run.reason = 'Prior live action stopped without a settled checkpoint; explicitly resume read-only result diagnosis.';
2155
2310
  }
2156
2311
  }
2312
+ if (event === 'Stop' && !suppressDispatch && automaticContinuationAllowed(record) &&
2313
+ canDiagnoseLegacyLocalEffect(run, provider, now)) {
2314
+ const latestLink = await providerLinkState(root, provider);
2315
+ if (latestLink.connected && latestLink.epoch === link.epoch) {
2316
+ recordDecision(run.managed, researchWaitDiagnosis(run.managed), now);
2317
+ run.state = 'active'; run.reason = 'The inconsistent local write remains unexecuted; inspect its assessment first.';
2318
+ run.nextStep = run.managed.decision.action.summary;
2319
+ run.checkpoint = null; run.consumedRevision = run.revision;
2320
+ run.managed.escalation = null;
2321
+ }
2322
+ }
2157
2323
  if (!run || TERMINAL.has(run.state)) return record;
2158
2324
  if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
2159
2325
  else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
2160
2326
  // The supervisor owns this exact attempt and consumes its structured
2161
2327
  // final result once. Provider lifecycle hooks must not race that lease.
2162
2328
  }
2163
- else if (['paused', 'verification_pending'].includes(run.state) || run.state === 'blocked' && run.managed?.dependencyWait) {
2329
+ else if (run.state !== 'paused' && (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type))) {
2330
+ // A native permission wait is still authoritative while independent
2331
+ // acceptance is pending; lifecycle bookkeeping must not hide it.
2332
+ run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
2333
+ }
2334
+ else if (['paused', 'verification_pending'].includes(run.state) || run.state === 'blocked' &&
2335
+ (run.managed?.dependencyWait || run.managed?.pendingApproval || run.managed?.nativeWait ||
2336
+ ['authorization', 'execution_uncertain'].includes(run.blocker))) {
2164
2337
  // A provider lifecycle event cannot override an explicit pause or a
2165
2338
  // completion candidate awaiting independent acceptance checks.
2166
2339
  }
2167
- else if (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type)) {
2168
- run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
2169
- } else if (event === 'SessionEnd') {
2170
- run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
2340
+ else if (event === 'SessionEnd') {
2341
+ const managed = run.managed, lastAttempt = managed?.attempts?.at(-1);
2342
+ // Ending the native writer must not erase a previously authorized retry.
2343
+ // A matching undispatched busy receipt also recovers this queue when an
2344
+ // older hook already replaced its cause with session_end.
2345
+ const undispatchedBusy = lastAttempt?.outcome === 'busy' && lastAttempt.id === managed?.executionAttempt?.id &&
2346
+ managed.executionAttempt.phase === 'claimed' && !managed.executionAttempt.dispatchedAt;
2347
+ const queued = provider === 'codex' && record.autoContinue === true && automaticContinuationAllowed(record) &&
2348
+ managed?.resumeChannel === 'codex_app_server' && managed.autoRecovery === true &&
2349
+ !managed.pendingApproval && !managed.executionUncertain && run.state === 'recovery_required' &&
2350
+ (['session_supervision', 'approved_action'].includes(run.recoveryCause) || run.recoveryCause === 'session_end' && undispatchedBusy);
2351
+ if (queued) {
2352
+ // Keep nextAttemptAt, the selected decision, approvals and all budgets.
2353
+ if (run.recoveryCause === 'session_end') run.recoveryCause = 'session_supervision';
2354
+ run.reason = 'The native session ended. Previously authorized automatic continuation is waiting for the writer lock to become available.';
2355
+ } else {
2356
+ run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
2357
+ }
2171
2358
  } else if (event === 'StopFailure') {
2172
2359
  run.failures += 1;
2173
2360
  run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
@@ -2178,6 +2365,10 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2178
2365
  // turn. Lease settlement and runtime observation above still run.
2179
2366
  const latestLink = await providerLinkState(root, provider);
2180
2367
  if (!automaticContinuationAllowed(record) || !latestLink.connected || latestLink.epoch !== link.epoch) return record;
2368
+ // Preserve the reason for a held action, including uncertainty observed
2369
+ // while settling this Stop's writer lease. Acceptance is not a wait release.
2370
+ if (run.state !== 'active' || run.managed?.nativeWait || run.managed?.purpose?.nativeBoundary ||
2371
+ run.managed?.pendingApproval && run.checkpoint?.status === 'completed') return record;
2181
2372
  // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
2182
2373
  // stable event ID; never dedupe on text (identical text can be a new turn).
2183
2374
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
@@ -2185,7 +2376,18 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2185
2376
  if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
2186
2377
  await verifyAutomaticCriteria(record, now);
2187
2378
  const pending = pendingAcceptance(run);
2188
- if (pending.length) {
2379
+ const missingFiles = pending.filter(item => ['file_exists', 'file_contains', 'file_equals'].includes(item.check) || run.managed.liveOutcomeReview?.passed === false && item.check === 'semantic');
2380
+ if (missingFiles.length && run.managed.autonomyPolicy && !run.managed.pendingApproval &&
2381
+ !run.managed.nativeWait && !run.managed.purpose?.nativeBoundary && !run.managed.executionUncertain) {
2382
+ // Completion reads at checkpoint time can become stale before Stop.
2383
+ // Use the same assessed diagnosis as a failed checkpoint predicate;
2384
+ // a new file mismatch is useful work, not human acceptance waiting.
2385
+ recordDecision(run.managed, missingAcceptanceDiagnosis(run.managed, missingFiles), now);
2386
+ run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false,
2387
+ nextStep: `Diagnose failed acceptance checks: ${missingFiles.map(item => item.id).join(', ')}` };
2388
+ run.nextStep = run.checkpoint.nextStep;
2389
+ run.managed.remainingWork = run.nextStep;
2390
+ } else if (pending.length) {
2189
2391
  run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
2190
2392
  run.lastStopKey = key; notifyState(run, now); return record;
2191
2393
  }
@@ -2302,7 +2504,7 @@ export function deliveryDecisionRevision(run) {
2302
2504
  return Number.isSafeInteger(revision) ? revision : undefined;
2303
2505
  }
2304
2506
  export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, resumeObservation, action, inspectProcess = processIdentity, now = Date.now() }) {
2305
- return updateRecord(root, provider, session, async record => {
2507
+ const updated = await updateRecord(root, provider, session, async record => {
2306
2508
  const run = record?.run;
2307
2509
  if (!run) throw new Error('Delivery Loop not found.');
2308
2510
  if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
@@ -2413,7 +2615,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2413
2615
  else if (action === 'pause') {
2414
2616
  const queuedAppServer = run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(run.recoveryCause) &&
2415
2617
  run.managed?.resumeChannel === 'codex_app_server' && run.managed.autoRecovery;
2416
- if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer) throw new Error('Only a running or queued managed task can pause.');
2618
+ if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer && !liveOutcomeReviewEligible(record)) throw new Error('Only a running or queued managed task can pause.');
2417
2619
  if (queuedAppServer || run.state === 'auto_resuming' && run.managed.resumeChannel === 'codex_app_server') run.managed.pausedRecoveryCause = run.recoveryCause || 'session_supervision';
2418
2620
  run.state = 'paused'; run.reason = 'Paused by user.';
2419
2621
  run.stopReason = { actor: 'user', code: 'explicit_pause', at: now };
@@ -2534,7 +2736,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2534
2736
  run.checkpoint = { status: 'in_progress', summary: run.reason, nextStep: run.nextStep, evidence: [], acceptanceMet: false };
2535
2737
  run.revision += 1;
2536
2738
  run.managed.approvedAction = { id: pending.id, nextStep: run.nextStep, action: { kind: pending.kind, scope: pending.scope, summary: pending.summary } };
2537
- if (searchEnabled(run.managed) && run.managed.resumeChannel === 'codex_app_server' && pending.kind === 'external' && run.managed.decision?.assessment?.reversibility === 'reversible') run.managed.autoRecovery = true;
2739
+ if (run.managed.resumeChannel === 'codex_app_server' &&
2740
+ (['read', 'local_edit', 'local_test'].includes(pending.kind) || searchEnabled(run.managed) && pending.kind === 'external' && run.managed.decision?.assessment?.reversibility === 'reversible')) run.managed.autoRecovery = true;
2538
2741
  } else {
2539
2742
  pending.denied = true; run.reason = `User rejected ${pending.scope}.`; run.blocker = 'authorization';
2540
2743
  rememberRejectedAction(run.managed, pending);
@@ -2546,6 +2749,10 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2546
2749
  }
2547
2750
  run.updatedAt = now; notifyState(run, now); return commitControl();
2548
2751
  });
2752
+ if (['stop', 'pause'].includes(action) && updated?.run?.managed?.nativeHandoff) {
2753
+ void import('../../../session-supervision/native-handoff.js').then(({ reconcileNativeHandoff }) => reconcileNativeHandoff({ root, record: updated })).catch(() => {});
2754
+ }
2755
+ return updated;
2549
2756
  }
2550
2757
  export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity, records: suppliedRecords } = {}) {
2551
2758
  const records = suppliedRecords || await listRecords(root);
@@ -34,14 +34,32 @@ export function searchState(managed) {
34
34
  const actionKey = action => hash([action.kind, action.scope, bounded(action.summary).replace(/\s+/g, ' ')]);
35
35
  const experimentContext = run => hash([run.managed.verifiedRevision, run.managed.purpose.evidence?.current?.digest || null]);
36
36
 
37
+ // Actual failed operations survive changes to the worker's prose. Keep only a
38
+ // digest here: captured output may contain sensitive data and is not authority.
39
+ function failedOperationKey(toolEvidence) {
40
+ const operations = [];
41
+ for (const item of toolEvidence || []) {
42
+ if (item?.source !== 'codex_tool_event' || item.kind !== 'commandExecution' ||
43
+ !Number.isInteger(item.exitCode) || item.exitCode === 0 || item.observation?.truncated) continue;
44
+ let captured;
45
+ try { captured = JSON.parse(item.observation?.text); } catch { continue; }
46
+ const command = item.command || captured.command;
47
+ if (typeof command !== 'string' || typeof captured.output !== 'string') continue;
48
+ operations.push([command, item.exitCode, captured.output]);
49
+ }
50
+ return operations.length ? hash(operations) : null;
51
+ }
52
+
37
53
  // Compact history survives the short conversational window and process restart.
38
54
  // It is a search hint, never a permission or an acceptance receipt.
39
55
  export function repeatedSearchAction(run) {
40
56
  if (!searchEnabled(run.managed)) return null;
41
57
  const action = run.managed.decision?.action;
42
58
  if (!action || !['read', 'local_edit', 'local_test'].includes(action.kind)) return null;
43
- return searchState(run.managed).experimentIndex?.find(item => item.actionKey === actionKey(action) &&
44
- item.context === experimentContext(run) && item.failures >= 2) || null;
59
+ const state = searchState(run.managed), context = experimentContext(run);
60
+ return state.experimentIndex?.find(item => item.context === context && item.failures >= 2 &&
61
+ (item.actionKey === actionKey(action) || item.observedOperation && item.lastCycle === state.cycle &&
62
+ item.action.kind === action.kind && item.action.scope === action.scope)) || null;
45
63
  }
46
64
 
47
65
  // These are leads for the next experiment, not completion or authority receipts.
@@ -50,9 +68,10 @@ export function recordSearchExperiment(run, performed, result, review, toolEvide
50
68
  const state = searchState(run.managed);
51
69
  state.cycle += 1;
52
70
  state.experimentIndex ||= [];
53
- const key = actionKey(performed.action), context = experimentContext(run);
71
+ const observedOperation = result.status === 'failed' ? failedOperationKey(toolEvidence) : null;
72
+ const key = observedOperation ? hash([performed.action.kind, performed.action.scope, observedOperation]) : actionKey(performed.action), context = experimentContext(run);
54
73
  const prior = state.experimentIndex.find(item => item.actionKey === key && item.context === context);
55
- const entry = prior || { actionKey: key, context, action: performed.action, attempts: 0, failures: 0, firstCycle: state.cycle };
74
+ const entry = prior || { actionKey: key, context, ...(observedOperation ? { observedOperation } : {}), action: performed.action, attempts: 0, failures: 0, firstCycle: state.cycle };
56
75
  entry.attempts += 1;
57
76
  entry.failures = result.status === 'failed' ? entry.failures + 1 : 0;
58
77
  Object.assign(entry, { lastCycle: state.cycle, result: result.status, finding: bounded(result.summary, 300), refutation: bounded(review?.refutation, 300) });