@awak-app/simy-cli 0.6.10 → 0.6.11

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/README.md CHANGED
@@ -22,6 +22,18 @@ Both providers share one analysis deadline. A deterministic provider failure pau
22
22
  new claims and retains failed history until environment/settings change or **Retry
23
23
  analysis** is selected.
24
24
 
25
+ After **6 consecutive SQM analysis failures**, this machine stops claiming new
26
+ SQM analyses for the current Web/account/organization. Codex and Claude share the
27
+ counter; a completed analysis resets it. Empty polls and user cancellations do
28
+ not change it. The pause and partial failure count survive restart, sign-in and
29
+ provider changes. A review interrupted by a host exit counts once on restart;
30
+ graceful cancellation clears that unfinished marker without changing the count.
31
+ Reconnecting or selecting Retry cannot clear this pause.
32
+ Installing and running a **newer SIMY version** resets the counter and resumes
33
+ claims automatically, subject to normal organization access and analysis settings.
34
+ The native organization menu shows when an update is required. This pause does
35
+ not change server analysis consent or stop ordinary implementation tasks.
36
+
25
37
  Agentic Loop preflight accepts the task's existing requirement, acceptance
26
38
  criteria, required checks and charter context alongside the repository/provider.
27
39
  Its `startup` summary lists all blockers, unverified conditions and recovery
@@ -41,6 +53,16 @@ remain available for history and usage warnings. The Web controller supports
41
53
  connected local Codex/Claude engines; unsupported cloud execution is refused
42
54
  before dispatch.
43
55
 
56
+ A completed Agentic worker's structured result is saved privately before evidence
57
+ collection and the final observation flush. If the controller restarts after that save,
58
+ the authorized daemon recovers the same attempt and collects fresh evidence
59
+ without dispatching the completed worker again. Collector read failures retry up
60
+ to three times; exhausted reads retain the result for an explicit continuation.
61
+ A result receipt does not prove acceptance. Stop, changed identity or objective,
62
+ and an already consumed remote result prevent automatic recovery. An unfinished
63
+ worker or a missing, invalid or redacted structured result still requires
64
+ reconciliation; no unknown external operation is automatically replayed.
65
+
44
66
  For interrupted Agentic work, `GET /v1/agentic-loop/:id/recovery` provides a
45
67
  read-only objective and checkout fingerprint. Reconcile an uncertain side effect
46
68
  with `POST` to that endpoint (`action: reconcile`, authoritative
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -3,6 +3,7 @@ import { startupRequirementChecks, summarizeStartupChecks } from "./startup-pref
3
3
  import { BROWSER_HANDOFF_TRANSPORT, browserHandoffPage, decryptBrowserHandoff } from "./browser-handoff.js";
4
4
  import { createAuthorizationWindow } from "./browser-authorization.js";
5
5
  import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
6
+ import { createSqmFailureCircuit, createSqmFailureCircuitStore } from "./sqm/failure-circuit.js";
6
7
  import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
7
8
  import { readSqmEnabled, writeSqmEnabled, readSqmSettings, writeSqmEngineFallback } from "./desktop-sqm-settings.js";
8
9
  import { createServer } from "node:http";
@@ -23,6 +24,7 @@ import {
23
24
  pauseLocalCodingRun,
24
25
  prepareLocalCodingRunRecovery,
25
26
  reconcileLocalCodingRun,
27
+ recoverCompletedLocalCodingRun,
26
28
  queueLocalCodingGuidance,
27
29
  recheckLocalCodingRun,
28
30
  publishRestoredLocalCodingRun,
@@ -35,7 +37,7 @@ import {
35
37
  toCompatibleCodingLoopState,
36
38
  toLedgerSnapshot,
37
39
  } from "./runner.js";
38
- import { assertRecoveryActor } from "./coding-run-recovery.js";
40
+ import { assertRecoveryActor, preserveCompletedWorkerResult } from "./coding-run-recovery.js";
39
41
  import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
40
42
  import {
41
43
  defaultSessionRoot,
@@ -635,7 +637,7 @@ export async function startAgent({
635
637
  if (remoteTime > localTime) {
636
638
  const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
637
639
  const remoteRun = restoreRun({
638
- snapshot, session, apiOrigin,
640
+ snapshot: preserveCompletedWorkerResult(localRun.snapshot, snapshot), session, apiOrigin,
639
641
  localPath: repository?.local_path || null,
640
642
  localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
641
643
  });
@@ -647,6 +649,7 @@ export async function startAgent({
647
649
  completedAt: remoteRun.completedAt,
648
650
  restoredFromState: remoteRun.restoredFromState,
649
651
  restoredObjectiveUnverified: remoteRun.restoredObjectiveUnverified,
652
+ restoredCompletedWorkerResult: remoteRun.restoredCompletedWorkerResult,
650
653
  });
651
654
  await saveCodingRunSnapshot(codingRunStateRoot, localRun.snapshot);
652
655
  await publishRestoredLocalCodingRun(localRun);
@@ -673,6 +676,23 @@ export async function startAgent({
673
676
  if (restored) notifyDesktopStatusChange();
674
677
  };
675
678
 
679
+ const recoverCompletedCodingRuns = async () => {
680
+ if (!daemon || shuttingDown || !desktopWorkAllowed() || !isSessionValid(session, Date.now(), apiOrigin)) return;
681
+ const requestedSession = session;
682
+ for (const run of registry.list()) {
683
+ if (!run.restoredCompletedWorkerResult || run.operation || run.child) continue;
684
+ try {
685
+ const scope = await authorizeDesktopWork();
686
+ if (shuttingDown || session !== requestedSession || !desktopWorkAllowed()) return;
687
+ assertRecoveryActor(run);
688
+ await authorizeDesktopWork(scope);
689
+ // Claim synchronously before starting the asynchronous controller. The
690
+ // normal process lease still protects this run against another daemon.
691
+ void recoverCompletedLocalCodingRun(run, dependencies.runOptions).catch(error => reportStartupConnectionError(error, quiet));
692
+ } catch (error) { reportStartupConnectionError(error, quiet); }
693
+ }
694
+ };
695
+
676
696
  const restoreLocalCodingRuns = async () => {
677
697
  if (!desktopWorkAllowed()) return;
678
698
  const requestedSession = session;
@@ -822,6 +842,7 @@ export async function startAgent({
822
842
  try {
823
843
  await restoreLocalCodingRuns();
824
844
  await restoreRemoteRuns();
845
+ await recoverCompletedCodingRuns();
825
846
  startCodingRunMonitor();
826
847
  } catch (error) {
827
848
  reportStartupConnectionError(error, quiet);
@@ -933,6 +954,7 @@ export async function startAgent({
933
954
  dependencies.createSqmLocalReviewWorker || createSqmLocalReviewWorker;
934
955
  sqmLocalReviewWorker = createWorker({
935
956
  api: sqmApi,
957
+ failureCircuit: createSqmFailureCircuit({ version: CLI_VERSION, store: createSqmFailureCircuitStore({ root: sessionRoot }) }),
936
958
  beginWork: () => acquireUpdateWork(updateManager),
937
959
  runReview: async (request, provider, context = {}) => {
938
960
  const scope = await authorizeDesktopWork();
@@ -2463,6 +2485,7 @@ export async function startAgent({
2463
2485
  await restoreLocalCodingRuns();
2464
2486
  if (shuttingDown) return;
2465
2487
  await restoreRemoteRuns();
2488
+ await recoverCompletedCodingRuns();
2466
2489
  startCodingRunMonitor();
2467
2490
  } catch (error) {
2468
2491
  reportStartupConnectionError(error, quiet);
@@ -2571,6 +2594,9 @@ export async function startAgent({
2571
2594
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2572
2595
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2573
2596
  providerPause: sqmLocalReviewWorker?.snapshot?.().provider_pause ?? null,
2597
+ consecutiveFailures: sqmLocalReviewWorker?.snapshot?.().consecutive_failures ?? 0,
2598
+ pausedUntilUpdate: sqmLocalReviewWorker?.snapshot?.().paused_until_update === true,
2599
+ pausedAtVersion: sqmLocalReviewWorker?.snapshot?.().paused_at_version ?? null,
2574
2600
  providerReady: [codex, claude].some(provider => provider?.state === "ready") },
2575
2601
  ...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
2576
2602
  update: updateManager?.snapshot?.() ?? null,
@@ -3112,6 +3138,7 @@ function capabilitiesFromInspections(codex, claude) {
3112
3138
  workflow_terminal_contract: true,
3113
3139
  workflow_deliverable_upload: true,
3114
3140
  sqm_local_review: codex.compatible || claude.compatible,
3141
+ sqm_failure_pause_until_update: true,
3115
3142
  codex_work_pattern_source: codex.compatible || claude.compatible,
3116
3143
  },
3117
3144
  session_ttl_hours: 48,
@@ -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.10";
16
+ export const CLI_VERSION = "0.6.11";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -21,9 +21,19 @@ const ACTIONS = new Set([...LOCAL_ACTIONS, 'external', 'destructive', 'scope_cha
21
21
  const AUTONOMY_POLICIES = new Set(['strict', 'balanced', 'autonomous']);
22
22
  const ASSESSMENT_FIELDS = { risk: ['low', 'medium', 'high', 'unknown'], reversibility: ['reversible', 'irreversible', 'unknown'], externalEffect: ['none', 'local', 'external', 'unknown'], blastRadius: ['single_file', 'repository', 'multi_system', 'unknown'], uncertainty: ['low', 'medium', 'high', 'unknown'] };
23
23
  export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
24
+ // Provider identity remains bound to record.cwd. File acceptance and writer
25
+ // coordination use the explicitly enrolled task directory instead.
26
+ export const taskDirectory = record => record.run?.executionCwd || record.cwd;
27
+ export const taskWorkspaceDirectories = record => [...new Set([record.cwd, taskDirectory(record)])];
28
+ export const sharesTaskWorkspace = (left, right) => taskWorkspaceDirectories(left).some(cwd => taskWorkspaceDirectories(right).includes(cwd));
29
+ const checkpointDirectoryMatches = (record, cwd) => record.cwd === cwd || taskDirectory(record) === cwd;
24
30
  const digest = value => createHash('sha256').update(value).digest('hex');
25
31
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
26
32
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
33
+ export function canUpgradeDelivery(run) {
34
+ return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
35
+ Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 && ['active', 'paused', 'recovery_required'].includes(run.state));
36
+ }
27
37
  const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
28
38
  export function recordPath(root, provider, session) {
29
39
  if (!['codex', 'claude'].includes(provider) || typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(session)) throw new Error('A provider and exact session ID are required.');
@@ -415,7 +425,15 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
415
425
  const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
416
426
  const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
417
427
  (appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
418
- const managed = { taskId: randomUUID(), thread: { provider, id: session },
428
+ let deliveryLevel = null;
429
+ if (config.deliveryLevel !== undefined) {
430
+ const value = config.deliveryLevel;
431
+ if (!Number.isInteger(value?.requested) || value.requested < 1 || value.requested > 5 ||
432
+ !Number.isInteger(value.effective) || value.effective < value.requested || value.effective > 5 ||
433
+ value.numberingVersion !== 'assurance-ascending-v2' || !text(value.selectionRef, 200)) throw new Error('Delivery level needs the user selection reference, requested/effective levels 1-5, and assurance-ascending-v2 numbering.');
434
+ deliveryLevel = { requested: value.requested, effective: value.effective, numberingVersion: value.numberingVersion, selectionRef: text(value.selectionRef, 200) };
435
+ }
436
+ const managed = { taskId: randomUUID(), deliveryLevel, thread: { provider, id: session },
419
437
  actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
420
438
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
421
439
  grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
@@ -431,25 +449,57 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
431
449
  if (initialPlan) recordDecision(managed, initialPlan, Date.now());
432
450
  return managed;
433
451
  }
434
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxFailures, continuationPolicy, managed, admission,
452
+ function freezeEnrollment(run, now) {
453
+ if (!run.managed || run.managed.enrollment) return;
454
+ run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
455
+ deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
456
+ }
457
+ export function pendingAcceptance(run) {
458
+ const enrollment = run.managed?.enrollment;
459
+ if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
460
+ JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
461
+ check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
462
+ return (run.managed?.acceptanceCriteria || []).filter(item => !Object.hasOwn(run.managed.verified, item.id))
463
+ .map(item => ({ id: item.id, description: item.description, check: item.check.type,
464
+ detail: run.managed.criterionObservations?.[item.id]?.observed_result || (item.check.type === 'manual' ? 'Explicit user verification required' : 'Independent check not yet passed') }));
465
+ }
466
+ function acceptancePendingReason(run) {
467
+ return `Acceptance verification pending: ${pendingAcceptance(run).map(item => `${item.id} (${item.description}): ${item.detail}`).join('; ')}`;
468
+ }
469
+ export function deliveryAcceptanceContext(run) {
470
+ return `Original objective: ${run.managed?.enrollment?.objective || run.objective}. Mode: ${run.managed ? 'managed acceptance tracking' : 'standard checkpoint tracking; completion reports are not verified acceptance'}.` +
471
+ `${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
472
+ ` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
473
+ }
474
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
435
475
  liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
436
476
  cwd = await realpath(cwd);
477
+ if (executionCwd !== undefined) {
478
+ executionCwd = await realpath(executionCwd);
479
+ if (!(await stat(executionCwd)).isDirectory()) throw new Error('Task execution directory must be a directory.');
480
+ }
437
481
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
438
482
  return updateRecord(root, provider, session, async previous => {
483
+ if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before starting Delivery Loop.');
439
484
  if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
440
- const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === cwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.cwd === cwd;
485
+ const sessionCwd = previous?.cwd;
486
+ const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === sessionCwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.admission.cwd === sessionCwd;
441
487
  if (liveHookAdmission) {
442
488
  const process = previous?.process;
443
489
  if (provider !== 'claude' || previous?.hookStartedAt !== liveHookAdmission.hookStartedAt ||
444
- !(await claudeHookChannel(previous, { session, cwd, inspectProcess })).ready || process?.pid !== liveHookAdmission.pid ||
490
+ !(await claudeHookChannel(previous, { session, cwd: sessionCwd, inspectProcess })).ready || process?.pid !== liveHookAdmission.pid ||
445
491
  process?.identity !== liveHookAdmission.identity || !process.identity ||
446
492
  await inspectProcess(process.pid) !== process.identity) throw new Error('Claude live hook changed before enrollment; refresh the session.');
447
493
  }
448
- if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
449
- if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
494
+ if ((!previous?.observedAt && !admitted) || sessionCwd !== cwd && executionCwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or explicitly bind its task execution directory.');
495
+ const upgrading = upgradeRunId !== undefined;
496
+ if (upgrading && (!canUpgradeDelivery(previous.run) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
497
+ if (previous.run && !upgrading) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
498
+ if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
450
499
  const managedConfig = managed && { ...(managed === true ? {} : managed),
451
500
  ...(continuationPolicy === undefined ? {} : { continuationPolicy }) };
452
501
  const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
502
+ if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
453
503
  if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
454
504
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
455
505
  if (managedState && previous.associationTaskId) {
@@ -464,6 +514,25 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
464
514
  maxFailures: positive(maxFailures, 3, 10),
465
515
  continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
466
516
  ...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
517
+ if (executionCwd || previous.run?.executionCwd) run.executionCwd = executionCwd || previous.run.executionCwd;
518
+ if (upgrading) {
519
+ const prior = previous.run;
520
+ // Adopt the same run, including all failure/progress budgets and history.
521
+ // Consume the legacy checkpoint so its completion claim cannot replay.
522
+ Object.assign(run, structuredClone(prior), { managed: managedState, executionCwd: run.executionCwd,
523
+ updatedAt: now, nextStep: text(nextStep), consumedRevision: prior.revision });
524
+ managedState.taskId = previous.associationTaskId || prior.id;
525
+ managedState.lastVerifiedProgressAt = prior.startedAt;
526
+ managedState.upgradedFrom = { runId: prior.id, state: prior.state, nextStep: prior.nextStep,
527
+ checkpoint: structuredClone(prior.checkpoint), at: now };
528
+ if (prior.state !== 'paused') {
529
+ run.state = waiting ? 'blocked' : queued ? 'recovery_required' : 'active';
530
+ run.reason = waiting ? managedState.decision.reason : 'Existing loop adopted; reconcile prior work before selecting another action.';
531
+ if (queued) run.recoveryCause = 'session_supervision';
532
+ }
533
+ if (managedState.capability === 'live_hook') managedState.awaitingReplanHook = true;
534
+ }
535
+ if (managedState && !managedState.contextBootstrap) freezeEnrollment(run, now);
467
536
  if (waiting) escalate(run, run.reason);
468
537
  return { ...previous, run };
469
538
  });
@@ -483,7 +552,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
483
552
  let effectiveStatus = checkpoint.status;
484
553
  let nextStep = text(checkpoint.nextStep);
485
554
  let repairPlan = null;
486
- if (!run || record.cwd !== cwd) throw new Error('No matching active Delivery Loop.');
555
+ if (!run || !checkpointDirectoryMatches(record, cwd)) throw new Error('No matching active Delivery Loop.');
487
556
  if (run.managed?.phase === 'understanding' || run.managed?.phase === 'needs_clarification') throw new Error('Session understanding needs its separate structured checkpoint.');
488
557
  if (checkpoint.status === 'completed' && !run.managed && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
489
558
  if (expectedLease && (run.state !== 'auto_resuming' || run.managed?.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Managed recovery lease changed; checkpoint discarded.');
@@ -558,8 +627,8 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
558
627
  run.revision += 1; run.updatedAt = now;
559
628
  recordManagedOutcomeProgress(run, now);
560
629
  if (run.managed && effectiveStatus === 'completed') {
561
- const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
562
- if (pending.length) { run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`; }
630
+ const pending = pendingAcceptance(run);
631
+ if (pending.length) { run.state = 'verification_pending'; run.reason = acceptancePendingReason(run); }
563
632
  else if (run.managed.contextBootstrap && !run.managed.acceptanceCriteria.length) {
564
633
  run.state = 'reported_done'; run.reason = 'Original session reported the goal done; no independent acceptance check was available.';
565
634
  }
@@ -582,7 +651,7 @@ export async function checkpointActionAssociation({ root, provider, session, cwd
582
651
  cwd = await realpath(cwd);
583
652
  return updateRecord(root, provider, session, record => {
584
653
  const run = record?.run, managed = run?.managed, association = managed?.actionAssociation, job = association?.job;
585
- if (!job || managed.phase !== 'action_association' || record.cwd !== cwd || job.taskId !== managed.taskId || job.provider !== provider || job.session !== session || managed.handoff && managed.handoff.status !== 'received' || TERMINAL.has(run.state) || managed.understandingRevision !== job.understandingRevision || managed.decision?.id !== job.id || run.revision !== job.runRevision || ['paused','blocked'].includes(run.state)) throw new Error('Association job changed; checkpoint discarded.');
654
+ if (!job || managed.phase !== 'action_association' || !checkpointDirectoryMatches(record, cwd) || job.taskId !== managed.taskId || job.provider !== provider || job.session !== session || managed.handoff && managed.handoff.status !== 'received' || TERMINAL.has(run.state) || managed.understandingRevision !== job.understandingRevision || managed.decision?.id !== job.id || run.revision !== job.runRevision || ['paused','blocked'].includes(run.state)) throw new Error('Association job changed; checkpoint discarded.');
586
655
  if (expectedLease && (run.state !== 'auto_resuming' || managed.resumeLease?.id !== expectedLease || run.revision !== managed.resumeLease.checkpointRevision)) throw new Error('Association lease changed; checkpoint discarded.');
587
656
  let result;
588
657
  try { result = validateAssociationResult(job, checkpoint); }
@@ -610,7 +679,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
610
679
  if (!text(checkpoint.summary, 1000)) throw new Error('Understanding checkpoint needs a summary.');
611
680
  return updateRecord(root, provider, session, async record => {
612
681
  const run = record?.run;
613
- if (!run || record.cwd !== cwd || run.managed?.phase !== 'understanding' ||
682
+ if (!run || !checkpointDirectoryMatches(record, cwd) || run.managed?.phase !== 'understanding' ||
614
683
  TERMINAL.has(run.state) || ['blocked', 'paused', 'recovery_required', 'verification_pending'].includes(run.state)) throw new Error('No active session understanding turn.');
615
684
  if (expectedLease && (run.state !== 'auto_resuming' || run.managed.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Session understanding lease changed; checkpoint discarded.');
616
685
  if (managedBudgetReason(run, now)) throw new Error('Session understanding deadline expired.');
@@ -647,6 +716,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
647
716
  ? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
648
717
  run.managed.verified = {};
649
718
  run.managed.phase = 'active';
719
+ freezeEnrollment(run, now);
650
720
  run.nextStep = understanding.nextAction.summary;
651
721
  recordDecision(run.managed, plan, now);
652
722
  run.reason = run.managed.decision.outcome === 'wait' ? run.managed.decision.reason : 'Session goal understood; selected next action awaits the normal dispatch gate.';
@@ -697,12 +767,13 @@ export async function verifyAutomaticCriteria(record, now) {
697
767
  if (criterion.check.type === 'manual') continue;
698
768
  const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
699
769
  if (!path) { failed(criterion, 'Frozen acceptance file path is absent'); continue; }
700
- const file = resolve(record.cwd, path);
701
- const rel = relative(record.cwd, file);
770
+ const directory = taskDirectory(record);
771
+ const file = resolve(directory, path);
772
+ const rel = relative(directory, file);
702
773
  if (rel.startsWith('..') || isAbsolute(rel)) { failed(criterion, 'Acceptance path is outside the enrolled workspace'); continue; }
703
774
  try {
704
775
  const actual = await realpath(file);
705
- const actualRel = relative(record.cwd, actual);
776
+ const actualRel = relative(directory, actual);
706
777
  if (actualRel.startsWith('..') || isAbsolute(actualRel)) { failed(criterion, 'Resolved acceptance path is outside the enrolled workspace'); continue; }
707
778
  const info = await stat(actual);
708
779
  if (!info.isFile() || info.size > 1024 * 1024) { failed(criterion, 'Acceptance file is not a bounded regular file'); continue; }
@@ -726,7 +797,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
726
797
  run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
727
798
  if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
728
799
  await verifyAutomaticCriteria(record, now);
729
- if (run.managed.acceptanceCriteria.every(item => Object.hasOwn(run.managed.verified, item.id))) {
800
+ if (!pendingAcceptance(run).length) {
730
801
  run.state = 'completed'; run.reason = run.checkpoint.summary;
731
802
  }
732
803
  }
@@ -843,6 +914,7 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
843
914
  }
844
915
  // Manual/legacy runs have no marker and retain their existing controls.
845
916
  export function automaticContinuationAllowed(record) {
917
+ if (record?.providerArchived === true) return false;
846
918
  const epoch = record?.run?.managed?.autoContinueEpoch;
847
919
  return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
848
920
  }
@@ -854,8 +926,13 @@ export function decideStop(run, now = Date.now()) {
854
926
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
855
927
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
856
928
  const cp = fresh ? next.checkpoint : null;
857
- if (cp?.status === 'completed') return finish(next.managed?.contextBootstrap && !next.managed.acceptanceCriteria.length ? 'reported_done' : 'completed',
858
- next.managed?.contextBootstrap && !next.managed.acceptanceCriteria.length ? 'Original session reported the goal done; no independent acceptance check was available.' : cp.summary);
929
+ if (cp?.status === 'completed') {
930
+ const pending = pendingAcceptance(next);
931
+ if (pending.length) return finish('verification_pending', acceptancePendingReason(next));
932
+ if (!next.managed || !next.managed.acceptanceCriteria.length) return finish('reported_done',
933
+ 'Original session reported the goal done; no independent acceptance check was available.');
934
+ return finish('completed', cp.summary);
935
+ }
859
936
  if (cp?.status === 'blocked') {
860
937
  next.blocker = cp.blocker;
861
938
  const approval = next.managed?.pendingApproval;
@@ -932,7 +1009,7 @@ export function decideStop(run, now = Date.now()) {
932
1009
  next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
933
1010
  const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
934
1011
  const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
935
- return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
1012
+ return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
936
1013
  }
937
1014
  // A process signature protects against PID reuse. Never treat a stale timer alone
938
1015
  // as a crash; an idle provider may be waiting for its user.
@@ -1024,12 +1101,12 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1024
1101
  const lease = run.managed.liveWorkspaceLease;
1025
1102
  const settled = run.managed.executionAttempt?.id === lease.attemptId && run.managed.executionAttempt?.phase === 'checkpointed' && lease.checkpointRevision === run.managed.executionAttempt.checkpointRevision;
1026
1103
  if (!settled) run.managed.executionUncertain = { attemptId: lease.attemptId, decisionId: lease.decisionId, cause: `live_${event}`, at: now };
1027
- boundaryLease = { ownerId: lease.ownerId, cwd: record.cwd };
1104
+ boundaryLease = { ownerId: lease.ownerId, cwd: taskDirectory(record), cwds: lease.cwds || taskWorkspaceDirectories(record) };
1028
1105
  if (settled && event === 'Stop') {
1029
1106
  // The matching checkpoint was durably committed before this verified
1030
1107
  // provider boundary. Releasing now permits the next write/test claim;
1031
1108
  // a crash cannot reinterpret the settled action as a new dispatch.
1032
- await releaseWorkspaceLease({ root, cwd: record.cwd, ownerId: lease.ownerId });
1109
+ await releaseWorkspaceLease({ root, cwd: taskDirectory(record), cwds: lease.cwds || taskWorkspaceDirectories(record), ownerId: lease.ownerId });
1033
1110
  boundaryLease = null;
1034
1111
  }
1035
1112
  delete run.managed.liveWorkspaceLease;
@@ -1040,6 +1117,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1040
1117
  }
1041
1118
  }
1042
1119
  if (!run || TERMINAL.has(run.state)) return record;
1120
+ if (event === 'UserPromptSubmit') output = { hookSpecificOutput: { hookEventName: event, additionalContext: deliveryAcceptanceContext(run) } };
1043
1121
  if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
1044
1122
  else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
1045
1123
  // The supervisor owns this exact attempt and consumes its structured
@@ -1066,11 +1144,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1066
1144
  // stable event ID; never dedupe on text (identical text can be a new turn).
1067
1145
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
1068
1146
  if (key && run.lastStopKey === key) return record;
1069
- if (run.managed && run.checkpoint?.status === 'completed') {
1147
+ if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
1070
1148
  await verifyAutomaticCriteria(record, now);
1071
- const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
1149
+ const pending = pendingAcceptance(run);
1072
1150
  if (pending.length) {
1073
- run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
1151
+ run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
1074
1152
  run.lastStopKey = key; notifyState(run, now); return record;
1075
1153
  }
1076
1154
  }
@@ -1082,7 +1160,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1082
1160
  const decision = decideStop(run, now);
1083
1161
  const selected = decision.run.managed?.decision;
1084
1162
  if (decision.output.decision === 'block' && provider === 'claude' && owner && run.managed.capability === 'live_hook' && ['local_edit', 'local_test'].includes(selected?.action.kind)) {
1085
- const unresolved = (await listRecords(root)).find(item => item.cwd === record.cwd && item.run?.managed?.executionUncertain);
1163
+ const unresolved = (await listRecords(root)).find(item => sharesTaskWorkspace(item, record) && item.run?.managed?.executionUncertain);
1086
1164
  if (unresolved) {
1087
1165
  run.reason = 'Workspace has an unresolved prior execution; the original task must reconcile its effects using read-only evidence before another write.';
1088
1166
  run.updatedAt = now; output = {}; return record;
@@ -1104,13 +1182,13 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1104
1182
  run.reason = claimedWorkspace.reason || 'Workspace is in use by another provider session; wait for its safe boundary.';
1105
1183
  run.updatedAt = now; output = {}; return record;
1106
1184
  }
1107
- const unresolvedAfterClaim = (await listRecords(root)).find(item => item.cwd === record.cwd && item.run?.managed?.executionUncertain);
1185
+ const unresolvedAfterClaim = (await listRecords(root)).find(item => sharesTaskWorkspace(item, record) && item.run?.managed?.executionUncertain);
1108
1186
  if (claimedWorkspace.recoveredOwner || unresolvedAfterClaim) {
1109
- boundaryLease = { ownerId: claimedWorkspace.owner.id, cwd: record.cwd };
1187
+ boundaryLease = { ownerId: claimedWorkspace.owner.id, cwd: taskDirectory(record), cwds: claimedWorkspace.owner.cwds };
1110
1188
  run.reason = 'Recovered workspace owner had unconfirmed effects; the original task must reconcile them before another write.';
1111
1189
  run.updatedAt = now; output = {}; return record;
1112
1190
  }
1113
- decision.run.managed.liveWorkspaceLease = { ownerId: claimedWorkspace.owner.id, decisionId: selected.id, attemptId };
1191
+ decision.run.managed.liveWorkspaceLease = { ownerId: claimedWorkspace.owner.id, cwds: claimedWorkspace.owner.cwds, decisionId: selected.id, attemptId };
1114
1192
  decision.run.managed.executionAttempt = { id: attemptId, decisionId: selected.id, phase: 'dispatching', dispatchedAt: now, lastActivityAt: now };
1115
1193
  }
1116
1194
  record.run = decision.run; output = decision.output;
@@ -1124,7 +1202,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
1124
1202
  if (claimedWorkspace?.release) await claimedWorkspace.release();
1125
1203
  throw error;
1126
1204
  }
1127
- if (boundaryLease) await releaseWorkspaceLease({ root, cwd: boundaryLease.cwd, ownerId: boundaryLease.ownerId });
1205
+ if (boundaryLease) await releaseWorkspaceLease({ root, ...boundaryLease });
1128
1206
  return output;
1129
1207
  }
1130
1208
  export function canReplanDelivery(run, provider, now = Date.now()) {
@@ -1157,6 +1235,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1157
1235
  try { return await updateRecord(root, provider, session, record => {
1158
1236
  const run = record?.run;
1159
1237
  if (!run) throw new Error('Delivery Loop not found.');
1238
+ if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
1160
1239
  if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
1161
1240
  if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
1162
1241
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
@@ -1321,8 +1400,9 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1321
1400
  const run = current.run;
1322
1401
  if (run.state === 'verification_pending' && run.managed) {
1323
1402
  await verifyAutomaticCriteria(current, now);
1324
- if (run.managed.acceptanceCriteria.every(item => Object.hasOwn(run.managed.verified, item.id))) {
1325
- run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
1403
+ if (!pendingAcceptance(run).length) {
1404
+ run.state = run.managed.acceptanceCriteria.length ? 'completed' : 'reported_done';
1405
+ run.reason = run.managed.acceptanceCriteria.length ? run.checkpoint.summary : 'Original session reported the goal done; no independent acceptance check was available.'; run.updatedAt = now;
1326
1406
  }
1327
1407
  }
1328
1408
  const activeTurn = run.managed &&
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { processIdentity } from './delivery-loop.mjs';
4
+ import { processIdentity, taskDirectory, taskWorkspaceDirectories } from './delivery-loop.mjs';
5
5
 
6
6
  export async function leaseOwnerDead(owner, inspectProcess = processIdentity) {
7
7
  if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return false;
@@ -17,14 +17,38 @@ export async function leaseOwnerDead(owner, inspectProcess = processIdentity) {
17
17
 
18
18
  // Provider writer locks protect a conversation. This lock protects the files
19
19
  // shared by different conversations. Tests may write fixtures/build outputs.
20
- export async function acquireWorkspaceLease({ root, record, leaseId, inspectProcess = processIdentity, onRecover = async () => {}, processOwner }) {
20
+ export async function acquireWorkspaceLease(options) {
21
+ const { record } = options;
21
22
  if (!['local_edit', 'local_test'].includes(record.run.managed.decision?.action.kind)) return { release: async () => {} };
22
- const cwd = await realpath(record.cwd);
23
+ // A Codex workspaceWrite turn also includes its original thread cwd. Lock
24
+ // both roots in canonical order even when task work targets another folder.
25
+ const cwds = [...new Set(await Promise.all(taskWorkspaceDirectories(record).map(cwd => realpath(cwd))))].sort();
26
+ const resources = [], ownerId = randomUUID();
27
+ let recoveredOwner = null;
28
+ try {
29
+ for (const cwd of cwds) {
30
+ const resource = await acquireDirectoryLease({ ...options, cwd, ownerId });
31
+ recoveredOwner ||= resource.recoveredOwner;
32
+ if (resource.busy) {
33
+ await Promise.all(resources.map(item => item.release()));
34
+ return { ...resource, recoveredOwner };
35
+ }
36
+ resources.push(resource);
37
+ }
38
+ const task = await realpath(taskDirectory(record));
39
+ return { recoveredOwner, owner: { ...resources.find(item => item.owner.cwd === task).owner, cwds },
40
+ async release(options) { await Promise.all(resources.map(item => item.release(options))); } };
41
+ } catch (error) {
42
+ await Promise.all(resources.map(item => item.release()));
43
+ throw error;
44
+ }
45
+ }
46
+ async function acquireDirectoryLease({ root, record, leaseId, cwd, ownerId, inspectProcess = processIdentity, onRecover = async () => {}, processOwner }) {
23
47
  const directory = join(root, 'workspace-leases');
24
48
  await mkdir(directory, { recursive: true, mode: 0o700 });
25
49
  const lock = join(directory, createHash('sha256').update(cwd).digest('hex'));
26
50
  const ownerPid = processOwner?.pid || process.pid;
27
- const owner = { id: randomUUID(), pid: ownerPid, identity: processOwner?.identity || await inspectProcess(ownerPid),
51
+ const owner = { id: ownerId, pid: ownerPid, identity: processOwner?.identity || await inspectProcess(ownerPid),
28
52
  taskId: record.run.managed.taskId, session: record.session, provider: record.provider,
29
53
  decisionId: record.run.managed.decision.id, leaseId, cwd, acquiredAt: Date.now() };
30
54
  // On Windows no POSIX process signature is available. Never reclaim a live
@@ -63,7 +87,10 @@ export async function acquireWorkspaceLease({ root, record, leaseId, inspectProc
63
87
  } };
64
88
  }
65
89
 
66
- export async function releaseWorkspaceLease({ root, cwd, ownerId }) {
90
+ export async function releaseWorkspaceLease({ root, cwd, cwds = [cwd], ownerId }) {
91
+ await Promise.all([...new Set(cwds)].map(cwd => releaseDirectoryLease({ root, cwd, ownerId })));
92
+ }
93
+ async function releaseDirectoryLease({ root, cwd, ownerId }) {
67
94
  const lock = join(root, 'workspace-leases', createHash('sha256').update(await realpath(cwd)).digest('hex'));
68
95
  let owner;
69
96
  try { owner = JSON.parse(await readFile(join(lock, 'owner.json'), 'utf8')); } catch { return; }
@@ -8,7 +8,7 @@ description: Continue an explicitly requested SIMY Delivery Loop in the current
8
8
  Use only when the user explicitly requests this skill or SIMY Delivery Loop for the current task. Installing this plugin does not opt a task in. Do not run SQM unless separately requested.
9
9
 
10
10
  1. Require the SIMY SessionStart hook context with the exact provider and session ID. If absent, explain how to install the SIMY plugin, review and enable its hooks with `/hooks`, and start a new conversation. Codex settings can be opened at `codex://settings`; a hooks subpage deep link is not guaranteed. Never change hook trust or bypass permissions yourself.
11
- 2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. When the user explicitly asks for a managed task with durable acceptance tracking, add `--managed-file <task.json>` as described below. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
11
+ 2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. For a Loop that must track acceptance, use the existing managed specification with `--managed-file <task.json>` as described below: freeze the original user outcome, the selected delivery level when one exists, and the necessary applicable acceptance criteria before work. A regular start without this specification is standard checkpoint tracking and can only end as `reported_done`; do not present it as a fully verified Agentic Loop. Do not fabricate a level selection or add engineering/SQM checks the user did not select or the task does not require. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
12
12
  3. Work in the current conversation with its normal permissions. Before ending each turn, submit a JSON checkpoint through standard input with `simy delivery checkpoint --provider <provider> --session <id> --file -` (for example, a shell heredoc). This updates SIMY state; it does not require writing a checkpoint file to the user's workspace. A file path remains supported when authorized.
13
13
 
14
14
  Checkpoint shapes:
@@ -22,7 +22,7 @@ Checkpoint shapes:
22
22
 
23
23
  For missing information use `blocker: "missing_information"`. For a failed attempt use `status: "error"` and a concrete `nextStep`; repeated errors stop the loop. Do not reclassify blocked work as in_progress to force continuation. Never assume a provider error means permission to switch accounts, credentials, or bypass approvals.
24
24
 
25
- 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. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
25
+ 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.
26
26
 
27
27
  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.
28
28
  5. After the user explicitly resolves the blocker and requests continuation, `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.
@@ -31,6 +31,8 @@ If the user selected **Continue this conversation** in SIMY desktop, the exact s
31
31
 
32
32
  For an explicitly enrolled managed task (`--managed-file`), keep its task ID and frozen acceptance criteria. Every `in_progress` or `error` checkpoint must include a concrete `proposedAction` with `kind`, `scope`, and `summary`, plus `remainingWork` when known. Allowed kinds are `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, and `unknown`. Record an authorization blocker with the proposed next step and a scoped `approval` object; do not assert that a provider permission has been granted by a SIMY policy. A `completed` checkpoint with worker evidence may remain `verification_pending` until the local verifier or user confirms every criterion. Do not mark a manual criterion verified on the user's behalf.
33
33
 
34
+ The optional `deliveryLevel` records an existing user selection as `{"requested":3,"effective":4,"numberingVersion":"assurance-ascending-v2","selectionRef":"user-turn:7"}`. Effective assurance cannot silently be lower than requested. A level is a recorded selection, not proof that checks at that level ran. Express selected, applicable tests, review, actual runtime/user outcome and SQM requirements as named acceptance criteria; semantic engineering checks remain `manual`, never a `file_exists` proxy. SQM `passed` with 0 applicable checks cannot establish quality acceptance. Do not weaken a failed requirement to make the Loop finish. Enrollment keeps the original objective, level and criteria across checkpoints and resume; changing a checkpoint cannot replace them.
35
+
34
36
  The managed file must include `initialAction` and nonempty `acceptanceCriteria`. Use only criteria the user actually agreed to, such as:
35
37
 
36
38
  ```json