@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
@@ -0,0 +1,82 @@
1
+ const TRANSIENT_STATUSES = new Set([502, 503, 504]);
2
+ const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENETUNREACH", "EAI_AGAIN", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
3
+
4
+ export function sqmDiagnostic(error, stage = "unknown") {
5
+ const status = error?.http_status ?? error?.status;
6
+ const timeout = error?.name === "TimeoutError" || error?.code === "ETIMEDOUT";
7
+ const network = (error instanceof TypeError && /fetch failed|failed to fetch|network request failed/i.test(error.message)) ||
8
+ NETWORK_CODES.has(error?.code) || NETWORK_CODES.has(error?.cause?.code);
9
+ const safeCode = typeof error?.code === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.code) ? error.code : null;
10
+ return {
11
+ stage: typeof error?.stage === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.stage) ? error.stage : stage,
12
+ code: safeCode || (status === 401 ? "sign_in_required" : status === 403 ? "access_denied" :
13
+ status === 409 ? "scope_unresolved" : TRANSIENT_STATUSES.has(status) ? "gateway_unavailable" :
14
+ timeout ? "request_timeout" : network ? "network_unavailable" : "check_failed"),
15
+ ...(Number.isInteger(status) && status >= 400 && status <= 599 ? { http_status: status } : {}),
16
+ retryable: error?.retryable !== false && (TRANSIENT_STATUSES.has(status) || timeout || network),
17
+ };
18
+ }
19
+
20
+ // This deliberately exposes only GET recovery. Uncertain POST success must be
21
+ // reconciled with a server idempotency contract before it may be replayed.
22
+ export async function fetchSqmRead(url, {
23
+ headers, signal, fetchImpl = globalThis.fetch,
24
+ timeoutMs = 45_000, requestTimeoutMs = 15_000, maxAttempts = 3,
25
+ sleep = abortableSleep, now = Date.now, onRecoveryEvent = async () => {},
26
+ } = {}) {
27
+ const totalLimit = boundedInteger(timeoutMs, 45_000);
28
+ const requestLimit = boundedInteger(requestTimeoutMs, 15_000);
29
+ const deadline = now() + totalLimit;
30
+ const deadlineSignal = AbortSignal.timeout(totalLimit);
31
+ const attempts = boundedInteger(maxAttempts, 3);
32
+ for (let attempt = 1; attempt <= attempts; attempt++) {
33
+ signal?.throwIfAborted();
34
+ deadlineSignal.throwIfAborted();
35
+ const remaining = deadline - now();
36
+ if (remaining <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
37
+ const attemptSignal = AbortSignal.any([
38
+ deadlineSignal, AbortSignal.timeout(Math.min(requestLimit, remaining)),
39
+ ...(signal ? [signal] : []),
40
+ ]);
41
+ try {
42
+ const response = await withAbort(fetchImpl(url, { method: "GET", headers, signal: attemptSignal }), attemptSignal);
43
+ if (!TRANSIENT_STATUSES.has(response.status)) return response;
44
+ const error = new Error(`SQM Cloud returned HTTP ${response.status}.`);
45
+ error.status = response.status;
46
+ if (attempt === attempts) return response;
47
+ await response.body?.cancel().catch(() => {});
48
+ throw error;
49
+ } catch (error) {
50
+ if (signal?.aborted) throw signal.reason;
51
+ const diagnostic = sqmDiagnostic(error, "knowledge_loading");
52
+ if (!diagnostic.retryable || attempt === attempts || deadlineSignal.aborted) throw error;
53
+ await onRecoveryEvent({ ...diagnostic, attempt, recovery: "retrying" });
54
+ const delay = Math.min(250 * 2 ** (attempt - 1), Math.max(0, deadline - now()));
55
+ if (delay <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
56
+ await sleep(delay, AbortSignal.any([deadlineSignal, ...(signal ? [signal] : [])]));
57
+ }
58
+ }
59
+ }
60
+
61
+ function boundedInteger(value, maximum) {
62
+ return Number.isFinite(value) && value > 0 ? Math.min(maximum, Math.max(1, Math.floor(value))) : maximum;
63
+ }
64
+
65
+ function withAbort(promise, signal) {
66
+ return new Promise((resolve, reject) => {
67
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
68
+ if (signal.aborted) abort();
69
+ else signal.addEventListener("abort", abort, { once: true });
70
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
71
+ });
72
+ }
73
+
74
+ function abortableSleep(milliseconds, signal) {
75
+ signal.throwIfAborted();
76
+ return new Promise((resolve, reject) => {
77
+ const finish = () => { signal.removeEventListener("abort", abort); resolve(); };
78
+ const timer = setTimeout(finish, milliseconds);
79
+ const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason); };
80
+ signal.addEventListener("abort", abort, { once: true });
81
+ });
82
+ }
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { inspectProviderStatus } from "../desktop-status.js";
6
7
  import { inspectDesktopExecutor } from "../desktop-executor.js";
7
8
  import {
8
9
  webApiErrorMessage,
@@ -88,7 +89,8 @@ export function createSqmLocalReviewWorker({
88
89
  leaseSeconds = DEFAULT_LEASE_SECONDS,
89
90
  heartbeatIntervalMs = Math.max(10_000, Math.floor(leaseSeconds * 500)),
90
91
  getProvider = () => "codex",
91
- runReview = (request, provider) => runStructuredProviderReview(provider, request),
92
+ runReview = (request, provider, options) => runStructuredProviderReview(provider, request, { ...options, beforeDispatch: options?.beforeLeaseDispatch }),
93
+ getEnvironmentKey = () => null,
92
94
  beginWork = () => () => {},
93
95
  onError = () => {},
94
96
  onChange = () => {},
@@ -96,12 +98,30 @@ export function createSqmLocalReviewWorker({
96
98
  getWorkspaceUrl = () => null,
97
99
  recordHistory = async () => {},
98
100
  onHistoryError = () => {},
101
+ now = () => Date.now(),
102
+ providerFailureCooldownMs = 5 * 60_000,
103
+ getRecoveryKey = () => null,
99
104
  } = {}) {
100
105
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
101
106
  let activeJobId = null;
102
107
  let pollPromise = null;
103
108
  let closed = false;
104
- let issue = null;
109
+ let issue = null, pausedEnvironment = null, pauseReason = null;
110
+ let issueContext = null;
111
+ const failureDetails = new WeakMap();
112
+ const pauses = new Map();
113
+ const contextKey = (provider, scope) => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId, provider]);
114
+ const currentPause = () => pauses.get(contextKey(getProvider(), getHistoryScope()));
115
+ const pauseForFailure = (error, provider, scope, recoveryKey) => {
116
+ const failure = failureDetails.get(error) || reviewFailure(error, "claim", provider);
117
+ 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;
118
+ const key = contextKey(provider, scope);
119
+ const failures = Math.min((pauses.get(key)?.failures || 0) + 1, 4);
120
+ const delay = Math.min(30 * 60_000, Math.max(1000, providerFailureCooldownMs) * 2 ** (failures - 1));
121
+ pauses.set(key, { code: failure.code, stage: failure.stage, status: failure.status, failures, recoveryKey,
122
+ 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.` });
123
+ if (pauses.size > 32) pauses.delete(pauses.keys().next().value);
124
+ };
105
125
  const history = [];
106
126
  const historyScopes = new WeakMap();
107
127
  const visibleHistory = () => history.filter(item => historyScopes.get(item) === JSON.stringify(getHistoryScope()));
@@ -114,7 +134,7 @@ export function createSqmLocalReviewWorker({
114
134
  item.state = state; item.finishedAt = new Date().toISOString();
115
135
  item.result = state === "completed" ? "SQM review submitted." : "";
116
136
  item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
117
- if (failure) { item.phase = failure.stage; item.errorCode = failure.code; }
137
+ if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; }
118
138
  await persist(scope, item);
119
139
  }
120
140
  onChange();
@@ -130,11 +150,11 @@ export function createSqmLocalReviewWorker({
130
150
 
131
151
  // Repair only a rejected model result, under the same live lease. Network,
132
152
  // authorization, and persistence failures must not repeat provider execution.
133
- const validatedReview = async (request, provider, code, submit, setStage) => {
153
+ const validatedReview = async (request, provider, code, submit, setStage, reviewOptions) => {
134
154
  let nextRequest = request;
135
155
  for (let attempt = 0; ; attempt += 1) {
136
156
  setStage(false);
137
- const review = await runReview(nextRequest, provider);
157
+ const review = await runReview(nextRequest, provider, reviewOptions);
138
158
  setStage(true);
139
159
  try {
140
160
  return { review, response: await submit(review) };
@@ -162,26 +182,38 @@ export function createSqmLocalReviewWorker({
162
182
  heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
163
183
  heartbeatTimer.unref?.();
164
184
  let stage = "analysis";
185
+ const reviewOptions = { providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
186
+ beforeLeaseDispatch: () => api.request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
187
+ const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
188
+ ? { reported_executor_provider: review.provider || selectedProvider } : {};
165
189
  try {
166
190
  const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => api.request({
167
191
  action: "advance",
168
192
  ...leaseBody(claim, provider),
169
193
  analysis_output: analysis.output,
170
194
  executor_model: analysis.model,
171
- }), submitting => { stage = submitting ? "advance" : "analysis"; });
195
+ ...providerReport(analysis),
196
+ }), submitting => { stage = submitting ? "advance" : "analysis"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
197
+ const analysisProvider = analysis.provider || provider;
198
+ const item = history.find(item => item.id === claim.job_id);
199
+ if (item) item.provider = analysisProvider;
172
200
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
173
201
  stage = "adjudication";
174
- const { response: completed } = await validatedReview(advanced.request, provider, "invalid_sqm_adjudication_output", adjudication => api.request({
202
+ const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => api.request({
175
203
  action: "complete",
176
204
  ...leaseBody(claim, provider),
177
205
  analysis_output: analysis.output,
178
206
  adjudication_output: adjudication.output,
179
207
  executor_model: adjudication.model || analysis.model,
180
- }), submitting => { stage = submitting ? "complete" : "adjudication"; });
208
+ ...providerReport(adjudication, analysisProvider),
209
+ ...(claim.capabilities?.reported_executor_provider === true ? { analysis_executor_provider: analysisProvider, analysis_executor_model: analysis.model } : {}),
210
+ }), submitting => { stage = submitting ? "complete" : "adjudication"; if (submitting) pauses.delete(contextKey(provider, historyScope)); }, reviewOptions);
211
+ if (item) item.provider = adjudication.provider || analysisProvider;
181
212
  await updateTask(claim.job_id, "completed", historyScope);
182
213
  return completed;
183
214
  } catch (error) {
184
215
  const failure = reviewFailure(error, stage, provider);
216
+ if (error && typeof error === "object") failureDetails.set(error, failure);
185
217
  let failureReported = error.failureRecorded === true;
186
218
  if (!failureReported) {
187
219
  try {
@@ -201,22 +233,33 @@ export function createSqmLocalReviewWorker({
201
233
 
202
234
  return {
203
235
  snapshot() {
204
- return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, issue };
236
+ const pause = currentPause();
237
+ return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
238
+ ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
205
239
  },
206
240
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
207
241
  desktopTaskDetail(key) {
208
242
  const item = visibleHistory().find(item => item.key === key);
209
243
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
210
244
  },
211
- resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
245
+ retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
246
+ resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
212
247
  poll() {
213
248
  if (closed || pollPromise) return pollPromise || Promise.resolve(null);
249
+ const environmentKey = getEnvironmentKey();
250
+ if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
251
+ if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
214
252
  const provider = getProvider();
215
253
  if (provider == null) return Promise.resolve(null);
216
254
  assertProvider(provider);
255
+ const historyScope = getHistoryScope();
256
+ const key = contextKey(provider, historyScope);
257
+ const recoveryKey = getRecoveryKey(provider);
258
+ const pause = pauses.get(key);
259
+ if (pause && pause.recoveryKey === recoveryKey && now() < pause.retryAt) return Promise.resolve(null);
260
+ if (pause && pause.recoveryKey !== recoveryKey) pauses.delete(key);
217
261
  const releaseWork = beginWork?.();
218
262
  if (!releaseWork) return Promise.resolve(null);
219
- const historyScope = getHistoryScope();
220
263
  const workspaceUrl = getWorkspaceUrl();
221
264
  pollPromise = (async () => {
222
265
  const claim = await api.request({
@@ -226,10 +269,18 @@ export function createSqmLocalReviewWorker({
226
269
  lease_seconds: leaseSeconds,
227
270
  });
228
271
  if (issue) { issue = null; onChange(); }
229
- if (!claim?.claimed) return null;
230
- return processClaim(claim, provider, historyScope, workspaceUrl);
272
+ if (!claim?.claimed) {
273
+ // A successful empty claim proves SIMY access recovered, but it says
274
+ // nothing about a local provider that has not executed again yet.
275
+ if (pause?.stage === "claim") pauses.delete(key);
276
+ return null;
277
+ }
278
+ const result = await processClaim(claim, provider, historyScope, workspaceUrl);
279
+ pauses.delete(key);
280
+ return result;
231
281
  })()
232
282
  .catch(async (error) => {
283
+ pauseForFailure(error, provider, historyScope, recoveryKey);
233
284
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
234
285
  const scope = historyScope;
235
286
  const failure = reviewFailure(error, "claim", provider);
@@ -239,6 +290,10 @@ export function createSqmLocalReviewWorker({
239
290
  history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
240
291
  await persist(scope, item);
241
292
  }
293
+ if (["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable", "provider_sign_in_required", "provider_status_unknown"].includes(error.code)) {
294
+ pausedEnvironment = environmentKey; pauseReason = error.code;
295
+ }
296
+ issueContext = key;
242
297
  issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
243
298
  onError(error);
244
299
  return null;
@@ -261,7 +316,33 @@ export function runStructuredCodexReview(request, options) {
261
316
  return runStructuredProviderReview("codex", request, options);
262
317
  }
263
318
 
264
- export async function runStructuredProviderReview(
319
+ export async function runStructuredProviderReview(provider, request, options = {}) {
320
+ const fallbackProviders = options.enableEngineFallback === true && options.providerReportingSupported === true && options.readOnlyAnalysis !== false && Array.isArray(options.allowedFallbackProviders)
321
+ ? [...new Set(options.allowedFallbackProviders.filter(candidate => ["codex", "claude"].includes(candidate) && candidate !== provider))] : [];
322
+ if (!fallbackProviders.length) return runSingleProviderReview(provider, request, options);
323
+ const deadlineAt = Date.now() + (options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
324
+ const attempts = [];
325
+ for (const candidate of [provider, ...fallbackProviders]) {
326
+ if (options.signal?.aborted) throw new SqmLocalReviewError("Local analysis was cancelled.", { code: "analysis_cancelled" });
327
+ if (Date.now() >= deadlineAt) throw new SqmLocalReviewError("Local analysis timed out.", { code: "analysis_timeout" });
328
+ if (candidate !== provider) {
329
+ await options.beforeDispatch?.(candidate);
330
+ const readiness = await (options.inspectProviderStatus || inspectProviderStatus)(candidate);
331
+ if (readiness.state !== "ready") throw new SqmLocalReviewError("Fallback provider is not signed in and ready.", {
332
+ code: readiness.state === "signed_out" ? "provider_sign_in_required" : "provider_status_unknown" });
333
+ }
334
+ try {
335
+ const result = await runSingleProviderReview(candidate, request, { ...options, deadlineAt,
336
+ beforeDispatch: () => options.beforeDispatch?.(candidate) });
337
+ return { ...result, provider: candidate, provider_attempts: [...attempts, { provider: candidate, code: "completed" }] };
338
+ } catch (error) {
339
+ attempts.push({ provider: candidate, code: error.code });
340
+ if (!["provider_model_unsupported", "codex_local_unavailable", "claude_local_unavailable"].includes(error.code) || candidate === fallbackProviders.at(-1)) throw error;
341
+ }
342
+ }
343
+ }
344
+
345
+ async function runSingleProviderReview(
265
346
  provider,
266
347
  request,
267
348
  {
@@ -273,6 +354,7 @@ export async function runStructuredProviderReview(
273
354
  readOnlyAnalysis = true,
274
355
  terminationGraceMs = 1_000,
275
356
  beforeDispatch = async () => {},
357
+ deadlineAt,
276
358
  } = {},
277
359
  ) {
278
360
  assertProvider(provider);
@@ -295,7 +377,7 @@ export async function runStructuredProviderReview(
295
377
  "Return only JSON that strictly matches the provided output schema.",
296
378
  request.prompt,
297
379
  ].join("\n\n");
298
- const deadline = Date.now() + executionTimeoutMs;
380
+ const deadline = deadlineAt ?? Date.now() + executionTimeoutMs;
299
381
  const models = provider === "codex" ? SQM_CODEX_MODELS : [null];
300
382
  let stdout;
301
383
  let selectedModel;
@@ -18,10 +18,18 @@ export async function ensureSqmSession(
18
18
  startAgentImpl = startAgent,
19
19
  openAuthorizationUrlImpl = openAuthorizationUrl,
20
20
  createAgentShutdownImpl = createAgentShutdown,
21
+ expectedScope,
22
+ signal,
23
+ rejectedToken,
21
24
  } = {},
22
25
  ) {
23
26
  let session = await readSessionImpl(webOrigin);
24
- if (sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) return session;
27
+ const pinnedScope = expectedScope || sqmSessionScope(session, webOrigin);
28
+ signal?.throwIfAborted();
29
+ if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
30
+ assertSqmSessionScope(session, webOrigin, pinnedScope);
31
+ return session;
32
+ }
25
33
 
26
34
  const agent = await startAgentImpl({ requestedPort: 0, webOrigin, quiet: true });
27
35
  const shutdown = createAgentShutdownImpl(agent);
@@ -35,19 +43,41 @@ export async function ensureSqmSession(
35
43
 
36
44
  const deadline = now() + timeoutMs;
37
45
  while (now() < deadline) {
46
+ signal?.throwIfAborted();
38
47
  await sleep(Math.min(500, Math.max(1, deadline - now())));
39
48
  session = await readSessionImpl(webOrigin);
40
- if (sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
49
+ if (session?.token !== rejectedToken && sqmSessionReady(session, webOrigin, now(), isSessionValidImpl)) {
50
+ assertSqmSessionScope(session, webOrigin, pinnedScope);
41
51
  logger.log("SIMY CLI authorization completed. Continuing SQM check.");
42
52
  return session;
43
53
  }
44
54
  }
45
- throw new Error("SIMY authorization timed out. Complete the browser sign-in and rerun the SQM command.");
55
+ const error = new Error("SIMY authorization timed out. Complete the browser sign-in and rerun the SQM command.");
56
+ error.code = "authorization_timeout";
57
+ throw error;
46
58
  } finally {
47
59
  await shutdown();
48
60
  }
49
61
  }
50
62
 
63
+ export function sqmSessionScope(session, webOrigin) {
64
+ return { account_id: session?.account_id || session?.auth_user_id || null,
65
+ organization_id: session?.organization_id || session?.org_id || null,
66
+ device_id: session?.device_id || null, api_base_url: session?.api_base_url || null,
67
+ web_origin: new URL(webOrigin).origin };
68
+ }
69
+
70
+ export function assertSqmSessionScope(session, webOrigin, expected) {
71
+ const current = sqmSessionScope(session, webOrigin);
72
+ for (const field of ["account_id", "organization_id", "device_id", "api_base_url", "web_origin"]) {
73
+ if (expected?.[field] && expected[field] !== current[field]) {
74
+ const error = new Error(`SQM authorization changed its ${field}; resume the original authorized scope.`);
75
+ error.code = "authorization_scope_changed";
76
+ throw error;
77
+ }
78
+ }
79
+ }
80
+
51
81
  function sqmSessionReady(session, webOrigin, now, isSessionValidImpl) {
52
82
  return Boolean(
53
83
  isSessionValidImpl(session, now, webOrigin) &&
@@ -0,0 +1,44 @@
1
+ import { createCodingLoopSnapshot } from "./orchestrator/contract.js";
2
+ import { validateCharter } from "./orchestrator/charter-validation.js";
3
+
4
+ // Reuse the execution charter rather than inventing a second approval policy.
5
+ // These checks inspect local metadata only; model access requires real dispatch.
6
+ export function startupRequirementChecks(request = {}) {
7
+ const checks = [{ key: "model_access", status: "unknown", blocking: false,
8
+ summary: "Signing in does not prove access to the requested model. Access is checked on dispatch without a paid preflight request.",
9
+ action: { id: "review_model_access", type: "inspect_provider", label: "Review provider and model settings" } }];
10
+ if (!String(request.requirement || "").trim()) {
11
+ checks.push({ key: "task_requirements", status: "unknown", blocking: false,
12
+ summary: "Environment check only. Supply the task requirement to check acceptance criteria and design approval.",
13
+ action: { id: "provide_requirement", type: "edit_requirements", label: "Provide task requirements" } });
14
+ return checks;
15
+ }
16
+ const { charter } = createCodingLoopSnapshot({ runId: "preflight", request });
17
+ const findings = validateCharter(charter);
18
+ for (const [key, code, passed, actionId, label] of [
19
+ ["acceptance_criteria", "EXPLICIT_ACCEPTANCE_CRITERIA_REQUIRED", "Acceptance criteria satisfy the task risk policy.", "provide_acceptance_criteria", "Provide explicit acceptance criteria"],
20
+ ["design_review", "DESIGN_REVIEW_REQUIRED", "Design approval satisfies the task risk policy.", "approve_design", "Provide design review and approval evidence"],
21
+ ["required_checks", "REQUIRED_CI_CHECKS_MISSING", "Required checks satisfy the task risk policy.", "provide_required_checks", "Provide CI and security check names"],
22
+ ]) {
23
+ const finding = findings.find(item => item.code === code);
24
+ checks.push({ key, status: finding ? "failed" : "passed", blocking: Boolean(finding),
25
+ summary: finding?.explanation || passed,
26
+ ...(finding ? { code, action: { id: actionId, type: "edit_requirements", label } } : {}) });
27
+ }
28
+ checks.push({ key: "assurance_selection", status: "unknown", blocking: false,
29
+ summary: "The executor must verify the five-level comparison and user selection in existing decision evidence before material implementation.",
30
+ action: { id: "review_assurance_selection", type: "review_decision_evidence", label: "Review assurance level and selection evidence" } });
31
+ checks.push({ key: "external_dependencies", status: "unknown", blocking: false,
32
+ summary: "Confirm test accounts, external resources and permissions required by the acceptance criteria. Local readiness does not certify Cloud entitlement.",
33
+ action: { id: "confirm_external_dependencies", type: "edit_requirements", label: "Confirm external inputs and permissions" } });
34
+ return checks;
35
+ }
36
+
37
+ export function summarizeStartupChecks(checks) {
38
+ const blockers = checks.filter(check => check.status === "failed" && check.blocking !== false);
39
+ const unknown = checks.filter(check => check.status === "unknown");
40
+ return { status: blockers.length ? "blocked" : unknown.length ? "needs_confirmation" : "ready",
41
+ blocker_keys: blockers.map(check => check.key), unknown_keys: unknown.map(check => check.key),
42
+ actions: checks.flatMap(check => check.action ? [{ check_key: check.key, ...check.action }]
43
+ : (check.recovery?.actions || []).map(action => ({ check_key: check.key, ...action }))) };
44
+ }