@awak-app/simy-cli 0.5.34 → 0.5.35

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.
@@ -1,19 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // Standalone by design: this exact module is shipped to both provider plugins.
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { mkdir, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { join } from 'node:path';
6
+ import { isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
 
10
- export const TERMINAL = new Set(['completed', 'stopped', 'failed', 'budget_exhausted']);
10
+ export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
11
11
  const EVENTS = new Set(['SessionStart', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
12
12
  const MAX_BYTES = 64 * 1024;
13
13
  const RETRYABLE = new Set(['overloaded', 'server_error', 'rate_limit']);
14
+ const APPROVAL_DECISIONS = new Set(['automatic', 'proceed_and_report', 'wait']);
15
+ const LOCAL_ACTIONS = new Set(['read', 'local_edit', 'local_test']);
16
+ const ACTIONS = new Set([...LOCAL_ACTIONS, 'external', 'destructive', 'scope_change', 'unknown']);
17
+ const AUTONOMY_POLICIES = new Set(['strict', 'balanced', 'autonomous']);
18
+ const ASSESSMENT_FIELDS = { 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'] };
14
19
  export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
15
20
  const digest = value => createHash('sha256').update(value).digest('hex');
16
21
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
22
+ const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
23
+ const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
17
24
  export function recordPath(root, provider, session) {
18
25
  if (!['codex', 'claude'].includes(provider) || typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(session)) throw new Error('A provider and exact session ID are required.');
19
26
  return join(root, `${digest(`${provider}\0${session}`)}.json`);
@@ -32,8 +39,18 @@ export async function updateRecord(root, provider, session, mutate) {
32
39
  try {
33
40
  const next = await mutate(await read(file));
34
41
  if (next) {
42
+ // Older file_equals drafts used `path`, which an older verifier could
43
+ // mistake for file_exists. Persist only the distinct exactPath field.
44
+ for (const criterion of next.run?.managed?.acceptanceCriteria || []) {
45
+ if (criterion.check?.type === 'file_equals' && criterion.check.path) {
46
+ criterion.check.exactPath = criterion.check.path;
47
+ delete criterion.check.path;
48
+ }
49
+ }
35
50
  temporary = `${file}.${randomUUID()}.tmp`;
36
- await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
51
+ const serialized = JSON.stringify(next);
52
+ if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 64 KiB; reduce task details.');
53
+ await writeFile(temporary, serialized, { mode: 0o600 });
37
54
  await rename(temporary, file);
38
55
  }
39
56
  return next;
@@ -51,53 +68,504 @@ function positive(value, fallback, max) {
51
68
  if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid delivery budget.');
52
69
  return value;
53
70
  }
54
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, now = Date.now() }) {
71
+ function actionDescriptor(value) {
72
+ if (!value || !ACTIONS.has(value.kind) || !text(value.scope, 200) || !text(value.summary, 1000)) throw new Error('Managed continuation requires a concrete proposedAction with kind, scope, and summary.');
73
+ return { kind: value.kind, scope: text(value.scope, 200), summary: text(value.summary, 1000) };
74
+ }
75
+ function assessment(value) {
76
+ const result = {};
77
+ for (const [field, allowed] of Object.entries(ASSESSMENT_FIELDS)) result[field] = allowed.includes(value?.[field]) ? value[field] : 'unknown';
78
+ result.inObjective = value?.inObjective === true ? true : value?.inObjective === false ? false : null;
79
+ result.userChoice = value?.userChoice === true ? true : value?.userChoice === false ? false : null;
80
+ return result;
81
+ }
82
+ function assessedPlan(value) {
83
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !text(value.diagnosis, 2000) || !Array.isArray(value.alternatives) || !value.alternatives.length || value.alternatives.length > 5) throw new Error('An assessed plan needs a diagnosis and one to five alternatives.');
84
+ const alternatives = value.alternatives.map(option => {
85
+ const id = text(option?.id, 80);
86
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) throw new Error('Invalid plan option ID.');
87
+ return { id, action: actionDescriptor(option.action), assessment: assessment(option.assessment), expectedVerification: text(option.expectedVerification, 1000), rollback: text(option.rollback, 1000) };
88
+ });
89
+ if (new Set(alternatives.map(option => option.id)).size !== alternatives.length) throw new Error('Duplicate plan option ID.');
90
+ const selectedOptionId = text(value.selectedOptionId, 80);
91
+ if (!alternatives.some(option => option.id === selectedOptionId)) throw new Error('Selected action must match an assessed option.');
92
+ const recommendation = { optionId: text(value.recommendation?.optionId, 80), reason: text(value.recommendation?.reason, 1000) };
93
+ if (!alternatives.some(option => option.id === recommendation.optionId) || !recommendation.reason) throw new Error('Plan recommendation must identify an assessed option and reason.');
94
+ return { diagnosis: text(value.diagnosis, 2000), alternatives, selectedOptionId, recommendation, userQuestion: text(value.userQuestion, 1000) || null };
95
+ }
96
+ function missingAcceptanceDiagnosis(managed, missing) {
97
+ const names = missing.map(item => item.id).join(', ');
98
+ const scope = managed.decision.action.scope;
99
+ const action = { kind: 'read', scope, summary: `Inspect missing acceptance checks: ${names}` };
100
+ return assessedPlan({ diagnosis: `Worker claimed completion, but independent file checks still fail: ${names}. Diagnose the discrepancy before any repair.`,
101
+ alternatives: [{ id: 'inspect_missing_acceptance', action,
102
+ assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
103
+ expectedVerification: `Report observed file state for ${names}`, rollback: 'No changes to undo' }],
104
+ selectedOptionId: 'inspect_missing_acceptance', recommendation: { optionId: 'inspect_missing_acceptance', reason: 'Independent checks have not verified completion.' } });
105
+ }
106
+ function rejectedActionDiagnosis(managed, rejected) {
107
+ const scope = managed.approvalScopes[0];
108
+ if (!scope) throw new Error('Replanning needs an enrolled task scope.');
109
+ const action = { kind: 'read', scope, summary: `Review the rejected ${rejected.kind} proposal and prior checkpoint; propose a different assessed action without executing it` };
110
+ return assessedPlan({ diagnosis: `The user rejected ${rejected.kind} in scope ${rejected.scope}: ${rejected.summary}. Inspect only the recorded task state and propose an alternative. Do not execute the rejected action.`,
111
+ alternatives: [{ id: 'diagnose_rejected_action', action,
112
+ assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
113
+ expectedVerification: 'Report the rejection reason and a concrete, different assessed next option.', rollback: 'No changes to undo.' }],
114
+ selectedOptionId: 'diagnose_rejected_action', recommendation: { optionId: 'diagnose_rejected_action', reason: 'Read the recorded rejection before choosing different work.' } });
115
+ }
116
+ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now()) {
117
+ const option = plan.alternatives.find(item => item.id === plan.selectedOptionId);
118
+ const { action } = option;
119
+ const facts = assessment(option.assessment);
120
+ let reason = null;
121
+ if (!scopes.includes(action.scope)) reason = 'Action scope is outside the task enrollment.';
122
+ else if (facts.inObjective !== true || facts.userChoice !== false) reason = 'Objective fit or user choice needs review.';
123
+ else if (Object.values(facts).includes('unknown') || facts.inObjective === null || facts.userChoice === null) reason = 'Action assessment is incomplete.';
124
+ else if (!option.expectedVerification) reason = 'Expected verification is missing.';
125
+ else if (!LOCAL_ACTIONS.has(action.kind) || facts.externalEffect === 'external' || facts.reversibility === 'irreversible' || facts.blastRadius === 'multi_system' || facts.risk === 'high' || facts.uncertainty === 'high') reason = 'Action has external, irreversible, broad, or high-uncertainty effects.';
126
+ else if (action.kind === 'local_edit' && (!option.rollback || facts.reversibility !== 'reversible' || facts.externalEffect !== 'local')) reason = 'Local edit needs a concrete rollback and local reversible effects.';
127
+ else if (action.kind !== 'local_edit' && facts.externalEffect !== 'none') reason = 'Read or test action has unexpected side effects.';
128
+ else if (policy === 'strict' && (action.kind === 'local_edit' || facts.risk !== 'low' || facts.uncertainty !== 'low')) reason = 'Strict policy requires low-risk read or local test.';
129
+ else if (policy === 'balanced' && (facts.risk !== 'low' || facts.uncertainty !== 'low' || action.kind === 'local_edit' && facts.blastRadius !== 'single_file')) reason = 'Balanced policy requires a low-risk, narrow action.';
130
+ else if (policy === 'autonomous' && (facts.risk === 'medium' && facts.blastRadius !== 'repository' && facts.blastRadius !== 'single_file')) reason = 'Medium-risk action exceeds the repository boundary.';
131
+ return { id: digest(JSON.stringify({ plan, policy })), outcome: reason ? 'wait' : policy === 'autonomous' && facts.risk === 'medium' ? 'report' : 'proceed', reason: reason || 'Selected action fits the enrolled objective, scope, and policy.', selectedOptionId: option.id, action, assessment: facts, expectedVerification: option.expectedVerification, rollback: option.rollback, policy, at: now };
132
+ }
133
+ // Shared by live Stop hooks and cold exact-session recovery. Keep the result
134
+ // contract identical across transports; the host still verifies acceptance.
135
+ export function assessedCheckpointContract(decision) {
136
+ return `Return ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question.`;
137
+ }
138
+ export function understandingCheckpointContract(decision) {
139
+ return `This is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; do not edit files, call external services, or start the proposed next action. Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. For semantic conditions use manual. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification.`;
140
+ }
141
+ function boundedContextList(value, maxItems = 8, maxLength = 300) {
142
+ if (!Array.isArray(value) || value.length > maxItems) throw new Error('Session understanding has too many items.');
143
+ return value.map(item => contextText(item, maxLength)).filter(Boolean);
144
+ }
145
+ function sessionUnderstanding(value) {
146
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !['clear', 'ambiguous', 'context_missing', 'already_done'].includes(value.state)) throw new Error('A structured session understanding is required.');
147
+ const basis = Array.isArray(value.basis) && value.basis.length <= 6 ? value.basis.map(item => {
148
+ if (!['user', 'assistant', 'artifact'].includes(item?.source)) throw new Error('Understanding basis needs a source.');
149
+ const turnRef = contextText(item.turnRef, 100) || null;
150
+ const snippet = contextText(item.snippet, 240) || null;
151
+ if (!turnRef && !snippet) throw new Error('Understanding basis needs a reference or excerpt.');
152
+ return { source: item.source, turnRef, snippet };
153
+ }) : [];
154
+ if (!basis.length && value.state !== 'context_missing') throw new Error('Understanding needs bounded context evidence.');
155
+ const criteria = Array.isArray(value.criteria) && value.criteria.length <= 10 ? value.criteria.map((item, index) => {
156
+ const description = contextText(item?.description, 300);
157
+ if (!description || !['user_grounded', 'proposed'].includes(item?.source)) throw new Error('Understanding criterion needs a description and provenance.');
158
+ const basisIndex = Number.isSafeInteger(item.basisIndex) && item.basisIndex >= 0 && item.basisIndex < basis.length ? item.basisIndex : null;
159
+ if (item.source === 'user_grounded' && (basisIndex === null || basis[basisIndex].source !== 'user')) throw new Error('User-grounded criterion needs a user-message basis.');
160
+ const check = item.check?.type || 'manual';
161
+ if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check)) throw new Error('Unsupported inferred acceptance check.');
162
+ if (check !== 'manual' && (!text(item.check.path, 500) || isAbsolute(item.check.path) || item.check.path.split(/[\\/]/).includes('..'))) throw new Error('Inferred file check needs a relative task path.');
163
+ if (check === 'file_contains' && !text(item.check.text, 1000)) throw new Error('Inferred file check needs expected text.');
164
+ if (check === 'file_equals' && exactText(item.check.text) === null) throw new Error('Inferred file_equals needs bounded exact UTF-8 text.');
165
+ return { id: `inferred-${index + 1}`, description, source: item.source, basisIndex,
166
+ check: check === 'manual' ? { type: 'manual' } : { type: check, path: text(item.check.path, 500), ...(check === 'file_contains' ? { text: contextText(item.check.text, 1000) } : {}), ...(check === 'file_equals' ? { text: exactText(item.check.text) } : {}) } };
167
+ }) : [];
168
+ const result = { state: value.state, objective: contextText(value.objective, 1000), constraints: boundedContextList(value.constraints),
169
+ done: boundedContextList(value.done), remaining: boundedContextList(value.remaining), nextAction: value.nextAction ? actionDescriptor(value.nextAction) : null,
170
+ criteria, ambiguities: boundedContextList(value.ambiguities, 3), question: contextText(value.question, 500) || null, basis };
171
+ 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.');
172
+ if (result.state === 'already_done' && (!result.objective || result.remaining.length || result.nextAction)) throw new Error('Already-finished understanding must have no remaining action.');
173
+ if (['clear', 'already_done'].includes(result.state) && !basis.some(item => item.source === 'user')) throw new Error('Inferred session goal needs a user-message basis.');
174
+ if (['ambiguous', 'context_missing'].includes(result.state) && (!result.question || result.ambiguities.length !== 1)) throw new Error('Unclear understanding needs one focused question.');
175
+ return result;
176
+ }
177
+ function recordDecision(managed, plan, now) {
178
+ const decision = evaluateAssessedPlan(plan, managed.autonomyPolicy, managed.approvalScopes, now);
179
+ if (managed.rejectedActionOverflow || managed.rejectedActionDigests?.includes(digest(JSON.stringify(decision.action)))) {
180
+ decision.outcome = 'wait';
181
+ decision.reason = 'A previously rejected action requires a fresh exact user decision.';
182
+ }
183
+ managed.decisionGeneration = (managed.decisionGeneration || 0) + 1;
184
+ decision.id = digest(`${managed.taskId}:${managed.decisionGeneration}:${decision.id}`);
185
+ managed.plan = plan; managed.decision = decision; managed.proposedAction = decision.action;
186
+ managed.decisionHistory ||= [];
187
+ 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 });
188
+ managed.decisionHistory = managed.decisionHistory.slice(-10);
189
+ managed.pendingApproval = decision.outcome === 'wait' ? { id: decision.id, ...decision.action, reason: decision.reason } : null;
190
+ return decision;
191
+ }
192
+ function rememberRejectedAction(managed, pending) {
193
+ if (!managed.autonomyPolicy) return;
194
+ managed.rejectedActionDigests ||= [];
195
+ const rejectedDigest = digest(JSON.stringify({ kind: pending.kind, scope: pending.scope, summary: pending.summary }));
196
+ if (!managed.rejectedActionDigests.includes(rejectedDigest)) {
197
+ if (managed.rejectedActionDigests.length < 50) managed.rejectedActionDigests.push(rejectedDigest);
198
+ else managed.rejectedActionOverflow = true;
199
+ }
200
+ const history = managed.decisionHistory?.findLast(item => item.id === pending.id);
201
+ if (history) history.userDecision = 'rejected';
202
+ }
203
+ function escalate(run, cause) {
204
+ if (!run.managed?.autonomyPolicy) return;
205
+ const managed = run.managed;
206
+ const history = (managed.workHistory || []).slice(-5);
207
+ managed.escalation = { cause, tried: history.map(item => ({ action: item.action?.summary, result: item.status, reason: item.summary })), results: history.map(item => ({ action: item.action?.summary, result: item.status, reason: item.verification })), remainingWork: text(managed.remainingWork || run.nextStep, 1000), userQuestion: managed.userQuestion || managed.plan?.userQuestion || 'Which action should be authorized or revised?', options: (managed.plan?.alternatives || []).map(option => ({ id: option.id, action: { kind: option.action.kind, scope: option.action.scope, summary: text(option.action.summary, 200) } })), recommendation: managed.plan?.recommendation || null };
208
+ }
209
+ export function notifyState(run, now) {
210
+ if (!run?.managed || !['blocked', 'verification_pending', 'recovery_required', 'failed', 'budget_exhausted', 'completed', 'reported_done'].includes(run.state)) return;
211
+ const key = `${run.state}:${run.revision}:${run.reason}`;
212
+ if (run.managed.notificationKey === key) return;
213
+ run.managed.notificationKey = key;
214
+ run.managed.notification = { key, kind: run.state, message: run.reason, at: now };
215
+ }
216
+ function managedSpec(spec, session, provider, observedProcess, admission) {
217
+ if (!spec) return null;
218
+ if (spec !== true && (typeof spec !== 'object' || Array.isArray(spec))) throw new Error('Invalid managed task specification.');
219
+ const config = spec === true ? {} : spec;
220
+ if (!Array.isArray(config.acceptanceCriteria) || (!config.acceptanceCriteria.length && config.contextBootstrap !== true) || config.acceptanceCriteria.length > 20) throw new Error('Managed tasks require acceptanceCriteria.');
221
+ const criteria = config.acceptanceCriteria.map((item, index) => {
222
+ if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
223
+ const id = text(item.id, 80) || `criterion-${index + 1}`;
224
+ if (!/^[A-Za-z0-9_-]+$/.test(id) || ['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
225
+ const check = item.check || { type: 'manual' };
226
+ if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
227
+ if (check.type !== 'manual' && (!text(check.path, 500) || isAbsolute(check.path) || check.path.split(/[\\/]/).includes('..'))) throw new Error('Acceptance file must be relative to the task directory.');
228
+ if (check.type === 'file_contains' && !text(check.text, 1000)) throw new Error('file_contains needs expected text.');
229
+ if (check.type === 'file_equals' && exactText(check.text) === null) throw new Error('file_equals needs bounded exact UTF-8 text.');
230
+ return { id, description: text(item.description, 500), check: { type: check.type, ...(check.type === 'file_equals' ? { exactPath: check.path } : check.type === 'manual' ? {} : { path: check.path }), ...(check.type === 'file_contains' ? { text: text(check.text, 1000) } : {}), ...(check.type === 'file_equals' ? { text: exactText(check.text) } : {}) } };
231
+ });
232
+ if (new Set(criteria.map(item => item.id)).size !== criteria.length) throw new Error('Duplicate acceptance criterion ID.');
233
+ const approvalPolicy = config.approvalPolicy || 'wait';
234
+ if (!APPROVAL_DECISIONS.has(approvalPolicy)) throw new Error('Invalid approval policy.');
235
+ const scopes = Array.isArray(config.approvalScopes) ? config.approvalScopes.map(value => text(value, 200)).filter(Boolean) : [];
236
+ const initialAction = actionDescriptor(config.initialAction);
237
+ const autonomyPolicy = config.autonomyPolicy === undefined ? null : config.autonomyPolicy;
238
+ if (autonomyPolicy !== null && !AUTONOMY_POLICIES.has(autonomyPolicy)) throw new Error('Invalid autonomy policy.');
239
+ const initialPlan = autonomyPolicy ? assessedPlan(config.initialPlan) : null;
240
+ if (initialPlan && JSON.stringify(initialPlan.alternatives.find(item => item.id === initialPlan.selectedOptionId).action) !== JSON.stringify(initialAction)) throw new Error('Initial action must be the selected assessed option.');
241
+ if (config.contextBootstrap === true && (!initialPlan || initialAction.kind !== 'read' || initialAction.scope !== 'task' || initialPlan.alternatives.length !== 1 || evaluateAssessedPlan(initialPlan, autonomyPolicy, ['task']).outcome !== 'proceed')) throw new Error('Session understanding requires an assessed read-only initial action.');
242
+ const actionDueAt = config.actionDueAt === undefined ? null : typeof config.actionDueAt === 'string' ? Date.parse(config.actionDueAt) : NaN;
243
+ if (actionDueAt !== null && !Number.isFinite(actionDueAt)) throw new Error('Invalid actionDueAt timestamp.');
244
+ if (config.autoRecoverySandbox && !['read-only', 'workspace-write'].includes(config.autoRecoverySandbox)) throw new Error('Invalid autoRecoverySandbox.');
245
+ const resumeModel = text(config.resumeModel, 80) || null;
246
+ if (resumeModel && !/^[A-Za-z0-9][A-Za-z0-9._-]{1,79}$/.test(resumeModel)) throw new Error('Invalid resumeModel.');
247
+ const exactCodexSession = provider === 'codex' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session);
248
+ const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
249
+ const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
250
+ (appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
251
+ const managed = { taskId: randomUUID(), thread: { provider, id: session },
252
+ actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
253
+ initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
254
+ grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
255
+ noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
256
+ autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
257
+ autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
258
+ capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
259
+ awaitingInitialHook: provider === 'claude' && Boolean(observedProcess?.identity) && Boolean(autonomyPolicy),
260
+ autonomyPolicy, plan: null, decision: null, decisionHistory: [], workHistory: [], escalation: null,
261
+ ...(config.contextBootstrap === true ? { contextBootstrap: true, phase: 'understanding', sessionUnderstanding: null, understandingRevision: 0 } : {}) };
262
+ if (initialPlan) recordDecision(managed, initialPlan, Date.now());
263
+ return managed;
264
+ }
265
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
266
+ liveHookAdmission, inspectProcess = processIdentity, now = Date.now() }) {
55
267
  cwd = await realpath(cwd);
56
268
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
57
- return updateRecord(root, provider, session, previous => {
58
- if (!previous?.observedAt || previous.cwd !== cwd) throw new Error('No matching hook session observed here. Enable SIMY hooks, then start a new turn and use the session ID from hook context.');
269
+ return updateRecord(root, provider, session, async previous => {
270
+ const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === cwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.cwd === cwd;
271
+ if (liveHookAdmission) {
272
+ const process = previous?.process;
273
+ if (provider !== 'claude' || previous?.hookStartedAt !== liveHookAdmission.hookStartedAt ||
274
+ !(['SessionStart', 'Stop'].includes(previous.lastEvent) || previous.lastEvent === 'Notification' && previous.lastNotificationType === 'idle_prompt') || process?.pid !== liveHookAdmission.pid ||
275
+ process?.identity !== liveHookAdmission.identity || !process.identity ||
276
+ await inspectProcess(process.pid) !== process.identity) throw new Error('Claude live hook changed before enrollment; refresh the session.');
277
+ }
278
+ if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
59
279
  if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
60
- return { ...previous, run: { id: randomUUID(), objective: text(objective), state: 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
280
+ const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null);
281
+ if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
282
+ identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
283
+ const waiting = managedState?.decision?.outcome === 'wait';
284
+ const queued = managedState?.capability === 'codex_app_server';
285
+ const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
61
286
  deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
62
- continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: 'started' } };
287
+ continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
288
+ ...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
289
+ if (waiting) escalate(run, run.reason);
290
+ return { ...previous, run };
63
291
  });
64
292
  }
65
- export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, now = Date.now() }) {
293
+ export async function checkpointDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), checkpoint, expectedLease, now = Date.now() }) {
294
+ if (checkpoint?.kind === 'session_understanding') return checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, now });
66
295
  try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
67
296
  const statuses = ['in_progress', 'completed', 'blocked', 'error'];
68
297
  if (!checkpoint || !statuses.includes(checkpoint.status)) throw new Error('Checkpoint status must be in_progress, completed, blocked, or error.');
69
298
  if (!text(checkpoint.summary)) throw new Error('Checkpoint summary is required.');
70
299
  if (['in_progress', 'error'].includes(checkpoint.status) && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
71
- if (checkpoint.status === 'blocked' && !['authorization', 'missing_information'].includes(checkpoint.blocker)) throw new Error('Specify authorization or missing_information.');
300
+ if (checkpoint.status === 'blocked' && !['authorization', 'missing_information', 'external_dependency'].includes(checkpoint.blocker)) throw new Error('Specify authorization, missing_information, or external_dependency.');
72
301
  const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
73
- if (checkpoint.status === 'completed' && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
74
- return updateRecord(root, provider, session, record => {
302
+ return updateRecord(root, provider, session, async record => {
75
303
  const run = record?.run;
304
+ let effectiveStatus = checkpoint.status;
305
+ let nextStep = text(checkpoint.nextStep);
306
+ let repairPlan = null;
76
307
  if (!run || record.cwd !== cwd) throw new Error('No matching active Delivery Loop.');
308
+ if (run.managed?.phase === 'understanding' || run.managed?.phase === 'needs_clarification') throw new Error('Session understanding needs its separate structured checkpoint.');
309
+ if (checkpoint.status === 'completed' && !run.managed && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
310
+ if (expectedLease && (run.state !== 'auto_resuming' || run.managed?.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Managed recovery lease changed; checkpoint discarded.');
77
311
  if (TERMINAL.has(run.state) || run.state === 'blocked' || run.state === 'recovery_required') throw new Error('Loop is stopped or awaiting a human resume.');
78
- run.checkpoint = { status: checkpoint.status, summary: text(checkpoint.summary), nextStep: text(checkpoint.nextStep), blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
312
+ if (['paused', 'verification_pending'].includes(run.state)) throw new Error('Managed task is paused or awaiting acceptance verification.');
313
+ if (run.managed) {
314
+ const performed = run.managed.autonomyPolicy ? structuredClone(run.managed.decision) : null;
315
+ let actionResult = null;
316
+ if (performed) {
317
+ const result = checkpoint.actionResult;
318
+ 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.');
319
+ 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' };
320
+ if (result.status === 'observed' && !actionResult.evidence.length) throw new Error('Observed action result needs concrete evidence.');
321
+ }
322
+ const previouslyVerified = Object.keys(run.managed.verified).length;
323
+ run.managed.remainingWork = text(checkpoint.remainingWork, 4000) || run.managed.remainingWork;
324
+ await verifyAutomaticCriteria(record, now);
325
+ if (run.managed.autonomyPolicy && checkpoint.status === 'completed') {
326
+ const missing = run.managed.acceptanceCriteria.filter(item => item.check.type !== 'manual' && !Object.hasOwn(run.managed.verified, item.id));
327
+ if (missing.length) {
328
+ effectiveStatus = 'in_progress';
329
+ repairPlan = checkpoint.plan && nextStep ? assessedPlan(checkpoint.plan) : missingAcceptanceDiagnosis(run.managed, missing);
330
+ nextStep ||= `Diagnose failed acceptance checks: ${missing.map(item => item.id).join(', ')}`;
331
+ }
332
+ }
333
+ if (performed) {
334
+ run.managed.workHistory ||= [];
335
+ run.managed.workHistory.push({ decisionId: performed.id, action: performed.action, status: checkpoint.status, summary: text(checkpoint.summary, 300), result: actionResult, verification: `${Math.max(0, Object.keys(run.managed.verified).length - previouslyVerified)} new acceptance criteria verified`, at: now });
336
+ run.managed.workHistory = run.managed.workHistory.slice(-10);
337
+ if (performed.outcome === 'report') run.managed.notification = { key: `report:${run.revision + 1}`, kind: 'action_report', message: `Attempted ${performed.action.summary}: ${text(checkpoint.summary, 300)}. ${run.managed.workHistory.at(-1).verification}.`, at: now };
338
+ if (run.managed.approvedAction?.id === performed.id) delete run.managed.approvedAction;
339
+ }
340
+ if (['in_progress', 'error'].includes(effectiveStatus)) {
341
+ if (run.managed.autonomyPolicy) {
342
+ const plan = repairPlan || assessedPlan(checkpoint.plan);
343
+ const selected = plan.alternatives.find(item => item.id === plan.selectedOptionId);
344
+ if (checkpoint.proposedAction && JSON.stringify(actionDescriptor(checkpoint.proposedAction)) !== JSON.stringify(selected.action)) throw new Error('Proposed action differs from selected assessed option.');
345
+ recordDecision(run.managed, plan, now);
346
+ if (actionResult.status === 'not_run' || actionResult.status === 'failed' && run.managed.decision.action.kind !== 'read') {
347
+ run.managed.decision.outcome = 'wait'; run.managed.decision.reason = actionResult.status === 'not_run' ? 'Previous selected action was not run.' : 'Failed action requires a read-only diagnosis before another change.';
348
+ run.managed.pendingApproval = { id: run.managed.decision.id, ...run.managed.decision.action, reason: run.managed.decision.reason };
349
+ }
350
+ } else run.managed.proposedAction = actionDescriptor(checkpoint.proposedAction);
351
+ }
352
+ if (checkpoint.status === 'blocked' && checkpoint.blocker === 'authorization') {
353
+ const request = checkpoint.approval;
354
+ if (!text(checkpoint.nextStep)) throw new Error('Authorization request requires the proposed nextStep.');
355
+ if (!text(request?.scope, 200) || !text(request?.summary, 1000)) throw new Error('Authorization blocker requires a scoped approval request.');
356
+ const proposed = actionDescriptor({ kind: request.kind || 'unknown', scope: request.scope, summary: request.summary });
357
+ if (run.managed.autonomyPolicy) {
358
+ const plan = assessedPlan(checkpoint.plan);
359
+ if (JSON.stringify(plan.alternatives.find(item => item.id === plan.selectedOptionId).action) !== JSON.stringify(proposed)) throw new Error('Authorization request must match the selected assessed option.');
360
+ const decision = recordDecision(run.managed, plan, now);
361
+ decision.outcome = 'wait'; decision.reason = 'Provider authorization requires an exact user decision.';
362
+ run.managed.pendingApproval = { id: decision.id, ...proposed, reason: decision.reason };
363
+ } else {
364
+ run.managed.proposedAction = proposed;
365
+ run.managed.pendingApproval = { id: digest(`${run.managed.taskId}:${run.revision + 1}:${JSON.stringify(proposed)}`), ...proposed };
366
+ }
367
+ }
368
+ if (run.managed.autonomyPolicy && checkpoint.status === 'blocked' && checkpoint.blocker === 'missing_information') {
369
+ if (!text(checkpoint.question, 1000)) throw new Error('Missing information blocker needs a specific user question.');
370
+ run.managed.userQuestion = text(checkpoint.question, 1000);
371
+ }
372
+ }
373
+ run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
79
374
  run.revision += 1; run.updatedAt = now;
375
+ if (run.managed && effectiveStatus === 'completed') {
376
+ const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
377
+ if (pending.length) { run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`; }
378
+ else if (run.managed.contextBootstrap && !run.managed.acceptanceCriteria.length) {
379
+ run.state = 'reported_done'; run.reason = 'Original session reported the goal done; no independent acceptance check was available.';
380
+ }
381
+ }
382
+ notifyState(run, now);
80
383
  return record;
81
384
  });
82
385
  }
386
+ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, now }) {
387
+ try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
388
+ if (!text(checkpoint.summary, 1000)) throw new Error('Understanding checkpoint needs a summary.');
389
+ return updateRecord(root, provider, session, async record => {
390
+ const run = record?.run;
391
+ if (!run || record.cwd !== cwd || run.managed?.phase !== 'understanding' ||
392
+ TERMINAL.has(run.state) || ['blocked', 'paused', 'recovery_required', 'verification_pending'].includes(run.state)) throw new Error('No active session understanding turn.');
393
+ if (expectedLease && (run.state !== 'auto_resuming' || run.managed.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Session understanding lease changed; checkpoint discarded.');
394
+ if (now >= run.deadline) throw new Error('Session understanding deadline expired.');
395
+ const decision = run.managed.decision;
396
+ const result = checkpoint.actionResult;
397
+ 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.');
398
+ const understanding = sessionUnderstanding(checkpoint.sessionUnderstanding);
399
+ if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
400
+ const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan) : null;
401
+ 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.');
402
+ run.managed.sessionUnderstanding = understanding;
403
+ run.managed.understandingRevision = (run.managed.understandingRevision || 0) + 1;
404
+ run.managed.understandingAnswer = null;
405
+ run.managed.workHistory ||= [];
406
+ run.managed.workHistory.push({ decisionId: decision.id, action: decision.action, status: 'session_understanding', summary: contextText(checkpoint.summary, 300),
407
+ result: { decisionId: decision.id, status: result.status, summary: contextText(result.summary, 300), source: 'provider_reported' },
408
+ verification: 'Context interpretation only; no acceptance verified', at: now });
409
+ run.managed.workHistory = run.managed.workHistory.slice(-10);
410
+ run.checkpoint = { kind: 'session_understanding', status: 'in_progress', summary: contextText(checkpoint.summary, 1000), evidence: [], acceptanceMet: false };
411
+ run.revision += 1; run.updatedAt = now;
412
+ if (expectedLease) run.continuations += 1;
413
+ if (result.status !== 'observed') run.failures += 1;
414
+ if (run.failures >= run.maxFailures) {
415
+ run.state = 'failed'; run.reason = 'Repeated session understanding failures; inspect the original conversation.';
416
+ notifyState(run, now); return record;
417
+ }
418
+ if (understanding.state === 'clear') {
419
+ run.objective = understanding.objective;
420
+ run.managed.remainingWork = understanding.remaining.join(' ').slice(0, 4000);
421
+ // Only mechanical checks tied to a cited user message become frozen
422
+ // acceptance. Semantic/provider-proposed criteria remain visible above.
423
+ run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded' && item.check.type !== 'manual')
424
+ .map(item => ({ id: item.id, description: item.description, check: item.check.type === 'file_equals'
425
+ ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
426
+ run.managed.verified = {};
427
+ run.managed.phase = 'active';
428
+ run.nextStep = understanding.nextAction.summary;
429
+ recordDecision(run.managed, plan, now);
430
+ run.reason = run.managed.decision.outcome === 'wait' ? run.managed.decision.reason : 'Session goal understood; selected next action awaits the normal dispatch gate.';
431
+ if (run.managed.decision.outcome === 'wait') { run.state = 'blocked'; run.blocker = 'authorization'; escalate(run, run.reason); }
432
+ } else if (understanding.state === 'already_done') {
433
+ run.objective = understanding.objective;
434
+ run.managed.remainingWork = null;
435
+ run.managed.phase = 'active';
436
+ run.state = 'reported_done';
437
+ run.reason = 'Original conversation reports no remaining work; independent completion was not verified.';
438
+ } else {
439
+ run.managed.phase = 'needs_clarification';
440
+ run.managed.understandingQuestion = { id: randomUUID(), text: understanding.question, revision: run.managed.understandingRevision };
441
+ run.state = 'blocked'; run.blocker = 'missing_information';
442
+ run.reason = understanding.question;
443
+ run.managed.autoRecovery = false;
444
+ }
445
+ notifyState(run, now);
446
+ return record;
447
+ });
448
+ }
449
+ export async function verifyAutomaticCriteria(record, now) {
450
+ const managed = record.run.managed;
451
+ for (const criterion of managed.acceptanceCriteria) {
452
+ if (criterion.check.type === 'manual') continue;
453
+ const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
454
+ if (!path) { delete managed.verified[criterion.id]; continue; }
455
+ const file = resolve(record.cwd, path);
456
+ const rel = relative(record.cwd, file);
457
+ if (rel.startsWith('..') || isAbsolute(rel)) { delete managed.verified[criterion.id]; continue; }
458
+ try {
459
+ const actual = await realpath(file);
460
+ const actualRel = relative(record.cwd, actual);
461
+ if (actualRel.startsWith('..') || isAbsolute(actualRel)) { delete managed.verified[criterion.id]; continue; }
462
+ const info = await stat(actual);
463
+ if (!info.isFile() || info.size > 1024 * 1024) { delete managed.verified[criterion.id]; continue; }
464
+ const content = await readFile(actual);
465
+ if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) { delete managed.verified[criterion.id]; continue; }
466
+ if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) { delete managed.verified[criterion.id]; continue; }
467
+ if (!Object.hasOwn(managed.verified, criterion.id)) managed.verifiedRevision += 1;
468
+ managed.verified[criterion.id] = { source: criterion.check.type, checkedAt: now, path, sha256: digest(content) };
469
+ } catch { delete managed.verified[criterion.id]; }
470
+ }
471
+ }
472
+ export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
473
+ return updateRecord(root, provider, session, async record => {
474
+ const run = record?.run;
475
+ if (!run?.managed || run.state !== 'verification_pending') throw new Error('Managed task is not awaiting acceptance verification.');
476
+ if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
477
+ const criterion = run.managed.acceptanceCriteria.find(item => item.id === criterionId);
478
+ if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
479
+ if (!Object.hasOwn(run.managed.verified, criterionId)) run.managed.verifiedRevision += 1;
480
+ run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
481
+ if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
482
+ await verifyAutomaticCriteria(record, now);
483
+ if (run.managed.acceptanceCriteria.every(item => Object.hasOwn(run.managed.verified, item.id))) {
484
+ run.state = 'completed'; run.reason = run.checkpoint.summary;
485
+ }
486
+ }
487
+ notifyState(run, now);
488
+ run.updatedAt = now; return record;
489
+ });
490
+ }
83
491
  export function decideStop(run, now = Date.now()) {
84
- if (TERMINAL.has(run.state) || ['blocked', 'recovery_required'].includes(run.state)) return { run, output: {} };
492
+ if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
85
493
  const next = structuredClone(run);
86
494
  const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
87
495
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
88
496
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
89
497
  const cp = fresh ? next.checkpoint : null;
90
- if (cp?.status === 'completed') return finish('completed', cp.summary);
91
- if (cp?.status === 'blocked') { next.blocker = cp.blocker; return finish('blocked', cp.summary); }
498
+ if (cp?.status === 'completed') return finish(next.managed?.contextBootstrap && !next.managed.acceptanceCriteria.length ? 'reported_done' : 'completed',
499
+ next.managed?.contextBootstrap && !next.managed.acceptanceCriteria.length ? 'Original session reported the goal done; no independent acceptance check was available.' : cp.summary);
500
+ if (cp?.status === 'blocked') {
501
+ next.blocker = cp.blocker;
502
+ const approval = next.managed?.pendingApproval;
503
+ const grant = approval && Object.hasOwn(next.managed.grants, approval.scope) ? next.managed.grants[approval.scope] : null;
504
+ if (!next.managed?.autonomyPolicy && approval && LOCAL_ACTIONS.has(approval.kind) && grant && grant.decision !== 'wait') {
505
+ next.managed.pendingApproval = null;
506
+ next.managed.notification = grant.decision === 'proceed_and_report' ? { key: `approval:${approval.id}`, kind: 'approval', message: `Proceeding under grant for ${approval.scope}.`, at: now } : next.managed.notification;
507
+ next.nextStep = text(cp.nextStep) || next.nextStep;
508
+ } else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
509
+ }
92
510
  if (now >= next.deadline || next.continuations >= next.maxContinuations) return finish('budget_exhausted', 'Delivery time or continuation budget exhausted.');
93
- if (!fresh || cp.status === 'error') next.failures += 1;
94
- else next.failures = 0;
511
+ const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
512
+ const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
513
+ const understandingHook = next.managed?.capability === 'live_hook' && next.managed.awaitingUnderstandingHook === true;
514
+ const approvedHook = next.managed?.capability === 'live_hook' && next.managed.decision?.outcome === 'wait' &&
515
+ next.managed.approvedAction?.id === next.managed.decision.id && !next.managed.approvedAction.dispatchedAt;
516
+ const legacyApprovedHook = next.managed?.capability === 'live_hook' && !next.managed.autonomyPolicy &&
517
+ next.managed.approvedAction && !next.managed.approvedAction.dispatchedAt &&
518
+ JSON.stringify(next.managed.approvedAction.action) === JSON.stringify(next.managed.proposedAction) &&
519
+ next.managed.approvedAction.nextStep === next.nextStep;
520
+ // A first hook or approved dispatch has not produced a new checkpoint yet.
521
+ // Keep prior failures until actual fresh work succeeds or another attempt fails.
522
+ if (!initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook) {
523
+ if (!fresh || cp.status === 'error') next.failures += 1;
524
+ else next.failures = 0;
525
+ }
95
526
  if (next.failures >= next.maxFailures) return finish('failed', 'Repeated failure or missing progress checkpoint; human review required.');
527
+ if (next.managed) {
528
+ if (next.managed.autonomyPolicy && next.managed.decision?.outcome === 'wait' && !approvedHook) {
529
+ escalate(next, next.managed.decision.reason);
530
+ return finish('blocked', next.managed.decision.reason);
531
+ }
532
+ const action = next.managed.proposedAction;
533
+ if (!next.managed.autonomyPolicy && action && cp?.status !== 'blocked') {
534
+ const grant = Object.hasOwn(next.managed.grants, action.scope) ? next.managed.grants[action.scope] : null;
535
+ const oneShot = !next.managed.approvedAction?.dispatchedAt && next.managed.approvedAction?.nextStep === (cp?.nextStep || next.nextStep) && JSON.stringify(next.managed.approvedAction.action) === JSON.stringify(action);
536
+ if (!oneShot && (!LOCAL_ACTIONS.has(action.kind) || !grant || grant.decision === 'wait')) {
537
+ next.managed.pendingApproval = { id: digest(`${next.managed.taskId}:${next.revision}:${JSON.stringify(action)}`), ...action };
538
+ return finish('blocked', `Review proposed ${action.kind} action: ${action.summary}`);
539
+ }
540
+ if (!oneShot && grant.decision === 'proceed_and_report') next.managed.notification = { key: `action:${next.revision}`, kind: 'action', message: `Proceeding with ${action.summary}`, at: now };
541
+ }
542
+ const reportedStep = next.managed.contextBootstrap && next.managed.phase === 'active' && !next.managed.acceptanceCriteria.length &&
543
+ cp?.kind !== 'session_understanding' && cp?.status === 'in_progress' &&
544
+ next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
545
+ if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
546
+ else if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
547
+ else if (!initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && cp?.kind !== 'session_understanding') next.managed.noProgress += 1;
548
+ next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
549
+ if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish('failed', 'No verified acceptance progress within the configured continuation limit.'); }
550
+ }
96
551
  next.consumedRevision = next.revision;
552
+ if (initialHook) next.managed.awaitingInitialHook = false;
553
+ if (replanHook) next.managed.awaitingReplanHook = false;
554
+ if (understandingHook) next.managed.awaitingUnderstandingHook = false;
97
555
  if (cp?.nextStep) next.nextStep = cp.nextStep;
98
- next.continuations += 1; next.updatedAt = now; next.state = 'active';
556
+ if (!(next.managed?.contextBootstrap && cp?.kind === 'session_understanding' && next.managed.resumeChannel === 'codex_app_server')) next.continuations += 1;
557
+ next.updatedAt = now; next.state = 'active';
558
+ if (approvedHook || legacyApprovedHook) {
559
+ const approval = next.managed.approvedAction;
560
+ approval.dispatchedAt = now;
561
+ next.managed.dispatches ||= [];
562
+ next.managed.dispatches.push({ approvalId: approval.id, at: now, channel: 'live_hook' });
563
+ next.managed.dispatches = next.managed.dispatches.slice(-20);
564
+ if (legacyApprovedHook) delete next.managed.approvedAction;
565
+ }
99
566
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
100
- const instruction = fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
567
+ const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
568
+ const instruction = selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
101
569
  return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations}/${next.maxContinuations}). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Do not reset budgets or start a replacement loop.` } };
102
570
  }
103
571
  // A process signature protects against PID reuse. Never treat a stale timer alone
@@ -112,7 +580,7 @@ async function providerProcess(provider) {
112
580
  let pid = process.ppid;
113
581
  for (let i = 0; i < 3 && pid > 1; i++) {
114
582
  const identity = await processIdentity(pid);
115
- if (identity && new RegExp(`(?:^|[/\\\\ ])${provider}(?:$|[ .-])`, 'i').test(identity)) return { pid, identity };
583
+ if (identity && new RegExp(`(?:^|[/\\\\ ])${provider}(?:$|[/\\\\ .-])`, 'i').test(identity)) return { pid, identity };
116
584
  try { const r = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'ppid='], { timeout: 300, maxBuffer: 128 }); pid = Number(r.stdout.trim()); } catch { return null; }
117
585
  }
118
586
  return null;
@@ -121,26 +589,48 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
121
589
  if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
122
590
  let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
123
591
  const event = input.hook_event_name;
124
- const identity = observedProcess === undefined && event === 'SessionStart' ? await providerProcess(provider) : observedProcess;
592
+ const identity = observedProcess === undefined && (event === 'SessionStart' || provider === 'claude' && event === 'Stop') ? await providerProcess(provider) : observedProcess;
125
593
  let output = {};
126
- await updateRecord(root, provider, input.session_id, old => {
594
+ await updateRecord(root, provider, input.session_id, async old => {
127
595
  if (old && old.cwd !== cwd) return old;
128
596
  const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
597
+ const owner = record.run?.managed?.liveHookOwner;
598
+ if (owner && ['SessionStart', 'Stop'].includes(event) &&
599
+ (!identity || identity.pid !== owner.pid || identity.identity !== owner.identity)) {
600
+ if (!TERMINAL.has(record.run.state) && !['paused', 'verification_pending'].includes(record.run.state)) {
601
+ if (!(record.run.state === 'blocked' && record.run.managed.pendingApproval)) record.run.state = 'recovery_required';
602
+ record.run.recoveryCause = 'hook_owner_mismatch';
603
+ record.run.reason = 'A different or unverified Claude process reported this session. Inspect and explicitly resume in the original conversation.';
604
+ record.run.managed.autoRecovery = false; record.run.updatedAt = now; notifyState(record.run, now);
605
+ }
606
+ return record;
607
+ }
129
608
  record.observedAt = now; record.lastEvent = event;
609
+ record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
130
610
  if (identity) record.process = identity;
131
611
  if (event === 'SessionStart') {
132
- output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}. Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
612
+ if (identity) record.hookStartedAt = now;
613
+ const approved = record.run?.managed?.approvedAction;
614
+ output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}.${approved ? ` User approved exactly one ${approved.action.kind} action in scope ${approved.action.scope}: ${approved.action.summary}. Next step: ${approved.nextStep}. ${approved.dispatchedAt ? 'That approval was already dispatched; any repeated action needs a new approval.' : 'Resume the original conversation to dispatch this one action.'}` : ''} Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
133
615
  }
134
616
  const run = record.run;
135
617
  if (!run || TERMINAL.has(run.state)) return record;
136
618
  if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
619
+ else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
620
+ // The supervisor owns this exact attempt and consumes its structured
621
+ // final result once. Provider lifecycle hooks must not race that lease.
622
+ }
623
+ else if (['paused', 'verification_pending'].includes(run.state) && ['SessionEnd', 'StopFailure'].includes(event)) {
624
+ // A provider lifecycle event cannot override an explicit pause or a
625
+ // completion candidate awaiting independent acceptance checks.
626
+ }
137
627
  else if (event === 'PermissionRequest' || event === 'Notification' && ['permission_prompt', 'elicitation_dialog'].includes(input.notification_type)) {
138
628
  run.state = 'blocked'; run.blocker = 'authorization'; run.reason = 'Resolve the provider approval or input request, then explicitly resume Delivery Loop.';
139
629
  } else if (event === 'SessionEnd') {
140
- run.state = 'recovery_required'; run.reason = 'Provider session ended. Check why it ended before resuming; no automatic restart.';
630
+ run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
141
631
  } else if (event === 'StopFailure') {
142
632
  run.failures += 1;
143
- run.state = run.failures >= run.maxFailures ? 'failed' : 'recovery_required';
633
+ run.state = run.failures >= run.maxFailures ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
144
634
  run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
145
635
  run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
146
636
  } else if (event === 'Stop') {
@@ -148,25 +638,147 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
148
638
  // stable event ID; never dedupe on text (identical text can be a new turn).
149
639
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
150
640
  if (key && run.lastStopKey === key) return record;
641
+ if (run.managed && run.checkpoint?.status === 'completed') {
642
+ await verifyAutomaticCriteria(record, now);
643
+ const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
644
+ if (pending.length) {
645
+ run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
646
+ run.lastStopKey = key; notifyState(run, now); return record;
647
+ }
648
+ }
151
649
  const decision = decideStop(run, now); record.run = decision.run; output = decision.output;
152
650
  record.run.lastStopKey = key;
153
651
  }
154
652
  record.run.updatedAt = now;
653
+ notifyState(record.run, now);
155
654
  return record;
156
655
  });
157
656
  return output;
158
657
  }
159
- export async function controlDelivery({ root = rootDirectory(), provider, session, action, now = Date.now() }) {
658
+ export function canReplanDelivery(run, provider, now = Date.now()) {
659
+ const managed = run?.managed;
660
+ const supported = managed?.resumeChannel === 'codex_app_server' && provider === 'codex' ||
661
+ managed?.capability === 'live_hook' && provider === 'claude' && Boolean(managed.liveHookOwner?.identity);
662
+ return Boolean(supported && managed.autonomyPolicy && managed.approvalScopes?.length && !managed.rejectedActionOverflow &&
663
+ run.state === 'blocked' && managed.pendingApproval?.denied === true &&
664
+ now < run.deadline && run.continuations < run.maxContinuations && run.failures < run.maxFailures);
665
+ }
666
+ export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, questionId, answer, action, now = Date.now() }) {
160
667
  return updateRecord(root, provider, session, record => {
161
668
  const run = record?.run;
162
669
  if (!run) throw new Error('Delivery Loop not found.');
163
- if (action === 'stop') { run.state = 'stopped'; run.reason = 'Stopped by user.'; }
670
+ if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
671
+ if (action === 'session_answer') {
672
+ const question = run.managed?.understandingQuestion;
673
+ if (run.managed?.phase !== 'needs_clarification' || run.state !== 'blocked' || !question || question.id !== questionId ||
674
+ !text(answer, 1000) || typeof answer !== 'string' || answer.length > 1000) throw new Error('Session question changed or answer is empty; refresh its status.');
675
+ if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Session cannot continue.');
676
+ const read = { kind: 'read', scope: 'task', summary: 'Re-examine the original conversation using the new user answer without changing files' };
677
+ const plan = assessedPlan({ diagnosis: `Resolve the recorded question before any work: ${question.text}`,
678
+ alternatives: [{ id: 'understand_answer', action: read,
679
+ assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
680
+ expectedVerification: 'Return a revised grounded session understanding or one focused remaining question.', rollback: 'No changes to undo.' }],
681
+ selectedOptionId: 'understand_answer', recommendation: { optionId: 'understand_answer', reason: 'Incorporate the user answer before choosing work.' } });
682
+ const decision = recordDecision(run.managed, plan, now);
683
+ if (decision.outcome !== 'proceed') throw new Error('Read-only understanding cannot be dispatched under current policy.');
684
+ run.managed.understandingAnswer = { questionId, text: contextText(answer, 1000) };
685
+ run.managed.understandingQuestion = null;
686
+ run.managed.phase = 'understanding';
687
+ run.blocker = null; run.checkpoint = null; run.consumedRevision = run.revision;
688
+ run.nextStep = 'Use the user answer to re-understand the original request; do not execute a work action.';
689
+ if (run.managed.resumeChannel === 'codex_app_server') {
690
+ run.managed.autoRecovery = true; run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
691
+ run.reason = 'User answer recorded; read-only understanding is queued in the exact Codex conversation.';
692
+ } else {
693
+ run.managed.awaitingUnderstandingHook = true; run.state = 'active';
694
+ run.reason = 'User answer recorded; understanding awaits the original Claude Stop boundary.';
695
+ }
696
+ }
697
+ else if (action === 'set_policy') {
698
+ if (!run.managed?.autonomyPolicy || !run.managed.plan || TERMINAL.has(run.state) || !AUTONOMY_POLICIES.has(autonomyPolicy)) throw new Error('Only a nonterminal assessed managed task can change autonomy policy.');
699
+ run.managed.autonomyPolicy = autonomyPolicy;
700
+ const decision = recordDecision(run.managed, run.managed.plan, now);
701
+ if (decision.outcome === 'wait') { run.state = 'blocked'; run.reason = decision.reason; escalate(run, decision.reason); }
702
+ 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; }
703
+ }
704
+ else if (action === 'stop') {
705
+ if (TERMINAL.has(run.state)) return record;
706
+ run.state = 'stopped'; run.reason = 'Stopped by user.';
707
+ }
708
+ else if (action === 'pause') {
709
+ const queuedAppServer = run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(run.recoveryCause) &&
710
+ run.managed?.resumeChannel === 'codex_app_server' && run.managed.autoRecovery;
711
+ if (!run.managed || !['active', 'auto_resuming'].includes(run.state) && !queuedAppServer) throw new Error('Only a running or queued managed task can pause.');
712
+ if (queuedAppServer || run.state === 'auto_resuming' && run.managed.resumeChannel === 'codex_app_server') run.managed.pausedRecoveryCause = run.recoveryCause || 'session_supervision';
713
+ run.state = 'paused'; run.reason = 'Paused by user.';
714
+ }
715
+ else if (action === 'replan') {
716
+ if (!canReplanDelivery(run, provider, now)) throw new Error('Replanning needs a rejected assessed approval, a supported original-session channel, and remaining budget.');
717
+ const rejected = run.managed.pendingApproval;
718
+ // Also cover denied records written by earlier versions before rejection
719
+ // fingerprints existed; never re-authorize that exact action on upgrade.
720
+ rememberRejectedAction(run.managed, rejected);
721
+ const plan = rejectedActionDiagnosis(run.managed, rejected);
722
+ const decision = recordDecision(run.managed, plan, now);
723
+ if (decision.outcome !== 'proceed' || decision.action.kind !== 'read') throw new Error('Safe read diagnosis is unavailable; keep the rejected action blocked.');
724
+ delete run.managed.approvedAction;
725
+ run.managed.userQuestion = null;
726
+ run.managed.escalation = null;
727
+ run.blocker = null;
728
+ run.nextStep = 'Review the recorded rejection and propose a different assessed option without executing it.';
729
+ run.checkpoint = null; run.consumedRevision = run.revision;
730
+ if (run.managed.resumeChannel === 'codex_app_server') {
731
+ run.managed.autoRecovery = true;
732
+ run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
733
+ run.reason = 'Rejected action remains denied; safe read diagnosis is queued in the exact Codex session.';
734
+ } else {
735
+ run.managed.awaitingReplanHook = true;
736
+ run.state = 'active';
737
+ run.reason = 'Rejected action remains denied; safe read diagnosis awaits the original Claude Stop boundary.';
738
+ }
739
+ }
164
740
  else if (action === 'resume') {
165
- if (!['blocked', 'recovery_required'].includes(run.state)) throw new Error('Only a blocked or recoverable loop can resume.');
741
+ if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
742
+ if (!['blocked', 'recovery_required', 'paused'].includes(run.state)) throw new Error('Only a blocked, paused, or recoverable loop can resume.');
743
+ if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
744
+ 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.');
166
745
  if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Loop cannot resume.');
167
- run.state = 'active'; run.reason = 'Explicit user resume'; run.checkpoint = null; run.consumedRevision = run.revision;
746
+ if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
747
+ run.managed.approvedAction.dispatchedAt = now;
748
+ run.managed.dispatches ||= [];
749
+ run.managed.dispatches.push({ approvalId: run.managed.approvedAction.id, at: now, channel: 'manual_original_conversation' });
750
+ run.managed.dispatches = run.managed.dispatches.slice(-20);
751
+ }
752
+ const selectedKind = run.managed?.decision?.action?.kind;
753
+ const approvedWait = run.managed?.decision?.outcome === 'wait' && run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
754
+ const queuedAppServer = run.managed?.resumeChannel === 'codex_app_server' &&
755
+ ['read', 'local_edit', 'local_test'].includes(selectedKind) &&
756
+ (run.managed.decision.outcome !== 'wait' || approvedWait);
757
+ if (queuedAppServer) run.managed.autoRecovery = true;
758
+ run.state = queuedAppServer ? 'recovery_required' : 'active';
759
+ if (queuedAppServer) run.recoveryCause = approvedWait ? 'approved_action' : 'session_supervision';
760
+ delete run.managed?.pausedRecoveryCause;
761
+ run.reason = queuedAppServer ? 'Exact-session supervision resumed; writer acquisition is queued.' : 'Explicit user resume';
762
+ run.checkpoint = null; run.consumedRevision = run.revision;
763
+ } else if (action === 'approve' || action === 'reject') {
764
+ const pending = run.managed?.pendingApproval;
765
+ if (!pending || pending.id !== approvalId || pending.denied || run.state !== 'blocked') throw new Error('Pending approval changed; refresh its status.');
766
+ run.managed.notification = { key: `approval:${pending.id}`, kind: 'approval', message: `${action === 'approve' ? 'Approved' : 'Rejected'} ${pending.scope}.`, at: now };
767
+ if (action === 'approve') {
768
+ if (now >= run.deadline || run.continuations >= run.maxContinuations) throw new Error('Original budget exhausted. Task cannot resume.');
769
+ run.managed.pendingApproval = null;
770
+ run.nextStep = run.checkpoint?.nextStep || run.nextStep;
771
+ run.state = 'recovery_required'; run.recoveryCause = 'approved_action';
772
+ run.reason = `User approved ${pending.scope}. Resume the original conversation to continue this exact action.`;
773
+ run.checkpoint = { status: 'in_progress', summary: run.reason, nextStep: run.nextStep, evidence: [], acceptanceMet: false };
774
+ run.revision += 1;
775
+ run.managed.approvedAction = { id: pending.id, nextStep: run.nextStep, action: { kind: pending.kind, scope: pending.scope, summary: pending.summary } };
776
+ } else {
777
+ pending.denied = true; run.reason = `User rejected ${pending.scope}.`; run.blocker = 'authorization';
778
+ rememberRejectedAction(run.managed, pending);
779
+ }
168
780
  } else throw new Error('Unsupported control.');
169
- run.updatedAt = now; return record;
781
+ run.updatedAt = now; notifyState(run, now); return record;
170
782
  });
171
783
  }
172
784
  export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
@@ -174,13 +786,20 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
174
786
  for (const record of records) {
175
787
  if (!record.run || TERMINAL.has(record.run.state)) continue;
176
788
  const observed = record.process ? await inspectProcess(record.process.pid) : null;
177
- await updateRecord(root, record.provider, record.session, current => {
789
+ await updateRecord(root, record.provider, record.session, async current => {
178
790
  if (!current?.run || TERMINAL.has(current.run.state)) return current;
179
791
  const run = current.run;
180
- if (now >= run.deadline) { run.state = 'budget_exhausted'; run.reason = 'Delivery time budget exhausted.'; run.updatedAt = now; }
792
+ if (run.state === 'verification_pending' && run.managed) {
793
+ await verifyAutomaticCriteria(current, now);
794
+ if (run.managed.acceptanceCriteria.every(item => Object.hasOwn(run.managed.verified, item.id))) {
795
+ run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
796
+ }
797
+ }
798
+ if (now >= run.deadline && !TERMINAL.has(run.state) && run.state !== 'verification_pending') { run.state = 'budget_exhausted'; run.reason = 'Delivery time budget exhausted.'; run.updatedAt = now; }
181
799
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
182
- run.state = 'recovery_required'; run.reason = 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
800
+ run.state = 'recovery_required'; run.recoveryCause = 'process_exit'; run.reason = run.managed?.autoRecovery ? 'Observed provider process exited. Managed recovery is eligible for a bounded exact-session attempt.' : 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
183
801
  }
802
+ notifyState(run, now);
184
803
  return current;
185
804
  }).catch(() => {});
186
805
  }