@awak-app/simy-cli 0.5.70 → 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.
@@ -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
+ }
@@ -14,8 +14,10 @@ const DEFAULT_LEASE_SECONDS = 300;
14
14
  // Claim can retrieve and freeze organization-scoped Slack evidence (45s budget).
15
15
  const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
16
16
  const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
17
- // Pin SQM independently of the user's interactive model and CLI defaults.
18
- export const SQM_CODEX_MODEL = "gpt-6.1-sol";
17
+ // Keep analysis independent of interactive config. Availability varies by
18
+ // account and CLI; only an explicit model rejection may try the next model.
19
+ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
20
+ export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
19
21
 
20
22
  export class SqmLocalReviewError extends Error {
21
23
  constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false, validationFeedback = "" } = {}) {
@@ -94,12 +96,30 @@ export function createSqmLocalReviewWorker({
94
96
  getWorkspaceUrl = () => null,
95
97
  recordHistory = async () => {},
96
98
  onHistoryError = () => {},
99
+ now = () => Date.now(),
100
+ providerFailureCooldownMs = 5 * 60_000,
101
+ getRecoveryKey = () => null,
97
102
  } = {}) {
98
103
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
99
104
  let activeJobId = null;
100
105
  let pollPromise = null;
101
106
  let closed = false;
102
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
+ };
103
123
  const history = [];
104
124
  const historyScopes = new WeakMap();
105
125
  const visibleHistory = () => history.filter(item => historyScopes.get(item) === JSON.stringify(getHistoryScope()));
@@ -166,7 +186,7 @@ export function createSqmLocalReviewWorker({
166
186
  ...leaseBody(claim, provider),
167
187
  analysis_output: analysis.output,
168
188
  executor_model: analysis.model,
169
- }), submitting => { stage = submitting ? "advance" : "analysis"; });
189
+ }), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
170
190
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
171
191
  stage = "adjudication";
172
192
  const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
@@ -175,11 +195,12 @@ export function createSqmLocalReviewWorker({
175
195
  analysis_output: analysis.output,
176
196
  adjudication_output: adjudication.output,
177
197
  executor_model: adjudication.model || analysis.model,
178
- }), submitting => { stage = submitting ? "complete" : "adjudication"; });
198
+ }), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); });
179
199
  await updateTask(claim.job_id, "completed", historyScope);
180
200
  return completed;
181
201
  } catch (error) {
182
202
  const failure = reviewFailure(error, stage, provider);
203
+ if (error && typeof error === "object") failureDetails.set(error, failure);
183
204
  let failureReported = error.failureRecorded === true;
184
205
  if (!failureReported) {
185
206
  try {
@@ -199,7 +220,9 @@ export function createSqmLocalReviewWorker({
199
220
 
200
221
  return {
201
222
  snapshot() {
202
- 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 } } : {}) };
203
226
  },
204
227
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
205
228
  desktopTaskDetail(key) {
@@ -212,9 +235,14 @@ export function createSqmLocalReviewWorker({
212
235
  const provider = getProvider();
213
236
  if (provider == null) return Promise.resolve(null);
214
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);
215
244
  const releaseWork = beginWork?.();
216
245
  if (!releaseWork) return Promise.resolve(null);
217
- const historyScope = getHistoryScope();
218
246
  const workspaceUrl = getWorkspaceUrl();
219
247
  pollPromise = (async () => {
220
248
  const claim = await api.request({
@@ -224,10 +252,18 @@ export function createSqmLocalReviewWorker({
224
252
  lease_seconds: leaseSeconds,
225
253
  });
226
254
  if (issue) { issue = null; onChange(); }
227
- if (!claim?.claimed) return null;
228
- 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;
229
264
  })()
230
265
  .catch(async (error) => {
266
+ pauseForFailure(error, provider, historyScope, recoveryKey);
231
267
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
232
268
  const scope = historyScope;
233
269
  const failure = reviewFailure(error, "claim", provider);
@@ -237,6 +273,7 @@ export function createSqmLocalReviewWorker({
237
273
  history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
238
274
  await persist(scope, item);
239
275
  }
276
+ issueContext = key;
240
277
  issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
241
278
  onError(error);
242
279
  return null;
@@ -293,38 +330,54 @@ export async function runStructuredProviderReview(
293
330
  "Return only JSON that strictly matches the provided output schema.",
294
331
  request.prompt,
295
332
  ].join("\n\n");
296
- await beforeDispatch();
297
- if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
298
- const stdout = await spawnReview(
299
- inspection.executable,
300
- provider === "claude" ? [
301
- "--print", "--output-format", "json",
302
- "--json-schema", JSON.stringify(request.output_schema),
303
- "--no-session-persistence", "--tools", "",
304
- "--setting-sources", "", "--settings", '{"disableAllHooks":true}',
305
- "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
306
- ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
307
- ] : [
308
- "exec",
309
- "--model",
310
- SQM_CODEX_MODEL,
311
- "--skip-git-repo-check",
312
- "--sandbox",
313
- "read-only",
314
- "--ephemeral",
315
- "--ignore-user-config",
316
- "--ignore-rules",
317
- ...(readOnlyAnalysis ? codexAnalysisArguments(request.system) : []),
318
- "--output-schema",
319
- schemaPath,
320
- "--output-last-message",
321
- outputPath,
322
- "-",
323
- ],
324
- prompt,
325
- { spawnImpl, executionTimeoutMs, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis,
326
- terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
327
- );
333
+ const deadline = Date.now() + executionTimeoutMs;
334
+ const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
335
+ let stdout;
336
+ let selectedModel;
337
+ for (const model of models) {
338
+ await beforeDispatch();
339
+ if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
340
+ const remaining = deadline - Date.now();
341
+ if (remaining <= 0) throw new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' });
342
+ // Never consume a partial/stale output file left by a rejected model.
343
+ await rm(outputPath, { force: true });
344
+ try {
345
+ stdout = await spawnReview(
346
+ inspection.executable,
347
+ provider === "claude" ? [
348
+ "--print", "--output-format", "json",
349
+ "--json-schema", JSON.stringify(request.output_schema),
350
+ "--no-session-persistence", "--tools", "",
351
+ "--setting-sources", "", "--settings", '{"disableAllHooks":true}',
352
+ "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
353
+ ...(readOnlyAnalysis ? ["--system-prompt", request.system, "--max-turns", "1"] : []),
354
+ ] : [
355
+ "exec",
356
+ "--model",
357
+ model,
358
+ "--skip-git-repo-check",
359
+ "--sandbox",
360
+ "read-only",
361
+ "--ephemeral",
362
+ "--ignore-user-config",
363
+ "--ignore-rules",
364
+ ...(readOnlyAnalysis ? codexAnalysisArguments(request.system) : []),
365
+ "--output-schema",
366
+ schemaPath,
367
+ "--output-last-message",
368
+ outputPath,
369
+ "-",
370
+ ],
371
+ prompt,
372
+ { spawnImpl, executionTimeoutMs: remaining, label, cwd: workDir, environment, provider, signal, readOnlyAnalysis, model,
373
+ terminationGraceMs: Number.isInteger(terminationGraceMs) ? Math.max(1, Math.min(terminationGraceMs, 5_000)) : 1_000 },
374
+ );
375
+ selectedModel = model;
376
+ break;
377
+ } catch (error) {
378
+ if (error.code !== "provider_model_unsupported" || model === models.at(-1)) throw error;
379
+ }
380
+ }
328
381
  const envelope = provider === "claude" ? JSON.parse(stdout) : null;
329
382
  if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
330
383
  if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
@@ -340,7 +393,7 @@ export async function runStructuredProviderReview(
340
393
  }
341
394
  return {
342
395
  output,
343
- model: provider === "codex" ? SQM_CODEX_MODEL : `${provider}-local/${inspection.installed_version || "unknown"}`,
396
+ model: provider === "codex" ? selectedModel : `${provider}-local/${inspection.installed_version || "unknown"}`,
344
397
  };
345
398
  } catch (error) {
346
399
  if (error instanceof SqmLocalReviewError || error?.code === 'desktop_access_required') throw error;
@@ -365,7 +418,7 @@ function codexAnalysisArguments(system) {
365
418
  '-c', `developer_instructions=${JSON.stringify(system)}`];
366
419
  }
367
420
 
368
- function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs }) {
421
+ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider, signal, readOnlyAnalysis, terminationGraceMs, model }) {
369
422
  return new Promise((resolve, reject) => {
370
423
  if (signal?.aborted) return reject(new Error('Local analysis was cancelled'));
371
424
  const child = spawnImpl(command, args, {
@@ -376,6 +429,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
376
429
  });
377
430
  let stdout = "";
378
431
  let stderr = "";
432
+ let stderrPending = "";
379
433
  let settled = false;
380
434
  let terminalError = null;
381
435
  let killTimer = null;
@@ -411,9 +465,16 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
411
465
  for (const line of stdout.split('\n').filter(Boolean)) {
412
466
  let event;
413
467
  try { event = JSON.parse(line); } catch { continue; }
414
- if (['error', 'turn.failed'].includes(event.type) && providerSignInRequired(event.message ?? event.error?.message)) {
415
- halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
416
- return;
468
+ if (['error', 'turn.failed'].includes(event.type)) {
469
+ const message = event.message ?? event.error?.message;
470
+ if (providerSignInRequired(message)) {
471
+ halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
472
+ return;
473
+ }
474
+ if (providerModelUnsupported(message, model)) {
475
+ halt(modelUnsupportedError(model));
476
+ return;
477
+ }
417
478
  }
418
479
  if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
419
480
  halt(new Error('Read-only analysis attempted to use a tool'));
@@ -429,9 +490,32 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
429
490
  child.stderr?.setEncoding?.("utf8");
430
491
  child.stderr?.on?.("data", (chunk) => {
431
492
  if (!readOnlyAnalysis) stderr = `${stderr}${chunk}`.slice(-4000);
493
+ if (provider === "codex") {
494
+ stderrPending = `${stderrPending}${chunk}`.slice(-8000);
495
+ const lines = stderrPending.split(/\r?\n/);
496
+ stderrPending = lines.pop() || "";
497
+ for (const line of lines) checkStderr(line);
498
+ }
432
499
  });
500
+ const checkStderr = line => {
501
+ // Native CLI diagnostics only; never match warnings or assistant text.
502
+ const match = line.match(/^\s*ERROR:\s*(.*)$/);
503
+ if (!match) return;
504
+ let message = match[1];
505
+ try {
506
+ const event = JSON.parse(message);
507
+ if (event.type !== "error") return;
508
+ message = event.error?.message ?? event.message;
509
+ } catch { /* Older CLIs print a plain native ERROR line. */ }
510
+ if (providerSignInRequired(message)) {
511
+ halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
512
+ } else if (providerModelUnsupported(message, model)) {
513
+ halt(modelUnsupportedError(model));
514
+ }
515
+ };
433
516
  child.on("error", finish);
434
517
  child.on("close", (code) => {
518
+ if (provider === "codex") checkStderr(stderrPending);
435
519
  if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
436
520
  let envelope;
437
521
  try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
@@ -448,6 +532,18 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
448
532
  });
449
533
  }
450
534
 
535
+ function providerModelUnsupported(message, model) {
536
+ if (typeof message !== "string" || typeof model !== "string") return false;
537
+ const rejection = message.trim().match(/^The ['"]([^'"]+)['"] model is not supported(?: when using Codex with a ChatGPT account)?\.?$/i);
538
+ return rejection?.[1] === model;
539
+ }
540
+
541
+ function modelUnsupportedError(model) {
542
+ return new SqmLocalReviewError(`The local Codex account does not support ${model}. Update Codex or check the models available to this account.`, {
543
+ code: "provider_model_unsupported", status: 400,
544
+ });
545
+ }
546
+
451
547
  function providerSignInRequired(message) {
452
548
  // Match explicit provider authentication failures only, never general 403s
453
549
  // (which can be permissions/quotas) or model-generated prose/tool output.
@@ -511,6 +607,7 @@ function reviewFailure(error, stage, provider) {
511
607
  function formatFailure(failure, failureReported = null) {
512
608
  if (!failure) return "SQM analysis failed. No failure diagnostics were recorded.";
513
609
  const hint = failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
610
+ : failure.code === "provider_model_unsupported" ? "The local Codex account rejected the configured SQM models. Update Codex or check this account's available models."
514
611
  : failure.code === "analysis_timeout" ? "The local AI analysis exceeded its time limit."
515
612
  : failure.code === "sqm_source_loading_failed" ? "SIMY could not load evidence for this analysis."
516
613
  : failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."