@awak-app/simy-cli 0.5.71 → 0.5.72

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
@@ -30,9 +30,20 @@ current analysis finishes and submits its result. The toggle does not grant SQM
30
30
  subscription or organization permissions. First-time repository history discovery
31
31
  still starts from the web workspace.
32
32
 
33
- **SQM Tasks** shows local SQM reviews from the current agent session, separately
34
- from ordinary **Tasks**. Up to 50 recent reviews are shown; changing organization
35
- clears the local view. The web SQM workspace retains the authoritative history.
33
+ **Quality checks** shows local analysis and check history separately from ordinary
34
+ **Tasks**. Recent records survive restart and are scoped to the Web environment,
35
+ account, and organization. Failed checks retain safe diagnostic codes, stages,
36
+ HTTP status, and request identifiers when available. The web SQM workspace
37
+ retains the authoritative analysis history. Persistent provider login, model or
38
+ access errors pause new local claims with a visible recovery hint; refreshing
39
+ after reconnecting permits another probe, with bounded automatic cooldowns.
40
+
41
+ Detached daemon and desktop-host diagnostics are saved to
42
+ `$SIMY_HOME/logs/daemon.log` and `$SIMY_HOME/logs/desktop-host.log`
43
+ (`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
44
+ backups; credentials, Cookie headers and URL queries are redacted. Installer
45
+ handoff diagnostics remain in `~/.simy/installer/desktop-update.log`.
46
+ `simy --version` or `simy -v` prints the package version without starting work.
36
47
 
37
48
  Closing the window keeps the agent running. Open the app again or choose
38
49
  **Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
@@ -411,6 +422,9 @@ SQM deliberately has two identity-bound stages:
411
422
  The full-check summary reports applicable modules, evaluated rules, scenario
412
423
  statuses and blocking/advisory scope. `passed` with `no_applicable_rules` or zero
413
424
  scenario evaluations does not establish feature or historical incident coverage.
425
+ An empty system/repository bundle adds `no_system_knowledge` and an explicit
426
+ zero-case warning; knowledge that exists but does not match this change uses a
427
+ separate warning. Both retain the existing `passed` outcome.
414
428
  Scenario statuses come from deterministic rule evaluation, not browser/provider
415
429
  execution. Keep concrete regression and delivery acceptance evidence separately.
416
430
  If knowledge is unavailable, the command returns exit 2 with the service cause
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.71",
3
+ "version": "0.5.72",
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
@@ -246,6 +246,7 @@ export async function startAgent({
246
246
  );
247
247
  let availableCapabilities;
248
248
  let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
249
+ const providerRecoveryRevisions = { codex: 0, claude: 0 };
249
250
  const refreshAvailableCapabilities = () => {
250
251
  const features = contractSourceCapabilities.features;
251
252
  availableCapabilities = withCliContract({
@@ -423,6 +424,7 @@ export async function startAgent({
423
424
  providerStatuses = { ...providerStatuses, [provider]: next };
424
425
  refreshAvailableCapabilities();
425
426
  if (previous[provider]?.version !== next.version || previous[provider]?.state !== next.state) {
427
+ providerRecoveryRevisions[provider] += 1;
426
428
  notifyDesktopStatusChange();
427
429
  }
428
430
  }));
@@ -883,6 +885,7 @@ export async function startAgent({
883
885
  return slug ? new URL(`/org/${encodeURIComponent(slug)}/quality-monitor/`, apiOrigin).toString() : null;
884
886
  },
885
887
  getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
888
+ getRecoveryKey: (provider) => providerRecoveryRevisions[provider] ?? 0,
886
889
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
887
890
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
888
891
  getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
@@ -2330,6 +2333,7 @@ export async function startAgent({
2330
2333
  sqm: { enabled: sqmEnabled, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
2331
2334
  activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
2332
2335
  issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
2336
+ providerPause: sqmLocalReviewWorker?.snapshot?.().provider_pause ?? null,
2333
2337
  providerReady: [codex, claude].some(provider => provider?.state === "ready") },
2334
2338
  ...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
2335
2339
  update: updateManager?.snapshot?.() ?? null,
@@ -2400,7 +2404,16 @@ export async function startAgent({
2400
2404
  return getDesktopStatus();
2401
2405
  },
2402
2406
  refreshCodexStatus: () => codexStatus({ force: true }),
2403
- refreshProviderStatus: () => readProviderStatuses({ force: true }),
2407
+ refreshProviderStatus: async () => {
2408
+ const statuses = await readProviderStatuses({ force: true });
2409
+ // Explicit reconnect/refresh permits one recovery attempt. Routine status
2410
+ // polling must not defeat the worker's persistent-failure cooldown.
2411
+ for (const provider of ["codex", "claude"]) {
2412
+ if (statuses[provider]?.state === "ready") providerRecoveryRevisions[provider] += 1;
2413
+ }
2414
+ notifyDesktopStatusChange();
2415
+ return statuses;
2416
+ },
2404
2417
  workspace,
2405
2418
  repositoryScanRoot,
2406
2419
  get capabilities() {
@@ -1,5 +1,6 @@
1
1
  import spawn from "cross-spawn";
2
2
  import os from "node:os";
3
+ import { readdirSync, statSync } from "node:fs";
3
4
  import path from "node:path";
4
5
 
5
6
  const MACOS_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
@@ -85,7 +86,7 @@ export function backendCandidates(backend, {
85
86
  "claude",
86
87
  // Desktop launch environments may predate the installer's PATH update.
87
88
  paths.join(userHome, ".local", "bin", platform === "win32" ? "claude.exe" : "claude"),
88
- ...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude"] : []),
89
+ ...(platform === "darwin" ? ["/opt/homebrew/bin/claude", "/usr/local/bin/claude", ...claudeDesktopCandidates(userHome)] : []),
89
90
  ];
90
91
  }
91
92
  return [
@@ -95,6 +96,28 @@ export function backendCandidates(backend, {
95
96
  ];
96
97
  }
97
98
 
99
+ // Desktop installs use either <version>/claude.app or <version>/<hash>/claude.app.
100
+ // Only inspect these two known levels; never recurse through arbitrary home paths
101
+ // or descend through version/hash directory symlinks. Existing explicit/PATH installs retain priority.
102
+ function claudeDesktopCandidates(home) {
103
+ const root = path.join(home, "Library", "Application Support", "Claude", "claude-code");
104
+ const directories = directory => {
105
+ try { return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name); }
106
+ catch { return []; }
107
+ };
108
+ return directories(root).filter(name => /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(name))
109
+ .sort((left, right) => compareVersions(right, left)).slice(0, 32)
110
+ .flatMap(version => {
111
+ const versionRoot = path.join(root, version);
112
+ const bundle = directory => path.join(directory, "claude.app", "Contents", "MacOS", "claude");
113
+ return [bundle(versionRoot), ...directories(versionRoot)
114
+ .filter(name => /^[a-zA-Z0-9_-]{8,128}$/.test(name)).sort().slice(0, 8)
115
+ .map(hash => bundle(path.join(versionRoot, hash)))];
116
+ }).filter(candidate => {
117
+ try { return statSync(candidate).isFile(); } catch { return false; }
118
+ });
119
+ }
120
+
98
121
  export function parseBackendVersion(output) {
99
122
  const match = String(output || "").match(/(?:^|\s|v)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$|\))/);
100
123
  return match?.[1] ?? null;
@@ -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.5.71";
16
+ export const CLI_VERSION = "0.5.72";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -743,9 +743,13 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
743
743
  return decision;
744
744
  }
745
745
  export function managedBudgetReason(run, now = Date.now()) {
746
- if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) return 'Original delivery time, continuation, or failure budget exhausted.';
746
+ const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
747
+ if (now >= run.deadline) return `Delivery time limit exhausted.${checkpointDetail}`;
748
+ if (run.continuations >= run.maxContinuations) return `Delivery continuation limit exhausted (${run.continuations}/${run.maxContinuations}).${checkpointDetail}`;
749
+ if (run.failures >= run.maxFailures) return `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
747
750
  const managed = run.managed;
748
- if (managed?.usageBlocked || managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return 'Provider token budget exhausted or reliable usage unavailable.';
751
+ if (managed?.usageBlocked) return `Reliable provider token usage is unavailable; budgeted continuation is blocked.${checkpointDetail}`;
752
+ if (managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return `Provider token budget exhausted (${managed.tokensUsed}/${managed.maxTokens}).${checkpointDetail}`;
749
753
  if (managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) return 'No verified acceptance progress within the configured time limit.';
750
754
  if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
751
755
  return null;
@@ -193,8 +193,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
193
193
  const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
194
194
  live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
195
195
  if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
196
- if (managedBudgetReason(live, currentTime()) || currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
197
- live.state = 'budget_exhausted'; live.reason = 'Original managed continuation budget exhausted.'; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
196
+ const budgetReason = managedBudgetReason(live, currentTime());
197
+ if (budgetReason) {
198
+ live.state = 'budget_exhausted'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
198
199
  }
199
200
  live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
200
201
  owner: { pid: process.pid, identity: ownerIdentity } };
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
20
20
  import { startAgent } from "./agent.js";
21
+ import { createRuntimeDiagnostics } from "./runtime-diagnostics.js";
21
22
  import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
22
23
  import { createAgentShutdown } from "./shutdown.js";
23
24
  import { resolveWebOrigin } from "./web-origin.js";
@@ -74,6 +75,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
74
75
  output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
75
76
  };
76
77
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
78
+ const diagnostics = createRuntimeDiagnostics(root, { name: "desktop-host" });
77
79
  const webOrigin = resolveWebOrigin(null, env);
78
80
  const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
79
81
  if (candidate && !await validateDesktopSupervisor(root, env.SIMY_DESKTOP_SUPERVISOR_TOKEN)) {
@@ -189,6 +191,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
189
191
  } catch { pluginSetup = { state: "error" }; }
190
192
  };
191
193
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
194
+ diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
192
195
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
193
196
  root: join(root, 'session-analysis'), analyze: async (...args) => {
194
197
  const scope = await authorizeWork();
@@ -291,7 +294,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
291
294
  updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
292
295
  daemon: true, interactive: true, webOrigin,
293
296
  disabled: env.SIMY_AUTO_UPDATE === "0",
294
- dependencies: { packageInfo, logger() {} },
297
+ dependencies: { packageInfo, logger: message => diagnostics.write(`update: ${message}`) },
295
298
  });
296
299
  agent = await (dependencies.startAgent || startAgent)({
297
300
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
@@ -317,12 +320,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
317
320
  let child;
318
321
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
319
322
  [join(dirname(installation.entryPath), "desktop-host.js")], {
320
- stdio: ["pipe", "pipe", "ignore"],
323
+ stdio: ["pipe", "pipe", "pipe"],
321
324
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
322
325
  }); } catch (error) {
323
326
  actionAssociations = makeActionAssociations();
324
327
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
325
328
  }
329
+ const stderr = diagnostics.stream("updated-runtime stderr");
330
+ child.stderr?.on("data", stderr.write);
331
+ child.stderr?.on("end", stderr.flush);
332
+ child.once("error", error => diagnostics.write(`updated-runtime error: ${error.code || "unknown"} ${error.message}`));
333
+ child.once("exit", (code, signal) => { stderr.flush(); diagnostics.write(`updated-runtime exit: code=${code} signal=${signal || "none"}`); });
326
334
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
327
335
  let accepted = false;
328
336
  child.stdin.on("error", () => {
@@ -668,6 +676,7 @@ if (isDesktopHostEntry()) {
668
676
  const stop = () => { void host?.close().finally(() => process.exit(0)); };
669
677
  process.once("SIGTERM", stop); process.once("SIGINT", stop);
670
678
  startDesktopHost().then((value) => { host = value; }).catch((error) => {
679
+ createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" }).write(`startup failed: ${error?.stack || error}`);
671
680
  const conflict = error?.code === PROCESS_CONFLICT_CODE;
672
681
  process.stdout.write(`${JSON.stringify({
673
682
  type: "error",
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
9
9
  import { startAgent } from "./agent.js";
10
+ import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
10
11
  import {
11
12
  autoUpdateHelpText,
12
13
  createCliAutoUpdater,
@@ -34,8 +35,16 @@ const entryPath = fileURLToPath(import.meta.url);
34
35
  const packageInfo = await readCliPackageInfo();
35
36
  const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
36
37
  let configuredWebOrigin;
38
+ const versionOnly = dispatchArgv.length === 1 && ["--version", "-v"].includes(dispatchArgv[0]);
39
+ if (versionOnly) {
40
+ console.log(packageInfo.version);
41
+ } else if (process.env.SIMY_DAEMON_CHILD === "1" && (args.has("--daemon") || args.has("--deamon"))) {
42
+ const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"));
43
+ captureRuntimeOutput(diagnostics);
44
+ diagnostics.write(`daemon starting version=${packageInfo.version} pid=${process.pid}`);
45
+ }
37
46
  try {
38
- configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
47
+ if (!versionOnly) configuredWebOrigin = resolveWebOrigin(readOption(argv, "--host"));
39
48
  } catch (error) {
40
49
  console.error(error instanceof Error ? error.message : String(error));
41
50
  process.exitCode = 2;
@@ -121,6 +130,7 @@ async function runCommand(installMode) {
121
130
  simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
122
131
 
123
132
  Options:
133
+ --version, -v Print the installed CLI version and exit
124
134
  --daemon, --deamon Run detached in the background
125
135
  --no-tui Disable the interactive Agentic Loop console
126
136
  --no-open Do not open the browser when authorization is required
@@ -26,7 +26,7 @@ export async function collectPrEvidence({
26
26
  const baseRefresh = await command(
27
27
  runCommand,
28
28
  "git",
29
- ["fetch", "--no-tags", "origin", charter.base_branch],
29
+ ["fetch", "--no-tags", "origin", charter.base_branch.replace(/^(?:refs\/remotes\/)?origin\//, "")],
30
30
  repositoryPath,
31
31
  );
32
32
  const local = await collectLocalGitEvidence({
@@ -42,7 +42,8 @@ export async function collectPrEvidence({
42
42
  const latest = await collectLocalGitEvidence({ baseBranch: charter.base_branch, repositoryPath, runCommand });
43
43
  latest.base_ref_refreshed = baseRefresh.ok;
44
44
  latest.base_ref_error = baseRefresh.ok ? null : baseRefresh.error;
45
- if (!latest.available || latest.head_sha !== local.head_sha || !latest.working_tree_clean) {
45
+ if (!latest.available || latest.head_sha !== local.head_sha || latest.base_sha !== local.base_sha ||
46
+ latest.base_ref !== local.base_ref || !latest.working_tree_clean) {
46
47
  minimumCompletion.status = "evidence_incomplete";
47
48
  minimumCompletion.errors = ["Checkout changed while collecting minimum evidence; verify again."];
48
49
  }
@@ -90,16 +91,17 @@ function observedEvidenceRef(ref, local, github) {
90
91
  }
91
92
 
92
93
  async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand }) {
93
- const baseRef = `origin/${baseBranch}`;
94
- const [head, branch, headline, status, changed, base, mergeBase, baseAncestor] = await Promise.all([
94
+ const base = await resolveBaseRef(runCommand, baseBranch, repositoryPath);
95
+ // Pin all comparisons to the same commit, even if another process moves the ref.
96
+ const baseCommit = base.ok ? base.stdout : null;
97
+ const [head, branch, headline, status, changed, mergeBase, baseAncestor] = await Promise.all([
95
98
  command(runCommand, "git", ["rev-parse", "HEAD"], repositoryPath),
96
99
  command(runCommand, "git", ["branch", "--show-current"], repositoryPath),
97
100
  command(runCommand, "git", ["log", "-1", "--pretty=%s"], repositoryPath),
98
101
  command(runCommand, "git", ["status", "--porcelain"], repositoryPath),
99
- changedFiles(runCommand, baseBranch, repositoryPath),
100
- command(runCommand, "git", ["rev-parse", baseRef], repositoryPath),
101
- command(runCommand, "git", ["merge-base", baseRef, "HEAD"], repositoryPath),
102
- command(runCommand, "git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], repositoryPath),
102
+ base.ok ? command(runCommand, "git", ["diff", "--name-only", "--diff-filter=ACMR", `${baseCommit}...HEAD`], repositoryPath) : base,
103
+ base.ok ? command(runCommand, "git", ["merge-base", baseCommit, "HEAD"], repositoryPath) : base,
104
+ base.ok ? command(runCommand, "git", ["merge-base", "--is-ancestor", baseCommit, "HEAD"], repositoryPath) : base,
103
105
  ]);
104
106
  const statusLines = lines(status.stdout);
105
107
  return {
@@ -110,7 +112,8 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
110
112
  working_tree_clean: status.ok && statusLines.length === 0,
111
113
  status_lines: statusLines,
112
114
  changed_files: lines(changed.stdout),
113
- base_sha: cleanString(base.stdout) || null,
115
+ base_ref: base.ref || null,
116
+ base_sha: baseCommit,
114
117
  merge_base_sha: cleanString(mergeBase.stdout) || null,
115
118
  base_is_ancestor: baseAncestor.ok,
116
119
  errors: [head, branch, headline, status, changed, base, mergeBase]
@@ -119,18 +122,16 @@ async function collectLocalGitEvidence({ baseBranch, repositoryPath, runCommand
119
122
  };
120
123
  }
121
124
 
122
- async function changedFiles(runCommand, baseBranch, repositoryPath) {
123
- const refs = [`origin/${baseBranch}`, baseBranch];
125
+ async function resolveBaseRef(runCommand, baseBranch, repositoryPath) {
126
+ const refs = baseBranch.startsWith("refs/") ? [baseBranch] :
127
+ baseBranch.startsWith("origin/") ? [`refs/remotes/${baseBranch}`] :
128
+ [`refs/remotes/origin/${baseBranch}`, `refs/heads/${baseBranch}`];
124
129
  for (const ref of refs) {
125
- const result = await command(
126
- runCommand,
127
- "git",
128
- ["diff", "--name-only", "--diff-filter=ACMR", `${ref}...HEAD`],
129
- repositoryPath,
130
- );
131
- if (result.ok) return result;
130
+ const result = await command(runCommand, "git",
131
+ ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], repositoryPath);
132
+ if (result.ok) return { ...result, ref };
132
133
  }
133
- return { ok: false, stdout: "", error: `Could not diff against ${baseBranch}.` };
134
+ return { ok: false, stdout: "", error: `Could not resolve base branch ${baseBranch}.` };
134
135
  }
135
136
 
136
137
  async function collectGitHubEvidence({ charter, attempt, repositoryPath, runCommand }) {
@@ -183,7 +183,7 @@ async function runCodingLoopOwned({
183
183
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
184
184
  attempt.implementation_gate = attempt.audit.implementation_gate;
185
185
  snapshot.attempts.push(attempt);
186
- assessOutcomeProgress(snapshot.charter, attempt, previousAttempt);
186
+ assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
187
187
  attempt.budget = refreshBudgetState(snapshot);
188
188
 
189
189
  if (snapshot.budget.token_exhausted) {
@@ -265,7 +265,7 @@ async function runCodingLoopOwned({
265
265
  });
266
266
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
267
267
  attempt.implementation_gate = attempt.audit.implementation_gate;
268
- assessOutcomeProgress(snapshot.charter, attempt, previousAttempt);
268
+ assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { resetUnavailableCount: resume && attemptNumber === firstAttempt });
269
269
 
270
270
  const independentFindings = attempt.independent_audit.findings || [];
271
271
  if (!attempt.audit.passed || !attempt.independent_audit.passed) {
@@ -555,6 +555,34 @@ async function handleFailedAudit({
555
555
  });
556
556
  return true;
557
557
  }
558
+ if (attempt.outcome_progress?.assessment_unavailable) {
559
+ const progress = attempt.outcome_progress;
560
+ snapshot.final_audit = {
561
+ ...(snapshot.final_audit || {}),
562
+ passed: false,
563
+ summary: "Outcome progress could not be assessed across consecutive attempts.",
564
+ findings: [...(snapshot.final_audit?.findings || []), {
565
+ passed: false,
566
+ code: "OUTCOME_ASSESSMENT_UNAVAILABLE",
567
+ severity: "blocker",
568
+ target: "outcome_monitor",
569
+ explanation: "Two consecutive attempts could not establish an independent outcome assessment. Resolve the recorded verification failures and continue this run; this does not prove the work has stalled.",
570
+ repairability: "manual",
571
+ }],
572
+ auto_repairable: false,
573
+ requires_human: true,
574
+ };
575
+ appendEvent(snapshot, "waiting_human", "Outcome assessment needs recovery before further automatic work.", {
576
+ code: "outcome_assessment_unavailable",
577
+ finding_codes: ["OUTCOME_ASSESSMENT_UNAVAILABLE"],
578
+ attempt_number: attemptNumber,
579
+ consecutive_unassessed_attempts: progress.consecutive_unassessed_attempts,
580
+ unavailable_reason: progress.unavailable_reason,
581
+ unproven_ids: progress.unproven_ids,
582
+ });
583
+ await publish(snapshot, "waiting_human", onUpdate);
584
+ return true;
585
+ }
558
586
  if (requiresHuman) {
559
587
  appendEvent(snapshot, "waiting_human", "PR audit requires human input.", {
560
588
  finding_codes: findings.map((finding) => finding.code),
@@ -17,17 +17,30 @@ function objective(charter) {
17
17
  };
18
18
  }
19
19
 
20
- function digest(value) {
21
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
20
+ const CANONICAL_DIGEST = "sha256-canonical-json-v1";
21
+
22
+ function canonicalValue(value) {
23
+ if (Array.isArray(value)) return value.map(canonicalValue);
24
+ if (value && typeof value === "object") {
25
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function digest(value, algorithm) {
31
+ return createHash("sha256").update(JSON.stringify(
32
+ algorithm === CANONICAL_DIGEST ? canonicalValue(value) : value,
33
+ )).digest("hex");
22
34
  }
23
35
 
24
36
  export function freezeObjective(charter) {
25
37
  const value = objective(charter);
26
38
  charter.thread_state.objective_contract = {
27
39
  version: 1,
40
+ digest_algorithm: CANONICAL_DIGEST,
28
41
  baseline: structuredClone(value),
29
42
  current: structuredClone(value),
30
- current_digest: digest(value),
43
+ current_digest: digest(value, CANONICAL_DIGEST),
31
44
  revisions: [],
32
45
  };
33
46
  }
@@ -36,7 +49,7 @@ export function reviseObjective(charter, source) {
36
49
  const contract = charter.thread_state.objective_contract;
37
50
  if (!contract) return;
38
51
  const next = objective(charter);
39
- const nextDigest = digest(next);
52
+ const nextDigest = digest(next, contract.digest_algorithm);
40
53
  if (nextDigest === contract.current_digest) return;
41
54
  contract.revisions.push({
42
55
  version: contract.version + 1,
@@ -55,6 +68,10 @@ export function verifyObjective(charter) {
55
68
  const contract = charter.thread_state?.objective_contract;
56
69
  if (!contract) return { valid: true, legacy: true };
57
70
  try {
71
+ if (contract.digest_algorithm && contract.digest_algorithm !== CANONICAL_DIGEST) {
72
+ return { valid: false, legacy: false, digest: null };
73
+ }
74
+ const contractDigest = (value) => digest(value, contract.digest_algorithm);
58
75
  const current = objective(charter);
59
76
  const revisions = contract.revisions;
60
77
  const valid = Boolean(
@@ -62,15 +79,15 @@ export function verifyObjective(charter) {
62
79
  contract.baseline &&
63
80
  Array.isArray(revisions) &&
64
81
  revisions.length === contract.version - 1 &&
65
- digest(contract.current) === contract.current_digest &&
66
- digest(current) === contract.current_digest &&
82
+ contractDigest(contract.current) === contract.current_digest &&
83
+ contractDigest(current) === contract.current_digest &&
67
84
  revisions.every((revision, index) =>
68
85
  revision.version === index + 2 &&
69
- revision.previous_digest === digest(revision.previous) &&
70
- revision.next_digest === digest(revision.next) &&
71
- (index === 0 ? revision.previous_digest === digest(contract.baseline) :
86
+ revision.previous_digest === contractDigest(revision.previous) &&
87
+ revision.next_digest === contractDigest(revision.next) &&
88
+ (index === 0 ? revision.previous_digest === contractDigest(contract.baseline) :
72
89
  revision.previous_digest === revisions[index - 1].next_digest)) &&
73
- (revisions.length === 0 ? digest(contract.baseline) === contract.current_digest :
90
+ (revisions.length === 0 ? contractDigest(contract.baseline) === contract.current_digest :
74
91
  revisions.at(-1).next_digest === contract.current_digest)
75
92
  );
76
93
  return { valid, legacy: false, digest: contract.current_digest };
@@ -81,7 +98,7 @@ export function verifyObjective(charter) {
81
98
 
82
99
  // The monitor reads independent criterion assessments, not executor test counts.
83
100
  // It records insufficient evidence when the independent review was not reached.
84
- export function assessOutcomeProgress(charter, attempt, previousAttempt = null) {
101
+ export function assessOutcomeProgress(charter, attempt, previousAttempt = null, { resetUnavailableCount = false } = {}) {
85
102
  const criteria = acceptanceCriterionRows(charter);
86
103
  const assessment = attempt.independent_audit;
87
104
  const results = Array.isArray(assessment?.criterion_results)
@@ -111,6 +128,20 @@ export function assessOutcomeProgress(charter, attempt, previousAttempt = null)
111
128
  stalled: false,
112
129
  };
113
130
  const previous = previousAttempt?.outcome_progress;
131
+ const sameObjective = previous?.objective_digest === result.objective_digest;
132
+ const previousUnavailableCount = !resetUnavailableCount && sameObjective && previous?.status === "insufficient_evidence"
133
+ ? previous.consecutive_unassessed_attempts || 1 : 0;
134
+ result.consecutive_unassessed_attempts = completeAssessment ? 0 : previousUnavailableCount + 1;
135
+ result.unavailable_reason = completeAssessment ? null : attempt.audit?.passed !== true
136
+ ? "local_verification_incomplete" : "independent_assessment_incomplete";
137
+ const previousMissingAssessments = !resetUnavailableCount && sameObjective &&
138
+ previous?.unavailable_reason === "independent_assessment_incomplete"
139
+ ? previous.consecutive_unavailable_assessments || 1 : 0;
140
+ result.consecutive_unavailable_assessments = result.unavailable_reason === "independent_assessment_incomplete"
141
+ ? previousMissingAssessments + 1 : 0;
142
+ // Local repair can improve without reaching independent review. Only a repeatedly
143
+ // missing independent assessment after local readiness opens this recovery gate.
144
+ result.assessment_unavailable = result.consecutive_unavailable_assessments >= 2;
114
145
  result.stalled = Boolean(
115
146
  completeAssessment && previous?.status === "assessed" &&
116
147
  previous.objective_digest === result.objective_digest &&
@@ -114,6 +114,7 @@ function recoveryReasonCode(state, message, detail) {
114
114
  if (code === "retry_circuit_open" || detail.reason === "identical_commit_and_findings") {
115
115
  return "retry_strategy_unchanged";
116
116
  }
117
+ if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
117
118
  if (code === "token_budget_exhausted") return "token_budget_exhausted";
118
119
  if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
119
120
  const findings = strings(detail.finding_codes);
@@ -148,6 +149,8 @@ function recoveryMessage(reasonCode) {
148
149
  requirements_incomplete:
149
150
  "SIMY needs the missing requirement or approval before implementation can start.",
150
151
  human_input_required: "SIMY needs your decision or clarification before it can continue.",
152
+ outcome_assessment_unavailable:
153
+ "SIMY could not assess outcome progress across consecutive attempts. Repair the recorded verification failure and continue this run.",
151
154
  token_budget_exhausted:
152
155
  "This run reached its Provider token safety limit. Provider tokens are not billed by SIMY. Increase the limit to continue this run.",
153
156
  retry_budget_exhausted:
package/src/runner.js CHANGED
@@ -354,6 +354,25 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
354
354
  const previousBudget = currentBudget.token_budget;
355
355
  const budget = increaseProviderTokenBudget(run.snapshot, value);
356
356
  run.request.token_budget = budget.token_budget;
357
+ // A budget change resolves only this blocker; retain the previous audit as evidence.
358
+ const previousAudit = run.snapshot.final_audit;
359
+ const resolvedBudgetAudit = previousAudit?.findings?.some(
360
+ (finding) => finding.code === "TOKEN_BUDGET_EXHAUSTED",
361
+ ) ? structuredClone(previousAudit) : null;
362
+ if (resolvedBudgetAudit) {
363
+ const findings = previousAudit.findings.filter((finding) => finding.code !== "TOKEN_BUDGET_EXHAUSTED");
364
+ run.snapshot.final_audit = {
365
+ ...previousAudit,
366
+ passed: false,
367
+ summary: findings.length > 0
368
+ ? "Provider token budget increased; other verification findings remain."
369
+ : "Provider token budget increased; continue this run to verify completion.",
370
+ findings,
371
+ budget,
372
+ auto_repairable: findings.length > 0 && findings.every((finding) => finding.repairability === "auto"),
373
+ requires_human: findings.some((finding) => finding.repairability !== "auto"),
374
+ };
375
+ }
357
376
  appendEvent(
358
377
  run.snapshot,
359
378
  "waiting_human",
@@ -361,6 +380,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
361
380
  {
362
381
  code: "provider_token_budget_increased",
363
382
  previous_provider_token_budget: previousBudget,
383
+ ...(resolvedBudgetAudit ? { previous_final_audit: resolvedBudgetAudit } : {}),
364
384
  provider_token_budget: budget.token_budget,
365
385
  budget,
366
386
  },
@@ -0,0 +1,77 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { redactExecutionText } from './orchestrator/execution-io.js';
5
+
6
+ // Local diagnostics are deliberately bounded and private. Use the same credential
7
+ // policy as execution history, with URL queries removed (login nonces live there).
8
+ export function createRuntimeDiagnostics(root, { name = 'daemon', maxBytes = 1024 * 1024, backups = 2 } = {}) {
9
+ const directory = join(root, 'logs');
10
+ const file = join(directory, `${name}.log`);
11
+ const write = value => {
12
+ try {
13
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
14
+ const text = redactExecutionText(String(value)
15
+ .replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1[REDACTED]')
16
+ .replace(/(https?:\/\/[^\s?#]+)[?#][^\s]*/g, '$1?[REDACTED]'), { maxChars: 8192 }).text;
17
+ // Do not retain fragments of PEM keys, whose boundaries may span writes.
18
+ const safe = /PRIVATE KEY|^[A-Za-z0-9+/=]{64,}$/.test(text.trim()) ? '[REDACTED KEY MATERIAL]' : text;
19
+ const record = Buffer.from(`${new Date().toISOString()} ${safe.replace(/[\r\n]+/g, ' ')}\n`);
20
+ const bounded = record.subarray(0, maxBytes);
21
+ let size = 0;
22
+ try { size = statSync(file).size; } catch {}
23
+ if (size + bounded.length > maxBytes) {
24
+ rmSync(`${file}.${backups}`, { force: true });
25
+ for (let index = backups - 1; index >= 1; index--) {
26
+ try { renameSync(`${file}.${index}`, `${file}.${index + 1}`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
27
+ }
28
+ try { renameSync(file, `${file}.1`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
29
+ }
30
+ appendFileSync(file, bounded, { mode: 0o600 });
31
+ chmodSync(file, 0o600);
32
+ } catch {
33
+ // Diagnostics must never prevent startup/recovery or recurse via stderr.
34
+ }
35
+ };
36
+ const stream = label => {
37
+ const decoder = new StringDecoder('utf8');
38
+ let pending = '', dropping = false, privateKey = false;
39
+ const writeChunk = chunk => {
40
+ const text = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
41
+ for (const part of text.split(/(?<=\n)/)) {
42
+ const end = part.endsWith('\n');
43
+ if (!dropping) {
44
+ pending += part;
45
+ if (pending.length > 8192) {
46
+ // Drop the whole oversized line, rather than persisting an unredacted
47
+ // partial credential split across chunks or the truncation boundary.
48
+ write(`${label}: [oversized diagnostic line omitted]`);
49
+ pending = ''; dropping = !end;
50
+ } else if (end) {
51
+ if (/-----BEGIN .*PRIVATE KEY-----/.test(pending)) privateKey = true;
52
+ write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending.trimEnd()}`);
53
+ if (/-----END .*PRIVATE KEY-----/.test(pending)) privateKey = false;
54
+ pending = '';
55
+ }
56
+ } else if (end) dropping = false;
57
+ }
58
+ };
59
+ return { write: writeChunk, flush() { if (pending && !dropping) write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending}`); pending = ''; dropping = false; } };
60
+ };
61
+ return { file, write, stream };
62
+ }
63
+
64
+ export function captureRuntimeOutput(diagnostics, streams = { stdout: process.stdout, stderr: process.stderr }) {
65
+ const restores = [];
66
+ for (const [label, target] of Object.entries(streams)) {
67
+ const sink = diagnostics.stream(label);
68
+ const original = target.write;
69
+ target.write = function (chunk, ...args) { sink.write(chunk); return original.call(this, chunk, ...args); };
70
+ restores.push(() => { target.write = original; sink.flush(); });
71
+ }
72
+ const fatal = error => diagnostics.write(`uncaught: ${error?.stack || error}`);
73
+ process.on('uncaughtExceptionMonitor', fatal);
74
+ const restore = () => { for (const callback of restores) callback(); process.removeListener('uncaughtExceptionMonitor', fatal); process.removeListener('exit', restore); };
75
+ process.once('exit', restore);
76
+ return restore;
77
+ }
@@ -1,3 +1,4 @@
1
+ import { safeSqmDiagnostic } from "./diagnostics.js";
1
2
  import { createHash, createPublicKey } from "node:crypto";
2
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -83,7 +84,7 @@ export async function loadKnowledgeBundle({
83
84
  } catch (error) {
84
85
  if (error?.code === "incompatible_cli") throw error;
85
86
  if (error?.fallbackAllowed !== false && cached && !cached.bundle.system_scope && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
86
- return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable");
87
+ return { ...result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable"), diagnostic: safeSqmDiagnostic(error, "knowledge_bundle", "knowledge_bundle_unavailable") };
87
88
  }
88
89
  }
89
90
  if (cached) {
@@ -200,6 +201,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
200
201
  );
201
202
  }
202
203
  const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
204
+ Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
205
+ requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
203
206
  error.fallbackAllowed = response.status >= 500 && response.status <= 599;
204
207
  throw error;
205
208
  }
@@ -1,3 +1,4 @@
1
+ import { safeSqmDiagnostic } from "./diagnostics.js";
1
2
  import { createSqmCheckRecorder } from "./history-store.js";
2
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -49,6 +50,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
49
50
 
50
51
  const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
51
52
  phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
53
+ let stage = "min_check";
52
54
  try {
53
55
  if (argv[0] === "min-check") {
54
56
  const result = await runSqmMinCheck(common);
@@ -60,6 +62,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
60
62
  return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
61
63
  }
62
64
 
65
+ stage = "min_result_read";
63
66
  const minPath = option(argv, "--min-result") || defaultMinResultPath();
64
67
  let minCheck;
65
68
  try {
@@ -71,11 +74,13 @@ export async function runSqmCommand(argv, { cliVersion }) {
71
74
  await atomicJson(minPath, minCheck);
72
75
  }
73
76
  if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
77
+ stage = "full_check";
74
78
  const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
75
79
  if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
76
80
  const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
77
81
  const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
78
82
  const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
83
+ stage = "artifact_write";
79
84
  await Promise.all([
80
85
  atomicJson(proofPath, result.proof),
81
86
  atomicJson(evidencePath, result.signed_evidence),
@@ -87,6 +92,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
87
92
  let upload = { uploaded: false, reason: "disabled" };
88
93
  if (!argv.includes("--no-upload")) {
89
94
  const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
95
+ stage = "evidence_upload";
90
96
  upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
91
97
  }
92
98
  result.upload = upload;
@@ -101,7 +107,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
101
107
  else printFull(result);
102
108
  return result.proof.outcome.status === "failed" ? 1 : 0;
103
109
  } catch (error) {
104
- await save({ status: "failed" });
110
+ await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, stage) });
105
111
  throw error;
106
112
  }
107
113
  }
@@ -0,0 +1,23 @@
1
+ // Persist machine diagnostics only. Provider/server response text can contain
2
+ // repository content or credentials and must never enter durable check history.
3
+ export function safeSqmDiagnostic(error, stage = "check", code = "sqm_check_failed") {
4
+ const value = error?.diagnostic || (error?.error && typeof error.error === "object" ? error.error : error) || {};
5
+ const machine = (candidate, fallback) => typeof candidate === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,79}$/.test(candidate) ? candidate : fallback;
6
+ const status = [value.status, value.errorStatus, value.httpStatus, value.error_status].find(candidate => Number.isInteger(candidate) && candidate >= 400 && candidate <= 599);
7
+ const requestId = value.requestId ?? value.request_id;
8
+ return {
9
+ stage: machine(value.stage ?? value.errorStage ?? value.error_stage, stage),
10
+ code: machine(value.code ?? value.errorCode ?? value.error_code, code),
11
+ ...(Number.isInteger(status) && status >= 400 && status <= 599 ? { status } : {}),
12
+ ...(typeof requestId === "string" && /^(?:[a-f0-9]{16,64}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(requestId) ? { requestId } : {}),
13
+ };
14
+ }
15
+
16
+ export function formatSqmDiagnostic(value) {
17
+ const hint = value.status === 401 ? "Reconnect your SIMY session."
18
+ : value.status === 403 ? "Check access to SQM in the selected organization."
19
+ : value.code === "repository_scope_unresolved" ? "Connect this repository to a system in SIMY."
20
+ : value.code === "knowledge_bundle_changed" || value.code === "repository_identity_changed" ? "Run min-check again for the current repository and knowledge version."
21
+ : "Inspect the command diagnostics for this stage before retrying.";
22
+ return `${value.stage} · ${value.code}${value.status ? ` · HTTP ${value.status}` : ""}${value.requestId ? ` · Request ${value.requestId}` : ""}\n${hint}`;
23
+ }
@@ -1,3 +1,4 @@
1
+ import { safeSqmDiagnostic } from "./diagnostics.js";
1
2
  export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
2
3
  if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
3
4
  const response = await fetchImpl(endpoint, {
@@ -7,6 +8,6 @@ export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence
7
8
  signal: AbortSignal.timeout(10_000),
8
9
  });
9
10
  const payload = await response.json().catch(() => null);
10
- if (!response.ok) throw new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
11
+ if (!response.ok) throw Object.assign(new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`), safeSqmDiagnostic({ status: response.status, code: payload?.error?.code, requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
11
12
  return { uploaded: true, ...payload };
12
13
  }
@@ -1,7 +1,8 @@
1
+ import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { basename, join } from "node:path";
4
- import { formatSqmCoverage } from "./report.js";
5
+ import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
5
6
  import { defaultSessionRoot } from "../session-store.js";
6
7
 
7
8
  const LIMIT = 100;
@@ -69,9 +70,10 @@ function metadata(entry) {
69
70
  if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
70
71
  if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
71
72
  const item = {};
72
- for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "webUrl"]) {
73
+ for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl"]) {
73
74
  if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
74
75
  }
76
+ if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
75
77
  return item;
76
78
  }
77
79
 
@@ -94,15 +96,25 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, on
94
96
  const repository = identity?.repository || result.repository;
95
97
  const outcome = proof?.outcome.status || result.min_check?.status || result.status;
96
98
  const state = outcome === "unavailable" ? "unavailable" : ["failed", "preliminary_failed"].includes(outcome) ? "failed" : ["requires_human", "requires_post_deploy"].includes(outcome) ? "verification_pending" : "completed";
99
+ const failure = result.upload?.diagnostic ? safeSqmDiagnostic(result.upload.diagnostic, "evidence_upload", "evidence_upload_failed") : ["failed", "unavailable"].includes(state) && (result.diagnostic || result.error || result.errorCode || !identity)
100
+ ? safeSqmDiagnostic(result, phase, state === "unavailable" ? "sqm_check_unavailable" : "sqm_check_failed") : null;
101
+ const minCheck = result.min_check || result;
102
+ const minWarnings = !proof && Array.isArray(minCheck.matched_modules)
103
+ ? sqmCoverageWarnings({ moduleCount: minCheck.matched_modules.length,
104
+ scenarioCount: Array.isArray(minCheck.full_check_plan) ? minCheck.full_check_plan.length :
105
+ minCheck.matched_modules.reduce((total, module) => total + (module.scenario_ids?.length || 0), 0),
106
+ noSystemKnowledge: minCheck.matched_modules.length === 0 && minCheck.coverage_reasons?.includes("no_system_knowledge") === true,
107
+ outcomeStatus: minCheck.status }) : [];
97
108
  const summary = proof ? [`Full check: ${proof.outcome.status}`, formatSqmCoverage(proof), `Evidence upload: ${result.upload?.uploaded ? "uploaded" : "kept locally"}`].join("\n")
98
- : state === "unavailable" ? "SQM could not obtain a verified result. Retry the command to see current diagnostics."
99
- : state === "failed" && !identity ? "The command did not finish. Retry it to see current diagnostics."
109
+ : state === "unavailable" ? "SQM could not obtain a verified result. See the recorded diagnostics below."
110
+ : state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
100
111
  : `Min-check: ${result.min_check?.status || result.status} · ${(result.preliminary_findings || result.min_check?.preliminary_findings)?.length || 0} preliminary finding(s) · ${(result.matched_modules || result.min_check?.matched_modules)?.length || 0} matched module(s).\nThis is a preflight; run the full check for signed verification.`;
101
112
  try {
102
113
  await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, finishedAt: new Date().toISOString(), repository,
103
114
  label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
104
115
  instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
105
- result: summary });
116
+ result: [summary, ...minWarnings].join("\n"),
117
+ ...(failure ? { error: formatSqmDiagnostic(failure), errorCode: failure.code, errorStage: failure.stage, errorStatus: failure.status, requestId: failure.requestId } : {}) });
106
118
  } catch { onError(); }
107
119
  };
108
120
  }
package/src/sqm/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { sqmCoverageWarnings } from "./report.js";
2
+ import { safeSqmDiagnostic } from "./diagnostics.js";
1
3
  import { createSqmCheckRecorder } from "./history-store.js";
2
4
  import { evaluateKnowledgeBundle } from "./checkers.js";
3
5
  import { loadKnowledgeBundle } from "./bundle-store.js";
@@ -35,7 +37,7 @@ export function createSqmChecker({
35
37
  try {
36
38
  repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
37
39
  } catch (error) {
38
- return unavailable(`SQM repository inspection failed: ${message(error)}`);
40
+ return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: safeSqmDiagnostic(error, "repository", "repository_inspection_failed") };
39
41
  }
40
42
  if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
41
43
  if (!pinnedLoad) {
@@ -43,7 +45,7 @@ export function createSqmChecker({
43
45
  pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, ...dependencies });
44
46
  }
45
47
  const loaded = await pinnedLoad;
46
- if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings };
48
+ if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
47
49
  try {
48
50
  const evaluate = executeKnowledgeBundleImpl
49
51
  ? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
@@ -56,7 +58,7 @@ export function createSqmChecker({
56
58
  try {
57
59
  full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
58
60
  } catch (error) {
59
- full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
61
+ full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}`, diagnostic: safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed") };
60
62
  full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
61
63
  }
62
64
  return full;
@@ -64,7 +66,7 @@ export function createSqmChecker({
64
66
  latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
65
67
  return toLegacyCheck(latestMinCheck, loaded);
66
68
  } catch (error) {
67
- return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
69
+ return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), diagnostic: safeSqmDiagnostic(error, phase, "sqm_execution_failed"), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
68
70
  }
69
71
  };
70
72
  return async (attempt, options = {}) => {
@@ -72,23 +74,23 @@ export function createSqmChecker({
72
74
  try {
73
75
  const result = await checkCurrentRepository(attempt, options);
74
76
  await save(result); return result;
75
- } catch (error) { await save({ status: "failed" }); throw error; }
77
+ } catch (error) { await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, options.phase || "min") }); throw error; }
76
78
  };
77
79
  }
78
80
 
79
81
  export async function runSqmMinCheck(options) {
80
82
  const context = await loadContext(options);
81
- if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
83
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
82
84
  const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
83
85
  return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
84
86
  }
85
87
 
86
88
  export async function runSqmCheck(options) {
87
89
  const context = await loadContext(options);
88
- if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
90
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
89
91
  verifyMinCheck(options.minCheck);
90
- if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw new Error("Repository identity changed after min-check; run `simy sqm min-check` again.");
91
- if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle.");
92
+ if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(new Error("Repository identity changed after min-check; run `simy sqm min-check` again."), { code: "repository_identity_changed", stage: "identity_validation" });
93
+ if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw Object.assign(new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle."), { code: "knowledge_bundle_changed", stage: "identity_validation" });
92
94
  const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
93
95
  return buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck: options.minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
94
96
  }
@@ -113,6 +115,13 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
113
115
  preliminary_findings: evaluation.findings,
114
116
  matched_modules: evaluation.modules.map((module) => ({ module_id: module.module_id, module_version: module.module_version, state_model_ids: module.state_models.map((model) => model.id), transition_ids: module.state_models.flatMap((model) => model.transitions.map((transition) => transition.id)), stressor_ids: module.stressors.map((stressor) => stressor.id), scenario_ids: module.scenarios.map((scenario) => scenario.id) })),
115
117
  full_check_plan: plan,
118
+ coverage_reasons: [
119
+ ...(evaluation.modules.length === 0 ? ["no_applicable_rules"] : []),
120
+ ...(bundle.modules?.length === 0 ? ["no_system_knowledge"] : []),
121
+ ],
122
+ warnings: sqmCoverageWarnings({ moduleCount: evaluation.modules.length, scenarioCount: plan.length,
123
+ noSystemKnowledge: bundle.modules?.length === 0,
124
+ outcomeStatus: evaluation.findings.some(finding => finding.enforcement === "blocking") ? "preliminary_failed" : "passed" }),
116
125
  });
117
126
  }
118
127
 
@@ -133,12 +142,12 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
133
142
  proof,
134
143
  signed_evidence: signedEvidence,
135
144
  signature_verification: verification,
136
- warnings: [],
145
+ warnings: sqmCoverageWarnings({ moduleCount: evaluation.modules.length, scenarioCount: evaluation.modules.reduce((count, module) => count + module.scenarios.length, 0), noSystemKnowledge: bundle.modules?.length === 0, outcomeStatus: proof.outcome.status }),
137
146
  };
138
147
  }
139
148
 
140
149
  function toLegacyCheck(minCheck, loaded) {
141
- return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: loaded.warnings, bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
150
+ return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: [...loaded.warnings, ...(minCheck.warnings || [])], bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
142
151
  }
143
152
 
144
153
  function bundleIdentity(bundle) {
package/src/sqm/proof.js CHANGED
@@ -73,6 +73,7 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
73
73
  ...(hasFailure ? ["one_or_more_blocking_checks_failed"] : []),
74
74
  ...(advisoryFindingCount > 0 ? ["advisory_findings_observed"] : []),
75
75
  ...(evaluation.modules.length === 0 ? ["no_applicable_rules"] : []),
76
+ ...(bundle.modules?.length === 0 ? ["no_system_knowledge"] : []),
76
77
  ...(requiresHuman ? ["human_evidence_required"] : []),
77
78
  ...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
78
79
  ],
package/src/sqm/report.js CHANGED
@@ -15,9 +15,16 @@ export function formatSqmCoverage(proof) {
15
15
  ];
16
16
  if (scenarios.length) lines.push(`Scenario evaluations: ${[...statusCounts].map(([status, count]) => `${status}=${count}`).join(" · ")}`);
17
17
  if (proof.outcome.reasons.length) lines.push(`Outcome reasons: ${proof.outcome.reasons.join(", ")}`);
18
- if (!modules.length) lines.push("No applicable rules: this result does not establish feature or historical incident coverage.");
19
- else if (!scenarios.length) lines.push("No scenario evaluations: rule results alone do not establish scenario coverage.");
18
+ lines.push(...sqmCoverageWarnings({ moduleCount: modules.length, scenarioCount: scenarios.length,
19
+ noSystemKnowledge: proof.outcome.reasons.includes("no_system_knowledge"), outcomeStatus: proof.outcome.status }));
20
20
  if (modules.length && !blocking) lines.push("Advisory-only scope: findings do not block this outcome.");
21
21
  lines.push("Scenario evaluations are deterministic checks, not browser/provider/deployed journey evidence. Record incident coverage and delivery acceptance separately.");
22
22
  return lines.join("\n");
23
23
  }
24
+
25
+ export function sqmCoverageWarnings({ moduleCount, scenarioCount, noSystemKnowledge, outcomeStatus = "passed" }) {
26
+ if (noSystemKnowledge) return [`Warning: the selected system/repository's downloaded SQM knowledge bundle has no published knowledge. ${outcomeStatus === "passed" ? "This is a pass with 0 evaluated cases" : "This result has 0 evaluated cases"}; it does not establish feature or historical incident coverage.`];
27
+ if (!moduleCount) return ["Warning: no applicable rules matched this change (0 evaluated cases). This result does not establish feature or historical incident coverage; it does not mean the system has no past knowledge."];
28
+ if (!scenarioCount) return ["Warning: No scenario evaluations: rule results alone do not establish scenario coverage."];
29
+ return [];
30
+ }
@@ -96,12 +96,30 @@ export function createSqmLocalReviewWorker({
96
96
  getWorkspaceUrl = () => null,
97
97
  recordHistory = async () => {},
98
98
  onHistoryError = () => {},
99
+ now = () => Date.now(),
100
+ providerFailureCooldownMs = 5 * 60_000,
101
+ getRecoveryKey = () => null,
99
102
  } = {}) {
100
103
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
101
104
  let activeJobId = null;
102
105
  let pollPromise = null;
103
106
  let closed = false;
104
107
  let issue = null;
108
+ let issueContext = null;
109
+ const failureDetails = new WeakMap();
110
+ const pauses = new Map();
111
+ const contextKey = (provider, scope) => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId, provider]);
112
+ const currentPause = () => pauses.get(contextKey(getProvider(), getHistoryScope()));
113
+ const pauseForFailure = (error, provider, scope, recoveryKey) => {
114
+ const failure = failureDetails.get(error) || reviewFailure(error, "claim", provider);
115
+ if (!["provider_sign_in_required", "provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "desktop_access_required"].includes(failure.code) && ![401, 403].includes(failure.status)) return;
116
+ const key = contextKey(provider, scope);
117
+ const failures = Math.min((pauses.get(key)?.failures || 0) + 1, 4);
118
+ const delay = Math.min(30 * 60_000, Math.max(1000, providerFailureCooldownMs) * 2 ** (failures - 1));
119
+ pauses.set(key, { code: failure.code, stage: failure.stage, status: failure.status, failures, recoveryKey,
120
+ retryAt: now() + delay, message: `${formatFailure(failure)}\nNew SQM claims are paused. After fixing this cause, refresh the local AI connection or wait for the next recovery attempt.` });
121
+ if (pauses.size > 32) pauses.delete(pauses.keys().next().value);
122
+ };
105
123
  const history = [];
106
124
  const historyScopes = new WeakMap();
107
125
  const visibleHistory = () => history.filter(item => historyScopes.get(item) === JSON.stringify(getHistoryScope()));
@@ -168,7 +186,7 @@ export function createSqmLocalReviewWorker({
168
186
  ...leaseBody(claim, provider),
169
187
  analysis_output: analysis.output,
170
188
  executor_model: analysis.model,
171
- }), submitting => { stage = submitting ? "advance" : "analysis"; });
189
+ }), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
172
190
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
173
191
  stage = "adjudication";
174
192
  const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
@@ -177,11 +195,12 @@ export function createSqmLocalReviewWorker({
177
195
  analysis_output: analysis.output,
178
196
  adjudication_output: adjudication.output,
179
197
  executor_model: adjudication.model || analysis.model,
180
- }), submitting => { stage = submitting ? "complete" : "adjudication"; });
198
+ }), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
181
199
  await updateTask(claim.job_id, "completed", historyScope);
182
200
  return completed;
183
201
  } catch (error) {
184
202
  const failure = reviewFailure(error, stage, provider);
203
+ if (error && typeof error === "object") failureDetails.set(error, failure);
185
204
  let failureReported = error.failureRecorded === true;
186
205
  if (!failureReported) {
187
206
  try {
@@ -201,7 +220,9 @@ export function createSqmLocalReviewWorker({
201
220
 
202
221
  return {
203
222
  snapshot() {
204
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue };
223
+ const pause = currentPause();
224
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
225
+ ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
205
226
  },
206
227
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
207
228
  desktopTaskDetail(key) {
@@ -214,9 +235,14 @@ export function createSqmLocalReviewWorker({
214
235
  const provider = getProvider();
215
236
  if (provider == null) return Promise.resolve(null);
216
237
  assertProvider(provider);
238
+ const historyScope = getHistoryScope();
239
+ const key = contextKey(provider, historyScope);
240
+ const recoveryKey = getRecoveryKey(provider);
241
+ const pause = pauses.get(key);
242
+ if (pause && pause.recoveryKey === recoveryKey && now() < pause.retryAt) return Promise.resolve(null);
243
+ if (pause && pause.recoveryKey !== recoveryKey) pauses.delete(key);
217
244
  const releaseWork = beginWork?.();
218
245
  if (!releaseWork) return Promise.resolve(null);
219
- const historyScope = getHistoryScope();
220
246
  const workspaceUrl = getWorkspaceUrl();
221
247
  pollPromise = (async () => {
222
248
  const claim = await api.request({
@@ -226,10 +252,18 @@ export function createSqmLocalReviewWorker({
226
252
  lease_seconds: leaseSeconds,
227
253
  });
228
254
  if (issue) { issue = null; onChange(); }
229
- if (!claim?.claimed) return null;
230
- return processClaim(claim, provider, historyScope, workspaceUrl);
255
+ if (!claim?.claimed) {
256
+ // A successful empty claim proves SIMY access recovered, but it says
257
+ // nothing about a local provider that has not executed again yet.
258
+ if (pause?.stage === "claim") pauses.delete(key);
259
+ return null;
260
+ }
261
+ const result = await processClaim(claim, provider, historyScope, workspaceUrl);
262
+ pauses.delete(key);
263
+ return result;
231
264
  })()
232
265
  .catch(async (error) => {
266
+ pauseForFailure(error, provider, historyScope, recoveryKey);
233
267
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
234
268
  const scope = historyScope;
235
269
  const failure = reviewFailure(error, "claim", provider);
@@ -239,6 +273,7 @@ export function createSqmLocalReviewWorker({
239
273
  history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
240
274
  await persist(scope, item);
241
275
  }
276
+ issueContext = key;
242
277
  issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
243
278
  onError(error);
244
279
  return null;