@awak-app/simy-cli 0.7.29 → 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 +2 -2
- 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 +389 -51
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- 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 +8 -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-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/instruction.js +1 -1
- package/src/runner.js +2 -1
- 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-owner-renewal.js +197 -0
- package/src/session-supervision/claude-sessions.js +143 -34
- package/src/session-supervision/codex-app-server.js +20 -2
- 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 +36 -7
- 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/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 +5 -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
|
}
|
|
@@ -554,12 +559,14 @@ export function canExecuteAutonomousExternal(managed) {
|
|
|
554
559
|
// Shared by live Stop hooks and cold exact-session recovery. Keep the result
|
|
555
560
|
// contract identical across transports; the host still verifies acceptance.
|
|
556
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.`;
|
|
557
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.';
|
|
558
|
-
return `${minimalSufficientChangeInstruction({ turn: 'assessed' })}\nReturn ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened"} for THIS selected action. ${observationField} ${actionReversibilityInstruction} This is provider-reported, not independent verification. When the requested outcome is a conversational answer or correction, put the actual answer text in summary. Saying it was prepared or delivered is insufficient; do not place the answer only in an undeclared JSON field. For an answer-only request, returning that text here is the conversational result: do not invent a separate message-delivery action unless the original user requested a separate destination. This does not verify implementation or external effects. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. Missing facts are unknown. Successful diagnosis or repair is only an action result, not completion of the original user task. If original work remains, return in_progress with remainingWork and the next assessed plan, preserving the original objective, corrections and acceptance criteria. Do not repeat effects already performed. Report completed only when the original requested outcome is achieved; keep unfinished checks as remaining work. Do not add audit reports, certification, or evidence-submission tasks. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. ${informationWaitInstruction} external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
|
|
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.`;
|
|
559
566
|
|
|
560
567
|
}
|
|
561
568
|
export function understandingCheckpointContract(decision) {
|
|
562
|
-
return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\n${correctiveFollowupInstruction}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; ${decision.purpose ? "Authorized read-only API and web research is allowed within existing provider permissions; do not send private material to external services. Do not edit files, write to external services, or start the proposed next action." : "do not edit files, call external services, or start the proposed next action."} Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|semantic|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For blastRadius, single_file and repository describe filesystem scope; multi_system is the conservative category for a known non-filesystem boundary, including an API/service or conversation. State the actual affected targets in the plan; this category grants no additional target or permission. Use unknown for missing scope facts, not merely because a known target is not a file. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require AI investigation; reversible preferences are delegated under autonomous policy. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. ${decision.search ? "For semantic outcomes use check.type semantic; reserve manual for an explicit requirement for a human decision. Missing verification tooling is work to do, not a manual condition." : "For semantic conditions use
|
|
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.`;
|
|
563
570
|
}
|
|
564
571
|
function boundedContextList(value, maxItems = 8, maxLength = 300) {
|
|
565
572
|
if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
|
|
@@ -591,7 +598,7 @@ function sessionUnderstanding(value) {
|
|
|
591
598
|
if (!description || !['user_grounded', 'proposed'].includes(item?.source)) throw new Error('Understanding criterion needs a description and provenance.');
|
|
592
599
|
const basisIndex = Number.isSafeInteger(item.basisIndex) && item.basisIndex >= 0 && item.basisIndex < basis.length ? item.basisIndex : null;
|
|
593
600
|
if (item.source === 'user_grounded' && (basisIndex === null || basis[basisIndex].source !== 'user')) throw new Error('User-grounded criterion needs a user-message basis.');
|
|
594
|
-
const check = item.check?.type || '
|
|
601
|
+
const check = item.check?.type || 'semantic';
|
|
595
602
|
if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check)) throw new Error('Unsupported inferred acceptance check.');
|
|
596
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.');
|
|
597
604
|
if (check === 'file_contains' && !text(item.check.text, 1000)) throw new Error('Inferred file check needs expected text.');
|
|
@@ -676,7 +683,11 @@ function recordPurposeProgress(run, now, countAttempt = true) {
|
|
|
676
683
|
}
|
|
677
684
|
}
|
|
678
685
|
function recordDecision(managed, plan, now) {
|
|
679
|
-
|
|
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);
|
|
680
691
|
const dependencyPending = option => (option?.dependsOn || []).some(id =>
|
|
681
692
|
!managed.purpose?.evidence?.units?.some(unit => unit.id === id && unit.status === 'verified' && unit.purposeRevision === managed.purpose.evidence.purposeRevision));
|
|
682
693
|
if (searchEnabled(managed) && (decision.outcome === 'wait' || dependencyPending(plan.alternatives.find(row => row.id === plan.selectedOptionId)))) {
|
|
@@ -685,7 +696,7 @@ function recordDecision(managed, plan, now) {
|
|
|
685
696
|
for (const candidate of [...plan.alternatives].sort((a, b) => (priorities.get(a.id) ?? 99) - (priorities.get(b.id) ?? 99))) {
|
|
686
697
|
if (candidate.id === plan.selectedOptionId || !candidate.independent || dependencyPending(candidate)) continue;
|
|
687
698
|
const alternative = { ...plan, selectedOptionId: candidate.id, recommendation: { optionId: candidate.id, reason: 'Continue the highest listed eligible independent alternative.' } };
|
|
688
|
-
const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now,
|
|
699
|
+
const assessed = evaluateAssessedPlan(alternative, managed.autonomyPolicy, managed.approvalScopes, now, assessmentOptions);
|
|
689
700
|
if (assessed.outcome !== 'wait' && !managed.rejectedActionOverflow && !managed.rejectedActionDigests?.includes(digest(JSON.stringify(assessed.action)))) {
|
|
690
701
|
plan = alternative; decision = assessed; break;
|
|
691
702
|
}
|
|
@@ -718,6 +729,7 @@ function recordDecision(managed, plan, now) {
|
|
|
718
729
|
status: decision.outcome === 'wait' ? 'waiting' : 'planned', evidenceRefs: [], purposeRevision: ledger.purposeRevision || 1, action: decision.action, createdAt: now });
|
|
719
730
|
compactWorkHistory(ledger);
|
|
720
731
|
}
|
|
732
|
+
delete managed.controllerDecisionWait;
|
|
721
733
|
managed.plan = plan; managed.decision = decision; managed.proposedAction = decision.action;
|
|
722
734
|
managed.decisionHistory ||= [];
|
|
723
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 });
|
|
@@ -758,7 +770,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
758
770
|
if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
|
|
759
771
|
const id = text(item.id, 80) || `criterion-${index + 1}`;
|
|
760
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.');
|
|
761
|
-
const check = item.check || { type: '
|
|
773
|
+
const check = item.check || { type: 'semantic' };
|
|
762
774
|
if (!['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
|
|
763
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.');
|
|
764
776
|
if (check.type === 'file_contains' && !text(check.text, 1000)) throw new Error('file_contains needs expected text.');
|
|
@@ -830,9 +842,11 @@ export function pendingAcceptance(run) {
|
|
|
830
842
|
if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
|
|
831
843
|
JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
|
|
832
844
|
check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
|
|
833
|
-
const
|
|
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))
|
|
834
847
|
.map(item => ({ id: item.id, description: item.description, check: item.check.type,
|
|
835
|
-
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') }));
|
|
836
850
|
// File predicates and a worker claim cannot waive an explicit review.
|
|
837
851
|
// The host checkpoint path supplies an independently bound result when available.
|
|
838
852
|
if (run.managed?.independentReviewRequired && !searchEnabled(run.managed) && run.managed.liveOutcomeReview?.passed !== true) pending.push({
|
|
@@ -905,10 +919,24 @@ export function liveOutcomeReviewEligible(record) {
|
|
|
905
919
|
!m.liveOutcomeReview && !m.pendingApproval && !m.nativeWait && !m.executionUncertain && !m.purpose?.nativeBoundary &&
|
|
906
920
|
!managedBudgetReason(run) && automaticContinuationAllowed(record));
|
|
907
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
|
+
}
|
|
908
934
|
export function deliveryAcceptanceContext(run) {
|
|
909
|
-
|
|
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'}.` +
|
|
910
937
|
`${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
|
|
911
|
-
` 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.`;
|
|
912
940
|
}
|
|
913
941
|
// CLI automation is the default for connected providers with executing hooks.
|
|
914
942
|
// Saved workspace overrides and revoked provider consent take precedence.
|
|
@@ -1021,6 +1049,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
1021
1049
|
managedState.taskId = previous.associationTaskId;
|
|
1022
1050
|
if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
|
|
1023
1051
|
}
|
|
1052
|
+
if (managedState && previous.controllerExecutionContext?.executionTaskId === null) managedState.controllerExecutionContext = { ...structuredClone(previous.controllerExecutionContext), executionTaskId: managedState.taskId };
|
|
1024
1053
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
1025
1054
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
1026
1055
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -1038,7 +1067,10 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
1038
1067
|
Object.assign(run, structuredClone(prior), { managed: managedState, executionCwd: run.executionCwd,
|
|
1039
1068
|
updatedAt: now, nextStep: text(nextStep), consumedRevision: prior.revision });
|
|
1040
1069
|
managedState.taskId = previous.associationTaskId || prior.id;
|
|
1041
|
-
|
|
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');
|
|
1042
1074
|
managedState.upgradedFrom = { runId: prior.id, state: prior.state, nextStep: prior.nextStep,
|
|
1043
1075
|
checkpoint: structuredClone(prior.checkpoint), at: now };
|
|
1044
1076
|
if (prior.state !== 'paused') {
|
|
@@ -1173,6 +1205,35 @@ export function queuePurposeDiagnosis(run, reason, key, now = Date.now(), recove
|
|
|
1173
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.';
|
|
1174
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.';
|
|
1175
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
|
+
|
|
1176
1237
|
export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, expectedNativeHandoff, toolEvidence = [], sourceContext, controllerEvidence, now = Date.now() }) {
|
|
1177
1238
|
if (expectedNativeHandoff && checkpoint?.kind) throw new Error('Native handoff requires an ordinary action checkpoint.');
|
|
1178
1239
|
if (checkpoint?.kind === 'action_association') return checkpointActionAssociation({ root, provider, session, cwd, checkpoint, expectedLease, now });
|
|
@@ -1207,11 +1268,27 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1207
1268
|
const remainingWork = text(checkpoint.remainingWork, 4000);
|
|
1208
1269
|
const resultDisposition = decideLoop({ completion_claim: checkpoint.status === 'completed',
|
|
1209
1270
|
remaining_work: Boolean(remainingWork), acceptance_failed: checkpoint.acceptanceMet === false });
|
|
1210
|
-
|
|
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
|
+
}
|
|
1211
1288
|
const missingNextStep = checkpoint.status === 'in_progress' && !nextStep;
|
|
1212
1289
|
if (incompleteResult || (missingNextStep && !run.managed)) {
|
|
1213
1290
|
effectiveStatus = 'in_progress';
|
|
1214
|
-
nextStep ||= resultFollowThroughInstruction();
|
|
1291
|
+
if (!run.managed) nextStep ||= resultFollowThroughInstruction();
|
|
1215
1292
|
}
|
|
1216
1293
|
if (missingNextStep && run.managed) throw new Error('A concrete nextStep is required.');
|
|
1217
1294
|
if (effectiveStatus === 'completed' && !run.managed && checkpoint.acceptanceMet !== true) throw new Error('Completion requires the requested outcome to be checked (acceptanceMet).');
|
|
@@ -1237,6 +1314,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1237
1314
|
if (performed) {
|
|
1238
1315
|
const result = checkpoint.actionResult;
|
|
1239
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.');
|
|
1240
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' };
|
|
1241
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'));
|
|
1242
1320
|
if (flexibleReasoning(run.managed) && capturedAction) actionResult.observationSource = 'controller_tool_result';
|
|
@@ -1379,6 +1457,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1379
1457
|
nextStep = run.nextStep;
|
|
1380
1458
|
}
|
|
1381
1459
|
run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
|
|
1460
|
+
run.providerRequest = null;
|
|
1382
1461
|
run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep,
|
|
1383
1462
|
blocker: unsupportedWait ? undefined : checkpoint.blocker,
|
|
1384
1463
|
...(unsupportedWait ? { reportedBlocker: checkpoint.blocker } : checkpoint.blocker === 'missing_information' ? { question: text(checkpoint.question, 1000) } : {}),
|
|
@@ -1415,6 +1494,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1415
1494
|
recordHandoffStage(request, run.managed.workHistory?.at(-1)?.result?.status === 'observed' ? 'inspected' : 'inspection_failed', now);
|
|
1416
1495
|
request.observedAt = now; request.result = run.managed.workHistory?.at(-1)?.result?.status || checkpoint.actionResult.status;
|
|
1417
1496
|
}
|
|
1497
|
+
diagnoseControllerApproval(record, now);
|
|
1418
1498
|
await continueResultCycle(record, resultEvent(record, { kind: 'checkpoint', origin: 'checkpointDelivery',
|
|
1419
1499
|
sourceId: expectedLease || `checkpoint:${run.revision}`, attemptId: expectedLease,
|
|
1420
1500
|
resultRef: `checkpoint:${run.revision}`, now }));
|
|
@@ -1672,8 +1752,8 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
1672
1752
|
if (understanding.state === 'clear') {
|
|
1673
1753
|
run.objective = understanding.objective;
|
|
1674
1754
|
run.managed.remainingWork = understanding.remaining.join(' ').slice(0, 4000);
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1755
|
+
// Keep user-grounded conditions for automatic result verification;
|
|
1756
|
+
// provider-proposed criteria remain interpretation only.
|
|
1677
1757
|
run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded')
|
|
1678
1758
|
.map(item => ({ id: item.id, description: item.description, check: item.check.type === 'file_equals'
|
|
1679
1759
|
? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
|
|
@@ -1684,6 +1764,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
1684
1764
|
recordDecision(run.managed, plan, now);
|
|
1685
1765
|
run.reason = run.managed.decision.outcome === 'wait' ? run.managed.decision.reason : 'Session goal understood; selected next action awaits the normal dispatch gate.';
|
|
1686
1766
|
if (run.managed.decision.outcome === 'wait') { run.state = 'blocked'; run.blocker = 'authorization'; escalate(run, run.reason); }
|
|
1767
|
+
diagnoseControllerApproval(record, now);
|
|
1687
1768
|
} else if (understanding.state === 'already_done') {
|
|
1688
1769
|
run.objective = understanding.objective;
|
|
1689
1770
|
run.managed.remainingWork = null;
|
|
@@ -1745,9 +1826,12 @@ export async function verifyAutomaticCriteria(record, now) {
|
|
|
1745
1826
|
evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
|
|
1746
1827
|
};
|
|
1747
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));
|
|
1748
1830
|
for (const criterion of effectiveAcceptanceCriteria(managed)) {
|
|
1749
1831
|
if (criterion.check.type === 'manual') {
|
|
1750
|
-
|
|
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];
|
|
1751
1835
|
continue;
|
|
1752
1836
|
}
|
|
1753
1837
|
if (criterion.check.type === 'semantic') continue;
|
|
@@ -1792,38 +1876,155 @@ function settleCompletionHandoffs(run, now, review) {
|
|
|
1792
1876
|
request.workspaceDigest = review.workspaceDigest;
|
|
1793
1877
|
}
|
|
1794
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
|
+
}
|
|
1795
1973
|
export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
|
|
1796
|
-
const started=Date.now(), initialNow=now;
|
|
1797
1974
|
return updateRecord(root, provider, session, async record => {
|
|
1798
1975
|
const run = record?.run;
|
|
1799
1976
|
if (!run?.managed || run.state !== 'verification_pending') throw new Error('Managed task is not awaiting acceptance verification.');
|
|
1800
1977
|
if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
1801
|
-
const criterion = run.managed.
|
|
1978
|
+
const criterion = effectiveAcceptanceCriteria(run.managed).find(item => item.id === criterionId);
|
|
1802
1979
|
if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
|
|
1803
|
-
|
|
1804
|
-
run
|
|
1805
|
-
|
|
1806
|
-
if (searchEnabled(run.managed)) {
|
|
1807
|
-
const review = run.managed.purpose.search?.review;
|
|
1808
|
-
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);
|
|
1809
|
-
now=initialNow+Math.max(0,Date.now()-started);
|
|
1810
|
-
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)) {
|
|
1811
|
-
for (const c of run.managed.acceptanceCriteria) if (c.check.type === 'semantic') delete run.managed.verified[c.id];
|
|
1812
|
-
queuePurposeVerification(run, 'Human decision recorded; recheck the changed workspace before completing.', now);
|
|
1813
|
-
}
|
|
1814
|
-
}
|
|
1815
|
-
if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
|
|
1816
|
-
await verifyAutomaticCriteria(record, now);
|
|
1817
|
-
if (!pendingAcceptance(run).length) {
|
|
1818
|
-
const barrier=purposeCompletionBarrier(run);
|
|
1819
|
-
if(barrier) queuePurposeVerification(run, 'Human decision recorded; '+barrier+' Independently reconcile the required operation before completing.', now);
|
|
1820
|
-
else {run.state = 'completed'; run.reason = run.checkpoint.summary; bindControllerCompletion(run, now); settleCompletionHandoffs(run, now, run.managed.purpose?.search?.review || {});}
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
notifyState(run, now);
|
|
1824
|
-
run.updatedAt = now; return record;
|
|
1980
|
+
await applyManualAcceptance(record, criterionId, now, !acceptanceExecutionHeld(run), root);
|
|
1981
|
+
advanceDeliveryControlRevision(run);
|
|
1982
|
+
notifyState(run, now); run.updatedAt = now; return record;
|
|
1825
1983
|
});
|
|
1826
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;
|
|
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) };
|
|
2027
|
+
}
|
|
1827
2028
|
export function prepareWaitingDiagnosis(run, reason, now) {
|
|
1828
2029
|
const managed = run.managed, scope = managed.approvalScopes[0];
|
|
1829
2030
|
if (!scope) throw new Error('Waiting recovery needs an enrolled scope.');
|
|
@@ -1893,14 +2094,25 @@ export function canExplicitlyResumeStopped(run) {
|
|
|
1893
2094
|
Boolean(run.managed.autonomyPolicy && run.managed.approvalScopes?.length) &&
|
|
1894
2095
|
!run.managed.resumeLease && !run.managed.liveWorkspaceLease;
|
|
1895
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
|
+
}
|
|
1896
2104
|
// Time spent waiting for a person is not missing work progress. An explicit
|
|
1897
2105
|
// resume, answer or approval starts a new progress window; failures remain.
|
|
1898
2106
|
function continueAfterHumanDecision(run, now) {
|
|
1899
2107
|
const stop = deliveryStop(run, now);
|
|
1900
2108
|
if ([STOP_CODES.failureLimit, 'purpose_budget'].includes(stop?.code)) throw new Error(stop.reason);
|
|
1901
|
-
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) {
|
|
1902
2114
|
run.managed.progressWindowResets = [...(run.managed.progressWindowResets || []),
|
|
1903
|
-
{ at: now, code
|
|
2115
|
+
{ at: now, code, noProgress: run.managed.noProgress ?? 0 }].slice(-10);
|
|
1904
2116
|
run.managed.noProgress = 0;
|
|
1905
2117
|
run.managed.outcomeProgress = restartOutcomeProgressWindow(run.managed.outcomeProgress);
|
|
1906
2118
|
run.managed.lastVerifiedProgressAt = now;
|
|
@@ -1940,13 +2152,16 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
|
|
|
1940
2152
|
managed.progressCheckpointRevision = run.revision;
|
|
1941
2153
|
}
|
|
1942
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);
|
|
1943
2156
|
export function automaticContinuationAllowed(record) {
|
|
2157
|
+
if (ownerRenewalPending(record) || record?.run?.managed?.ownerRenewal?.needsReadOnlyReassessment === true) return false;
|
|
1944
2158
|
if (record?.providerArchived === true || record?.run?.managed?.purpose && record.autoContinue === false) return false;
|
|
1945
2159
|
const epoch = record?.run?.managed?.autoContinueEpoch;
|
|
1946
2160
|
return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
|
|
1947
2161
|
}
|
|
1948
2162
|
|
|
1949
2163
|
export function decideStop(run, now = Date.now()) {
|
|
2164
|
+
if (ownerRenewalPending({ run })) return { run, output: {} };
|
|
1950
2165
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
1951
2166
|
const next = structuredClone(run);
|
|
1952
2167
|
const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
|
|
@@ -1988,7 +2203,9 @@ export function decideStop(run, now = Date.now()) {
|
|
|
1988
2203
|
// Keep prior failures until actual fresh work succeeds or another attempt fails.
|
|
1989
2204
|
if (next.managed?.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook) {
|
|
1990
2205
|
if (!fresh || cp.status === 'error') next.failures += 1;
|
|
1991
|
-
|
|
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;
|
|
1992
2209
|
}
|
|
1993
2210
|
if (failureLimitReached(next)) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
|
|
1994
2211
|
if (next.managed) {
|
|
@@ -2073,6 +2290,7 @@ export async function processIdentity(pid) {
|
|
|
2073
2290
|
// of turn/permission events. Permission and dispatch decisions remain in the loop.
|
|
2074
2291
|
export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
|
|
2075
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.');
|
|
2076
2294
|
if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
|
|
2077
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.');
|
|
2078
2296
|
if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
|
|
@@ -2123,6 +2341,81 @@ export function canEmitHookContinuation(root, observed) {
|
|
|
2123
2341
|
!['paused', 'blocked', 'recovery_required', 'verification_pending'].includes(latest.run.state) &&
|
|
2124
2342
|
automaticContinuationAllowed(latest) && !hasPendingUserStop(root, latest));
|
|
2125
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
|
+
}
|
|
2126
2419
|
export async function handleHook(options) {
|
|
2127
2420
|
const root = options.root || rootDirectory(), event = options.input?.hook_event_name;
|
|
2128
2421
|
if (!EVENTS.has(event) || !text(options.input?.session_id) || !text(options.input?.cwd)) return {};
|
|
@@ -2138,10 +2431,19 @@ export async function handleHook(options) {
|
|
|
2138
2431
|
if (record?.run) atomicSave(stopMarkerPath(root, options.provider, options.input.session_id), { schemaVersion: 1, id: randomUUID(), runId: record.run.id });
|
|
2139
2432
|
}
|
|
2140
2433
|
// Stop/permission lifecycle must never wait on the update dispatch lock.
|
|
2141
|
-
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
|
+
}
|
|
2142
2443
|
try {
|
|
2143
2444
|
const observedProcess = options.observedProcess === undefined ? await providerProcess(options.provider) : options.observedProcess;
|
|
2144
2445
|
options = { ...options, observedProcess };
|
|
2446
|
+
if (event === 'Stop') await settleOwnedContinuation(root, options);
|
|
2145
2447
|
const owner = captureHookOwner(rootFromDelivery(root), options.provider, options.input?.session_id, observedProcess);
|
|
2146
2448
|
let current = await read(recordPath(root, options.provider, options.input.session_id)), pendingOutput;
|
|
2147
2449
|
return await fenceHook(owner, `${options.provider}:${options.input?.session_id}`, async suppressDispatch => {
|
|
@@ -2151,7 +2453,10 @@ export async function handleHook(options) {
|
|
|
2151
2453
|
return hasPendingUserStop(root, current) ? {} : output;
|
|
2152
2454
|
}, options.emit, () => pendingOutput?.decision !== 'block' || canEmitHookContinuation(root, current), () => {
|
|
2153
2455
|
const taskId = current?.run?.managed?.taskId || current?.run?.id;
|
|
2154
|
-
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;
|
|
2155
2460
|
});
|
|
2156
2461
|
} catch (error) {
|
|
2157
2462
|
if (error.code !== 'state_busy') throw error;
|
|
@@ -2191,6 +2496,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2191
2496
|
}
|
|
2192
2497
|
}
|
|
2193
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 });
|
|
2194
2501
|
if (provider === 'claude' && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event) &&
|
|
2195
2502
|
(!identity || owner && (identity.pid !== owner.pid || identity.identity !== owner.identity))) {
|
|
2196
2503
|
// An unverified lifecycle event invalidates prior proof without adopting
|
|
@@ -2329,6 +2636,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2329
2636
|
else if (run.state !== 'paused' && (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type))) {
|
|
2330
2637
|
// A native permission wait is still authoritative while independent
|
|
2331
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 };
|
|
2332
2641
|
run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
|
|
2333
2642
|
}
|
|
2334
2643
|
else if (['paused', 'verification_pending'].includes(run.state) || run.state === 'blocked' &&
|
|
@@ -2503,10 +2812,13 @@ export function deliveryDecisionRevision(run) {
|
|
|
2503
2812
|
const revision = run.revision + controls;
|
|
2504
2813
|
return Number.isSafeInteger(revision) ? revision : undefined;
|
|
2505
2814
|
}
|
|
2506
|
-
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, resumeObservation, action, inspectProcess = processIdentity, now = Date.now() }) {
|
|
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() }) {
|
|
2507
2816
|
const updated = await updateRecord(root, provider, session, async record => {
|
|
2817
|
+
if (authorize) await authorize();
|
|
2508
2818
|
const run = record?.run;
|
|
2509
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' });
|
|
2510
2822
|
if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
|
|
2511
2823
|
(typeof expectedRunId !== 'string' || !expectedRunId || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 ||
|
|
2512
2824
|
run.id !== expectedRunId || deliveryDecisionRevision(run) !== expectedRevision)) throw new Error('This decision changed. Refresh before choosing.');
|
|
@@ -2521,7 +2833,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2521
2833
|
}
|
|
2522
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.');
|
|
2523
2835
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
2524
|
-
const commitControl = () => {
|
|
2836
|
+
const commitControl = async () => {
|
|
2837
|
+
if (authorize) await authorize();
|
|
2525
2838
|
advanceDeliveryControlRevision(run);
|
|
2526
2839
|
return record;
|
|
2527
2840
|
};
|
|
@@ -2608,6 +2921,12 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2608
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; }
|
|
2609
2922
|
}
|
|
2610
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
|
+
}
|
|
2611
2930
|
if (TERMINAL.has(run.state)) return record;
|
|
2612
2931
|
run.stopReason = { actor: 'user', code: 'explicit_stop', at: now };
|
|
2613
2932
|
run.state = 'stopped'; run.reason = 'Stopped by user.';
|
|
@@ -2655,12 +2974,14 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2655
2974
|
}
|
|
2656
2975
|
}
|
|
2657
2976
|
else if (action === 'resume') {
|
|
2977
|
+
run.providerRequest = null;
|
|
2658
2978
|
const marker = readState(stopMarkerPath(root, provider, session));
|
|
2659
2979
|
if (marker?.runId === run.id) run.handledStopMarkerId = marker.id;
|
|
2660
2980
|
const wasStopped = canExplicitlyResumeStopped(run);
|
|
2981
|
+
const wasVerifying = canResumePendingVerification(run);
|
|
2661
2982
|
if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
|
|
2662
2983
|
if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
|
|
2663
|
-
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.');
|
|
2664
2985
|
if (run.managed && run.state === 'blocked' && run.blocker === 'missing_information') {
|
|
2665
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);
|
|
2666
2987
|
run.blocker = null;
|
|
@@ -2668,6 +2989,16 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2668
2989
|
if (run.managed?.executionUncertain) { prepareRecoveryDiagnosis(run, now); run.blocker = null; }
|
|
2669
2990
|
if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
|
|
2670
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
|
+
}
|
|
2671
3002
|
if (wasStopped) {
|
|
2672
3003
|
const link = await providerLinkState(root, provider);
|
|
2673
3004
|
if (!link.connected) throw new Error('Restore the existing provider connection before resuming.');
|
|
@@ -2704,6 +3035,13 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
2704
3035
|
// connector permissions or the frozen enrollment authority here.
|
|
2705
3036
|
}
|
|
2706
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
|
+
}
|
|
2707
3045
|
continueAfterHumanDecision(run, now);
|
|
2708
3046
|
delete run.stopReason;
|
|
2709
3047
|
if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
|