@awak-app/simy-cli 0.6.28 → 0.6.30

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.28",
3
+ "version": "0.6.30",
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.28";
16
+ export const CLI_VERSION = "0.6.30";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -0,0 +1,14 @@
1
+ import { createHash } from 'node:crypto';
2
+ const digest = run => createHash('sha256').update(JSON.stringify([run.id, run.objective, run.revision, run.checkpoint,
3
+ run.managed?.taskId, run.managed?.acceptanceCriteria, run.managed?.verified, run.managed?.grants, run.managed?.purpose?.ruleDigest])).digest('hex');
4
+ // A compatibility fence, not a semantic proof or a cryptographic attestation.
5
+ // An older writer retaining an unknown receipt cannot certify its later revision.
6
+ export function bindControllerCompletion(run, now) {
7
+ run.managed.completionBinding = { version:1, digest:digest(run), at:now, source:'canonical_acceptance_controller' };
8
+ }
9
+ export function completionBindingCurrent(run) {
10
+ return run.managed?.completionBinding?.version===1 && run.managed.completionBinding.source==='canonical_acceptance_controller' && run.managed.completionBinding.digest===digest(run);
11
+ }
12
+ export function completionClaimUnbound(record) {
13
+ return ['completed','reported_done'].includes(record?.run?.state) && record.run.managed?.purpose?.enabled === true && !completionBindingCurrent(record.run);
14
+ }
@@ -0,0 +1,38 @@
1
+ // Durable handoff deadlines concern unowned work, never a live effect lease.
2
+ // Expiry queues diagnosis; it does not replay a write or grant permission.
3
+ export const HANDOFF_RECEIPT_MS = 2 * 60_000;
4
+ export const HANDOFF_PROGRESS_MS = 5 * 60_000;
5
+ export function recordHandoffStage(item, stage, now, details = {}) {
6
+ const previous = item.stage;
7
+ item.stage = stage;
8
+ Object.assign(item, details);
9
+ if (previous !== stage) {
10
+ item.history ||= [];
11
+ item.history.push({ from: previous || null, to: stage, at: now });
12
+ item.history = item.history.slice(-16);
13
+ item.stageStartedAt = now;
14
+ }
15
+ item.owner = stage === 'pending' ? 'independent_auditor' : ['received', 'executing'].includes(stage) ? 'task_controller' : item.owner || 'independent_auditor';
16
+ item.dueAt = ['resolved', 'superseded'].includes(stage) ? null : (item.stageStartedAt || item.createdAt || now) + (stage === 'pending' ? HANDOFF_RECEIPT_MS : HANDOFF_PROGRESS_MS);
17
+ }
18
+ export function handoffHealth(managed, now) {
19
+ const requests = managed?.consistencyOutbox || [];
20
+ const busy = Boolean(managed?.resumeLease || managed?.liveWorkspaceLease || managed?.outcomeAudit?.lease);
21
+ return requests.filter(item => !['resolved', 'superseded'].includes(item.stage)).map(item => ({
22
+ id: item.id, owner: item.owner || 'task_controller', stage: item.stage,
23
+ overdue: !busy && now >= (item.dueAt || (item.observedAt || item.receivedAt || item.createdAt || now) + HANDOFF_PROGRESS_MS),
24
+ ageMs: Math.max(0, now - (item.createdAt || now)), effectDecisionId: item.decisionId || null,
25
+ }));
26
+ }
27
+ export function handoffMetrics(records, now) {
28
+ const requests = records.flatMap(r => r.run?.managed?.consistencyOutbox || []);
29
+ const resolved = requests.filter(r => r.stage === 'resolved' && Number.isFinite(r.verifiedAt) && Number.isFinite(r.createdAt));
30
+ const audits = records.map(r => r.run?.managed?.outcomeAudit).filter(Boolean);
31
+ return { pendingHandoffs: requests.filter(r => !['resolved', 'superseded'].includes(r.stage)).length,
32
+ overdueHandoffs: records.flatMap(r => handoffHealth(r.run?.managed, now)).filter(r => r.overdue).length,
33
+ resolvedHandoffs: resolved.length, recoveryLatencyMs: resolved.length ? resolved.reduce((n,r) => n + Math.max(0,r.verifiedAt-r.createdAt),0) / resolved.length : null,
34
+ auditTokens: audits.reduce((n,a) => n + (a.tokensUsed || 0),0),
35
+ unknownAuditConsumption: audits.filter(a => a.unknownConsumption).length,
36
+ falseCompletionCount: null, unnecessaryConfirmationCount: null, duplicateEffectCount: null, costAmount: null,
37
+ measurementScope: 'retained_controller_receipts', };
38
+ }
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { bindControllerCompletion } from './completion-binding.mjs';
3
+ import { recordHandoffStage } from './consistency-handoff.mjs';
2
4
  import { retainApplicableRules } from './purpose-rule-policy.mjs';
3
5
  import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
4
6
  import { workspaceIdentity } from './purpose-workspace.mjs';
@@ -7,7 +9,7 @@ import { searchEnabled, failureLimitReached, searchState, recordSearchExperiment
7
9
  import { createHash, randomUUID } from 'node:crypto';
8
10
  import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
9
11
  import { homedir } from 'node:os';
10
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
12
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
11
13
  import { execFile } from 'node:child_process';
12
14
  import { promisify } from 'node:util';
13
15
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
@@ -63,9 +65,40 @@ function applyProviderConsent(record, link) {
63
65
  revision: (record.run.revision || 0) + 1,
64
66
  ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
65
67
  }
68
+ async function applyArchiveFence(root, record) {
69
+ if (!record) return record;
70
+ const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
71
+ let archived;
72
+ try { archived = JSON.parse(await readFile(file, 'utf8')).archived !== false; }
73
+ catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
74
+ if (!archived) return record;
75
+ return { ...record, providerArchived: true, autoContinue: false, tracked: false, monitor: false,
76
+ ...(record.run && !TERMINAL.has(record.run.state) ? { run: { ...record.run, state: 'paused',
77
+ reason: 'The original provider conversation was archived.',
78
+ ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
79
+ }
80
+ export async function recordProviderArchive(root, provider, session, archived, observedAt) {
81
+ await mkdir(root, { recursive: true, mode: 0o700 });
82
+ const file = `${recordPath(root, provider, session)}.archive-fence`;
83
+ const lock = `${file}.lock`, token = await acquireStateLock(lock, processIdentity);
84
+ const temporary = `${file}.${randomUUID()}.tmp`;
85
+ try {
86
+ let previous; try { previous = JSON.parse(await readFile(file, 'utf8')); } catch {}
87
+ if (previous?.observedAt > observedAt) return;
88
+ await writeFile(temporary, JSON.stringify({ provider, session, archived, observedAt }), { mode: 0o600 });
89
+ await rename(temporary, file);
90
+ } finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
91
+ }
66
92
  async function currentProviderConsent(root, records) {
67
93
  const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
68
- return records.map(record => applyProviderConsent(record, links[record.provider] || {}));
94
+ let archiveNames;
95
+ try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
96
+ catch { /* A failed index read falls back to fresh per-record fencing. */ }
97
+ return Promise.all(records.map(record => {
98
+ const current = applyProviderConsent(record, links[record.provider] || {});
99
+ if (archiveNames && !archiveNames.has(`${basename(recordPath(root, record.provider, record.session))}.archive-fence`)) return current;
100
+ return applyArchiveFence(root, current);
101
+ }));
69
102
  }
70
103
  export async function linkedProvider(root, provider) {
71
104
  return (await providerLinkState(root, provider)).connected === true;
@@ -175,6 +208,34 @@ async function acquireStateLock(lock, inspectProcess, depth = 0) {
175
208
  await releaseStateLock(reclaim, token);
176
209
  }
177
210
  }
211
+ export async function withSessionParentLock(root, operation, inspectProcess = processIdentity) {
212
+ await mkdir(root, { recursive: true, mode: 0o700 });
213
+ const lock = join(root, 'session-lineage.lock');
214
+ // Distinct SessionStart hooks share this lock. Wait within the hook timeout
215
+ // for short writers, without reclaiming unknown owners or replaying work.
216
+ const deadline = Date.now() + 500;
217
+ let token;
218
+ for (;;) {
219
+ try { token = await acquireStateLock(lock, inspectProcess); break; }
220
+ catch (error) {
221
+ if (Date.now() >= deadline || !/^(?:Delivery state is busy; retry the command\.|Delivery state lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
222
+ await new Promise(resolve => setTimeout(resolve, 10));
223
+ }
224
+ }
225
+ try { return await operation(); } finally { await releaseStateLock(lock, token); }
226
+ }
227
+ export async function validateSessionParent(root, child, parent) {
228
+ const key = item => `${item.provider}:${item.session}`;
229
+ const seen = new Set([key(child)]);
230
+ let cursor = parent;
231
+ for (let depth = 0; cursor; depth++) {
232
+ if (depth >= 256 || seen.has(key(cursor))) throw new Error('Parent relationship would create a cycle or exceed the ancestry limit.');
233
+ seen.add(key(cursor));
234
+ const ancestor = await readRecord(root, cursor.provider, cursor.session);
235
+ if (!ancestor?.observedAt && !ancestor?.admission?.validatedAt) throw new Error('The parent ancestry must contain observed provider conversations.');
236
+ cursor = ancestor.parentSession;
237
+ }
238
+ }
178
239
  export async function updateRecord(root, provider, session, mutate, { inspectProcess = processIdentity } = {}) {
179
240
  await mkdir(root, { recursive: true, mode: 0o700 });
180
241
  const file = recordPath(root, provider, session), lock = `${file}.lock`;
@@ -184,7 +245,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
184
245
  let temporary;
185
246
  try {
186
247
  const stored = await read(file);
187
- const previous = applyProviderConsent(stored, await providerLinkState(root, provider));
248
+ const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
188
249
  const previousBytes = stored === null ? null : JSON.stringify(stored);
189
250
  const next = await mutate(previous);
190
251
  if (next) {
@@ -210,7 +271,7 @@ export async function readRecord(root = rootDirectory(), provider, session) {
210
271
  const path = recordPath(root, provider, session);
211
272
  try {
212
273
  const record = await read(path);
213
- return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyProviderConsent(record, await providerLinkState(root, provider)) : null;
274
+ return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyArchiveFence(root, applyProviderConsent(record, await providerLinkState(root, provider))) : null;
214
275
  } catch { return null; } // Match listRecords' treatment of unreadable records.
215
276
  }
216
277
  const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
@@ -608,6 +669,9 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
608
669
  continuationPolicy: continuationMode(config.continuationPolicy),
609
670
  autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
610
671
  autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
672
+ ...(appServerChannel && admission.nativeSandbox && admission.nativeSandboxSource ? {
673
+ providerSandbox: structuredClone(admission.nativeSandbox), providerSandboxSource: structuredClone(admission.nativeSandboxSource) } :
674
+ appServerChannel ? { providerSandbox: null, providerSandboxSource: { type: 'unavailable_at_enrollment' } } : {}),
611
675
  capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
612
676
  awaitingInitialHook: provider === 'claude' && Boolean(observedProcess?.identity) && Boolean(autonomyPolicy),
613
677
  autonomyPolicy, plan: null, decision: null, decisionHistory: [], workHistory: [], escalation: null,
@@ -623,6 +687,7 @@ function freezeEnrollment(run, now) {
623
687
  ledger.charter ||= { objective: run.objective, criteria: structuredClone(run.managed.acceptanceCriteria), approvalScopes: [...run.managed.approvalScopes], frozenAt: now };
624
688
  }
625
689
  run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
690
+ ...(run.managed.providerSandboxSource ? {nativeAuthorityDigest:digest(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource]))} : {}),
626
691
  deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
627
692
  }
628
693
  export function pendingAcceptance(run) {
@@ -943,7 +1008,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
943
1008
  delete run.managed.recoveryDiagnosisDecisionId;
944
1009
  }
945
1010
  for (const request of run.managed?.consistencyOutbox || []) if (request.decisionId && request.decisionId === checkpoint.actionResult?.decisionId) {
946
- request.stage = checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed';
1011
+ recordHandoffStage(request, checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed', now);
947
1012
  request.observedAt = now; request.result = checkpoint.actionResult.status;
948
1013
  }
949
1014
  notifyState(run, now);
@@ -991,6 +1056,29 @@ export async function settlePurposeVerification({ root, provider, session, expec
991
1056
  'Required operation, effect reconciliation or original-purpose contract remains unresolved. Preserve the waiting unit and continue independent authorized work.'] };
992
1057
  }
993
1058
  if(!externalReviewIsCurrent(managed,review,now)) review={...review,passed:false,needsHuman:false,findings:[...review.findings,'Controller external evidence expired before settlement; reacquire the same authorized observations and independently re-evaluate without replaying writes.']};
1059
+ // Validate counterevidence against a disposable ledger before mutations.
1060
+ // Verifier contract failures are verifier work, never worker checkpoint errors.
1061
+ if (!review.controllerEvidenceFailure) {
1062
+ try { reconcileFindings(structuredClone(managed), review, now); }
1063
+ catch (error) {
1064
+ if (!/^(Reopening a closed finding|Finding resolution needs|Independent review left unresolved findings|A finding cannot be open and resolved\.)/.test(error.message)) throw error;
1065
+ review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: {
1066
+ code: 'independent_finding_evidence_invalid', targets: [...(review.resolutions || []).map(row => `resolution:${row.id}`), ...(review.findings || []).filter(row => row.reopenReason).map(row => `reopen:${row.id}`)],
1067
+ instruction: error.message + ' Repeat isolated verification and reconcile existing findings with primary reads. For reopening use the exact prior closure read scope.',
1068
+ requiredReads: (managed.purpose.evidence?.findings || []).filter(row => row.status !== 'open').map(row => ({ id: row.id, closedReads: row.closedReads || [] }))
1069
+ } };
1070
+ }
1071
+ }
1072
+ if (review.controllerEvidenceFailure) {
1073
+ const prior = state.verifierFailures;
1074
+ const key = digest(JSON.stringify([currentDigest, review.controllerEvidenceFailure.code, review.controllerEvidenceFailure.targets]));
1075
+ state.verifierFailures = { key, attempts: prior?.key === key ? prior.attempts + 1 : 1, at: now, failure: review.controllerEvidenceFailure };
1076
+ state.review = review;
1077
+ run.revision += 1; run.updatedAt = now;
1078
+ queuePurposeVerification(run, `${review.controllerEvidenceFailure.instruction} Invalid targets: ${(review.controllerEvidenceFailure.targets || []).join(', ')}. Use a narrow direct read containing the cited excerpt within 2000 characters.`, now);
1079
+ managed.nextAttemptAt = now + Math.min(60_000, 1000 * 2 ** Math.min(state.verifierFailures.attempts - 1, 6));
1080
+ notifyState(run, now); return record;
1081
+ }
994
1082
  const openFindings = reconcileFindings(managed, review, now);
995
1083
  const units=evidenceLedger(managed).units;
996
1084
  for (const result of (review.passed || review.needsHuman) ? review.workUnits || [] : []) {
@@ -1018,11 +1106,9 @@ export async function settlePurposeVerification({ root, provider, session, expec
1018
1106
  for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
1019
1107
  run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
1020
1108
  run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
1109
+ bindControllerCompletion(run, now);
1021
1110
  managed.autoRecovery = false;
1022
- for (const request of managed.consistencyOutbox || []) {
1023
- request.stage = 'resolved'; request.verifiedAt = now; request.reviewerSession = review.session;
1024
- request.workspaceDigest = review.workspaceDigest;
1025
- }
1111
+ settleCompletionHandoffs(run, now, review);
1026
1112
  } else if (pending.length && pending.every(c => c.check.type === 'manual')) {
1027
1113
  run.state = 'verification_pending'; run.reason = 'Only explicitly human acceptance remains.'; state.phase = 'waiting';
1028
1114
  run.checkpoint = { status: 'completed', summary: review.summary }; managed.autoRecovery = false;
@@ -1219,6 +1305,17 @@ function purposeCompletionBarrier(run) {
1219
1305
  const pending=ledger.units.filter(unit=>unit.required && unit.purposeRevision===ledger.purposeRevision && !['verified','superseded'].includes(unit.status));
1220
1306
  return pending.length ? 'Required work units still waiting: '+pending.map(unit=>unit.id).join(', ') : null;
1221
1307
  }
1308
+ function settleCompletionHandoffs(run, now, review) {
1309
+ const managed = run.managed;
1310
+ for (const request of managed.consistencyOutbox || []) {
1311
+ if (request.targetTask !== managed.taskId || request.objectiveDigest !== digest(JSON.stringify([run.objective, managed.acceptanceCriteria])) || request.version !== managed.controllerContractVersion) {
1312
+ recordHandoffStage(request, 'superseded', now, { reason: 'contract_changed' });
1313
+ continue;
1314
+ }
1315
+ recordHandoffStage(request, 'resolved', now, { verifiedAt: now, reviewerSession: review.session });
1316
+ request.workspaceDigest = review.workspaceDigest;
1317
+ }
1318
+ }
1222
1319
  export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
1223
1320
  const started=Date.now(), initialNow=now;
1224
1321
  return updateRecord(root, provider, session, async record => {
@@ -1244,7 +1341,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
1244
1341
  if (!pendingAcceptance(run).length) {
1245
1342
  const barrier=purposeCompletionBarrier(run);
1246
1343
  if(barrier) queuePurposeVerification(run, 'Human decision recorded; '+barrier+' Independently reconcile the required operation before completing.', now);
1247
- else {run.state = 'completed'; run.reason = run.checkpoint.summary;}
1344
+ else {run.state = 'completed'; run.reason = run.checkpoint.summary; bindControllerCompletion(run, now); settleCompletionHandoffs(run, now, run.managed.purpose?.search?.review || {});}
1248
1345
  }
1249
1346
  }
1250
1347
  notifyState(run, now);
@@ -1519,7 +1616,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1519
1616
  const event = input.hook_event_name;
1520
1617
  const identity = observedProcess === undefined && (['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
1521
1618
  let output = {}, boundaryLease = null, claimedWorkspace = null;
1522
- try { await updateRecord(root, provider, input.session_id, async old => {
1619
+ const applyObservation = () => updateRecord(root, provider, input.session_id, async old => {
1523
1620
  const currentLink = await providerLinkState(root, provider);
1524
1621
  if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
1525
1622
  if (old && old.cwd !== cwd) return old;
@@ -1531,7 +1628,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1531
1628
  const parentId = env.SIMY_PARENT_SESSION || (parentProvider === 'codex' ? env.CODEX_THREAD_ID : null);
1532
1629
  if (['codex', 'claude'].includes(parentProvider) && /^[0-9a-f-]{36}$/i.test(parentId || '') &&
1533
1630
  !(parentProvider === provider && parentId === input.session_id)) {
1534
- record.parentSession = { provider: parentProvider, session: parentId };
1631
+ const parent = { provider: parentProvider, session: parentId };
1632
+ try {
1633
+ await validateSessionParent(root, { provider, session: input.session_id }, parent);
1634
+ record.parentSession = parent;
1635
+ } catch { /* Unverified or cyclic ancestry never changes navigation. */ }
1535
1636
  }
1536
1637
  }
1537
1638
  const owner = record.run?.managed?.liveHookOwner;
@@ -1673,7 +1774,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1673
1774
  record.run.updatedAt = now;
1674
1775
  notifyState(record.run, now);
1675
1776
  return record;
1676
- }); } catch (error) {
1777
+ });
1778
+ try {
1779
+ if (event === 'SessionStart') await withSessionParentLock(root, applyObservation, inspectProcess);
1780
+ else await applyObservation();
1781
+ } catch (error) {
1677
1782
  if (claimedWorkspace?.release) await claimedWorkspace.release();
1678
1783
  throw error;
1679
1784
  }
@@ -42,9 +42,13 @@ export function validatePurposeReview(value, plan, ruleIds = [], previousRules =
42
42
  result.priorities.some(item => !Number.isInteger(item.rank) || item.rank < 1 || item.rank > options.length) ||
43
43
  new Set(result.priorities.map(item => item.rank)).size !== options.length ||
44
44
  options.length && result.priorities.find(item => item.rank === 1)?.optionId !== plan.selectedOptionId) throw new Error('Purpose priorities must rank every option and select the highest priority.');
45
- if (!Array.isArray(value.rules) || value.rules.length > ruleIds.length || new Set(value.rules.map(item => item?.id)).size !== value.rules.length) throw new Error('Every purpose rule needs one disposition.');
45
+ if (!Array.isArray(value.rules) || value.rules.length > ruleIds.length) throw new Error('Every purpose rule needs one disposition.');
46
+ // Requirement references use rule:<id>; both spellings identify the same
47
+ // frozen clause. Normalize the label, never the status or evidence.
48
+ const suppliedRules=value.rules.map(item=>({...item,id:typeof item?.id==='string' && item.id.startsWith('rule:')?item.id.slice(5):item?.id}));
49
+ if(new Set(suppliedRules.map(item=>item.id)).size!==suppliedRules.length)throw new Error('Every purpose rule needs one disposition.');
46
50
  const merged = new Map(previousRules.filter(item => ruleIds.includes(item.id)).map(item => [item.id, item]));
47
- for (const item of value.rules) { if (!ruleIds.includes(item?.id)) throw new Error('Invalid purpose rule receipt.'); merged.set(item.id, item); }
51
+ for (const item of suppliedRules) { if (!ruleIds.includes(item?.id)) throw new Error('Invalid purpose rule receipt.'); merged.set(item.id, item); }
48
52
  if (!selectiveRuleReview(managed) && merged.size !== ruleIds.length) throw new Error('Every purpose rule needs one disposition.');
49
53
  result.rules = (selectiveRuleReview(managed) ? [...merged.values()] : ruleIds.map(id => merged.get(id))).map(item => {
50
54
  if (!ruleIds.includes(item?.id) || !['satisfied', 'failed', 'unverified', 'not_applicable'].includes(item.status) || !clean(item.basis, 160)) throw new Error('Invalid purpose rule receipt.');
@@ -117,7 +117,7 @@ export function normalizeFindings(value, managed) {
117
117
  if (typeof item === 'string') return { id: `legacy:${digest(item).slice(0, 16)}`, requirementId: null, source: 'legacy_review', owner: 'worker', gap: text(item, 1000), verification: 'Inspect the reported gap', status: 'open' };
118
118
  const row = ledger.requirements.find(row => row.id === item?.requirementId);
119
119
  if (!row || !/^[\w:.-]{1,100}$/.test(item?.id) || !owners.includes(item.owner) || !text(item.gap) || !text(item.verification) || !text(item.source)) throw new Error('Review finding must identify a known requirement, source, owner, observation and verification.');
120
- return { id: item.id, requirementId: row.id, source: text(item.source), owner: item.owner, gap: text(item.gap, 1000), verification: text(item.verification), reopenReason: text(item.reopenReason), ...(item.observation && ['observer', 'capability'].includes(item.owner) ? { observation: { type: text(item.observation.type, 40), ...(item.observation.url ? { url: text(item.observation.url, 2000) } : {}), ...(item.observation.path ? { path: text(item.observation.path, 500) } : {}), ...(Array.isArray(item.observation.fields) ? { fields: item.observation.fields.slice(0, 5).map(field => text(field, 201)) } : {}) } } : {}), status: 'open' };
120
+ return { id: item.id, requirementId: row.id, source: text(item.source), owner: item.owner, gap: text(item.gap, 1000), verification: text(item.verification), reopenReason: text(item.reopenReason), reopenEvidenceRefs: Array.isArray(item.reopenEvidenceRefs) ? item.reopenEvidenceRefs.slice(0, 8).map(row => ({ target: text(row.target, 2000), digest: text(row.digest, 64), scope: row.scope })) : [], ...(item.observation && ['observer', 'capability'].includes(item.owner) ? { observation: { type: text(item.observation.type, 40), ...(item.observation.url ? { url: text(item.observation.url, 2000) } : {}), ...(item.observation.path ? { path: text(item.observation.path, 500) } : {}), ...(Array.isArray(item.observation.fields) ? { fields: item.observation.fields.slice(0, 5).map(field => text(field, 201)) } : {}) } } : {}), status: 'open' };
121
121
  });
122
122
  }
123
123
 
@@ -129,11 +129,19 @@ export function reconcileFindings(managed, review, now) {
129
129
  const prior = ledger.findings.find(row => row.id === resolution.id);
130
130
  if (!prior || !['resolved', 'refuted'].includes(resolution.status) || !text(resolution.evidence)) throw new Error('Finding resolution needs a known ID and independent evidence.');
131
131
  if (findings.some(row => row.id === resolution.id)) throw new Error('A finding cannot be open and resolved.');
132
- Object.assign(prior, { status: resolution.status, evidence: text(resolution.evidence, 1000), checkedAt: now, workspaceDigest: review.workspaceDigest });
132
+ const boundReads = (resolution.evidenceRefs || []).filter(link => /^[a-f0-9]{64}$/.test(link.digest) && (review.capturedReads || []).some(row => row.target === link.target && row.digest === link.digest && row.scope === link.scope));
133
+ if (!boundReads.length) throw new Error('Finding resolution needs current captured finding-specific evidence references.');
134
+ Object.assign(prior, { status: resolution.status, evidence: text(resolution.evidence, 1000), checkedAt: now, workspaceDigest: review.workspaceDigest, closedReads: boundReads.map(row => ({ target: row.target, digest: row.digest, scope: row.scope })) });
133
135
  }
134
136
  for (const finding of findings) {
135
137
  const prior = ledger.findings.find(row => row.id === finding.id);
136
- if (prior && prior.status !== 'open' && !finding.reopenReason) throw new Error('Reopening a closed finding needs new counterevidence or a changed dependency.');
138
+ if (prior && prior.status !== 'open') {
139
+ const currentReads = review.capturedReads || [];
140
+ const newBoundEvidence = finding.reopenEvidenceRefs.some(link => /^[a-f0-9]{64}$/.test(link.digest) &&
141
+ currentReads.some(row => row.target === link.target && row.digest === link.digest && row.scope === link.scope) &&
142
+ (prior.closedReads || []).some(row => row.target === link.target && row.scope === link.scope && row.digest !== link.digest));
143
+ if (!finding.reopenReason || !newBoundEvidence) throw new Error('Reopening a closed finding needs new independently observed counterevidence linked to the finding, not a paraphrased reason or missing observation.');
144
+ }
137
145
  if (prior) Object.assign(prior, finding, { checkedAt: now, workspaceDigest: review.workspaceDigest });
138
146
  else ledger.findings.push({ ...finding, checkedAt: now, workspaceDigest: review.workspaceDigest });
139
147
  }
@@ -49,15 +49,22 @@ export function assessRuleReview(review, managed) {
49
49
  source: 'Independent canonical rule assessment', owner, gap: `Resolve applicable failed/unverified controls: ${values.slice(0, 25).join(', ')}`,
50
50
  verification: owner === 'worker' ? 'Repair the observed result and independently recheck.' : 'Acquire missing independent evidence or repair the owning observation/controller capability; do not require worker-authored proof.', status: 'open' });
51
51
  const complete = !missing.length && !unknown && !unresolved.length;
52
- const resolutions = [...(review.resolutions || [])].filter(row => !/^independent-rule-(?:coverage|obligations(?:-[a-z]+)?)$/.test(row.id));
52
+ const resolutions = [...(review.resolutions || [])];
53
53
  const generatedIds = new Set(['independent-rule-coverage', 'independent-rule-obligations',
54
54
  ...(managed.purpose.evidence?.findings || []).filter(row => /^independent-rule-obligations-[a-z]+$/.test(row.id)).map(row => row.id),
55
55
  ...findings.filter(row => /^independent-rule-obligations-[a-z]+$/.test(row.id)).map(row => row.id)]);
56
56
  for (const id of generatedIds) {
57
57
  const current = findings.find(item => item.id === id);
58
58
  const prior = managed.purpose.evidence?.findings?.find(item => item.id === id);
59
- if (!current && !missing.length && prior?.status === 'open') resolutions.push({ id, status: 'resolved', evidence: 'Current independent assessment covers this obligation with no remaining gap.' });
60
- if (current && prior && prior.status !== 'open') current.reopenReason = 'Current independent rule assessment no longer supports the prior resolution.';
59
+ if (!current && !missing.length && prior?.status === 'open' && !resolutions.some(row => row.id === id)) {
60
+ const resolution = { id, status: 'resolved', evidence: 'Current independent assessment covers this obligation with no remaining gap.', evidenceRefs: (review.capturedReads || []).filter(read => rules.filter(rule => rule.id === prior.requirementId?.replace(/^rule:/, '')).some(rule => rule.evidenceRefs.some(link => link.target === read.target && (link.ref === read.ref || link.ref === `read:${read.target}` && (review.capturedReads || []).filter(row => row.target === read.target).length === 1)))) };
61
+ if (resolution.evidenceRefs.length) resolutions.push(resolution);
62
+ else review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: { code: 'generated_resolution_evidence_missing', targets: [`resolution:${id}`], instruction: 'Independently acquire finding-specific primary evidence for the prior rule applicability gap before resolving it. A not_applicable label alone cannot close an existing finding.' } };
63
+ }
64
+ if (current && prior && prior.status !== 'open') {
65
+ current.reopenReason = 'Current independent rule assessment no longer supports the prior resolution.';
66
+ current.reopenEvidenceRefs = (review.findings || []).find(row => row.id === id)?.reopenEvidenceRefs || [];
67
+ }
61
68
  }
62
69
  return { ...review, rules, findings, resolutions, passed: review.passed && complete, needsHuman: review.needsHuman && complete,
63
70
  ruleCoverage: { total: inventory.length, mode: selective ? 'applicable' : 'full', unevaluated: inventory.length - rules.length, assessed: ids.length - missing.length, missing: missing.length, unresolved: unresolved.length, unknownIds: unknown,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: delivery-loop
3
- description: Continue an explicitly requested SIMY Delivery Loop in the current provider conversation using user-reviewed hooks, bounded checkpoints, and human blocker handling.
3
+ description: Continue an explicitly requested SIMY Delivery Loop with bounded checkpoints, evidence-based autonomous recovery, and preserved native permissions and user stops.
4
4
  ---
5
5
 
6
6
  # SIMY Delivery Loop
@@ -39,7 +39,7 @@ For missing information use `blocker: "missing_information"`. For a failed attem
39
39
  For completed work use `{"status":"completed","summary":"Actual result","acceptanceMet":true,"evidence":["Exact checks and observed results"]}` only after checking the user's acceptance criteria. For standard tracking this ends as `reported_done`, never verified `completed`. In managed tracking SIMY independently checks frozen file predicates or waits for explicit user verification of manual criteria. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
40
40
 
41
41
  4. The Stop hook evaluates checkpoints and supplies the next instruction within the original scope while progress is verified. Missing checkpoints count toward the repeated-failure circuit. A human interrupt stops the loop. Approval/input events block it. Provider errors/exits may require reopening the existing conversation; these hooks cannot restart a dead provider process.
42
- 5. Once a blocker is resolved with current evidence, continue already-authorized work without an additional continuation request. AI handles reversible decisions by investigating, selecting and verifying a suitable option; use LLM judgment first and HC-01–HC-102 as critique lenses. A matching irreversible-operation approval or necessary answer is sufficient to continue that operation. User-directed pause/stop requires an explicit resume; actual provider permissions, rejected actions and budgets remain authoritative. For manual recovery, `simy delivery resume --provider <provider> --session <id>` retains usage, failure and progress history; local time, turn and token counters do not stop work. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
42
+ 5. Once a blocker is resolved with current evidence, continue already-authorized work without an additional continuation request. AI handles reversible decisions by investigating, selecting and verifying a suitable option; use LLM judgment first and HC-01–HC-102 as critique lenses. A matching irreversible-operation approval or necessary answer is sufficient to continue that operation. User-directed pause/stop requires an explicit resume; actual provider permissions, rejected actions and budgets remain authoritative. `simy delivery resume --provider <provider> --session <id>` retains task identity, usage, failure and progress history and does not reset existing hard time, turn or token budgets. A budget boundary is not a request for human judgment about an ordinary implementation choice. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
43
43
 
44
44
  If the user selected **Continue this conversation** in SIMY desktop, the exact session already has a managed task. Do not run `delivery start` or ask the user to restate its goal. Follow the Stop hook's distinct `session_understanding` contract first: inspect only the original conversation context, report the original user goal and grounded progress, and submit the JSON checkpoint via `--file -`. Do not execute the next assessed work action during understanding. If context is materially unclear, report one focused question and wait for a matching `session_answer`; generic Resume is insufficient. A model-reported finished goal without an independent check is shown as `reported_done`, not verified `completed`.
45
45
 
@@ -0,0 +1,157 @@
1
+ // Only fixed controller schema messages may be sent back to an AI worker.
2
+ // Provider bodies, user text and arbitrary exception messages remain private.
3
+ const known = new Set([
4
+ "A concrete nextStep is required.",
5
+ "A finding cannot be open and resolved.",
6
+ "A provider and exact session ID are required.",
7
+ "A structured session understanding is required.",
8
+ "Acceptance file must be relative to the task directory.",
9
+ "Already-finished understanding must have no remaining action.",
10
+ "An assessed plan needs a diagnosis and one to five alternatives.",
11
+ "An outcome already satisfied without changes permits only read-only verification.",
12
+ "Answer the current session question before resuming.",
13
+ "Association job changed; checkpoint discarded.",
14
+ "Association lease changed; checkpoint discarded.",
15
+ "Authorization blocker requires a scoped approval request.",
16
+ "Authorization request must match the selected assessed option.",
17
+ "Authorization request requires the proposed nextStep.",
18
+ "Automatic continuation requires the original verified provider channel.",
19
+ "Automatic continuation was disabled before enrollment.",
20
+ "Checkpoint needs an actionResult bound to the selected decision.",
21
+ "Checkpoint status must be in_progress, completed, blocked, or error.",
22
+ "Checkpoint summary is required.",
23
+ "Claude live hook changed before enrollment; refresh the session.",
24
+ "Clear understanding needs an objective, remaining work, and an unambiguous next action.",
25
+ "Clear understanding requires an observed read result.",
26
+ "Completion requires acceptanceMet and concrete verification evidence.",
27
+ "Confirm the exact external dependency condition before resuming.",
28
+ "Delivery Loop not found.",
29
+ "Delivery level needs the user selection reference, requested/effective levels 1-5, and assurance-ascending-v2 numbering.",
30
+ "Delivery state exceeds 256 KiB; reduce task details.",
31
+ "Delivery state is busy; retry the command.",
32
+ "Delivery state lock owner is unavailable; inspect the lock before retrying.",
33
+ "Delivery state reclaim lock requires inspection.",
34
+ "Dependency wait changed or confirmation is empty; refresh its status.",
35
+ "Duplicate acceptance criterion ID.",
36
+ "Duplicate plan option ID.",
37
+ "Each acceptance criterion needs a description.",
38
+ "Empty purpose observation.",
39
+ "Every purpose rule needs one disposition.",
40
+ "Exact session changed before automatic continuation.",
41
+ "External dependency needs waitCondition {condition, owner, nextCheck} and a nextStep.",
42
+ "Finding ledger capacity reached; reconcile existing finding IDs.",
43
+ "Finding resolution needs a known ID and independent evidence.",
44
+ "Finding resolution needs current captured finding-specific evidence references.",
45
+ "Hook input too large.",
46
+ "Independent review lease changed.",
47
+ "Independent review left unresolved findings.",
48
+ "Independent review provenance is missing.",
49
+ "Inferred file check needs a relative task path.",
50
+ "Inferred file check needs expected text.",
51
+ "Inferred file_equals needs bounded exact UTF-8 text.",
52
+ "Inferred session goal needs a user-message basis.",
53
+ "Initial action must be the selected assessed option.",
54
+ "Invalid acceptance criterion ID.",
55
+ "Invalid actionDueAt timestamp.",
56
+ "Invalid approval policy.",
57
+ "Invalid autoRecoverySandbox.",
58
+ "Invalid autonomy policy.",
59
+ "Invalid delivery budget.",
60
+ "Invalid managed task specification.",
61
+ "Invalid plan option ID.",
62
+ "Invalid purpose rule receipt.",
63
+ "Invalid requirement updates.",
64
+ "Invalid resumeModel.",
65
+ "Invalid work unit metadata.",
66
+ "Loop is stopped or awaiting a human resume.",
67
+ "Managed continuation requires a concrete proposedAction with kind, scope, and summary.",
68
+ "Managed recovery lease changed; checkpoint discarded.",
69
+ "Managed task identity changed; refresh its status.",
70
+ "Managed task is not awaiting acceptance verification.",
71
+ "Managed task is paused or awaiting acceptance verification.",
72
+ "Managed tasks require acceptanceCriteria.",
73
+ "Missing information blocker needs a specific user question.",
74
+ "No active session understanding turn.",
75
+ "No matching active Delivery Loop.",
76
+ "No matching verified provider session observed here. Select an exact discovered session or explicitly bind its task execution directory.",
77
+ "Objective and concrete next step are required.",
78
+ "Observed action result needs concrete evidence.",
79
+ "Only a blocked, paused, or recoverable loop can resume.",
80
+ "Only a nonterminal assessed managed task can change autonomy policy.",
81
+ "Only a running or queued managed task can pause.",
82
+ "Only manual criteria can be verified by the user.",
83
+ "Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.",
84
+ "Original budget exhausted; automatic continuation cannot reset it.",
85
+ "Pause a managed task before changing its continuation policy.",
86
+ "Pause the task and supply an explicit user purpose revision with sourceRef, objective and acceptanceCriteria.",
87
+ "Pending approval changed; refresh its status.",
88
+ "Plan recommendation must identify an assessed option and reason.",
89
+ "Progress continuation requires frozen managed acceptance criteria.",
90
+ "Proposed action differs from selected assessed option.",
91
+ "Provider token budgets require the host-observed Codex app-server channel.",
92
+ "Purpose continuation is not enabled.",
93
+ "Purpose priorities are required.",
94
+ "Purpose priorities must rank every option and select the highest priority.",
95
+ "Purpose review is required.",
96
+ "Purpose review needs an explicit no-change assessment.",
97
+ "Purpose review needs bounded observations.",
98
+ "Purpose revision history requires archival before another revision.",
99
+ "Read-only recovery diagnosis needs human review.",
100
+ "Read-only understanding cannot be dispatched under current policy.",
101
+ "Recovery diagnosis needs an enrolled task scope.",
102
+ "Reopening a closed finding needs new independently observed counterevidence linked to the finding, not a paraphrased reason or missing observation.",
103
+ "Replanning needs a supported original-session channel, a rejected assessed approval or safe research wait, and available budget.",
104
+ "Replanning needs an enrolled task scope.",
105
+ "Required unit resolution needs independent criterion-linked evidence.",
106
+ "Requirement update must reference an enrolled criterion or canonical rule.",
107
+ "Requirement updates need source ID, observation, owner and reason.",
108
+ "Research replanning requires the original verified live hook owner.",
109
+ "Resolve the exact pending approval before resuming.",
110
+ "Restore the original provider conversation before changing Delivery Loop.",
111
+ "Restore the original provider conversation before starting Delivery Loop.",
112
+ "Retained candidates require explicit archival before another purpose revision.",
113
+ "Review finding must identify a known requirement, source, owner, observation and verification.",
114
+ "Safe read diagnosis is unavailable; keep the waiting action blocked.",
115
+ "Select an explicit continuation policy.",
116
+ "Selected action must match an assessed option.",
117
+ "Selected action requires a fresh scoped user decision.",
118
+ "Session question changed or answer is empty; refresh its status.",
119
+ "Session understanding deadline expired.",
120
+ "Session understanding has too many items.",
121
+ "Session understanding lease changed; checkpoint discarded.",
122
+ "Session understanding needs its separate structured checkpoint.",
123
+ "Session understanding requires an assessed read-only initial action.",
124
+ "Specify authorization, missing_information, or external_dependency.",
125
+ "State exceeds limit.",
126
+ "Task execution directory must be a directory.",
127
+ "Task handoff is locked; continue in the received target session.",
128
+ "Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.",
129
+ "This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.",
130
+ "This task cannot change supervision while completed, executing, or handed off.",
131
+ "Unclear understanding needs one focused question.",
132
+ "Understanding basis needs a reference or excerpt.",
133
+ "Understanding basis needs a source.",
134
+ "Understanding checkpoint needs a summary.",
135
+ "Understanding criterion needs a description and provenance.",
136
+ "Understanding needs bounded context evidence.",
137
+ "Understanding result must match the selected read decision.",
138
+ "Understood next action differs from selected assessed option.",
139
+ "Unit belongs to an old purpose revision.",
140
+ "Unsupported acceptance check.",
141
+ "Unsupported control.",
142
+ "Unsupported inferred acceptance check.",
143
+ "Upgrade must preserve the original objective and task directory.",
144
+ "Upgrade requires read-only reconciliation and frozen acceptance of the original objective.",
145
+ "User-grounded criterion needs a user-message basis.",
146
+ "Waiting recovery needs an enrolled scope.",
147
+ "Work frontier needs reconciliation before more dispatch.",
148
+ "Work unit references unknown criterion.",
149
+ "file_contains needs expected text.",
150
+ "file_equals needs bounded exact UTF-8 text."
151
+ ]);
152
+ export function safeCheckpointDiagnostic(error) {
153
+ const message = error?.message;
154
+ if (known.has(message)) return message;
155
+ if (['gap','hypothesis','refutation','necessity'].some(key => message === `Purpose review needs ${key}.`)) return message;
156
+ return 'Checkpoint did not satisfy the controller schema; reread the supplied checkpoint contract and preserve the same task and action identity.';
157
+ }
@@ -1,3 +1,4 @@
1
+ import { linkSessionParent } from '../session-supervision/session-lineage.js';
1
2
  import { adoptAutonomousPolicy } from '../session-supervision/autonomous-upgrade.js';
2
3
  import { deliveryEvidence } from './evidence.js';
3
4
  import { readFile } from 'node:fs/promises';
@@ -79,6 +80,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
79
80
  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.');
80
81
  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
82
  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.');
83
+ output('simy delivery link_parent --provider codex|claude --session CHILD --parent-provider codex|claude --parent-session PARENT — record an explicit observed navigation relationship without starting work.');
82
84
  output('simy delivery supervise [--once] — service already opted-in tasks with this runtime; refuses an active desktop host.');
83
85
  output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
84
86
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
@@ -88,11 +90,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
88
90
  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.');
89
91
  return 0;
90
92
  }
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']);
93
+ 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', 'parent-provider', 'parent-session']);
92
94
  const options = parseDeliveryOptions(args, allowed);
93
95
  const base = { root, cwd, provider: options.provider, session: options.session };
94
96
  let result;
95
- if (action === 'evidence') {
97
+ if (action === 'link_parent') result = await linkSessionParent({ ...base, parentProvider: options['parent-provider'], parentSession: options['parent-session'] });
98
+ else if (action === 'evidence') {
96
99
  const record = await readRecord(root, options.provider, options.session);
97
100
  if (!record) throw new Error('Exact provider session record not found.');
98
101
  result = deliveryEvidence(record, root);