@awak-app/simy-cli 0.7.11 → 0.7.13

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/package.json +1 -1
  2. package/src/agent.js +50 -8
  3. package/src/auto-update.js +32 -9
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  6. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  7. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  8. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  9. package/src/delivery-hooks/supervisor.js +39 -10
  10. package/src/desktop-host.js +54 -8
  11. package/src/desktop-status.js +1 -1
  12. package/src/direct-executor.js +104 -4
  13. package/src/local-task.js +14 -3
  14. package/src/runner.js +19 -4
  15. package/src/session-supervision/claude-sessions.js +57 -5
  16. package/src/session-supervision/codex-app-server.js +20 -8
  17. package/src/sqm/claude-plugin.js +5 -0
  18. package/src/sqm/review-worker.js +15 -6
  19. package/src/update-detachment/claude-observation.js +33 -0
  20. package/src/update-detachment/codex-context.js +27 -0
  21. package/src/update-detachment/coordinator.js +117 -0
  22. package/src/update-detachment/dispatch-gate.js +1 -0
  23. package/src/update-detachment/ledger-adapter.js +62 -0
  24. package/src/update-detachment/ledger-reader.js +42 -0
  25. package/src/update-detachment/provenance.js +1 -0
  26. package/src/update-detachment/provider-adapters.js +33 -0
  27. package/src/update-detachment/reconcile.js +105 -0
  28. package/src/update-detachment/reconciliation-adapter.js +13 -0
  29. package/src/update-detachment/runtime.js +319 -0
  30. package/src/update-detachment/state-store.js +1 -0
  31. package/src/update-detachment/worker-entry.js +145 -0
  32. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  33. package/src/update-detachment/worker-ledger.js +138 -0
  34. package/src/update-detachment/worker-storage.js +29 -0
  35. package/src/update-detachment/worker.js +167 -0
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
1
3
  import { normalizePullRequests } from './pull-requests.js';
2
4
  import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
3
5
  import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
@@ -18,7 +20,7 @@ import { createInterface } from 'node:readline';
18
20
  import { homedir } from 'node:os';
19
21
  import { basename, isAbsolute, join, relative, sep } from 'node:path';
20
22
  import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecutable } from '../backend-executable.js';
21
- import { canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
23
+ import { hasPendingUserStop, recordPath, rootDirectory, canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
22
24
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
23
25
  import { JsonDataUrlFilter } from './json-data-url-filter.js';
24
26
  import { RECENT_SESSION_LIMIT } from './discovery-window.js';
@@ -625,6 +627,10 @@ async function frozenNativeSandbox(record, root, signal) {
625
627
  }
626
628
 
627
629
  export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
630
+ const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
631
+ let dispatchAttemptId;
632
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: record.run.managed.taskId, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
633
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
628
634
  const run = record.run, decision = run.managed?.decision;
629
635
  if(run.managed?.enrollment?.nativeAuthorityDigest && run.managed.enrollment.nativeAuthorityDigest!==createHash('sha256').update(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource])).digest('hex'))throw new CodexAppServerError('permission_narrowed','Frozen native enrollment authority changed; do not dispatch with the altered ceiling.');
630
636
  if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
@@ -660,11 +666,16 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
660
666
  prompt: peerOpinionPrompt(record, sourceContext, peerAction),
661
667
  output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
662
668
  }, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
669
+ dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance }, attemptId => {
670
+ if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
671
+ dispatchAttemptId = attemptId;
672
+ return initiate();
673
+ }),
663
674
  beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
664
675
  onContext: session => { if (peerSession && peerSession !== session) ambiguousContext = true; peerSession = session; },
665
676
  onUsage: observed => { usage = observed; onUsage(observed); } });
666
677
  if (!peerSession || peerSession === record.session || ambiguousContext || typeof reply.output?.opinion !== 'string' || !reply.output.opinion.trim()) throw new CodexAppServerError('identity', 'Independent peer context or opinion unavailable.');
667
- return { code: 0, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
678
+ return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
668
679
  }
669
680
  // Connector schemas can exceed ordinary checkpoint limits; keep review pages
670
681
  // bounded to one server and a separate finite transport ceiling.
@@ -851,9 +862,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
851
862
  });
852
863
  signal?.addEventListener('abort', cancelled, { once: true });
853
864
  client.child.once('close', closed);
854
- turnStart = client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
855
- model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) })
856
- .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
865
+ const targetProvenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: targetSession, worktree: isolated ? executionCwd : record.cwd, taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
866
+ turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
867
+ model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
868
+ .then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (turnId) queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId }); if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
857
869
  startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
858
870
  await startReceipt;
859
871
  startRecorded = true;
@@ -908,14 +920,14 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
908
920
  review.findings.push(criterion ? { id: 'workspace-audit-binding', requirementId: `criterion:${criterion.id}`, source: 'controller_workspace_inventory', owner: 'controller', gap, verification: 'Establish the correct authorized workspace scope and independently bind its actual artifacts before acceptance.', reopenReason: 'The current workspace identity is unavailable or its scope changed.' } : gap);
909
921
  }
910
922
  review = assessIndependentInspection(review, { toolEvidence, observations: reviewObservations, criteria: effectiveAcceptanceCriteria(run.managed), flexible: flexibleReasoning(run.managed) });
911
- return { code: 0, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
923
+ return { code: 0, dispatchAttemptId, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
912
924
  }
913
925
  const currentEvidence = beforeEvidence ? await captureWorkspace() : null;
914
926
  const controllerEvidence = beforeEvidence ? { source: 'adapter_capture', actionId: decision.id,
915
927
  baseline: run.managed.purpose.evidence?.baseline ? undefined : { ...beforeEvidence, kind: 'first_managed_observation', continuation: run.continuations },
916
928
  current: currentEvidence, before: beforeEvidence.digest, after: currentEvidence?.digest, receipts: toolEvidence } : undefined;
917
- if (!run.managed.purpose?.enabled) return { code: 0, checkpoint };
918
- return { code: 0, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
929
+ if (!run.managed.purpose?.enabled) return { code: 0, dispatchAttemptId, checkpoint };
930
+ return { code: 0, dispatchAttemptId, checkpoint, usage, providerTurnId: activeTurnId, providerSessionId:targetSession, toolEvidence, sourceContext, controllerEvidence };
919
931
  } catch (error) {
920
932
  if (activeTurnId) error.providerTurnId ||= activeTurnId;
921
933
  if(targetSession!==record.session)error.providerSessionId ||=targetSession;
@@ -45,11 +45,16 @@ export async function claudePluginFiles() {
45
45
  files.set("hooks/acceptance-policy.mjs", await readFile(path.join(SOURCE, "hooks/acceptance-policy.mjs"), "utf8"));
46
46
  files.set("hooks/purpose-rule-review.mjs", await readFile(path.join(SOURCE, "hooks/purpose-rule-review.mjs"), "utf8"));
47
47
  files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
48
+ for (const name of ["update-state-store.mjs", "update-dispatch-gate.mjs", "update-dispatch-identity.mjs"]) files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
48
49
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
49
50
  files.set("hooks/dev-work.mjs", await readFile(path.join(SOURCE, "hooks/dev-work.mjs"), "utf8"));
50
51
  const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
51
52
  delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
52
53
  hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);
54
+ // Observe policy/model invalidation without emitting continuation decisions.
55
+ // Supported by the pinned 2.1.201 CLI. PostModelSwitch is rejected by its
56
+ // manifest validator; Stop observations therefore cannot retain an old model.
57
+ hooks.hooks.ConfigChange = structuredClone(hooks.hooks.SessionStart);
53
58
  hooks.hooks.Notification = structuredClone(hooks.hooks.PermissionRequest);
54
59
  for (const [event, groups] of Object.entries(hooks.hooks)) {
55
60
  for (const group of groups) {
@@ -431,6 +431,7 @@ async function runSingleProviderReview(
431
431
  request,
432
432
  {
433
433
  spawnImpl = spawn,
434
+ dispatchTransport = initiate => initiate(),
434
435
  inspectExecutor = inspectDesktopExecutor,
435
436
  executionTimeoutMs = DEFAULT_EXECUTION_TIMEOUT_MS,
436
437
  environment = process.env,
@@ -463,6 +464,10 @@ async function runSingleProviderReview(
463
464
  const workDir = await mkdtemp(join(tmpdir(), "simy-sqm-review-"));
464
465
  const schemaPath = join(workDir, "output-schema.json");
465
466
  const outputPath = join(workDir, "output.json");
467
+ let transportFailure;
468
+ const fencedTransport = initiate => {
469
+ try { return dispatchTransport(initiate); } catch (error) { transportFailure = error; throw error; }
470
+ };
466
471
  try {
467
472
  await writeFile(schemaPath, JSON.stringify(request.output_schema), { mode: 0o600 });
468
473
  const prompt = [
@@ -512,7 +517,7 @@ async function runSingleProviderReview(
512
517
  "-",
513
518
  ],
514
519
  prompt,
515
- { spawnImpl, executionTimeoutMs: remaining, stallTimeoutMs: provider === "codex" && readOnlyAnalysis && allowAutomaticRetry && !stallRetried && remaining > codexAttemptTimeoutMs ? codexAttemptTimeoutMs : null, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
520
+ { spawnImpl, dispatchTransport: fencedTransport, executionTimeoutMs: remaining, stallTimeoutMs: provider === "codex" && readOnlyAnalysis && allowAutomaticRetry && !stallRetried && remaining > codexAttemptTimeoutMs ? codexAttemptTimeoutMs : null, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model, retryAttempt,
516
521
  terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000, onUsage, onContext },
517
522
  );
518
523
  selectedModel = model;
@@ -563,7 +568,7 @@ async function runSingleProviderReview(
563
568
  ...(processResult.diagnostic.retryAttempt === 1 ? { retryAttempt: 1 } : {}),
564
569
  };
565
570
  } catch (error) {
566
- if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required' || ['subscription_auth_required', 'subscription_auth_unknown'].includes(error?.code)) throw error;
571
+ if (error === transportFailure || error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required' || ['subscription_auth_required', 'subscription_auth_unknown'].includes(error?.code)) throw error;
567
572
  throw new SqmLocalReviewError(`Local ${label} review failed before producing a structured result.`, {
568
573
  code: `${provider}_local_review_failed`,
569
574
  });
@@ -585,15 +590,19 @@ function codexAnalysisArguments(system) {
585
590
  '-c', `developer_instructions=${JSON.stringify(system)}`];
586
591
  }
587
592
 
588
- function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, stallTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt, onUsage, onContext }) {
593
+ function spawnReview(command, args, prompt, { spawnImpl, dispatchTransport, executionTimeoutMs, stallTimeoutMs, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model, retryAttempt, onUsage, onContext }) {
589
594
  return new Promise((resolve, reject) => {
590
595
  if (signal?.aborted) return reject(providerFailure("analysis_cancelled"));
591
596
  let child;
592
597
  try {
593
- child = spawnImpl(command, args, {
594
- env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
598
+ child = dispatchTransport(() => {
599
+ try {
600
+ return spawnImpl(command, args, {
601
+ env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
602
+ });
603
+ } catch { throw providerFailure("provider_spawn_failed", { model }); }
595
604
  });
596
- } catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
605
+ } catch (error) { reject(error); return; }
597
606
  let stdout = "", stderrPending = "", outputBytes = 0, phase = "before_turn";
598
607
  let settled = false, terminalError = null, killTimer = null;
599
608
  let reportedTokens = -1, reportedContext = null;
@@ -0,0 +1,33 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { readClaudeSession, readClaudeBoundary } from '../session-supervision/claude-sessions.js';
3
+ import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { digest } from './state-store.js';
5
+
6
+ const fail = code => { throw Object.assign(new Error(`Update connection: ${code}`), { code }); };
7
+
8
+ // This permits observation only. Unknown policy stays unknown, and callers must
9
+ // install a durable session/task dispatch fence before releasing the root gate.
10
+ export async function observeClaudeReadOnly(record, { inspectProcess = processIdentity,
11
+ readMetadata = readClaudeSession, readConversation = null, readBoundary = readClaudeBoundary } = {}) {
12
+ const owner = record.run?.managed?.liveHookOwner || record.process;
13
+ const hook = record.hookSessionProcess, runtime = record.runtime, boundary = record.updateObservation;
14
+ if (!owner?.identity || !record.hookStartedAt || record.hookEndedAt ||
15
+ hook?.pid !== owner.pid || hook.identity !== owner.identity || await inspectProcess(owner.pid) !== owner.identity)
16
+ fail('original_claude_owner_unavailable');
17
+ if (runtime?.process?.pid !== owner.pid || runtime.process.identity !== owner.identity || typeof runtime.isRunning !== 'boolean')
18
+ fail('claude_turn_boundary_unobserved');
19
+ if (runtime.isRunning) fail('claude_turn_running');
20
+ if (boundary?.process?.pid !== owner.pid || boundary.process.identity !== owner.identity || boundary.source !== 'Stop' ||
21
+ boundary.observedAt !== runtime.observedAt || boundary.observedAt < record.hookStartedAt || boundary.backgroundIdle !== true)
22
+ fail('claude_background_boundary_unobserved');
23
+ const metadata = await readMetadata(record.session);
24
+ if (metadata?.session !== record.session || await realpath(metadata.cwd) !== await realpath(record.cwd)) fail('provider_identity_changed');
25
+ const context = readConversation ? await readConversation(metadata) : await readBoundary(metadata);
26
+ if (readConversation ? !context || context.incomplete !== false || context.truncated !== false || !Array.isArray(context.messages) : !context || context.complete !== true || !/^[a-f0-9]{64}$/.test(context.digest)) fail('claude_history_coverage_unknown');
27
+ if (await inspectProcess(owner.pid) !== owner.identity) fail('original_claude_owner_unavailable');
28
+ return { identity: { session: metadata.session, worktree: await realpath(metadata.cwd) }, model: boundary.model || null,
29
+ permissions: null, permissionMode: boundary.permissionMode || null, canResume: false, readOnlyReconnect: true,
30
+ cursor: digest([context, metadata.lastActivityAt, boundary]), historyCoverage: true, running: false,
31
+ writer: { id: owner.identity, verified: false, state: 'idle', source: 'read_only_live_hook', acquisitionRequired: true },
32
+ liveHook: { pid: owner.pid, identity: owner.identity, startedAt: record.hookStartedAt, verified: true, source: 'original_live_hook' } };
33
+ }
@@ -0,0 +1,27 @@
1
+ import { open, realpath } from 'node:fs/promises';
2
+ import { resolve, relative, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { digest } from './state-store.js';
5
+
6
+ // Read only session metadata and the newest turn_context. Never read auth.json,
7
+ // copy prompts/tool output, write provider configuration or acquire a writer.
8
+ export async function readCodexPolicyContext(file, session, worktree, { codexHome = process.env.CODEX_HOME || join(homedir(),'.codex') } = {}) {
9
+ if(typeof file!=='string')throw new Error('Codex rollout reference unavailable.');
10
+ const [base,target]=await Promise.all([realpath(codexHome),realpath(file)]);
11
+ const rel=relative(base,target); if(rel.startsWith('..') || resolve(base,rel)!==target || !/^(sessions|archived_sessions)[/\\]/.test(rel))throw new Error('Codex rollout outside existing account.');
12
+ const handle=await open(target,'r');
13
+ try {
14
+ const before=await handle.stat(); const head=Buffer.alloc(Math.min(32768,before.size));
15
+ await handle.read(head,0,head.length,0);
16
+ const size=Math.min(2*1024*1024,before.size), tail=Buffer.alloc(size);
17
+ await handle.read(tail,0,size,before.size-size);
18
+ const parse=line=>{try{return JSON.parse(line);}catch{return null;}};
19
+ const meta=head.toString().split('\n').map(parse).find(item=>item?.type==='session_meta');
20
+ const contexts=tail.toString().split('\n').map(parse).filter(item=>item?.type==='turn_context');
21
+ const context=contexts.at(-1)?.payload;
22
+ const after=await handle.stat();
23
+ if(before.size!==after.size || before.mtimeMs!==after.mtimeMs || meta?.payload?.id!==session || !context?.model ||
24
+ !context.sandbox_policy || context.approval_policy===undefined || await realpath(context.cwd)!==await realpath(worktree))throw new Error('Codex turn context is incomplete or changed.');
25
+ return {model:context.model,permissions:digest([context.sandbox_policy,context.approval_policy]),revision:digest(context)};
26
+ } finally {await handle.close();}
27
+ }
@@ -0,0 +1,117 @@
1
+ // PR3 owns sequencing only. Persistence/fencing and provider identity checks are
2
+ // supplied by PR1/PR2; absence of either must never become permission to update.
3
+ export function createUpdateCoordinator({ store, gate, boundary, reconcile, now = Date.now,
4
+ preserveConnectedOnStartup = false, initialState = 'connected', onChange = () => {}, pollMs = 50, timeoutMs = 30_000, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
5
+ let state = initialState, phase = 'unavailable', operation = null, cancelled = false;
6
+ const available = Boolean(store?.read && store?.write && gate?.close && gate?.open && boundary && reconcile);
7
+ const snapshot = () => ({ state, phase, available, busy: Boolean(operation),
8
+ canCancel: state === 'detaching', canReconnect: !operation && ['detached', 'recovery_required'].includes(state) });
9
+ const save = async (next, nextPhase, extra = {}) => {
10
+ // Await the durable commit before advertising or acting on a transition.
11
+ await store.write({ connectionState: next, phase: nextPhase, ...extra });
12
+ state = next; phase = nextPhase; onChange();
13
+ };
14
+ const requireAvailable = () => {
15
+ if (!available) throw Object.assign(new Error('Update detachment dependencies are unavailable.'), { code: 'detachment_unavailable' });
16
+ };
17
+ const exclusive = fn => {
18
+ if (operation) return Promise.reject(new Error('An update connection operation is already in progress.'));
19
+ operation = Promise.resolve().then(fn).finally(() => { operation = null; });
20
+ return operation;
21
+ };
22
+ const recover = async reason => {
23
+ state = 'recovery_required'; phase = reason;
24
+ await gate.close();
25
+ await save(state, phase);
26
+ };
27
+ const reconcileConnection = async () => {
28
+ await save('reconciling', 'identity_check');
29
+ const result = await reconcile(await store.read());
30
+ if (result?.verified !== true || result?.unresolvedAttempts !== 0) {
31
+ await recover('reconciliation_required');
32
+ return snapshot();
33
+ }
34
+ await save('ready', 'reconciled');
35
+ // Gate owns per-task eligibility: ready is not permission to resume a
36
+ // paused/blocked/waiting-human task, reset budgets, or replay an attempt.
37
+ await gate.open({ resumeTasks: false });
38
+ await save('connected', 'complete');
39
+ return snapshot();
40
+ };
41
+ return {
42
+ snapshot,
43
+ acceptsNewWork: () => state === 'connected',
44
+ async initialize() {
45
+ if (!available) return snapshot();
46
+ if (operation) throw new Error('An update connection operation is already in progress.');
47
+ // Fence admission synchronously and reserve the operation before reading
48
+ // disk: startup participates in the same exclusion as detach/reconnect.
49
+ state = 'reconciling'; phase = 'startup';
50
+ return exclusive(async () => {
51
+ try {
52
+ const record = await store.read();
53
+ if (preserveConnectedOnStartup && record?.connectionState === 'connected') { state = 'connected'; phase = 'complete'; return snapshot(); }
54
+ await gate.close();
55
+ // Missing state may mean a process died before its first checkpoint.
56
+ state = record?.connectionState || 'recovery_required';
57
+ phase = record?.phase || 'startup';
58
+ if (state !== 'connected') { await recover('interrupted_update'); return snapshot(); }
59
+ return await reconcileConnection();
60
+ } catch (error) { await recover('startup_failed'); throw error; }
61
+ });
62
+ },
63
+ async detachUpdate(update) {
64
+ requireAvailable();
65
+ if (state !== 'connected') return Promise.reject(new Error('Reconnect the original conversation before updating.'));
66
+ if (operation) throw new Error('An update connection operation is already in progress.');
67
+ // Synchronous local admission closure precedes the first asynchronous IO.
68
+ state = 'detaching'; phase = 'closing_admission'; cancelled = false;
69
+ return exclusive(async () => {
70
+ try {
71
+ await gate.close();
72
+ await save('detaching', 'draining');
73
+ const deadline = now() + timeoutMs;
74
+ while (true) {
75
+ const remaining = deadline - now();
76
+ if (remaining <= 0) throw new Error('Safe update boundary timed out.');
77
+ let timeout;
78
+ const proof = await Promise.race([
79
+ Promise.resolve().then(boundary),
80
+ new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('Safe update boundary timed out.')), remaining); }),
81
+ ]).finally(() => clearTimeout(timeout));
82
+ if (cancelled) { await recover('cancelled'); return snapshot(); }
83
+ if (now() >= deadline) throw new Error('Safe update boundary timed out.');
84
+ if (proof?.safe === true && proof.pendingWrites === 0 && proof.unsettledPermissions === 0 &&
85
+ (proof.liveExecutions === 0 || proof.workerHandoffVerified === true)) break;
86
+ await sleep(pollMs);
87
+ }
88
+ await save('detached', 'checkpoint_committed');
89
+ if (cancelled) { await recover('cancelled'); return snapshot(); }
90
+ // The updater returns only after installation and candidate health
91
+ // verification; it owns rollback. An ambiguous outcome stays fenced.
92
+ await save('detached', 'updating');
93
+ const result = await update();
94
+ if (result?.connectionTransferred === true && result?.healthy === true) { state = 'connected'; phase = 'complete'; return snapshot(); }
95
+ if (result?.healthy !== true) throw new Error('Updated runtime health was not verified.');
96
+ return await reconcileConnection();
97
+ } catch (error) {
98
+ try { await recover('update_failed'); } catch { error.recoveryFailed = true; }
99
+ throw error;
100
+ }
101
+ });
102
+ },
103
+ async reconnect() {
104
+ requireAvailable();
105
+ if (operation) throw new Error('An update connection operation is already in progress.');
106
+ state = 'reconciling'; phase = 'closing_admission';
107
+ return exclusive(async () => {
108
+ try { await gate.close(); return await reconcileConnection(); }
109
+ catch (error) { await recover('reconnect_failed'); throw error; }
110
+ }); },
111
+ cancel() {
112
+ if (state !== 'detaching') throw new Error('Cancellation is only available before detachment.');
113
+ cancelled = true;
114
+ return snapshot();
115
+ },
116
+ };
117
+ }
@@ -0,0 +1 @@
1
+ export * from '../codex-plugins/simy/hooks/update-dispatch-gate.mjs';
@@ -0,0 +1,62 @@
1
+ import { join } from 'node:path';
2
+ import { acquireLock } from './state-store.js';
3
+ import { detachmentRoot, readDispatchGate, transitionConnection, prepareUpgradeFence, DISPATCH_PROTOCOL, readTaskAdmission, setTaskAdmissionFence } from './dispatch-gate.js';
4
+
5
+ // Adapt the published PR1 root barrier without creating a competing ledger.
6
+ // Identity/result verification and safe-boundary proof are mandatory PR2/host
7
+ // dependencies; this module never fabricates either from a task status label.
8
+ export function createLedgerCoordinatorAdapters({ root = detachmentRoot(), boundary, reconcile, readWriters } = {}) {
9
+ let releaseOwner = null;
10
+ const release = () => { releaseOwner?.(); releaseOwner = null; };
11
+ const transition = connection => {
12
+ const current = readDispatchGate(root);
13
+ if (current.connection !== connection) transitionConnection({ root, expectedRevision: current.revision, connection });
14
+ };
15
+ const recovery = () => {
16
+ const current = readDispatchGate(root).connection;
17
+ if (current === 'recovery_required') return;
18
+ if (['connected', 'ready'].includes(current)) transition('detaching');
19
+ if (readDispatchGate(root).connection === 'detached') transition('reconciling');
20
+ transition('recovery_required');
21
+ };
22
+ return {
23
+ store: {
24
+ read: async () => { const ledger = readDispatchGate(root); return { ...ledger, connectionState: ledger.connection }; },
25
+ write: async patch => {
26
+ const next = patch.connectionState;
27
+ // Reconnect is a verification path, not an assertion that an active
28
+ // owner drained. Move through recovery instead of claiming detached.
29
+ if (next === 'reconciling' && readDispatchGate(root).connection === 'detaching') recovery();
30
+ if (next === 'recovery_required') recovery();
31
+ else transition(next);
32
+ if (['connected', 'recovery_required'].includes(next)) release();
33
+ },
34
+ },
35
+ gate: {
36
+ close: async () => {
37
+ // Hold ownership across async drain, update and identity verification.
38
+ // A second process cannot replace a newer barrier using stale proof.
39
+ releaseOwner ||= acquireLock(join(root, 'update-detachment', 'coordinator'));
40
+ if (['connected', 'ready'].includes(readDispatchGate(root).connection)) {
41
+ if(readWriters) { const writers=await readWriters(),current=readDispatchGate(root);prepareUpgradeFence({root,expectedRevision:current.revision,writers}); }
42
+ else transition('detaching');
43
+ }
44
+ },
45
+ open: async ({ resumeTasks }) => {
46
+ if (resumeTasks !== false || readDispatchGate(root).connection !== 'ready') throw new Error('Verified ready ledger required.');
47
+ transition('connected');
48
+ },
49
+ fenceTask: async ({identity,taskId,reason}) => {
50
+ if(!releaseOwner || !['detaching','reconciling'].includes(readDispatchGate(root).connection)) throw new Error('Coordinator ownership and closed admission required.');
51
+ const writers=await readWriters(),current=readTaskAdmission({root,identity,taskId});
52
+ // This is the explicit coordinator authority while its exclusive
53
+ // lease is held, never a refresh of an old execution dispatch owner.
54
+ return setTaskAdmissionFence({owner:{root,generation:current.generation,protocol:DISPATCH_PROTOCOL},identity,taskId,
55
+ expectedRevision:current.revision,expectedFenceRevision:current.record?.revision ?? null,reason,writers});
56
+ },
57
+ },
58
+ handoff: release,
59
+ boundary,
60
+ reconcile,
61
+ };
62
+ }
@@ -0,0 +1,42 @@
1
+ import { createStateStore, digest } from './state-store.js';
2
+ import { readDispatchGate } from './dispatch-gate.js';
3
+ import { normalizeDispatchIdentity, listDispatchRegistrations } from './provenance.js';
4
+
5
+ // PR1 stores references, not copied task authority. Read the original record
6
+ // and include all three revisions in the sample; never synthesize missing state.
7
+ export function ledgerSnapshotReader({ identity, taskId, readTaskSnapshot, readGate = readDispatchGate }) {
8
+ identity = Object.freeze(structuredClone(identity));
9
+ const exactIdentity = normalizeDispatchIdentity(identity);
10
+ const exactHashes = new Set([digest(identity), digest(exactIdentity)]);
11
+ const store = createStateStore(identity);
12
+ return async () => {
13
+ const scope = store.read();
14
+ const registration = scope.tasks[digest(taskId)];
15
+ if (!registration?.recordRef) throw new Error('Task not registered in exact scope');
16
+ const task = await readTaskSnapshot(registration.recordRef);
17
+ if (!task || !Number.isSafeInteger(task.revision) || task.revision < 0 ||
18
+ !Object.keys(identity).every(key => task.identity?.[key] === identity[key])) throw new Error('Task identity unavailable');
19
+ const gate = readGate(identity.root);
20
+ const knownScopes = new Set(listDispatchRegistrations(identity.root, { taskId, retained: true }).map(item=>digest(item.identity)));
21
+ const attempts = Object.values(gate.attempts).filter(attempt => {
22
+ if(attempt.taskHash!==digest(taskId))return false;
23
+ if(exactHashes.has(attempt.identityHash))return true;
24
+ if(attempt.identityHash && knownScopes.has(attempt.identityHash))return false;
25
+ // Unbound and unregistered evidence is still ambiguous, never silently
26
+ // attributed to another provider or discarded as someone else's work.
27
+ throw new Error('Attempt identity mismatch');
28
+ }).map(attempt => {
29
+ if (!exactHashes.has(attempt.identityHash)) throw new Error('Attempt identity mismatch');
30
+ const receipts = Object.values(gate.receipts).filter(receipt => receipt.attemptId === attempt.id);
31
+ if (receipts.some(receipt => receipt.generation !== attempt.generation)) throw new Error('Receipt generation mismatch');
32
+ const ids = [...new Set(receipts.map(receipt => receipt.providerExecutionId).filter(value => value != null))];
33
+ const refs = [...new Set(receipts.map(receipt => receipt.resultRef).filter(value => value != null))];
34
+ if (ids.length > 1 || refs.length > 1) throw new Error('Conflicting attempt receipts');
35
+ const state = attempt.phase === 'provider_identified' ? 'sent' : attempt.phase;
36
+ return { id: attempt.id, state, generation: attempt.generation,
37
+ ...(ids.length ? { providerExecutionId: ids[0] } : {}), ...(refs.length ? { resultRef: refs[0] } : {}) };
38
+ });
39
+ return { ...structuredClone(task), revision: digest([scope.revision, gate.revision, task]),
40
+ generation: gate.generation, identity: structuredClone(identity), attempts };
41
+ };
42
+ }
@@ -0,0 +1 @@
1
+ export * from '../codex-plugins/simy/hooks/update-dispatch-identity.mjs';
@@ -0,0 +1,33 @@
1
+ import { readCodexSession } from '../session-supervision/codex-app-server.js';
2
+ import { readClaudeSession } from '../session-supervision/claude-sessions.js';
3
+ import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+
5
+ // The existing owner supplies account/origin, policy, history, artifact and
6
+ // execution evidence as one revisioned observation. No new process is opened.
7
+ // Codex thread/read alone does not establish account, permissions or writer.
8
+ export function codexReconciliationAdapter({ client, readObservation, readSession = readCodexSession }) {
9
+ return { async observe(snapshot) {
10
+ if (snapshot.identity.provider !== 'codex') throw new Error('Wrong adapter');
11
+ const metadata = await readSession(client, snapshot.identity.session);
12
+ const observed = await readObservation(snapshot);
13
+ if (metadata?.session !== snapshot.identity.session || metadata.cwd !== snapshot.identity.worktree ||
14
+ metadata.model !== observed?.policy?.model || observed?.writer?.source !== 'existing_owner') throw new Error('Unverified Codex owner');
15
+ return observed;
16
+ } };
17
+ }
18
+
19
+ export function claudeReconciliationAdapter({ readObservation, readSession = readClaudeSession, inspectProcess = processIdentity }) {
20
+ return { async observe(snapshot) {
21
+ if (snapshot.identity.provider !== 'claude') throw new Error('Wrong adapter');
22
+ const metadata = await readSession({ session: snapshot.identity.session, cwd: snapshot.identity.worktree });
23
+ const observed = await readObservation(snapshot);
24
+ const hook = snapshot.liveHook;
25
+ if (metadata?.session !== snapshot.identity.session || metadata.cwd !== snapshot.identity.worktree ||
26
+ metadata.observation?.source !== 'claude_local_index' || !hook?.identity ||
27
+ !Number.isSafeInteger(hook.pid) || hook.pid < 2 ||
28
+ observed?.liveHook?.source !== 'original_live_hook' ||
29
+ observed.liveHook.startedAt !== hook.startedAt || observed.liveHook.pid !== hook.pid || observed.liveHook.identity !== hook.identity ||
30
+ await inspectProcess(hook.pid) !== hook.identity) throw new Error('Original Claude process unavailable');
31
+ return { ...observed, liveHook: { ...observed.liveHook, verified: true } };
32
+ } };
33
+ }
@@ -0,0 +1,105 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ const IDENTITY = ['provider', 'session', 'account', 'origin', 'worktree', 'device', 'root'];
4
+ const POLICY = ['model', 'permissions', 'approvals', 'budget', 'deadline'];
5
+ const STOPPED = new Set(['stopped', 'paused', 'paused_by_user', 'cancelled']);
6
+ const ATTEMPT_STATES = new Set(['intent', 'sending', 'sent', 'running', 'result_saved', 'confirmed']);
7
+ const present = value => typeof value === 'string' && value.length > 0;
8
+ const own = (object, key) => Object.hasOwn(object || {}, key);
9
+
10
+ // Pure assessment: no persistence, writer acquisition, provider turn or replay.
11
+ export function assessReconciliation(snapshot, observation, { now = Date.now(), maxAgeMs = 5_000, connectionOnly = false } = {}) {
12
+ const taskState = snapshot?.task?.state;
13
+ const stopped = STOPPED.has(taskState);
14
+ const result = (status, reasons, evidence = {}) => ({
15
+ status, reasons, connectionState: status === 'ready' ? 'ready' : 'recovery_required',
16
+ taskState, preserveStop: stopped, canReleaseGate: status === 'ready' && !stopped,
17
+ discardNextStep: status !== 'ready', replayAttempts: false,
18
+ nextAction: status === 'already_running' ? 'wait_original_provider' :
19
+ status === 'changed' ? 'review_current_state' : status === 'recovery_required' ? 'inspect_original_conversation' : 'none',
20
+ evidence,
21
+ });
22
+ const recovery = reason => result('recovery_required', [reason]);
23
+ if (!snapshot || !observation || !IDENTITY.every(key => present(snapshot.identity?.[key]))) return recovery('snapshot_identity_missing');
24
+ if (!IDENTITY.every(key => observation.identity?.[key] === snapshot.identity[key])) return recovery('provider_identity_mismatch');
25
+ if (!['codex', 'claude'].includes(snapshot.identity.provider)) return recovery('unsupported_provider');
26
+ if (!present(taskState) || !present(snapshot.revision) || !Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) return recovery('snapshot_invalid');
27
+ if (!POLICY.every(key => own(snapshot.policy, key) && snapshot.policy[key] !== undefined && own(observation.policy, key) && observation.policy[key] !== undefined) || !present(snapshot.policy.model) || !present(observation.policy.model)) return recovery('policy_unobserved');
28
+ if (observation.generation !== snapshot.generation) return recovery('owner_generation_mismatch');
29
+ if (!Number.isFinite(now) || !Number.isFinite(maxAgeMs) || maxAgeMs < 0 || !Number.isFinite(observation.observedAt) ||
30
+ observation.observedAt > now || now - observation.observedAt > maxAgeMs) return recovery('observation_stale');
31
+ if (observation.consistent !== true || !present(observation.revision)) return recovery('observation_unstable');
32
+ const history = observation.history;
33
+ if (!present(snapshot.historyCursor) || history?.incomplete !== false || history.truncated !== false ||
34
+ history.fromCursor !== snapshot.historyCursor || !present(history.cursor) || !Array.isArray(history.events) ||
35
+ history.events.some(event => !present(event?.id)) || new Set(history.events.map(event => event.id)).size !== history.events.length) return recovery('history_cursor_unverified');
36
+ if (!Array.isArray(snapshot.artifacts) || !Array.isArray(observation.artifacts) ||
37
+ !Array.isArray(snapshot.attempts) || !Array.isArray(observation.executions)) return recovery('evidence_unobserved');
38
+ const writer = observation.writer;
39
+ if (snapshot.identity.provider === 'codex') {
40
+ const readOnlyBoundary = connectionOnly === true && writer?.source === 'read_only_boundary' && writer.acquisitionRequired === true && writer.verified === false && writer.state === 'idle' && writer.id === snapshot.identity.session && snapshot.writer?.id === snapshot.identity.session;
41
+ if (!readOnlyBoundary && (!present(snapshot.writer?.id) || writer?.verified !== true || writer.id !== snapshot.writer.id ||
42
+ !['idle', 'running'].includes(writer.state))) return recovery('codex_writer_unverified');
43
+ } else if (observation.liveHook?.verified !== true ||
44
+ observation.liveHook.pid !== snapshot.liveHook?.pid || observation.liveHook.identity !== snapshot.liveHook?.identity ||
45
+ observation.liveHook.startedAt !== snapshot.liveHook?.startedAt || !present(observation.liveHook.identity) ||
46
+ !Number.isSafeInteger(observation.liveHook.pid) || observation.liveHook.pid < 2 || !present(observation.liveHook.startedAt) ||
47
+ !['idle', 'running'].includes(writer?.state)) return recovery('claude_live_hook_unverified');
48
+
49
+ const attempts = snapshot.attempts;
50
+ if (attempts.some(attempt => !present(attempt?.id) || !ATTEMPT_STATES.has(attempt.state)) ||
51
+ new Set(attempts.map(attempt => attempt.id)).size !== attempts.length) return recovery('attempt_ledger_invalid');
52
+ if (observation.executions.some(execution => !present(execution?.id) || !['running', 'completed', 'failed', 'cancelled'].includes(execution.state)) ||
53
+ new Set(observation.executions.map(execution => execution.id)).size !== observation.executions.length) return recovery('execution_evidence_invalid');
54
+ const executionIds = attempts.filter(attempt => attempt.state !== 'intent').map(attempt => attempt.providerExecutionId);
55
+ if (new Set(executionIds).size !== executionIds.length) return recovery('attempt_execution_ambiguous');
56
+ if (observation.executions.some(execution => !attempts.some(attempt => attempt.state !== 'intent' &&
57
+ attempt.providerExecutionId === execution.id && attempt.id === execution.attemptId))) return recovery('untracked_execution');
58
+ const matches = [];
59
+ for (const attempt of attempts) {
60
+ if (attempt.state === 'intent') return recovery('attempt_intent_unresolved');
61
+ if (!present(attempt.providerExecutionId)) return recovery('attempt_send_outcome_unknown');
62
+ const execution = observation.executions.find(item => item.id === attempt.providerExecutionId);
63
+ if (!execution || execution.attemptId !== attempt.id) return recovery('attempt_execution_unmatched');
64
+ if (['completed', 'failed', 'cancelled'].includes(execution.state) && !present(execution.resultRef)) return recovery('execution_result_missing');
65
+ if (['result_saved', 'confirmed'].includes(attempt.state) &&
66
+ (execution.state !== 'completed' || !present(attempt.resultRef) || attempt.resultRef !== execution.resultRef)) return recovery('saved_result_mismatch');
67
+ matches.push({ attemptId: attempt.id, providerExecutionId: execution.id, state: execution.state,
68
+ ...(execution.resultRef ? { resultRef: execution.resultRef } : {}) });
69
+ }
70
+ const evidence = { writerAcquisitionRequired: connectionOnly === true && writer.acquisitionRequired === true, historyCursor: history.cursor, attempts: matches,
71
+ artifacts: structuredClone(observation.artifacts), currentPolicy: Object.fromEntries(POLICY.map(key => [key, structuredClone(observation.policy[key])])) };
72
+ // These observations update the plan; they never authorize the old plan.
73
+ const changed = POLICY.filter(key => !isDeepStrictEqual(snapshot.policy[key], observation.policy[key]));
74
+ if (changed.length || history.events.length || history.cursor !== snapshot.historyCursor ||
75
+ !isDeepStrictEqual(snapshot.artifacts, observation.artifacts)) return result('changed', ['manual_or_provider_progress', ...changed.map(key => `${key}_changed`)], evidence);
76
+ if (writer.state === 'running' || matches.some(item => item.state === 'running')) return result('already_running', ['original_provider_running'], evidence);
77
+ if (matches.some(item => ['failed', 'cancelled'].includes(item.state))) return result('recovery_required', ['execution_did_not_complete'], evidence);
78
+ if (attempts.length && attempts.every(attempt => attempt.state !== 'intent') && matches.every(item => item.state === 'completed')) return result('completed', ['execution_results_observed'], evidence);
79
+ if (stopped) return result('changed', ['user_stop_preserved'], evidence);
80
+ if (taskState !== 'active') return result('changed', ['task_not_runnable'], evidence);
81
+ return result('ready', ['identity_history_writer_and_policy_verified'], evidence);
82
+ }
83
+
84
+ // readSnapshot is PR1's read-only normalized view, not a state-store implementation.
85
+ // Two ledger/observation samples fence concurrent manual edits and owner changes.
86
+ export async function reconcileSession({ readSnapshot, adapter, now = Date.now, maxAgeMs, connectionOnly = false }) {
87
+ try {
88
+ if (typeof readSnapshot !== 'function' || typeof adapter?.observe !== 'function') throw new Error('Missing reader');
89
+ const snapshot = structuredClone(await readSnapshot());
90
+ const first = structuredClone(await adapter.observe(structuredClone(snapshot)));
91
+ const observation = structuredClone(await adapter.observe(structuredClone(snapshot)));
92
+ const current = await readSnapshot();
93
+ if (!isDeepStrictEqual(snapshot, current) || !isDeepStrictEqual({ ...first, observedAt: undefined }, { ...observation, observedAt: undefined })) return {
94
+ status: 'recovery_required', reasons: ['concurrent_change'], connectionState: 'recovery_required',
95
+ taskState: current?.task?.state, preserveStop: STOPPED.has(current?.task?.state), canReleaseGate: false,
96
+ discardNextStep: true, replayAttempts: false, nextAction: 'inspect_original_conversation', evidence: {},
97
+ };
98
+ return assessReconciliation(snapshot, observation, { now: now(), maxAgeMs, connectionOnly });
99
+ } catch {
100
+ // Provider errors can contain private transcript/authentication data.
101
+ return { status: 'recovery_required', reasons: ['observation_unavailable'], connectionState: 'recovery_required',
102
+ canReleaseGate: false, preserveStop: true, discardNextStep: true, replayAttempts: false,
103
+ nextAction: 'inspect_original_conversation', evidence: {} };
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ import { reconcileSession } from './reconcile.js';
2
+
3
+ // Use PR2's assessment without interpreting a changed/stopped/unknown task as
4
+ // permission to release the root barrier. The caller supplies the registered
5
+ // scope reader and original-provider observation adapter, never a new session.
6
+ export function createCoordinatorReconciler({ readSnapshot, adapter, now, maxAgeMs } = {}) {
7
+ if (typeof readSnapshot !== 'function' || typeof adapter?.observe !== 'function') return undefined;
8
+ return async () => {
9
+ const result = await reconcileSession({ readSnapshot, adapter, now, maxAgeMs });
10
+ return { verified: result.status === 'ready' && result.canReleaseGate === true,
11
+ unresolvedAttempts: result.status === 'ready' ? 0 : null, assessment: result };
12
+ };
13
+ }