@awak-app/simy-cli 0.5.71 → 0.5.73

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.
Files changed (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +17 -12
  16. package/src/desktop-host.js +19 -8
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +18 -1
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. package/src/startup-preflight.js +44 -0
@@ -21,6 +21,8 @@ export async function claudePluginFiles() {
21
21
  files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
22
22
  }
23
23
  files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
24
+ files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
25
+ files.set("hooks/continuation-policy.mjs", await readFile(path.join(SOURCE, "hooks/continuation-policy.mjs"), "utf8"));
24
26
  files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));
25
27
  files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
26
28
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
@@ -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";
@@ -10,8 +11,9 @@ import { uploadRunEvidence } from "./evidence-client.js";
10
11
  import { evaluateKnowledgeBundle } from "./checkers.js";
11
12
  import { runSqmCheck, runSqmMinCheck } from "./index.js";
12
13
  import { lintKnowledgeModule, summarizeReplay } from "./reliability.js";
13
- import { ensureSqmSession } from "./session.js";
14
+ import { ensureSqmSession, sqmSessionScope } from "./session.js";
14
15
  import { formatSqmCoverage } from "./report.js";
16
+ import { sqmDiagnostic } from "./request-recovery.js";
15
17
 
16
18
  export async function runSqmCommand(argv, { cliVersion }) {
17
19
  if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
@@ -23,14 +25,17 @@ export async function runSqmCommand(argv, { cliVersion }) {
23
25
  if (argv[0] === "replay") return await runReplayCommand(argv);
24
26
  if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|lint|replay> [options]");
25
27
  const webOrigin = resolveWebOrigin(option(argv, "--host"));
28
+ const authorizationLogger = argv.includes("--json")
29
+ ? { log: message => console.warn(message), error: message => console.error(message) }
30
+ : console;
26
31
  let session = await readSession(webOrigin);
27
32
  let validSession = isSessionValid(session, Date.now(), webOrigin) ? session : null;
28
33
  if (!validSession && !explicitSqmEnvironment(argv)) {
29
- session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open") });
34
+ session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), logger: authorizationLogger });
30
35
  validSession = session;
31
36
  }
32
37
  const organizationId = process.env.SIMY_SQM_ORGANIZATION_ID || validSession?.organization_id || validSession?.org_id;
33
- const token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
38
+ let token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
34
39
  const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
35
40
  const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
36
41
  if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
@@ -45,10 +50,33 @@ export async function runSqmCommand(argv, { cliVersion }) {
45
50
  endpoint,
46
51
  accountId,
47
52
  organizationId,
53
+ deviceId,
48
54
  };
49
55
 
50
56
  const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
51
57
  phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
58
+ const onRecoveryEvent = async diagnostic => createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
59
+ phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
60
+ common.bundleDependencies = { onRecoveryEvent };
61
+ common.onRecoveryEvent = onRecoveryEvent;
62
+ let authenticationAttempted = false;
63
+ if (validSession && !process.env.SIMY_SQM_TOKEN && !process.env.SIMY_ACCESS_TOKEN) {
64
+ const expectedScope = sqmSessionScope(validSession, webOrigin);
65
+ common.onAuthenticationRequired = async ({ rejectedToken }) => {
66
+ if (authenticationAttempted) {
67
+ const error = new Error("SQM authorization was already refreshed once; resolve the sign-in error before another check.");
68
+ error.code = "authorization_refresh_failed"; throw error;
69
+ }
70
+ authenticationAttempted = true;
71
+ validSession = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), rejectedToken, expectedScope, logger: authorizationLogger });
72
+ token = validSession.token;
73
+ common.token = token;
74
+ return { token, accountId: validSession.account_id || validSession.auth_user_id || validSession.device_id,
75
+ organizationId: validSession.organization_id || validSession.org_id, deviceId: validSession.device_id,
76
+ endpoint: process.env.SIMY_SQM_BUNDLE_URL || new URL("sqm/knowledge-bundle", validSession.api_base_url).href };
77
+ };
78
+ }
79
+ let stage = "min_check";
52
80
  try {
53
81
  if (argv[0] === "min-check") {
54
82
  const result = await runSqmMinCheck(common);
@@ -60,6 +88,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
60
88
  return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
61
89
  }
62
90
 
91
+ stage = "min_result_read";
63
92
  const minPath = option(argv, "--min-result") || defaultMinResultPath();
64
93
  let minCheck;
65
94
  try {
@@ -71,11 +100,19 @@ export async function runSqmCommand(argv, { cliVersion }) {
71
100
  await atomicJson(minPath, minCheck);
72
101
  }
73
102
  if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
103
+ stage = "full_check";
74
104
  const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
75
105
  if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
106
+ if (result.recovery?.code === "knowledge_bundle_changed") {
107
+ // Keep the earlier min as history; never rewrite it into an apparent
108
+ // success for the new bundle.
109
+ await atomicJson(`${minPath}.previous.${minCheck.result_digest.slice(-16)}.json`, minCheck);
110
+ await atomicJson(minPath, result.min_check);
111
+ }
76
112
  const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
77
113
  const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
78
114
  const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
115
+ stage = "artifact_write";
79
116
  await Promise.all([
80
117
  atomicJson(proofPath, result.proof),
81
118
  atomicJson(evidencePath, result.signed_evidence),
@@ -87,6 +124,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
87
124
  let upload = { uploaded: false, reason: "disabled" };
88
125
  if (!argv.includes("--no-upload")) {
89
126
  const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
127
+ stage = "evidence_upload";
90
128
  upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
91
129
  }
92
130
  result.upload = upload;
@@ -101,7 +139,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
101
139
  else printFull(result);
102
140
  return result.proof.outcome.status === "failed" ? 1 : 0;
103
141
  } catch (error) {
104
- await save({ status: "failed" });
142
+ await save({ status: "failed", diagnostic: { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) } });
105
143
  throw error;
106
144
  }
107
145
  }
@@ -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.http_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,12 +1,29 @@
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
- const response = await fetchImpl(endpoint, {
4
- method: "POST",
5
- headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
6
- body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
7
- signal: AbortSignal.timeout(10_000),
8
- });
4
+ let response;
5
+ try {
6
+ response = await fetchImpl(endpoint, {
7
+ method: "POST",
8
+ headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
9
+ body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
10
+ signal: AbortSignal.timeout(10_000),
11
+ });
12
+ } catch (cause) {
13
+ const error = cause instanceof Error ? cause : new Error("SQM run evidence upload failed.");
14
+ error.stage = "evidence_upload";
15
+ error.retryable = false;
16
+ throw error;
17
+ }
9
18
  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"}` : ""}.`);
19
+ if (!response.ok) {
20
+ const error = new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
21
+ error.status = response.status;
22
+ error.stage = "evidence_upload";
23
+ error.retryable = false;
24
+ Object.assign(error, safeSqmDiagnostic({ status: response.status, code: payload?.error?.code,
25
+ requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
26
+ throw error;
27
+ }
11
28
  return { uploaded: true, ...payload };
12
29
  }
@@ -1,7 +1,9 @@
1
+ import { diagnosticLogger } from "../diagnostic-log.js";
2
+ import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
5
  import { basename, join } from "node:path";
4
- import { formatSqmCoverage } from "./report.js";
6
+ import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
5
7
  import { defaultSessionRoot } from "../session-store.js";
6
8
 
7
9
  const LIMIT = 100;
@@ -44,6 +46,8 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
44
46
  try {
45
47
  await writeFile(temp, JSON.stringify(item), { mode: 0o600 });
46
48
  await rename(temp, target);
49
+ if (["failed", "unavailable"].includes(item.state)) await diagnosticLogger({ root, scope }).record({
50
+ kind: item.kind, stage: entry.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider, http_status: entry.httpStatus, at: item.finishedAt || item.startedAt });
47
51
  } finally { await rm(temp, { force: true }); cache.delete(dir); }
48
52
  const items = await list(scope);
49
53
  for (const kind of ["analysis", "check"]) {
@@ -69,9 +73,11 @@ function metadata(entry) {
69
73
  if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
70
74
  if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
71
75
  const item = {};
72
- for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "webUrl"]) {
76
+ for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl"]) {
73
77
  if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
74
78
  }
79
+ if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
80
+ if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
75
81
  return item;
76
82
  }
77
83
 
@@ -94,15 +100,25 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, on
94
100
  const repository = identity?.repository || result.repository;
95
101
  const outcome = proof?.outcome.status || result.min_check?.status || result.status;
96
102
  const state = outcome === "unavailable" ? "unavailable" : ["failed", "preliminary_failed"].includes(outcome) ? "failed" : ["requires_human", "requires_post_deploy"].includes(outcome) ? "verification_pending" : "completed";
103
+ 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)
104
+ ? safeSqmDiagnostic(result, phase, state === "unavailable" ? "sqm_check_unavailable" : "sqm_check_failed") : null;
105
+ const minCheck = result.min_check || result;
106
+ const minWarnings = !proof && Array.isArray(minCheck.matched_modules)
107
+ ? sqmCoverageWarnings({ moduleCount: minCheck.matched_modules.length,
108
+ scenarioCount: Array.isArray(minCheck.full_check_plan) ? minCheck.full_check_plan.length :
109
+ minCheck.matched_modules.reduce((total, module) => total + (module.scenario_ids?.length || 0), 0),
110
+ noSystemKnowledge: minCheck.matched_modules.length === 0 && minCheck.coverage_reasons?.includes("no_system_knowledge") === true,
111
+ outcomeStatus: minCheck.status }) : [];
97
112
  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."
113
+ : state === "unavailable" ? "SQM could not obtain a verified result. See the recorded diagnostics below."
114
+ : state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
100
115
  : `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
116
  try {
102
117
  await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, finishedAt: new Date().toISOString(), repository,
103
118
  label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
104
119
  instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
105
- result: summary });
120
+ result: [summary, ...minWarnings].join("\n"),
121
+ ...(failure ? { error: formatSqmDiagnostic(failure), errorCode: failure.code, errorStage: failure.stage, errorStatus: failure.status, requestId: failure.requestId } : {}) });
106
122
  } catch { onError(); }
107
123
  };
108
124
  }
package/src/sqm/index.js CHANGED
@@ -1,9 +1,13 @@
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";
4
6
  import { uploadRunEvidence } from "./evidence-client.js";
5
7
  import { buildProof, finalizeMinCheck, repositoryIdentity, sameRepositoryIdentity, signProof, verifyMinCheck, verifySignedEvidence } from "./proof.js";
6
8
  import { inspectRepository } from "./repository.js";
9
+ import { sqmDiagnostic } from "./request-recovery.js";
10
+ import { canonicalize } from "./canonical.js";
7
11
 
8
12
  export function createSqmChecker({
9
13
  cwd = process.cwd(),
@@ -19,6 +23,7 @@ export function createSqmChecker({
19
23
  webOrigin,
20
24
  historyRoot,
21
25
  evidenceEndpoint,
26
+ signingKeyPath,
22
27
  uploadRunEvidenceImpl = uploadRunEvidence,
23
28
  inspectRepositoryImpl = inspectRepository,
24
29
  loadKnowledgeBundleImpl = loadKnowledgeBundle,
@@ -29,34 +34,59 @@ export function createSqmChecker({
29
34
  let pinnedLoad = null;
30
35
  let pinnedRepository = null;
31
36
  let latestMinCheck = null;
37
+ let pinnedSystemScope;
38
+ const onRecoveryEvent = async (diagnostic) => {
39
+ await createSqmCheckRecorder({ root: historyRoot,
40
+ scope: historyRoot ? { webOrigin, accountId, organizationId } : null,
41
+ phase: diagnostic.stage === "evidence_upload" ? "full" : "min", cwd, baseBranch })({ status: "failed", diagnostic });
42
+ await dependencies.onRecoveryEvent?.(diagnostic);
43
+ };
32
44
 
33
45
  const checkCurrentRepository = async function(_attempt = null, { phase = "min" } = {}) {
34
46
  let repository;
35
47
  try {
36
48
  repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
37
49
  } catch (error) {
38
- return unavailable(`SQM repository inspection failed: ${message(error)}`);
50
+ return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: { ...safeSqmDiagnostic(error, "repository", "repository_inspection_failed"), ...sqmDiagnostic(error, "repository") } };
39
51
  }
40
52
  if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
41
53
  if (!pinnedLoad) {
42
54
  pinnedRepository = repository.repository;
43
- pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, ...dependencies });
55
+ pinnedLoad = loadKnowledgeBundleImpl({ ...dependencies, repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, onRecoveryEvent });
44
56
  }
45
57
  const loaded = await pinnedLoad;
46
- if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings };
58
+ if (!loaded.bundle) {
59
+ // A failed read must not poison the run permanently. A later explicit
60
+ // same-scope check or refreshed sign-in may try loading again.
61
+ pinnedLoad = null;
62
+ return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
63
+ }
64
+ if (pinnedSystemScope === undefined) pinnedSystemScope = loaded.bundle.system_scope || null;
65
+ else if (!sameSystemScope(pinnedSystemScope, loaded.bundle.system_scope || null)) {
66
+ return { ...unavailable("SQM system scope changed during recovery; authorize the current scope before a new check.", repository.repository),
67
+ diagnostic: { stage: "knowledge_loading", code: "authorization_scope_changed", retryable: false } };
68
+ }
47
69
  try {
48
70
  const evaluate = executeKnowledgeBundleImpl
49
71
  ? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
50
72
  : evaluateKnowledgeBundleImpl;
51
73
  if (phase === "full") {
52
- if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository))) {
74
+ if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository)) ||
75
+ latestMinCheck.knowledge_bundle.digest !== loaded.bundle.integrity.digest) {
53
76
  latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
54
77
  }
55
- const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId });
78
+ const fullEvaluation = await evaluate(loaded.bundle, repository);
79
+ const current = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
80
+ if (!sameRepositoryIdentity(repositoryIdentity(repository), repositoryIdentity(current))) {
81
+ throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
82
+ }
83
+ const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: fullEvaluation, organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId, signingKeyPath });
56
84
  try {
57
85
  full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
58
86
  } catch (error) {
59
87
  full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
88
+ full.upload.diagnostic = { ...safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed"), ...sqmDiagnostic(error, "evidence_upload") };
89
+ await onRecoveryEvent(full.upload.diagnostic);
60
90
  full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
61
91
  }
62
92
  return full;
@@ -64,38 +94,91 @@ export function createSqmChecker({
64
94
  latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
65
95
  return toLegacyCheck(latestMinCheck, loaded);
66
96
  } 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)}`] };
97
+ return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`], diagnostic: { ...safeSqmDiagnostic(error, `${phase}_check`, "sqm_execution_failed"), ...sqmDiagnostic(error, `${phase}_check`) } };
68
98
  }
69
99
  };
70
- return async (attempt, options = {}) => {
100
+ const checker = async (attempt, options = {}) => {
71
101
  const save = createSqmCheckRecorder({ root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
72
102
  try {
73
103
  const result = await checkCurrentRepository(attempt, options);
74
104
  await save(result); return result;
75
- } catch (error) { await save({ status: "failed" }); throw error; }
105
+ } catch (error) {
106
+ const stage = options.phase === "full" ? "full_check" : "min_check";
107
+ const diagnostic = { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) };
108
+ if (diagnostic.retryable) pinnedLoad = null;
109
+ await save({ status: "failed", diagnostic }); throw error;
110
+ }
76
111
  };
112
+ checker.updateSession = (next) => {
113
+ for (const [field, current] of Object.entries({ accountId, organizationId, deviceId, endpoint, webOrigin })) {
114
+ if (current && next?.[field] !== current) {
115
+ const error = new Error(`SQM session changed its ${field}; the original scope cannot be resumed with it.`);
116
+ error.code = "authorization_scope_changed"; throw error;
117
+ }
118
+ }
119
+ if (!next?.token) throw new Error("An authenticated same-scope SQM session is required.");
120
+ if (next.token !== token) { token = next.token; pinnedLoad = null; }
121
+ };
122
+ return checker;
77
123
  }
78
124
 
79
125
  export async function runSqmMinCheck(options) {
80
126
  const context = await loadContext(options);
81
- if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
127
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
82
128
  const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
83
129
  return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
84
130
  }
85
131
 
86
132
  export async function runSqmCheck(options) {
87
133
  const context = await loadContext(options);
88
- if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
134
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
89
135
  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
- const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
93
- 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 });
136
+ if (options.minCheck.organization_id !== options.organizationId || context.loaded.bundle.organization_id !== options.organizationId) {
137
+ throw scopeError("SQM organization changed after min-check; authorize the original organization before resuming.");
138
+ }
139
+ if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(candidateError("Repository identity changed after min-check; run `simy sqm min-check` again."), { stage: "identity_validation" });
140
+ if (!sameSystemScope(options.minCheck.knowledge_bundle.system_scope || null, context.loaded.bundle.system_scope || null)) {
141
+ throw scopeError("SQM system scope changed after min-check; authorize the current scope before a new check.");
142
+ }
143
+ const evaluate = options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle;
144
+ let minCheck = options.minCheck;
145
+ if (minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) {
146
+ await options.onRecoveryEvent?.({ stage: "full_check", code: "knowledge_bundle_changed", retryable: true });
147
+ minCheck = await buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle,
148
+ evaluation: await evaluate(context.loaded.bundle, context.repository), organizationId: options.organizationId, cliVersion: options.cliVersion });
149
+ const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
150
+ if (!sameRepositoryIdentity(minCheck.repository_identity, repositoryIdentity(current))) throw new Error("Repository identity changed during min-check recovery; run `simy sqm min-check` again.");
151
+ }
152
+ const evaluation = await evaluate(context.loaded.bundle, context.repository);
153
+ const verifiedRepository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
154
+ if (!sameRepositoryIdentity(repositoryIdentity(context.repository), repositoryIdentity(verifiedRepository))) {
155
+ throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
156
+ }
157
+ const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
158
+ if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
159
+ return result;
94
160
  }
95
161
 
96
162
  async function loadContext(options) {
97
163
  const repository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
98
- const loaded = await (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh, token: options.token, endpoint: options.endpoint, accountId: options.accountId, organizationId: options.organizationId, ...(options.bundleDependencies || {}) });
164
+ const rejectedToken = options.token;
165
+ const originalIdentity = repositoryIdentity(repository);
166
+ const scope = Object.fromEntries(["accountId", "organizationId", "deviceId", "endpoint"].map(field => [field, options[field]]));
167
+ const load = (token) => (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ ...(options.bundleDependencies || {}),
168
+ repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh,
169
+ token, endpoint: scope.endpoint, accountId: scope.accountId, organizationId: scope.organizationId });
170
+ let loaded = await load(rejectedToken);
171
+ if (!loaded.bundle && loaded.diagnostic?.http_status === 401 && options.onAuthenticationRequired && !options.offline) {
172
+ await options.onRecoveryEvent?.(loaded.diagnostic);
173
+ const next = await options.onAuthenticationRequired({ rejectedToken, ...scope });
174
+ for (const field of ["accountId", "organizationId", "deviceId", "endpoint"]) {
175
+ if (scope[field] && next?.[field] !== scope[field]) throw scopeError(`SQM authorization changed its ${field}; do not resume another scope.`);
176
+ }
177
+ if (!next?.token || next.token === rejectedToken) throw scopeError("SQM authorization did not replace the rejected token.");
178
+ const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
179
+ if (!sameRepositoryIdentity(originalIdentity, repositoryIdentity(current))) throw candidateError("Repository identity changed while waiting for SQM authorization; begin a new check against the current candidate.");
180
+ loaded = await load(next.token);
181
+ }
99
182
  return { repository, loaded };
100
183
  }
101
184
 
@@ -113,6 +196,13 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
113
196
  preliminary_findings: evaluation.findings,
114
197
  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
198
  full_check_plan: plan,
199
+ coverage_reasons: [
200
+ ...(evaluation.modules.length === 0 ? ["no_applicable_rules"] : []),
201
+ ...(bundle.modules?.length === 0 ? ["no_system_knowledge"] : []),
202
+ ],
203
+ warnings: sqmCoverageWarnings({ moduleCount: evaluation.modules.length, scenarioCount: plan.length,
204
+ noSystemKnowledge: bundle.modules?.length === 0,
205
+ outcomeStatus: evaluation.findings.some(finding => finding.enforcement === "blocking") ? "preliminary_failed" : "passed" }),
116
206
  });
117
207
  }
118
208
 
@@ -133,12 +223,12 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
133
223
  proof,
134
224
  signed_evidence: signedEvidence,
135
225
  signature_verification: verification,
136
- warnings: [],
226
+ 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
227
  };
138
228
  }
139
229
 
140
230
  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 };
231
+ 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
232
  }
143
233
 
144
234
  function bundleIdentity(bundle) {
@@ -150,3 +240,12 @@ function unavailable(warning, repository = null) {
150
240
  }
151
241
 
152
242
  function message(error) { return error instanceof Error ? error.message : String(error); }
243
+
244
+ function sameSystemScope(left, right) {
245
+ const normalized = (scope) => scope ? { organization_id: scope.organization_id,
246
+ repository: scope.repository?.toLowerCase(), graph_revision: scope.graph_revision,
247
+ repositories: [...(scope.repositories || [])].map((repository) => repository.toLowerCase()).sort() } : null;
248
+ return canonicalize(normalized(left)) === canonicalize(normalized(right));
249
+ }
250
+ function scopeError(message) { const error = new Error(message); error.code = "authorization_scope_changed"; return error; }
251
+ function candidateError(message) { const error = new Error(message); error.code = "repository_identity_changed"; return error; }
package/src/sqm/proof.js CHANGED
@@ -1,9 +1,9 @@
1
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { createHash, createPublicKey, randomUUID, sign, verify } from "node:crypto";
3
2
  import { homedir } from "node:os";
4
3
  import path from "node:path";
5
4
 
6
5
  import { canonicalize } from "./canonical.js";
6
+ import { loadDeviceSigningIdentity } from "../device-signing-identity.js";
7
7
 
8
8
  export function repositoryIdentity(repository) {
9
9
  return {
@@ -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
  ],
@@ -97,7 +98,7 @@ export async function signProof(proof, {
97
98
  issuedAt = new Date().toISOString(),
98
99
  } = {}) {
99
100
  if (!deviceId) throw new Error("A trusted SIMY CLI device identity is required to sign SQM proof.");
100
- const identity = await loadOrCreateSigningIdentity(keyPath);
101
+ const identity = await loadDeviceSigningIdentity(keyPath, { create: true });
101
102
  const proofDigest = verifyProof(proof);
102
103
  const signature = sign(null, Buffer.from(proofDigest.slice("sha256:".length), "hex"), identity.privateKey).toString("base64");
103
104
  return {
@@ -132,26 +133,6 @@ export function verifySignedEvidence(proof, evidence) {
132
133
  return { proof_digest: proofDigest, key_id: keyId, valid: true };
133
134
  }
134
135
 
135
- async function loadOrCreateSigningIdentity(location) {
136
- try {
137
- const parsed = JSON.parse(await readFile(location, "utf8"));
138
- const privateKey = createPrivateKey({ key: Buffer.from(parsed.private_key, "base64"), format: "der", type: "pkcs8" });
139
- const publicKeyDer = Buffer.from(parsed.public_key, "base64");
140
- const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
141
- if (parsed.key_id !== keyId) throw new Error("stored key ID mismatch");
142
- return { privateKey, publicKeyDer, keyId };
143
- } catch (error) {
144
- if (error?.code !== "ENOENT") throw new Error(`SQM signing identity is invalid: ${error.message}`);
145
- }
146
- const pair = generateKeyPairSync("ed25519");
147
- const privateKeyDer = pair.privateKey.export({ format: "der", type: "pkcs8" });
148
- const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
149
- const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
150
- await mkdir(path.dirname(location), { recursive: true, mode: 0o700 });
151
- await writeFile(location, `${JSON.stringify({ algorithm: "ed25519", key_id: keyId, private_key: privateKeyDer.toString("base64"), public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
152
- return { privateKey: pair.privateKey, publicKeyDer, keyId };
153
- }
154
-
155
136
  function taggedDigest(value) {
156
137
  return `sha256:${createHash("sha256").update(canonicalize(value), "utf8").digest("hex")}`;
157
138
  }
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
+ }