@awak-app/simy-cli 0.6.28 → 0.6.29

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.29",
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.29";
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';
@@ -608,6 +610,9 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
608
610
  continuationPolicy: continuationMode(config.continuationPolicy),
609
611
  autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
610
612
  autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
613
+ ...(appServerChannel && admission.nativeSandbox && admission.nativeSandboxSource ? {
614
+ providerSandbox: structuredClone(admission.nativeSandbox), providerSandboxSource: structuredClone(admission.nativeSandboxSource) } :
615
+ appServerChannel ? { providerSandbox: null, providerSandboxSource: { type: 'unavailable_at_enrollment' } } : {}),
611
616
  capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
612
617
  awaitingInitialHook: provider === 'claude' && Boolean(observedProcess?.identity) && Boolean(autonomyPolicy),
613
618
  autonomyPolicy, plan: null, decision: null, decisionHistory: [], workHistory: [], escalation: null,
@@ -623,6 +628,7 @@ function freezeEnrollment(run, now) {
623
628
  ledger.charter ||= { objective: run.objective, criteria: structuredClone(run.managed.acceptanceCriteria), approvalScopes: [...run.managed.approvalScopes], frozenAt: now };
624
629
  }
625
630
  run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
631
+ ...(run.managed.providerSandboxSource ? {nativeAuthorityDigest:digest(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource]))} : {}),
626
632
  deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
627
633
  }
628
634
  export function pendingAcceptance(run) {
@@ -943,7 +949,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
943
949
  delete run.managed.recoveryDiagnosisDecisionId;
944
950
  }
945
951
  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';
952
+ recordHandoffStage(request, checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed', now);
947
953
  request.observedAt = now; request.result = checkpoint.actionResult.status;
948
954
  }
949
955
  notifyState(run, now);
@@ -991,6 +997,29 @@ export async function settlePurposeVerification({ root, provider, session, expec
991
997
  'Required operation, effect reconciliation or original-purpose contract remains unresolved. Preserve the waiting unit and continue independent authorized work.'] };
992
998
  }
993
999
  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.']};
1000
+ // Validate counterevidence against a disposable ledger before mutations.
1001
+ // Verifier contract failures are verifier work, never worker checkpoint errors.
1002
+ if (!review.controllerEvidenceFailure) {
1003
+ try { reconcileFindings(structuredClone(managed), review, now); }
1004
+ catch (error) {
1005
+ 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;
1006
+ review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: {
1007
+ code: 'independent_finding_evidence_invalid', targets: [...(review.resolutions || []).map(row => `resolution:${row.id}`), ...(review.findings || []).filter(row => row.reopenReason).map(row => `reopen:${row.id}`)],
1008
+ instruction: error.message + ' Repeat isolated verification and reconcile existing findings with primary reads. For reopening use the exact prior closure read scope.',
1009
+ requiredReads: (managed.purpose.evidence?.findings || []).filter(row => row.status !== 'open').map(row => ({ id: row.id, closedReads: row.closedReads || [] }))
1010
+ } };
1011
+ }
1012
+ }
1013
+ if (review.controllerEvidenceFailure) {
1014
+ const prior = state.verifierFailures;
1015
+ const key = digest(JSON.stringify([currentDigest, review.controllerEvidenceFailure.code, review.controllerEvidenceFailure.targets]));
1016
+ state.verifierFailures = { key, attempts: prior?.key === key ? prior.attempts + 1 : 1, at: now, failure: review.controllerEvidenceFailure };
1017
+ state.review = review;
1018
+ run.revision += 1; run.updatedAt = now;
1019
+ queuePurposeVerification(run, `${review.controllerEvidenceFailure.instruction} Invalid targets: ${(review.controllerEvidenceFailure.targets || []).join(', ')}. Use a narrow direct read containing the cited excerpt within 2000 characters.`, now);
1020
+ managed.nextAttemptAt = now + Math.min(60_000, 1000 * 2 ** Math.min(state.verifierFailures.attempts - 1, 6));
1021
+ notifyState(run, now); return record;
1022
+ }
994
1023
  const openFindings = reconcileFindings(managed, review, now);
995
1024
  const units=evidenceLedger(managed).units;
996
1025
  for (const result of (review.passed || review.needsHuman) ? review.workUnits || [] : []) {
@@ -1018,11 +1047,9 @@ export async function settlePurposeVerification({ root, provider, session, expec
1018
1047
  for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
1019
1048
  run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
1020
1049
  run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
1050
+ bindControllerCompletion(run, now);
1021
1051
  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
- }
1052
+ settleCompletionHandoffs(run, now, review);
1026
1053
  } else if (pending.length && pending.every(c => c.check.type === 'manual')) {
1027
1054
  run.state = 'verification_pending'; run.reason = 'Only explicitly human acceptance remains.'; state.phase = 'waiting';
1028
1055
  run.checkpoint = { status: 'completed', summary: review.summary }; managed.autoRecovery = false;
@@ -1219,6 +1246,17 @@ function purposeCompletionBarrier(run) {
1219
1246
  const pending=ledger.units.filter(unit=>unit.required && unit.purposeRevision===ledger.purposeRevision && !['verified','superseded'].includes(unit.status));
1220
1247
  return pending.length ? 'Required work units still waiting: '+pending.map(unit=>unit.id).join(', ') : null;
1221
1248
  }
1249
+ function settleCompletionHandoffs(run, now, review) {
1250
+ const managed = run.managed;
1251
+ for (const request of managed.consistencyOutbox || []) {
1252
+ if (request.targetTask !== managed.taskId || request.objectiveDigest !== digest(JSON.stringify([run.objective, managed.acceptanceCriteria])) || request.version !== managed.controllerContractVersion) {
1253
+ recordHandoffStage(request, 'superseded', now, { reason: 'contract_changed' });
1254
+ continue;
1255
+ }
1256
+ recordHandoffStage(request, 'resolved', now, { verifiedAt: now, reviewerSession: review.session });
1257
+ request.workspaceDigest = review.workspaceDigest;
1258
+ }
1259
+ }
1222
1260
  export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
1223
1261
  const started=Date.now(), initialNow=now;
1224
1262
  return updateRecord(root, provider, session, async record => {
@@ -1244,7 +1282,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
1244
1282
  if (!pendingAcceptance(run).length) {
1245
1283
  const barrier=purposeCompletionBarrier(run);
1246
1284
  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;}
1285
+ else {run.state = 'completed'; run.reason = run.checkpoint.summary; bindControllerCompletion(run, now); settleCompletionHandoffs(run, now, run.managed.purpose?.search?.review || {});}
1248
1286
  }
1249
1287
  }
1250
1288
  notifyState(run, now);
@@ -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,5 @@
1
+ import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
2
+ import { handoffHealth, handoffMetrics, recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
1
3
  import { createHash, randomUUID } from 'node:crypto';
2
4
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
5
  import { dirname, join } from 'node:path';
@@ -14,7 +16,7 @@ async function save(path, value) {
14
16
  finally { await rm(temp, { force: true }); }
15
17
  }
16
18
 
17
- export function consistencyFindings(record) {
19
+ export function consistencyFindings(record, now = Date.now()) {
18
20
  const run = record.run, m = run?.managed;
19
21
  if (!m) return [];
20
22
  const findings = [];
@@ -22,9 +24,11 @@ export function consistencyFindings(record) {
22
24
  code, taskId: m.taskId, provider: record.provider, session: record.session, runRevision: run.revision,
23
25
  contractVersion: m.controllerContractVersion || null, objectiveDigest: hash([run.objective, m.acceptanceCriteria]),
24
26
  next, suppressed });
25
- if (!record.autoContinue || record.providerArchived || run.state === 'stopped' || run.state === 'paused' && pauseOrigin(run) === 'user') {
27
+ if (!record.autoContinue || record.providerArchived || run.stopReason?.actor === 'user' || run.state === 'stopped' || run.state === 'paused' && pauseOrigin(run) === 'user') {
26
28
  add('user_boundary', 'Preserve OFF, archive, explicit pause/stop and rejection.', true); return findings;
27
29
  }
30
+ if (completionClaimUnbound(record)) add('completion_receipt_gap', 'Independently inspect only the original goal: an old or unbound completed claim is not a new task or verified acceptance.');
31
+ if (handoffHealth(m, now).some(item => item.overdue)) add('handoff_overdue', 'Inspect the unreceived or overdue handoff and reconcile its effect receipt before any repair; retain task and decision identity.');
28
32
  if (m.pendingApproval?.denied || m.rejectedActionOverflow) add('rejection_boundary', 'Preserve the rejected action; investigate a different authorized alternative.', true);
29
33
  if (!['completed', 'reported_done'].includes(run.state) && (m.purpose?.version !== 2 || m.purpose?.decisionPolicy !== 'llm-first-hc102' || m.controllerContractVersion !== 2)) add('contract_drift', 'Inspect the original provider identity and idempotently migrate existing consent without resetting history.');
30
34
  if (run.state === 'paused') add(pauseOrigin(run) === 'system' ? 'system_pause' : 'unclassified_pause', 'Read the original stop evidence before recovery; never infer a user resume.');
@@ -60,7 +64,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
60
64
  heartbeat.unref?.();
61
65
  try {
62
66
  for (const record of rows) {
63
- const findings = consistencyFindings(record);
67
+ const findings = consistencyFindings(record, now);
64
68
  const key = `${record.provider}:${record.session}`;
65
69
  const old = previous.tasks[key];
66
70
  const current = { taskId: record.run?.managed?.taskId || null, state: record.run?.state || 'not_started',
@@ -73,6 +77,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
73
77
  if (old?.inspection?.key === deliveryKey && now < old.inspection.retryAt) continue;
74
78
  const request = { id: deliveryKey, stage: 'pending', targetTask: current.taskId,
75
79
  version: record.run?.managed?.controllerContractVersion || null, objectiveDigest: actionable[0].objectiveDigest, findings: actionable.map(f => f.id), createdAt: now };
80
+ recordHandoffStage(request, 'pending', now);
76
81
  current.inspection = { key: deliveryKey, request, attempts: (old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0) + 1, retryAt: now + Math.min(15 * 60_000, intervalMs * 2 ** Math.min(old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0, 4)) };
77
82
  await persist();
78
83
  await updateRecord(root, record.provider, record.session, live => {
@@ -83,7 +88,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
83
88
  // the same contract/findings. Never discard an executing repair.
84
89
  for (const item of outbox) if (!item.decisionId && item.targetTask === request.targetTask && item.version === request.version &&
85
90
  item.objectiveDigest === request.objectiveDigest && JSON.stringify(item.findings) === JSON.stringify(request.findings) &&
86
- ['pending', 'received', 'inspected'].includes(item.stage)) { item.stage = 'superseded'; item.supersededBy = request.id; }
91
+ ['pending', 'received', 'inspected'].includes(item.stage)) { recordHandoffStage(item, 'superseded', now, { supersededBy: request.id }); }
87
92
  outbox.push(request);
88
93
  }
89
94
  const terminal = outbox.filter(item => ['resolved', 'superseded'].includes(item.stage));
@@ -100,17 +105,23 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
100
105
  }
101
106
  // Receipt means delivered to the canonical controller, never resolved.
102
107
  if (receipt?.received === true) {
103
- request.stage = receipt.audited === true ? 'inspected' : 'received';
108
+ const stage = receipt.audited === true ? 'inspected' : receipt.audited === false ? 'inspection_failed' : 'received';
109
+ recordHandoffStage(request, stage, now);
110
+ if (receipt.audited === false) {
111
+ status.failures++; status.lastFailure = { at: now, code: 'consistency_audit_failed' };
112
+ current.inspection.lastFailure = { at: now, code: receipt.reason === 'audit_timeout' ? 'audit_timeout' : 'independent_audit_failed' };
113
+ }
104
114
  status.metrics.requestsDelivered++;
105
115
  if (receipt.audited === true) status.metrics.independentInspections++;
106
116
  request.receivedAt = now;
107
117
  await updateRecord(root, record.provider, record.session, live => {
108
118
  const item = live?.run?.managed?.consistencyOutbox?.find(item => item.id === request.id);
109
- if (item && item.stage === 'pending') { item.stage = request.stage; item.receivedAt = now; }
119
+ if (item && ['pending', 'received', 'inspection_failed'].includes(item.stage)) { recordHandoffStage(item, request.stage, now, { receivedAt: now }); }
110
120
  return live;
111
121
  });
112
122
  }
113
123
  }
124
+ Object.assign(status.metrics, handoffMetrics(await readRecords(), now));
114
125
  status.lastSuccessAt = now;
115
126
  await persist();
116
127
  return status;