@awak-app/simy-cli 0.6.26 → 0.6.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.6.26";
16
+ export const CLI_VERSION = "0.6.28";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -1,5 +1,22 @@
1
- // Frozen manual acceptance is an explicit enrollment boundary. Its description
2
- // or an old auto-continue switch cannot authorize a different verifier.
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const acceptanceIdentity = criterion => createHash('sha256').update(JSON.stringify(criterion)).digest('hex');
4
+ // A description cannot establish consent to replace a frozen verifier in any
5
+ // language. Only a user's delegation naming this exact criterion can do so.
6
+ export function legacyIndependentCriterion(criterion, delegatedIds = []) {
7
+ return criterion.check?.type === 'manual' && delegatedIds.includes(criterion.id) && Boolean(criterion.description?.trim());
8
+ }
9
+
10
+ export const policyContractIdentity = managed => createHash('sha256').update(JSON.stringify([managed.taskId, managed.enrollment || null, managed.acceptanceCriteria, managed.approvalScopes, managed.grants || null, managed.autoContinueEpoch || null])).digest('hex');
11
+ export const autonomousPolicyAuthorized = managed => managed?.policyAuthorization?.source === 'explicit_user_runtime_policy' && managed.policyAuthorization.revokedAt === undefined &&
12
+ managed.policyAuthorization.taskId === managed.taskId && managed.policyAuthorization.contractDigest === policyContractIdentity(managed);
13
+
3
14
  export function effectiveAcceptanceCriteria(managed) {
4
- return managed?.acceptanceCriteria || [];
15
+ return (managed?.acceptanceCriteria || []).map(criterion => {
16
+ const route = managed.acceptanceRoutes?.[criterion.id];
17
+ const eligible = autonomousPolicyAuthorized(managed) && managed.autonomyPolicy === 'autonomous' && managed.purpose?.version === 2 &&
18
+ managed.purpose.decisionPolicy === 'llm-first-hc102' && route?.source === 'explicit_user_runtime_policy' &&
19
+ route.authorizationId === managed.policyAuthorization.id && route.criterionDigest === acceptanceIdentity(criterion) && legacyIndependentCriterion(criterion, managed.policyAuthorization.independentCriterionIds);
20
+ return eligible ? { ...criterion, check: { type: 'semantic' }, originalCheck: criterion.check } : criterion;
21
+ });
5
22
  }
@@ -1183,7 +1183,7 @@ export async function verifyAutomaticCriteria(record, now) {
1183
1183
  evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
1184
1184
  };
1185
1185
  const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
1186
- for (const criterion of managed.acceptanceCriteria) {
1186
+ for (const criterion of effectiveAcceptanceCriteria(managed)) {
1187
1187
  if (criterion.check.type === 'manual') {
1188
1188
  if (managed.verified[criterion.id]?.source !== 'user') delete managed.verified[criterion.id];
1189
1189
  continue;
@@ -1511,7 +1511,7 @@ async function providerProcess(provider) {
1511
1511
  }
1512
1512
  return null;
1513
1513
  }
1514
- export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
1514
+ export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, env = process.env }) {
1515
1515
  const link = await providerLinkState(root, provider);
1516
1516
  if (!link.connected) return {};
1517
1517
  if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
@@ -1524,6 +1524,16 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1524
1524
  if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1525
1525
  if (old && old.cwd !== cwd) return old;
1526
1526
  const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
1527
+ // Inherited provider identity links an AI-launched conversation to its
1528
+ // origin. Never infer parentage from a shared workspace or nearby time.
1529
+ if (event === 'SessionStart' && !record.parentSession) {
1530
+ const parentProvider = env.SIMY_PARENT_PROVIDER || (provider === 'claude' && env.CODEX_THREAD_ID ? 'codex' : null);
1531
+ const parentId = env.SIMY_PARENT_SESSION || (parentProvider === 'codex' ? env.CODEX_THREAD_ID : null);
1532
+ if (['codex', 'claude'].includes(parentProvider) && /^[0-9a-f-]{36}$/i.test(parentId || '') &&
1533
+ !(parentProvider === provider && parentId === input.session_id)) {
1534
+ record.parentSession = { provider: parentProvider, session: parentId };
1535
+ }
1536
+ }
1527
1537
  const owner = record.run?.managed?.liveHookOwner;
1528
1538
  if (owner && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event) &&
1529
1539
  (!identity || identity.pid !== owner.pid || identity.identity !== owner.identity)) {
@@ -1780,6 +1790,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1780
1790
  }
1781
1791
  else if (action === 'set_policy') {
1782
1792
  if (!run.managed?.autonomyPolicy || !run.managed.plan || TERMINAL.has(run.state) || !AUTONOMY_POLICIES.has(autonomyPolicy)) throw new Error('Only a nonterminal assessed managed task can change autonomy policy.');
1793
+ if (run.managed.policyAuthorization) run.managed.policyAuthorization = { ...run.managed.policyAuthorization, revokedAt: now, revokedBy: 'user_set_policy' };
1783
1794
  run.managed.autonomyPolicy = autonomyPolicy;
1784
1795
  const decision = recordDecision(run.managed, run.managed.plan, now);
1785
1796
  if (decision.outcome === 'wait') { run.state = 'blocked'; run.reason = decision.reason; escalate(run, decision.reason); }
@@ -1,3 +1,4 @@
1
+ import { adoptAutonomousPolicy } from '../session-supervision/autonomous-upgrade.js';
1
2
  import { deliveryEvidence } from './evidence.js';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery, readRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
@@ -25,10 +26,24 @@ async function readCheckpointInput(input) {
25
26
  return Buffer.concat(chunks).toString('utf8');
26
27
  }
27
28
 
29
+ function parseDeliveryOptions(args, allowed) {
30
+ const options = {};
31
+ for (let i = 0; i < args.length; i += 2) {
32
+ const key = args[i]?.slice(2);
33
+ if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
34
+ options[key] = args[i + 1];
35
+ }
36
+ return options;
37
+ }
38
+
28
39
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
29
40
  const [action, ...args] = argv;
30
41
  if (action === 'runtime') {
31
- if (args.length !== 1 || !['install', 'status', 'workflows', 'desktop-install', 'desktop-activate', 'watch', 'watchdog-install', 'legacy-hooks', 'doctor'].includes(args[0])) throw new Error('Use simy delivery runtime install|status|workflows|desktop-install|doctor.');
42
+ if (args[0] === 'policy-adopt') {
43
+ const options = parseDeliveryOptions(args.slice(1), new Set(['task-id', 'criterion-id']));
44
+ output(JSON.stringify(await adoptAutonomousPolicy({ root, taskId: options['task-id'], criterionId: options['criterion-id'] }), null, 2)); return 0;
45
+ }
46
+ if (args.length !== 1 || !['install', 'status', 'workflows', 'desktop-install', 'desktop-activate', 'watch', 'watchdog-install', 'legacy-hooks', 'policy-adopt', 'doctor'].includes(args[0])) throw new Error('Use simy delivery runtime install|status|workflows|desktop-install|doctor.');
32
47
  if (args[0] === 'legacy-hooks') { output(JSON.stringify(await routeLegacyHooks({ root: dirname(root) }), null, 2)); return 0; }
33
48
  if (args[0] === 'watchdog-install') { output(JSON.stringify(await installRuntimeWatchdog({ root: dirname(root) }), null, 2)); return 0; }
34
49
  if (args[0] === 'desktop-activate') { output(JSON.stringify(await activateLocalDesktop({ root: dirname(root) }), null, 2)); return 0; }
@@ -63,6 +78,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
63
78
  if (!action || action === '--help') {
64
79
  output('simy delivery runtime install|status|workflows — install an immutable local candidate, inspect its version/digest, or refresh existing plugin sources/cache while preserving enabled state and hook trust. Never enables a task.');
65
80
  output('simy delivery runtime doctor|desktop-install|desktop-activate|watch — audit live host/state/monitor alignment, or prepare a separate local desktop using the verified fixed runtime. Does not start or resume tasks.');
81
+ output('simy delivery runtime policy-adopt [--task-id ID [--criterion-id ID]] — explicitly delegate decisions within existing permissions. Manual acceptance stays manual unless the user names the exact task and criterion to delegate its verifier.');
66
82
  output('simy delivery supervise [--once] — service already opted-in tasks with this runtime; refuses an active desktop host.');
67
83
  output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
68
84
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
@@ -72,13 +88,8 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
72
88
  output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--execution-cwd PATH] [--max-failures 3]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Standard start tracks checkpoints and reports done without certifying acceptance. Managed start freezes the original objective, optional user-selected deliveryLevel, and acceptance specification. Desktop Continue infers the original conversation goal first. Continue adopts an eligible ordinary loop with the same goal and failure budget; its first managed action reconciles prior work read-only. --execution-cwd explicitly binds the task directory while provider identity retains its original folder. Resume preserves original task identity, history and approvals.');
73
89
  return 0;
74
90
  }
75
- const options = {};
76
91
  const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'execution-cwd', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
77
- for (let i = 0; i < args.length; i += 2) {
78
- const key = args[i]?.replace(/^--/, '');
79
- if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
80
- options[key] = args[i + 1];
81
- }
92
+ const options = parseDeliveryOptions(args, allowed);
82
93
  const base = { root, cwd, provider: options.provider, session: options.session };
83
94
  let result;
84
95
  if (action === 'evidence') {
@@ -122,5 +122,5 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
122
122
 
123
123
  export async function consistencyMonitorStatus(root, now = Date.now()) {
124
124
  const status = await read(join(root, 'consistency', 'monitor.json'));
125
- return status ? { ...status, health: status.lastFailure ? 'failed' : status.lastAttemptAt > (status.lastSuccessAt || 0) ? 'interrupted' : now - status.lastSuccessAt > 3 * 60_000 ? 'stale' : 'healthy' } : { health: 'not_started' };
125
+ return status ? { ...status, health: status.lastFailure ? 'failed' : status.lastAttemptAt > (status.lastSuccessAt || 0) ? now - (status.lastHeartbeatAt || status.lastAttemptAt) < 90_000 ? 'auditing' : 'interrupted' : now - (status.lastHeartbeatAt || status.lastSuccessAt) > 3 * 60_000 ? 'stale' : 'healthy' } : { health: 'not_started' };
126
126
  }
@@ -53,7 +53,8 @@ export async function tickRuntimeWatchdog({ root, now = Date.now(), run = exec,
53
53
  host: null, monitor: null, error: null, action: 'observe' };
54
54
  try {
55
55
  state.host = await observeHost({ root });
56
- state.monitor = await observeMonitor(join(root, 'delivery-hooks'), now);
56
+ const monitor = await observeMonitor(join(root, 'delivery-hooks'), now);
57
+ state.monitor = Object.fromEntries(['health', 'ownerPid', 'lastAttemptAt', 'lastSuccessAt', 'lastHeartbeatAt', 'lastFailure', 'metrics'].map(key => [key, monitor[key] ?? null]));
57
58
  const active = await read(join(root, 'desktop-runtime/active.json'));
58
59
  if (active?.suspendedBy === 'user_quit') state.action = 'user_quit_preserved';
59
60
  if (active && !active.suspendedBy && state.host.state !== 'matched' && now - (previous.lastLaunchAt || 0) >= 60_000) {
@@ -96,6 +97,9 @@ export async function installRuntimeWatchdog({ root, home = homedir(), run = exe
96
97
  await run('/usr/bin/plutil', ['-lint', plist]);
97
98
  const domain = `gui/${process.getuid()}`;
98
99
  try { await run('/bin/launchctl', ['bootout', `${domain}/${label}`]); } catch { /* First install has no job. */ }
99
- await run('/bin/launchctl', ['bootstrap', domain, plist]);
100
+ for (let attempt = 0; ; attempt++) {
101
+ try { await run('/bin/launchctl', ['bootstrap', domain, plist]); break; }
102
+ catch (error) { if (attempt >= 2) throw error; await delay(250 * (attempt + 1)); }
103
+ }
100
104
  return { label, plist, scope: 'activated_local_desktop_only' };
101
105
  }
@@ -172,7 +172,7 @@ export function parseLegacyAgentArguments(args) {
172
172
  const previous = agentArgs[index - 1];
173
173
  return previous !== "--host" && previous !== "--port";
174
174
  });
175
- if (subcommand === "sqm" || subcommand === "update") return null;
175
+ if (subcommand === "sqm" || subcommand === "update" || subcommand === "delivery" && agentArgs[agentArgs.indexOf("delivery") + 1] !== "supervise" || agentArgs.includes("--version") || agentArgs.includes("-v")) return null;
176
176
  let webOrigin = null;
177
177
  const hostIndex = argv.indexOf("--host", entryIndex + 1);
178
178
  const inlineHost = argv.slice(entryIndex + 1).find((value) => value.startsWith("--host="));
@@ -1,5 +1,6 @@
1
- import { createHash } from 'node:crypto';
2
- import { automaticContinuationAllowed, queuePurposeDiagnosis, queuePurposeVerification } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { automaticContinuationAllowed, listRecords, updateRecord, queuePurposeDiagnosis, queuePurposeVerification } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { autonomousPolicyAuthorized, policyContractIdentity, acceptanceIdentity, legacyIndependentCriterion, effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
3
4
  import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
4
5
 
5
6
  export const AUTONOMOUS_STATE_CONTRACT = 2;
@@ -21,6 +22,12 @@ export function upgradeEligible(record) {
21
22
  !(record.run.state === 'paused' && pauseOrigin(record.run) === 'user');
22
23
  }
23
24
 
25
+ export function policyAdoptionPending(record) {
26
+ const m = record?.run?.managed;
27
+ return autonomousPolicyAuthorized(m) && (m.autonomyPolicy !== 'autonomous' || m.acceptanceCriteria.some(c => !m.verified[c.id] && legacyIndependentCriterion(c, m.policyAuthorization.independentCriterionIds) &&
28
+ (m.acceptanceRoutes?.[c.id]?.source !== 'explicit_user_runtime_policy' || m.acceptanceRoutes[c.id].authorizationId !== m.policyAuthorization.id || m.acceptanceRoutes[c.id].criterionDigest !== acceptanceIdentity(c))));
29
+ }
30
+
24
31
  // Controller-only, idempotent migration. Frozen criteria and grants remain
25
32
  // byte-identical, including each criterion's original verifier.
26
33
  export function upgradeAutonomousRecord(record, { rules, now, metadata } = {}) {
@@ -28,28 +35,79 @@ export function upgradeAutonomousRecord(record, { rules, now, metadata } = {}) {
28
35
  const run = record.run, m = run.managed, previousState = run.state;
29
36
  if (metadata?.session !== record.session || metadata.cwd !== record.cwd || !metadata.model ||
30
37
  metadata.model !== (m.resumeModel || record.admission.model)) return { changed: false, reason: 'original_channel_unverified' };
38
+ if (metadata.isRunning !== false) return { changed: false, reason: 'original_not_idle' };
31
39
  if (!m.approvalScopes?.length) return { changed: false, reason: 'authorized_read_scope_missing' };
32
40
  const before = digest({ purpose: m.purpose, autonomyPolicy: m.autonomyPolicy, routes: m.acceptanceRoutes, channel: m.resumeChannel, contractVersion: m.controllerContractVersion });
33
41
  const original = digest({ objective: run.objective, criteria: m.acceptanceCriteria, level: m.deliveryLevel, grants: m.grants });
34
42
  m.purpose = { ...m.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102',
35
43
  failureBudgetMode: purposeFailureMode(record), ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id),
36
44
  recoveries: m.purpose?.recoveries || [] };
45
+ if (autonomousPolicyAuthorized(m)) {
46
+ m.autonomyPolicy = 'autonomous'; m.acceptanceRoutes ||= {};
47
+ for (const criterion of m.acceptanceCriteria) if (!m.verified[criterion.id] && legacyIndependentCriterion(criterion, m.policyAuthorization.independentCriterionIds)) {
48
+ const expected = acceptanceIdentity(criterion), route = m.acceptanceRoutes[criterion.id];
49
+ if (route?.criterionDigest !== expected || route.authorizationId !== m.policyAuthorization.id || route.source !== 'explicit_user_runtime_policy') m.acceptanceRoutes[criterion.id] = {
50
+ source: 'explicit_user_runtime_policy', authorizationId: m.policyAuthorization.id, mode: 'independent', criterionDigest: expected, at: now };
51
+ }
52
+ }
37
53
  m.controllerContractVersion = AUTONOMOUS_STATE_CONTRACT;
38
54
  m.resumeChannel = 'codex_app_server'; m.resumeModel = metadata.model; m.capability = 'codex_app_server';
39
55
  const after = digest({ purpose: m.purpose, autonomyPolicy: m.autonomyPolicy, routes: m.acceptanceRoutes, channel: m.resumeChannel, contractVersion: m.controllerContractVersion });
40
56
  if (before !== after) {
41
57
  m.policyMigrations = [...(m.policyMigrations || []), { version: AUTONOMOUS_STATE_CONTRACT, at: now, originalContract: original,
42
- previous: before, applied: after, source: 'existing_auto_continue_consent' }].slice(-20);
58
+ previous: before, applied: after, source: autonomousPolicyAuthorized(m) ? 'explicit_user_runtime_policy' : 'existing_auto_continue_consent' }].slice(-20);
43
59
  m.controllerContractVersion = AUTONOMOUS_STATE_CONTRACT;
44
60
  }
61
+ if (autonomousPolicyAuthorized(m) && run.state === 'paused' && pauseOrigin(run) === 'system' && m.dependencyWait &&
62
+ !m.pendingApproval && !m.userQuestion && !m.understandingQuestion && !m.nativeWait && !m.providerWarning && !m.executionUncertain) {
63
+ run.stopReason = { actor: 'system', code: 'legacy_no_progress', at: now, evidence: run.reason };
64
+ run.state = 'blocked'; run.blocker = 'external_dependency';
65
+ run.reason = 'Monitoring the original dependency and permitted independent work; no authority or prior effect is assumed.';
66
+ m.purpose.nextDependencyCheckAt = now;
67
+ }
45
68
  const canRecover = metadata.isRunning === false && !m.pendingApproval && !m.userQuestion &&
46
69
  !m.understandingQuestion && !m.dependencyWait && !m.providerWarning && !m.nativeWait && !m.executionUncertain;
47
70
  if (canRecover && run.state === 'paused' && pauseOrigin(run) === 'system') {
48
71
  run.stopReason = { actor: 'system', code: 'legacy_no_progress', at: now, evidence: run.reason };
49
72
  run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
50
73
  queuePurposeDiagnosis(run, 'Independently diagnose the original outcome after a system no-progress pause. Preserve prior effects, failure history and budgets.', `upgrade:${original}`, now);
51
- } else if (canRecover && run.state === 'verification_pending' && m.acceptanceCriteria.some(c => c.check.type === 'semantic' && !m.verified[c.id])) {
74
+ } else if (canRecover && run.state === 'verification_pending' && effectiveAcceptanceCriteria(m).some(c => c.check.type === 'semantic' && !m.verified[c.id])) {
52
75
  queuePurposeVerification(run, 'Existing outcome awaits independent evidence inspection; inspect original instructions and actual artifacts, not a worker completion claim.', now);
53
76
  }
54
77
  return { changed: before !== after || run.state !== previousState, reason: 'applied', originalContract: original };
55
78
  }
79
+
80
+ // Explicit CLI user control; never inferred from installation, ON, descriptions
81
+ // or a worker checkpoint. It records intent; migration waits for verified idle
82
+ // authority, and retains all original grants and budgets.
83
+ export async function adoptAutonomousPolicy({ root, taskId, criterionId, now = Date.now() } = {}) {
84
+ if (criterionId && !taskId) throw new Error('Delegating an acceptance verifier requires an exact task ID.');
85
+ const snapshots = await listRecords(root);
86
+ if (taskId && snapshots.filter(record => record.run?.managed?.taskId === taskId).length !== 1) throw new Error('Exact managed task not found or ambiguous.');
87
+ const receipts = [];
88
+ for (const snapshot of snapshots) {
89
+ const m = snapshot.run?.managed;
90
+ if (taskId && m?.taskId !== taskId) continue;
91
+ if (criterionId && !m?.acceptanceCriteria?.some(c => c.id === criterionId && c.check.type === 'manual')) throw new Error('Exact manual acceptance criterion not found.');
92
+ if (snapshot.provider !== 'codex' || !m || !snapshot.autoContinue || !automaticContinuationAllowed(snapshot) || snapshot.providerArchived ||
93
+ ['stopped', 'completed', 'reported_done', 'budget_exhausted', 'failed'].includes(snapshot.run.state) ||
94
+ snapshot.run.state === 'paused' && pauseOrigin(snapshot.run) === 'user') continue;
95
+ const expected = policyContractIdentity(m);
96
+ const saved = await updateRecord(root, snapshot.provider, snapshot.session, current => {
97
+ if (!current?.autoContinue || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== snapshot.autoContinueEpoch ||
98
+ current.run?.id !== snapshot.run.id || policyContractIdentity(current.run.managed) !== expected ||
99
+ ['stopped', 'completed', 'reported_done', 'budget_exhausted', 'failed'].includes(current.run.state) ||
100
+ current.run.state === 'paused' && pauseOrigin(current.run) === 'user') return current;
101
+ const authorized = autonomousPolicyAuthorized(current.run.managed);
102
+ const independentCriterionIds = authorized ? current.run.managed.policyAuthorization.independentCriterionIds || [] : [];
103
+ if (!authorized || criterionId && !independentCriterionIds.includes(criterionId)) current.run.managed.policyAuthorization = {
104
+ id: randomUUID(), source: 'explicit_user_runtime_policy', taskId: m.taskId, contractDigest: expected,
105
+ independentCriterionIds: [...new Set([...independentCriterionIds, ...(criterionId ? [criterionId] : [])])],
106
+ decisionPolicy: 'llm-first-hc102', at: now, effects: 'existing_scopes_and_permissions_only' };
107
+ return current;
108
+ });
109
+ if (autonomousPolicyAuthorized(saved?.run?.managed)) receipts.push({ provider: snapshot.provider, session: snapshot.session, taskId: m.taskId,
110
+ authorizationId: saved.run.managed.policyAuthorization.id, stateUnchanged: true });
111
+ }
112
+ return { policy: 'llm-first-hc102', scope: 'existing_opted_in_codex_tasks', receipts };
113
+ }
@@ -22,7 +22,7 @@ function rememberMetadata(path, value) {
22
22
  }
23
23
  function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
24
24
  return {
25
- provider: 'claude', session: id, cwd: metadata.cwd, title: metadata.title,
25
+ provider: 'claude', session: id, ...(metadata.parentSessionId ? { parentSession: { provider: 'claude', session: metadata.parentSessionId } } : {}), cwd: metadata.cwd, title: metadata.title,
26
26
  source: 'claude_cli', lastActivityAt,
27
27
  observation: { source: 'claude_local_index', observedAt },
28
28
  availability: 'unsupported', model: null,
@@ -60,13 +60,14 @@ function metadataFromPrefix(bytes, id) {
60
60
  const text = bytes.toString('utf8');
61
61
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
62
62
  if (!complete) return null;
63
- let cwd = '', title = '', customTitle = '', found = false;
63
+ let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false;
64
64
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
65
65
  if (!line) continue;
66
66
  let item;
67
67
  try { item = JSON.parse(line); } catch { return null; }
68
68
  if (unscopedFileHistory(item)) continue;
69
69
  if (item.sessionId !== id) return null;
70
+ if (UUID.test(item.parentSessionId || '') && item.parentSessionId !== id) parentSessionId = item.parentSessionId;
70
71
  if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
71
72
  if (item.type !== 'user' && item.type !== 'assistant') continue;
72
73
  if (item.isSidechain === true) return null;
@@ -77,7 +78,7 @@ function metadataFromPrefix(bytes, id) {
77
78
  if (!title) title = sessionTitle(item.slug);
78
79
  found = true;
79
80
  }
80
- return found ? { cwd, title: customTitle || title } : null;
81
+ return found ? { cwd, title: customTitle || title, parentSessionId } : null;
81
82
  }
82
83
 
83
84
  // /rename appends a custom-title envelope, often beyond the bounded prefix.
@@ -195,6 +195,8 @@ export function codexThreadMetadata(thread) {
195
195
  if (thread.parentThreadId) return null;
196
196
  return {
197
197
  provider: 'codex', session: thread.id, cwd: thread.cwd,
198
+ ...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
199
+ ? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
198
200
  title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
199
201
  source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
200
202
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
@@ -494,7 +496,7 @@ async function reviewConnectorInventory(client, threadId) {
494
496
  const servers = [], seen = new Set();
495
497
  let cursor = null;
496
498
  do {
497
- const page = await client.request('mcpServerStatus/list', { threadId, cursor, limit: 1 });
499
+ const page = await client.request('mcpServerStatus/list', { ...(threadId ? { threadId } : {}), cursor, limit: 1 });
498
500
  if (!Array.isArray(page?.data) || servers.length + page.data.length > 1000) throw new CodexAppServerError('unsupported', 'Review connector inventory is unavailable.');
499
501
  servers.push(...page.data); cursor = page.nextCursor || null;
500
502
  if (cursor && seen.has(cursor)) throw new CodexAppServerError('unsupported', 'Review connector pagination is inconsistent.');
@@ -536,7 +538,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
536
538
  // Connector schemas can exceed ordinary checkpoint limits; keep review pages
537
539
  // bounded to one server and a separate finite transport ceiling.
538
540
  const client = await openClient({ signal, ...(isolated ? { maxLineBytes: 8 * 1024 * 1024 } : {}) });
539
- let targetSession = record.session, reviewBaseline = null, reviewObservations = [], reviewInventory = [];
541
+ let targetSession = record.session, reviewBaseline = null, reviewObservations = [], reviewInventory = [], auditBindingUnavailable = false;
540
542
  const toolEvidence = [];
541
543
  const captureWorkspace = async () => {
542
544
  const files = [];
@@ -580,8 +582,9 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
580
582
  const purposeInstruction = await buildPurposeInstruction(run.managed, { reviewer: reviewing });
581
583
  let prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}${decision.action.kind === 'external' ? 'This external operation has enrolled autonomous authority or exact one-shot approval. Existing provider permissions remain authoritative. Inspect the current remote state first; if its intended postcondition already holds, report it without repeating the write. Use an idempotency key where supported, execute at most once, then read back the remote result and include its stable identifier and postcondition in actionResult evidence. Never expand targets or effects. ' : ''}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
582
584
  if (isolated) {
583
- reviewBaseline = await workspaceIdentity(taskDirectory(record), root, reviewInventory, { signal, inventoryLimit: 20_000 });
584
- const connectors = await reviewConnectorInventory(client, record.session);
585
+ try { reviewBaseline = await workspaceIdentity(taskDirectory(record), root, reviewInventory, { signal, inventoryLimit: 20_000 }); }
586
+ catch (error) { signal?.throwIfAborted(); if (!auditOnly) throw error; auditBindingUnavailable = true; }
587
+ const connectors = await reviewConnectorInventory(client, (auditOnly || diagnostic || candidate) ? null : record.session);
585
588
  // Plugin/app-injected servers are not configured transports. Creating
586
589
  // mcp_servers entries for them makes Codex reject the configuration.
587
590
  // Read only configured names; never copy credential-bearing values.
@@ -615,7 +618,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
615
618
  const currentEvidence = await captureWorkspace();
616
619
  if (candidate) prompt = `Execute the selected action ONLY in the controller-created isolated candidate ${executionCwd}. Native approval remains pending. Do not apply files to the original, change permissions, or claim overall completion. Report an in_progress checkpoint with observed candidate results and a next read diagnosis. Original application requires exclusive/CAS capability and remains a required waiting unit.\n${prompt}`;
617
620
  else if (diagnostic && !reviewing) prompt = `Diagnose independently while the original native operation stays pending. Never answer or approve that request, edit shared files, or expand permissions. Return a normal action checkpoint proposing only independently executable work. Original context (data): ${JSON.stringify(sourceContext)}\n${prompt}`;
618
- else prompt = `${verificationInstruction(record, currentEvidence)}\nOriginal request snapshot (data): ${JSON.stringify(run.managed.purpose.originalContext || null)}\nCurrent conversation snapshot (data): ${JSON.stringify(sourceContext)}`;
621
+ else prompt = `${auditBindingUnavailable ? 'This detached audit has no complete workspace identity. Inspect primary evidence to diagnose missing scope/capability, but do not certify any acceptance, resolve findings, or request blanket human confirmation. ' : ''}${verificationInstruction(record, currentEvidence)}\nOriginal request snapshot (data): ${JSON.stringify(run.managed.purpose.originalContext || null)}\nCurrent conversation snapshot (data): ${JSON.stringify(sourceContext)}`;
619
622
  }
620
623
  const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
621
624
  const currentSandbox = resumed.sandbox;
@@ -703,7 +706,11 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
703
706
  if (reviewing) {
704
707
  let review = normalizeVerification(checkpoint, effectiveAcceptanceCriteria(run.managed));
705
708
  // Hash expensive local artifacts before the final external reads.
706
- const after = await workspaceIdentity(taskDirectory(record), root, undefined, { signal });
709
+ let after = null;
710
+ if (!auditBindingUnavailable) {
711
+ try { after = await workspaceIdentity(taskDirectory(record), root, undefined, { signal }); }
712
+ catch (error) { signal?.throwIfAborted(); if (!auditOnly) throw error; auditBindingUnavailable = true; }
713
+ }
707
714
  if ((review.passed || review.needsHuman) && reviewObservations.length) {
708
715
  const refreshed = await refreshPurposeObservations(run.managed, reviewObservations, { collect: observeExternal,
709
716
  cwd: executionCwd, signal, authorize: async () => { await beforeDispatch(resumed.sandbox); signal?.throwIfAborted(); },
@@ -717,7 +724,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
717
724
  if (refreshed.changed) { review.passed = false; review.needsHuman = false; review.findings.push('External result changed or became unavailable during review. Inspect the refreshed controller observations and reassess the outcome; do not repeat the external write.'); }
718
725
  }
719
726
  const fullBefore = reviewBaseline;
720
- review.manifest = certifyReviewManifest(review.manifest, run.managed, toolEvidence, reviewInventory);
727
+ review.manifest = auditBindingUnavailable ? undefined : certifyReviewManifest(review.manifest, run.managed, toolEvidence, reviewInventory);
721
728
  let boundAfter = after;
722
729
  if (review.manifest?.exclusions.length) {
723
730
  // Reconstruct the baseline scope from per-path controller hashes. The
@@ -730,6 +737,14 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
730
737
  unknownDependencies: reviewInventory.truncated === true || afterInventory.truncated === true };
731
738
  }
732
739
  if (reviewBaseline !== after) { review.passed = false; review.needsHuman = false; review.findings.push('Workspace changed while reviewing; inspect the new revision and repeat verification.'); }
740
+ if (auditBindingUnavailable) {
741
+ review.passed = false; review.needsHuman = false; review.resolutions = [];
742
+ review.criteria = review.criteria.map(row => ({ ...row, passed: false }));
743
+ review.workspaceBinding = 'unavailable';
744
+ const criterion = run.managed.acceptanceCriteria[0];
745
+ const gap = 'The complete workspace identity is unavailable; this read-only diagnosis cannot certify task acceptance.';
746
+ 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);
747
+ }
733
748
  review = assessIndependentInspection(review, { toolEvidence, observations: reviewObservations });
734
749
  return { code: 0, verification: { ...review, source: 'independent_codex_session', session: targetSession, workspaceDigest: boundAfter }, usage, toolEvidence };
735
750
  }
@@ -59,6 +59,10 @@ function autoContinueState(record, isRunning, availability) {
59
59
  }[record.run.recoveryCause] || 'automatic recovery is unavailable. Review the task and explicitly resume through its existing controls.';
60
60
  return { autoContinueStatus: 'needs_user', autoContinueReason: `Existing supervision needs your attention: ${recoveryReason}` };
61
61
  }
62
+ if (record.provider === 'codex' && managed?.controllerContractVersion === 2 && managed.purpose?.decisionPolicy === 'llm-first-hc102' &&
63
+ managed.dependencyWait && !managed.userQuestion && (state === 'blocked' && record.run.blocker === 'external_dependency' || state === 'paused' && pauseOrigin(record.run) === 'system')) {
64
+ return { autoContinueStatus: 'watching', autoContinueReason: 'Monitoring dependency evidence and permitted independent work while preserving the original authority boundary.' };
65
+ }
62
66
  if (record.provider === 'codex' && managed?.controllerContractVersion === 2 && !managed.userQuestion && !managed.dependencyWait &&
63
67
  (state === 'paused' && pauseOrigin(record.run) === 'system' || state === 'verification_pending' && effectiveAcceptanceCriteria(managed).some(c => !managed.verified?.[c.id] && c.check.type === 'semantic'))) {
64
68
  return { autoContinueStatus: 'supervising', autoContinueReason: 'Inspecting the original outcome and evidence before the next authorized action.' };
@@ -227,6 +231,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
227
231
  } catch { /* Unverified process leaves runtime status unknown. */ }
228
232
  }
229
233
  return { provider: item.provider, session: item.session, cwd: item.cwd,
234
+ ...((item.parentSession || record?.parentSession) ? { parentSession: item.parentSession || record.parentSession } : {}),
230
235
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
231
236
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
232
237
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
@@ -7,7 +7,7 @@ import { openCodexAppServer, readCodexSession, observeCodexAuthentication } from
7
7
  import { realpath } from 'node:fs/promises';
8
8
  import { loadPurposeRuleSet } from './purpose-instruction.js';
9
9
  import { purposeEnabled } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
10
- import { upgradeAutonomousRecord, upgradeEligible } from './autonomous-upgrade.js';
10
+ import { upgradeAutonomousRecord, upgradeEligible, policyAdoptionPending } from './autonomous-upgrade.js';
11
11
  import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
12
12
 
13
13
  export async function observePurposeTurn(record, { openClient = openCodexAppServer } = {}) {
@@ -44,7 +44,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
44
44
  for (let record of await readRecords()) {
45
45
  try {
46
46
  let run = record.run;
47
- if (upgradeEligible(record) && (run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
47
+ if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
48
48
  let metadata, migrationLink;
49
49
  try { migrationLink = await requireProviderLink({ root, provider: record.provider }); metadata = await observeMetadata(record); }
50
50
  catch { metadata = null; }