@awak-app/simy-cli 0.7.45 → 0.7.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/cli-contract.js +1 -1
  3. package/src/codex-plugins/simy/.mcp.json +11 -0
  4. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +60 -4
  5. package/src/codex-plugins/simy/hooks/measurement-collector.mjs +130 -0
  6. package/src/codex-plugins/simy/hooks/measurement-contract.mjs +51 -0
  7. package/src/codex-plugins/simy/hooks/measurement-judge.mjs +46 -0
  8. package/src/codex-plugins/simy/hooks/measurement-snapshot.mjs +89 -0
  9. package/src/codex-plugins/simy/hooks/measurement-store.mjs +132 -0
  10. package/src/codex-plugins/simy/native/app-tools.mjs +45 -0
  11. package/src/codex-plugins/simy/native/bridge.mjs +156 -0
  12. package/src/codex-plugins/simy/native/guidance-intent.mjs +30 -0
  13. package/src/codex-plugins/simy/native/launch +36 -0
  14. package/src/codex-plugins/simy/native/server.mjs +26 -0
  15. package/src/codex-plugins/simy/native/submission-receipt.mjs +9 -0
  16. package/src/delivery-hooks/command.js +6 -0
  17. package/src/delivery-hooks/measurement.js +149 -0
  18. package/src/delivery-hooks/supervisor.js +20 -2
  19. package/src/desktop-host.js +180 -12
  20. package/src/session-supervision/claude-sessions.js +25 -14
  21. package/src/session-supervision/claude-stream-owner.js +266 -0
  22. package/src/session-supervision/claude-stream-presentation.js +27 -0
  23. package/src/session-supervision/claude-stream-transport.js +188 -0
  24. package/src/session-supervision/codex-app-server.js +30 -8
  25. package/src/session-supervision/codex-app-tools-connection.js +30 -0
  26. package/src/session-supervision/controller-store.js +58 -8
  27. package/src/session-supervision/conversation-presentation.js +2 -0
  28. package/src/session-supervision/index.js +5 -1
  29. package/src/session-supervision/native-handoff.js +69 -13
  30. package/src/session-supervision/native-turn-observer.js +196 -0
  31. package/src/session-supervision/purpose-verification.js +18 -7
  32. package/src/session-supervision/session-guidance.js +160 -0
  33. package/src/sqm/claude-plugin.js +4 -0
  34. package/src/sqm/history-store.js +28 -1
  35. package/src/sqm/review-worker.js +5 -1
  36. package/src/web-api.js +5 -2
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.47",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@
43
43
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
44
44
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
45
45
  "check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
46
- "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/claude-delivery-fault-live-e2e.mjs && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
46
+ "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/claude-stream-acceptance.mjs && node --check ./scripts/claude-delivery-fault-live-e2e.mjs && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
47
47
  "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/login-shell-path.js && node --check ./src/hook-runtime.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/delivery-hooks/recovery.js && node --check ./src/delivery-hooks/supervisor-owner.js && node --check ./src/delivery-hooks/supervisor-watchdog.js && node --check ./src/delivery-hooks/supervisor-service.js && node --check ./scripts/supervisor-watchdog-live-e2e.js && node --check ./src/codex-plugins/simy/hooks/recovery-incident.mjs && node --check ./src/session-supervision/automatic-continuation.js && node --check ./src/session-supervision/stop-notifications.js",
48
48
  "check:package": "node ./scripts/check-package-contents.js",
49
49
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
@@ -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.47";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "simy_native": {
4
+ "command": "./native/launch",
5
+ "args": ["./native/server.mjs"],
6
+ "cwd": ".",
7
+ "env_vars": ["CODEX_APP_TOOLS_PIPE_PATH", "CODEX_MCP_NODE_PATH", "CODEX_BROWSER_USE_NODE_PATH", "CODEX_ELECTRON_RESOURCES_PATH", "CODEX_CLI_PATH", "SIMY_HOME", "HOME", "PATH"],
8
+ "startup_timeout_sec": 10
9
+ }
10
+ }
11
+ }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import {guidancePending} from '../native/guidance-intent.mjs';
2
3
  import { decideLoop, compactWorkHistory } from './loop-kernel.mjs';
3
4
  import { completionContractIdentity } from './purpose-completion-contract.mjs';
4
5
  import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
@@ -34,6 +35,12 @@ import { associationContract, validateAssociationResult } from './action-associa
34
35
  import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
35
36
  import { continuationMode } from './continuation-policy.mjs';
36
37
  import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
38
+ import { collectMeasurementTransition, retainMeasurementHookInstruction } from './measurement-collector.mjs';
39
+ import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled } from './measurement-store.mjs';
40
+ const measurementEnabled = async (...args) => {
41
+ try { const binding = await measurementBinding(...args); return Boolean(binding && measurementCollectionEnabled(...args)); }
42
+ catch { console.error('Adoption measurement configuration is unreadable; collection is incomplete.'); return false; }
43
+ };
37
44
 
38
45
  export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
39
46
  const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification', 'ConfigChange', 'PostModelSwitch']);
@@ -279,11 +286,15 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
279
286
  // Recovery requires positive process-death evidence and a serialized second
280
287
  // observation. Unknown/legacy owners are never reclaimed by age.
281
288
  const lockToken = await waitForStateLock(lock, inspectProcess);
282
- let temporary, observation, result;
289
+ let temporary, observation, result, measurementObservation;
283
290
  try {
284
291
  const stored = await read(file, MAX_STATE_BYTES);
285
292
  const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
286
293
  const previousBytes = stored === null ? null : JSON.stringify(stored);
294
+ const measurementPriorRunId = previous?.run?.id;
295
+ let priorMeasurement, savedMeasurement;
296
+ try { savedMeasurement = await measurementBinding(root, provider, session); priorMeasurement = savedMeasurement && measurementCollectionEnabled(root, provider, session) ? savedMeasurement : null; } catch { /* Collection never blocks task state. */ }
297
+ const measurementPrevious = priorMeasurement ? structuredClone(previous) : null;
287
298
  const next = await mutate(previous);
288
299
  if (next) {
289
300
  // Older file_equals drafts used `path`, which an older verifier could
@@ -295,6 +306,12 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
295
306
  }
296
307
  }
297
308
  let serialized = JSON.stringify(next);
309
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) {
310
+ // Optional raw capture cannot consume the executor's state budget.
311
+ if (next.measurementObservation) Object.assign(next.measurementObservation, { response: null, responseComplete: false, prompt: null, promptComplete: false });
312
+ if (next.run?.managed?.measurementProviderResult) Object.assign(next.run.managed.measurementProviderResult, { response: null, responseComplete: false });
313
+ serialized = JSON.stringify(next);
314
+ }
298
315
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
299
316
  serialized = JSON.stringify(next);
300
317
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
@@ -317,9 +334,31 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
317
334
  // Runtime identity is revalidated by the caller that has a fresh direct
318
335
  // observation. Persistence alone is never treated as execution evidence.
319
336
  observation = { ...next, runtime: null };
337
+ let enrollProspectively = false;
338
+ try { enrollProspectively = (next.run?.id !== measurementPriorRunId || !next.run && !savedMeasurement) && measurementPolicyEnabled(root, provider, next.cwd); } catch { /* Collection never blocks task state. */ }
339
+ if (priorMeasurement || enrollProspectively)
340
+ measurementObservation = { previous: measurementPrevious, priorRunId: measurementPriorRunId, current: next, trace: priorMeasurement?.trace };
320
341
  }
321
342
  result = next;
322
343
  } finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
344
+ if (measurementObservation) {
345
+ const { previous, priorRunId, current } = measurementObservation;
346
+ // Artifact reads run outside the task lock. A concurrent stop or new turn
347
+ // invalidates the snapshot instead of delaying a control or inventing S1.
348
+ const stamp = value => JSON.stringify([value?.run?.id, value?.run?.revision, value?.run?.controlRevision,
349
+ value?.run?.continuations, value?.run?.managed?.executionAttempt, value?.measurementObservation?.id,
350
+ value?.run?.managed?.measurementProviderResult?.id, value?.autoContinue, value?.providerArchived]);
351
+ const expected = stamp(current);
352
+ const validateCapture = async () => stamp(await read(file, MAX_STATE_BYTES)) === expected;
353
+ let trace = measurementObservation.trace;
354
+ try {
355
+ if (await validateCapture()) trace = (await enrollMeasurementFromPolicy({ root, previous: { run: { id: priorRunId } }, current }))?.trace || trace;
356
+ if (trace) await collectMeasurementTransition({ root, previous, current, trace, validateCapture });
357
+ } catch {
358
+ try { await appendMeasurement({ root, provider, session, trace, type: 'gap', key: `capture:${createHash('sha256').update(expected).digest('hex')}`, data: { reason: 'capture_failed' } }); }
359
+ catch { console.error('Adoption measurement journal unavailable; collection is incomplete.'); }
360
+ }
361
+ }
323
362
  // Do not wait on process inspection or the collector while holding the
324
363
  // delivery state lock. A matching fresh identity is required for start.
325
364
  if (observation) void (async () => {
@@ -1636,10 +1675,10 @@ export function externalReviewIsCurrent(managed,review,now) {
1636
1675
  const refs=[...(review.criteria||[]).filter(row=>row.passed),...(review.rules||[]).filter(row=>row.status==='satisfied'),...(review.workUnits||[])].flatMap(row=>row.evidenceRefs||[]).filter(link=>/^[a-f0-9]{64}$/.test(link.ref));
1637
1676
  // A flexible semantic verdict refers to its actual inspection, including
1638
1677
  // external observations even when no authored citation was supplied.
1639
- if (review.semanticInspection?.source === 'controller_actual_read_set') refs.push(...(review.semanticInspection.observationRefs || []).map(ref => ({ ref })));
1678
+ if (['controller_actual_read_set', 'controller_conversation_answer'].includes(review.semanticInspection?.source)) refs.push(...(review.semanticInspection.observationRefs || []).map(ref => ({ ref })));
1640
1679
  const ledger=managed.purpose.evidence;
1641
1680
  const observations=[...(ledger?.observations||[]),...(ledger?.reviewObservationActionId===(review.decisionId||managed.decision.id) ? ledger.reviewObservations||[] : [])];
1642
- const inspected = new Set(review.semanticInspection?.source === 'controller_actual_read_set' ? review.semanticInspection.observationRefs || [] : []);
1681
+ const inspected = new Set(['controller_actual_read_set', 'controller_conversation_answer'].includes(review.semanticInspection?.source) ? review.semanticInspection.observationRefs || [] : []);
1643
1682
  return refs.filter(link => inspected.has(link.ref) || observations.some(item => item.id === link.ref))
1644
1683
  .every(link=>observations.some(item=>item.id===link.ref && item.status==='observed' && currentPurposeObservation(item,now,review.observationWindow)));
1645
1684
 
@@ -1761,7 +1800,7 @@ export async function settlePurposeVerification({ root, provider, session, expec
1761
1800
  } else if (pending.length && pending.every(c => c.check.type === 'manual')) {
1762
1801
  run.state = 'verification_pending'; run.reason = 'Only explicitly human acceptance remains.'; state.phase = 'waiting';
1763
1802
  run.checkpoint = { status: 'completed', summary: review.summary }; managed.autoRecovery = false;
1764
- } else review = { ...review, passed: false, needsHuman: false, findings: ['Local acceptance changed during review; investigate and recheck.'] };
1803
+ } else if (review.passed || review.needsHuman) review = { ...review, passed: false, needsHuman: false, findings: ['Local acceptance changed during review; investigate and recheck.'] };
1765
1804
  }
1766
1805
  state.review={...review,decisionId:managed.decision.id};
1767
1806
  if (!review.passed && !review.needsHuman && openFindings.some(item => item.observation)) {
@@ -2423,6 +2462,7 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
2423
2462
  // Manual/legacy runs have no marker and retain their existing controls.
2424
2463
  export const ownerRenewalPending = record => ['awaiting_human', 'prepared', 'gate_committed'].includes(record?.run?.managed?.ownerRenewal?.status);
2425
2464
  export function automaticContinuationAllowed(record) {
2465
+ if (guidancePending(record)) return false;
2426
2466
  if (ownerRenewalPending(record) || record?.run?.managed?.ownerRenewal?.needsReadOnlyReassessment === true) return false;
2427
2467
  if (record?.providerArchived === true || record?.run?.managed?.purpose && record.autoContinue === false) return false;
2428
2468
  const epoch = record?.run?.managed?.autoContinueEpoch;
@@ -2786,6 +2826,18 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2786
2826
  return record;
2787
2827
  }
2788
2828
  record.observedAt = now; record.lastEvent = event;
2829
+ if (await measurementEnabled(root, provider, input.session_id)) {
2830
+ // Claude's hook has no stable turn identifier. Preserve it as unknown
2831
+ // rather than deduplicating separate identical assistant messages.
2832
+ if (['Stop', 'StopFailure', 'UserPromptSubmit', 'Interrupt', 'PermissionRequest'].includes(event))
2833
+ record.measurementObservation = { id: input.turn_id || input.tool_use_id || randomUUID(), event, at: now,
2834
+ stable: Boolean(input.turn_id || input.tool_use_id), hookActive: input.stop_hook_active === true,
2835
+ response: typeof input.last_assistant_message === 'string' ? input.last_assistant_message.slice(0, 65536) : null,
2836
+ responseComplete: typeof input.last_assistant_message === 'string' && input.last_assistant_message.length <= 65536,
2837
+ prompt: event === 'UserPromptSubmit' && typeof input.prompt === 'string' ? input.prompt.slice(0, 65536) : null,
2838
+ promptComplete: event === 'UserPromptSubmit' && typeof input.prompt === 'string' && input.prompt.length <= 65536,
2839
+ origin: typeof input.origin?.kind === 'string' ? input.origin.kind.slice(0, 80) : null };
2840
+ }
2789
2841
  if (provider === 'claude' && identity && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'ConfigChange', 'PostModelSwitch'].includes(event)) {
2790
2842
  // Optional model/mode observations are not complete permission evidence.
2791
2843
  // Only an explicit empty task AND wakeup registry proves this boundary.
@@ -3043,6 +3095,10 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
3043
3095
  current.run.state !== observed.run.state) return {};
3044
3096
  output.reason += '\n' + perspectiveInstruction(memory);
3045
3097
  }
3098
+ if (output.decision === 'block') {
3099
+ try { await retainMeasurementHookInstruction({ root, record: observed, text: output.reason }); }
3100
+ catch { console.error('Adoption measurement instruction context unavailable; collection is incomplete.'); }
3101
+ }
3046
3102
  return output;
3047
3103
  }
3048
3104
  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
+ }