@awak-app/simy-cli 0.7.22 → 0.7.25

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.
@@ -45,6 +45,18 @@ function parseDeliveryOptions(args, allowed) {
45
45
 
46
46
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), webOrigin, input = process.stdin, output = console.log } = {}) {
47
47
  const [action, ...args] = argv;
48
+ if (action === 'history-recovery') {
49
+ const [operation,...flags] = args;
50
+ const options = parseDeliveryOptions(flags,new Set(['audit','digest']));
51
+ const recovery = await import('../update-detachment/history-recovery.js');
52
+ const parentRoot = dirname(root);
53
+ let result;
54
+ if (operation === 'plan') result = await recovery.planDispatchHistoryRecovery({root:parentRoot});
55
+ else if (operation === 'apply') result = await recovery.applyDispatchHistoryRecovery({root:parentRoot,auditRef:options.audit,auditDigest:options.digest});
56
+ else if (operation === 'reconnect') result = await recovery.reconnectRecoveredHistory({root:parentRoot,origin:webOrigin});
57
+ else throw new Error('Use simy delivery history-recovery plan|apply --audit PATH --digest SHA256|reconnect.');
58
+ output(JSON.stringify(result,null,2)); return 0;
59
+ }
48
60
  if (action === 'auto') {
49
61
  const [operation, ...flags] = args;
50
62
  if (!['on', 'off', 'status'].includes(operation)) throw new Error('Use simy delivery auto on|off|status --provider codex|claude in the project directory.');
@@ -1,5 +1,5 @@
1
1
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
- import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, confirmDispatch, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
3
3
  import { bindCompletionArtifacts } from '../session-supervision/completion-artifacts.js';
4
4
  import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
5
5
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
@@ -480,7 +480,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
480
480
  else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
481
481
  checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
482
482
  if (result.dispatchAttemptId) queueDispatchReceipt({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId, receiptId: `checkpoint:${leaseId}`, resultRef: `delivery-record:${record.provider}:${record.session}:${leaseId}` });
483
- await updateRecord(root, record.provider, record.session, current => {
483
+ const checkpointed = await updateRecord(root, record.provider, record.session, current => {
484
484
  if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
485
485
  current.run.managed.executionAttempt.phase = 'checkpointed';
486
486
  current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
@@ -501,6 +501,14 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
501
501
  }
502
502
  return current;
503
503
  });
504
+ // Settle this successful native turn while its exact durable checkpoint
505
+ // is still current. A later worker replaces executionAttempt, so waiting
506
+ // until an update boundary would lose the original result association.
507
+ const savedAttempt = checkpointed?.run?.managed?.executionAttempt;
508
+ if (result.dispatchAttemptId && checkpointed?.run?.id === claimed.run.id &&
509
+ savedAttempt?.id === leaseId && result.dispatchAttemptId === leaseId &&
510
+ savedAttempt.phase === 'checkpointed' && savedAttempt.checkpointRevision === checkpointed.run.revision)
511
+ confirmDispatch({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId });
504
512
  } catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected', diagnostic: safeCheckpointDiagnostic(error) }; }
505
513
  }
506
514
  await updateRecordAfterControl(root, record.provider, record.session, async current => {
@@ -657,8 +665,10 @@ export function createManagedSupervisor(options) {
657
665
  // A queue entry is a wakeup, not authority or a saved action. Re-read
658
666
  // consent, revisions and task identity at admission and each claim.
659
667
  const readRecords = async () => {
660
- const rows = options.readRecords ? await options.readRecords()
661
- : [await readRecord(options.root, record.provider, record.session)];
668
+ const rows = options.readCurrentRecord
669
+ ? [await options.readCurrentRecord(record.provider, record.session)]
670
+ : options.readRecords ? await options.readRecords()
671
+ : [await readRecord(options.root, record.provider, record.session)];
662
672
  return rows.filter(row => row?.provider === record.provider && row.session === record.session &&
663
673
  row.run?.id === record.run.id && row.run?.managed?.taskId === record.run.managed.taskId);
664
674
  };
@@ -7,7 +7,7 @@ export function validDesktopAccessPath(path, slug) {
7
7
  return path === expected || path === `${expected}/`;
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
- const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
10
+ const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'authorization_copy', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
11
  'check_update', 'auto_update_set_enabled', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
@@ -0,0 +1,23 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // Pass private links over stdin, never in process arguments, status, or logs.
4
+ export function copyDesktopText(text, { platform = process.platform, spawnImpl = spawn } = {}) {
5
+ const command = platform === 'darwin' ? ['pbcopy', []] : platform === 'win32'
6
+ ? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '$inputText = [Console]::In.ReadToEnd(); Set-Clipboard -Value $inputText -ErrorAction Stop']]
7
+ : ['xclip', ['-selection', 'clipboard']];
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawnImpl(command[0], command[1], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
10
+ const timer = setTimeout(() => { child.kill(); finish(false); }, 5000);
11
+ let settled = false;
12
+ function finish(ok) {
13
+ if (settled) return;
14
+ settled = true; clearTimeout(timer);
15
+ if (ok) resolve();
16
+ else { child.kill(); reject(Object.assign(new Error('Clipboard unavailable.'), { code: 'desktop_clipboard_unavailable' })); }
17
+ }
18
+ child.on('error', () => finish(false));
19
+ child.on('close', code => finish(code === 0));
20
+ child.stdin.on('error', () => finish(false));
21
+ child.stdin.end(text);
22
+ });
23
+ }
@@ -1,4 +1,5 @@
1
1
  import { remoteOperationFailure } from './session-supervision/remote-errors.js';
2
+ import { copyDesktopText } from './desktop-clipboard.js';
2
3
  import { createRemoteHostManager } from './session-supervision/remote-hosts.js';
3
4
  import { readDesktopAutoUpdate, writeDesktopAutoUpdate } from './desktop-update-settings.js';
4
5
  import { createUpdateRuntime } from './update-detachment/runtime.js';
@@ -23,7 +24,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
23
24
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
24
25
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
25
26
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
26
- import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
27
+ import { controlDelivery, verifyDelivery, createRecordReader, readRecord, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
27
28
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
28
29
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
29
30
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
@@ -105,6 +106,7 @@ function desktopActionError(action, error) {
105
106
  };
106
107
  if (messages[error?.code]) return messages[error.code];
107
108
  }
109
+ if (action === 'authorization_copy') return 'Could not copy the connection link. Reopen the connection page and try again.';
108
110
  if (action === 'authorization_open' || error?.code === 'desktop_browser_unavailable') return "Could not open your browser. Open the verification link again, or check your default browser.";
109
111
  if (error?.code === "desktop_access_required") return "Sign in to an organization with SQM and Developer access before using SIMY.";
110
112
  if (action === "codex_hooks" || action === "claude_hooks") return "Could not open the setup guide. Check your default browser, or use /hooks inside the provider.";
@@ -409,6 +411,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
409
411
  const link = await requireProviderLink({ root: deliveryRoot, provider: record.provider });
410
412
  return JSON.stringify([scope, link.epoch]);
411
413
  }, readRecords: () => recordReader.list(),
414
+ readCurrentRecord: (provider, session) => readRecord(deliveryRoot, provider, session),
412
415
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
413
416
  let shuttingDown = null;
414
417
  let commands;
@@ -529,7 +532,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
529
532
  phase = "authorize_connect";
530
533
  await authorizeWork();
531
534
  phase = "save_connect";
532
- providerLinks = await writeProviderLink(root, provider, true);
535
+ providerLinks = await writeProviderLink(root, provider, true, { repairInvalidSettings: true });
533
536
  }
534
537
  delete providerLinkFeedback[provider];
535
538
  try { await agent.refreshProviderStatus?.(); }
@@ -1329,6 +1332,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1329
1332
  else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
1330
1333
  else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
1331
1334
  else if (action === "organization_cancel") await agent.cancelOrganizationSelection();
1335
+ else if (action === 'authorization_copy') {
1336
+ const url = agent.getPendingAuthorizationUrl?.() || await agent.requestDesktopLogin();
1337
+ if (!url || new URL(url).origin !== webOrigin || new URL(url).pathname !== '/local-cli/connect') throw new Error('No valid connection link.');
1338
+ await (dependencies.copyText || copyDesktopText)(url);
1339
+ }
1332
1340
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
1333
1341
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
1334
1342
  const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
@@ -1655,7 +1663,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1655
1663
  };
1656
1664
  if (command.action === "quit" || command.action === "prepare_update_restart" || command.action === "update" && (command.force === true || stoppingForUpdate)) {
1657
1665
  void execute();
1658
- } else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
1666
+ } else if (["login", "organization_select", "organization_cancel", "authorization_open", "authorization_copy"].includes(command.action)) {
1659
1667
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
1660
1668
  } else if (/^(codex|claude)_login$/.test(command.action || "") || ["provider_repair", "provider_connect", "provider_disconnect"].includes(command.action)) {
1661
1669
  // Opening an auth terminal must not wait for an unrelated provider turn.
@@ -67,6 +67,7 @@ export function desktopAuthStatus(session, { now = Date.now(), webOrigin = null
67
67
  state: valid ? "signed_in" : exists ? "expired" : "signed_out",
68
68
  expiresAt: typeof session?.expires_at === "string" ? session.expires_at : null,
69
69
  userLabel: sessionUserLabel(session),
70
+ userEmail: valid ? sessionUserEmail(session) : null,
70
71
  userId: valid ? (session.account_id ?? session.auth_user_id ?? null) : null,
71
72
  };
72
73
  }
@@ -323,6 +324,14 @@ function reportPresentation(result, allowSchemaLess = false) {
323
324
  } catch { return null; }
324
325
  }
325
326
 
327
+ function sessionUserEmail(session) {
328
+ for (const key of ["email", "user_label"]) {
329
+ const value = session?.[key];
330
+ if (typeof value === "string" && value.trim().length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) return value.trim();
331
+ }
332
+ return null;
333
+ }
334
+
326
335
  function sessionUserLabel(session) {
327
336
  if (!session) return null;
328
337
  for (const key of ["user_label", "display_name", "email", "account_id", "auth_user_id"]) {
@@ -101,11 +101,11 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
101
101
  target: check.id,
102
102
  explanation: check.detail,
103
103
  repairability:
104
- check.id === "executor_succeeded" && attempt.raw_output.blocked
104
+ check.id === "executor_succeeded" && attempt.raw_output?.blocked && attempt.raw_output?.blocker_kind === "human"
105
105
  ? "manual"
106
106
  : check.repairability,
107
107
  auto_fix_hint:
108
- check.id === "executor_succeeded" && attempt.raw_output.blocked
108
+ check.id === "executor_succeeded" && attempt.raw_output?.blocked && attempt.raw_output?.blocker_kind === "human"
109
109
  ? null
110
110
  : `Resolve ${check.label.toLowerCase()} and check the result.`,
111
111
  }));
@@ -12,7 +12,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
12
12
  return buildWorkflowReportingInstruction({ auditor: true }) + "\n" + buildAcceptedHumanGuidanceInstruction(charter) + "\n" + section("requested_review", `Review the original request and actual behavior read-only. Report concrete defects, regressions and missing user outcomes. No preparation matrix, citation package, assurance score or mandatory screenshot record is required. Do not certify unobserved results or grant permission.
13
13
  Request: ${JSON.stringify({ requirement: charter.requirement, criteria: acceptanceCriterionRows(charter), mustNot: charter.must_not, experience: charter.completion_contract?.experience })}
14
14
  Current result: ${JSON.stringify({ summary: attempt.summary ?? null, residual_risks: attempt.residual_risks, tests: attempt.tests_run, observations: attempt.criterion_evidence, current: attempt.observed_evidence })}
15
- Return SIMY_AUDIT_JSON: with {passed,summary,findings,criterion_results,coverage_review:{user_outcome:{passed,verification_method,observed_result}}}. Check the original requested result even if the listed criteria mention only code or tests. Each criterion result describes criterion_id,passed,verification_method,observed_result. Each finding candidate must contain passed. Only passed=false entries are findings; never report passed=true evidence as a finding. Findings describe the concrete remaining defect and necessary repair.`);
15
+ Return SIMY_AUDIT_JSON: with {passed,summary,findings,criterion_results,coverage_review:{user_outcome:{passed,verification_method,observed_result}}}. Check the original requested result even if the listed criteria mention only code or tests. Each criterion result describes criterion_id,passed,verification_method,observed_result. Each finding candidate must contain passed. Only passed=false entries are findings; never report passed=true evidence as a finding. Findings describe the concrete remaining defect and necessary repair. Set blocker_kind to machine for technical failures, external for unavailable dependencies, and human only for actual missing authorization, authentication interaction or an explicitly user-reserved decision. A legacy repairability=manual label or missing report does not establish a human dependency.`);
16
16
  }
17
17
 
18
18
  export function buildIndependentAudit(execution, charter = null, attempt = null) {
@@ -60,11 +60,11 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
60
60
  severity: cleanString(item.severity).toLowerCase() || "major",
61
61
  target: cleanString(item.target) || "pull_request",
62
62
  explanation: cleanString(item.explanation) || "Independent audit finding.",
63
- repairability: item.repairability === "manual" ? "manual" : "auto",
63
+ repairability: item.blocker_kind === "human" ? "manual" : "auto",
64
64
  disposition: ["defer", "dismiss"].includes(item.disposition) && cleanString(item.explanation)
65
65
  ? item.disposition : "fix",
66
66
  blocker_kind: ["machine", "human", "external"].includes(item.blocker_kind)
67
- ? item.blocker_kind : item.repairability === "manual" ? "human" : "machine",
67
+ ? item.blocker_kind : "machine",
68
68
  auto_fix_hint: cleanString(item.auto_fix_hint) || null,
69
69
  }));
70
70
  const findings = evaluations.filter((item) => item.passed === false);
@@ -13,6 +13,7 @@ export function buildCodingInstruction(
13
13
  },
14
14
  ) {
15
15
  return [
16
+ section("automatic_recovery", "Continue authorized work without procedural sign-off. Repair technical failures automatically. For an actual blocker, report blocker_kind=machine for technical repair, external for an unavailable dependency, or human for missing authorization, authentication interaction or an explicitly reserved decision. Preserve native permissions and explicit stops; blocked=true alone does not require human input."),
16
17
  section("role", "Complete the user's requested outcome in this repository."),
17
18
  buildWorkflowReportingInstruction(),
18
19
  buildAcceptedHumanGuidanceInstruction(charter),
@@ -113,7 +113,7 @@ async function runCodingLoopOwned({
113
113
  previousAttempt,
114
114
  previousFindings,
115
115
  promptInterventions,
116
- });
116
+ }) + (snapshot.metadata?.automatic_recovery ? '\nAutomatic recovery of the same unfinished task: inspect the current failure and previous attempts, test a different cause or reversible repair, then return to the original requested work. Do not repeat an unchanged failing operation or ask the user to certify technical correctness. Preserve existing permissions and limits.' : '');
117
117
 
118
118
  let attempt = recoveredAttempt;
119
119
  if (!attempt) {
@@ -574,7 +574,18 @@ async function handleFailedAudit({
574
574
  }) {
575
575
  snapshot.charter.thread_state.failure_count += 1;
576
576
  const diagnosticCircuit = evaluateRetryCircuit({ attempt, previousAttempt: snapshot.attempts.at(-2) ?? null, findings });
577
- const technicalStall = attempt.outcome_progress?.stalled || attempt.outcome_progress?.assessment_unavailable || diagnosticCircuit.open && !attempt.outcome_progress?.has_progress;
577
+ // A new commit does not resolve an unchanged review failure. Criteria can all
578
+ // pass while a required review finding still prevents acceptance. Compare
579
+ // concrete details: a shared generic code does not identify the same defect.
580
+ const priorAttempt = snapshot.attempts.at(-2);
581
+ const repeatedReview = !attempt.outcome_progress?.has_progress &&
582
+ diagnosticCircuit.previous_fingerprint?.review_findings_sha256 === diagnosticCircuit.fingerprint.review_findings_sha256 &&
583
+ priorAttempt?.outcome_progress?.objective_digest === attempt.outcome_progress?.objective_digest;
584
+ attempt.audit_retry_streak = repeatedReview ? (priorAttempt.audit_retry_streak || 1) + 1 : 1;
585
+ const reviewStalled = !requiresHuman && attempt.audit?.passed === true && attempt.independent_audit?.passed === false &&
586
+ attempt.audit_retry_streak >= 3 && attempt.outcome_progress?.status === "assessed" &&
587
+ !attempt.outcome_progress?.stalled && !attempt.outcome_progress?.assessment_unavailable;
588
+ const technicalStall = reviewStalled || attempt.outcome_progress?.stalled || attempt.outcome_progress?.assessment_unavailable || diagnosticCircuit.open && !attempt.outcome_progress?.has_progress;
578
589
  if (technicalStall && !requiresHuman) {
579
590
  const state = snapshot.charter.thread_state;
580
591
  const key = JSON.stringify([state.objective_contract?.current_digest, attempt.outcome_progress?.unproven_ids || [], findings.filter(row => row.code !== 'AI_STRATEGY_CHANGE_REQUIRED').map(row => row.code).sort()]);
@@ -590,18 +601,18 @@ async function handleFailedAudit({
590
601
  return false;
591
602
  }
592
603
  }
593
- if (attempt.outcome_progress?.stalled) {
594
- const unresolved = attempt.outcome_progress.unproven_ids;
604
+ if (reviewStalled || attempt.outcome_progress?.stalled) {
605
+ const unresolved = attempt.outcome_progress?.unproven_ids || [];
595
606
  snapshot.final_audit = {
596
607
  ...(snapshot.final_audit || {}),
597
608
  passed: false,
598
- summary: "Independent review found no progress on the remaining user outcomes.",
609
+ summary: reviewStalled ? "Automatic diagnosis did not resolve the repeated review failure." : "Independent review found no progress on the remaining user outcomes.",
599
610
  findings: [...(snapshot.final_audit?.findings || []), {
600
611
  passed: false,
601
- code: "OUTCOME_PROGRESS_STALLED",
612
+ code: reviewStalled ? "REVIEW_PROGRESS_STALLED" : "OUTCOME_PROGRESS_STALLED",
602
613
  severity: "blocker",
603
614
  target: "acceptance_criteria",
604
- explanation: `The same user outcomes remain unproven after another attempt: ${unresolved.join(", ")}. Reassess the approach against the approved objective before more work.`,
615
+ explanation: reviewStalled ? "The same review findings remain after automatic diagnosis and another repair attempt. Preserve the failure for machine recovery; human certification cannot resolve it." : `The same user outcomes remain unproven after another attempt: ${unresolved.join(", ")}. Reassess the approach against the approved objective before more work.`,
605
616
  repairability: "auto",
606
617
  blocker_kind: "machine",
607
618
  }],
@@ -618,11 +629,7 @@ async function handleFailedAudit({
618
629
  await publish(snapshot, "blocked", onUpdate);
619
630
  return true;
620
631
  }
621
- const retryCircuit = evaluateRetryCircuit({
622
- attempt,
623
- previousAttempt: snapshot.attempts.at(-2) ?? null,
624
- findings,
625
- });
632
+ const retryCircuit = diagnosticCircuit;
626
633
  if (retryCircuit.open && !attempt.outcome_progress?.has_progress &&
627
634
  !(attempt.audit?.passed && attempt.outcome_progress?.status === "insufficient_evidence" &&
628
635
  !attempt.outcome_progress?.failed_ids?.length)) {
@@ -1,4 +1,4 @@
1
- import { suggestedProviderTokenBudget, MAX_PROVIDER_TOKEN_BUDGET } from "./budget.js";
1
+ import { suggestedProviderTokenBudget, MAX_PROVIDER_TOKEN_BUDGET, budgetState } from "./budget.js";
2
2
 
3
3
  const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
4
4
 
@@ -8,13 +8,17 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
8
8
 
9
9
  const reasonCode = recoveryReasonCode(state, message, detail);
10
10
  const attemptNumber = positiveInteger(detail.attempt_number);
11
- const actions = recoveryActions(reasonCode, detail.budget);
11
+ const machineStopped = state === "blocked" && snapshot?.final_audit?.requires_human === false &&
12
+ ["outcome_progress_stalled", "retry_circuit_open"].includes(detail.code);
13
+ const actions = machineStopped ? [] : recoveryActions(reasonCode, detail.budget);
14
+ const automaticRecovery = machineStopped && !budgetState(snapshot).exhausted;
12
15
  return {
13
16
  schema_version: 1,
14
17
  state,
15
- reason_code: reasonCode,
16
- user_message: isSafetyCeiling(reasonCode) ? safetyCeilingRecoveryMessage(reasonCode, detail.budget) : recoveryMessage(reasonCode),
17
- primary_action_id: actions[0].id,
18
+ reason_code: automaticRecovery ? 'machine_recovery_pending' : machineStopped ? "machine_recovery_exhausted" : reasonCode,
19
+ ...(machineStopped ? { requires_user_action: false } : {}),
20
+ user_message: automaticRecovery ? 'The task remains unfinished. SIMY will retry this same task after a recovery cooldown while its connection is enabled. No manual confirmation is required.' : machineStopped ? "Automatic diagnosis and repair attempts could not resolve the technical failure. The task remains unfinished; failure details are retained. No human approval or success declaration is required." : isSafetyCeiling(reasonCode) ? safetyCeilingRecoveryMessage(reasonCode, detail.budget) : recoveryMessage(reasonCode),
21
+ primary_action_id: actions[0]?.id ?? null,
18
22
  actions,
19
23
  technical_details: {
20
24
  event_code: clean(detail.code) || null,
@@ -104,9 +108,10 @@ export function isRecoveryContract(value) {
104
108
  value.schema_version === 1 &&
105
109
  typeof value.reason_code === "string" &&
106
110
  typeof value.user_message === "string" &&
107
- typeof value.primary_action_id === "string" &&
108
111
  Array.isArray(value.actions) &&
109
- value.actions.length > 0,
112
+ (typeof value.primary_action_id === "string" && value.actions.length > 0 ||
113
+ value.state === "blocked" && ['machine_recovery_pending', 'machine_recovery_exhausted'].includes(value.reason_code) &&
114
+ value.requires_user_action === false && value.primary_action_id === null && value.actions.length === 0),
110
115
  );
111
116
  }
112
117
 
@@ -39,6 +39,7 @@ export function buildRetryFingerprint(attempt, findings) {
39
39
  commit_sha: commitSha,
40
40
  finding_codes: normalizedFindings.map((finding) => finding.code),
41
41
  findings_sha256: sha256(findingPayload),
42
+ review_findings_sha256: sha256(stableJson(normalizeFindings(findings, true))),
42
43
  evidence_sha256: sha256(evidencePayload),
43
44
  };
44
45
  }
@@ -52,7 +53,7 @@ function findingsForAttempt(attempt) {
52
53
  ];
53
54
  }
54
55
 
55
- function normalizeFindings(findings) {
56
+ function normalizeFindings(findings, includeDetails = false) {
56
57
  return (Array.isArray(findings) ? findings : [])
57
58
  .filter((finding) => finding && typeof finding === "object")
58
59
  .map((finding) => ({
@@ -62,6 +63,11 @@ function normalizeFindings(findings) {
62
63
  severity: cleanString(finding.severity).toLowerCase() || null,
63
64
  target: cleanString(finding.target) || null,
64
65
  repairability: cleanString(finding.repairability).toLowerCase() || null,
66
+ ...(includeDetails ? {
67
+ explanation: cleanString(finding.explanation) || null,
68
+ auto_fix_hint: cleanString(finding.auto_fix_hint) || null,
69
+ blocker_kind: cleanString(finding.blocker_kind) || null,
70
+ } : {}),
65
71
  }))
66
72
  .sort(compareStableValues);
67
73
  }
@@ -14,12 +14,13 @@ async function readSettings(root) {
14
14
  let payload;
15
15
  try { payload = JSON.parse(await readFile(join(root, 'provider-links.json'), 'utf8')); }
16
16
  catch (error) { if (error.code === 'ENOENT') return defaults(); throw error; }
17
- if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
17
+ if (payload?.schemaVersion !== 1) throw new Error('Unsupported SIMY provider connection settings version.');
18
+ if (!payload.providers || PROVIDER_LINKS.some(provider => {
18
19
  const value = payload.providers[provider];
19
20
  return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
20
21
  value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128) ||
21
22
  value.consentEpoch !== undefined && (value.consentEpoch !== value.epoch || !value.consentEpoch || value.connected !== false || typeof value.enabled !== 'boolean');
22
- })) throw new Error('Invalid SIMY provider connection settings.');
23
+ })) throw Object.assign(new Error('Invalid SIMY provider connection settings.'), { code: 'invalid_provider_link_settings' });
23
24
  return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
24
25
  connected: payload.providers[provider].consentEpoch ? payload.providers[provider].enabled : payload.providers[provider].connected,
25
26
  ...(payload.providers[provider].consentEpoch ? { consentEpoch: payload.providers[provider].consentEpoch } : {}),
@@ -52,13 +53,22 @@ export async function requireProviderLink({ root = join(defaultRoot(), 'delivery
52
53
  return link;
53
54
  }
54
55
 
55
- export function writeProviderLink(root = defaultRoot(), provider, connected) {
56
+ export function writeProviderLink(root = defaultRoot(), provider, connected, { repairInvalidSettings = false } = {}) {
56
57
  validateProvider(provider);
57
58
  if (typeof connected !== 'boolean') throw new Error('Choose whether this provider is connected to SIMY.');
58
59
  const key = resolve(root);
59
60
  const previous = writeQueues.get(key) || Promise.resolve();
60
61
  const pending = previous.catch(() => {}).then(async () => {
61
- const providers = await readSettings(root);
62
+ let providers;
63
+ try { providers = await readSettings(root); }
64
+ catch (error) {
65
+ if (!repairInvalidSettings || !connected || !(error instanceof SyntaxError || error.code === 'invalid_provider_link_settings')) throw error;
66
+ // Only an explicit Connect action restores this provider. Retain the
67
+ // damaged file and leave all other providers disconnected.
68
+ const original = await readFile(join(root, 'provider-links.json'));
69
+ await writeFile(join(root, `provider-links.invalid-${randomUUID()}.json`), original, { mode: 0o600, flag: 'wx' });
70
+ providers = disconnected();
71
+ }
62
72
  // Reconnection restores discovery only. Session execution consent is cleared
63
73
  // separately and must be selected again, including after a host restart.
64
74
  const epoch = randomUUID();
package/src/runner.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  assertRecoveryActor, completedWorkerResult, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
60
60
  } from "./coding-run-recovery.js";
61
61
  import { workflowRunEnv } from "./dev-work-events.js";
62
+ import { canRecoverCodingRun, codingRecoveryDue, scheduleCodingRecovery } from './coding-run-automatic-recovery.js';
62
63
 
63
64
  const execFileAsync = promisify(execFile);
64
65
  const MAX_LOCAL_LOG_LINES = 1_000;
@@ -321,6 +322,7 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
321
322
  run.startedAt = restoredSnapshot.created_at || null;
322
323
  run.completedAt = restoredSnapshot.updated_at || null;
323
324
  run.controlState = restoredState === "stopped" ? "stopped" : "waiting_human";
325
+ if (canRecoverCodingRun(run)) run.controlState = 'complete';
324
326
  run.restoredFromState = restoredFromState;
325
327
  run.restoredObjectiveUnverified = objectiveUnverified;
326
328
  run.restoredCompletedWorkerResult = Boolean(recoverableResult);
@@ -430,6 +432,23 @@ export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
430
432
  return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
431
433
  }
432
434
 
435
+ // Automated recovery is not human guidance and does not reset stop intent,
436
+ // usage, the objective, or the existing task identity.
437
+ export async function recoverLocalCodingRun(run, dependencies = {}, now = Date.now()) {
438
+ if (!canRecoverCodingRun(run)) return run.snapshot;
439
+ if (!run.snapshot.metadata?.automatic_recovery) {
440
+ scheduleCodingRecovery(run.snapshot, now);
441
+ if (run.localStateRoot) await saveCodingRunSnapshot(run.localStateRoot, run.snapshot);
442
+ return run.snapshot;
443
+ }
444
+ if (!codingRecoveryDue(run, now)) return run.snapshot;
445
+ assertRunCanContinue(run);
446
+ run.completedAt = null;
447
+ run.controlState = 'running';
448
+ run.resumeCount += 1;
449
+ return executeLocalCodingRun(run, dependencies, { resume: true });
450
+ }
451
+
433
452
  function assertRunCanContinue(run) {
434
453
  if (run.operation || run.child) throw new Error("The selected Agentic Loop run is still active.");
435
454
  if (!RESUMABLE_STATES.has(run.status)) {
@@ -1324,6 +1343,11 @@ async function updateRun(run, state) {
1324
1343
  run.snapshot.state = state;
1325
1344
  run.snapshot.metadata = { ...run.snapshot.metadata, pr_lifecycle_state: state };
1326
1345
  run.snapshot.updated_at = new Date().toISOString();
1346
+ if (state === 'blocked' && run.snapshot.final_audit?.requires_human === false &&
1347
+ ['outcome_progress_stalled', 'retry_circuit_open'].includes(run.snapshot.events.at(-1)?.detail?.code) &&
1348
+ !budgetState(run.snapshot).exhausted) {
1349
+ scheduleCodingRecovery(run.snapshot);
1350
+ }
1327
1351
  run.completedAt = TERMINAL_STATES.has(state) ? run.snapshot.updated_at : null;
1328
1352
  if (state === "waiting_human" || state === "pr_ready_for_review") {
1329
1353
  run.controlState = "waiting_human";
@@ -1,3 +1,4 @@
1
+ import { recordSqmConnectionObservation } from '../connection-overview.js';
1
2
  import { ADVISORY_GUIDANCE_CAPABILITY, isAdvisoryGuidance } from './advisory-guidance.js';
2
3
  import { safeSqmDiagnostic } from "./diagnostics.js";
3
4
  import { createHash, createPublicKey } from "node:crypto";
@@ -25,13 +26,40 @@ export class SqmIncompatibleCliError extends Error {
25
26
  }
26
27
 
27
28
  export async function loadKnowledgeBundle({
28
- repository,
29
- cliVersion,
30
29
  endpoint = process.env.SIMY_SQM_BUNDLE_URL,
31
30
  token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN,
32
31
  accountId = process.env.SIMY_SQM_ACCOUNT_ID || (token ? `token:${createHash("sha256").update(token).digest("hex")}` : "anonymous"),
33
32
  organizationId = process.env.SIMY_SQM_ORGANIZATION_ID,
34
33
  cacheRoot = process.env.SIMY_SQM_CACHE_DIR || path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm"),
34
+ ...options
35
+ }) {
36
+ const context = { ...options, endpoint, token, accountId, organizationId, cacheRoot };
37
+ const observe = async result => {
38
+ if (options.offline || !endpoint || !accountId || !organizationId) return;
39
+ try { await recordSqmConnectionObservation(context, result, { root: cacheRoot, now: options.now }); }
40
+ catch { /* Display metadata must not change SQM behavior. */ }
41
+ };
42
+ try {
43
+ const result = await loadKnowledgeBundleImpl(context);
44
+ // An online fallback retains usable knowledge, but does not confirm the
45
+ // current connection. Offline/cache-only reads never refresh this record.
46
+ await observe(result.source === 'stale_cache' ? { source: 'unavailable' } : result);
47
+ return result;
48
+ } catch (error) {
49
+ // Cancellation is an intentional stop, not a failed connection attempt.
50
+ if (!options.signal?.aborted) await observe({ source: 'unavailable' });
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ async function loadKnowledgeBundleImpl({
56
+ repository,
57
+ cliVersion,
58
+ endpoint,
59
+ token,
60
+ accountId,
61
+ organizationId,
62
+ cacheRoot,
35
63
  ttlMs = numberEnv("SIMY_SQM_CACHE_TTL_MS", DEFAULT_TTL_MS),
36
64
  offline = false,
37
65
  refresh = false,