@awak-app/simy-cli 0.7.28 → 0.7.31
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.
- package/README.md +88 -3
- package/package.json +4 -4
- package/src/agent.js +1 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +570 -65
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +12 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-executor.js +6 -0
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/budget.js +24 -0
- package/src/orchestrator/charter-repair.js +1 -1
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/independent-audit.js +13 -6
- package/src/orchestrator/instruction.js +1 -1
- package/src/orchestrator/loop.js +34 -4
- package/src/provider-stream.js +9 -2
- package/src/runner.js +44 -21
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-outcome-review.js +132 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-review-definition.js +5 -0
- package/src/session-supervision/claude-sessions.js +210 -40
- package/src/session-supervision/codex-app-server.js +189 -36
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +38 -9
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/purpose-verification.js +103 -9
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +7 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +1 -1
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- package/src/update-detachment/runtime.js +78 -15
|
@@ -18,7 +18,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { atomicSave, readState } from './update-state-store.mjs';
|
|
21
|
-
import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission } from './update-dispatch-gate.mjs';
|
|
21
|
+
import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission, reattachHookOwner, settleHookContinuation, DISPATCH_MODULE_DIGEST } from './update-dispatch-gate.mjs';
|
|
22
22
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
23
|
const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')
|
|
24
24
|
.then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);
|
|
@@ -26,7 +26,7 @@ import { execFile } from 'node:child_process';
|
|
|
26
26
|
import { promisify } from 'node:util';
|
|
27
27
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
28
28
|
import { purposeEnabled, purposeContract, purposeDiagnosisPlan, validatePurposeReview, flexibleReasoning } from './purpose-cycle.mjs';
|
|
29
|
-
import { effectiveAcceptanceCriteria } from './acceptance-policy.mjs';
|
|
29
|
+
import { effectiveAcceptanceCriteria, acceptanceIdentity } from './acceptance-policy.mjs';
|
|
30
30
|
import { assessRuleReview } from './purpose-rule-review.mjs';
|
|
31
31
|
import { associationContract, validateAssociationResult } from './action-association.mjs';
|
|
32
32
|
import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
|
|
@@ -241,7 +241,12 @@ async function waitForStateLock(lock, inspectProcess) {
|
|
|
241
241
|
for (;;) {
|
|
242
242
|
try { token = await acquireStateLock(lock, inspectProcess); break; }
|
|
243
243
|
catch (error) {
|
|
244
|
-
|
|
244
|
+
// Windows can deny mkdir while a removed lock directory is still closing.
|
|
245
|
+
// Retry only acquisition at this exact path within the existing deadline;
|
|
246
|
+
// owner writes, unrelated permission failures and mutations are not replayed.
|
|
247
|
+
const pendingWindowsDirectory = process.platform === 'win32' && error.code === 'EPERM' &&
|
|
248
|
+
error.syscall === 'mkdir' && error.path === lock;
|
|
249
|
+
if (Date.now() >= deadline || (!pendingWindowsDirectory && !/^(?:Delivery state is busy; retry the command\.|Delivery state lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message))) throw error;
|
|
245
250
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
246
251
|
}
|
|
247
252
|
}
|
|
@@ -432,14 +437,14 @@ function assessedPlan(value, managed = null) {
|
|
|
432
437
|
if (!alternatives.some(option => option.id === recommendation.optionId) || (!flexible && !recommendation.reason)) throw new Error('Plan recommendation must identify an assessed option and reason.');
|
|
433
438
|
return { diagnosis: text(value.diagnosis, 2000), alternatives, selectedOptionId, recommendation, userQuestion: text(value.userQuestion, 1000) || null };
|
|
434
439
|
}
|
|
435
|
-
function missingAcceptanceDiagnosis(managed, missing) {
|
|
440
|
+
export function missingAcceptanceDiagnosis(managed, missing) {
|
|
436
441
|
const names = missing.map(item => item.id).join(', ');
|
|
437
442
|
const scope = managed.decision.action.scope;
|
|
438
443
|
const action = { kind: 'read', scope, summary: `Inspect missing acceptance checks: ${names}` };
|
|
439
|
-
return assessedPlan({ diagnosis: `Worker claimed completion, but independent
|
|
444
|
+
return assessedPlan({ diagnosis: `Worker claimed completion, but independent acceptance checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
|
|
440
445
|
alternatives: [{ id: 'inspect_missing_acceptance', action,
|
|
441
446
|
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
442
|
-
expectedVerification: `Report
|
|
447
|
+
expectedVerification: `Report actual outcome and unmet conditions for ${names}`, rollback: 'No changes to undo' }],
|
|
443
448
|
selectedOptionId: 'inspect_missing_acceptance', recommendation: { optionId: 'inspect_missing_acceptance', reason: 'Independent checks have not verified completion.' } });
|
|
444
449
|
}
|
|
445
450
|
function rejectedActionDiagnosis(managed, rejected) {
|
|
@@ -503,6 +508,18 @@ export function canResearchReplan(run, provider, now) {
|
|
|
503
508
|
!m.userQuestion && !['understanding', 'needs_clarification', 'action_association'].includes(m.phase) &&
|
|
504
509
|
!m.nativeWait && !m.providerWarning && !m.usageBlocked && !m.purpose?.nativeBoundary && !run.blocker && !deliveryStop(run, now, { checkProgress: false }));
|
|
505
510
|
}
|
|
511
|
+
// A legacy worker can mislabel a filesystem write as effect-free. Keep that
|
|
512
|
+
// write held; only diagnose its assessment after the original turn has settled.
|
|
513
|
+
function canDiagnoseLegacyLocalEffect(run, provider, now) {
|
|
514
|
+
const m = run?.managed, d = m?.decision, pending = m?.pendingApproval;
|
|
515
|
+
if (!['active', 'blocked'].includes(run?.state) || searchEnabled(m) || m?.capability !== 'live_hook' || m.autonomyPolicy !== 'autonomous' ||
|
|
516
|
+
d?.action?.kind !== 'local_edit' || d.assessment?.externalEffect !== 'none' ||
|
|
517
|
+
d.assessment.reversibility !== 'reversible' || d.reason !== 'Local write has inconsistent effect boundaries.' ||
|
|
518
|
+
pending?.id !== d.id || pending.denied || m.plan?.userQuestion ||
|
|
519
|
+
m.rejectedActionDigests?.includes(digest(JSON.stringify(d.action)))) return false;
|
|
520
|
+
return canResearchReplan({ ...run, state: 'blocked', managed: { ...m, pendingApproval: null,
|
|
521
|
+
decision: { ...d, waitKind: 'research' } } }, provider, now);
|
|
522
|
+
}
|
|
506
523
|
export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { allowLocalTestWrites = false, llmDelegated = false, allowReversibleExternal = false } = {}) {
|
|
507
524
|
const option = plan.alternatives.find(item => item.id === plan.selectedOptionId);
|
|
508
525
|
const { action } = option;
|
|
@@ -514,9 +531,10 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now(), { a
|
|
|
514
531
|
if (!scopes.includes(action.scope)) { reason = 'Action scope is outside the task enrollment.'; waitKind = 'authority'; }
|
|
515
532
|
else if (facts.inObjective !== true) reason = 'Establish objective fit or choose work within the original task.';
|
|
516
533
|
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 =
|
|
534
|
+
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
535
|
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
536
|
else if (!option.expectedVerification) reason = 'Expected verification is missing; acquire a verification method.';
|
|
537
|
+
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
538
|
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
539
|
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
540
|
else if (localWrite && facts.externalEffect !== 'local') reason = 'Local write has inconsistent effect boundaries.';
|
|
@@ -541,12 +559,14 @@ export function canExecuteAutonomousExternal(managed) {
|
|
|
541
559
|
// Shared by live Stop hooks and cold exact-session recovery. Keep the result
|
|
542
560
|
// contract identical across transports; the host still verifies acceptance.
|
|
543
561
|
export function assessedCheckpointContract(decision, managed = null) {
|
|
562
|
+
const scopes = managed?.approvalScopes ?? (decision.action?.scope ? [decision.action.scope] : []);
|
|
563
|
+
const actionFields = `Enrolled action.scope labels: ${JSON.stringify(scopes)}. scope is an exact enrollment label, not a filename or target description. Reuse an enrolled label for work within that scope; do not invent labels. Put concrete file names and affected targets in action.summary. These labels do not expand the original objective or provider permissions. Describe actual effects consistently: read uses externalEffect:"none"; local_edit uses externalEffect:"local". Do not relabel an outside-scope action to bypass approval.`;
|
|
544
564
|
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} 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. 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.`;
|
|
565
|
+
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. ${actionFields} 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
566
|
|
|
547
567
|
}
|
|
548
568
|
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
|
|
569
|
+
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 semantic; do not create human verification requirements."} 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 and obtain the needed observation automatically. 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
570
|
}
|
|
551
571
|
function boundedContextList(value, maxItems = 8, maxLength = 300) {
|
|
552
572
|
if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
|
|
@@ -578,7 +598,7 @@ function sessionUnderstanding(value) {
|
|
|
578
598
|
if (!description || !['user_grounded', 'proposed'].includes(item?.source)) throw new Error('Understanding criterion needs a description and provenance.');
|
|
579
599
|
const basisIndex = Number.isSafeInteger(item.basisIndex) && item.basisIndex >= 0 && item.basisIndex < basis.length ? item.basisIndex : null;
|
|
580
600
|
if (item.source === 'user_grounded' && (basisIndex === null || basis[basisIndex].source !== 'user')) throw new Error('User-grounded criterion needs a user-message basis.');
|
|
581
|
-
const check = item.check?.type || '
|
|
601
|
+
const check = item.check?.type || 'semantic';
|
|
582
602
|
if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check)) throw new Error('Unsupported inferred acceptance check.');
|
|
583
603
|
if (!['manual', 'semantic'].includes(check) && (!text(item.check.path, 500) || isAbsolute(item.check.path) || item.check.path.split(/[\\/]/).includes('..'))) throw new Error('Inferred file check needs a relative task path.');
|
|
584
604
|
if (check === 'file_contains' && !text(item.check.text, 1000)) throw new Error('Inferred file check needs expected text.');
|
|
@@ -588,13 +608,40 @@ function sessionUnderstanding(value) {
|
|
|
588
608
|
}) : [];
|
|
589
609
|
const result = { state: value.state, objective: contextText(value.objective, 1000), constraints: boundedContextList(value.constraints),
|
|
590
610
|
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 };
|
|
611
|
+
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
612
|
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
613
|
if (result.state === 'already_done' && (!result.objective || result.remaining.length || result.nextAction)) throw new Error('Already-finished understanding must have no remaining action.');
|
|
594
614
|
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
615
|
if (['ambiguous', 'context_missing'].includes(result.state) && (!result.question || result.ambiguities.length !== 1)) throw new Error('Unclear understanding needs one focused question.');
|
|
596
616
|
return result;
|
|
597
617
|
}
|
|
618
|
+
function normalizeConversationOutcome(value) {
|
|
619
|
+
if (value?.kind !== 'answer_only' || value.independentReviewRequested !== false) return null;
|
|
620
|
+
const refs = key => Array.isArray(value[key]) && value[key].length > 0 && value[key].length <= 32 &&
|
|
621
|
+
value[key].every(ref => typeof ref === 'string' && ref.length > 0 && ref.length <= 160) ? [...new Set(value[key])] : null;
|
|
622
|
+
const requestTurnRefs = refs('requestTurnRefs'), answerTurnRefs = refs('answerTurnRefs');
|
|
623
|
+
return requestTurnRefs && answerTurnRefs ? { kind: 'answer_only', independentReviewRequested: false, requestTurnRefs, answerTurnRefs } : null;
|
|
624
|
+
}
|
|
625
|
+
function conversationUnderstandingComplete(run, understanding, context) {
|
|
626
|
+
const managed = run.managed, outcome = understanding.conversationOutcome;
|
|
627
|
+
if (!managed.contextBootstrap || managed.acceptanceCriteria.length || managed.independentReviewRequired ||
|
|
628
|
+
understanding.state !== 'already_done' || !outcome || context?.proseComplete !== true || context.proseOnly !== true || context.incomplete || context.awaitingTurn ||
|
|
629
|
+
!understanding.criteria.length || understanding.criteria.some(c => c.source !== 'user_grounded' || c.check.type !== 'semantic') ||
|
|
630
|
+
managed.executionUncertain || managed.purpose?.nativeBoundary) return false;
|
|
631
|
+
if ([...(managed.workHistory || []), ...(managed.purpose?.evidence?.units || [])].some(row => row.action?.kind && row.action.kind !== 'read') ||
|
|
632
|
+
managed.purpose?.evidence?.receipts?.some(row => row.kind === 'fileChange')) return false;
|
|
633
|
+
const messages = context.messages || [], latestUser = messages.findLastIndex(row => row.role === 'user');
|
|
634
|
+
const latestAnswer = messages.findLastIndex(row => row.role === 'assistant');
|
|
635
|
+
if (latestUser < 0 || latestAnswer <= latestUser || !outcome.requestTurnRefs.includes(messages[latestUser].turnRef) ||
|
|
636
|
+
!outcome.answerTurnRefs.includes(messages[latestAnswer].turnRef)) return false;
|
|
637
|
+
if (outcome.requestTurnRefs.some(ref => !messages.some(row => row.role === 'user' && row.turnRef === ref)) ||
|
|
638
|
+
outcome.answerTurnRefs.some(ref => !messages.some((row, index) => index > latestUser && row.role === 'assistant' && row.turnRef === ref && row.text?.trim()))) return false;
|
|
639
|
+
return understanding.criteria.every(criterion => {
|
|
640
|
+
const basis = understanding.basis[criterion.basisIndex];
|
|
641
|
+
return basis?.source === 'user' && outcome.requestTurnRefs.includes(basis.turnRef) && basis.snippet &&
|
|
642
|
+
messages.some(row => row.role === 'user' && row.turnRef === basis.turnRef && row.text.includes(basis.snippet));
|
|
643
|
+
});
|
|
644
|
+
}
|
|
598
645
|
function recordPurposeReview(managed, review, now) {
|
|
599
646
|
const purpose = managed.purpose;
|
|
600
647
|
if (searchEnabled(managed)) reconcileRequirements(managed, review, now);
|
|
@@ -636,7 +683,11 @@ function recordPurposeProgress(run, now, countAttempt = true) {
|
|
|
636
683
|
}
|
|
637
684
|
}
|
|
638
685
|
function recordDecision(managed, plan, now) {
|
|
639
|
-
|
|
686
|
+
// Classify effectful local tests as writes, with the same scope, policy and
|
|
687
|
+
// rollback checks as local_edit. Native write permissions remain independent.
|
|
688
|
+
const assessmentOptions = { allowLocalTestWrites: true, llmDelegated: searchEnabled(managed),
|
|
689
|
+
allowReversibleExternal: searchEnabled(managed) && managed.resumeChannel === 'codex_app_server' };
|
|
690
|
+
let decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now, assessmentOptions);
|
|
640
691
|
const dependencyPending = option => (option?.dependsOn || []).some(id =>
|
|
641
692
|
!managed.purpose?.evidence?.units?.some(unit => unit.id === id && unit.status === 'verified' && unit.purposeRevision === managed.purpose.evidence.purposeRevision));
|
|
642
693
|
if (searchEnabled(managed) && (decision.outcome === 'wait' || dependencyPending(plan.alternatives.find(row => row.id === plan.selectedOptionId)))) {
|
|
@@ -645,7 +696,7 @@ function recordDecision(managed, plan, now) {
|
|
|
645
696
|
for (const candidate of [...plan.alternatives].sort((a, b) => (priorities.get(a.id) ?? 99) - (priorities.get(b.id) ?? 99))) {
|
|
646
697
|
if (candidate.id === plan.selectedOptionId || !candidate.independent || dependencyPending(candidate)) continue;
|
|
647
698
|
const alternative = { ...plan, selectedOptionId: candidate.id, recommendation: { optionId: candidate.id, reason: 'Continue the highest listed eligible independent alternative.' } };
|
|
648
|
-
const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now,
|
|
699
|
+
const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now, assessmentOptions);
|
|
649
700
|
if (assessed.outcome !== 'wait' && !managed.rejectedActionOverflow && !managed.rejectedActionDigests?.includes(digest(JSON.stringify(assessed.action)))) {
|
|
650
701
|
plan = alternative; decision = assessed; break;
|
|
651
702
|
}
|
|
@@ -678,6 +729,7 @@ function recordDecision(managed, plan, now) {
|
|
|
678
729
|
status: decision.outcome === 'wait' ? 'waiting' : 'planned', evidenceRefs: [], purposeRevision: ledger.purposeRevision || 1, action: decision.action, createdAt: now });
|
|
679
730
|
compactWorkHistory(ledger);
|
|
680
731
|
}
|
|
732
|
+
delete managed.controllerDecisionWait;
|
|
681
733
|
managed.plan = plan; managed.decision = decision; managed.proposedAction = decision.action;
|
|
682
734
|
managed.decisionHistory ||= [];
|
|
683
735
|
if (managed.decisionHistory.at(-1)?.id !== decision.id) managed.decisionHistory.push({ id: decision.id, at: now, outcome: decision.outcome, reason: decision.reason, selectedOptionId: decision.selectedOptionId, action: decision.action });
|
|
@@ -718,7 +770,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
718
770
|
if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
|
|
719
771
|
const id = text(item.id, 80) || `criterion-${index + 1}`;
|
|
720
772
|
if (!/^[A-Za-z0-9_-]+$/.test(id) || ['USER-GOAL', '__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
|
|
721
|
-
const check = item.check || { type: '
|
|
773
|
+
const check = item.check || { type: 'semantic' };
|
|
722
774
|
if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
|
|
723
775
|
if (!['manual', 'semantic'].includes(check.type) && (!text(check.path, 500) || isAbsolute(check.path) || check.path.split(/[\\/]/).includes('..'))) throw new Error('Acceptance file must be relative to the task directory.');
|
|
724
776
|
if (check.type === 'file_contains' && !text(check.text, 1000)) throw new Error('file_contains needs expected text.');
|
|
@@ -790,17 +842,101 @@ export function pendingAcceptance(run) {
|
|
|
790
842
|
if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
|
|
791
843
|
JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
|
|
792
844
|
check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
|
|
793
|
-
|
|
845
|
+
const feedback = new Map(pendingAcceptanceFeedback(run).map(item => [item.criterionId, item]));
|
|
846
|
+
const pending = effectiveAcceptanceCriteria(run.managed).filter(item => !Object.hasOwn(run.managed.verified, item.id) || feedback.has(item.id))
|
|
794
847
|
.map(item => ({ id: item.id, description: item.description, check: item.check.type,
|
|
795
|
-
detail:
|
|
848
|
+
detail: feedback.has(item.id) ? `User result feedback: ${feedback.get(item.id).reason}` :
|
|
849
|
+
run.managed.criterionObservations?.[item.id]?.observed_result || (item.check.type === 'manual' ? 'Explicit user verification required' : 'Independent check not yet passed') }));
|
|
850
|
+
// File predicates and a worker claim cannot waive an explicit review.
|
|
851
|
+
// The host checkpoint path supplies an independently bound result when available.
|
|
852
|
+
if (run.managed?.independentReviewRequired && !searchEnabled(run.managed) && run.managed.liveOutcomeReview?.passed !== true) pending.push({
|
|
853
|
+
id: 'independent-review', description: 'Required independent outcome review', check: 'semantic',
|
|
854
|
+
detail: 'Independent outcome review has not passed; file checks alone cannot complete this task' });
|
|
855
|
+
return pending;
|
|
796
856
|
}
|
|
797
857
|
function acceptancePendingReason(run) {
|
|
798
858
|
return `Acceptance verification pending: ${pendingAcceptance(run).map(item => `${item.id} (${item.description}): ${item.detail}`).join('; ')}`;
|
|
799
859
|
}
|
|
860
|
+
// Only the host review executor calls this; worker checkpoints cannot supply a
|
|
861
|
+
// passing verdict. Recheck the enrollment, holds, and actual workspace at commit.
|
|
862
|
+
export async function settleLiveOutcomeReview({ root, provider, session, snapshot, review }) {
|
|
863
|
+
return updateRecord(root, provider, session, async record => {
|
|
864
|
+
if (!liveOutcomeReviewEligible(record) || record.run.id !== snapshot.run.id || record.run.revision !== snapshot.run.revision || record.run.controlRevision !== snapshot.run.controlRevision ||
|
|
865
|
+
JSON.stringify(record.run.managed.enrollment) !== JSON.stringify(snapshot.run.managed.enrollment) ||
|
|
866
|
+
hasPendingUserStop(root, record) || !(await providerLinkState(root, provider)).connected) return record;
|
|
867
|
+
const run = record.run, managed = run.managed;
|
|
868
|
+
const owner = managed.liveHookOwner;
|
|
869
|
+
if (owner && await processIdentity(owner.pid) !== owner.identity) return record;
|
|
870
|
+
if (review.inspectionAvailable !== false && await workspaceIdentity(taskDirectory(record)).catch(() => null) !== review.workspaceDigest)
|
|
871
|
+
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() };
|
|
872
|
+
managed.liveOutcomeReview = review;
|
|
873
|
+
if (review.nativeRequest) {
|
|
874
|
+
managed.nativeOutcomeReview = review.nativeRequest;
|
|
875
|
+
const plan = missingAcceptanceDiagnosis(managed, [{ id: 'native-independent-review', description: review.summary }]);
|
|
876
|
+
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.`;
|
|
877
|
+
plan.diagnosis = review.summary;
|
|
878
|
+
recordDecision(managed, plan, Date.now());
|
|
879
|
+
run.state = 'active';
|
|
880
|
+
run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false, nextStep: plan.alternatives[0].action.summary };
|
|
881
|
+
run.nextStep = run.checkpoint.nextStep;
|
|
882
|
+
return record;
|
|
883
|
+
}
|
|
884
|
+
if (review.budgetBoundary) {
|
|
885
|
+
// Do not spend the original worker allowance on retrying an executor that
|
|
886
|
+
// cannot share its cap. Keep acceptance pending at the genuine Stop.
|
|
887
|
+
run.reason = review.summary;
|
|
888
|
+
return record;
|
|
889
|
+
}
|
|
890
|
+
if (review.passed) {
|
|
891
|
+
for (const criterion of effectiveAcceptanceCriteria(managed)) if (criterion.check.type === 'semantic')
|
|
892
|
+
managed.verified[criterion.id] = { source: 'independent_claude_review', checkedAt: Date.now(), workspaceDigest: review.workspaceDigest };
|
|
893
|
+
await verifyAutomaticCriteria(record, Date.now());
|
|
894
|
+
// Keep the original writer lease until its genuine Stop boundary.
|
|
895
|
+
if (!pendingAcceptance(run).length) { run.state = 'active'; run.reason = 'Independent review passed; awaiting original Stop readback.'; }
|
|
896
|
+
} else if (managed.autonomyPolicy && managed.decision) {
|
|
897
|
+
const missing = [{ id: 'independent-review', description: review.summary }];
|
|
898
|
+
const plan = missingAcceptanceDiagnosis(managed, missing);
|
|
899
|
+
plan.diagnosis = [review.summary, ...(review.findings || [])].join(' ').slice(0, 4000);
|
|
900
|
+
if (review.inspectionAvailable === false) {
|
|
901
|
+
plan.alternatives[0].action.summary = 'Inspect independent review availability and missing observations without changing the deliverable';
|
|
902
|
+
plan.alternatives[0].expectedVerification = 'Identify the concrete observation or provider failure and an authorized way to retry verification of the original outcome.';
|
|
903
|
+
plan.diagnosis = `Independent inspection unavailable; no product defect has been established. ${plan.diagnosis}`;
|
|
904
|
+
run.failures += 1;
|
|
905
|
+
}
|
|
906
|
+
recordDecision(managed, plan, Date.now());
|
|
907
|
+
run.state = 'active';
|
|
908
|
+
run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false, nextStep: plan.alternatives[0].action.summary };
|
|
909
|
+
run.nextStep = run.checkpoint.nextStep; managed.remainingWork = plan.diagnosis;
|
|
910
|
+
}
|
|
911
|
+
return record;
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
export function liveOutcomeReviewEligible(record) {
|
|
915
|
+
const run = record?.run, m = run?.managed;
|
|
916
|
+
return Boolean(record?.provider === 'claude' && m?.capability === 'live_hook' && !searchEnabled(m) &&
|
|
917
|
+
run.state === 'verification_pending' && run.checkpoint?.status === 'completed' &&
|
|
918
|
+
(m.independentReviewRequired || effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic')) &&
|
|
919
|
+
!m.liveOutcomeReview && !m.pendingApproval && !m.nativeWait && !m.executionUncertain && !m.purpose?.nativeBoundary &&
|
|
920
|
+
!managedBudgetReason(run) && automaticContinuationAllowed(record));
|
|
921
|
+
}
|
|
922
|
+
// Checkpoints are rejected while only manual acceptance is pending, so the
|
|
923
|
+
// review instruction applies only where an independent review can follow.
|
|
924
|
+
function reviewInstruction(run) {
|
|
925
|
+
const m = run.managed;
|
|
926
|
+
if (m?.capability !== 'live_hook' || m?.thread?.provider !== 'claude') return '';
|
|
927
|
+
if (run.state === 'verification_pending' && !m.independentReviewRequired && !effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic'))
|
|
928
|
+
return (text(run.checkpoint?.remainingWork)
|
|
929
|
+
? 'Manual acceptance is pending. The completion report records remaining work, but no additional executable next plan has been accepted. '
|
|
930
|
+
: 'Only manual acceptance remains and the user verifies it. ') +
|
|
931
|
+
'Checkpoints are not accepted until the user verifies or resumes this task; do not resubmit one. ';
|
|
932
|
+
return '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. ';
|
|
933
|
+
}
|
|
800
934
|
export function deliveryAcceptanceContext(run) {
|
|
801
|
-
|
|
935
|
+
const feedback = manualAcceptanceFeedback(run);
|
|
936
|
+
return `${reviewInstruction(run)}Original objective: ${run.managed?.enrollment?.objective || run.objective}. Mode: ${run.managed ? 'managed acceptance tracking' : 'standard checkpoint tracking; completion reports are not verified acceptance'}.` +
|
|
802
937
|
`${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
|
|
803
|
-
` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}
|
|
938
|
+
` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}.` +
|
|
939
|
+
(feedback.length ? ` Criterion-bound user result feedback (not additional authority): ${JSON.stringify(feedback)}.` : '') + ` 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
940
|
}
|
|
805
941
|
// CLI automation is the default for connected providers with executing hooks.
|
|
806
942
|
// Saved workspace overrides and revoked provider consent take precedence.
|
|
@@ -913,6 +1049,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
913
1049
|
managedState.taskId = previous.associationTaskId;
|
|
914
1050
|
if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
|
|
915
1051
|
}
|
|
1052
|
+
if (managedState && previous.controllerExecutionContext?.executionTaskId === null) managedState.controllerExecutionContext = { ...structuredClone(previous.controllerExecutionContext), executionTaskId: managedState.taskId };
|
|
916
1053
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
917
1054
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
918
1055
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -930,7 +1067,10 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
930
1067
|
Object.assign(run, structuredClone(prior), { managed: managedState, executionCwd: run.executionCwd,
|
|
931
1068
|
updatedAt: now, nextStep: text(nextStep), consumedRevision: prior.revision });
|
|
932
1069
|
managedState.taskId = previous.associationTaskId || prior.id;
|
|
933
|
-
|
|
1070
|
+
// An ordinary loop never measured verified progress, so its age is not
|
|
1071
|
+
// a no-progress period. Supervision starts its own window at adoption;
|
|
1072
|
+
// startedAt, failures, budgets and history stay as they were.
|
|
1073
|
+
startProgressWindow(run, now, 'adoption');
|
|
934
1074
|
managedState.upgradedFrom = { runId: prior.id, state: prior.state, nextStep: prior.nextStep,
|
|
935
1075
|
checkpoint: structuredClone(prior.checkpoint), at: now };
|
|
936
1076
|
if (prior.state !== 'paused') {
|
|
@@ -1065,6 +1205,35 @@ export function queuePurposeDiagnosis(run, reason, key, now = Date.now(), recove
|
|
|
1065
1205
|
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
1206
|
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
1207
|
|
|
1208
|
+
// A worker authorization classification is not itself a native permission
|
|
1209
|
+
// request. Diagnose existing authority before presenting a synthetic gate.
|
|
1210
|
+
export function controllerApprovalDiagnosisEligible(record, now = Date.now()) {
|
|
1211
|
+
const run = record?.run, managed = run?.managed;
|
|
1212
|
+
const pending = managed?.pendingApproval || managed?.controllerDecisionWait;
|
|
1213
|
+
return Boolean(purposeEnabled(run) && searchEnabled(managed) && managed.autonomyPolicy === 'autonomous' && automaticContinuationAllowed(record) && pending &&
|
|
1214
|
+
managed.resumeChannel === 'codex_app_server' && !managed.providerWarning && !managed.usageBlocked && !managedBudgetReason(run, now) &&
|
|
1215
|
+
!['pending', 'transferred', 'pending_received'].includes(managed.handoff?.status) &&
|
|
1216
|
+
!pending.denied && !managed.nativeWait && !managed.purpose?.nativeBoundary && !run.providerRequest &&
|
|
1217
|
+
!managed.userQuestion && !managed.understandingQuestion && !managed.executionUncertain && run.stopReason?.actor !== 'user');
|
|
1218
|
+
}
|
|
1219
|
+
export function diagnoseControllerApproval(record, now) {
|
|
1220
|
+
const run = record.run, managed = run?.managed;
|
|
1221
|
+
if (!controllerApprovalDiagnosisEligible(record, now)) {
|
|
1222
|
+
// A deferred diagnosis must remain visible when its dispatch is no longer
|
|
1223
|
+
// possible. Restoring the request grants no authority to execute it.
|
|
1224
|
+
if (managed?.controllerDecisionWait && !managed.pendingApproval) {
|
|
1225
|
+
managed.pendingApproval = managed.controllerDecisionWait;
|
|
1226
|
+
delete managed.controllerDecisionWait;
|
|
1227
|
+
}
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
const pending = managed.pendingApproval || managed.controllerDecisionWait;
|
|
1231
|
+
const queued = queuePurposeDiagnosis(run, `Inspect existing user instructions and provider permissions for the waiting action: ${pending.summary}. Do not execute or approve it during diagnosis. Continue already authorized work, or identify concrete indispensable missing information.`, `controller-approval:${digest(JSON.stringify([pending.kind,pending.scope,pending.summary]))}`, now, 'authorization_diagnosis');
|
|
1232
|
+
if (queued) delete managed.controllerDecisionWait;
|
|
1233
|
+
else { managed.controllerDecisionWait = { ...pending }; managed.pendingApproval = null; }
|
|
1234
|
+
return queued;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1068
1237
|
export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, expectedNativeHandoff, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
|
|
1069
1238
|
if (expectedNativeHandoff && checkpoint?.kind) throw new Error('Native handoff requires an ordinary action checkpoint.');
|
|
1070
1239
|
if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
|
|
@@ -1099,11 +1268,27 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1099
1268
|
const remainingWork = text(checkpoint.remainingWork, 4000);
|
|
1100
1269
|
const resultDisposition = decideLoop({ completion_claim: checkpoint.status === 'completed',
|
|
1101
1270
|
remaining_work: Boolean(remainingWork), acceptance_failed: checkpoint.acceptanceMet === false });
|
|
1102
|
-
|
|
1271
|
+
// A current managed report can contradict its own completion claim. Only
|
|
1272
|
+
// its explicit, assessed next plan can keep the work lane active; never
|
|
1273
|
+
// recover a plan from historical remainingWork/nextStep or reopen a wait.
|
|
1274
|
+
// Completed reports may also describe manual/external verification without
|
|
1275
|
+
// a next plan. Preserve those waits; prose alone never admits execution.
|
|
1276
|
+
const managedWorkRemains = Boolean(run.managed && checkpoint.status === 'completed' && remainingWork && checkpoint.plan != null);
|
|
1277
|
+
const incompleteResult = managedWorkRemains || !run.managed && checkpoint.status === 'completed' && resultDisposition.action === 'repair';
|
|
1278
|
+
if (managedWorkRemains) {
|
|
1279
|
+
if (!run.managed.autonomyPolicy || !run.managed.decision || !nextStep) throw new Error('Remaining managed work requires a current assessed next plan and nextStep.');
|
|
1280
|
+
if (acceptanceAuthorityHeld(run) || run.managed.outcomeAudit?.lease ||
|
|
1281
|
+
run.managed.resumeLease && run.managed.resumeLease.id !== expectedLease ||
|
|
1282
|
+
['pending', 'pending_received', 'transferred'].includes(run.managed.handoff?.status) ||
|
|
1283
|
+
hasPendingUserStop(root, record)) throw new Error('Remaining managed work is held by the existing execution or authority boundary.');
|
|
1284
|
+
repairPlan = assessedPlan(checkpoint.plan, run.managed);
|
|
1285
|
+
const selected = repairPlan.alternatives.find(option => option.id === repairPlan.selectedOptionId);
|
|
1286
|
+
if (selected.action.summary !== nextStep) throw new Error('Remaining work nextStep must match its selected assessed action.');
|
|
1287
|
+
}
|
|
1103
1288
|
const missingNextStep = checkpoint.status === 'in_progress' && !nextStep;
|
|
1104
1289
|
if (incompleteResult || (missingNextStep && !run.managed)) {
|
|
1105
1290
|
effectiveStatus = 'in_progress';
|
|
1106
|
-
nextStep ||= resultFollowThroughInstruction();
|
|
1291
|
+
if (!run.managed) nextStep ||= resultFollowThroughInstruction();
|
|
1107
1292
|
}
|
|
1108
1293
|
if (missingNextStep && run.managed) throw new Error('A concrete nextStep is required.');
|
|
1109
1294
|
if (effectiveStatus === 'completed' && !run.managed && checkpoint.acceptanceMet !== true) throw new Error('Completion requires the requested outcome to be checked (acceptanceMet).');
|
|
@@ -1129,6 +1314,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1129
1314
|
if (performed) {
|
|
1130
1315
|
const result = checkpoint.actionResult;
|
|
1131
1316
|
if (result?.decisionId !== performed.id || !['observed', 'failed', 'not_run'].includes(result?.status) || !text(result.summary, 500)) throw new Error('Checkpoint needs an actionResult bound to the selected decision.');
|
|
1317
|
+
if (managedWorkRemains && result.status !== 'observed') throw new Error('Remaining managed work needs an observed result for the current selected action.');
|
|
1132
1318
|
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' };
|
|
1133
1319
|
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'));
|
|
1134
1320
|
if (flexibleReasoning(run.managed) && capturedAction) actionResult.observationSource = 'controller_tool_result';
|
|
@@ -1271,12 +1457,17 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1271
1457
|
nextStep = run.nextStep;
|
|
1272
1458
|
}
|
|
1273
1459
|
run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
|
|
1460
|
+
run.providerRequest = null;
|
|
1274
1461
|
run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep,
|
|
1275
1462
|
blocker: unsupportedWait ? undefined : checkpoint.blocker,
|
|
1276
1463
|
...(unsupportedWait ? { reportedBlocker: checkpoint.blocker } : checkpoint.blocker === 'missing_information' ? { question: text(checkpoint.question, 1000) } : {}),
|
|
1277
1464
|
...(remainingWork ? { remainingWork } : {}), ...(incompleteResult ? { reportedStatus: checkpoint.status } : {}),
|
|
1278
1465
|
evidence, acceptanceMet: !unsupportedWait && !incompleteResult && checkpoint.acceptanceMet === true };
|
|
1279
1466
|
if (!run.managed && effectiveStatus !== 'completed') delete run.completionCheck;
|
|
1467
|
+
if (run.managed?.liveOutcomeReview) {
|
|
1468
|
+
delete run.managed.liveOutcomeReview;
|
|
1469
|
+
for (const criterion of effectiveAcceptanceCriteria(run.managed)) if (criterion.check.type === 'semantic') delete run.managed.verified[criterion.id];
|
|
1470
|
+
}
|
|
1280
1471
|
run.revision += 1; run.updatedAt = now;
|
|
1281
1472
|
recordManagedOutcomeProgress(run, now);
|
|
1282
1473
|
// File predicates were reread above; a prior semantic verdict can belong
|
|
@@ -1303,6 +1494,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1303
1494
|
recordHandoffStage(request, run.managed.workHistory?.at(-1)?.result?.status === 'observed' ? 'inspected' : 'inspection_failed', now);
|
|
1304
1495
|
request.observedAt = now; request.result = run.managed.workHistory?.at(-1)?.result?.status || checkpoint.actionResult.status;
|
|
1305
1496
|
}
|
|
1497
|
+
diagnoseControllerApproval(record, now);
|
|
1306
1498
|
await continueResultCycle(record, resultEvent(record, { kind: 'checkpoint', origin: 'checkpointDelivery',
|
|
1307
1499
|
sourceId: expectedLease || `checkpoint:${run.revision}`, attemptId: expectedLease,
|
|
1308
1500
|
resultRef: `checkpoint:${run.revision}`, now }));
|
|
@@ -1345,6 +1537,16 @@ export function externalReviewIsCurrent(managed,review,now) {
|
|
|
1345
1537
|
.every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
|
|
1346
1538
|
|
|
1347
1539
|
}
|
|
1540
|
+
function verifiedConversationResult(managed, review) {
|
|
1541
|
+
const inspection = review.semanticInspection;
|
|
1542
|
+
if (inspection?.source !== 'controller_conversation_answer' || inspection.digest !== review.answerContext?.digest ||
|
|
1543
|
+
!/^[a-f0-9]{64}$/.test(inspection.digest || '') || !review.inspectionPerformed) return null;
|
|
1544
|
+
const answer = (managed.purpose?.evidence?.receipts || []).filter(row => row.kind === 'agentMessage' && row.ref?.startsWith('answer:')).at(-1);
|
|
1545
|
+
if (!answer || answer.ref !== inspection.answerRefs?.at(-1) || answer.id !== inspection.answerDigest ||
|
|
1546
|
+
answer.observation?.truncated || answer.observation?.redacted || typeof answer.observation?.text !== 'string' ||
|
|
1547
|
+
createHash('sha256').update(answer.observation.text).digest('hex') !== inspection.answerDigest) return null;
|
|
1548
|
+
return answer.observation.text;
|
|
1549
|
+
}
|
|
1348
1550
|
export async function settlePurposeVerification({ root, provider, session, expectedLease, review, verifySnapshot, now = Date.now() }) {
|
|
1349
1551
|
const started = Date.now(), initialNow = now;
|
|
1350
1552
|
const snapshot = (await listRecords(root)).find(record => record.provider === provider && record.session === session);
|
|
@@ -1445,7 +1647,7 @@ export async function settlePurposeVerification({ root, provider, session, expec
|
|
|
1445
1647
|
if (review.passed && !pending.length && !managed.purpose.nativeBoundary && !managed.executionUncertain && pendingAcceptance(run).length === 0) {
|
|
1446
1648
|
for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
|
|
1447
1649
|
run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
|
|
1448
|
-
run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
|
|
1650
|
+
run.checkpoint = { status: 'completed', summary: verifiedConversationResult(managed, review) || review.summary, evidence: review.criteria.map(c => c.evidence) };
|
|
1449
1651
|
bindControllerCompletion(run, now);
|
|
1450
1652
|
managed.autoRecovery = false;
|
|
1451
1653
|
settleCompletionHandoffs(run, now, review);
|
|
@@ -1516,6 +1718,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
1516
1718
|
const result = checkpoint.actionResult;
|
|
1517
1719
|
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.');
|
|
1518
1720
|
const understanding = sessionUnderstanding(checkpoint.sessionUnderstanding);
|
|
1721
|
+
const conversationalCompletion = Boolean(expectedLease) && conversationUnderstandingComplete(run, understanding, sourceContext);
|
|
1519
1722
|
if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
|
|
1520
1723
|
const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan, run.managed) : null;
|
|
1521
1724
|
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.');
|
|
@@ -1549,8 +1752,8 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
1549
1752
|
if (understanding.state === 'clear') {
|
|
1550
1753
|
run.objective = understanding.objective;
|
|
1551
1754
|
run.managed.remainingWork = understanding.remaining.join(' ').slice(0, 4000);
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1755
|
+
// Keep user-grounded conditions for automatic result verification;
|
|
1756
|
+
// provider-proposed criteria remain interpretation only.
|
|
1554
1757
|
run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded')
|
|
1555
1758
|
.map(item => ({ id: item.id, description: item.description, check: item.check.type === 'file_equals'
|
|
1556
1759
|
? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
|
|
@@ -1561,14 +1764,23 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
1561
1764
|
recordDecision(run.managed, plan, now);
|
|
1562
1765
|
run.reason = run.managed.decision.outcome === 'wait' ? run.managed.decision.reason : 'Session goal understood; selected next action awaits the normal dispatch gate.';
|
|
1563
1766
|
if (run.managed.decision.outcome === 'wait') { run.state = 'blocked'; run.blocker = 'authorization'; escalate(run, run.reason); }
|
|
1767
|
+
diagnoseControllerApproval(record, now);
|
|
1564
1768
|
} else if (understanding.state === 'already_done') {
|
|
1565
1769
|
run.objective = understanding.objective;
|
|
1566
1770
|
run.managed.remainingWork = null;
|
|
1567
1771
|
run.managed.phase = 'active';
|
|
1568
1772
|
run.state = 'reported_done';
|
|
1569
1773
|
run.reason = 'Original conversation reports no remaining work; independent completion was not verified.';
|
|
1570
|
-
if (
|
|
1571
|
-
|
|
1774
|
+
if (conversationalCompletion) {
|
|
1775
|
+
// These are inferred expectations, not a user-enrolled verification
|
|
1776
|
+
// contract. Preserve them in sessionUnderstanding without promoting
|
|
1777
|
+
// them into new audit obligations or claiming independent acceptance.
|
|
1778
|
+
run.reason = 'The requested conversational answer was checked against its user context; no independent acceptance was requested.';
|
|
1779
|
+
run.managed.autoRecovery = false;
|
|
1780
|
+
run.checkpoint = { ...run.checkpoint, status: 'completed', summary: checkpoint.summary };
|
|
1781
|
+
bindControllerCompletion(run, now);
|
|
1782
|
+
} else if (purposeEnabled(run)) {
|
|
1783
|
+
if (!run.managed.acceptanceCriteria.length) run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded').map(item => ({ id: item.id, description: item.description,
|
|
1572
1784
|
check: item.check.type === 'file_equals' ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
|
|
1573
1785
|
if (searchEnabled(run.managed)) queuePurposeVerification(run, 'Original conversation reports completion.', now);
|
|
1574
1786
|
else queuePurposeDiagnosis(run, 'The conversation reports completion. Check the original purpose against actual artifacts and outcomes before accepting that claim.', 'reported_completion', now);
|
|
@@ -1602,6 +1814,11 @@ function newVerifiedCriterion(run, id, now) {
|
|
|
1602
1814
|
}
|
|
1603
1815
|
export async function verifyAutomaticCriteria(record, now) {
|
|
1604
1816
|
const managed = record.run.managed;
|
|
1817
|
+
if (managed.liveOutcomeReview?.passed && managed.liveOutcomeReview.workspaceDigest !== await workspaceIdentity(taskDirectory(record)).catch(() => null)) {
|
|
1818
|
+
managed.liveOutcomeReview = { ...managed.liveOutcomeReview, passed: false, summary: 'Workspace changed after independent review; inspect the current result.' };
|
|
1819
|
+
delete managed.nativeOutcomeReview;
|
|
1820
|
+
for (const criterion of effectiveAcceptanceCriteria(managed)) if (criterion.check.type === 'semantic') delete managed.verified[criterion.id];
|
|
1821
|
+
}
|
|
1605
1822
|
managed.criterionObservations = {};
|
|
1606
1823
|
const observe = (criterion, passed, detail, sha256 = null) => {
|
|
1607
1824
|
managed.criterionObservations[criterion.id] = { criterion_id: criterion.id, passed,
|
|
@@ -1609,9 +1826,12 @@ export async function verifyAutomaticCriteria(record, now) {
|
|
|
1609
1826
|
evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
|
|
1610
1827
|
};
|
|
1611
1828
|
const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
|
|
1829
|
+
const feedbackPending = new Set(pendingAcceptanceFeedback(record.run).map(item => item.criterionId));
|
|
1612
1830
|
for (const criterion of effectiveAcceptanceCriteria(managed)) {
|
|
1613
1831
|
if (criterion.check.type === 'manual') {
|
|
1614
|
-
|
|
1832
|
+
// Result feedback keeps earlier independent context as evidence while
|
|
1833
|
+
// pendingAcceptance still requires a fresh human decision for this check.
|
|
1834
|
+
if (managed.verified[criterion.id]?.source !== 'user' && !feedbackPending.has(criterion.id)) delete managed.verified[criterion.id];
|
|
1615
1835
|
continue;
|
|
1616
1836
|
}
|
|
1617
1837
|
if (criterion.check.type === 'semantic') continue;
|
|
@@ -1656,37 +1876,154 @@ function settleCompletionHandoffs(run, now, review) {
|
|
|
1656
1876
|
request.workspaceDigest = review.workspaceDigest;
|
|
1657
1877
|
}
|
|
1658
1878
|
}
|
|
1879
|
+
// Criterion feedback is a separate control plane from operation approvals.
|
|
1880
|
+
// It records human evidence without granting a scope or interrupting a writer.
|
|
1881
|
+
const MANUAL_ACCEPTANCE_STATES = new Set(['active', 'auto_resuming', 'verification_pending', 'paused', 'blocked', 'recovery_required']);
|
|
1882
|
+
function manualAcceptanceUnavailable(record) {
|
|
1883
|
+
const run = record?.run, m = run?.managed;
|
|
1884
|
+
if (!m) return 'not_managed';
|
|
1885
|
+
if (record.providerArchived) return 'archived';
|
|
1886
|
+
if (!text(run.id, 200) || !text(m.taskId, 200) || !Number.isSafeInteger(deliveryDecisionRevision(run)) || !text(taskDirectory(record), 4000)) return 'identity_unavailable';
|
|
1887
|
+
if (!MANUAL_ACCEPTANCE_STATES.has(run.state)) return 'task_not_open';
|
|
1888
|
+
if (['pending', 'pending_received', 'transferred'].includes(m.handoff?.status)) return 'handoff_locked';
|
|
1889
|
+
if (!m.enrollment || m.enrollment.objective !== run.objective || m.enrollment.acceptanceDigest !== digest(JSON.stringify(m.acceptanceCriteria)) ||
|
|
1890
|
+
JSON.stringify(m.enrollment.deliveryLevel) !== JSON.stringify(m.deliveryLevel || null)) return 'enrollment_changed';
|
|
1891
|
+
return null;
|
|
1892
|
+
}
|
|
1893
|
+
function acceptanceAuthorityHeld(run) {
|
|
1894
|
+
const m = run.managed;
|
|
1895
|
+
return Boolean(m.pendingApproval || m.nativeWait || m.purpose?.nativeBoundary || m.executionUncertain ||
|
|
1896
|
+
m.providerWarning || m.dependencyWait || m.userQuestion || m.phase === 'needs_clarification' ||
|
|
1897
|
+
m.decision?.outcome === 'wait' || run.stopReason?.actor === 'user');
|
|
1898
|
+
}
|
|
1899
|
+
function acceptanceExecutionHeld(run) {
|
|
1900
|
+
const m = run.managed;
|
|
1901
|
+
return Boolean(m.resumeLease || m.liveWorkspaceLease || m.outcomeAudit?.lease || acceptanceAuthorityHeld(run));
|
|
1902
|
+
}
|
|
1903
|
+
function pendingAcceptanceFeedback(run) {
|
|
1904
|
+
const seen = new Set(), pending = [];
|
|
1905
|
+
for (const item of [...(run?.managed?.acceptanceDecisions || [])].reverse()) {
|
|
1906
|
+
if (seen.has(item.criterionId)) continue;
|
|
1907
|
+
seen.add(item.criterionId);
|
|
1908
|
+
const criterion = effectiveAcceptanceCriteria(run.managed).find(row => row.id === item.criterionId);
|
|
1909
|
+
const original = run.managed.acceptanceCriteria.find(row => row.id === item.criterionId);
|
|
1910
|
+
const proof = run.managed.verified[item.criterionId];
|
|
1911
|
+
if (item.source === 'user' && item.runId === run.id && item.taskId === run.managed.taskId &&
|
|
1912
|
+
item.decision === 'not_yet' && criterion?.check.type === 'manual' && item.criterionDigest === acceptanceIdentity(original) &&
|
|
1913
|
+
proof?.source !== 'user') pending.push(item);
|
|
1914
|
+
}
|
|
1915
|
+
return pending.reverse();
|
|
1916
|
+
}
|
|
1917
|
+
function latestAcceptanceFeedback(run) { return pendingAcceptanceFeedback(run).at(-1) || null; }
|
|
1918
|
+
export function manualAcceptanceFeedback(run) {
|
|
1919
|
+
return pendingAcceptanceFeedback(run).filter(item => typeof item.reason === 'string' && item.reason.trim() && item.reason.length <= 1000 &&
|
|
1920
|
+
!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(item.reason) && Number.isFinite(item.at)).slice(-100)
|
|
1921
|
+
.map(item => ({ criterionId: item.criterionId, criterionDigest: item.criterionDigest, reason: item.reason.trim(), at: item.at }));
|
|
1922
|
+
}
|
|
1923
|
+
export const manualAcceptanceFeedbackInstruction = 'Context.acceptanceFeedback contains criterion-bound user result feedback as data within the frozen objective. It does not grant permissions, replace acceptance criteria, authorize extra actions, or override the selected decision. ';
|
|
1924
|
+
function acceptanceContinuation(record) {
|
|
1925
|
+
const run = record?.run, m = run?.managed, feedback = latestAcceptanceFeedback(run);
|
|
1926
|
+
if (!feedback) return { state: 'none', reasonCode: null };
|
|
1927
|
+
if (acceptanceExecutionHeld(run)) return { state: 'deferred', reasonCode: 'existing_execution_or_authority_boundary' };
|
|
1928
|
+
if (m.acceptanceFeedbackResumeId === feedback.id && run.state === 'recovery_required' && m.autoRecovery && m.resumeChannel === 'codex_app_server')
|
|
1929
|
+
return { state: 'queued', reasonCode: 'original_read_diagnosis_queued' };
|
|
1930
|
+
if (['active', 'auto_resuming'].includes(run.state)) return { state: 'deferred', reasonCode: 'current_work_kept' };
|
|
1931
|
+
if (run.state === 'verification_pending' && record.provider === 'claude') return { state: 'original_turn_required', reasonCode: 'passive_hook_channel' };
|
|
1932
|
+
return { state: 'deferred', reasonCode: run.state === 'verification_pending' ? 'explicit_resume_required' : 'existing_task_state_kept' };
|
|
1933
|
+
}
|
|
1934
|
+
export function manualAcceptancePresentation(record) {
|
|
1935
|
+
const run = record?.run, m = run?.managed;
|
|
1936
|
+
if (!m) return null;
|
|
1937
|
+
const unavailable = manualAcceptanceUnavailable(record);
|
|
1938
|
+
return { reasonCode: unavailable, latestDecision: m.acceptanceDecisions?.at(-1) || null,
|
|
1939
|
+
continuation: acceptanceContinuation(record),
|
|
1940
|
+
criteria: effectiveAcceptanceCriteria(m).filter(item => item.check.type === 'manual').map(item => {
|
|
1941
|
+
const criterion = m.acceptanceCriteria.find(row => row.id === item.id), proof = m.verified[item.id];
|
|
1942
|
+
return { id: item.id, description: item.description, accepted: proof?.source === 'user',
|
|
1943
|
+
canAccept: !unavailable && proof?.source !== 'user', canNotYet: !unavailable,
|
|
1944
|
+
latestDecision: m.acceptanceDecisions?.findLast(row => row.criterionId === item.id) || null,
|
|
1945
|
+
controls: { provider: record.provider, session: record.session, taskId: m.taskId, expectedRunId: run.id,
|
|
1946
|
+
expectedRevision: deliveryDecisionRevision(run), expectedWorkspace: taskDirectory(record),
|
|
1947
|
+
criterionId: item.id, expectedCriterionDigest: acceptanceIdentity(criterion) } };
|
|
1948
|
+
}) };
|
|
1949
|
+
}
|
|
1950
|
+
async function applyManualAcceptance(record, criterionId, now, settle, root) {
|
|
1951
|
+
const run = record.run, session = record.session, started = Date.now(), initialNow = now;
|
|
1952
|
+
newVerifiedCriterion(run, criterionId, now);
|
|
1953
|
+
run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
|
|
1954
|
+
recordPurposeProgress(run, now, false);
|
|
1955
|
+
if (settle && searchEnabled(run.managed)) {
|
|
1956
|
+
const review = run.managed.purpose.search?.review;
|
|
1957
|
+
const currentDigest = await workspaceIdentity(taskDirectory(record), root, undefined, { excludedPaths: review?.manifest?.source === 'controller_certified_independent_proposal' ? review.manifest.exclusions.map(row => row.path) : [] }).catch(() => null);
|
|
1958
|
+
now=initialNow+Math.max(0,Date.now()-started);
|
|
1959
|
+
if (review?.source !== 'independent_codex_session' || !review.session || review.session===session || (!review.passed && !review.needsHuman) || !review.workspaceDigest || currentDigest !== review.workspaceDigest || !externalReviewIsCurrent(run.managed,review,now)) {
|
|
1960
|
+
for (const c of run.managed.acceptanceCriteria) if (c.check.type === 'semantic') delete run.managed.verified[c.id];
|
|
1961
|
+
queuePurposeVerification(run, 'Human decision recorded; recheck the changed workspace before completing.', now);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (settle && run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
|
|
1965
|
+
await verifyAutomaticCriteria(record, now);
|
|
1966
|
+
if (!pendingAcceptance(run).length) {
|
|
1967
|
+
const barrier=purposeCompletionBarrier(run);
|
|
1968
|
+
if(barrier) queuePurposeVerification(run, 'Human decision recorded; '+barrier+' Independently reconcile the required operation before completing.', now);
|
|
1969
|
+
else {run.state = 'completed'; run.reason = run.checkpoint.summary; bindControllerCompletion(run, now); settleCompletionHandoffs(run, now, run.managed.purpose?.search?.review || {});}
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1659
1973
|
export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
|
|
1660
|
-
const started=Date.now(), initialNow=now;
|
|
1661
1974
|
return updateRecord(root, provider, session, async record => {
|
|
1662
1975
|
const run = record?.run;
|
|
1663
1976
|
if (!run?.managed || run.state !== 'verification_pending') throw new Error('Managed task is not awaiting acceptance verification.');
|
|
1664
1977
|
if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
1665
|
-
const criterion = run.managed.
|
|
1978
|
+
const criterion = effectiveAcceptanceCriteria(run.managed).find(item => item.id === criterionId);
|
|
1666
1979
|
if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
|
|
1667
|
-
|
|
1668
|
-
run
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1980
|
+
await applyManualAcceptance(record, criterionId, now, !acceptanceExecutionHeld(run), root);
|
|
1981
|
+
advanceDeliveryControlRevision(run);
|
|
1982
|
+
notifyState(run, now); run.updatedAt = now; return record;
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
export async function decideManualAcceptance({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision,
|
|
1986
|
+
expectedWorkspace, criterionId, expectedCriterionDigest, decision, reason, authorize, now = Date.now() }) {
|
|
1987
|
+
let updated = await updateRecord(root, provider, session, async record => {
|
|
1988
|
+
if (authorize) await authorize();
|
|
1989
|
+
const run = record?.run, unavailable = manualAcceptanceUnavailable(record);
|
|
1990
|
+
if (unavailable) throw new Error(`Manual acceptance contract is unavailable: ${unavailable}.`);
|
|
1991
|
+
if (taskId !== run.managed.taskId || expectedRunId !== run.id || !Number.isSafeInteger(expectedRevision) ||
|
|
1992
|
+
expectedRevision !== deliveryDecisionRevision(run) || expectedWorkspace !== taskDirectory(record)) throw new Error('This decision changed. Refresh before choosing.');
|
|
1993
|
+
const criterion = run.managed.acceptanceCriteria.find(item => item.id === criterionId);
|
|
1994
|
+
const effective = effectiveAcceptanceCriteria(run.managed).find(item => item.id === criterionId);
|
|
1995
|
+
if (!criterion || effective?.check.type !== 'manual') throw new Error('Only a known manual criterion can receive a user decision.');
|
|
1996
|
+
if (expectedCriterionDigest !== acceptanceIdentity(criterion)) throw new Error('This criterion changed. Refresh before choosing.');
|
|
1997
|
+
if (!['accept', 'not_yet'].includes(decision)) throw new Error('Choose Accept or Not yet.');
|
|
1998
|
+
if (decision === 'not_yet' && (typeof reason !== 'string' || !reason.trim() || reason.length > 1000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(reason))) throw new Error('Provide a result feedback reason of 1 to 1000 characters.');
|
|
1999
|
+
if ((run.managed.acceptanceDecisions?.length || 0) >= 100) throw new Error('Acceptance decision history is full; retain and archive its evidence before continuing.');
|
|
2000
|
+
const previous = run.managed.verified[criterionId];
|
|
2001
|
+
if (decision === 'accept' && previous?.source === 'user') throw new Error('This criterion already has verification evidence. Refresh before choosing.');
|
|
2002
|
+
const entry = { id: randomUUID(), source: 'user', decision, criterionId, criterionDigest: expectedCriterionDigest, runId: run.id, taskId,
|
|
2003
|
+
workspace: expectedWorkspace, revision: expectedRevision, at: now, reason: decision === 'not_yet' ? reason.trim() : null,
|
|
2004
|
+
...(decision === 'not_yet' && previous?.source === 'user' ? { priorUserAttestation: structuredClone(previous) } : {}),
|
|
2005
|
+
...(decision === 'accept' && previous && previous.source !== 'user' ? { priorIndependentAttestation: structuredClone(previous) } : {}) };
|
|
2006
|
+
run.managed.acceptanceDecisions ||= []; run.managed.acceptanceDecisions.push(entry);
|
|
2007
|
+
if (decision === 'accept') await applyManualAcceptance(record, criterionId, now, run.state === 'verification_pending' && !acceptanceExecutionHeld(run), root);
|
|
2008
|
+
else if (previous?.source === 'user') delete run.managed.verified[criterionId];
|
|
2009
|
+
advanceDeliveryControlRevision(run);
|
|
2010
|
+
notifyState(run, now); run.updatedAt = now;
|
|
2011
|
+
if (authorize) await authorize();
|
|
2012
|
+
return record;
|
|
1689
2013
|
});
|
|
2014
|
+
const run = updated.run, m = run.managed;
|
|
2015
|
+
// Not yet authorizes only feedback within the frozen goal. If an already
|
|
2016
|
+
// enrolled original controller can safely inspect it, queue that diagnosis;
|
|
2017
|
+
// otherwise preserve the current work/hold and say what actually happened.
|
|
2018
|
+
if (decision === 'not_yet' && provider === 'codex' && updated.autoContinue === true && automaticContinuationAllowed(updated) &&
|
|
2019
|
+
m.resumeChannel === 'codex_app_server' && canResumePendingVerification(run) && !acceptanceExecutionHeld(run) && !deliveryStopBlocksResume(run, now) &&
|
|
2020
|
+
(await providerLinkState(root, provider)).connected) {
|
|
2021
|
+
try {
|
|
2022
|
+
updated = await controlDelivery({ root, provider, session, taskId, expectedRunId: run.id,
|
|
2023
|
+
expectedRevision: deliveryDecisionRevision(run), action: 'resume', authorize, now });
|
|
2024
|
+
} catch { /* The feedback remains recorded; a changed task is never forced. */ }
|
|
2025
|
+
}
|
|
2026
|
+
return { record: updated, decision: updated.run.managed.acceptanceDecisions.at(-1), continuation: acceptanceContinuation(updated) };
|
|
1690
2027
|
}
|
|
1691
2028
|
export function prepareWaitingDiagnosis(run, reason, now) {
|
|
1692
2029
|
const managed = run.managed, scope = managed.approvalScopes[0];
|
|
@@ -1757,14 +2094,25 @@ export function canExplicitlyResumeStopped(run) {
|
|
|
1757
2094
|
Boolean(run.managed.autonomyPolicy && run.managed.approvalScopes?.length) &&
|
|
1758
2095
|
!run.managed.resumeLease && !run.managed.liveWorkspaceLease;
|
|
1759
2096
|
}
|
|
2097
|
+
// Acceptance can remain pending while the user asks for more work. Reopening
|
|
2098
|
+
// work never verifies a criterion or interrupts an owned review/provider turn.
|
|
2099
|
+
export function canResumePendingVerification(run) {
|
|
2100
|
+
const m = run?.managed;
|
|
2101
|
+
return Boolean(run?.state === 'verification_pending' && m?.autonomyPolicy && m.approvalScopes?.length &&
|
|
2102
|
+
!m.resumeLease && !m.liveWorkspaceLease && !m.outcomeAudit?.lease && !m.executionUncertain && !m.nativeWait && !m.providerWarning && !m.purpose?.nativeBoundary);
|
|
2103
|
+
}
|
|
1760
2104
|
// Time spent waiting for a person is not missing work progress. An explicit
|
|
1761
2105
|
// resume, answer or approval starts a new progress window; failures remain.
|
|
1762
2106
|
function continueAfterHumanDecision(run, now) {
|
|
1763
2107
|
const stop = deliveryStop(run, now);
|
|
1764
2108
|
if ([STOP_CODES.failureLimit, 'purpose_budget'].includes(stop?.code)) throw new Error(stop.reason);
|
|
1765
|
-
if (
|
|
2109
|
+
if (run.managed) startProgressWindow(run, now, stop?.code ?? 'human_decision');
|
|
2110
|
+
}
|
|
2111
|
+
// Starts a recorded no-progress window. Failure counts and verified criteria
|
|
2112
|
+
// are never touched; only the time/attempt progress clock restarts.
|
|
2113
|
+
function startProgressWindow(run, now, code) {
|
|
1766
2114
|
run.managed.progressWindowResets = [...(run.managed.progressWindowResets || []),
|
|
1767
|
-
{ at: now, code
|
|
2115
|
+
{ at: now, code, noProgress: run.managed.noProgress ?? 0 }].slice(-10);
|
|
1768
2116
|
run.managed.noProgress = 0;
|
|
1769
2117
|
run.managed.outcomeProgress = restartOutcomeProgressWindow(run.managed.outcomeProgress);
|
|
1770
2118
|
run.managed.lastVerifiedProgressAt = now;
|
|
@@ -1804,13 +2152,16 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
|
|
|
1804
2152
|
managed.progressCheckpointRevision = run.revision;
|
|
1805
2153
|
}
|
|
1806
2154
|
// Manual/legacy runs have no marker and retain their existing controls.
|
|
2155
|
+
export const ownerRenewalPending = record => ['awaiting_human', 'prepared', 'gate_committed'].includes(record?.run?.managed?.ownerRenewal?.status);
|
|
1807
2156
|
export function automaticContinuationAllowed(record) {
|
|
2157
|
+
if (ownerRenewalPending(record) || record?.run?.managed?.ownerRenewal?.needsReadOnlyReassessment === true) return false;
|
|
1808
2158
|
if (record?.providerArchived === true || record?.run?.managed?.purpose && record.autoContinue === false) return false;
|
|
1809
2159
|
const epoch = record?.run?.managed?.autoContinueEpoch;
|
|
1810
2160
|
return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
|
|
1811
2161
|
}
|
|
1812
2162
|
|
|
1813
2163
|
export function decideStop(run, now = Date.now()) {
|
|
2164
|
+
if (ownerRenewalPending({ run })) return { run, output: {} };
|
|
1814
2165
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
1815
2166
|
const next = structuredClone(run);
|
|
1816
2167
|
const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
|
|
@@ -1852,7 +2203,9 @@ export function decideStop(run, now = Date.now()) {
|
|
|
1852
2203
|
// Keep prior failures until actual fresh work succeeds or another attempt fails.
|
|
1853
2204
|
if (next.managed?.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook) {
|
|
1854
2205
|
if (!fresh || cp.status === 'error') next.failures += 1;
|
|
1855
|
-
|
|
2206
|
+
// Normal observed work may clear consecutive failures under existing policy;
|
|
2207
|
+
// reinterpreting a contradictory completion report is not a new success.
|
|
2208
|
+
else if (!(next.managed && cp?.reportedStatus === 'completed' && cp.remainingWork)) next.failures = 0;
|
|
1856
2209
|
}
|
|
1857
2210
|
if (failureLimitReached(next)) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
|
|
1858
2211
|
if (next.managed) {
|
|
@@ -1937,6 +2290,7 @@ export async function processIdentity(pid) {
|
|
|
1937
2290
|
// of turn/permission events. Permission and dispatch decisions remain in the loop.
|
|
1938
2291
|
export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
|
|
1939
2292
|
const unavailable = (code, reason) => ({ ready: false, code, reason });
|
|
2293
|
+
if (ownerRenewalPending(record)) return unavailable('owner_renewal_pending', 'The original conversation connection recovery is not complete.');
|
|
1940
2294
|
if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
|
|
1941
2295
|
if (!record) return unavailable('hook_missing', 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
|
|
1942
2296
|
if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
|
|
@@ -1987,6 +2341,81 @@ export function canEmitHookContinuation(root, observed) {
|
|
|
1987
2341
|
!['paused', 'blocked', 'recovery_required', 'verification_pending'].includes(latest.run.state) &&
|
|
1988
2342
|
automaticContinuationAllowed(latest) && !hasPendingUserStop(root, latest));
|
|
1989
2343
|
}
|
|
2344
|
+
// Without a provider turn ID, a nonempty terminal message supplies a stable,
|
|
2345
|
+
// conservative event fingerprint. A repeated identical message remains unknown
|
|
2346
|
+
// rather than being invented as another turn from a fresh local random UUID.
|
|
2347
|
+
function hookTerminalExecutionId(provider, input) {
|
|
2348
|
+
return text(input.turn_id, 200) || (provider === 'claude' && text(input.last_assistant_message, 65536)
|
|
2349
|
+
? `claude-stop:${digest(JSON.stringify([input.hook_event_name, input.last_assistant_message]))}` : '');
|
|
2350
|
+
}
|
|
2351
|
+
async function settleOwnedContinuation(root, { provider, input, observedProcess }) {
|
|
2352
|
+
if (input.stop_hook_active !== true || !observedProcess) return null;
|
|
2353
|
+
const record = await read(recordPath(root, provider, input.session_id)), owner = record?.hookSessionProcess;
|
|
2354
|
+
if (!record?.run || owner?.pid !== observedProcess.pid || owner?.identity !== observedProcess.identity) return null;
|
|
2355
|
+
let cwd; try { cwd = await realpath(input.cwd); } catch { return null; }
|
|
2356
|
+
if (cwd !== record.cwd) return null;
|
|
2357
|
+
const executionId = hookTerminalExecutionId(provider, input);
|
|
2358
|
+
try {
|
|
2359
|
+
return settleHookContinuation({ root: rootFromDelivery(root), provider, session: input.session_id, writer: observedProcess,
|
|
2360
|
+
runId: record.run.id, taskId: record.run.managed?.taskId || record.run.id, continuation: record.run.continuations, executionId,
|
|
2361
|
+
status: input.hook_event_name === 'Stop' ? 'completed' : 'failed', response: text(input.last_assistant_message, 65536) });
|
|
2362
|
+
} catch (error) { if (!error?.code) throw error; return null; }
|
|
2363
|
+
}
|
|
2364
|
+
// Claude Code also submits harness notifications (finished background tasks,
|
|
2365
|
+
// side-session or CI events) as prompts, and an empty event carries no human
|
|
2366
|
+
// intent. Neither is a reconnect boundary.
|
|
2367
|
+
const AUTOMATED_PROMPT = /^<(task-notification|side-sessions-event|ci-monitor-event|system-reminder)\b/;
|
|
2368
|
+
const humanPrompt = input => (input.origin?.kind === undefined || input.origin.kind === 'human') &&
|
|
2369
|
+
text(input.prompt) !== '' && !AUTOMATED_PROMPT.test(text(input.prompt));
|
|
2370
|
+
// An observation of a replacement writer is not adoption or execution consent.
|
|
2371
|
+
// The explicit recovery action separately verifies the transcript's human UUID.
|
|
2372
|
+
function observeReplacementWriter(record, { input, event, identity, now, invalidationId, epoch, root }) {
|
|
2373
|
+
if (record.provider !== 'claude' || !record.run?.managed?.liveHookOwner ||
|
|
2374
|
+
record.run.state !== 'paused' || record.autoContinue !== false || !identity?.identity) return;
|
|
2375
|
+
if (event === 'UserPromptSubmit') {
|
|
2376
|
+
if (!humanPrompt(input) || typeof input.prompt !== 'string' || Buffer.byteLength(input.prompt) > MAX_BYTES) {
|
|
2377
|
+
delete record.ownerRenewalCandidate; return;
|
|
2378
|
+
}
|
|
2379
|
+
const request = record.run.managed.ownerRenewal;
|
|
2380
|
+
record.ownerRenewalCandidate = { version: 1, id: randomUUID(),
|
|
2381
|
+
ownerRenewalId: request?.status === 'awaiting_human' ? request.id : null,
|
|
2382
|
+
provider: 'claude', session: record.session, cwd: record.cwd, writer: { ...identity },
|
|
2383
|
+
observedAt: now, promptSHA256: createHash('sha256').update(input.prompt).digest('hex'),
|
|
2384
|
+
promptBytes: Buffer.byteLength(input.prompt), permissionMode: text(input.permission_mode, 64) || null,
|
|
2385
|
+
model: null, moduleDigest: DISPATCH_MODULE_DIGEST, providerEpoch: epoch,
|
|
2386
|
+
generation: readDispatchGate(rootFromDelivery(root)).generation,
|
|
2387
|
+
eventFence: invalidationId };
|
|
2388
|
+
} else if (record.ownerRenewalCandidate) {
|
|
2389
|
+
const candidate = record.ownerRenewalCandidate;
|
|
2390
|
+
if (event === 'Stop' && candidate.writer.pid === identity.pid && candidate.writer.identity === identity.identity)
|
|
2391
|
+
candidate.eventFence = invalidationId;
|
|
2392
|
+
else delete record.ownerRenewalCandidate;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
// A human prompt in the exact original process is the supported reconnect
|
|
2396
|
+
// boundary after recovery advanced the update generation. Autonomous Stop never
|
|
2397
|
+
// adopts; a failed check is recorded for the UI and leaves dispatch fenced.
|
|
2398
|
+
async function reattachAtUserBoundary(root, { provider, input, observedProcess, now = Date.now() }) {
|
|
2399
|
+
// A disconnected provider link leaves every hook inert, including reconnects.
|
|
2400
|
+
if (!humanPrompt(input) || !(await providerLinkState(root, provider)).connected) return null;
|
|
2401
|
+
const file = recordPath(root, provider, input.session_id);
|
|
2402
|
+
const record = await read(file), owner = record?.hookSessionProcess;
|
|
2403
|
+
let cwd; try { cwd = await realpath(input.cwd); } catch { return null; }
|
|
2404
|
+
if (!record || !observedProcess || record.cwd !== cwd || record.providerArchived === true || hasPendingUserStop(root, record) ||
|
|
2405
|
+
owner?.pid !== observedProcess.pid || owner?.identity !== observedProcess.identity) return null;
|
|
2406
|
+
let outcome;
|
|
2407
|
+
try {
|
|
2408
|
+
const result = reattachHookOwner({ root: rootFromDelivery(root), provider, session: input.session_id, writer: observedProcess,
|
|
2409
|
+
permissionMode: text(input.permission_mode, 64), taskIds: [record.run?.managed?.taskId, record.run?.id].filter(Boolean), observedAt: now });
|
|
2410
|
+
if (!result.reattached) return result;
|
|
2411
|
+
outcome = { state: 'reattached', at: now, fromGeneration: result.fromGeneration, generation: result.generation };
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
if (!error?.code) throw error;
|
|
2414
|
+
outcome = { state: 'blocked', at: now, code: error.code };
|
|
2415
|
+
}
|
|
2416
|
+
await updateRecord(root, provider, input.session_id, current => current && current.cwd === cwd ? { ...current, writerReconnect: outcome } : current);
|
|
2417
|
+
return outcome;
|
|
2418
|
+
}
|
|
1990
2419
|
export async function handleHook(options) {
|
|
1991
2420
|
const root = options.root || rootDirectory(), event = options.input?.hook_event_name;
|
|
1992
2421
|
if (!EVENTS.has(event) || !text(options.input?.session_id) || !text(options.input?.cwd)) return {};
|
|
@@ -2002,10 +2431,19 @@ export async function handleHook(options) {
|
|
|
2002
2431
|
if (record?.run) atomicSave(stopMarkerPath(root, options.provider, options.input.session_id), { schemaVersion: 1, id: randomUUID(), runId: record.run.id });
|
|
2003
2432
|
}
|
|
2004
2433
|
// Stop/permission lifecycle must never wait on the update dispatch lock.
|
|
2005
|
-
if (event !== 'Stop' && event !== 'SessionStart') {
|
|
2434
|
+
if (event !== 'Stop' && event !== 'SessionStart') {
|
|
2435
|
+
const userBoundary = options.provider === 'claude' && event === 'UserPromptSubmit';
|
|
2436
|
+
const settles = event === 'StopFailure' && options.input.stop_hook_active === true;
|
|
2437
|
+
if ((userBoundary || settles) && options.observedProcess === undefined) options = { ...options, observedProcess: await providerProcess(options.provider) };
|
|
2438
|
+
if (settles) await settleOwnedContinuation(root, options);
|
|
2439
|
+
const output = await handleHookState({ ...options, root, suppressDispatch: true });
|
|
2440
|
+
if (userBoundary) await reattachAtUserBoundary(root, options);
|
|
2441
|
+
if (options.emit) options.emit(output); return output;
|
|
2442
|
+
}
|
|
2006
2443
|
try {
|
|
2007
2444
|
const observedProcess = options.observedProcess === undefined ? await providerProcess(options.provider) : options.observedProcess;
|
|
2008
2445
|
options = { ...options, observedProcess };
|
|
2446
|
+
if (event === 'Stop') await settleOwnedContinuation(root, options);
|
|
2009
2447
|
const owner = captureHookOwner(rootFromDelivery(root), options.provider, options.input?.session_id, observedProcess);
|
|
2010
2448
|
let current = await read(recordPath(root, options.provider, options.input.session_id)), pendingOutput;
|
|
2011
2449
|
return await fenceHook(owner, `${options.provider}:${options.input?.session_id}`, async suppressDispatch => {
|
|
@@ -2015,7 +2453,10 @@ export async function handleHook(options) {
|
|
|
2015
2453
|
return hasPendingUserStop(root, current) ? {} : output;
|
|
2016
2454
|
}, options.emit, () => pendingOutput?.decision !== 'block' || canEmitHookContinuation(root, current), () => {
|
|
2017
2455
|
const taskId = current?.run?.managed?.taskId || current?.run?.id;
|
|
2018
|
-
return taskId ? { taskId, attemptId: (current.run.managed?.executionAttempt?.phase === 'dispatching' ? current.run.managed.executionAttempt.id : null) || `${taskId}:hook:${current.run.continuations}`,
|
|
2456
|
+
return taskId ? { taskId, attemptId: (current.run.managed?.executionAttempt?.phase === 'dispatching' ? current.run.managed.executionAttempt.id : null) || `${taskId}:hook:${current.run.continuations}`,
|
|
2457
|
+
hookBinding: observedProcess ? { version: 1, provider: current.provider, session: current.session, runId: current.run.id, taskId,
|
|
2458
|
+
continuation: current.run.continuations, pid: observedProcess.pid, processIdentity: observedProcess.identity,
|
|
2459
|
+
sourceExecutionId: hookTerminalExecutionId(options.provider, options.input) || null } : null, provenance: captureDispatchProvenance({ root: owner.root, provider: current.provider, providerSession: current.session, worktree: taskDirectory(current), taskId, recordRef: recordPath(root, current.provider, current.session) }) } : null;
|
|
2019
2460
|
});
|
|
2020
2461
|
} catch (error) {
|
|
2021
2462
|
if (error.code !== 'state_busy') throw error;
|
|
@@ -2055,6 +2496,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2055
2496
|
}
|
|
2056
2497
|
}
|
|
2057
2498
|
const owner = record.run?.managed?.liveHookOwner || (['ConfigChange', 'PostModelSwitch'].includes(event) ? record.hookSessionProcess : null);
|
|
2499
|
+
if (owner && (!identity || identity.pid !== owner.pid || identity.identity !== owner.identity))
|
|
2500
|
+
observeReplacementWriter(record, { input, event, identity, now, invalidationId, epoch: link.epoch, root });
|
|
2058
2501
|
if (provider === 'claude' && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event) &&
|
|
2059
2502
|
(!identity || owner && (identity.pid !== owner.pid || identity.identity !== owner.identity))) {
|
|
2060
2503
|
// An unverified lifecycle event invalidates prior proof without adopting
|
|
@@ -2173,19 +2616,37 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2173
2616
|
run.reason = 'Prior live action stopped without a settled checkpoint; explicitly resume read-only result diagnosis.';
|
|
2174
2617
|
}
|
|
2175
2618
|
}
|
|
2619
|
+
if (event === 'Stop' && !suppressDispatch && automaticContinuationAllowed(record) &&
|
|
2620
|
+
canDiagnoseLegacyLocalEffect(run, provider, now)) {
|
|
2621
|
+
const latestLink = await providerLinkState(root, provider);
|
|
2622
|
+
if (latestLink.connected && latestLink.epoch === link.epoch) {
|
|
2623
|
+
recordDecision(run.managed, researchWaitDiagnosis(run.managed), now);
|
|
2624
|
+
run.state = 'active'; run.reason = 'The inconsistent local write remains unexecuted; inspect its assessment first.';
|
|
2625
|
+
run.nextStep = run.managed.decision.action.summary;
|
|
2626
|
+
run.checkpoint = null; run.consumedRevision = run.revision;
|
|
2627
|
+
run.managed.escalation = null;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2176
2630
|
if (!run || TERMINAL.has(run.state)) return record;
|
|
2177
2631
|
if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
|
|
2178
2632
|
else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
|
|
2179
2633
|
// The supervisor owns this exact attempt and consumes its structured
|
|
2180
2634
|
// final result once. Provider lifecycle hooks must not race that lease.
|
|
2181
2635
|
}
|
|
2182
|
-
else if (
|
|
2636
|
+
else if (run.state !== 'paused' && (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type))) {
|
|
2637
|
+
// A native permission wait is still authoritative while independent
|
|
2638
|
+
// acceptance is pending; lifecycle bookkeeping must not hide it.
|
|
2639
|
+
const requestId = text(input.tool_use_id, 200) || (run.state === 'blocked' ? run.providerRequest?.id : null) || randomUUID();
|
|
2640
|
+
run.providerRequest = { id: requestId, kind: event === 'Notification' && input.notification_type === 'elicitation_dialog' ? 'question' : 'approval', source: 'provider_hook', observedAt: now };
|
|
2641
|
+
run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
|
|
2642
|
+
}
|
|
2643
|
+
else if (['paused', 'verification_pending'].includes(run.state) || run.state === 'blocked' &&
|
|
2644
|
+
(run.managed?.dependencyWait || run.managed?.pendingApproval || run.managed?.nativeWait ||
|
|
2645
|
+
['authorization', 'execution_uncertain'].includes(run.blocker))) {
|
|
2183
2646
|
// A provider lifecycle event cannot override an explicit pause or a
|
|
2184
2647
|
// completion candidate awaiting independent acceptance checks.
|
|
2185
2648
|
}
|
|
2186
|
-
else if (event === '
|
|
2187
|
-
run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
|
|
2188
|
-
} else if (event === 'SessionEnd') {
|
|
2649
|
+
else if (event === 'SessionEnd') {
|
|
2189
2650
|
const managed = run.managed, lastAttempt = managed?.attempts?.at(-1);
|
|
2190
2651
|
// Ending the native writer must not erase a previously authorized retry.
|
|
2191
2652
|
// A matching undispatched busy receipt also recovers this queue when an
|
|
@@ -2213,6 +2674,10 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2213
2674
|
// turn. Lease settlement and runtime observation above still run.
|
|
2214
2675
|
const latestLink = await providerLinkState(root, provider);
|
|
2215
2676
|
if (!automaticContinuationAllowed(record) || !latestLink.connected || latestLink.epoch !== link.epoch) return record;
|
|
2677
|
+
// Preserve the reason for a held action, including uncertainty observed
|
|
2678
|
+
// while settling this Stop's writer lease. Acceptance is not a wait release.
|
|
2679
|
+
if (run.state !== 'active' || run.managed?.nativeWait || run.managed?.purpose?.nativeBoundary ||
|
|
2680
|
+
run.managed?.pendingApproval && run.checkpoint?.status === 'completed') return record;
|
|
2216
2681
|
// Codex turn IDs make re-delivered Stop events idempotent. Claude has no
|
|
2217
2682
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
2218
2683
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
@@ -2220,7 +2685,18 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2220
2685
|
if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
|
|
2221
2686
|
await verifyAutomaticCriteria(record, now);
|
|
2222
2687
|
const pending = pendingAcceptance(run);
|
|
2223
|
-
|
|
2688
|
+
const missingFiles = pending.filter(item => ['file_exists', 'file_contains', 'file_equals'].includes(item.check) || run.managed.liveOutcomeReview?.passed === false && item.check === 'semantic');
|
|
2689
|
+
if (missingFiles.length && run.managed.autonomyPolicy && !run.managed.pendingApproval &&
|
|
2690
|
+
!run.managed.nativeWait && !run.managed.purpose?.nativeBoundary && !run.managed.executionUncertain) {
|
|
2691
|
+
// Completion reads at checkpoint time can become stale before Stop.
|
|
2692
|
+
// Use the same assessed diagnosis as a failed checkpoint predicate;
|
|
2693
|
+
// a new file mismatch is useful work, not human acceptance waiting.
|
|
2694
|
+
recordDecision(run.managed, missingAcceptanceDiagnosis(run.managed, missingFiles), now);
|
|
2695
|
+
run.checkpoint = { ...run.checkpoint, status: 'in_progress', reportedStatus: 'completed', acceptanceMet: false,
|
|
2696
|
+
nextStep: `Diagnose failed acceptance checks: ${missingFiles.map(item => item.id).join(', ')}` };
|
|
2697
|
+
run.nextStep = run.checkpoint.nextStep;
|
|
2698
|
+
run.managed.remainingWork = run.nextStep;
|
|
2699
|
+
} else if (pending.length) {
|
|
2224
2700
|
run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
|
|
2225
2701
|
run.lastStopKey = key; notifyState(run, now); return record;
|
|
2226
2702
|
}
|
|
@@ -2336,10 +2812,13 @@ export function deliveryDecisionRevision(run) {
|
|
|
2336
2812
|
const revision = run.revision + controls;
|
|
2337
2813
|
return Number.isSafeInteger(revision) ? revision : undefined;
|
|
2338
2814
|
}
|
|
2339
|
-
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() }) {
|
|
2815
|
+
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, resumeObservation, action, authorize, inspectProcess = processIdentity, now = Date.now() }) {
|
|
2340
2816
|
const updated = await updateRecord(root, provider, session, async record => {
|
|
2817
|
+
if (authorize) await authorize();
|
|
2341
2818
|
const run = record?.run;
|
|
2342
2819
|
if (!run) throw new Error('Delivery Loop not found.');
|
|
2820
|
+
if (ownerRenewalPending(record) && !['stop', 'pause'].includes(action))
|
|
2821
|
+
throw Object.assign(new Error('Finish the original conversation connection recovery before changing its controls.'), { code: 'owner_renewal_pending' });
|
|
2343
2822
|
if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
|
|
2344
2823
|
(typeof expectedRunId !== 'string' || !expectedRunId || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 ||
|
|
2345
2824
|
run.id !== expectedRunId || deliveryDecisionRevision(run) !== expectedRevision)) throw new Error('This decision changed. Refresh before choosing.');
|
|
@@ -2354,7 +2833,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2354
2833
|
}
|
|
2355
2834
|
if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
|
|
2356
2835
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
2357
|
-
const commitControl = () => {
|
|
2836
|
+
const commitControl = async () => {
|
|
2837
|
+
if (authorize) await authorize();
|
|
2358
2838
|
advanceDeliveryControlRevision(run);
|
|
2359
2839
|
return record;
|
|
2360
2840
|
};
|
|
@@ -2441,6 +2921,12 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2441
2921
|
else { run.state = 'recovery_required'; run.recoveryCause = 'policy_change'; run.reason = 'Policy changed; resume the original conversation for the selected action.'; run.managed.autoRecovery = false; }
|
|
2442
2922
|
}
|
|
2443
2923
|
else if (action === 'stop') {
|
|
2924
|
+
if (ownerRenewalPending(record)) {
|
|
2925
|
+
// Explicit Stop ends only the pending binding request, never its audit
|
|
2926
|
+
// history, old unknown attempts or the task's terminal-state contract.
|
|
2927
|
+
Object.assign(run.managed.ownerRenewal, { status: 'cancelled', cancelledAt: now, cancelReason: 'explicit_user_stop' });
|
|
2928
|
+
if (TERMINAL.has(run.state)) return commitControl();
|
|
2929
|
+
}
|
|
2444
2930
|
if (TERMINAL.has(run.state)) return record;
|
|
2445
2931
|
run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
|
|
2446
2932
|
run.state = 'stopped'; run.reason = 'Stopped by user.';
|
|
@@ -2448,7 +2934,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2448
2934
|
else if (action === 'pause') {
|
|
2449
2935
|
const queuedAppServer = run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(run.recoveryCause) &&
|
|
2450
2936
|
run.managed?.resumeChannel === 'codex_app_server' && run.managed.autoRecovery;
|
|
2451
|
-
if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer) throw new Error('Only a running or queued managed task can pause.');
|
|
2937
|
+
if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer && !liveOutcomeReviewEligible(record)) throw new Error('Only a running or queued managed task can pause.');
|
|
2452
2938
|
if (queuedAppServer || run.state === 'auto_resuming' && run.managed.resumeChannel === 'codex_app_server') run.managed.pausedRecoveryCause = run.recoveryCause || 'session_supervision';
|
|
2453
2939
|
run.state = 'paused'; run.reason = 'Paused by user.';
|
|
2454
2940
|
run.stopReason = { actor: 'user', code: 'explicit_pause', at: now };
|
|
@@ -2488,12 +2974,14 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2488
2974
|
}
|
|
2489
2975
|
}
|
|
2490
2976
|
else if (action === 'resume') {
|
|
2977
|
+
run.providerRequest = null;
|
|
2491
2978
|
const marker = readState(stopMarkerPath(root, provider, session));
|
|
2492
2979
|
if (marker?.runId === run.id) run.handledStopMarkerId = marker.id;
|
|
2493
2980
|
const wasStopped = canExplicitlyResumeStopped(run);
|
|
2981
|
+
const wasVerifying = canResumePendingVerification(run);
|
|
2494
2982
|
if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
|
|
2495
2983
|
if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
|
|
2496
|
-
if (!['blocked', 'recovery_required', 'paused'].includes(run.state) && !wasStopped) throw new Error('Only a blocked, paused, recoverable, or stopped managed app-server loop
|
|
2984
|
+
if (!['blocked', 'recovery_required', 'paused'].includes(run.state) && !wasStopped && !wasVerifying) throw new Error('Only a blocked, paused, recoverable, unowned pending-verification task, or stopped managed app-server loop can resume.');
|
|
2497
2985
|
if (run.managed && run.state === 'blocked' && run.blocker === 'missing_information') {
|
|
2498
2986
|
prepareWaitingDiagnosis(run, `Inspect the previous information question and current conversation before choosing work: ${text(run.managed.userQuestion, 1000)}. Do not replay the prior action.`, now);
|
|
2499
2987
|
run.blocker = null;
|
|
@@ -2501,6 +2989,16 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2501
2989
|
if (run.managed?.executionUncertain) { prepareRecoveryDiagnosis(run, now); run.blocker = null; }
|
|
2502
2990
|
if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
|
|
2503
2991
|
if (run.managed?.autonomyPolicy && run.managed.decision?.outcome === 'wait' && (run.managed.approvedAction?.id !== run.managed.decision.id || run.managed.approvedAction.dispatchedAt)) throw new Error('Selected action requires a fresh scoped user decision.');
|
|
2992
|
+
if (wasVerifying) {
|
|
2993
|
+
run.resumeHistory ||= [];
|
|
2994
|
+
run.resumeHistory.push({ at: now, source: 'explicit_resume', priorState: run.state,
|
|
2995
|
+
reason: run.reason, checkpoint: run.checkpoint ?? null });
|
|
2996
|
+
const feedback = latestAcceptanceFeedback(run);
|
|
2997
|
+
prepareWaitingDiagnosis(run, 'The user requested continued work while acceptance remains pending. Inspect the original goal, current result and remaining next steps. Do not replay prior effects or treat this resume as acceptance.' +
|
|
2998
|
+
(feedback ? ` Criterion-bound user result feedback (does not grant scope or permissions): ${JSON.stringify(manualAcceptanceFeedback(run))}` : ''), now);
|
|
2999
|
+
if (feedback) run.managed.acceptanceFeedbackResumeId = feedback.id;
|
|
3000
|
+
run.blocker = null; run.blockerSource = null;
|
|
3001
|
+
}
|
|
2504
3002
|
if (wasStopped) {
|
|
2505
3003
|
const link = await providerLinkState(root, provider);
|
|
2506
3004
|
if (!link.connected) throw new Error('Restore the existing provider connection before resuming.');
|
|
@@ -2537,6 +3035,13 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2537
3035
|
// connector permissions or the frozen enrollment authority here.
|
|
2538
3036
|
}
|
|
2539
3037
|
}
|
|
3038
|
+
if (run.managed?.ownerRenewal?.needsReadOnlyReassessment) {
|
|
3039
|
+
prepareWaitingDiagnosis(run, 'The original Claude connection was restored. Re-read the latest human request and current constraints in this original conversation before proposing work. Do not replay the historical selected action or perform an implementation when the current request is design-only.', now);
|
|
3040
|
+
run.managed.awaitingReplanHook = true;
|
|
3041
|
+
run.checkpoint = null; run.consumedRevision = run.revision;
|
|
3042
|
+
run.managed.ownerRenewal.needsReadOnlyReassessment = false;
|
|
3043
|
+
run.managed.ownerRenewal.reassessmentDecisionId = run.managed.decision.id;
|
|
3044
|
+
}
|
|
2540
3045
|
continueAfterHumanDecision(run, now);
|
|
2541
3046
|
delete run.stopReason;
|
|
2542
3047
|
if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
|