@awak-app/simy-cli 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +51 -0
  2. package/package.json +2 -1
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +2 -2
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +144 -59
  6. package/src/codex-plugins/simy/hooks/perspective-memory.mjs +115 -0
  7. package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +43 -12
  8. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +31 -8
  9. package/src/codex-plugins/simy/hooks/purpose-search.mjs +2 -1
  10. package/src/codex-plugins/simy/hooks/state-limits.mjs +2 -0
  11. package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +23 -1
  12. package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +2 -0
  13. package/src/delivery-hooks/checkpoint-diagnostic.js +1 -0
  14. package/src/delivery-hooks/command.js +10 -2
  15. package/src/delivery-hooks/consistency-monitor.js +71 -22
  16. package/src/delivery-hooks/desktop.js +2 -2
  17. package/src/delivery-hooks/local-runtime.js +2 -2
  18. package/src/delivery-hooks/supervisor.js +15 -4
  19. package/src/desktop-host.js +7 -2
  20. package/src/session-supervision/codex-app-server.js +84 -36
  21. package/src/session-supervision/consistency-auditor.js +87 -34
  22. package/src/session-supervision/index.js +22 -1
  23. package/src/session-supervision/native-delegation.js +86 -0
  24. package/src/session-supervision/purpose-instruction.js +4 -2
  25. package/src/session-supervision/purpose-recovery.js +48 -16
  26. package/src/session-supervision/purpose-verification.js +95 -10
  27. package/src/session-supervision/redelegate-native.js +48 -0
  28. package/src/sqm/claude-plugin.js +2 -0
  29. package/src/sqm/command.js +10 -3
  30. package/src/sqm/hypothesis-knowledge.js +2 -1
  31. package/src/sqm/index.js +11 -5
  32. package/src/sqm/local-tests.js +155 -0
  33. package/src/sqm/proof.js +14 -3
  34. package/src/sqm/report.js +8 -3
  35. package/src/sqm/review-worker.js +16 -2
package/README.md CHANGED
@@ -1027,6 +1027,17 @@ recovery operations do not switch identity automatically. The private
1027
1027
 
1028
1028
  自律判断の改定方針と人間判断候補の一覧: [AI判断を基本にする改定](docs/ai-decision-policy.md)。LLMの文脈理解・推論・調査を基本にし、HC-01〜HC-102を判断の批判視点として適用します。
1029
1029
 
1030
+ ### Explicit local re-delegation of legacy Codex tasks
1031
+
1032
+ After the user explicitly delegates again, `simy delivery redelegate-native
1033
+ --provider codex --session ID --task-id ID --consent-ref REF` records current
1034
+ native local edit/test authority for that exact legacy task. The reference identifies
1035
+ the user's new instruction. It does not reconstruct historical enrollment, resume
1036
+ stopped tasks, change the execution directory, reset budgets, or grant network or
1037
+ connector use. Current native rights are still checked before each worker dispatch.
1038
+ An active lease, pending approval, changed task or provider connection prevents
1039
+ application. Resolve an actual dependency only after the new receipt is stored.
1040
+
1030
1041
  ### Local autonomous recovery (0.6.25)
1031
1042
 
1032
1043
  `simy delivery runtime install` pins a hash-verified snapshot. `runtime desktop-install`
@@ -1064,6 +1075,42 @@ recorded; an audit receipt does not complete a task. SQM evidence stays bound to
1064
1075
  repository/commit/diff scope and does not prove the whole user outcome. Git output
1065
1076
  no longer uses a 10 MiB `execFile` buffer; the full diff is still retained in memory.
1066
1077
 
1078
+ Routine purpose alignment asks the existing independent AI for a short natural
1079
+ language opinion using the conversation and available work. The working LLM weighs
1080
+ it against the user's purpose, adopts useful parts, disagrees or tries a small
1081
+ reversible experiment, then continues. Uncertainty and disagreement do not require
1082
+ consensus, a decision log, extra forms or human arbitration. Only the latest opinion
1083
+ is retained; delivering it closes the consultation request without a worker reply.
1084
+ Advice supplies neither acceptance evidence nor permission. Actual completion and
1085
+ known outcome gaps retain the independent verification path.
1086
+
1087
+ For enrolled purpose-v2 tasks with `decisionPolicy:llm-first-hc102`, ordinary
1088
+ reasoning is now optional: `purposeReview` can be omitted, a short natural-language
1089
+ note, or an object with `thinking`. Existing structured reports still work. The
1090
+ worker chooses useful perspectives for the situation; there is no per-checkpoint
1091
+ hypothesis/refutation/observation/ranking quota. Missing observations are retained
1092
+ as unverified, not fabricated. An unobserved write goes to automatic read-only
1093
+ reconciliation before another effect; missing reasoning notes alone do not stop
1094
+ a valid next plan. Original acceptance, known failed obligations, provider
1095
+ permissions, user stops and total budgets remain enforced. The v1 contract is
1096
+ unchanged. Existing enrolled v2 records need no restart or replacement for this
1097
+ parser change; installing this code does not resume a stopped task.
1098
+
1099
+ Reproduce the prototype locally (no provider call or existing task mutation):
1100
+
1101
+ ```bash
1102
+ npm run test:e2e:flexible-delivery
1103
+ ```
1104
+
1105
+ It runs the real controller and temporary filesystem through missing observations
1106
+ → investigation → implementation → separate verification, an uncertain write,
1107
+ an unmet review returning to repair, and an explicit user stop. Worker/reviewer
1108
+ responses in this deterministic demo are scripted. For an opt-in real Codex trial
1109
+ in a new disposable conversation, use
1110
+ `node scripts/purpose-search-live-e2e.js --run --max-minutes=4`.
1111
+ That trial reports actual checkpoints and completion separately; the automated
1112
+ demo is not proof of uninterrupted operation in the installed desktop host.
1113
+
1067
1114
  If a later audit finds unmet work while an earlier review was waiting only for
1068
1115
  human acceptance, the controller reopens independent inspection of the durable
1069
1116
  findings. Confirmed gaps return to diagnosis, authorized repair and reinspection;
@@ -1071,6 +1118,10 @@ incorrect findings need captured counterevidence to be refuted. Restarting the h
1071
1118
  retains this handoff, task identity and budgets. Actual human acceptance is never
1072
1119
  supplied by the repair loop, and unresolved native permissions are preserved.
1073
1120
 
1121
+ Outcome audits and recovery workers serialize their access to a task. Full outcome inspection has a finite five-minute default deadline, brief peer opinions two minutes; both respect the original task deadline. Failed inspections wait from settlement time before retrying, and worker progress alone does not reset that backoff. Independent JSON evidence ignores formatting whitespace only and must retain complete scalar values.
1122
+
1123
+ Owned-worker Git commits require the checkout’s administrative paths to be explicitly allowed by both the original frozen and current native sandbox policies (or full access in each). Implicit workspace access does not grant Git-metadata writes.
1124
+
1074
1125
  Codex cold recovery and independent audit are supported. Claude retains its
1075
1126
  original live-hook boundary; cold recovery and detached independent audit are not
1076
1127
  implemented in this release and are exposed as a capability gap. Neither an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "build:macos-installer:production": "bash packaging/build-macos-arm64.sh --production",
52
52
  "test:e2e:claude-parity": "node ./scripts/claude-parity-runtime-e2e.js",
53
53
  "test:e2e:claude-plugin": "node ./scripts/claude-plugin-runtime-e2e.js",
54
+ "test:e2e:flexible-delivery": "node --test --test-name-pattern=\"flexible prototype\" test/purpose-search.test.js",
54
55
  "test:e2e:sqm-faults": "node ./scripts/sqm-complete-loop-e2e.js"
55
56
  },
56
57
  "engines": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.7.4";
16
+ export const CLI_VERSION = "0.7.6";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -18,14 +18,14 @@ export function recordHandoffStage(item, stage, now, details = {}) {
18
18
  export function handoffHealth(managed, now) {
19
19
  const requests = managed?.consistencyOutbox || [];
20
20
  const busy = Boolean(managed?.resumeLease || managed?.liveWorkspaceLease || managed?.outcomeAudit?.lease);
21
- return requests.filter(item => !['resolved', 'superseded'].includes(item.stage)).map(item => ({
21
+ return requests.filter(item => !item.advisory && !['resolved', 'superseded'].includes(item.stage)).map(item => ({
22
22
  id: item.id, owner: item.owner || 'task_controller', stage: item.stage,
23
23
  overdue: !busy && now >= (item.dueAt || (item.observedAt || item.receivedAt || item.createdAt || now) + HANDOFF_PROGRESS_MS),
24
24
  ageMs: Math.max(0, now - (item.createdAt || now)), effectDecisionId: item.decisionId || null,
25
25
  }));
26
26
  }
27
27
  export function handoffMetrics(records, now) {
28
- const requests = records.flatMap(r => r.run?.managed?.consistencyOutbox || []);
28
+ const requests = records.flatMap(r => r.run?.managed?.consistencyOutbox || []).filter(item => !item.advisory);
29
29
  const resolved = requests.filter(r => r.stage === 'resolved' && Number.isFinite(r.verifiedAt) && Number.isFinite(r.createdAt));
30
30
  const audits = records.map(r => r.run?.managed?.outcomeAudit).filter(Boolean);
31
31
  return { pendingHandoffs: requests.filter(r => !['resolved', 'superseded'].includes(r.stage)).length,
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { MAX_STATE_BYTES } from './state-limits.mjs';
3
+ import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
2
4
  import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
3
5
  import { bindControllerCompletion } from './completion-binding.mjs';
4
6
  import { recordHandoffStage } from './consistency-handoff.mjs';
@@ -14,7 +16,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat
14
16
  import { execFile } from 'node:child_process';
15
17
  import { promisify } from 'node:util';
16
18
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
17
- import { purposeEnabled, purposeContract, purposeDiagnosisPlan, validatePurposeReview } from './purpose-cycle.mjs';
19
+ import { purposeEnabled, purposeContract, purposeDiagnosisPlan, validatePurposeReview, flexibleReasoning } from './purpose-cycle.mjs';
18
20
  import { effectiveAcceptanceCriteria } from './acceptance-policy.mjs';
19
21
  import { assessRuleReview } from './purpose-rule-review.mjs';
20
22
  import { associationContract, validateAssociationResult } from './action-association.mjs';
@@ -116,20 +118,22 @@ export function recordPath(root, provider, session) {
116
118
  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.');
117
119
  return join(root, `${digest(`${provider}\0${session}`)}.json`);
118
120
  }
119
- async function read(file) {
121
+ async function read(file, limit = MAX_BYTES) {
120
122
  let handle;
121
123
  try {
122
124
  handle = await open(file, 'r');
123
125
  // Read a bounded allocation even when an external writer supplies a huge
124
126
  // file. The extra byte distinguishes the limit from a truncated document.
125
- const bytes = Buffer.alloc(MAX_BYTES + 1);
127
+ const stat = await handle.stat();
128
+ if (!stat.isFile() || stat.size > limit) throw new Error('State exceeds limit.');
129
+ const bytes = Buffer.alloc(Math.min(limit, stat.size) + 1);
126
130
  let size = 0;
127
131
  while (size < bytes.length) {
128
132
  const result = await handle.read(bytes, size, bytes.length - size, null);
129
133
  if (!result.bytesRead) break;
130
134
  size += result.bytesRead;
131
135
  }
132
- if (size > MAX_BYTES) throw new Error('State exceeds limit.');
136
+ if (size > limit || size > stat.size) throw new Error('State changed or exceeds limit.');
133
137
  return JSON.parse(bytes.subarray(0, size).toString('utf8'));
134
138
  } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
135
139
  finally { await handle?.close(); }
@@ -209,11 +213,9 @@ async function acquireStateLock(lock, inspectProcess, depth = 0) {
209
213
  await releaseStateLock(reclaim, token);
210
214
  }
211
215
  }
212
- export async function withSessionParentLock(root, operation, inspectProcess = processIdentity) {
213
- await mkdir(root, { recursive: true, mode: 0o700 });
214
- const lock = join(root, 'session-lineage.lock');
215
- // Distinct SessionStart hooks share this lock. Wait within the hook timeout
216
- // for short writers, without reclaiming unknown owners or replaying work.
216
+ async function waitForStateLock(lock, inspectProcess) {
217
+ // mkdir/owner publication and owner/directory removal are not atomic.
218
+ // Wait only for acquisition; never replay a mutation or remove unknown owners.
217
219
  const deadline = Date.now() + 500;
218
220
  let token;
219
221
  for (;;) {
@@ -223,6 +225,12 @@ export async function withSessionParentLock(root, operation, inspectProcess = pr
223
225
  await new Promise(resolve => setTimeout(resolve, 10));
224
226
  }
225
227
  }
228
+ return token;
229
+ }
230
+ export async function withSessionParentLock(root, operation, inspectProcess = processIdentity) {
231
+ await mkdir(root, { recursive: true, mode: 0o700 });
232
+ const lock = join(root, 'session-lineage.lock');
233
+ const token = await waitForStateLock(lock, inspectProcess);
226
234
  try { return await operation(); } finally { await releaseStateLock(lock, token); }
227
235
  }
228
236
  export async function validateSessionParent(root, child, parent) {
@@ -242,10 +250,10 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
242
250
  const file = recordPath(root, provider, session), lock = `${file}.lock`;
243
251
  // Recovery requires positive process-death evidence and a serialized second
244
252
  // observation. Unknown/legacy owners are never reclaimed by age.
245
- const lockToken = await acquireStateLock(lock, inspectProcess);
253
+ const lockToken = await waitForStateLock(lock, inspectProcess);
246
254
  let temporary;
247
255
  try {
248
- const stored = await read(file);
256
+ const stored = await read(file, MAX_STATE_BYTES);
249
257
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
250
258
  const previousBytes = stored === null ? null : JSON.stringify(stored);
251
259
  const next = await mutate(previous);
@@ -259,7 +267,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
259
267
  }
260
268
  }
261
269
  const serialized = JSON.stringify(next);
262
- if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 256 KiB; reduce task details.');
270
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
263
271
  if (serialized === previousBytes) return next;
264
272
  temporary = `${file}.${randomUUID()}.tmp`;
265
273
  await writeFile(temporary, serialized, { mode: 0o600 });
@@ -271,7 +279,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
271
279
  export async function readRecord(root = rootDirectory(), provider, session) {
272
280
  const path = recordPath(root, provider, session);
273
281
  try {
274
- const record = await read(path);
282
+ const record = await read(path, MAX_STATE_BYTES);
275
283
  return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyArchiveFence(root, applyProviderConsent(record, await providerLinkState(root, provider))) : null;
276
284
  } catch { return null; } // Match listRecords' treatment of unreadable records.
277
285
  }
@@ -292,7 +300,7 @@ async function mapRecordNames(names, visit) {
292
300
  }
293
301
  async function readNamedRecord(root, name) {
294
302
  try {
295
- const path = join(root, name), record = await read(path);
303
+ const path = join(root, name), record = await read(path, MAX_STATE_BYTES);
296
304
  return record?.schemaVersion === 1 && recordPath(root, record.provider, record.session) === path ? record : null;
297
305
  } catch { return null; }
298
306
  }
@@ -316,7 +324,7 @@ export function createRecordReader(root = rootDirectory()) {
316
324
  const before = await stat(path, { bigint: true }), key = fingerprint(before);
317
325
  const cached = cache.get(name);
318
326
  if (cached?.key === key) return cached.record;
319
- const record = before.size <= BigInt(MAX_BYTES) ? await readNamedRecord(root, name) : null;
327
+ const record = before.size <= BigInt(MAX_STATE_BYTES) ? await readNamedRecord(root, name) : null;
320
328
  // Do not retain a value when a write races the read; the next list must
321
329
  // inspect it again. This also prevents malformed transient JSON sticking.
322
330
  const after = await stat(path, { bigint: true });
@@ -353,8 +361,9 @@ function assessment(value) {
353
361
  result.userChoice = value?.userChoice === true ? true : value?.userChoice === false ? false : null;
354
362
  return result;
355
363
  }
356
- function assessedPlan(value) {
357
- 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.');
364
+ function assessedPlan(value, managed = null) {
365
+ const flexible = flexibleReasoning(managed);
366
+ if (!value || typeof value !== 'object' || Array.isArray(value) || (!flexible && !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.');
358
367
  const alternatives = value.alternatives.map(option => {
359
368
  const id = text(option?.id, 80);
360
369
  if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) throw new Error('Invalid plan option ID.');
@@ -368,8 +377,8 @@ function assessedPlan(value) {
368
377
  if (new Set(alternatives.map(option => option.id)).size !== alternatives.length) throw new Error('Duplicate plan option ID.');
369
378
  const selectedOptionId = text(value.selectedOptionId, 80);
370
379
  if (!alternatives.some(option => option.id === selectedOptionId)) throw new Error('Selected action must match an assessed option.');
371
- const recommendation = { optionId: text(value.recommendation?.optionId, 80), reason: text(value.recommendation?.reason, 1000) };
372
- if (!alternatives.some(option => option.id === recommendation.optionId) || !recommendation.reason) throw new Error('Plan recommendation must identify an assessed option and reason.');
380
+ const recommendation = { optionId: text(value.recommendation?.optionId, 80) || (flexible ? selectedOptionId : ''), reason: text(value.recommendation?.reason, 1000) };
381
+ if (!alternatives.some(option => option.id === recommendation.optionId) || (!flexible && !recommendation.reason)) throw new Error('Plan recommendation must identify an assessed option and reason.');
373
382
  return { diagnosis: text(value.diagnosis, 2000), alternatives, selectedOptionId, recommendation, userQuestion: text(value.userQuestion, 1000) || null };
374
383
  }
375
384
  function missingAcceptanceDiagnosis(managed, missing) {
@@ -480,8 +489,9 @@ export function canExecuteAutonomousExternal(managed) {
480
489
  }
481
490
  // Shared by live Stop hooks and cold exact-session recovery. Keep the result
482
491
  // contract identical across transports; the host still verifies acceptance.
483
- export function assessedCheckpointContract(decision) {
484
- 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",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. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
492
+ export function assessedCheckpointContract(decision, managed = null) {
493
+ const observationField = flexibleReasoning(managed) ? 'Plan diagnosis, recommendation and its reason are optional when the selected action is clear. Optional observations may be omitted; do not create evidence files, citations or extra reports. Actual tool results are captured by the controller.' : 'Include evidence:["concrete observation"] in actionResult.';
494
+ 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} This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown. Missing facts are unknown. Completion claims go to SIMY independent inspection of the actual frozen acceptance conditions; no worker-authored proof is certification. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
485
495
  }
486
496
  export function understandingCheckpointContract(decision) {
487
497
  return `${minimalSufficientChangeInstruction({ turn: 'understanding' })}\nThis is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; ${decision.purpose ? "Authorized read-only API and web research is allowed within existing provider permissions; do not send private material to external services. Do not edit files, write to external services, or start the proposed next action." : "do not edit files, call external services, or start the proposed next action."} Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|semantic|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: risk low|medium|high|unknown; reversibility reversible|irreversible|unknown; externalEffect none|local|external|unknown; blastRadius single_file|repository|multi_system|unknown; uncertainty low|medium|high|unknown; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require AI investigation; reversible preferences are delegated under autonomous policy. Write objective and criteria in the original user's language. Lead objective with the intended user experience (who can do what in which situation), when grounded in the request. Criteria must describe observable outcomes for that experience, not merely code written or tests passing. Keep technical-only requests technical; do not invent new UX obligations. Preserve done and remaining as the recorded stages of the whole requested work, distinguishing reported implementation from verified outcomes. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. ${decision.search ? "For semantic outcomes use check.type semantic; reserve manual for an explicit requirement for a human decision. Missing verification tooling is work to do, not a manual condition." : "For semantic conditions use manual."} Never emit file_equals with null, omitted, guessed, or placeholder text. If exact expected contents are unknown, choose read to learn them and use semantic for purpose search or manual otherwise. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. First recover missing context with authorized reads and infer reversible defaults from the original request. Select read investigation when facts remain unknown. Only if the original goal cannot be grounded at all or irreversible personal consequences require a decision, set one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification. Optionally include sessionUnderstanding.matchingContext:{deliverables:["concrete requested output"],projectHints:["project name"],repositoryUrls:[],relatedUrls:[],actionIds:[],sourceRefs:[{platform:"slack",externalThreadId:"exact thread",channelExternalId:"exact channel"}]}. Extract only explicit conversation evidence; IDs and sources are retrieval signals, never authenticated identity. Do not infer URLs or identifiers.`;
@@ -667,7 +677,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
667
677
  const initialAction = actionDescriptor(config.initialAction);
668
678
  const autonomyPolicy = config.autonomyPolicy === undefined ? null : config.autonomyPolicy;
669
679
  if (autonomyPolicy !== null && !AUTONOMY_POLICIES.has(autonomyPolicy)) throw new Error('Invalid autonomy policy.');
670
- const initialPlan = autonomyPolicy ? assessedPlan(config.initialPlan) : null;
680
+ const initialPlan = autonomyPolicy ? assessedPlan(config.initialPlan, config) : null;
671
681
  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.');
672
682
  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.');
673
683
  const actionDueAt = config.actionDueAt === undefined ? null : typeof config.actionDueAt === 'string' ? Date.parse(config.actionDueAt) : NaN;
@@ -923,10 +933,23 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
923
933
  const result = checkpoint.actionResult;
924
934
  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.');
925
935
  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' };
926
- if (result.status === 'observed' && !actionResult.evidence.length) throw new Error('Observed action result needs concrete evidence.');
936
+ 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'));
937
+ if (flexibleReasoning(run.managed) && capturedAction) actionResult.observationSource = 'controller_tool_result';
938
+ if (result.status === 'observed' && !actionResult.evidence.length && !flexibleReasoning(run.managed)) throw new Error('Observed action result needs concrete evidence.');
939
+ // Local observations remain the worker's report until independent
940
+ // inspection. Missing paperwork alone is not an unknown side effect.
941
+ // For remote effects, keep reconciliation before any repeat operation.
942
+ if (result.status === 'observed' && !actionResult.evidence.length && flexibleReasoning(run.managed) && performed.action.kind === 'external') {
943
+ actionResult.status = 'unverified';
944
+ run.managed.executionUncertain ||= { decisionId: performed.id, cause: 'missing_external_postcondition', at: now };
945
+ if (checkpoint.status !== 'blocked') {
946
+ effectiveStatus = 'in_progress'; repairPlan = recoveryDiagnosisPlan(run.managed);
947
+ nextStep = repairPlan.alternatives[0].action.summary;
948
+ }
949
+ }
927
950
  }
928
951
  if (purposeEnabled(run)) {
929
- const workerPlan = checkpoint.plan ? assessedPlan(checkpoint.plan) : null;
952
+ const workerPlan = checkpoint.plan ? assessedPlan(checkpoint.plan, run.managed) : null;
930
953
  const review = validatePurposeReview(checkpoint.purposeReview, workerPlan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed);
931
954
  recordPurposeReview(run.managed, review, now);
932
955
  if (!searchEnabled(run.managed) && checkpoint.status === 'completed' && review.rules.some(rule => ['failed', 'unverified'].includes(rule.status))) {
@@ -941,16 +964,19 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
941
964
  recordPurposeProgress(run, now);
942
965
  if (searchEnabled(run.managed) && performed && actionResult) {
943
966
  const unit = evidenceLedger(run.managed).units?.find(row => row.id === performed.id);
944
- if (unit) { unit.status = actionResult.status === 'failed' ? 'failed' : actionResult.status === 'not_run' ? 'waiting' : 'observed'; unit.evidenceRefs = (toolEvidence || []).map(row => row.ref || row.digest); unit.updatedAt = now;
967
+ if (unit) { unit.status = actionResult.status === 'failed' ? 'failed' : ['not_run', 'unverified'].includes(actionResult.status) ? 'waiting' : 'observed'; unit.evidenceRefs = (toolEvidence || []).map(row => row.ref || row.digest); unit.updatedAt = now;
945
968
  if (unit.status === 'observed' && unit.criterionIds.length && unit.criterionIds.every(id=>run.managed.verified[id] && !['semantic','manual'].includes(run.managed.acceptanceCriteria.find(row=>row.id===id)?.check.type))) {unit.status='verified';unit.verificationSource='controller_automatic_criteria';}
946
969
  }
947
970
  }
948
- if (searchEnabled(run.managed) && performed && actionResult) recordSearchExperiment(run, performed, actionResult, run.managed.purpose.lastReview, expectedLease ? toolEvidence : [], now);
971
+ if (searchEnabled(run.managed) && performed && actionResult) {
972
+ recordSearchExperiment(run, performed, actionResult, run.managed.purpose.lastReview, expectedLease ? toolEvidence : [], now);
973
+ rememberPerspectives(run, run.managed.purpose.lastReview, actionResult, now, perspectiveScope(record));
974
+ }
949
975
  if (run.managed.autonomyPolicy && checkpoint.status === 'completed') {
950
976
  const missing = run.managed.acceptanceCriteria.filter(item => !['manual', 'semantic'].includes(item.check.type) && !Object.hasOwn(run.managed.verified, item.id));
951
977
  if (missing.length) {
952
978
  effectiveStatus = 'in_progress';
953
- repairPlan = checkpoint.plan && nextStep ? assessedPlan(checkpoint.plan) : missingAcceptanceDiagnosis(run.managed, missing);
979
+ repairPlan = checkpoint.plan && nextStep ? assessedPlan(checkpoint.plan, run.managed) : missingAcceptanceDiagnosis(run.managed, missing);
954
980
  nextStep ||= `Diagnose failed acceptance checks: ${missing.map(item => item.id).join(', ')}`;
955
981
  }
956
982
  }
@@ -963,7 +989,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
963
989
  }
964
990
  if (['in_progress', 'error'].includes(effectiveStatus)) {
965
991
  if (run.managed.autonomyPolicy) {
966
- const plan = repairPlan || assessedPlan(checkpoint.plan);
992
+ const plan = repairPlan || assessedPlan(checkpoint.plan, run.managed);
967
993
  const selected = plan.alternatives.find(item => item.id === plan.selectedOptionId);
968
994
  if (checkpoint.proposedAction && JSON.stringify(actionDescriptor(checkpoint.proposedAction)) !== JSON.stringify(selected.action)) throw new Error('Proposed action differs from selected assessed option.');
969
995
  recordDecision(run.managed, plan, now);
@@ -981,7 +1007,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
981
1007
  if (!text(request?.scope, 200) || !text(request?.summary, 1000)) throw new Error('Authorization blocker requires a scoped approval request.');
982
1008
  const proposed = actionDescriptor({ kind: request.kind || 'unknown', scope: request.scope, summary: request.summary });
983
1009
  if (run.managed.autonomyPolicy) {
984
- const plan = assessedPlan(checkpoint.plan);
1010
+ const plan = assessedPlan(checkpoint.plan, run.managed);
985
1011
  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.');
986
1012
  const decision = recordDecision(run.managed, plan, now);
987
1013
  if (purposeEnabled(run) && run.managed.autonomyPolicy === 'autonomous' && decision.outcome !== 'wait') {
@@ -1008,12 +1034,29 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1008
1034
  if (searchEnabled(run.managed) && checkpoint.status === 'blocked') {
1009
1035
  rememberSearchWait(run.managed, checkpoint.blocker, { reason: checkpoint.summary, question: checkpoint.question,
1010
1036
  condition: checkpoint.waitCondition?.condition, owner: checkpoint.waitCondition?.owner, action: checkpoint.approval || null }, now);
1011
- if (checkpoint.plan && checkpoint.blocker !== 'authorization' && checkpoint.plan.alternatives?.find(o => o.id === checkpoint.plan.selectedOptionId)?.independent === true) recordDecision(run.managed, assessedPlan(checkpoint.plan), now);
1037
+ if (checkpoint.plan && checkpoint.blocker !== 'authorization' && checkpoint.plan.alternatives?.find(o => o.id === checkpoint.plan.selectedOptionId)?.independent === true) recordDecision(run.managed, assessedPlan(checkpoint.plan, run.managed), now);
1012
1038
  if (checkpoint.plan && run.managed.decision?.outcome !== 'wait' && (checkpoint.blocker === 'authorization' || run.managed.plan.alternatives.find(o => o.id === run.managed.plan.selectedOptionId)?.independent)) {
1013
1039
  effectiveStatus = 'in_progress'; nextStep = run.managed.decision.action.summary;
1014
1040
  run.managed.userQuestion = null; run.managed.dependencyWait = null;
1015
1041
  }
1016
1042
  }
1043
+ const lastWork = run.managed?.workHistory?.at(-1);
1044
+ const uncertain = run.managed?.executionUncertain;
1045
+ const uncertainAction = uncertain?.action || run.managed?.workHistory?.find(item => item.decisionId === uncertain?.decisionId)?.action;
1046
+ const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
1047
+ if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === lastWork?.result?.decisionId && lastWork.result.status === 'observed' && (lastWork.result.evidence?.length || lastWork.result.observationSource === 'controller_tool_result' || localReconciliation) && lastWork.action.kind === 'read') {
1048
+ run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
1049
+ delete run.managed.executionUncertain;
1050
+ delete run.managed.recoveryDiagnosisDecisionId;
1051
+ }
1052
+ // Resolve uncertainty before queuing completion verification. Otherwise a
1053
+ // sparse completed/error checkpoint can replace the reconciliation decision
1054
+ // while leaving its lease guard behind, making all later dispatches skip.
1055
+ if (run.managed?.executionUncertain && effectiveStatus !== 'blocked') {
1056
+ effectiveStatus = 'in_progress';
1057
+ prepareRecoveryDiagnosis(run, now);
1058
+ nextStep = run.nextStep;
1059
+ }
1017
1060
  run.blockerSource = effectiveStatus === 'blocked' ? 'checkpoint' : null;
1018
1061
  run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
1019
1062
  run.revision += 1; run.updatedAt = now;
@@ -1031,14 +1074,9 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1031
1074
  run.managed.executionAttempt = { ...run.managed.executionAttempt, phase: 'checkpointed', checkpointRevision: run.revision, endedAt: now };
1032
1075
  run.managed.liveWorkspaceLease.checkpointRevision = run.revision;
1033
1076
  }
1034
- if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === checkpoint.actionResult?.decisionId && checkpoint.actionResult?.status === 'observed' && checkpoint.actionResult?.evidence?.length && run.managed.workHistory?.at(-1)?.action.kind === 'read') {
1035
- run.managed.recoveryEvidence = { decisionId: checkpoint.actionResult.decisionId, source: 'provider_reported_read', checkedAt: now };
1036
- delete run.managed.executionUncertain;
1037
- delete run.managed.recoveryDiagnosisDecisionId;
1038
- }
1039
1077
  for (const request of run.managed?.consistencyOutbox || []) if (request.decisionId && request.decisionId === checkpoint.actionResult?.decisionId) {
1040
- recordHandoffStage(request, checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed', now);
1041
- request.observedAt = now; request.result = checkpoint.actionResult.status;
1078
+ recordHandoffStage(request, run.managed.workHistory?.at(-1)?.result?.status === 'observed' ? 'inspected' : 'inspection_failed', now);
1079
+ request.observedAt = now; request.result = run.managed.workHistory?.at(-1)?.result?.status || checkpoint.actionResult.status;
1042
1080
  }
1043
1081
  notifyState(run, now);
1044
1082
  return record;
@@ -1213,7 +1251,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
1213
1251
  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.');
1214
1252
  const understanding = sessionUnderstanding(checkpoint.sessionUnderstanding);
1215
1253
  if (['clear', 'already_done'].includes(understanding.state) && result.status !== 'observed') throw new Error('Clear understanding requires an observed read result.');
1216
- const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan) : null;
1254
+ const plan = understanding.state === 'clear' ? assessedPlan(checkpoint.plan, run.managed) : null;
1217
1255
  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.');
1218
1256
  if (purposeEnabled(run)) recordPurposeReview(run.managed,
1219
1257
  validatePurposeReview(checkpoint.purposeReview, plan, run.managed.purpose.ruleIds, searchEnabled(run.managed) ? run.managed.purpose.lastReview?.rules || [] : [], run.managed), now);
@@ -1434,16 +1472,23 @@ export function deliveryProgressContext(run) {
1434
1472
  export function managedBudgetReason(run, now = Date.now(), options) {
1435
1473
  return deliveryStop(run, now, options)?.reason ?? null;
1436
1474
  }
1437
- // Only repeated failures make a task unrecoverable. A missing-progress pause is a
1438
- // review point: an explicit resume starts a fresh progress window.
1475
+ // Failure and purpose budget limits still block explicit resume. A missing-progress
1476
+ // pause is a review point: an explicit resume starts a fresh progress window.
1439
1477
  export function deliveryStopBlocksResume(run, now = Date.now()) {
1440
- return deliveryStop(run, now)?.code === STOP_CODES.failureLimit;
1478
+ return [STOP_CODES.failureLimit, 'purpose_budget'].includes(deliveryStop(run, now)?.code);
1479
+ }
1480
+ // Stopped remains terminal for hooks, polling and automatic recovery. Only the
1481
+ // explicit control action may reopen an enrolled app-server task for diagnosis.
1482
+ export function canExplicitlyResumeStopped(run) {
1483
+ return run?.state === 'stopped' && run.managed?.resumeChannel === 'codex_app_server' &&
1484
+ Boolean(run.managed.autonomyPolicy && run.managed.approvalScopes?.length) &&
1485
+ !run.managed.resumeLease && !run.managed.liveWorkspaceLease;
1441
1486
  }
1442
1487
  // Time spent waiting for a person is not missing work progress. An explicit
1443
1488
  // resume, answer or approval starts a new progress window; failures remain.
1444
1489
  function continueAfterHumanDecision(run, now) {
1445
1490
  const stop = deliveryStop(run, now);
1446
- if (stop?.code === STOP_CODES.failureLimit) throw new Error(stop.reason);
1491
+ if ([STOP_CODES.failureLimit, 'purpose_budget'].includes(stop?.code)) throw new Error(stop.reason);
1447
1492
  if (!run.managed) return;
1448
1493
  run.managed.progressWindowResets = [...(run.managed.progressWindowResets || []),
1449
1494
  { at: now, code: stop?.code ?? 'human_decision', noProgress: run.managed.noProgress }].slice(-10);
@@ -1594,7 +1639,7 @@ export function decideStop(run, now = Date.now()) {
1594
1639
  }
1595
1640
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
1596
1641
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
1597
- const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? `${minimalSufficientChangeInstruction()}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\nCheck the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
1642
+ const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected, next.managed)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? `${minimalSufficientChangeInstruction()}\n${next.nextStep}` : `${minimalSufficientChangeInstruction()}\nCheck the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
1598
1643
  return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\n${purposeContract(next.managed)}\nStay within the original request and permissions. If one action lacks authorization or information, preserve that dependency and select independent authorized work. Resume this same task; do not start a replacement loop.` } };
1599
1644
  }
1600
1645
  // A process signature protects against PID reuse. Never treat a stale timer alone
@@ -1637,14 +1682,14 @@ async function providerProcess(provider) {
1637
1682
  }
1638
1683
  return null;
1639
1684
  }
1640
- export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, env = process.env }) {
1685
+ export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, recallMemory = recallPerspectives, env = process.env }) {
1641
1686
  const link = await providerLinkState(root, provider);
1642
1687
  if (!link.connected) return {};
1643
1688
  if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
1644
1689
  let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
1645
1690
  const event = input.hook_event_name;
1646
1691
  const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
1647
- let output = {}, boundaryLease = null, claimedWorkspace = null;
1692
+ let output = {}, boundaryLease = null, claimedWorkspace = null, observed;
1648
1693
  const applyObservation = () => updateRecord(root, provider, input.session_id, async old => {
1649
1694
  const currentLink = await providerLinkState(root, provider);
1650
1695
  if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
@@ -1805,13 +1850,23 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1805
1850
  return record;
1806
1851
  });
1807
1852
  try {
1808
- if (event === 'SessionStart') await withSessionParentLock(root, applyObservation, inspectProcess);
1809
- else await applyObservation();
1853
+ if (event === 'SessionStart') observed = await withSessionParentLock(root, applyObservation, inspectProcess);
1854
+ else observed = await applyObservation();
1810
1855
  } catch (error) {
1811
1856
  if (claimedWorkspace?.release) await claimedWorkspace.release();
1812
1857
  throw error;
1813
1858
  }
1814
1859
  if (boundaryLease) await releaseWorkspaceLease({ root, ...boundaryLease });
1860
+ if (output.decision === 'block' && flexibleReasoning(observed?.run?.managed)) {
1861
+ // Optional cross-task disk reads must not hold the task's control lock.
1862
+ const memory = await recallMemory({ root, record: observed });
1863
+ const current = await readRecord(root, provider, input.session_id);
1864
+ // A stop or another checkpoint during retrieval supersedes this decision.
1865
+ if (current?.run?.id !== observed.run.id ||
1866
+ deliveryDecisionRevision(current?.run) !== deliveryDecisionRevision(observed.run) ||
1867
+ current.run.state !== observed.run.state) return {};
1868
+ output.reason += '\n' + perspectiveInstruction(memory);
1869
+ }
1815
1870
  return output;
1816
1871
  }
1817
1872
  export function canReplanDelivery(run, provider, now = Date.now()) {
@@ -1850,9 +1905,8 @@ export function deliveryDecisionRevision(run) {
1850
1905
  const revision = run.revision + controls;
1851
1906
  return Number.isSafeInteger(revision) ? revision : undefined;
1852
1907
  }
1853
- export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, expectedRunId, expectedRevision, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, purposeUpdate, action, inspectProcess = processIdentity, now = Date.now() }) {
1854
- for (let retry = 0; ; retry += 1) {
1855
- try { return await updateRecord(root, provider, session, async record => {
1908
+ 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() }) {
1909
+ return updateRecord(root, provider, session, async record => {
1856
1910
  const run = record?.run;
1857
1911
  if (!run) throw new Error('Delivery Loop not found.');
1858
1912
  if ((expectedRunId !== undefined || expectedRevision !== undefined) &&
@@ -1996,10 +2050,10 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1996
2050
  }
1997
2051
  }
1998
2052
  else if (action === 'resume') {
1999
- delete run.stopReason;
2053
+ const wasStopped = canExplicitlyResumeStopped(run);
2000
2054
  if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
2001
2055
  if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
2002
- if (!['blocked', 'recovery_required', 'paused'].includes(run.state)) throw new Error('Only a blocked, paused, or recoverable loop can resume.');
2056
+ if (!['blocked', 'recovery_required', 'paused'].includes(run.state) && !wasStopped) throw new Error('Only a blocked, paused, recoverable, or stopped managed app-server loop without a live lease can resume.');
2003
2057
  if (run.managed && run.state === 'blocked' && run.blocker === 'missing_information') {
2004
2058
  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);
2005
2059
  run.blocker = null;
@@ -2007,7 +2061,44 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2007
2061
  if (run.managed?.executionUncertain) { prepareRecoveryDiagnosis(run, now); run.blocker = null; }
2008
2062
  if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
2009
2063
  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.');
2064
+ if (wasStopped) {
2065
+ const link = await providerLinkState(root, provider);
2066
+ if (!link.connected) throw new Error('Restore the existing provider connection before resuming.');
2067
+ // A stopped write may have taken effect even when its old receipt lacks
2068
+ // executionUncertain. Always inspect first, never replay its decision.
2069
+ const decision = recordDecision(run.managed, recoveryDiagnosisPlan(run.managed), now);
2070
+ if (decision.outcome !== 'proceed') throw new Error('Read-only recovery diagnosis needs human review.');
2071
+ run.managed.recoveryDiagnosisDecisionId = decision.id;
2072
+ run.nextStep = decision.action.summary;
2073
+ run.resumeHistory ||= [];
2074
+ run.resumeHistory.push({ at: now, source: 'explicit_resume', priorState: run.state,
2075
+ reason: run.reason, stopReason: run.stopReason ?? null, checkpoint: run.checkpoint ?? null });
2076
+ if (run.managed.purpose) {
2077
+ const epoch = randomUUID();
2078
+ record.autoContinue = true; record.autoContinueEpoch = epoch;
2079
+ record.tracked = true; record.monitor = true;
2080
+ run.managed.autoContinueEpoch = epoch;
2081
+ }
2082
+ }
2083
+ if (resumeObservation) {
2084
+ const link = await providerLinkState(root, provider);
2085
+ if (!link.connected || link.epoch !== resumeObservation.providerEpoch) throw new Error('Provider connection changed before resume.');
2086
+ if (provider !== 'codex' || run.managed?.resumeChannel !== 'codex_app_server' ||
2087
+ resumeObservation.session !== session || resumeObservation.cwd !== record.cwd ||
2088
+ resumeObservation.source !== 'codex_app_server' || !text(resumeObservation.model, 200)) {
2089
+ throw new Error('Exact original session observation changed before resume.');
2090
+ }
2091
+ if (run.managed.resumeModel !== resumeObservation.model) {
2092
+ run.managed.modelHistory ||= [];
2093
+ run.managed.modelHistory.push({ at: now, from: run.managed.resumeModel,
2094
+ to: resumeObservation.model, source: 'explicit_resume_provider_observation' });
2095
+ run.managed.resumeModel = resumeObservation.model;
2096
+ // Follow the user's current native model. Never refresh sandbox,
2097
+ // connector permissions or the frozen enrollment authority here.
2098
+ }
2099
+ }
2010
2100
  continueAfterHumanDecision(run, now);
2101
+ delete run.stopReason;
2011
2102
  if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
2012
2103
  run.managed.approvedAction.dispatchedAt = now;
2013
2104
  run.managed.dispatches ||= [];
@@ -2045,13 +2136,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
2045
2136
  }
2046
2137
  } else throw new Error('Unsupported control.');
2047
2138
  run.updatedAt = now; notifyState(run, now); return commitControl();
2048
- }); } catch (error) {
2049
- // Explicit user controls wait briefly for a live short state writer;
2050
- // they never reclaim unknown owners or broaden execution authority.
2051
- if (retry >= 20 || !/^(?:Delivery state is busy; retry the command\.|Delivery state lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
2052
- await new Promise(resolve => setTimeout(resolve, 10));
2053
- }
2054
- }
2139
+ });
2055
2140
  }
2056
2141
  export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity, records: suppliedRecords } = {}) {
2057
2142
  const records = suppliedRecords || await listRecords(root);