@awak-app/simy-cli 0.7.45 → 0.7.46

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.45",
3
+ "version": "0.7.46",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.45";
17
+ export const CLI_VERSION = "0.7.46";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -34,6 +34,12 @@ import { associationContract, validateAssociationResult } from './action-associa
34
34
  import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
35
35
  import { continuationMode } from './continuation-policy.mjs';
36
36
  import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
37
+ import { collectMeasurementTransition, retainMeasurementHookInstruction } from './measurement-collector.mjs';
38
+ import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled } from './measurement-store.mjs';
39
+ const measurementEnabled = async (...args) => {
40
+ try { const binding = await measurementBinding(...args); return Boolean(binding && measurementCollectionEnabled(...args)); }
41
+ catch { console.error('Adoption measurement configuration is unreadable; collection is incomplete.'); return false; }
42
+ };
37
43
 
38
44
  export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
39
45
  const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification', 'ConfigChange', 'PostModelSwitch']);
@@ -279,11 +285,15 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
279
285
  // Recovery requires positive process-death evidence and a serialized second
280
286
  // observation. Unknown/legacy owners are never reclaimed by age.
281
287
  const lockToken = await waitForStateLock(lock, inspectProcess);
282
- let temporary, observation, result;
288
+ let temporary, observation, result, measurementObservation;
283
289
  try {
284
290
  const stored = await read(file, MAX_STATE_BYTES);
285
291
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
286
292
  const previousBytes = stored === null ? null : JSON.stringify(stored);
293
+ const measurementPriorRunId = previous?.run?.id;
294
+ let priorMeasurement, savedMeasurement;
295
+ try { savedMeasurement = await measurementBinding(root, provider, session); priorMeasurement = savedMeasurement && measurementCollectionEnabled(root, provider, session) ? savedMeasurement : null; } catch { /* Collection never blocks task state. */ }
296
+ const measurementPrevious = priorMeasurement ? structuredClone(previous) : null;
287
297
  const next = await mutate(previous);
288
298
  if (next) {
289
299
  // Older file_equals drafts used `path`, which an older verifier could
@@ -295,6 +305,12 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
295
305
  }
296
306
  }
297
307
  let serialized = JSON.stringify(next);
308
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) {
309
+ // Optional raw capture cannot consume the executor's state budget.
310
+ if (next.measurementObservation) Object.assign(next.measurementObservation, { response: null, responseComplete: false, prompt: null, promptComplete: false });
311
+ if (next.run?.managed?.measurementProviderResult) Object.assign(next.run.managed.measurementProviderResult, { response: null, responseComplete: false });
312
+ serialized = JSON.stringify(next);
313
+ }
298
314
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
299
315
  serialized = JSON.stringify(next);
300
316
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
@@ -317,9 +333,31 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
317
333
  // Runtime identity is revalidated by the caller that has a fresh direct
318
334
  // observation. Persistence alone is never treated as execution evidence.
319
335
  observation = { ...next, runtime: null };
336
+ let enrollProspectively = false;
337
+ try { enrollProspectively = (next.run?.id !== measurementPriorRunId || !next.run && !savedMeasurement) && measurementPolicyEnabled(root, provider, next.cwd); } catch { /* Collection never blocks task state. */ }
338
+ if (priorMeasurement || enrollProspectively)
339
+ measurementObservation = { previous: measurementPrevious, priorRunId: measurementPriorRunId, current: next, trace: priorMeasurement?.trace };
320
340
  }
321
341
  result = next;
322
342
  } finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
343
+ if (measurementObservation) {
344
+ const { previous, priorRunId, current } = measurementObservation;
345
+ // Artifact reads run outside the task lock. A concurrent stop or new turn
346
+ // invalidates the snapshot instead of delaying a control or inventing S1.
347
+ const stamp = value => JSON.stringify([value?.run?.id, value?.run?.revision, value?.run?.controlRevision,
348
+ value?.run?.continuations, value?.run?.managed?.executionAttempt, value?.measurementObservation?.id,
349
+ value?.run?.managed?.measurementProviderResult?.id, value?.autoContinue, value?.providerArchived]);
350
+ const expected = stamp(current);
351
+ const validateCapture = async () => stamp(await read(file, MAX_STATE_BYTES)) === expected;
352
+ let trace = measurementObservation.trace;
353
+ try {
354
+ if (await validateCapture()) trace = (await enrollMeasurementFromPolicy({ root, previous: { run: { id: priorRunId } }, current }))?.trace || trace;
355
+ if (trace) await collectMeasurementTransition({ root, previous, current, trace, validateCapture });
356
+ } catch {
357
+ try { await appendMeasurement({ root, provider, session, trace, type: 'gap', key: `capture:${createHash('sha256').update(expected).digest('hex')}`, data: { reason: 'capture_failed' } }); }
358
+ catch { console.error('Adoption measurement journal unavailable; collection is incomplete.'); }
359
+ }
360
+ }
323
361
  // Do not wait on process inspection or the collector while holding the
324
362
  // delivery state lock. A matching fresh identity is required for start.
325
363
  if (observation) void (async () => {
@@ -2786,6 +2824,18 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2786
2824
  return record;
2787
2825
  }
2788
2826
  record.observedAt = now; record.lastEvent = event;
2827
+ if (await measurementEnabled(root, provider, input.session_id)) {
2828
+ // Claude's hook has no stable turn identifier. Preserve it as unknown
2829
+ // rather than deduplicating separate identical assistant messages.
2830
+ if (['Stop', 'StopFailure', 'UserPromptSubmit', 'Interrupt', 'PermissionRequest'].includes(event))
2831
+ record.measurementObservation = { id: input.turn_id || input.tool_use_id || randomUUID(), event, at: now,
2832
+ stable: Boolean(input.turn_id || input.tool_use_id), hookActive: input.stop_hook_active === true,
2833
+ response: typeof input.last_assistant_message === 'string' ? input.last_assistant_message.slice(0, 65536) : null,
2834
+ responseComplete: typeof input.last_assistant_message === 'string' && input.last_assistant_message.length <= 65536,
2835
+ prompt: event === 'UserPromptSubmit' && typeof input.prompt === 'string' ? input.prompt.slice(0, 65536) : null,
2836
+ promptComplete: event === 'UserPromptSubmit' && typeof input.prompt === 'string' && input.prompt.length <= 65536,
2837
+ origin: typeof input.origin?.kind === 'string' ? input.origin.kind.slice(0, 80) : null };
2838
+ }
2789
2839
  if (provider === 'claude' && identity && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event)) {
2790
2840
  // Optional model/mode observations are not complete permission evidence.
2791
2841
  // Only an explicit empty task AND wakeup registry proves this boundary.
@@ -3043,6 +3093,10 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
3043
3093
  current.run.state !== observed.run.state) return {};
3044
3094
  output.reason += '\n' + perspectiveInstruction(memory);
3045
3095
  }
3096
+ if (output.decision === 'block') {
3097
+ try { await retainMeasurementHookInstruction({ root, record: observed, text: output.reason }); }
3098
+ catch { console.error('Adoption measurement instruction context unavailable; collection is incomplete.'); }
3099
+ }
3046
3100
  return output;
3047
3101
  }
3048
3102
  export function canReplanDelivery(run, provider, now = Date.now()) {
@@ -0,0 +1,130 @@
1
+ import { appendMeasurement, measurementBinding, measurementDirectory, measurementEventForSource, measurementCollectionEnabled, retainMeasurementArtifact } from './measurement-store.mjs';
2
+ import { measurementDigest } from './measurement-contract.mjs';
3
+ import { captureMeasurementSnapshot } from './measurement-snapshot.mjs';
4
+ import { judgeMeasurementStop, readFrozenMeasurementJudge } from './measurement-judge.mjs';
5
+ import { join } from 'node:path';
6
+
7
+ const terminal = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
8
+ // Called after state persistence, outside the task lock. The caller rechecks
9
+ // the exact task/turn before and after artifact capture. It cannot authorize work.
10
+ export async function collectMeasurementTransition({ root, previous, current, trace, capture = captureMeasurementSnapshot, validateCapture = async () => true }) {
11
+ if (!current) return;
12
+ const binding = await measurementBinding(root, current.provider, current.session, trace);
13
+ if (!binding) return;
14
+ if (!measurementCollectionEnabled(root, current.provider, current.session, binding.trace)) return;
15
+ const directory = measurementDirectory(root, current.provider, current.session, binding.trace);
16
+ if (!current.run && binding.loop === 'off' && binding.task === measurementDigest(`passive:${current.session}`))
17
+ current = { ...current, run: { id: `passive:${current.session}`, state: 'observing', continuations: 0 } };
18
+ if (!current.run) return;
19
+ const run = current.run, prior = previous?.run?.id === run.id ? previous.run : null;
20
+ const append = (type, key, data, at) => appendMeasurement({ root, provider: current.provider, session: current.session, trace: binding.trace, type, key, data, at });
21
+ if (binding.task !== measurementDigest(run.managed?.taskId || run.id)) {
22
+ await append('gap', `foreign_run:${run.id}`, { reason: 'history_incomplete' }); return;
23
+ }
24
+ const priorResults = new Set(prior?.resultCycle?.events?.map(row => row.id) || []);
25
+ const hook = current.measurementObservation;
26
+ const newHook = hook && JSON.stringify(hook) !== JSON.stringify(previous?.measurementObservation);
27
+ const results = (run.resultCycle?.events || []).filter(row => !(newHook && ['Stop', 'StopFailure'].includes(hook.event) && row.kind === 'stop'));
28
+ if (newHook && ['Stop', 'StopFailure'].includes(hook.event)) {
29
+ results.push({ id: `turn:${hook.id}`, kind: hook.event === 'StopFailure' ? 'failure' : 'stop', at: hook.at, stable: hook.stable });
30
+ if (!hook.stable) await append('gap', `hook_identity:${hook.id}`, { reason: 'unknown_stop_identity' }, hook.at);
31
+ }
32
+ if (newHook && hook.event === 'UserPromptSubmit') {
33
+ const event = await append('user_input', hook.id, { input: measurementDigest(hook.id) }, hook.at);
34
+ await retainMeasurementArtifact(join(directory, 'inputs', `${event.id}.json`), { schemaVersion: 1,
35
+ source: 'provider_user_role_hook', origin: hook.origin || null, prompt: hook.prompt || null, complete: hook.promptComplete === true });
36
+ }
37
+ if (newHook && ['Interrupt', 'PermissionRequest'].includes(hook.event)) await append('boundary', hook.id, { boundary: hook.event === 'Interrupt' ? 'user_stop' : 'permission' }, hook.at);
38
+ if (current.providerArchived && !previous?.providerArchived) await append('boundary', `archive:${run.id}`, { boundary: 'archive' });
39
+ if (current.autoContinue === false && previous?.autoContinue === true) await append('boundary', `disconnect:${run.controlRevision}`, { boundary: 'disconnect' });
40
+ if (run.managed?.nativeWait && JSON.stringify(run.managed.nativeWait) !== JSON.stringify(prior?.managed?.nativeWait))
41
+ await append('boundary', `native:${measurementDigest(run.managed.nativeWait)}`, { boundary: 'permission' });
42
+ if (run.managed?.userQuestion && run.managed.userQuestion !== prior?.managed?.userQuestion)
43
+ await append('boundary', `question:${measurementDigest(run.managed.userQuestion)}`, { boundary: 'question' });
44
+ if (prior?.managed && JSON.stringify(run.managed?.approvalScopes) !== JSON.stringify(prior.managed.approvalScopes))
45
+ await append('boundary', `scope:${run.controlRevision}:${run.revision}`, { boundary: 'scope_change' });
46
+ const providerResult = run.managed?.measurementProviderResult;
47
+ if (providerResult && providerResult.id !== prior?.managed?.measurementProviderResult?.id)
48
+ results.push({ id: `turn:${providerResult.turnId || providerResult.id}`, kind: providerResult.success ? 'stop' : 'failure', at: providerResult.at, stable: Boolean(providerResult.turnId) });
49
+ for (const result of results) {
50
+ if (priorResults.has(result.id) || !['stop', 'final_response', 'failure', 'checkpoint'].includes(result.kind)) continue;
51
+ if (await measurementEventForSource({ root, provider: current.provider, session: current.session, trace: binding.trace, type: 'stop', key: result.id })) continue;
52
+ // A structured checkpoint is not a natural provider turn end. The
53
+ // distinction is retained so it cannot become an S1 counterfactual.
54
+ const spec = await readFrozenMeasurementJudge(directory);
55
+ let snapshot = null;
56
+ try {
57
+ if (!await validateCapture()) throw new Error('Task changed before snapshot.');
58
+ snapshot = await capture({ directory, cwd: run.executionCwd || current.cwd, requiredPaths: spec?.checks.map(check => check.path) || [],
59
+ context: { objective: run.objective || null, originalContext: run.managed?.purpose?.originalContext || null, checkpoint: run.checkpoint || null,
60
+ providerResponse: newHook ? hook.response || null : providerResult?.response || run.managed?.finalResponseWithoutCheckpoint || null,
61
+ responseComplete: newHook ? hook.responseComplete === true : providerResult?.responseComplete === true } });
62
+ if (!await validateCapture()) throw new Error('Task changed during snapshot.');
63
+ }
64
+ catch { snapshot = null; await append('gap', `snapshot:${result.id}`, { reason: 'snapshot_unavailable' }, result.at); }
65
+ const stop = await append('stop', result.id, { snapshot: snapshot?.id || null, candidate: snapshot?.candidate || null,
66
+ natural: result.kind === 'stop' ? result.stable === false ? null : true : result.kind === 'checkpoint' ? false : null,
67
+ stop_kind: result.kind === 'failure' ? 'error' : 'unknown', influenced: (prior?.continuations || 0) > 0 ? true : null }, result.at);
68
+ if (snapshot && spec) await judgeMeasurementStop({ root, provider: current.provider, session: current.session, trace: binding.trace, stop, spec });
69
+ }
70
+ if (run.continuations > (prior?.continuations || 0)) {
71
+ await append('instruction', `continuation:${run.continuations}`, { decision: measurementDigest(run.managed?.decision?.id || `continuation:${run.continuations}`), instruction_type: 'unknown', dispatched: null }, run.updatedAt);
72
+ if (run.continuations - (prior?.continuations || 0) > 1) await append('gap', `continuations:${run.continuations}`, { reason: 'history_incomplete' }, run.updatedAt);
73
+ }
74
+ const attempt = run.managed?.executionAttempt, oldAttempt = prior?.managed?.executionAttempt;
75
+ if (attempt?.id && (!oldAttempt || oldAttempt.id !== attempt.id || oldAttempt.phase !== attempt.phase) && ['claimed', 'dispatching'].includes(attempt.phase)) {
76
+ await append('execution', `${attempt.id}:${attempt.phase}`, { attempt: measurementDigest(attempt.id), phase: attempt.phase, decision: attempt.decisionId ? measurementDigest(attempt.decisionId) : null, provider_turn: attempt.providerTurnId ? measurementDigest(attempt.providerTurnId) : null }, attempt.dispatchedAt || attempt.claimedAt);
77
+ }
78
+ if (attempt?.id && attempt.startedAt && (oldAttempt?.id !== attempt.id || !oldAttempt.startedAt)) {
79
+ await append('execution', `${attempt.id}:started`, { attempt: measurementDigest(attempt.id), phase: 'started', decision: attempt.decisionId ? measurementDigest(attempt.decisionId) : null, provider_turn: attempt.providerTurnId ? measurementDigest(attempt.providerTurnId) : null }, attempt.startedAt);
80
+ const instruction = await append('instruction', `attempt:${attempt.id}`, { decision: measurementDigest(attempt.decisionId || attempt.id), instruction_type: 'unknown', dispatched: true }, attempt.startedAt);
81
+ await retainMeasurementArtifact(join(directory, 'instructions', `${instruction.id}.json`), { schemaVersion: 1, source: 'controller_dispatch_context',
82
+ objective: run.objective || null, nextStep: run.nextStep || null, action: attempt.action || null, decision: run.managed?.decision || null });
83
+ }
84
+ for (const receipt of run.managed?.attempts || []) {
85
+ if ((prior?.managed?.attempts || []).some(row => row.id === receipt.id && row.outcome === receipt.outcome)) continue;
86
+ if (!['failed', 'cancelled', 'busy', 'checkpoint'].includes(receipt.outcome)) continue;
87
+ await append('execution', `${receipt.id}:${receipt.outcome}`, { attempt: measurementDigest(receipt.id), phase: receipt.outcome, decision: receipt.decisionId ? measurementDigest(receipt.decisionId) : null, provider_turn: attempt?.id === receipt.id && attempt.providerTurnId ? measurementDigest(attempt.providerTurnId) : null }, receipt.endedAt || receipt.finishedAt || receipt.at);
88
+ if (!['busy', 'cancelled'].includes(receipt.outcome) && run.managed?.lastUsage?.attemptId !== receipt.id)
89
+ await append('cost', `unavailable:${receipt.id}`, { attempt: measurementDigest(receipt.id), tokens: null, turns: null, amount_micros: null, currency: 'unknown', basis: 'unavailable' }, receipt.finishedAt || receipt.at);
90
+ }
91
+ const usage = run.managed?.lastUsage;
92
+ if (usage?.attemptId && JSON.stringify(usage) !== JSON.stringify(prior?.managed?.lastUsage)) {
93
+ await append('cost', `usage:${usage.attemptId}`, { attempt: measurementDigest(usage.attemptId), tokens: Number.isSafeInteger(usage.tokens) ? usage.tokens : null,
94
+ turns: null, amount_micros: null, currency: 'unknown', basis: 'provider_usage' });
95
+ }
96
+ if (run.stopReason?.actor === 'user' && JSON.stringify(run.stopReason) !== JSON.stringify(prior?.stopReason)) {
97
+ await append('boundary', `control:${run.controlRevision}:${run.stopReason.at}`, { boundary: run.state === 'paused' ? 'user_pause' : 'user_stop' }, run.stopReason.at);
98
+ }
99
+ if (terminal.has(run.state) && prior?.state !== run.state) await append('run_finished', `terminal:${run.state}:${run.controlRevision || 0}:${run.revision}`, { state: run.state }, run.updatedAt);
100
+ }
101
+
102
+ export async function retainMeasurementHookInstruction({ root, record, text }) {
103
+ if (!record?.run || typeof text !== 'string') return;
104
+ const binding = await measurementBinding(root, record.provider, record.session);
105
+ if (!binding || binding.task !== measurementDigest(record.run.managed?.taskId || record.run.id) ||
106
+ !measurementCollectionEnabled(root, record.provider, record.session, binding.trace)) return;
107
+ const event = await measurementEventForSource({ root, provider: record.provider, session: record.session, trace: binding.trace,
108
+ type: 'instruction', key: `continuation:${record.run.continuations}` });
109
+ if (!event) return;
110
+ await retainMeasurementArtifact(join(measurementDirectory(root, record.provider, record.session, binding.trace), 'instructions', `${event.id}.json`), {
111
+ schemaVersion: 1, source: 'generated_hook_instruction', emitted: null, text: text.slice(0, 65536), complete: text.length <= 65536,
112
+ });
113
+ }
114
+
115
+ export async function recordMeasurementSqm({ root, provider, session, trace, result }) {
116
+ if (!provider || !session || !result?.proof) return;
117
+ const binding = await measurementBinding(root, provider, session, trace);
118
+ if (!binding || !measurementCollectionEnabled(root, provider, session, binding.trace)) return;
119
+ const proof = result.proof, findings = proof.findings || result.findings || [];
120
+ await retainMeasurementArtifact(join(measurementDirectory(root, provider, session, binding.trace), 'sqm-proofs', `${measurementDigest(proof.proof_id)}.json`), {
121
+ schemaVersion: 1, proof, signedEvidence: result.signed_evidence || null,
122
+ });
123
+ const id = finding => measurementDigest(finding.finding_id || finding.id || finding);
124
+ await appendMeasurement({ root, provider, session, trace: binding.trace, type: 'sqm', key: proof.proof_id,
125
+ data: { proof: measurementDigest(proof.proof_id), candidate: measurementDigest(proof.repository_identity),
126
+ outcome: ['passed', 'failed', 'requires_human', 'requires_post_deploy', 'skipped'].includes(proof.outcome?.status) ? proof.outcome.status : 'unavailable',
127
+ findings: [...new Set(findings.map(id))], blocking: [...new Set(findings.filter(f => f.enforcement === 'blocking').map(id))],
128
+ advisory: [...new Set(findings.filter(f => f.enforcement !== 'blocking').map(id))],
129
+ scenarios: (proof.executions || []).reduce((sum, row) => sum + (row.scenarios?.length || 0), 0) } });
130
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const measurementDigest = value => createHash('sha256').update(typeof value === 'string' || value instanceof Uint8Array ? value : JSON.stringify(value)).digest('hex');
4
+ export const MEASUREMENT_VERSION = 'adoption_measurement.v1';
5
+ // Cloud transport is deliberately content-free. Artifacts and judge specifications
6
+ // remain local; every reference below is a digest, never a path or conversation.
7
+ export const MEASUREMENT_FIELDS = Object.freeze({
8
+ run_started: { loop: ['on', 'off'], cohort: 'hash', repository: 'nullable_repository', runtime: 'nullable_hash', collector: 'hash' },
9
+ stop: { snapshot: 'nullable_hash', candidate: 'nullable_hash', natural: 'nullable_boolean', stop_kind: ['unknown', 'claimed_done', 'plan_only', 'question', 'error', 'limit'], influenced: 'nullable_boolean' },
10
+ instruction: { decision: 'hash', instruction_type: ['unknown', 'boilerplate', 'informative', 'owner_wait'], dispatched: 'nullable_boolean' },
11
+ execution: { attempt: 'hash', phase: ['claimed', 'dispatching', 'started', 'finished', 'failed', 'cancelled', 'busy', 'checkpoint'], decision: 'nullable_hash', provider_turn: 'nullable_hash' },
12
+ sqm: { proof: 'hash', candidate: 'hash', outcome: ['passed', 'failed', 'requires_human', 'requires_post_deploy', 'skipped', 'unavailable'], findings: 'hashes', blocking: 'hashes', advisory: 'hashes', scenarios: 'integer' },
13
+ sqm_attempt: { attempt: 'hash', phase: ['min', 'full'], outcome: ['passed', 'failed', 'unavailable', 'unknown'], candidate: 'nullable_hash', diagnostic: 'nullable_hash' },
14
+ judge: { stop: 'hash', snapshot: 'hash', judge: 'hash', scope: ['artifact_predicates', 'task_acceptance'], satisfied: 'boolean', passed: 'integer', total: 'integer', violations: 'hashes' },
15
+ stop_label: { stop: 'hash', reviewer: 'hash', stop_kind: ['claimed_done', 'plan_only', 'question', 'error', 'limit'], influenced: 'boolean' },
16
+ instruction_label: { instruction: 'hash', reviewer: 'hash', instruction_type: ['boilerplate', 'informative', 'owner_wait'] },
17
+ resolution: { finding: 'hash', first_proof: 'hash', final_proof: 'nullable_hash', actor: ['controller', 'human', 'unresolved', 'unknown'], evidence: 'hash', reviewer: 'hash' },
18
+ gate: { gate: ['premature_finish', 'after_stop', 'permission', 'scope', 'resume_stopped', 'candidate_identity', 'execution_evidence', 'gap_disclosure', 'correct_recheck', 'pass_handling', 'handoff'], outcome: ['passed', 'failed', 'unknown'], evidence: 'hash', reviewer: 'hash' },
19
+ user_input: { input: 'hash' },
20
+ boundary: { boundary: ['user_stop', 'user_pause', 'permission', 'question', 'scope_change', 'archive', 'disconnect'] },
21
+ cost: { attempt: 'hash', tokens: 'nullable_integer', turns: 'nullable_integer', amount_micros: 'nullable_integer', currency: ['unknown', 'USD', 'JPY'], basis: ['provider_usage', 'billing', 'unavailable'] },
22
+ run_finished: { state: ['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'] },
23
+ gap: { reason: ['capture_failed', 'snapshot_unavailable', 'late_enrollment', 'unknown_stop_identity', 'usage_unavailable', 'journal_error', 'history_incomplete'] },
24
+ });
25
+ const hashPattern = /^[a-f0-9]{64}$/;
26
+ function fieldValid(value, type) {
27
+ if (Array.isArray(type)) return type.includes(value);
28
+ if (type.startsWith('nullable_')) return value === null || fieldValid(value, type.slice(9));
29
+ if (type === 'hash') return typeof value === 'string' && hashPattern.test(value);
30
+ if (type === 'repository') return typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,100}\/[a-zA-Z0-9_.-]{1,100}$/.test(value);
31
+ if (type === 'hashes') return Array.isArray(value) && value.length <= 512 && new Set(value).size === value.length && value.every(item => fieldValid(item, 'hash'));
32
+ if (type === 'boolean') return typeof value === 'boolean';
33
+ return type === 'integer' && Number.isSafeInteger(value) && value >= 0;
34
+ }
35
+ export function validateMeasurementEvent(event) {
36
+ const keys = ['version', 'trace', 'provider', 'session', 'task', 'sequence', 'source', 'layer', 'type', 'at', 'data', 'id'];
37
+ const fields = Object.hasOwn(MEASUREMENT_FIELDS, event?.type) ? MEASUREMENT_FIELDS[event.type] : null;
38
+ if (!event || Object.keys(event).length !== keys.length || keys.some(key => !Object.hasOwn(event, key)) || !fields ||
39
+ event.version !== MEASUREMENT_VERSION || !['codex', 'claude'].includes(event.provider) ||
40
+ !['field', 'fixture'].includes(event.source) || ![null, 'N', 'S', 'F', 'W', 'X', 'B'].includes(event.layer) ||
41
+ !['trace', 'session', 'task', 'id'].every(key => fieldValid(event[key], 'hash')) ||
42
+ !Number.isSafeInteger(event.sequence) || event.sequence < 1 ||
43
+ typeof event.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(event.at) || !Number.isFinite(Date.parse(event.at)) || new Date(event.at).toISOString() !== event.at ||
44
+ !event.data || typeof event.data !== 'object' || Array.isArray(event.data) ||
45
+ Object.keys(event.data).length !== Object.keys(fields).length ||
46
+ Object.entries(fields).some(([key, type]) => !fieldValid(event.data[key], type))) throw new Error('Invalid adoption measurement event.');
47
+ const { id, ...unsigned } = event;
48
+ if (id !== measurementDigest(unsigned) || Buffer.byteLength(JSON.stringify(event)) > 65536) throw new Error('Adoption measurement identity mismatch.');
49
+ if (event.type === 'judge' && (event.data.total < 1 || event.data.passed > event.data.total || event.data.satisfied !== (event.data.passed === event.data.total && event.data.violations.length === 0))) throw new Error('Invalid independent judgement.');
50
+ return event;
51
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, isAbsolute } from 'node:path';
3
+ import { measurementDigest } from './measurement-contract.mjs';
4
+ import { appendMeasurement, measurementDirectory } from './measurement-store.mjs';
5
+ import { readMeasurementSnapshot } from './measurement-snapshot.mjs';
6
+
7
+ // Declarative, held-out predicates only. Never execute a command supplied by a
8
+ // provider or let a judge create the very output it is supposed to evaluate.
9
+ export function validateMeasurementJudge(spec) {
10
+ if (spec?.version !== 'artifact_judge.v1' || Object.keys(spec).some(key => !['version', 'checks', 'scope'].includes(key)) ||
11
+ ![undefined, 'artifact_predicates', 'task_acceptance'].includes(spec.scope) ||
12
+ !Array.isArray(spec.checks) || !spec.checks.length || spec.checks.length > 256) throw new Error('Invalid held-out judge.');
13
+ const ids = new Set();
14
+ for (const check of spec.checks) {
15
+ if (!check || typeof check.id !== 'string' || !check.id || ids.has(check.id) ||
16
+ typeof check.path !== 'string' || isAbsolute(check.path) || check.path.split(/[\\/]/).some(part => ['..', '.', ''].includes(part)) ||
17
+ !['equals', 'contains', 'absent', 'sha256'].includes(check.type) ||
18
+ Object.keys(check).some(key => !['id', 'path', 'type', 'expected', 'gate'].includes(key)) ||
19
+ typeof check.gate !== 'boolean' || check.type !== 'absent' && typeof check.expected !== 'string' ||
20
+ check.type === 'sha256' && !/^[a-f0-9]{64}$/.test(check.expected) ||
21
+ check.type === 'contains' && !check.expected) throw new Error('Invalid held-out predicate.');
22
+ ids.add(check.id);
23
+ }
24
+ return spec;
25
+ }
26
+ export async function judgeMeasurementStop({ root, provider, session, trace, stop, spec }) {
27
+ validateMeasurementJudge(spec);
28
+ if (stop.type !== 'stop' || !stop.data.snapshot) throw new Error('No complete snapshot to judge.');
29
+ const snapshot = await readMeasurementSnapshot(measurementDirectory(root, provider, session, trace), stop.data.snapshot, { paths: spec.checks.map(check => check.path) });
30
+ if (spec.checks.some(check => !snapshot.manifest.files.some(file => file.path === check.path))) throw new Error('Judge artifact was not included in this snapshot.');
31
+ const verdicts = spec.checks.map(check => {
32
+ const content = snapshot.files.get(check.path);
33
+ const passed = check.type === 'absent' ? !content : content !== undefined &&
34
+ (check.type === 'sha256' ? measurementDigest(content) === check.expected :
35
+ check.type === 'equals' ? content.equals(Buffer.from(check.expected)) : content.toString('utf8').includes(check.expected));
36
+ return { id: measurementDigest(check.id), passed, gate: check.gate };
37
+ });
38
+ const violations = verdicts.filter(v => v.gate && !v.passed).map(v => v.id), passed = verdicts.filter(v => v.passed).length;
39
+ return appendMeasurement({ root, provider, session, trace, type: 'judge', key: `${stop.id}:${measurementDigest(spec)}`,
40
+ data: { stop: stop.id, snapshot: stop.data.snapshot, judge: measurementDigest(spec), scope: spec.scope || 'artifact_predicates', satisfied: passed === verdicts.length && !violations.length,
41
+ passed, total: verdicts.length, violations } });
42
+ }
43
+ export async function readFrozenMeasurementJudge(directory) {
44
+ try { return validateMeasurementJudge(JSON.parse(await readFile(join(directory, 'judge.json'), 'utf8'))); }
45
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
46
+ }
@@ -0,0 +1,89 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { lstat, readFile, mkdir, writeFile, realpath } from 'node:fs/promises';
4
+ import { join, relative, resolve, sep } from 'node:path';
5
+ import { measurementDigest } from './measurement-contract.mjs';
6
+
7
+ const exec = promisify(execFile);
8
+ const MAX_BYTES = 64 * 1024 * 1024;
9
+ export async function measurementRepository(cwd) {
10
+ try {
11
+ const { stdout } = await exec('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 2000, maxBuffer: 4096 });
12
+ const match = /(?:github\.com[:/])([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+?)(?:\.git)?\s*$/.exec(stdout);
13
+ return match?.[1] || null;
14
+ } catch { return null; }
15
+ }
16
+ // Opt-in capture, local only. A bounded capture is either complete or unknown;
17
+ // a partial directory must never be presented to a judge as the whole artifact.
18
+ export async function captureMeasurementSnapshot({ directory, cwd, context = null, requiredPaths = [], maxBytes = MAX_BYTES }) {
19
+ cwd = await realpath(cwd);
20
+ const git = async args => (await exec('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, timeout: 5000 })).stdout;
21
+ const head = (await git(['rev-parse', 'HEAD'])).trim();
22
+ const tree = (await git(['rev-parse', 'HEAD^{tree}'])).trim();
23
+ const patchArgs = ['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD'];
24
+ const patch = await git(patchArgs);
25
+ const listedPaths = async () => [...new Set([
26
+ ...(await git(['diff', '--no-ext-diff', '--no-textconv', '--name-only', '-z', 'HEAD'])).split('\0'),
27
+ ...(await git(['ls-files', '-z', '--others', '--exclude-standard'])).split('\0'),
28
+ ...requiredPaths,
29
+ ].filter(Boolean))].sort();
30
+ const paths = await listedPaths();
31
+ if (paths.length > 20000) throw new Error('Snapshot file limit exceeded.');
32
+ const files = []; let bytes = Buffer.byteLength(JSON.stringify(context)) + Buffer.byteLength(patch);
33
+ if (bytes > maxBytes) throw new Error('Snapshot byte limit exceeded.');
34
+ await mkdir(join(directory, 'blobs'), { recursive: true, mode: 0o700 });
35
+ await mkdir(join(directory, 'snapshots'), { recursive: true, mode: 0o700 });
36
+ for (const path of paths) {
37
+ const full = resolve(cwd, path), within = relative(cwd, full);
38
+ if (within.startsWith(`..${sep}`) || within === '..' || within === '' || full === cwd) throw new Error('Snapshot path escaped workspace.');
39
+ let parent = cwd;
40
+ for (const component of within.split(sep).slice(0, -1)) {
41
+ parent = join(parent, component);
42
+ let info; try { info = await lstat(parent); } catch (error) { if (error.code === 'ENOENT') break; throw error; }
43
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Snapshot path traverses an unsupported directory.');
44
+ }
45
+ let before;
46
+ try { before = await lstat(full); } catch (error) { if (error.code === 'ENOENT') { files.push({ path, deleted: true }); continue; } throw error; }
47
+ if (!before.isFile()) throw new Error('Snapshot contains unsupported file type.');
48
+ if (await realpath(full) !== full) throw new Error('Snapshot path traverses a symbolic link.');
49
+ bytes += before.size;
50
+ if (bytes > maxBytes) throw new Error('Snapshot byte limit exceeded.');
51
+ const content = await readFile(full), after = await lstat(full);
52
+ if (!after.isFile() || after.ino !== before.ino || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || content.length !== before.size) throw new Error('Workspace changed during snapshot.');
53
+ const blob = measurementDigest(content);
54
+ files.push({ path, blob, mode: before.mode & 0o777 });
55
+ // Content-addressed blobs are retained once, not accumulated in RAM for a
56
+ // repository-sized snapshot. Unchanged baseline assets are named by Git.
57
+ await writeOnce(join(directory, 'blobs', blob), content);
58
+ }
59
+ if ((await git(['rev-parse', 'HEAD'])).trim() !== head || await git(patchArgs) !== patch ||
60
+ JSON.stringify(await listedPaths()) !== JSON.stringify(paths)) throw new Error('Workspace identity changed during snapshot.');
61
+ for (const file of files) {
62
+ if (file.deleted) { try { await lstat(join(cwd, file.path)); throw new Error('Deleted artifact reappeared.'); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
63
+ else if (measurementDigest(await readFile(join(cwd, file.path))) !== file.blob) throw new Error('Artifact changed during snapshot.');
64
+ }
65
+ const manifest = { schemaVersion: 1, head, tree, patch, files, context,
66
+ coverage: 'changed_files_and_declared_artifacts', requiredPaths: [...new Set(requiredPaths)].sort(),
67
+ baselineBytesIncluded: false };
68
+ const id = measurementDigest(manifest);
69
+ await writeOnce(join(directory, 'snapshots', `${id}.json`), JSON.stringify(manifest));
70
+ return { id, candidate: measurementDigest({ head, tree, patch, files }) };
71
+ }
72
+ async function writeOnce(path, content) {
73
+ try { await writeFile(path, content, { flag: 'wx', mode: 0o600 }); }
74
+ catch (error) { if (error.code !== 'EEXIST') throw error; const saved = await readFile(path); if (!saved.equals(Buffer.from(content))) throw new Error('Snapshot content conflict.'); }
75
+ }
76
+ export async function readMeasurementSnapshot(directory, id, { paths = null } = {}) {
77
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid snapshot reference.');
78
+ const manifest = JSON.parse(await readFile(join(directory, 'snapshots', `${id}.json`), 'utf8'));
79
+ if (measurementDigest(manifest) !== id) throw new Error('Snapshot manifest was modified.');
80
+ const files = new Map();
81
+ for (const file of manifest.files) {
82
+ if (file.deleted || paths && !paths.includes(file.path)) continue;
83
+ if (!/^[a-f0-9]{64}$/.test(file.blob)) throw new Error('Invalid snapshot blob.');
84
+ const content = await readFile(join(directory, 'blobs', file.blob));
85
+ if (measurementDigest(content) !== file.blob) throw new Error('Snapshot blob was modified.');
86
+ files.set(file.path, content);
87
+ }
88
+ return { manifest, files };
89
+ }
@@ -0,0 +1,132 @@
1
+ import { mkdir, readFile, readdir, link, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { acquireLock, atomicSave, readState } from './update-state-store.mjs';
5
+ import { MEASUREMENT_VERSION, measurementDigest, validateMeasurementEvent } from './measurement-contract.mjs';
6
+ import { measurementRepository } from './measurement-snapshot.mjs';
7
+
8
+ const sessionDirectory = (root, provider, session) => join(root, 'measurement', measurementDigest([provider, session]));
9
+ async function measurementLock(file) {
10
+ for (let attempt = 0; ; attempt++) {
11
+ try { return acquireLock(file); }
12
+ catch (error) { if (error.code !== 'state_busy' || attempt >= 30) throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
13
+ }
14
+ }
15
+ export const measurementDirectory = (root, provider, session, trace) => {
16
+ const directory = sessionDirectory(root, provider, session);
17
+ const id = trace || readState(join(directory, 'active.json'))?.trace;
18
+ if (id && !/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid measurement trace.');
19
+ return id ? join(directory, id) : directory;
20
+ };
21
+ export const measurementPolicyPath = (root, provider, cwd) => join(root, 'measurement-policies', `${measurementDigest([provider, cwd])}.json`);
22
+ export const measurementPolicyEnabled = (root, provider, cwd) => readState(measurementPolicyPath(root, provider, cwd))?.enabled === true;
23
+ export const measurementRuntime = record => record.admission?.model ? measurementDigest({ provider: record.provider,
24
+ model: record.admission.model, source: record.admission.source || null,
25
+ permissionMode: record.updateObservation?.permissionMode || null }) : null;
26
+ export async function enrollMeasurementFromPolicy({ root, previous, current }) {
27
+ if (!current) return;
28
+ const policy = readState(measurementPolicyPath(root, current.provider, current.cwd));
29
+ if (!policy?.enabled) return;
30
+ const task = current.run?.managed?.taskId || current.run?.id || `passive:${current.session}`;
31
+ if (!current.run && (policy.loop !== 'off' || await measurementBinding(root, current.provider, current.session))) return;
32
+ if (current.run && previous?.run?.id === current.run.id) return;
33
+ if (policy.loop === 'off' && current.autoContinue !== false || policy.loop === 'on' && current.autoContinue !== true) return;
34
+ const binding = await enrollMeasurement({ root, provider: current.provider, session: current.session, task,
35
+ source: policy.source, layer: policy.layer, loop: policy.loop, cohort: policy.cohort, scope: policy.scope,
36
+ late: !(['SessionStart', 'UserPromptSubmit'].includes(current.lastEvent) && (!current.run || current.observedAt === current.run.startedAt)),
37
+ repository: await measurementRepository(current.run?.executionCwd || current.cwd), runtime: measurementRuntime(current) });
38
+ if (policy.judge) await writeFile(join(measurementDirectory(root, current.provider, current.session, binding.trace), 'judge.json'), JSON.stringify(policy.judge), { flag: 'wx', mode: 0o600 });
39
+ return binding;
40
+ }
41
+ export async function measurementBinding(root, provider, session, trace) {
42
+ return readState(join(measurementDirectory(root, provider, session, trace), 'binding.json'));
43
+ }
44
+ export function measurementCollectionEnabled(root, provider, session, trace) {
45
+ return readState(join(measurementDirectory(root, provider, session, trace), 'collection.json'))?.enabled !== false;
46
+ }
47
+ export async function enrollMeasurement({ root, provider, session, task, source = 'field', layer = null, loop = 'on', cohort, late = true, scope = null, repository = null, runtime = null }) {
48
+ if (!['codex', 'claude'].includes(provider) || !session || !task || !['field', 'fixture'].includes(source) ||
49
+ ![null, 'N', 'S', 'F', 'W', 'X', 'B'].includes(layer) || !['on', 'off'].includes(loop) || !cohort) throw new Error('Explicit measurement identity and cohort required.');
50
+ // Enrollment and the active pointer form one session-scoped publication.
51
+ // Concurrent task updates cannot steal another trace's initial event.
52
+ const release = await measurementLock(join(sessionDirectory(root, provider, session), 'active.json'));
53
+ try {
54
+ const existing = await measurementBinding(root, provider, session);
55
+ if (existing?.task === measurementDigest(task)) throw new Error('Task measurement binding already exists.');
56
+ const trace = measurementDigest(randomUUID());
57
+ const directory = measurementDirectory(root, provider, session, trace);
58
+ await mkdir(directory, { recursive: true, mode: 0o700 });
59
+ const binding = { schemaVersion: 1, trace, provider, session: measurementDigest(session),
60
+ task: measurementDigest(task), source, layer, loop, cohort: measurementDigest(cohort), scope };
61
+ // Each task owns an immutable trace. Moving the active pointer retains every
62
+ // prior task and its complete journal in the same provider conversation.
63
+ const temporary = join(directory, `${randomUUID()}.tmp`);
64
+ await writeFile(temporary, JSON.stringify(binding), { flag: 'wx', mode: 0o600 });
65
+ try { await link(temporary, join(directory, 'binding.json')); }
66
+ finally { await rm(temporary, { force: true }); }
67
+ const collectorSources = await Promise.all(['contract', 'store', 'snapshot', 'judge', 'collector'].map(name => readFile(new URL(`./measurement-${name}.mjs`, import.meta.url), 'utf8')));
68
+ const collector = measurementDigest(collectorSources);
69
+ await appendMeasurement({ root, provider, session, trace, type: 'run_started', key: 'enrollment', data: { loop, cohort: binding.cohort, repository, runtime, collector } });
70
+ if (late) await appendMeasurement({ root, provider, session, trace, type: 'gap', key: 'late_enrollment', data: { reason: 'late_enrollment' } });
71
+ atomicSave(join(sessionDirectory(root, provider, session), 'active.json'), { schemaVersion: 1, trace });
72
+ return binding;
73
+ } finally { release(); }
74
+ }
75
+ export async function appendMeasurement({ root, provider, session, trace, type, key, data, at = Date.now() }) {
76
+ const binding = await measurementBinding(root, provider, session, trace);
77
+ if (!binding) return null;
78
+ if (typeof key !== 'string' || !key || key.length > 2000) throw new Error('Measurement source identity required.');
79
+ const directory = measurementDirectory(root, provider, session, binding.trace), manifest = join(directory, 'journal.json');
80
+ const release = await measurementLock(manifest);
81
+ try {
82
+ const journal = readState(manifest) || { schemaVersion: 1, sequence: 0 };
83
+ const source = measurementDigest([type, key]), eventFile = join(directory, `${source}.event.json`);
84
+ let saved;
85
+ try { saved = await readFile(eventFile, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
86
+ if (saved) {
87
+ const event = validateMeasurementEvent(JSON.parse(saved));
88
+ if (JSON.stringify(event.data) !== JSON.stringify(data)) throw new Error('Measurement source changed; preserve the original event.');
89
+ return event;
90
+ }
91
+ const unsigned = { version: MEASUREMENT_VERSION, trace: binding.trace, provider, session: binding.session, task: binding.task,
92
+ sequence: journal.sequence + 1, source: binding.source, layer: binding.layer, type, at: new Date(at).toISOString(), data };
93
+ const event = validateMeasurementEvent({ ...unsigned, id: measurementDigest(unsigned) });
94
+ // Reserve the sequence durably BEFORE publishing: a crash is a visible gap,
95
+ // never two different observations occupying one sequence number.
96
+ atomicSave(manifest, { schemaVersion: 1, sequence: event.sequence });
97
+ atomicSave(eventFile, event);
98
+ return event;
99
+ } finally { release(); }
100
+ }
101
+ export async function readMeasurements({ root, provider, session, trace }) {
102
+ const directory = measurementDirectory(root, provider, session, trace);
103
+ const binding = await measurementBinding(root, provider, session, trace);
104
+ let names; try { names = await readdir(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
105
+ const events = [];
106
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.event\.json$/.test(name))) {
107
+ const event = validateMeasurementEvent(JSON.parse(await readFile(join(directory, name), 'utf8')));
108
+ if (!binding || event.trace !== binding.trace || event.task !== binding.task || event.session !== binding.session || event.provider !== binding.provider || event.source !== binding.source || event.layer !== binding.layer) throw new Error('Measurement event belongs to another trace.');
109
+ events.push(event);
110
+ }
111
+ return events.sort((left, right) => left.sequence - right.sequence);
112
+ }
113
+ export async function measurementEventForSource({ root, provider, session, trace, type, key }) {
114
+ const file = join(measurementDirectory(root, provider, session, trace), `${measurementDigest([type, key])}.event.json`);
115
+ try { return validateMeasurementEvent(JSON.parse(await readFile(file, 'utf8'))); }
116
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
117
+ }
118
+ export async function listMeasurementTraces({ root, provider, session }) {
119
+ let names; try { names = await readdir(sessionDirectory(root, provider, session)); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
120
+ return names.filter(name => /^[a-f0-9]{64}$/.test(name));
121
+ }
122
+ export async function retainMeasurementArtifact(file, value) {
123
+ const temporary = `${file}.${randomUUID()}.publish`;
124
+ atomicSave(temporary, value);
125
+ try {
126
+ try { await link(temporary, file); }
127
+ catch (error) {
128
+ if (error.code !== 'EEXIST') throw error;
129
+ if (JSON.stringify(JSON.parse(await readFile(file, 'utf8'))) !== JSON.stringify(value)) throw new Error('Measurement artifact content conflict.');
130
+ }
131
+ } finally { await rm(temporary, { force: true }); }
132
+ }
@@ -47,6 +47,10 @@ function parseDeliveryOptions(args, allowed) {
47
47
 
48
48
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), webOrigin, input = process.stdin, output = console.log } = {}) {
49
49
  const [action, ...args] = argv;
50
+ if (action === 'measurement') {
51
+ const { runMeasurementCommand } = await import('./measurement.js');
52
+ return runMeasurementCommand(args, { root, cwd, webOrigin, output });
53
+ }
50
54
  if (action === 'auto-continue') {
51
55
  const controller = new AbortController(), abort = () => controller.abort();
52
56
  process.once('SIGINT', abort); process.once('SIGTERM', abort);
@@ -238,6 +242,8 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
238
242
  output('simy delivery consult [--scope diagnose|repair_continue] [--request-id USER_TURN_ID] [--context-file context.json] [--provider codex|claude --session ID | --task-id ID] — diagnose scope: check why it stopped, using the observed current conversation or selected original work; repair_continue scope never implies completion.');
239
243
  output('simy delivery diagnose --provider codex|claude --session ID [--task-id ID] [--mode analyze|evidence] [--issues-file issues.json] [--detail brief|full] — advisory check of why it stopped, in a separate process; never resumes the target. Exact --task-id alone is also supported.');
240
244
  output('simy delivery evidence --provider codex|claude --session ID — read-only execution and acceptance receipts.');
245
+ output('simy delivery measurement enable|disable --provider codex|claude --cohort ID [--source field|fixture --layer N|S|F|W|X|B --loop on|off --judge-file FILE] — opt in future tasks in this workspace to local snapshot collection.');
246
+ output('simy delivery measurement start|stop|list|status|export|record|flush --provider codex|claude --session ID [--trace SHA256] — audit or explicitly upload content-free observations. See docs/adoption-measurement.md.');
241
247
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
242
248
  output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
243
249
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
@@ -0,0 +1,149 @@
1
+ import { readFile, writeFile, realpath } from 'node:fs/promises';
2
+ import { dirname, join, relative, isAbsolute, sep } from 'node:path';
3
+ import { readRecord, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { enrollMeasurement, measurementBinding, measurementDirectory, measurementPolicyPath, readMeasurements, appendMeasurement, listMeasurementTraces, measurementRuntime } from '../codex-plugins/simy/hooks/measurement-store.mjs';
5
+ import { measurementDigest } from '../codex-plugins/simy/hooks/measurement-contract.mjs';
6
+ import { validateMeasurementJudge } from '../codex-plugins/simy/hooks/measurement-judge.mjs';
7
+ import { readSession, isSessionValid } from '../session-store.js';
8
+ import { webApiUrl } from '../web-api.js';
9
+ import { atomicSave, readState } from '../codex-plugins/simy/hooks/update-state-store.mjs';
10
+ import { measurementRepository } from '../codex-plugins/simy/hooks/measurement-snapshot.mjs';
11
+
12
+ function measurementCloudScope(webOrigin, auth) {
13
+ if (!webOrigin || !isSessionValid(auth, Date.now(), webOrigin) || !auth.organization_id ||
14
+ !(auth.account_id || auth.auth_user_id) || !auth.device_id) return null;
15
+ return { origin: new URL(webOrigin).origin, organization: auth.organization_id,
16
+ account: auth.account_id || auth.auth_user_id, device: auth.device_id };
17
+ }
18
+
19
+ async function loadMeasurementJudge(file, cwd) {
20
+ const root = await realpath(cwd), target = await realpath(file), path = relative(root, target);
21
+ if (!(path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path))) throw new Error('Held-out judge must remain outside the solver workspace.');
22
+ const content = await readFile(target);
23
+ if (content.length > 65536) throw new Error('Held-out judge exceeds 64 KiB.');
24
+ return validateMeasurementJudge(JSON.parse(content.toString('utf8')));
25
+ }
26
+
27
+ export function measurementReadiness(events, { reservedSequence = events.at(-1)?.sequence || 0 } = {}) {
28
+ const gaps = []; let expected = 1;
29
+ for (const event of events) { if (event.sequence !== expected) gaps.push({ expected, received: event.sequence }); expected = event.sequence + 1; }
30
+ if (reservedSequence >= expected) gaps.push({ expected, received: null, reserved: reservedSequence });
31
+ const stops = events.filter(event => event.type === 'stop');
32
+ const judges = events.filter(event => event.type === 'judge');
33
+ return {
34
+ eventCount: events.length, stopCount: stops.length, sequenceGaps: gaps,
35
+ missingRunStart: !events.some(event => event.type === 'run_started' && event.sequence === 1),
36
+ terminalObserved: events.some(event => event.type === 'run_finished'),
37
+ declaredGaps: events.filter(event => event.type === 'gap').map(event => event.data.reason),
38
+ missingSnapshots: stops.filter(event => !event.data.snapshot).length,
39
+ unjudgedStops: stops.filter(stop => !judges.some(judge => judge.data.stop === stop.id && judge.data.snapshot === stop.data.snapshot && judge.data.scope === 'task_acceptance')).length,
40
+ artifactJudgedStops: stops.filter(stop => judges.some(judge => judge.data.stop === stop.id && judge.data.snapshot === stop.data.snapshot)).length,
41
+ unknownNaturalStops: stops.filter(event => event.data.natural === null).length,
42
+ unclassifiedStops: stops.filter(stop => stop.data.stop_kind === 'unknown' && !events.some(event => event.type === 'stop_label' && event.data.stop === stop.id)).length,
43
+ unclassifiedInstructions: events.filter(instruction => instruction.type === 'instruction' && instruction.data.instruction_type === 'unknown' && !events.some(event => event.type === 'instruction_label' && event.data.instruction === instruction.id)).length,
44
+ hasBillingReceipt: events.some(event => event.type === 'cost' && event.data.basis === 'billing' && event.data.amount_micros !== null),
45
+ // This is a collection audit. It cannot certify causal calibration, sample
46
+ // sufficiency, real-user acceptance or turn a fixture into field evidence.
47
+ effectReadiness: 'requires_cohort_evaluation',
48
+ remainingEvaluation: [
49
+ ...(stops.some(stop => !judges.some(judge => judge.data.stop === stop.id && judge.data.scope === 'task_acceptance')) ? ['independent_task_judgement'] : []),
50
+ 'cohort_calibration', 'sample_sufficiency',
51
+ ],
52
+ };
53
+ }
54
+
55
+ export async function flushMeasurements({ root, provider, session, trace, webOrigin, fetchImpl = globalThis.fetch }) {
56
+ const binding = await measurementBinding(root, provider, session, trace);
57
+ if (!binding?.scope) throw new Error('Measurement was not enrolled with a Cloud scope.');
58
+ const auth = await readSession(webOrigin, dirname(root));
59
+ const scope = measurementCloudScope(webOrigin, auth);
60
+ if (!scope || !binding.scope.device || JSON.stringify(scope) !== JSON.stringify(binding.scope)) throw new Error('Measurement Cloud account or organization changed; original scope required.');
61
+ const events = await readMeasurements({ root, provider, session, trace: binding.trace });
62
+ const directory = measurementDirectory(root, provider, session, binding.trace); let uploaded = 0;
63
+ for (const event of events) {
64
+ let saved;
65
+ try { saved = await readFile(join(directory, `${event.id}.ack`), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
66
+ if (saved === event.id) continue;
67
+ const response = await fetchImpl(webApiUrl('sqm/measurement-events', { webOrigin, apiBaseUrl: auth.api_base_url }), {
68
+ method: 'POST', headers: { authorization: `Bearer ${auth.token}`, 'content-type': 'application/json', accept: 'application/json' },
69
+ body: JSON.stringify(event), signal: AbortSignal.timeout(10000),
70
+ });
71
+ const ack = await response.json().catch(() => null);
72
+ if (!response.ok || ack?.accepted !== true || ack?.event_id !== event.id || ack?.sequence !== event.sequence || ack?.trace !== event.trace)
73
+ throw new Error('Measurement upload was not acknowledged; local events retained.');
74
+ await writeFile(join(directory, `${event.id}.ack`), event.id, { mode: 0o600 }); uploaded++;
75
+ }
76
+ return { uploaded, retained: events.length };
77
+ }
78
+
79
+ export async function runMeasurementCommand(args, { root, cwd, webOrigin, output }) {
80
+ const [operation, ...flags] = args, options = {};
81
+ const allowed = new Set(['provider', 'session', 'trace', 'source', 'layer', 'loop', 'cohort', 'judge-file', 'event-file']);
82
+ for (let index = 0; index < flags.length; index += 2) {
83
+ const key = flags[index]?.slice(2);
84
+ if (!flags[index]?.startsWith('--') || !allowed.has(key) || !flags[index + 1] || Object.hasOwn(options, key)) throw new Error('Invalid measurement option.');
85
+ options[key] = flags[index + 1];
86
+ }
87
+ const { provider, session } = options;
88
+ if (!['codex', 'claude'].includes(provider) || !session && !['enable', 'disable'].includes(operation)) throw new Error('Use measurement enable|disable|start|status|export|record|flush --provider codex|claude [--session ID].');
89
+ const context = { root, provider, session, trace: options.trace };
90
+ if (['enable', 'disable'].includes(operation)) {
91
+ if (operation === 'disable') atomicSave(measurementPolicyPath(root, provider, cwd), { schemaVersion: 1, enabled: false });
92
+ else {
93
+ if (!options.cohort || !['field', 'fixture'].includes(options.source || 'field') || !['on', 'off'].includes(options.loop || 'on') || ![undefined, 'N','S','F','W','X','B'].includes(options.layer)) throw new Error('Explicit cohort and valid measurement policy required.');
94
+ const auth = webOrigin ? await readSession(webOrigin, dirname(root)) : null;
95
+ const scope = measurementCloudScope(webOrigin, auth);
96
+ const judge = options['judge-file'] ? await loadMeasurementJudge(options['judge-file'], cwd) : null;
97
+ atomicSave(measurementPolicyPath(root, provider, cwd), { schemaVersion: 1, enabled: true, cohort: options.cohort, source: options.source || 'field', layer: options.layer || null, loop: options.loop || 'on', scope, judge });
98
+ }
99
+ output(JSON.stringify({ enabled: operation === 'enable', appliesTo: 'future_tasks_in_this_workspace', historicalStopsAvailable: false }, null, 2));
100
+ } else if (operation === 'start') {
101
+ const record = await readRecord(root, provider, session);
102
+ if (!record?.run) throw new Error('Enroll an existing exact task; no task is started by measurement.');
103
+ if (options.loop === 'off' && record.autoContinue !== false) throw new Error('Turn off Auto Continue before enrolling a Loop-off control.');
104
+ if (options.loop !== 'off' && record.autoContinue !== true) throw new Error('A Loop-on cohort requires enabled Auto Continue.');
105
+ const auth = webOrigin ? await readSession(webOrigin, dirname(root)) : null;
106
+ const scope = measurementCloudScope(webOrigin, auth);
107
+ let spec;
108
+ if (options['judge-file']) spec = await loadMeasurementJudge(options['judge-file'], cwd);
109
+ let binding;
110
+ await updateRecord(root, provider, session, async fresh => {
111
+ if (fresh?.run?.id !== record.run.id || fresh.autoContinue !== record.autoContinue) throw new Error('Task changed before measurement enrollment.');
112
+ binding = await enrollMeasurement({ ...context, task: record.run.managed?.taskId || record.run.id,
113
+ source: options.source || 'field', layer: options.layer || null, loop: options.loop || 'on', cohort: options.cohort, late: true, scope,
114
+ repository: await measurementRepository(record.run.executionCwd || record.cwd), runtime: measurementRuntime(record) });
115
+ if (spec) await writeFile(join(measurementDirectory(root, provider, session, binding.trace), 'judge.json'), JSON.stringify(spec), { flag: 'wx', mode: 0o600 });
116
+ return fresh;
117
+ });
118
+ output(JSON.stringify({ enrolled: true, trace: binding.trace, cloudScopeBound: Boolean(scope), historicalStopsAvailable: false }, null, 2));
119
+ } else if (operation === 'stop') {
120
+ if (!await measurementBinding(root, provider, session, context.trace)) throw new Error('Measurement trace not found.');
121
+ atomicSave(join(measurementDirectory(root, provider, session, context.trace), 'collection.json'), { schemaVersion: 1, enabled: false });
122
+ output(JSON.stringify({ collectionEnabled: false, eventsRetained: true, taskChanged: false }, null, 2));
123
+ } else if (operation === 'list') output(JSON.stringify({ traces: await listMeasurementTraces(context) }, null, 2));
124
+ else if (operation === 'record') {
125
+ if (!options['event-file']) throw new Error('--event-file is required.');
126
+ const entry = JSON.parse(await readFile(options['event-file'], 'utf8'));
127
+ if (!['stop_label', 'instruction_label', 'resolution', 'gate', 'cost'].includes(entry.type)) throw new Error('Only externally reviewed annotations or usage receipts may be imported.');
128
+ const events = await readMeasurements(context);
129
+ const referenced = entry.data?.stop || entry.data?.instruction;
130
+ if (referenced && !events.some(event => event.id === referenced && event.type === (entry.type === 'stop_label' ? 'stop' : 'instruction'))) throw new Error('Annotation target is absent from this trace.');
131
+ if (entry.type === 'resolution') {
132
+ const first = events.find(event => event.type === 'sqm' && event.data.proof === entry.data?.first_proof);
133
+ if (!first?.data.findings.includes(entry.data?.finding) || entry.data.final_proof !== null &&
134
+ !events.some(event => event.type === 'sqm' && event.data.proof === entry.data.final_proof))
135
+ throw new Error('Resolution finding or proof is absent from this trace.');
136
+ }
137
+ if (entry.type === 'cost' && !events.some(event => ['execution', 'cost'].includes(event.type) && event.data.attempt === entry.data?.attempt))
138
+ throw new Error('Cost attempt is absent from this trace.');
139
+ const event = await appendMeasurement({ ...context, type: entry.type, key: entry.key, data: entry.data });
140
+ output(JSON.stringify({ recorded: true, event_id: event.id }, null, 2));
141
+ } else if (operation === 'flush') output(JSON.stringify(await flushMeasurements({ ...context, webOrigin }), null, 2));
142
+ else if (operation === 'status' || operation === 'export') {
143
+ const events = await readMeasurements(context);
144
+ const reservedSequence = readState(join(measurementDirectory(root, provider, session, context.trace), 'journal.json'))?.sequence || 0;
145
+ output(JSON.stringify(operation === 'export' ? { version: 'adoption_measurement_export.v1', events, readiness: measurementReadiness(events, { reservedSequence }) }
146
+ : { enrolled: Boolean(await measurementBinding(root, provider, session, context.trace)), ...measurementReadiness(events, { reservedSequence }) }, null, 2));
147
+ } else throw new Error('Use measurement start|status|export|flush.');
148
+ return 0;
149
+ }
@@ -5,6 +5,7 @@ import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/
5
5
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
6
6
  import { continueResultCycle, resultEvent, executeResultCycle, acceptResultEvent } from '../codex-plugins/simy/hooks/result-cycle.mjs';
7
7
  import { finalResponseRecoveryInstruction } from '../session-supervision/final-response-recovery.js';
8
+ import { measurementBinding, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
8
9
  import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
9
10
  import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
10
11
  import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervision/purpose-verification.js';
@@ -533,6 +534,15 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
533
534
  await updateRecordAfterControl(root, record.provider, record.session, async current => {
534
535
  const live = current?.run;
535
536
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
537
+ try {
538
+ if (!result.busy && await measurementBinding(root, record.provider, record.session) && measurementCollectionEnabled(root, record.provider, record.session)) {
539
+ const response = typeof result.output === 'string' ? result.output : null;
540
+ live.managed.measurementProviderResult = { id: leaseId, at: currentTime(),
541
+ turnId: result.providerTurnId || live.managed.executionAttempt?.providerTurnId || null,
542
+ success: result.code === 0 && !result.cancelled, response: response?.slice(0, 65536) || null,
543
+ responseComplete: response !== null && response.length <= 65536 };
544
+ }
545
+ } catch { /* Collection does not change the executor's completion policy. */ }
536
546
  delete live.managed.resumeLease;
537
547
  if (searchEnabled(live.managed) && !result.busy && (dispatched || result.code === 0)) live.continuations += 1;
538
548
  if (live.managed.executionAttempt?.budgetReservation) live.managed.executionAttempt.budgetReservation.state = usage && result.code===0 ? 'settled' : dispatched ? 'unknown' : 'released';
@@ -782,7 +782,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
782
782
  const row = automaticContinuation?.project(analyzed) || analyzed;
783
783
  const projected = { ...row, actionDeadline: row.actionDeadline || { status: 'unavailable', online: false, reminder: null },
784
784
  workState: sessionWorkState(row, record?.run, { provider: sessionStatus.providers?.[candidate.provider] }) };
785
- return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run) };
785
+ return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run, { rawRecord: record }) };
786
786
  }).sort(compareSessionWork);
787
787
  const providers = { ...sessionStatus.providers };
788
788
  for (const provider of PROVIDER_LINKS) {
@@ -1,11 +1,16 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
 
5
6
  export const conversationKey = ({ hostId = 'local', provider, session }) => JSON.stringify([hostId, provider, session]);
6
7
  export const controllerScope = status => status?.auth?.userId && status?.access?.organizationId
7
8
  ? JSON.stringify([status.auth.userId, status.access.organizationId]) : null;
8
9
  const digest = value => createHash('sha256').update(value).digest('hex');
10
+ const catalogDigest = catalog => {
11
+ const { observedAt, ...content } = catalog;
12
+ return digest(JSON.stringify(content));
13
+ };
9
14
 
10
15
  // User preferences belong to the controlling device, never to an SSH target.
11
16
  // Each account/organization has a separate file. All mutations serialize and
@@ -13,7 +18,25 @@ const digest = value => createHash('sha256').update(value).digest('hex');
13
18
  export function createControllerStore(root) {
14
19
  let queue = Promise.resolve();
15
20
  const states = new Map();
21
+ const catalogDigests = new Map();
16
22
  const file = scope => join(root, 'controller', `${digest(scope)}.json`);
23
+ const observationFile = scope => join(root, 'controller', `${digest(scope)}.catalog-observation`);
24
+ async function readObservation(scope, state, contentDigest) {
25
+ if (!state.catalogObservationId || !state.catalog) return state;
26
+ let handle;
27
+ try {
28
+ handle = await open(observationFile(scope), 'r');
29
+ const bytes = Buffer.alloc(4097), { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
30
+ if (bytesRead > 4096) return state;
31
+ const observation = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'));
32
+ if (observation.catalogId === state.catalogObservationId && observation.catalogDigest === contentDigest && Number.isFinite(observation.observedAt) &&
33
+ observation.observedAt >= state.catalog.observedAt) {
34
+ return { ...state, catalog: { ...state.catalog, observedAt: observation.observedAt } };
35
+ }
36
+ } catch { /* Missing, malformed or mismatched freshness never renews old history. */ }
37
+ finally { await handle?.close(); }
38
+ return state;
39
+ }
17
40
  async function load(scope) {
18
41
  if (!scope) throw new Error('Controller identity unavailable.');
19
42
  if (states.has(scope)) return states.get(scope);
@@ -21,24 +44,35 @@ export function createControllerStore(root) {
21
44
  try { state = JSON.parse(await readFile(file(scope), 'utf8')); }
22
45
  catch (error) { if (error.code !== 'ENOENT') throw error; state = { version: 1, conversations: {}, events: {} }; }
23
46
  if (state.version !== 1 || !state.conversations || !state.events) throw new Error('Invalid controller settings.');
47
+ const contentDigest = state.catalog ? catalogDigest(state.catalog) : null;
48
+ state = await readObservation(scope, state, contentDigest);
49
+ if (contentDigest) catalogDigests.set(scope, contentDigest);
24
50
  states.set(scope, state); return state;
25
51
  }
26
52
  function enqueue(work) {
27
53
  const pending = queue.catch(() => {}).then(work);
28
54
  queue = pending; return pending;
29
55
  }
30
- async function persist(scope, next) {
56
+ async function atomicWrite(path, value) {
31
57
  await mkdir(join(root, 'controller'), { recursive: true, mode: 0o700 });
32
- const path = file(scope), temp = `${path}.${randomUUID()}.tmp`;
33
- try { await writeFile(temp, JSON.stringify(next) + '\n', { mode: 0o600 }); await rename(temp, path); }
58
+ const temp = `${path}.${randomUUID()}.tmp`;
59
+ try { await writeFile(temp, JSON.stringify(value) + '\n', { mode: 0o600 }); await rename(temp, path); }
34
60
  finally { await rm(temp, { force: true }); }
61
+ }
62
+ async function persist(scope, next) {
63
+ await atomicWrite(file(scope), next);
35
64
  states.set(scope, next);
36
65
  }
37
66
  function change(scope, mutate) {
38
67
  return enqueue(async () => {
39
- const next = structuredClone(await load(scope));
68
+ const previous = await load(scope), next = structuredClone(previous);
40
69
  const result = await mutate(next);
41
- await persist(scope, next); return result;
70
+ await persist(scope, next);
71
+ // Mutators (including older writers) may edit catalog contents while
72
+ // retaining unknown root fields. Its digest, not a copied ID, binds the
73
+ // next receipt; avoid another deep comparison on preference/event writes.
74
+ catalogDigests.delete(scope);
75
+ return result;
42
76
  });
43
77
  }
44
78
  function replaceCatalog(scope, catalog, { immutableInput = false } = {}) {
@@ -49,9 +83,25 @@ export function createControllerStore(root) {
49
83
  // callers retain immediate mutation isolation, including while queued.
50
84
  const snapshot = immutableInput ? catalog : structuredClone(catalog);
51
85
  return enqueue(async () => {
52
- const settings = { ...await load(scope) };
86
+ const previous = await load(scope);
87
+ const { observedAt: previousTime, ...previousContent } = previous.catalog || {};
88
+ const { observedAt, ...content } = snapshot;
89
+ if (previous.catalogObservationId && Number.isFinite(observedAt) && observedAt >= previousTime &&
90
+ isDeepStrictEqual(previousContent, content)) {
91
+ // The owned row array is shared across unchanged desktop snapshots.
92
+ // Renew CLI freshness with a tiny receipt rather than serialize/write
93
+ // the entire history on every connection heartbeat. Bind it to this
94
+ // exact durable base so a crash or later base cannot renew old rows.
95
+ const contentDigest = catalogDigests.get(scope) || catalogDigest(previous.catalog);
96
+ await atomicWrite(observationFile(scope), { catalogId: previous.catalogObservationId, catalogDigest: contentDigest, observedAt });
97
+ catalogDigests.set(scope, contentDigest);
98
+ states.set(scope, { ...previous, catalog: snapshot });
99
+ return;
100
+ }
101
+ const settings = { ...previous };
53
102
  delete settings.catalog;
54
- await persist(scope, { ...structuredClone(settings), catalog: snapshot });
103
+ await persist(scope, { ...structuredClone(settings), catalogObservationId: randomUUID(), catalog: snapshot });
104
+ catalogDigests.set(scope, catalogDigest(snapshot));
55
105
  });
56
106
  }
57
107
  return { load, change, replaceCatalog, peek: scope => states.get(scope),
@@ -61,6 +61,8 @@ export async function claudePluginFiles() {
61
61
  files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
62
62
  files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
63
63
  files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
64
+ for (const name of ['measurement-contract', 'measurement-store', 'measurement-snapshot', 'measurement-judge', 'measurement-collector'])
65
+ files.set(`hooks/${name}.mjs`, await readFile(path.join(SOURCE, `hooks/${name}.mjs`), 'utf8'));
64
66
  files.set("hooks/scene-reasoning.mjs", await readFile(path.join(SOURCE, "hooks/scene-reasoning.mjs"), "utf8"));
65
67
  files.set("hooks/purpose-cycle.mjs", await readFile(path.join(SOURCE, "hooks/purpose-cycle.mjs"), "utf8"));
66
68
  files.set("hooks/purpose-rule-policy.mjs", await readFile(path.join(SOURCE, "hooks/purpose-rule-policy.mjs"), "utf8"));
@@ -6,6 +6,9 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promise
6
6
  import { basename, join } from "node:path";
7
7
  import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
8
8
  import { defaultSessionRoot } from "../session-store.js";
9
+ import { recordMeasurementSqm } from '../codex-plugins/simy/hooks/measurement-collector.mjs';
10
+ import { measurementBinding, appendMeasurement, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
11
+ import { measurementDigest } from '../codex-plugins/simy/hooks/measurement-contract.mjs';
9
12
 
10
13
  // Limit the desktop projection, never the durable history.
11
14
  const DISPLAY_LIMIT = 100;
@@ -132,11 +135,35 @@ export function projectSqmHistory(items, live = { activeCount: 0, items: [] }) {
132
135
  }
133
136
 
134
137
  export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, providerSession, conversationProvider, onError = () => console.warn("Could not save the local SQM check history.") }) {
135
- if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return async () => {};
138
+ const historyEnabled = Boolean(scope?.webOrigin && scope?.accountId && scope?.organizationId);
136
139
  const history = createSqmHistoryStore({ root });
137
140
  const key = `sqm-check:${randomUUID()}`;
138
141
  const startedAt = new Date().toISOString();
142
+ const measurementRoot = join(root || defaultSessionRoot(), 'delivery-hooks');
143
+ // Pin the current trace before the check starts. A new task in the same
144
+ // provider conversation cannot inherit the old task's late SQM result.
145
+ const measurement = providerSession && conversationProvider ? measurementBinding(measurementRoot, conversationProvider, providerSession).catch(() => null) : Promise.resolve(null);
139
146
  return async (result, presentation = {}) => {
147
+ try {
148
+ const binding = await measurement;
149
+ if (binding && measurementCollectionEnabled(measurementRoot, conversationProvider, providerSession, binding.trace)) {
150
+ const context = { root: measurementRoot, provider: conversationProvider, session: providerSession, trace: binding.trace };
151
+ if (result.proof) await recordMeasurementSqm({ ...context, result });
152
+ else await appendMeasurement({ ...context, type: 'sqm_attempt', key,
153
+ data: { attempt: measurementDigest(key), phase: phase === 'min' ? 'min' : 'full',
154
+ outcome: result.status === 'unavailable' ? 'unavailable' : ['failed', 'preliminary_failed'].includes(result.status) ? 'failed' : ['passed', 'preliminary_passed'].includes(result.status) ? 'passed' : 'unknown',
155
+ candidate: result.repository_identity ? measurementDigest(result.repository_identity) : null,
156
+ diagnostic: result.diagnostic ? measurementDigest(result.diagnostic) : null } });
157
+ }
158
+ } catch {
159
+ console.warn('Adoption measurement could not retain this SQM observation.');
160
+ try {
161
+ const binding = await measurement;
162
+ if (binding) await appendMeasurement({ root: measurementRoot, provider: conversationProvider, session: providerSession, trace: binding.trace,
163
+ type: 'gap', key: `sqm:${key}`, data: { reason: 'capture_failed' } });
164
+ } catch { /* The collection audit must report the damaged journal. */ }
165
+ }
166
+ if (!historyEnabled) return;
140
167
  const proof = result.proof, identity = proof?.repository_identity || result.repository_identity || result.min_check?.repository_identity;
141
168
  const repository = identity?.repository || result.repository;
142
169
  const outcome = result.status === "stale" ? "stale" : proof?.outcome.status || result.min_check?.status || result.status;
package/src/web-api.js CHANGED
@@ -17,6 +17,9 @@ export function publicImageUrl(value) {
17
17
 
18
18
  const APP_DEV_ORIGIN = "https://app-dev.simy.one";
19
19
  const APP_DEV_GATEWAY_BASE_URL = "https://app.simy.one/api/local-cli-gateway/";
20
+ // An isolated dev build can explicitly select the dev gateway before a new
21
+ // route reaches stable. Never infer this endpoint or redirect a saved session.
22
+ const APP_DEV_LOCAL_GATEWAY_BASE_URL = "https://app-dev.simy.one/api/local-cli-gateway/";
20
23
 
21
24
  export function resolveWebApiBaseUrl(webOrigin, requestedBaseUrl = null) {
22
25
  const normalizedWebOrigin = new URL(webOrigin).origin;
@@ -36,9 +39,9 @@ export function resolveWebApiBaseUrl(webOrigin, requestedBaseUrl = null) {
36
39
  if (requested.href === directBase) return directBase;
37
40
  if (
38
41
  normalizedWebOrigin === APP_DEV_ORIGIN &&
39
- requested.href === APP_DEV_GATEWAY_BASE_URL
42
+ [APP_DEV_GATEWAY_BASE_URL, APP_DEV_LOCAL_GATEWAY_BASE_URL].includes(requested.href)
40
43
  ) {
41
- return APP_DEV_GATEWAY_BASE_URL;
44
+ return requested.href;
42
45
  }
43
46
  throw new Error("SIMY Web provided an untrusted CLI API endpoint.");
44
47
  }