@awak-app/simy-cli 0.7.44 → 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.44",
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.44";
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';
@@ -160,7 +161,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
160
161
  });
161
162
  }
162
163
 
163
- export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield, nativeHandoff = reconcileNativeHandoff } = {}) {
164
+ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, readCancellationRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield, nativeHandoff = reconcileNativeHandoff } = {}) {
164
165
  const tickWallStart = Date.now();
165
166
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
166
167
  if (signal?.aborted) return;
@@ -170,7 +171,11 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
170
171
  // OFF changes the consent epoch and removes a task from a scoped activation.
171
172
  // Still reconcile its old native queue on reconnect, without admitting work
172
173
  // outside this supervisor's selected scope.
173
- const cancellationOnly = readRecords ? (await listRecords(root)).filter(r => r.run?.managed?.nativeHandoff && !selectedIds.has(identity(r))) : [];
174
+ // Desktop callers can share their fresh metadata-validated global index.
175
+ // Keep it separate from the selected scope: a revoked native queue must still
176
+ // be cancelled, and the handoff reconciler re-reads authority before acting.
177
+ const cancellationOnly = readRecords ? (await (readCancellationRecords ? readCancellationRecords() : listRecords(root)))
178
+ .filter(r => r.run?.managed?.nativeHandoff && !selectedIds.has(identity(r))) : [];
174
179
  for (const record of [...selected, ...cancellationOnly]) {
175
180
  if (signal?.aborted || await shouldYield?.()) return;
176
181
  if ((!execute || record.run?.managed?.nativeHandoff) && await nativeHandoff({ root, record, signal, authorize: authorizeDispatch, cancelOnly: !selectedIds.has(identity(record)) })) nativeOwned.add(identity(record));
@@ -529,6 +534,15 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
529
534
  await updateRecordAfterControl(root, record.provider, record.session, async current => {
530
535
  const live = current?.run;
531
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. */ }
532
546
  delete live.managed.resumeLease;
533
547
  if (searchEnabled(live.managed) && !result.busy && (dispatched || result.code === 0)) live.continuations += 1;
534
548
  if (live.managed.executionAttempt?.budgetReservation) live.managed.executionAttempt.budgetReservation.state = usage && result.code===0 ? 'settled' : dispatched ? 'unknown' : 'released';
@@ -41,6 +41,38 @@ function updateRows(previous, incoming, full, { unchanged = false, immutableInpu
41
41
  return { rows: immutableInput ? incoming : rows, payload: { count: incoming.length, changes } };
42
42
  }
43
43
 
44
+ // A display ordering change is not a row-content change. Version 2 references
45
+ // positions in the last delivered base; ambiguous identities are sent in full.
46
+ const rowIdentity = row => row && typeof row.session === 'string' && row.session
47
+ ? JSON.stringify([row.hostId ?? 'local', row.provider ?? null, row.session]) : null;
48
+ function updateOrderedRows(previous, incoming, full, { unchanged = false, immutableInput = false } = {}) {
49
+ if (full || previous === null) return updateRows(previous, incoming, true, { immutableInput });
50
+ if (unchanged) return { rows: previous, payload: { count: previous.length, changes: [] } };
51
+ const positions = new Map(), incomingCounts = new Map();
52
+ for (let index = 0; index < previous.length; index++) {
53
+ const key = rowIdentity(previous[index]);
54
+ if (key !== null) positions.set(key, positions.has(key) ? -1 : index);
55
+ }
56
+ for (const row of incoming) { const key = rowIdentity(row); if (key !== null) incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1); }
57
+ const order = [], changes = [], rows = [], reused = new Set();
58
+ let reordered = previous.length !== incoming.length;
59
+ for (let index = 0; index < incoming.length; index++) {
60
+ const row = incoming[index], key = rowIdentity(row), source = positions.get(key);
61
+ const unique = key !== null && incomingCounts.get(key) === 1 && source >= 0;
62
+ const reuse = unique && !reused.has(source) && isDeepStrictEqual(previous[source], row) ? source
63
+ : !unique && index < previous.length && !reused.has(index) && isDeepStrictEqual(previous[index], row) ? index : null;
64
+ if (reuse !== null) {
65
+ rows.push(previous[reuse]); order.push(reuse); reused.add(reuse);
66
+ if (reuse !== index) reordered = true;
67
+ } else {
68
+ const detached = immutableInput ? row : structuredClone(row);
69
+ rows.push(detached); order.push(null); changes.push([index, detached]);
70
+ }
71
+ }
72
+ return { rows: immutableInput ? incoming : !reordered && changes.length === 0 ? previous : rows,
73
+ payload: { count: incoming.length, changes, ...(reordered ? { order } : {}) } };
74
+ }
75
+
44
76
  // Detach mutable producer data once. Unchanged catalog rows may be shared only
45
77
  // between these owned read-only snapshots; changed rows get detached copies and
46
78
  // a new array. Older queued frames and durable writes must remain immutable.
@@ -69,7 +101,9 @@ export function createDesktopStatusSnapshot() {
69
101
  // One encoder per ordered output stream. Call only when a frame actually enters
70
102
  // that stream, after replaceable/backpressured snapshots have been coalesced.
71
103
  // This cache is transport state; it never authorizes work or provider dispatch.
72
- export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
104
+ export function createDesktopCatalogEncoder({ immutableInput = false, version = 1 } = {}) {
105
+ if (![1, 2].includes(version)) throw new Error('Unsupported desktop catalog version.');
106
+ const projectRows = version === 2 ? updateOrderedRows : updateRows;
73
107
  let rows = null, candidates = null, sourceRows = null, sourceCandidates = null, scope = null, revision = 0;
74
108
  const reset = () => { rows = null; candidates = null; sourceRows = null; sourceCandidates = null; scope = null; };
75
109
  return { reset, encode(frame) {
@@ -81,16 +115,16 @@ export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
81
115
  const incoming = status.sessionSupervision.sessions;
82
116
  const identity = catalogScope(status);
83
117
  const previous = revision++;
84
- const full = rows === null || scope !== identity || rows.length !== incoming.length;
85
- const snapshot = updateRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
118
+ const full = rows === null || scope !== identity || version === 1 && rows.length !== incoming.length;
119
+ const snapshot = projectRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
86
120
  rows = snapshot.rows; sourceRows = incoming; scope = identity;
87
- const catalog = { version: 1, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
121
+ const catalog = { version, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
88
122
  const { sessions, ...metadata } = status.sessionSupervision;
89
123
  let supervision = metadata;
90
124
  if (Array.isArray(metadata.selectionCandidates)) {
91
125
  // This is another complete directory, used by the conversation picker.
92
126
  // Do not silently retransmit it as supposedly small status metadata.
93
- const snapshot = updateRows(candidates, metadata.selectionCandidates, full,
127
+ const snapshot = projectRows(candidates, metadata.selectionCandidates, full,
94
128
  { immutableInput, unchanged: immutableInput && sourceCandidates === metadata.selectionCandidates });
95
129
  candidates = snapshot.rows; sourceCandidates = metadata.selectionCandidates; catalog.selectionCandidates = snapshot.payload;
96
130
  const { selectionCandidates, ...small } = metadata;
@@ -160,8 +160,8 @@ export { createDesktopFrameWriter } from './desktop-frame-writer.js';
160
160
 
161
161
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
162
162
  updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
163
- let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && env.SIMY_DESKTOP_CATALOG_DELTA === '1'
164
- ? createDesktopCatalogEncoder({ immutableInput: true }) : null;
163
+ let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && ['1', '2'].includes(env.SIMY_DESKTOP_CATALOG_DELTA)
164
+ ? createDesktopCatalogEncoder({ immutableInput: true, version: env.SIMY_DESKTOP_CATALOG_VERSION === '2' ? 2 : Number(env.SIMY_DESKTOP_CATALOG_DELTA) }) : null;
165
165
  // Object frames below own detached snapshots. Queue their references rather
166
166
  // than copying multi-MiB history again whenever a native reader is paused.
167
167
  const frames = createDesktopFrameWriter(output, { capture: frame => frame,
@@ -605,6 +605,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
605
605
  const link = await requireProviderLink({ root: deliveryRoot, provider: record.provider });
606
606
  return JSON.stringify([scope, link.epoch]);
607
607
  }, readRecords: () => recordReader.list(),
608
+ readCancellationRecords: () => recordReader.list(),
608
609
  readCurrentRecord: (provider, session) => readRecord(deliveryRoot, provider, session),
609
610
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
610
611
  let shuttingDown = null;
@@ -781,7 +782,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
781
782
  const row = automaticContinuation?.project(analyzed) || analyzed;
782
783
  const projected = { ...row, actionDeadline: row.actionDeadline || { status: 'unavailable', online: false, reminder: null },
783
784
  workState: sessionWorkState(row, record?.run, { provider: sessionStatus.providers?.[candidate.provider] }) };
784
- return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run) };
785
+ return { ...projected, conversationPresentation: conversationPresentation(projected, record?.run, { rawRecord: record }) };
785
786
  }).sort(compareSessionWork);
786
787
  const providers = { ...sessionStatus.providers };
787
788
  for (const provider of PROVIDER_LINKS) {
@@ -2563,7 +2564,8 @@ if (isDesktopHostEntry()) {
2563
2564
  try {
2564
2565
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
2565
2566
  if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true,
2566
- catalogDeltas: process.env.SIMY_DESKTOP_CATALOG_DELTA === '1' })) process.exit(0);
2567
+ catalogDeltas: ['1', '2'].includes(process.env.SIMY_DESKTOP_CATALOG_DELTA)
2568
+ ? process.env.SIMY_DESKTOP_CATALOG_VERSION === '2' ? 2 : Number(process.env.SIMY_DESKTOP_CATALOG_DELTA) : false })) process.exit(0);
2567
2569
  try { await adoptInstalledRuntime({ root }); }
2568
2570
  catch (error) {
2569
2571
  if (error.code === PROCESS_CONFLICT_CODE) throw error;
@@ -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),
@@ -45,7 +45,8 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
45
45
  if (!authenticated) {
46
46
  if (command.nonce !== nonce) { peer.destroy(); return; }
47
47
  authenticated = true; desktopClient = allowDesktopClient && command.client === 'desktop'; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
48
- if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1)) catalogEncoders.set(peer, createDesktopCatalogEncoder());
48
+ if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1))
49
+ catalogEncoders.set(peer, createDesktopCatalogEncoder({ version: command.catalogVersion === 2 ? 2 : 1 }));
49
50
  if ([1, 2].includes(command.desktopProtocol) && desktopClient) send(peer, { type: 'desktop_owner_ready', version: catalogEncoders.has(peer) ? 2 : 1 });
50
51
  // One-shot controls need their result, not multi-megabyte broadcasts
51
52
  // that can fill the write queue before the result is sent.
@@ -119,7 +120,8 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
119
120
  } catch (error) { peer.destroy(); throw error; }
120
121
  // A protocol-1 owner replies with complete frames. New native windows accept
121
122
  // both versions, so attaching never requires replacing its execution owner.
122
- peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1, ...(catalogDeltas ? { catalogDeltas: 1 } : {}) } : {}) }) + '\n');
123
+ peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1,
124
+ ...(catalogDeltas ? { catalogDeltas: 1, ...(catalogDeltas === 2 ? { catalogVersion: 2 } : {}) } : {}) } : {}) }) + '\n');
123
125
  // Old owners ignore unknown hello fields and expose only the restricted SSH
124
126
  // projection. Never attach a native window or forward its controls as though
125
127
  // that were a complete desktop endpoint. Keep the original owner untouched.
@@ -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
  }