@awak-app/simy-cli 0.5.83 → 0.5.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.83",
3
+ "version": "0.5.84",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.83";
16
+ export const CLI_VERSION = "0.5.84";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,3 +1,7 @@
1
+ import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
2
+ import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
3
+ import { openCodexAppServer } from './session-supervision/codex-app-server.js';
4
+ import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
1
5
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
2
6
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
3
7
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
@@ -89,12 +93,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
89
93
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
90
94
  const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
91
95
  let actionAssociations = makeActionAssociations();
96
+ const statisticsCache = new Map();
92
97
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
93
98
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
94
99
  const setAccessSnapshot = async access => {
95
100
  const previousScope = accessScope(currentAccess);
96
101
  currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
97
102
  if (previousScope !== accessScope(currentAccess)) {
103
+ statisticsCache.clear();
98
104
  discoveryController?.abort();
99
105
  sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
100
106
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
@@ -246,6 +252,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
246
252
  await actionAssociations.decorateSessions?.(sessions, projection);
247
253
  const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
248
254
  for (const session of sessions) {
255
+ session.statistics = statisticsCache.get(`${session.provider}:${session.session}`) || unavailableStatistics();
256
+ session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
249
257
  const context = await actionAssociations.deadlineContext?.(session, projection);
250
258
  if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
251
259
  else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
@@ -457,6 +465,21 @@ export async function startDesktopHost({ input = process.stdin, output = process
457
465
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
458
466
  if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
459
467
 
468
+ if (action === "session_statistics") {
469
+ const candidate = await selectedSession(command, { preferObserved: true });
470
+ let statistics;
471
+ if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate);
472
+ else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate);
473
+ else {
474
+ const client = await openCodexAppServer();
475
+ try { statistics = await readCodexStatistics(client, candidate); } finally { await client.close(); }
476
+ }
477
+ await authorizeWork(commandScope);
478
+ statisticsCache.clear(); // Only the selected conversation needs chart data.
479
+ statisticsCache.set(`${candidate.provider}:${candidate.session}`, statistics);
480
+ emit({ type: "result", id: command.id, ok: true });
481
+ void publish(); return;
482
+ }
460
483
  if (action === "my_ai_session") {
461
484
  const ticket = await agent.createDesktopWebSession();
462
485
  await authorizeWork(commandScope);
@@ -15,10 +15,15 @@ export async function readSqmSettings(root, origin, session) {
15
15
  if (!path) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] };
16
16
  try {
17
17
  const settings = JSON.parse(await readFile(path, 'utf8'));
18
- return { enabled: settings.enabled === true, enable_engine_fallback: settings.enable_engine_fallback === true,
19
- allowed_fallback_providers: Array.isArray(settings.allowed_fallback_providers)
20
- ? [...new Set(settings.allowed_fallback_providers.filter(provider => ['codex', 'claude'].includes(provider)))] : [] };
21
- } catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] }; throw error; }
18
+ // Desktop analysis is automatic. Legacy UI opt-outs no longer gate it.
19
+ if (settings?.policyVersion !== 2) return defaultSqmSettings();
20
+ const providers = Array.isArray(settings.allowed_fallback_providers) ? settings.allowed_fallback_providers : [];
21
+ return {
22
+ enabled: settings.enabled === true,
23
+ enable_engine_fallback: settings.enable_engine_fallback === true,
24
+ allowed_fallback_providers: [...new Set(providers.filter(provider => ['codex', 'claude'].includes(provider)))],
25
+ };
26
+ } catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return defaultSqmSettings(); throw error; }
22
27
  }
23
28
  export async function writeSqmEnabled(root, origin, session, enabled) {
24
29
  return writeSqmSettings(root, origin, session, { enabled });
@@ -35,6 +40,10 @@ async function writeSqmSettings(root, origin, session, update) {
35
40
  const current = await readSqmSettings(root, origin, session);
36
41
  await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
37
42
  const temporary = `${path}.${randomUUID()}.tmp`;
38
- await writeFile(temporary, JSON.stringify({ ...current, ...update }), { mode: 0o600 });
43
+ await writeFile(temporary, JSON.stringify({ ...current, ...update, policyVersion: 2 }), { mode: 0o600 });
39
44
  await rename(temporary, path);
40
45
  }
46
+
47
+ function defaultSqmSettings() {
48
+ return { enabled: true, enable_engine_fallback: true, allowed_fallback_providers: ['codex', 'claude'] };
49
+ }
@@ -1,3 +1,4 @@
1
+ import { statisticsFromHandle, unavailableStatistics } from './statistics.js';
1
2
  import { constants } from 'node:fs';
2
3
  import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
@@ -97,7 +98,7 @@ async function latestCustomTitle(handle, size, id) {
97
98
  return title;
98
99
  }
99
100
 
100
- async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
101
+ async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false) {
101
102
  const id = fileName.slice(0, -'.jsonl'.length);
102
103
  if (!UUID.test(id)) return null;
103
104
  const project = join(root, projectName);
@@ -139,13 +140,14 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
139
140
  }
140
141
  if (!metadata) return null;
141
142
  const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
143
+ if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id);
142
144
  if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
143
145
  return candidate;
144
146
  } catch { metadataCache.delete(path); return null; }
145
147
  finally { await handle?.close(); }
146
148
  }
147
149
 
148
- async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, signal } = {}) {
150
+ async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
149
151
  if (signal?.aborted) return [];
150
152
  const root = await projectsRoot(home);
151
153
  if (!root) return [];
@@ -167,9 +169,9 @@ async function collect({ home = homedir(), limit = 100, session = null, includeC
167
169
  const id = file.name.slice(0, -'.jsonl'.length);
168
170
  if (session && id !== session) continue;
169
171
  inspected++;
170
- const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata);
172
+ const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata, includeStatistics);
171
173
  if (!candidate) continue;
172
- const key = `${candidate.session}\0${candidate.cwd}${includeContext ? `\0${entry.name}\0${file.name}` : ''}`;
174
+ const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${entry.name}\0${file.name}` : ''}`;
173
175
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
174
176
  }
175
177
  if (inspected >= MAX_TRANSCRIPTS) break;
@@ -230,3 +232,9 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
230
232
  }
231
233
  return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
232
234
  }
235
+
236
+ export async function readClaudeStatistics(candidate, { home } = {}) {
237
+ if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
238
+ const matches = (await collect({ home, session: candidate.session, includeStatistics: true })).filter(item => item.cwd === candidate.cwd);
239
+ return matches.length === 1 ? matches[0].statistics : unavailableStatistics();
240
+ }
@@ -0,0 +1,14 @@
1
+ // Never associate by repository or the AI that executed an analysis. A result
2
+ // belongs under a conversation only when the original provider/session match.
3
+ export function conversationSqmChecks(candidate, checks) {
4
+ return checks.filter(check => check.conversationProvider === candidate.provider && check.providerSession === candidate.session &&
5
+ !['unavailable', 'interrupted'].includes(check.state) && (check.state !== 'failed' || ['failed', 'preliminary_failed'].includes(check.outcome) && (!check.errorStage || check.errorStage === 'evidence_upload'))).sort((a, b) => (b.finishedAt || b.startedAt || '').localeCompare(a.finishedAt || a.startedAt || '')).slice(0, 5).map(check => ({
6
+ id: check.key, state: check.state, checkedAt: check.finishedAt || check.startedAt,
7
+ scope: check.phase === 'min' ? 'Quick review of the changed work' : 'Review of the changed work and its checks',
8
+ result: check.state === 'running' ? 'Checking this work now.' : check.state === 'verification_pending'
9
+ ? 'Some checks still need a person to confirm the result.' : check.outcome === 'preliminary_failed'
10
+ ? 'The quick review found issues that need attention.' : check.outcome === 'failed' ? 'The review found issues that need attention.' : check.outcome === 'passed'
11
+ ? check.noApplicableChecks ? 'No applicable checks were recorded for this work.' : check.phase === 'min' ? 'The quick review found no blocking issues.' : 'The recorded checks passed.' : 'The review finished. Open the recorded result for its coverage.',
12
+ checked: check.checked || [], findings: check.findings ?? null,
13
+ }));
14
+ }
@@ -0,0 +1,102 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, realpath } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative, isAbsolute, sep } from 'node:path';
5
+
6
+ const HOUR = 3_600_000;
7
+ export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], activity: [] });
8
+
9
+ // Only usage envelopes and timestamps leave this reader, never message text.
10
+ // Work time is an estimate from recorded activity, not a provider billing metric.
11
+ export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
12
+ const events = [], usage = new Map();
13
+ let cumulative = 0, missing = truncated, matched = false;
14
+ for (const line of text.split('\n')) {
15
+ if (!line.trim()) continue;
16
+ let row;
17
+ try { row = JSON.parse(line); } catch { missing = true; continue; }
18
+ if (provider === 'codex' && row.type === 'session_meta') {
19
+ if (row.payload?.id !== session) return unavailableStatistics();
20
+ matched = true;
21
+ }
22
+ if (provider === 'claude') {
23
+ if (row.sessionId !== session || row.isSidechain === true) continue;
24
+ matched = true;
25
+ }
26
+ const at = Date.parse(row.timestamp);
27
+ if (!Number.isFinite(at) || at > now) continue;
28
+ const active = provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
29
+ : ['response_item', 'event_msg'].includes(row.type) && row.payload?.type !== 'token_count';
30
+ if (active) events.push(at);
31
+ if (provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
32
+ const total = row.payload.info?.total_token_usage?.total_tokens;
33
+ if (!Number.isSafeInteger(total) || total < 0) continue;
34
+ if (total < cumulative) { missing = true; cumulative = total; continue; }
35
+ const delta = total - cumulative; cumulative = total;
36
+ const key = `codex:${at}:${total}`;
37
+ if (delta > 0 || !usage.has(key)) usage.set(key, { at, tokens: delta });
38
+ } else if (provider === 'claude' && row.type === 'assistant' && row.message?.id) {
39
+ const u = row.message.usage;
40
+ if (!u || ![u.input_tokens, u.output_tokens].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
41
+ // Cached input is separate in Claude. Count it once with uncached input.
42
+ if (![u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
43
+ const tokens = u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
44
+ if (!Number.isSafeInteger(tokens) || tokens < 0) continue;
45
+ const previous = usage.get(row.message.id);
46
+ if (!previous || tokens > previous.tokens) usage.set(row.message.id, { at, tokens });
47
+ }
48
+ }
49
+ if (!matched) return unavailableStatistics();
50
+ const timestamps = [...events, ...[...usage.values()].map(x => x.at)].sort((a, b) => a - b);
51
+ if (!timestamps.length) return unavailableStatistics();
52
+ const start = Math.max(timestamps[0], now - 7 * 24 * HOUR);
53
+ const end = now;
54
+ const hours = new Map();
55
+ for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
56
+ for (const item of usage.values()) {
57
+ if (item.at < start || item.at > end) continue;
58
+ const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
59
+ bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
60
+ }
61
+ const activity = [];
62
+ for (const at of events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
63
+ const previous = activity.at(-1);
64
+ if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
65
+ else activity.push({ start: at, end: at });
66
+ }
67
+ const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
68
+ const elapsedMs = end - start;
69
+ return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
70
+ tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
71
+ activeMs, idleMs: Math.max(0, elapsedMs - activeMs), utilization: elapsedMs ? activeMs / elapsedMs : null,
72
+ activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
73
+ }
74
+
75
+ export async function statisticsFromHandle(handle, info, provider, session, options) {
76
+ const maximum = 32 * 1024 * 1024;
77
+ // Keep the prefix for identity and cumulative usage. Never invent a baseline
78
+ // from a truncated tail or claim the unobserved remainder as zero.
79
+ const buffer = Buffer.alloc(Math.min(info.size, maximum));
80
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
81
+ const after = await handle.stat();
82
+ return transcriptStatistics(buffer.subarray(0, bytesRead).toString('utf8'), provider, session,
83
+ { ...options, truncated: info.size > maximum || after.size !== info.size || after.mtimeMs !== info.mtimeMs });
84
+ }
85
+
86
+ export async function readCodexStatistics(client, candidate, { home = process.env.CODEX_HOME || join(homedir(), '.codex'), ...options } = {}) {
87
+ const response = await client.request('thread/read', { threadId: candidate.session, includeTurns: false });
88
+ const thread = response?.thread;
89
+ if (thread?.id !== candidate.session || thread.cwd !== candidate.cwd || typeof thread.path !== 'string') return unavailableStatistics();
90
+ let handle;
91
+ try {
92
+ const root = await realpath(home), path = await realpath(thread.path), rel = relative(root, path);
93
+ if (isAbsolute(rel) || rel.startsWith(`..${sep}`) || !['sessions', 'archived_sessions'].includes(rel.split(sep)[0])) return unavailableStatistics();
94
+ const before = await lstat(thread.path);
95
+ if (!before.isFile() || before.isSymbolicLink()) return unavailableStatistics();
96
+ handle = await open(thread.path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
97
+ const info = await handle.stat();
98
+ if (info.ino !== before.ino || info.dev !== before.dev || await realpath(thread.path) !== path) return unavailableStatistics();
99
+ return await statisticsFromHandle(handle, info, 'codex', candidate.session, options);
100
+ } catch { return unavailableStatistics(); }
101
+ finally { await handle?.close(); }
102
+ }
@@ -1,3 +1,4 @@
1
+ import { sqmConversationIdentity } from './conversation-identity.js';
1
2
  import { safeSqmDiagnostic } from "./diagnostics.js";
2
3
  import { createSqmCheckRecorder } from "./history-store.js";
3
4
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -40,7 +41,10 @@ export async function runSqmCommand(argv, { cliVersion }) {
40
41
  const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
41
42
  if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
42
43
  const endpoint = process.env.SIMY_SQM_BUNDLE_URL || (validSession?.api_base_url ? new URL("sqm/knowledge-bundle", validSession.api_base_url).href : undefined);
44
+ const { providerSession, conversationProvider } = await sqmConversationIdentity({ session: option(argv, "--session"), provider: option(argv, "--provider") });
45
+ let checkedDescriptions = [];
43
46
  const common = {
47
+ onCheckedDescriptions: descriptions => { checkedDescriptions = descriptions; },
44
48
  cwd: process.cwd(),
45
49
  baseBranch: option(argv, "--base") || "dev",
46
50
  cliVersion,
@@ -53,10 +57,11 @@ export async function runSqmCommand(argv, { cliVersion }) {
53
57
  deviceId,
54
58
  };
55
59
 
56
- const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
57
- phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
60
+ const recorder = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
61
+ providerSession, conversationProvider, phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
62
+ const save = result => recorder(result, { checked: checkedDescriptions });
58
63
  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 });
64
+ providerSession, conversationProvider, phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
60
65
  common.bundleDependencies = { onRecoveryEvent };
61
66
  common.onRecoveryEvent = onRecoveryEvent;
62
67
  let authenticationAttempted = false;
@@ -0,0 +1,35 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { defaultSessionRoot } from '../session-store.js';
4
+ import { join } from 'node:path';
5
+ import { listRecords, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
+
7
+ const exec = promisify(execFile);
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export async function sqmConversationIdentity({ session, provider, env = process.env, cwd = process.cwd(), root = defaultSessionRoot(),
10
+ records, ancestorPids, inspectProcess = processIdentity } = {}) {
11
+ if (!session && UUID.test(env.CODEX_THREAD_ID || '')) { session = env.CODEX_THREAD_ID; provider = 'codex'; }
12
+ if (UUID.test(session || '') && ['codex', 'claude'].includes(provider)) return { providerSession: session, conversationProvider: provider };
13
+ if (process.platform === 'win32' && !ancestorPids) return {};
14
+ // Claude does not export a session ID. Resolve only a live provider process
15
+ // in this command's ancestor chain, never a similar title or repository.
16
+ try {
17
+ const ancestors = ancestorPids || [];
18
+ if (!ancestorPids) {
19
+ let pid = process.ppid;
20
+ for (let depth = 0; pid > 1 && depth < 16; depth++) {
21
+ ancestors.push(pid);
22
+ const { stdout } = await exec('ps', ['-o', 'ppid=', '-p', String(pid)], { timeout: 1000 });
23
+ const parent = Number(stdout.trim());
24
+ if (!Number.isSafeInteger(parent) || parent === pid) break;
25
+ pid = parent;
26
+ }
27
+ }
28
+ const candidates = (records || await listRecords(join(root, 'delivery-hooks'))).filter(record =>
29
+ record.cwd === cwd && UUID.test(record.session || '') && ['codex', 'claude'].includes(record.provider) && ancestors.includes(record.process?.pid));
30
+ const verified = [];
31
+ for (const candidate of candidates) if (candidate.process?.identity && await inspectProcess(candidate.process.pid) === candidate.process.identity) verified.push(candidate);
32
+ if (verified.length === 1) return { providerSession: verified[0].session, conversationProvider: verified[0].provider };
33
+ } catch { /* Standalone or ambiguous commands have no conversation tag. */ }
34
+ return {};
35
+ }
@@ -76,11 +76,14 @@ function metadata(entry) {
76
76
  if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
77
77
  if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
78
78
  const item = {};
79
- for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl"]) {
79
+ for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl", "providerSession", "conversationProvider", "outcome"]) {
80
80
  if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
81
81
  }
82
82
  if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
83
83
  if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
84
+ if (typeof entry.noApplicableChecks === 'boolean') item.noApplicableChecks = entry.noApplicableChecks;
85
+ if (Array.isArray(entry.checked)) item.checked = entry.checked.filter(value => typeof value === 'string').slice(0, 10).map(value => value.slice(0, 160));
86
+ if (Number.isSafeInteger(entry.findings) && entry.findings >= 0) item.findings = entry.findings;
84
87
  const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
85
88
  if (Object.keys(providerDiagnostic).length) item.providerDiagnostic = providerDiagnostic;
86
89
  return item;
@@ -95,12 +98,12 @@ export function projectSqmHistory(items, live = { activeCount: 0, items: [] }) {
95
98
  sqmChecks: { activeCount: 0, items: rows(items.filter(item => item.kind === "check")) } };
96
99
  }
97
100
 
98
- export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, onError = () => console.warn("Could not save the local SQM check history.") }) {
101
+ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, providerSession, conversationProvider, onError = () => console.warn("Could not save the local SQM check history.") }) {
99
102
  if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return async () => {};
100
103
  const history = createSqmHistoryStore({ root });
101
104
  const key = `sqm-check:${randomUUID()}`;
102
105
  const startedAt = new Date().toISOString();
103
- return async result => {
106
+ return async (result, presentation = {}) => {
104
107
  const proof = result.proof, identity = proof?.repository_identity || result.repository_identity || result.min_check?.repository_identity;
105
108
  const repository = identity?.repository || result.repository;
106
109
  const outcome = proof?.outcome.status || result.min_check?.status || result.status;
@@ -119,7 +122,10 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, on
119
122
  : state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
120
123
  : `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.`;
121
124
  try {
122
- await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, finishedAt: new Date().toISOString(), repository,
125
+ await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, providerSession, conversationProvider, outcome,
126
+ checked: result.checked_descriptions || presentation.checked || [],
127
+ noApplicableChecks: (proof?.outcome.reasons || minCheck.coverage_reasons || []).includes('no_applicable_rules'),
128
+ findings: (proof?.findings || result.preliminary_findings || minCheck.preliminary_findings || []).length, finishedAt: new Date().toISOString(), repository,
123
129
  label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
124
130
  instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
125
131
  result: [summary, ...minWarnings].join("\n"),
package/src/sqm/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sqmConversationIdentity } from './conversation-identity.js';
1
2
  import { sqmCoverageWarnings } from "./report.js";
2
3
  import { safeSqmDiagnostic } from "./diagnostics.js";
3
4
  import { createSqmCheckRecorder } from "./history-store.js";
@@ -31,12 +32,14 @@ export function createSqmChecker({
31
32
  executeKnowledgeBundleImpl,
32
33
  ...dependencies
33
34
  }) {
35
+ let conversationIdentity;
36
+ const getConversationIdentity = () => conversationIdentity ||= sqmConversationIdentity({ cwd, root: historyRoot });
34
37
  let pinnedLoad = null;
35
38
  let pinnedRepository = null;
36
39
  let latestMinCheck = null;
37
40
  let pinnedSystemScope;
38
41
  const onRecoveryEvent = async (diagnostic) => {
39
- await createSqmCheckRecorder({ root: historyRoot,
42
+ await createSqmCheckRecorder({ ...await getConversationIdentity(), root: historyRoot,
40
43
  scope: historyRoot ? { webOrigin, accountId, organizationId } : null,
41
44
  phase: diagnostic.stage === "evidence_upload" ? "full" : "min", cwd, baseBranch })({ status: "failed", diagnostic });
42
45
  await dependencies.onRecoveryEvent?.(diagnostic);
@@ -98,7 +101,7 @@ export function createSqmChecker({
98
101
  }
99
102
  };
100
103
  const checker = async (attempt, options = {}) => {
101
- const save = createSqmCheckRecorder({ root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
104
+ const save = createSqmCheckRecorder({ ...await getConversationIdentity(), root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
102
105
  try {
103
106
  const result = await checkCurrentRepository(attempt, options);
104
107
  await save(result); return result;
@@ -126,6 +129,7 @@ export async function runSqmMinCheck(options) {
126
129
  const context = await loadContext(options);
127
130
  if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
128
131
  const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
132
+ await options.onCheckedDescriptions?.(checkedDescriptions(evaluation.modules, context.loaded.bundle));
129
133
  return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
130
134
  }
131
135
 
@@ -219,6 +223,7 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
219
223
  min_check: minCheck,
220
224
  findings: evaluation.findings,
221
225
  executions: evaluation.modules,
226
+ checked_descriptions: checkedDescriptions(evaluation.modules, bundle),
222
227
  matcher_diagnostics: evaluation.matcherDiagnostics || [],
223
228
  proof,
224
229
  signed_evidence: signedEvidence,
@@ -228,7 +233,15 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
228
233
  }
229
234
 
230
235
  function toLegacyCheck(minCheck, loaded) {
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 };
236
+ return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, checked_descriptions: checkedDescriptions(minCheck.matched_modules, loaded.bundle), warnings: [...loaded.warnings, ...(minCheck.warnings || [])], bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
237
+ }
238
+
239
+ // Display descriptions come from the verified knowledge bundle. They are
240
+ // separate from the signed proof/min-check contract and never certify coverage.
241
+ function checkedDescriptions(executions, bundle) {
242
+ const matched = new Set(executions.map(module => module.module_id));
243
+ return (bundle.modules || []).filter(module => matched.has(module.module_id || module.id))
244
+ .map(module => module.invariant?.statement).filter(value => typeof value === 'string');
232
245
  }
233
246
 
234
247
  function bundleIdentity(bundle) {