@awak-app/simy-cli 0.7.49 → 0.7.52

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.7.49",
3
+ "version": "0.7.52",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
2
2
  import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
3
+ import { syncLoopOutcomes } from './sqm/loop-outcome-outbox.js';
3
4
  import { readLocalPhotoSource } from "./local-photos.js";
4
5
  import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
5
6
  import { observePairedNativeIdentities } from './native-provider-identity.js';
@@ -964,6 +965,14 @@ export async function startAgent({
964
965
  apiBaseUrl: requestedSession.api_base_url,
965
966
  token: requestedSession.token,
966
967
  fetchImpl: dependencies.fetch || globalThis.fetch,
968
+ })).catch(() => {}).then(() => syncLoopOutcomes({
969
+ getSession: () => session,
970
+ // Content-free Delivery Loop outcomes; queued on failure, never blocks the heartbeat.
971
+ root: sessionRoot,
972
+ apiOrigin,
973
+ apiBaseUrl: requestedSession.api_base_url,
974
+ token: requestedSession.token,
975
+ fetchImpl: dependencies.fetch || globalThis.fetch,
967
976
  })).catch(() => {});
968
977
  }
969
978
  // Register the device immediately; provider subprocesses must not delay
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.49";
17
+ export const CLI_VERSION = "0.7.52";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -104,6 +104,20 @@ async function readStore(root) {
104
104
  async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
105
105
  function status(state) { return { enabled: state.config.enabled === true, conversations: Object.values(state.config.conversations).map(({ provider, session, sequence }) => ({ provider, session, sequence })), queued: state.outbox.events.length }; }
106
106
  export async function readObservability(root) { return (await readStore(root)).config; }
107
+ // Bound only when a NEW Loop run is persisted. A later sweep must never assign
108
+ // the currently logged-in organization to historical conversations.
109
+ export async function captureLoopOutcomeScope(root) {
110
+ const c = await readObservability(root), a = c.actor;
111
+ if (c.enabled !== true || !validGeneration(c.consentGeneration) || !environmentFromOrigin(a?.origin) ||
112
+ ![a?.accountId, a?.organizationId, a?.deviceId].every(v => typeof v === 'string' && v.length > 0)) return null;
113
+ const { readSession, isSessionValid } = await import('./session-store.js');
114
+ const session = await readSession(a.origin, root);
115
+ if (!isSessionValid(session, Date.now(), a.origin) || session.account_id !== a.accountId ||
116
+ session.organization_id !== a.organizationId || session.device_id !== a.deviceId) return null;
117
+ return { origin: a.origin, accountId: a.accountId, organizationId: a.organizationId,
118
+ deviceId: a.deviceId, consentGeneration: c.consentGeneration };
119
+ }
120
+
107
121
  export async function observabilityStatus(root) { return status(await readStore(root)); }
108
122
  function stateFromRecord(record, now = Date.now()) {
109
123
  const raw = record?.run?.state;
@@ -5,6 +5,7 @@ import { completionContractIdentity } from './purpose-completion-contract.mjs';
5
5
  import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
6
6
  import { MAX_STATE_BYTES } from './state-limits.mjs';
7
7
  import { continueResultCycle, resultEvent, resultFollowThroughInstruction, correctiveFollowupInstruction } from './result-cycle.mjs';
8
+ import { loopStopBefore, recordLoopStop } from './loop-stop-record.mjs';
8
9
  import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from './recovery-incident.mjs';
9
10
  import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
10
11
  import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
@@ -36,7 +37,7 @@ import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
36
37
  import { continuationMode } from './continuation-policy.mjs';
37
38
  import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
38
39
  import { collectMeasurementTransition, retainMeasurementHookInstruction } from './measurement-collector.mjs';
39
- import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled } from './measurement-store.mjs';
40
+ import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled, measurementPolicyPath } from './measurement-store.mjs';
40
41
  const measurementEnabled = async (...args) => {
41
42
  try { const binding = await measurementBinding(...args); return Boolean(binding && measurementCollectionEnabled(...args)); }
42
43
  catch { console.error('Adoption measurement configuration is unreadable; collection is incomplete.'); return false; }
@@ -87,9 +88,8 @@ function applyProviderConsent(record, link) {
87
88
  ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
88
89
  }
89
90
  const readArchiveFence = async file => JSON.parse(await readFile(file, 'utf8')).archived !== false;
90
- async function applyArchiveFence(root, record, readFence = readArchiveFence) {
91
+ async function applyArchiveFence(root, record, readFence = readArchiveFence, file = record && `${recordPath(root, record.provider, record.session)}.archive-fence`) {
91
92
  if (!record) return record;
92
- const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
93
93
  let archived;
94
94
  try { archived = await readFence(file); }
95
95
  catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
@@ -111,15 +111,16 @@ export async function recordProviderArchive(root, provider, session, archived, o
111
111
  await rename(temporary, file);
112
112
  } finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
113
113
  }
114
- async function currentProviderConsent(root, records, readFence = readArchiveFence) {
114
+ async function currentProviderConsent(root, records, readFence = readArchiveFence, recordNames) {
115
115
  const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
116
116
  let archiveNames;
117
117
  try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
118
118
  catch { /* A failed index read falls back to fresh per-record fencing. */ }
119
119
  return Promise.all(records.map(record => {
120
120
  const current = applyProviderConsent(record, links[record.provider] || {});
121
- if (archiveNames && !archiveNames.has(`${basename(recordPath(root, record.provider, record.session))}.archive-fence`)) return current;
122
- return applyArchiveFence(root, current, readFence);
121
+ const name = recordNames?.get(record) || basename(recordPath(root, record.provider, record.session));
122
+ if (archiveNames && !archiveNames.has(`${name}.archive-fence`)) return current;
123
+ return applyArchiveFence(root, current, readFence, join(root, `${name}.archive-fence`));
123
124
  }));
124
125
  }
125
126
  export async function linkedProvider(root, provider) {
@@ -292,11 +293,27 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
292
293
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
293
294
  const previousBytes = stored === null ? null : JSON.stringify(stored);
294
295
  const measurementPriorRunId = previous?.run?.id;
296
+ const priorOutcomeScope = previous?.run?.outcomeScope ? structuredClone(previous.run.outcomeScope) : null;
295
297
  let priorMeasurement, savedMeasurement;
296
298
  try { savedMeasurement = await measurementBinding(root, provider, session); priorMeasurement = savedMeasurement && measurementCollectionEnabled(root, provider, session) ? savedMeasurement : null; } catch { /* Collection never blocks task state. */ }
297
299
  const measurementPrevious = priorMeasurement ? structuredClone(previous) : null;
298
300
  const next = await mutate(previous);
299
301
  if (next) {
302
+ if (next.run?.id && next.run.id !== measurementPriorRunId) {
303
+ // Fixed at enrollment, under the same record lock; missing provenance
304
+ // remains null forever for this run, including explicit backfill.
305
+ const scope = await import('../../../cli-observability.js')
306
+ .then(({ captureLoopOutcomeScope }) => captureLoopOutcomeScope(dirname(root))).catch(() => null);
307
+ let policy;
308
+ try { policy = readState(measurementPolicyPath(root, provider, next.cwd)); } catch { /* Invalid optional consent never blocks task persistence. */ }
309
+ const enrolled = policy?.enabled === true && policy.source === 'field' && policy.loop === 'on' &&
310
+ policy.scope?.origin === scope?.origin && policy.scope?.account === scope?.accountId &&
311
+ policy.scope?.organization === scope?.organizationId && policy.scope?.device === scope?.deviceId;
312
+ if (scope && enrolled) next.run.outcomeScope = { ...scope, policyKey: basename(measurementPolicyPath(root, provider, next.cwd), '.json'),
313
+ policyDigest: createHash('sha256').update(JSON.stringify(policy)).digest('hex') }; else delete next.run.outcomeScope;
314
+ } else if (next.run) {
315
+ if (priorOutcomeScope) next.run.outcomeScope = priorOutcomeScope; else delete next.run.outcomeScope;
316
+ }
300
317
  // Older file_equals drafts used `path`, which an older verifier could
301
318
  // mistake for file_exists. Persist only the distinct exactPath field.
302
319
  for (const criterion of next.run?.managed?.acceptanceCriteria || []) {
@@ -413,7 +430,7 @@ export async function listRecords(root = rootDirectory()) {
413
430
  export function createRecordReader(root = rootDirectory(), { cacheEntries = RECORD_CACHE_LIMIT, cacheBytes = RECORD_CACHE_BYTES } = {}) {
414
431
  if (!Number.isSafeInteger(cacheEntries) || cacheEntries < 1 || cacheEntries > RECORD_CACHE_LIMIT ||
415
432
  !Number.isSafeInteger(cacheBytes) || cacheBytes < 1 || cacheBytes > RECORD_CACHE_BYTES) throw new Error('Invalid record cache limits.');
416
- const cache = new Map();
433
+ const cache = new Map(), validatedNames = new WeakMap();
417
434
  let inFlight = null, closed = false, retainedBytes = 0;
418
435
  const discard = name => {
419
436
  const entry = cache.get(name);
@@ -469,6 +486,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
469
486
  const cached = cache.get(name);
470
487
  if (cached?.key === key) return cached.record;
471
488
  const record = before.size <= BigInt(MAX_STATE_BYTES) ? await readNamedRecord(root, name) : null;
489
+ // A private, validated identity need not be hashed again on every list.
490
+ // Weak keys share the existing record lifetime and cache budgets.
491
+ if (record) validatedNames.set(record, name);
472
492
  // Do not retain a value when a write races the read; the next list must
473
493
  // inspect it again. This also prevents malformed transient JSON sticking.
474
494
  const after = await stat(path, { bigint: true });
@@ -488,7 +508,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
488
508
  if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
489
509
  // Consumers can decorate/mutate their own snapshots without corrupting the
490
510
  // cached records or a simultaneous caller's view.
491
- return currentProviderConsent(root, structuredClone(await inFlight), readCachedFence);
511
+ // Consent/fence projection never mutates private records. Detach after
512
+ // projection so their validated filenames survive through fresh fencing.
513
+ return structuredClone(await currentProviderConsent(root, await inFlight, readCachedFence, validatedNames));
492
514
  },
493
515
  async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); retainedBytes = 0; },
494
516
  };
@@ -2986,10 +3008,12 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2986
3008
  run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
2987
3009
  }
2988
3010
  } else if (event === 'StopFailure') {
3011
+ const stopBefore = loopStopBefore(run);
2989
3012
  run.failures += 1;
2990
3013
  run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
2991
3014
  run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
2992
3015
  run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
3016
+ recordLoopStop(run, { before: stopBefore, failed: true, source: input.turn_id ? `failure:${input.turn_id}` : null, now });
2993
3017
  } else if (event === 'Stop' && !suppressDispatch) {
2994
3018
  // OFF suppresses future Stop blocking; it never interrupts the provider
2995
3019
  // turn. Lease settlement and runtime observation above still run.
@@ -3003,6 +3027,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
3003
3027
  // stable event ID; never dedupe on text (identical text can be a new turn).
3004
3028
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
3005
3029
  if (key && run.lastStopKey === key) return record;
3030
+ const stopBefore = loopStopBefore(run);
3006
3031
  if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
3007
3032
  await verifyAutomaticCriteria(record, now);
3008
3033
  const pending = pendingAcceptance(run);
@@ -3019,6 +3044,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
3019
3044
  run.managed.remainingWork = run.nextStep;
3020
3045
  } else if (pending.length) {
3021
3046
  run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
3047
+ recordLoopStop(run, { before: stopBefore, source: key, now });
3022
3048
  run.lastStopKey = key; notifyState(run, now); return record;
3023
3049
  }
3024
3050
  }
@@ -3069,6 +3095,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
3069
3095
  }
3070
3096
  decision.run.resultCycle = run.resultCycle;
3071
3097
  record.run = decision.run; output = decision.output;
3098
+ recordLoopStop(record.run, { before: stopBefore, source: key, now });
3072
3099
  if (record.runtime && output.decision === 'block') record.runtime.isRunning = true;
3073
3100
  record.run.lastStopKey = key;
3074
3101
  }
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // One bounded, content-free entry per stop the Delivery Loop observes. It is
4
+ // measurement only: it never decides continuation, completion or authority.
5
+ // The kind reads the Loop's own records (fresh checkpoint, wait state, budget
6
+ // transition), never the provider's prose; there is no STATUS: line in a
7
+ // normal conversation.
8
+ export const LOOP_STOP_KINDS = Object.freeze(['claimed_done', 'plan_only', 'question', 'error', 'limit']);
9
+ export const MAX_LOOP_STOPS = 200;
10
+
11
+ const STOP_LIKE = new Set(['stop', 'final_response', 'failure']);
12
+
13
+ // Snapshot the run BEFORE the Loop decides on this stop. A checkpoint counts
14
+ // only when the provider submitted it during the turn that just ended.
15
+ export function loopStopBefore(run, { checkpointFresh = Boolean(run?.checkpoint && run.revision > run.consumedRevision) } = {}) {
16
+ if (!run) return null;
17
+ const ledger = run.resultCycle;
18
+ return {
19
+ state: run.state,
20
+ checkpoint: checkpointFresh && run.checkpoint ? {
21
+ status: run.checkpoint.status, reportedStatus: run.checkpoint.reportedStatus, acceptanceMet: run.checkpoint.acceptanceMet,
22
+ } : null,
23
+ // A run whose earlier stops were observed before this recorder existed
24
+ // must not present its next stop as stop #1.
25
+ priorStops: Boolean(run.lastStopKey || run.continuations > 0 || run.managed?.attempts?.length ||
26
+ (ledger?.events || []).some(row => STOP_LIKE.has(row.kind)) || (ledger?.stops?.entries || []).length),
27
+ };
28
+ }
29
+
30
+ // The Loop's own review: every current acceptance criterion verified. No
31
+ // criteria means it has not judged the stop (null), not that it passed.
32
+ export function loopCriteriaMet(run) {
33
+ const criteria = run?.managed?.acceptanceCriteria;
34
+ if (!Array.isArray(criteria) || !criteria.length) return null;
35
+ const verified = run.managed.verified || {};
36
+ return criteria.every(item => Object.hasOwn(verified, item?.id));
37
+ }
38
+
39
+ export function classifyLoopStop({ before, after, failed = false, question = false }) {
40
+ if (after?.state === 'budget_exhausted' || before?.state === 'active' && after?.state === 'paused') return 'limit';
41
+ if (failed) return 'error';
42
+ const checkpoint = before?.checkpoint;
43
+ if (checkpoint?.status === 'error') return 'error';
44
+ if (checkpoint?.status === 'completed' || checkpoint?.reportedStatus === 'completed' || checkpoint?.acceptanceMet === true) return 'claimed_done';
45
+ if (question || checkpoint?.status === 'blocked' || after?.managed?.userQuestion || after?.managed?.plan?.userQuestion ||
46
+ after?.providerRequest?.kind === 'question') return 'question';
47
+ return 'plan_only';
48
+ }
49
+
50
+ // Append one stop to run.resultCycle.stops. `source` (turn id, attempt id)
51
+ // coalesces a re-delivered observation; it is stored only as a short hash.
52
+ export function recordLoopStop(run, { before, failed = false, question = false, source = null, now = Date.now() }) {
53
+ if (!run || !before) return null;
54
+ const cycle = run.resultCycle ||= { version: 1, events: [], omitted: 0 };
55
+ if (cycle.version !== 1) return null;
56
+ const stops = cycle.stops ||= { version: 1, complete: !before.priorStops, entries: [], omitted: 0 };
57
+ if (stops.version !== 1 || !Array.isArray(stops.entries)) return null;
58
+ const ref = source ? createHash('sha256').update(String(source)).digest('hex').slice(0, 16) : null;
59
+ if (ref && stops.entries.some(entry => entry.ref === ref)) return null;
60
+ const index = stops.entries.length + stops.omitted + 1;
61
+ if (stops.entries.length >= MAX_LOOP_STOPS) { stops.omitted++; return null; }
62
+ const entry = { index, kind: classifyLoopStop({ before, after: run, failed, question }),
63
+ criteria_met: loopCriteriaMet(run), at: new Date(now).toISOString(), ...(ref ? { ref } : {}) };
64
+ stops.entries.push(entry);
65
+ return entry;
66
+ }
@@ -5,6 +5,7 @@ import { bindCompletionArtifacts } from '../session-supervision/completion-artif
5
5
  import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
6
6
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
7
7
  import { continueResultCycle, resultEvent, executeResultCycle, acceptResultEvent } from '../codex-plugins/simy/hooks/result-cycle.mjs';
8
+ import { loopStopBefore, recordLoopStop } from '../codex-plugins/simy/hooks/loop-stop-record.mjs';
8
9
  import { finalResponseRecoveryInstruction } from '../session-supervision/final-response-recovery.js';
9
10
  import { measurementBinding, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
10
11
  import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
@@ -596,6 +597,12 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
596
597
  if (live.state !== 'auto_resuming') {
597
598
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
598
599
  }
600
+ // Measurement only: one content-free stop entry per dispatched turn.
601
+ const stopBefore = !result.busy && dispatched ? loopStopBefore({ ...claimed.run, state: 'active',
602
+ checkpoint: live.checkpoint, revision: live.revision, consumedRevision: claimed.run.revision }) : null;
603
+ const recordStop = target => recordLoopStop(target, { before: stopBefore, source: `attempt:${leaseId}`,
604
+ question: ['provider_question', 'provider_approval'].includes(result.errorCode),
605
+ failed: (result.code !== 0 || result.cancelled) && !['provider_question', 'provider_approval'].includes(result.errorCode) });
599
606
  if (result.busy) {
600
607
  if (result.accessDenied && dispatched) {
601
608
  // The final guard rejected before the provider call. Keep its one-shot approval unused.
@@ -656,6 +663,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
656
663
  const pending = live.managed.acceptanceCriteria.filter(item => !Object.hasOwn(live.managed.verified, item.id));
657
664
  if (pending.length) {
658
665
  live.state = 'verification_pending'; live.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
666
+ recordStop(live);
659
667
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
660
668
  }
661
669
  }
@@ -669,6 +677,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
669
677
  if (result.code !== 0) await continueResultCycle(current, resultEvent(current, {
670
678
  kind: 'failure', origin: 'executor_result', sourceId: result.providerTurnId || leaseId,
671
679
  attemptId: leaseId, resultRef: `attempt:${leaseId}` }));
680
+ recordStop(current.run);
672
681
  current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
673
682
  });
674
683
  // Settling uncertainty must precede unlocking: otherwise another session
@@ -799,12 +799,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
799
799
  for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
800
800
  return [...rows.values()];
801
801
  };
802
+ const firstUsablePage = sessionStatus.initialLoading !== false;
802
803
  sessionStatus = { ...sessionStatus, ...value,
803
804
  sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
804
805
  selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
805
806
  initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt, catalogRevision: ++catalogRevision };
806
807
  sessionRefreshedAt = Date.now();
807
- void publish().catch(() => {});
808
+ void (firstUsablePage || force ? publish() : publishBackground()).catch(() => {});
808
809
  void refreshCatalogAnalysis();
809
810
  };
810
811
  const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
@@ -825,9 +826,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
825
826
  refresh.complete(!failed);
826
827
  sessionStatus = { ...sessionStatus, historyLoading: false,
827
828
  discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
828
- // Publish before PR/network observation and queue maintenance. Neither
829
- // operation owns the first usable catalog or the discovery promise.
830
- void publish().catch(() => {});
829
+ // The first usable page already published without waiting for optional
830
+ // enrichment. Coalesce later pages/completion with background updates.
831
+ void (force ? publish() : publishBackground()).catch(() => {});
831
832
  const enrichment = enrichmentController = new AbortController();
832
833
  const catalog = sessionStatus;
833
834
  sessionEnrichment = boundedDiscovery(async signal => {
@@ -838,7 +839,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
838
839
  if (closed || relay || signal.aborted || !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope || sessionStatus !== catalog) return;
839
840
  sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessions) };
840
841
  await queue;
841
- if (!signal.aborted) void publish().catch(() => {});
842
+ if (!signal.aborted) void (force ? publish() : publishBackground()).catch(() => {});
842
843
  }, { signal: enrichment.signal, timeoutMs: dependencies.enrichmentTimeoutMs ?? 10_000, onDrain: trackDiscoveryDrain })
843
844
  .catch(() => {}).finally(() => { if (enrichmentController === enrichment) { enrichmentController = null; sessionEnrichment = null; } });
844
845
  })
@@ -846,7 +847,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
846
847
  historyLoading: false, discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
847
848
  providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
848
849
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
849
- if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
850
+ if (!closed) setImmediate(() => { void (force ? publish() : publishBackground()).catch(() => {}); }); });
850
851
  }
851
852
  return sessionStatus;
852
853
  };
@@ -1053,17 +1054,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
1053
1054
  setImmediate(() => { void publish().catch(() => {}); });
1054
1055
  } },
1055
1056
  });
1056
- // Batch burst notifications and heartbeat/status requests into one frame. The
1057
- // short window leaves commands responsive and the regular heartbeat intact.
1058
- const publish = () => {
1057
+ // Background producers share the normal heartbeat window. Explicit commands
1058
+ // promote a pending background publication instead of inheriting its delay.
1059
+ const schedulePublication = delay => {
1059
1060
  if (closed || relay || !agent) return Promise.resolve();
1060
- if (!scheduledPublish) {
1061
+ const dueAt = Date.now() + delay;
1062
+ if (scheduledPublish && scheduledPublish.dueAt <= dueAt) return scheduledPublish.promise;
1063
+ let pending = scheduledPublish;
1064
+ if (pending) clearTimeout(pending.timer);
1065
+ else {
1061
1066
  let resolve, reject;
1062
1067
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
1063
- scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
1068
+ pending = { promise, resolve, reject };
1064
1069
  }
1065
- return scheduledPublish.promise;
1070
+ pending.dueAt = dueAt;
1071
+ pending.timer = setTimeout(() => {
1072
+ scheduledPublish = null;
1073
+ projectStatus().then(pending.resolve, pending.reject);
1074
+ }, delay);
1075
+ scheduledPublish = pending;
1076
+ return pending.promise;
1066
1077
  };
1078
+ const publish = () => schedulePublication(75);
1079
+ const publishBackground = () => schedulePublication(3000);
1067
1080
  let lastDeadlineFailureAt = -Infinity;
1068
1081
  let connectionPublishing = null;
1069
1082
  let connectionRevision = 0;
@@ -1254,7 +1267,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
1254
1267
  status.controllerCompletions = retainedCompletions;
1255
1268
  status.controllerEventsVersion = 1;
1256
1269
  status.controllerEvents = preferenceScope ? await notificationJournal.snapshot(preferenceScope) : [];
1257
- const projection = await actionAssociations.beginProjection?.({ records });
1270
+ const projection = await actionAssociations.beginProjection?.({ records,
1271
+ prioritySessionKeys: statisticsSelection.key ? [statisticsSelection.key] : [],
1272
+ scope: { organizationId: status.access?.organizationId, userId: status.auth?.userId } });
1258
1273
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
1259
1274
  // A full record projection owns this receipt. Connection-only heartbeats
1260
1275
  // replay the cached receipt; they must never mint evidence of a fresh read.
@@ -1419,7 +1434,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1419
1434
  deferStartupSynchronization: true, enforceDesktopAccess: true,
1420
1435
  suspendOrganizationAnalyses, resumeOrganizationAnalyses,
1421
1436
  onDesktopStatusChange: () => {
1422
- // Let an in-progress frame finish before publishing its new snapshot.
1437
+ // Connection/auth/task observations must not wait for background metadata.
1423
1438
  setImmediate(() => { void publish().catch(() => {}); });
1424
1439
  },
1425
1440
  });
@@ -205,12 +205,14 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
205
205
  return {
206
206
  // A projection is private to one desktop frame. Mutations and deadline
207
207
  // validation still reread the exact record and current authentication.
208
- async beginProjection({ records: suppliedRecords } = {}) {
208
+ async beginProjection({ records: suppliedRecords, prioritySessionKeys = [], scope } = {}) {
209
209
  const authRevision = await current();
210
210
  const records = suppliedRecords || await list(root);
211
211
  const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
212
212
  for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
213
- return { records, authRevision, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
213
+ const associationNamespace = scope?.organizationId && scope?.userId ? namespaceFor(webOrigin, scope) : null;
214
+ return { records, authRevision, prioritySessionKeys, associationNamespace,
215
+ byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
214
216
  },
215
217
  async validateProjection(projection) {
216
218
  return !closed && await current() === projection.authRevision;
@@ -8,6 +8,7 @@ import {
8
8
  import { associationRevision } from './action-association.js';
9
9
  import { ActionAssociationApiError } from './action-association-api.js';
10
10
  import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
11
+ import { liveSessionWindow, supervisedSession } from './discovery-window.js';
11
12
 
12
13
  const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
13
14
  (candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
@@ -222,16 +223,37 @@ export function createOrdinaryAssociationStore({
222
223
  ]),
223
224
  );
224
225
  const auth = snapshot && Object.hasOwn(snapshot, 'authRevision') ? snapshot.authRevision : await api.authRevision();
226
+ const selected = new Set(snapshot?.prioritySessionKeys || []);
227
+ const keyFor = candidate => `${candidate.provider}:${candidate.session}`;
228
+ const observed = new Set(liveSessionWindow(sessions.filter(candidate => candidate.archived !== true || selected.has(keyFor(candidate))), {
229
+ priority: candidate => {
230
+ const record = records.get(keyFor(candidate));
231
+ // Token renewal changes authRevision without changing the account /
232
+ // organization namespace. Retain those bound reminders for a fresh
233
+ // authorized read; another organization's binding is not a watch.
234
+ const association = record?.actionAssociation;
235
+ const namespace = snapshot?.associationNamespace;
236
+ const scopedBinding = auth && association?.binding?.action && (namespace
237
+ ? association.namespace === namespace : association.authRevision === auth);
238
+ return selected.has(keyFor(candidate)) || candidate.isRunning === true || candidate.autoContinue === true ||
239
+ supervisedSession(record) || Boolean(scopedBinding);
240
+ },
241
+ }).map(keyFor));
225
242
  const taskIds = new Set([...records.values()].map(record => record.run?.managed?.taskId || record.associationTaskId).filter(Boolean));
226
243
  for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
227
244
  const pending = [];
228
245
  for (const candidate of sessions) {
229
- let record;
246
+ const observe = observed.has(keyFor(candidate));
247
+ let record = records.get(keyFor(candidate));
248
+ // Complete history is navigation, not a subscription. Do not create
249
+ // identities or start network discovery for untouched historical rows.
250
+ if (!observe && !record?.associationTaskId && !record?.run?.managed?.taskId) {
251
+ candidate.actionAssociation = { status: 'unavailable', binding: null, candidates: [], canCorrect: false, canReadBinding: false };
252
+ continue;
253
+ }
230
254
  try {
231
- record = await ensure(
232
- candidate,
233
- records.get(`${candidate.provider}:${candidate.session}`) || null,
234
- );
255
+ if (record && record.cwd !== candidate.cwd) throw new ActionAssociationApiError('conflict');
256
+ if (observe) record = await ensure(candidate, record || null);
235
257
  } catch (error) {
236
258
  // A stale workspace or unreadable local record affects only this
237
259
  // optional association. Never rewrite its identity or stop the host.
@@ -244,9 +266,10 @@ export function createOrdinaryAssociationStore({
244
266
  candidate.binding = { ...candidate.binding, taskId };
245
267
  if (record.run?.managed) continue;
246
268
  const cached = views.get(taskId),
247
- context = sessionAssociationContext(candidate),
269
+ context = observe ? sessionAssociationContext(candidate) : null,
248
270
  active = activeConversation(candidate, record);
249
271
  if (
272
+ observe &&
250
273
  auth &&
251
274
  !closed &&
252
275
  !inFlight.has(taskId) &&
@@ -263,7 +286,7 @@ export function createOrdinaryAssociationStore({
263
286
  }
264
287
  const view = views.get(taskId);
265
288
  const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
266
- const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
289
+ const snapshot = context && bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
267
290
  const local = record.actionAssociation;
268
291
  const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
269
292
  const newer = scoped &&
@@ -337,17 +360,25 @@ export function createOrdinaryAssociationStore({
337
360
  },
338
361
  correct(command, candidate) {
339
362
  const work = serial.then(async () => {
340
- const before = await find(candidate);
363
+ let before = await find(candidate);
364
+ const bootstrap = command.action === 'session_action_refresh' && !command.taskId;
365
+ // A user can explicitly inspect history outside the live window. Only
366
+ // that read-only refresh may create its previously unreserved identity;
367
+ // select/unlink still require the exact existing task and revisions.
368
+ if (bootstrap && !before?.run?.managed) {
369
+ if (!await api.authRevision()) throw new ActionAssociationApiError('signed_out');
370
+ before = await ensure(candidate, before || null);
371
+ }
341
372
  if (
342
373
  !before ||
343
- before.associationTaskId !== command.taskId ||
374
+ (!bootstrap && before.associationTaskId !== command.taskId) ||
344
375
  before.run?.managed
345
376
  )
346
377
  throw new ActionAssociationApiError('invalid_request');
347
378
  const auth = await api.authRevision();
348
379
  if (!auth) throw new ActionAssociationApiError('signed_out');
349
380
  await sync(candidate, true);
350
- const view = views.get(command.taskId);
381
+ const view = views.get(bootstrap ? before.associationTaskId : command.taskId);
351
382
  if (
352
383
  !view?.snapshot ||
353
384
  view.auth !== auth ||
@@ -40,13 +40,17 @@ export async function claudePluginFiles() {
40
40
  // Standalone hooks do not ship the CLI capability dependency graph. Freeze the
41
41
  // maintained package version into their generated collector instead.
42
42
  files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs")
43
- .replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`));
43
+ .replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`)
44
+ .replace("'./session-store.js'", "'./session-store.mjs'"));
45
+ files.set("hooks/session-store.mjs", (await readFile(new URL("../session-store.js", import.meta.url), "utf8"))
46
+ .replace('"./web-origin.js"', '"./web-origin.mjs"'));
47
+ files.set("hooks/web-origin.mjs", await readFile(new URL("../web-origin.js", import.meta.url), "utf8"));
44
48
  files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
45
49
  const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
46
50
  files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
47
51
  "const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
48
52
  "import { observeDeliveryRecord } from './cli-observability.mjs';",
49
- ));
53
+ ).replaceAll("'../../../cli-observability.js'", "'./cli-observability.mjs'"));
50
54
  files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
51
55
  files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
52
56
  files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
@@ -63,6 +67,7 @@ export async function claudePluginFiles() {
63
67
  files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
64
68
  files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
65
69
  files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
70
+ files.set("hooks/loop-stop-record.mjs", await readFile(path.join(SOURCE, "hooks/loop-stop-record.mjs"), "utf8"));
66
71
  for (const name of ['measurement-contract', 'measurement-store', 'measurement-snapshot', 'measurement-judge', 'measurement-collector'])
67
72
  files.set(`hooks/${name}.mjs`, await readFile(path.join(SOURCE, `hooks/${name}.mjs`), 'utf8'));
68
73
  files.set("hooks/scene-reasoning.mjs", await readFile(path.join(SOURCE, "hooks/scene-reasoning.mjs"), "utf8"));
@@ -1,6 +1,7 @@
1
1
  import { loadHypothesisKnowledge } from './hypothesis-knowledge.js';
2
2
  import { verifyLocalTestArtifacts } from './local-tests.js';
3
3
  import { sqmConversationIdentity } from './conversation-identity.js';
4
+ import { recordLoopSqmProof, resolveLoopConversationBinding } from './loop-conversation.js';
4
5
  import { safeSqmDiagnostic } from "./diagnostics.js";
5
6
  import { createSqmHistoryStore, createSqmCheckRecorder } from "./history-store.js";
6
7
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -20,6 +21,7 @@ import { ensureSqmSession, sqmSessionScope } from "./session.js";
20
21
  import { formatSqmCoverage } from "./report.js";
21
22
  import { sqmDiagnostic } from "./request-recovery.js";
22
23
  import { withSemanticCancellation } from "./reasoned-assessment.js";
24
+ import { runSqmLoopOutcomeCommand } from "./loop-outcome-command.js";
23
25
 
24
26
  export async function runSqmCommand(argv, options) {
25
27
  try {
@@ -32,6 +34,7 @@ export async function runSqmCommand(argv, options) {
32
34
  }
33
35
 
34
36
  async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoader = loadHypothesisKnowledge }) {
37
+ if (argv[0] === "effect" && argv[1] === "backfill") return await runSqmLoopOutcomeCommand(argv.slice(1));
35
38
  if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
36
39
  printHelp();
37
40
  return 0;
@@ -203,7 +206,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
203
206
  }
204
207
  if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
205
208
  stage = "full_check";
206
- const fullOptions = { ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined };
209
+ // Bound only while this exact conversation has an open Delivery Loop run.
210
+ const loopBinding = await resolveLoopConversationBinding({ provider: conversationProvider, session: providerSession });
211
+ const fullOptions = { ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined,
212
+ conversationRef: loopBinding?.conversationRef ?? null };
207
213
  const full = semanticProvider || common.localTests
208
214
  ? await withSemanticCancellation(signal => runSqmCheck({ ...fullOptions, signal }), { signal: common.signal })
209
215
  : await runSqmCheck(fullOptions);
@@ -227,6 +233,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
227
233
  matcher_diagnostics: result.matcher_diagnostics,
228
234
  }),
229
235
  ]);
236
+ if (loopBinding) {
237
+ try { result.loop_conversation = { recorded: await recordLoopSqmProof(loopBinding, result.proof.proof_id) }; }
238
+ catch { result.loop_conversation = { recorded: false, warning: "loop_record_update_failed" }; }
239
+ }
230
240
  let upload = { uploaded: false, reason: "disabled" };
231
241
  if (!argv.includes("--no-upload")) {
232
242
  const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
@@ -290,6 +300,7 @@ function printHelp() {
290
300
  simy sqm lint --bundle <path>
291
301
  simy sqm replay --bundle <path> --dataset <path>
292
302
  simy sqm knowledge --query <mechanism> [--offset <number>] [--offline] [--host <origin>] --json
303
+ simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
293
304
 
294
305
  knowledge searches the verified scoped catalog using existing authorization only;
295
306
  it never opens sign-in, runs checks or certifies the candidate. Use nextOffset
@@ -304,6 +315,8 @@ Two-stage SQM flow:
304
315
  checks. Saves the storage acknowledgement beside the proof.
305
316
  lint Report broad matchers and missing evaluation gates before publish.
306
317
  replay Evaluate a bundle against labelled historical repository snapshots.
318
+ effect backfill sends content-free Delivery Loop outcomes for the Quality
319
+ Monitor "Adoption effect" page (see "simy sqm effect backfill --help").
307
320
 
308
321
  Options:
309
322
  --base <branch> Compare against this base branch (default: dev)
package/src/sqm/index.js CHANGED
@@ -182,7 +182,7 @@ export async function runSqmCheck(options) {
182
182
  throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
183
183
  }
184
184
  options.signal?.throwIfAborted();
185
- 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, createSigningKey: options.createSigningKey });
185
+ 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, createSigningKey: options.createSigningKey, conversationRef: options.conversationRef ?? null });
186
186
  options.signal?.throwIfAborted();
187
187
  if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
188
188
  return result;
@@ -237,8 +237,8 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
237
237
  });
238
238
  }
239
239
 
240
- async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey }) {
241
- const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion });
240
+ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey, conversationRef = null }) {
241
+ const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion, conversationRef });
242
242
  const signedEvidence = await signProof(proof, { deviceId, accountId, keyPath: signingKeyPath, createSigningKey });
243
243
  const verification = verifySignedEvidence(proof, signedEvidence);
244
244
  return {
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { readRecord, rootDirectory, updateRecord } from "../codex-plugins/simy/hooks/delivery-loop.mjs";
4
+
5
+ // Binds an SQM full check to the Delivery Loop conversation it ran in, without
6
+ // sending conversation content: only `sha256(provider + ":" + session)` leaves
7
+ // the device (contract sqm_loop_outcome.v1 / proof.provenance.conversation_ref).
8
+ export const SQM_LOOP_PROOF_ID_LIMIT = 50;
9
+ export const CONVERSATION_REF = /^sha256:[0-9a-f]{64}$/;
10
+ const PROOF_ID = /^SQM-PROOF-[0-9a-f-]{36}$/i;
11
+ const OPEN_STATES = new Set(["active", "paused", "blocked", "recovery_required", "auto_resuming", "verification_pending"]);
12
+ const PROVIDERS = new Set(["codex", "claude"]);
13
+
14
+ export function sqmConversationRef(provider, session) {
15
+ if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) throw new Error("A provider and exact session ID are required.");
16
+ return `sha256:${createHash("sha256").update(`${provider}:${session}`, "utf8").digest("hex")}`;
17
+ }
18
+
19
+ // Only a conversation whose Delivery Loop run is still open is Loop-managed.
20
+ // Standalone checks, unknown conversations and finished runs get no binding.
21
+ export function loopConversationBinding(record) {
22
+ const run = record?.run;
23
+ if (!run || typeof run.id !== "string" || !run.id || !OPEN_STATES.has(run.state)) return null;
24
+ if (!PROVIDERS.has(record.provider) || typeof record.session !== "string") return null;
25
+ return { provider: record.provider, session: record.session, runId: run.id, conversationRef: sqmConversationRef(record.provider, record.session) };
26
+ }
27
+
28
+ export async function resolveLoopConversationBinding({ provider, session, root = rootDirectory(), readRecordImpl = readRecord } = {}) {
29
+ if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) return null;
30
+ try { return loopConversationBinding(await readRecordImpl(root, provider, session)); }
31
+ catch { return null; } // An unreadable loop record never blocks or tags a check.
32
+ }
33
+
34
+ // Appends a signed proof ID to the same Loop run that was bound when the proof
35
+ // was built. A run that ended or was replaced meanwhile is left unchanged.
36
+ export async function recordLoopSqmProof(binding, proofId, { root = rootDirectory(), updateRecordImpl = updateRecord } = {}) {
37
+ if (!binding?.runId || !PROOF_ID.test(proofId || "")) return false;
38
+ let recorded = false;
39
+ await updateRecordImpl(root, binding.provider, binding.session, record => {
40
+ if (!record?.run || record.run.id !== binding.runId || !OPEN_STATES.has(record.run.state)) return record;
41
+ const current = loopSqmProofIds(record);
42
+ if (!current.includes(proofId)) record.run.sqmProofIds = [...current, proofId].slice(-SQM_LOOP_PROOF_ID_LIMIT);
43
+ recorded = true;
44
+ return record;
45
+ });
46
+ return recorded;
47
+ }
48
+
49
+ // Read side for sqm_loop_outcome.v1.sqm_proof_ids: valid, unique, at most 50,
50
+ // oldest first. Returns [] for records without Loop-bound SQM proofs.
51
+ export function loopSqmProofIds(record) {
52
+ const ids = Array.isArray(record?.run?.sqmProofIds) ? record.run.sqmProofIds : [];
53
+ return [...new Set(ids.filter(id => typeof id === "string" && PROOF_ID.test(id)))].slice(-SQM_LOOP_PROOF_ID_LIMIT);
54
+ }
@@ -0,0 +1,74 @@
1
+ // `simy sqm effect backfill`: send one content-free `sqm_loop_outcome.v1`
2
+ // record for every local Delivery Loop conversation. Past runs have no stop
3
+ // record, so they go as stops_recorded:false with no stops.
4
+ import { defaultSessionRoot, isSessionValid, readSession } from "../session-store.js";
5
+ import { resolveWebOrigin } from "../web-origin.js";
6
+ import { buildLoopOutcomeRecord, summarizeLoopOutcomes } from "./loop-outcome.js";
7
+ import { syncLoopOutcomes } from "./loop-outcome-outbox.js";
8
+
9
+ export function printLoopOutcomeHelp(logger = console) {
10
+ logger.log(`Usage:
11
+ simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
12
+
13
+ backfill reads this machine's Delivery Loop records and sends one record per
14
+ conversation the Loop ran in: provider, outcome, Loop-added turns, tokens and
15
+ timestamps. Conversation text, titles, folders and objectives are never sent;
16
+ the conversation is identified only by a one-way hash. Conversations from
17
+ before stop recording send no stop list. --dry-run prints the counts and sends
18
+ nothing. Sending needs this device's CLI observability consent; without it the
19
+ client sends nothing. Unbound historical runs are never assigned to the current
20
+ organization. Failed sends stay
21
+ queued and the running SIMY agent retries them.`);
22
+ }
23
+
24
+ async function listEntries(root) {
25
+ const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
26
+ return listRecords(`${root}/delivery-hooks`);
27
+ }
28
+
29
+ export async function runSqmLoopOutcomeCommand(argv, { fetchImpl = globalThis.fetch, logger = console, root = defaultSessionRoot(),
30
+ now = Date.now(), entries, session: providedSession } = {}) {
31
+ const [action, ...rest] = argv;
32
+ if (action !== "backfill" || rest.includes("--help")) { printLoopOutcomeHelp(logger); return action === "backfill" ? 0 : 2; }
33
+ const json = rest.includes("--json"), dryRun = rest.includes("--dry-run");
34
+ const host = rest.includes("--host") ? rest[rest.indexOf("--host") + 1] : undefined;
35
+ const all = entries || await listEntries(root);
36
+ const records = [], sources = [], skipped = {};
37
+ for (const entry of all) {
38
+ const built = buildLoopOutcomeRecord(entry, { now });
39
+ if (built.record) { records.push(built.record); sources.push(entry); }
40
+ else if (built.skipped !== "no_run") skipped[built.skipped] = (skipped[built.skipped] || 0) + 1;
41
+ }
42
+ const summary = { dry_run: dryRun, delivery_records: all.length, ...summarizeLoopOutcomes(records, skipped) };
43
+ if (dryRun) {
44
+ logger.log(json ? JSON.stringify(summary, null, 2) : formatSummary(summary, "Read"));
45
+ return 0;
46
+ }
47
+ const webOrigin = resolveWebOrigin(host);
48
+ let session = providedSession ?? await readSession(webOrigin, root);
49
+ if (!providedSession && !isSessionValid(session, now, webOrigin)) session = null;
50
+ if (!session?.token) throw new Error(`Sending needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
51
+ const result = await syncLoopOutcomes({ root, apiOrigin: webOrigin, apiBaseUrl: session.api_base_url,
52
+ token: session.token, fetchImpl, now, entries: sources, minIntervalMs: 0,
53
+ ...(providedSession ? { getSession: () => providedSession } : {}) });
54
+ const failed = Boolean(result.failure);
55
+ const output = { ...summary, sent: result };
56
+ if (json) logger.log(JSON.stringify(output, null, 2));
57
+ else {
58
+ logger.log(formatSummary(summary, "Read"));
59
+ logger.log(result.skipped ? `Not sent: ${result.skipped}.`
60
+ : `Stored: ${result.sent} proven-scope record(s); ${result.pending} queued; ${result.unproven} lack matching enrollment provenance${result.failure ? ` (${result.failure})` : ""}.`);
61
+ }
62
+ return failed ? 1 : 0;
63
+ }
64
+
65
+ function formatSummary(summary, verb) {
66
+ const states = Object.entries(summary.final_states).filter(([, n]) => n).map(([state, n]) => `${state} ${n}`).join(", ") || "none";
67
+ const skipped = Object.entries(summary.skipped).map(([reason, n]) => `${reason} ${n}`).join(", ");
68
+ return [`${verb} ${summary.conversations} Delivery Loop conversation(s) from ${summary.delivery_records} local record(s)${summary.dry_run ? "; nothing was sent (--dry-run)" : ""}.`,
69
+ ` provider: codex ${summary.providers.codex}, claude ${summary.providers.claude}`,
70
+ ` outcome: ${states}`,
71
+ ` Loop-added turns: ${summary.continuations_total}; tokens known for ${summary.tokens_known} (total ${summary.tokens_total})`,
72
+ ` with stop record: ${summary.stops_recorded}`,
73
+ ...(skipped ? [` not sent: ${skipped}`] : [])].join("\n");
74
+ }
@@ -0,0 +1,221 @@
1
+ // Local queue for `sqm_loop_outcome.v1` records (contract 8.1/8.2). The agent
2
+ // sweeps delivery records on its authenticated heartbeat, queues one record
3
+ // per conversation when it ends or reaches a milestone, and drains the queue
4
+ // with backoff. Network I/O never runs inside a delivery-state update, and a
5
+ // missing route (404 before the web relay ships) only leaves records queued.
6
+ import { createHash } from "node:crypto";
7
+ import { isSessionValid } from "../session-store.js";
8
+ import { captureLoopOutcomeScope } from "../cli-observability.js";
9
+ import { readFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ import { acquireLock, atomicSave, readState } from "../codex-plugins/simy/hooks/update-state-store.mjs";
13
+ import { webApiHeaders, webApiUrl } from "../web-api.js";
14
+ import { buildLoopOutcomeRecord, loopOutcomeDigest, loopOutcomeReceiptProblem, loopOutcomeRecordProblem } from "./loop-outcome.js";
15
+
16
+ export const LOOP_OUTCOME_OUTBOX = "sqm-loop-outcome-outbox.json";
17
+ export const LOOP_OUTCOME_ROUTE = "sqm/loop-outcome";
18
+ export const ACTIVE_RECORD_INTERVAL_MS = 10 * 60_000;
19
+ export const CONSENT_RECHECK_MS = 6 * 60 * 60_000;
20
+ // The heartbeat runs every 20s; reading every delivery record is not free.
21
+ export const SWEEP_INTERVAL_MS = 5 * 60_000;
22
+ const MAX_FLUSH = 25;
23
+ const MAX_PENDING = 1000;
24
+ const MAX_BACKOFF_MS = 60 * 60_000;
25
+
26
+ // Keep legacy queues untouched. Each proven enrollment scope has an isolated
27
+ // retry ledger; switching organization or consent generation cannot drain it.
28
+ export const sameLoopOutcomeScope = (a, b) => a && b &&
29
+ ["origin", "accountId", "organizationId", "deviceId", "consentGeneration"].every(k =>
30
+ typeof a[k] === "string" && a[k].length > 0 && a[k] === b[k]);
31
+ export function loopOutcomeScopeRoot(root, scope) {
32
+ if (!sameLoopOutcomeScope(scope, scope)) throw new Error("Loop outcome scope is unproven.");
33
+ const key = [scope.origin, scope.accountId, scope.organizationId, scope.deviceId, scope.consentGeneration];
34
+ return path.join(root, "sqm-loop-outcomes", createHash("sha256").update(JSON.stringify(key)).digest("hex"));
35
+ }
36
+
37
+ export function loopOutcomeCollectionCurrent(root, binding) {
38
+ if (!/^[a-f0-9]{64}$/.test(binding?.policyKey || "") || !/^[a-f0-9]{64}$/.test(binding?.policyDigest || "")) return false;
39
+ try {
40
+ const policy = readState(path.join(root, "delivery-hooks", "measurement-policies", binding.policyKey + ".json"));
41
+ return policy?.enabled === true && policy.source === "field" && policy.loop === "on" &&
42
+ createHash("sha256").update(JSON.stringify(policy)).digest("hex") === binding.policyDigest;
43
+ } catch { return false; }
44
+ }
45
+
46
+ const blank = () => ({ schemaVersion: 1, conversations: {}, pending: {}, retry: { attempts: 0, at: 0 }, consentBlockedUntil: 0 });
47
+
48
+ export async function readLoopOutcomeOutbox(root) {
49
+ try {
50
+ const state = JSON.parse(await readFile(path.join(root, LOOP_OUTCOME_OUTBOX), "utf8"));
51
+ if (state?.schemaVersion === 1 && state.conversations && state.pending && state.retry) return state;
52
+ } catch { /* missing or unreadable: start over; records are re-derived from delivery state */ }
53
+ return blank();
54
+ }
55
+
56
+ async function saveOutbox(root, state) {
57
+ atomicSave(path.join(root, LOOP_OUTCOME_OUTBOX), state);
58
+ }
59
+
60
+ // Reuse the existing exclusive-owner/atomic-save protocol. Retry policy stays
61
+ // local to this async queue; crashed locks are never stolen by elapsed time.
62
+ async function withOutboxLock(root, operation) {
63
+ let release;
64
+ for (let attempt = 0; ; attempt++) {
65
+ try { release = acquireLock(path.join(root, LOOP_OUTCOME_OUTBOX)); break; }
66
+ catch (error) {
67
+ if (error.code !== "state_busy" || attempt >= 120) throw error;
68
+ await new Promise(resolve => setTimeout(resolve, 10));
69
+ }
70
+ }
71
+ try { return await operation(); }
72
+ finally { release(); }
73
+ }
74
+
75
+ async function defaultEntries(root) {
76
+ const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
77
+ return listRecords(path.join(root, "delivery-hooks"));
78
+ }
79
+
80
+ const milestone = (record, entry) =>
81
+ `${record.final_state}:${entry.run?.resultCycle?.stops?.entries?.length || 0}:${record.continuations}`;
82
+
83
+ // Queue changed conversations. An active run is queued at a milestone (new
84
+ // stop, state or turn) at most once per ACTIVE_RECORD_INTERVAL_MS; an ended
85
+ // run is queued whenever its outcome changes.
86
+ export async function collectLoopOutcomes(options = {}) {
87
+ if (!sameLoopOutcomeScope(options.scope, options.scope)) return { queued: 0, pending: 0, skipped: "unproven_scope" };
88
+ const storageRoot = loopOutcomeScopeRoot(options.root, options.scope);
89
+ return withOutboxLock(storageRoot, () => collectLoopOutcomesLocked({ ...options, storageRoot }));
90
+ }
91
+
92
+ async function collectLoopOutcomesLocked({ root, storageRoot, scope, now = Date.now(), entries, minIntervalMs = 0 }) {
93
+ const state = await readLoopOutcomeOutbox(storageRoot);
94
+ if (state.consentBlockedUntil > now) return { queued: 0, pending: Object.keys(state.pending).length, consentRequired: true };
95
+ if (minIntervalMs && Number.isFinite(state.sweptAt) && now - state.sweptAt < minIntervalMs) return { queued: 0, pending: Object.keys(state.pending).length, deferred: true };
96
+ let queued = 0, unproven = 0;
97
+ for (const entry of entries || await defaultEntries(root)) {
98
+ if (!sameLoopOutcomeScope(entry.run?.outcomeScope, scope) || !loopOutcomeCollectionCurrent(root, entry.run?.outcomeScope)) { unproven++; continue; }
99
+ const { record } = buildLoopOutcomeRecord(entry, { now });
100
+ if (!record) continue;
101
+ const ref = record.conversation_ref, previous = state.conversations[ref];
102
+ const digest = loopOutcomeDigest(record), mark = milestone(record, entry);
103
+ if (previous?.digest === digest) continue;
104
+ if (record.final_state === "active" && previous &&
105
+ (previous.milestone === mark || now - previous.enqueuedAt < ACTIVE_RECORD_INTERVAL_MS)) continue;
106
+ state.conversations[ref] = { collection: { policyKey: entry.run.outcomeScope.policyKey, policyDigest: entry.run.outcomeScope.policyDigest }, digest, milestone: mark, enqueuedAt: now, sentAt: previous?.sentAt || null };
107
+ state.pending[ref] = record;
108
+ queued++;
109
+ }
110
+ const refs = Object.keys(state.pending);
111
+ if (refs.length > MAX_PENDING) {
112
+ refs.sort((a, b) => Date.parse(state.pending[a].recorded_at) - Date.parse(state.pending[b].recorded_at));
113
+ for (const ref of refs.slice(0, refs.length - MAX_PENDING)) { delete state.pending[ref]; delete state.conversations[ref]; }
114
+ }
115
+ state.sweptAt = now;
116
+ if (queued || minIntervalMs) await saveOutbox(storageRoot, state);
117
+ return { queued, pending: Object.keys(state.pending).length, unproven };
118
+ }
119
+
120
+ // One POST. Returns stored | consent_required | rejected | failed; never throws.
121
+ export async function sendLoopOutcome({ endpoint, token, record, consentGeneration, fetchImpl = globalThis.fetch }) {
122
+ const problem = loopOutcomeRecordProblem(record);
123
+ if (problem) return { status: "rejected", problem };
124
+ let response;
125
+ try {
126
+ response = await fetchImpl(endpoint, { method: "POST",
127
+ headers: webApiHeaders({ token }, { accept: "application/json", "content-type": "application/json", ...(consentGeneration ? { "x-simy-consent-generation": consentGeneration } : {}) }),
128
+ body: JSON.stringify(record), signal: AbortSignal.timeout(10_000) });
129
+ } catch (error) {
130
+ return { status: "failed", problem: "network", detail: error?.name || "error" };
131
+ }
132
+ const body = await response.json().catch(() => null);
133
+ if (response.status === 403 && (body?.error === "consent_required" || body?.error?.code === "consent_required" || body?.code === "consent_required")) {
134
+ return { status: "consent_required" };
135
+ }
136
+ if ([400, 409, 422].includes(response.status)) return { status: "rejected", problem: `http_${response.status}` };
137
+ if (!response.ok) return { status: "failed", problem: `http_${response.status}` };
138
+ const receiptProblem = loopOutcomeReceiptProblem(response.status, body);
139
+ if (receiptProblem) return { status: "failed", problem: `receipt_${receiptProblem}` };
140
+ return { status: "stored", created: body.created, outcome_id: body.outcome_id };
141
+ }
142
+
143
+ export async function flushLoopOutcomes({ root, scope, authorize, fetchImpl = globalThis.fetch, now = Date.now() }) {
144
+ if (!sameLoopOutcomeScope(scope, scope) || typeof authorize !== "function") return { sent: 0, skipped: "unproven_scope" };
145
+ const storageRoot = loopOutcomeScopeRoot(root, scope);
146
+ const initial = await readLoopOutcomeOutbox(storageRoot);
147
+ if (initial.retry.at > now || initial.consentBlockedUntil > now) return { sent: 0, pending: Object.keys(initial.pending).length, deferred: true };
148
+ const batch = Object.values(initial.pending)
149
+ .sort((a, b) => Date.parse(a.recorded_at) - Date.parse(b.recorded_at)).slice(0, MAX_FLUSH);
150
+ if (!batch.length) return { sent: 0, pending: 0 };
151
+ const results = [];
152
+ for (const record of batch) {
153
+ // Resolve current credentials and consent immediately before EACH send.
154
+ // A changed login/organization/generation leaves this partition untouched.
155
+ if (!loopOutcomeCollectionCurrent(root, initial.conversations[record.conversation_ref]?.collection)) continue;
156
+ const auth = await authorize();
157
+ if (!auth?.token || !sameLoopOutcomeScope(auth.scope, scope)) break;
158
+ const result = await sendLoopOutcome({ endpoint: auth.endpoint, token: auth.token, record, consentGeneration: scope.consentGeneration, fetchImpl });
159
+ results.push([record, result]);
160
+ if (result.status === "failed" || result.status === "consent_required") break;
161
+ }
162
+ // Keep network I/O outside the lock. Reconcile under the same lock as sweeps
163
+ // so another collector cannot overwrite the freshly read state before save.
164
+ return withOutboxLock(storageRoot, () => applyFlushResults(storageRoot, results, now));
165
+ }
166
+
167
+ async function applyFlushResults(root, results, now) {
168
+ const state = await readLoopOutcomeOutbox(root);
169
+ let sent = 0, failure = null;
170
+ for (const [record, result] of results) {
171
+ const ref = record.conversation_ref, current = state.pending[ref];
172
+ const same = current && loopOutcomeDigest(current) === loopOutcomeDigest(record);
173
+ if (result.status === "stored") {
174
+ sent++;
175
+ if (same) delete state.pending[ref];
176
+ if (state.conversations[ref]) state.conversations[ref].sentAt = now;
177
+ } else if (result.status === "rejected") {
178
+ if (same) delete state.pending[ref];
179
+ } else if (result.status === "consent_required") {
180
+ // Quietly stop without erasing the original scope's pending evidence.
181
+ // A new consent generation uses a different partition and cannot replay it.
182
+ state.consentBlockedUntil = now + CONSENT_RECHECK_MS;
183
+ failure = "consent_required";
184
+ } else {
185
+ failure = result.problem;
186
+ }
187
+ }
188
+ if (failure && failure !== "consent_required") {
189
+ state.retry = { attempts: state.retry.attempts + 1, at: now + Math.min(MAX_BACKOFF_MS, 30_000 * 2 ** state.retry.attempts) };
190
+ } else if (!failure) {
191
+ state.retry = { attempts: 0, at: 0 };
192
+ }
193
+ await saveOutbox(root, state);
194
+ return { sent, pending: Object.keys(state.pending).length, ...(failure ? { failure } : {}) };
195
+ }
196
+
197
+ export function loopOutcomeEndpoint({ apiOrigin, apiBaseUrl }) {
198
+ return webApiUrl(LOOP_OUTCOME_ROUTE, { webOrigin: apiOrigin, apiBaseUrl }).href;
199
+ }
200
+
201
+ // Agent heartbeat entry. Same consent as CLI observability: nothing is
202
+ // collected or sent unless this device's observability consent is active.
203
+ export async function syncLoopOutcomes({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now(), entries,
204
+ getSession, minIntervalMs = SWEEP_INTERVAL_MS } = {}) {
205
+ if (!token) return { sent: 0, skipped: "no_token" };
206
+ const authorize = async () => {
207
+ const scope = await captureLoopOutcomeScope(root).catch(() => null);
208
+ const session = getSession ? await getSession() : await import("../session-store.js")
209
+ .then(({ readSession }) => readSession(apiOrigin, root)).catch(() => null);
210
+ if (!scope || !session?.token || !isSessionValid(session, now, apiOrigin) || scope.origin !== apiOrigin ||
211
+ scope.accountId !== session.account_id || scope.organizationId !== session.organization_id ||
212
+ scope.deviceId !== session.device_id) return null;
213
+ const endpoint = loopOutcomeEndpoint({ apiOrigin, apiBaseUrl: session.api_base_url || apiBaseUrl });
214
+ if (new URL(endpoint).origin !== scope.origin) return null;
215
+ return { scope, token: session.token, endpoint };
216
+ };
217
+ const auth = await authorize();
218
+ if (!auth) return { sent: 0, skipped: "no_consent" };
219
+ const collected = await collectLoopOutcomes({ root, scope: auth.scope, now, entries, minIntervalMs });
220
+ return { ...await flushLoopOutcomes({ root, scope: auth.scope, authorize, fetchImpl, now }), unproven: collected.unproven || 0 };
221
+ }
@@ -0,0 +1,141 @@
1
+ // `sqm_loop_outcome.v1`: one content-free record per Delivery Loop
2
+ // conversation for the Quality Monitor "Adoption effect" page. Built only from
3
+ // counts, states and timestamps in the local delivery record; the
4
+ // conversation's text, title, cwd and objective never enter it.
5
+ import { createHash } from "node:crypto";
6
+
7
+ import { LOOP_STOP_KINDS, MAX_LOOP_STOPS } from "../codex-plugins/simy/hooks/loop-stop-record.mjs";
8
+ import { loopSqmProofIds, sqmConversationRef, SQM_LOOP_PROOF_ID_LIMIT } from "./loop-conversation.js";
9
+
10
+ export const LOOP_OUTCOME_CONTRACT = "sqm_loop_outcome.v1";
11
+ export const LOOP_OUTCOME_KEYS = Object.freeze(["contract_version", "conversation_ref", "provider", "model", "loop_started_at",
12
+ "ended_at", "final_state", "continuations", "tokens_used", "stops_recorded", "stops", "sqm_proof_ids", "recorded_at"]);
13
+ export const LOOP_OUTCOME_STOP_KEYS = Object.freeze(["index", "kind", "criteria_met", "at"]);
14
+ export const LOOP_OUTCOME_FINAL_STATES = Object.freeze(["active", "completed", "reported_done", "returned_to_human", "budget_exhausted", "failed"]);
15
+ const PROVIDERS = new Set(["codex", "claude"]);
16
+ const MAX_PROOF_IDS = SQM_LOOP_PROOF_ID_LIMIT;
17
+
18
+ // Contract 8.1. `auto_resuming` and `verification_pending` are the Loop still
19
+ // working on the conversation (not in 8.1's table); any other unknown state is
20
+ // not sent rather than guessed.
21
+ const FINAL_STATE = Object.freeze({
22
+ completed: "completed", reported_done: "reported_done",
23
+ stopped: "returned_to_human", paused: "returned_to_human", blocked: "returned_to_human", recovery_required: "returned_to_human",
24
+ budget_exhausted: "budget_exhausted", failed: "failed",
25
+ active: "active", auto_resuming: "active", verification_pending: "active",
26
+ });
27
+
28
+ export function loopOutcomeFinalState(runState) {
29
+ return Object.hasOwn(FINAL_STATE, runState) ? FINAL_STATE[runState] : null;
30
+ }
31
+
32
+ const iso = value => Number.isFinite(value) && value > 0 ? new Date(value).toISOString() : null;
33
+ const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
34
+
35
+ // Returns { record } or { skipped: <reason> }. Stops are sent only when the
36
+ // recorder observed the run from its first stop (otherwise #1 would not be the
37
+ // first stop); older runs, including every backfilled one, send
38
+ // stops_recorded:false with no stops. Backfill uses this same builder so it
39
+ // never overwrites a conversation's recorded stops with an empty list.
40
+ export function buildLoopOutcomeRecord(entry, { now = Date.now() } = {}) {
41
+ const run = entry?.run;
42
+ if (!run || typeof run !== "object") return { skipped: "no_run" };
43
+ if (!PROVIDERS.has(entry.provider) || typeof entry.session !== "string" || !entry.session) return { skipped: "unsupported_provider" };
44
+ const finalState = loopOutcomeFinalState(run.state);
45
+ if (!finalState) return { skipped: "unknown_state" };
46
+ const startedAt = iso(run.startedAt);
47
+ if (!startedAt) return { skipped: "missing_start" };
48
+ const ledger = run.resultCycle?.stops;
49
+ const stopsRecorded = ledger?.version === 1 && ledger.complete === true && Array.isArray(ledger.entries);
50
+ const stops = stopsRecorded ? ledger.entries.slice(0, MAX_LOOP_STOPS).map(stop => ({
51
+ index: stop.index, kind: stop.kind, criteria_met: stop.criteria_met ?? null, at: stop.at })) : [];
52
+ const model = typeof run.managed?.resumeModel === "string" && run.managed.resumeModel.trim() ? run.managed.resumeModel.trim().slice(0, 100) : null;
53
+ const endedAt = finalState === "active" ? null : iso(run.updatedAt) || new Date(now).toISOString();
54
+ const record = {
55
+ contract_version: LOOP_OUTCOME_CONTRACT,
56
+ conversation_ref: sqmConversationRef(entry.provider, entry.session),
57
+ provider: entry.provider,
58
+ model,
59
+ loop_started_at: startedAt,
60
+ ended_at: endedAt,
61
+ final_state: finalState,
62
+ continuations: count(run.continuations) ?? 0,
63
+ tokens_used: count(run.managed?.tokensUsed),
64
+ stops_recorded: stopsRecorded,
65
+ stops,
66
+ sqm_proof_ids: loopSqmProofIds(entry),
67
+ recorded_at: new Date(now).toISOString(),
68
+ };
69
+ const problem = loopOutcomeRecordProblem(record);
70
+ return problem ? { skipped: `invalid:${problem}` } : { record };
71
+ }
72
+
73
+ const isIso = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/u.test(value) && Number.isFinite(Date.parse(value));
74
+ const sameKeys = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
75
+ Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
76
+
77
+ // Mirrors 8.1 (unknown keys are rejected, value ranges enforced) so a record
78
+ // the backend would refuse never enters the queue.
79
+ export function loopOutcomeRecordProblem(record) {
80
+ if (!sameKeys(record, LOOP_OUTCOME_KEYS)) return "keys";
81
+ if (record.contract_version !== LOOP_OUTCOME_CONTRACT) return "contract_version";
82
+ if (typeof record.conversation_ref !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(record.conversation_ref)) return "conversation_ref";
83
+ if (!PROVIDERS.has(record.provider)) return "provider";
84
+ if (record.model !== null && (typeof record.model !== "string" || !record.model || record.model.length > 100)) return "model";
85
+ if (!isIso(record.loop_started_at)) return "loop_started_at";
86
+ if (record.ended_at !== null && !isIso(record.ended_at)) return "ended_at";
87
+ if (!LOOP_OUTCOME_FINAL_STATES.includes(record.final_state)) return "final_state";
88
+ if ((record.final_state === "active") !== (record.ended_at === null)) return "ended_at";
89
+ if (!Number.isSafeInteger(record.continuations) || record.continuations < 0) return "continuations";
90
+ if (record.tokens_used !== null && (!Number.isSafeInteger(record.tokens_used) || record.tokens_used < 0)) return "tokens_used";
91
+ if (typeof record.stops_recorded !== "boolean") return "stops_recorded";
92
+ if (!Array.isArray(record.stops) || record.stops.length > MAX_LOOP_STOPS) return "stops";
93
+ if (!record.stops_recorded && record.stops.length) return "stops";
94
+ let previous = 0;
95
+ for (const stop of record.stops) {
96
+ if (!sameKeys(stop, LOOP_OUTCOME_STOP_KEYS)) return "stops";
97
+ if (!Number.isSafeInteger(stop.index) || stop.index <= previous) return "stops";
98
+ if (!LOOP_STOP_KINDS.includes(stop.kind)) return "stops";
99
+ if (![true, false, null].includes(stop.criteria_met)) return "stops";
100
+ if (!isIso(stop.at)) return "stops";
101
+ previous = stop.index;
102
+ }
103
+ if (!Array.isArray(record.sqm_proof_ids) || record.sqm_proof_ids.length > MAX_PROOF_IDS ||
104
+ new Set(record.sqm_proof_ids).size !== record.sqm_proof_ids.length ||
105
+ record.sqm_proof_ids.some(id => typeof id !== "string" || !/^SQM-PROOF-[0-9a-f-]{36}$/iu.test(id))) return "sqm_proof_ids";
106
+ if (!isIso(record.recorded_at)) return "recorded_at";
107
+ return null;
108
+ }
109
+
110
+ // 8.2: 201 { outcome_id, created: true, recorded_at } or 200 { ..., created: false }.
111
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
112
+ export function loopOutcomeReceiptProblem(status, body) {
113
+ if (![200, 201].includes(status)) return "status";
114
+ if (!body || typeof body !== "object" || Array.isArray(body)) return "body";
115
+ if (typeof body.outcome_id !== "string" || !UUID.test(body.outcome_id)) return "outcome_id";
116
+ if (typeof body.created !== "boolean" || body.created !== (status === 201)) return "created";
117
+ if (!isIso(body.recorded_at)) return "recorded_at";
118
+ return null;
119
+ }
120
+
121
+ // Change detection for the queue: everything but the creation time.
122
+ export function loopOutcomeDigest(record) {
123
+ const { recorded_at: _recordedAt, ...rest } = record;
124
+ return createHash("sha256").update(JSON.stringify(rest)).digest("hex");
125
+ }
126
+
127
+ // Content-free tallies for `backfill --dry-run`.
128
+ export function summarizeLoopOutcomes(records, skipped = {}) {
129
+ const finalStates = Object.fromEntries(LOOP_OUTCOME_FINAL_STATES.map(state => [state, 0]));
130
+ const providers = { codex: 0, claude: 0 };
131
+ let continuations = 0, tokensKnown = 0, tokens = 0, stopsRecorded = 0;
132
+ for (const record of records) {
133
+ finalStates[record.final_state]++; providers[record.provider]++;
134
+ continuations += record.continuations;
135
+ if (record.tokens_used !== null) { tokensKnown++; tokens += record.tokens_used; }
136
+ if (record.stops_recorded) stopsRecorded++;
137
+ }
138
+ return { conversations: records.length, providers, final_states: finalStates,
139
+ continuations_total: continuations, tokens_known: tokensKnown, tokens_total: tokens,
140
+ stops_recorded: stopsRecorded, skipped };
141
+ }
package/src/sqm/proof.js CHANGED
@@ -37,7 +37,8 @@ export function verifyMinCheck(value) {
37
37
  return value;
38
38
  }
39
39
 
40
- export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString() }) {
40
+ export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString(), conversationRef = null }) {
41
+ if (conversationRef !== null && !/^sha256:[0-9a-f]{64}$/.test(conversationRef)) throw new Error("SQM conversation reference is invalid.");
41
42
  const enforcingModules = evaluation.modules.filter((module) => module.enforcement === "blocking");
42
43
  const enforcingFindings = findings.filter((finding) => finding.enforcement === "blocking");
43
44
  const statuses = enforcingModules.flatMap((module) => module.scenarios.map((scenario) => scenario.status));
@@ -91,7 +92,9 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
91
92
  ...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
92
93
  ],
93
94
  },
94
- provenance: { cli_version: cliVersion, executed_at: executedAt, executor: "simy-cli" },
95
+ // conversation_ref is set only for checks inside a Delivery Loop conversation
96
+ // and is covered by the proof digest and per-run signature.
97
+ provenance: { cli_version: cliVersion, executed_at: executedAt, executor: "simy-cli", ...(conversationRef ? { conversation_ref: conversationRef } : {}) },
95
98
  };
96
99
  return { ...proof, integrity: { algorithm: "sha256", digest: taggedDigest(proof) } };
97
100
  }