@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.
- package/package.json +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.mcp.json +11 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +60 -4
- package/src/codex-plugins/simy/hooks/measurement-collector.mjs +130 -0
- package/src/codex-plugins/simy/hooks/measurement-contract.mjs +51 -0
- package/src/codex-plugins/simy/hooks/measurement-judge.mjs +46 -0
- package/src/codex-plugins/simy/hooks/measurement-snapshot.mjs +89 -0
- package/src/codex-plugins/simy/hooks/measurement-store.mjs +132 -0
- package/src/codex-plugins/simy/native/app-tools.mjs +45 -0
- package/src/codex-plugins/simy/native/bridge.mjs +156 -0
- package/src/codex-plugins/simy/native/guidance-intent.mjs +30 -0
- package/src/codex-plugins/simy/native/launch +36 -0
- package/src/codex-plugins/simy/native/server.mjs +26 -0
- package/src/codex-plugins/simy/native/submission-receipt.mjs +9 -0
- package/src/delivery-hooks/command.js +6 -0
- package/src/delivery-hooks/measurement.js +149 -0
- package/src/delivery-hooks/supervisor.js +20 -2
- package/src/desktop-host.js +180 -12
- package/src/session-supervision/claude-sessions.js +25 -14
- package/src/session-supervision/claude-stream-owner.js +266 -0
- package/src/session-supervision/claude-stream-presentation.js +27 -0
- package/src/session-supervision/claude-stream-transport.js +188 -0
- package/src/session-supervision/codex-app-server.js +30 -8
- package/src/session-supervision/codex-app-tools-connection.js +30 -0
- package/src/session-supervision/controller-store.js +58 -8
- package/src/session-supervision/conversation-presentation.js +2 -0
- package/src/session-supervision/index.js +5 -1
- package/src/session-supervision/native-handoff.js +69 -13
- package/src/session-supervision/native-turn-observer.js +196 -0
- package/src/session-supervision/purpose-verification.js +18 -7
- package/src/session-supervision/session-guidance.js +160 -0
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/history-store.js +28 -1
- package/src/sqm/review-worker.js +5 -1
- package/src/web-api.js +5 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {createInterface} from 'node:readline';
|
|
3
|
+
const fail=code=>Object.assign(new Error(code),{code});
|
|
4
|
+
export async function openAppTools({record,signal,node,server,pipe}) {
|
|
5
|
+
const p=spawn(node,[server],{env:{...process.env,CODEX_APP_TOOLS_PIPE_PATH:pipe},stdio:['pipe','pipe','ignore']});
|
|
6
|
+
let sequence=0,closed=false;const pending=new Map();const lines=createInterface({input:p.stdout});
|
|
7
|
+
const close=()=>{if(closed)return;closed=true;signal?.removeEventListener('abort',close);for(const q of pending.values())q.reject(fail(q.write?'native_submission_unknown':'native_tools_closed'));pending.clear();lines.close();p.stdin.end();p.kill('SIGTERM');};
|
|
8
|
+
p.on('error',close);p.on('exit',close);p.stdin.on('error',close);signal?.addEventListener('abort',close,{once:true});
|
|
9
|
+
lines.on('line',line=>{let d;try{d=JSON.parse(line);}catch{close();return;}const q=pending.get(d.id);if(!q)return;pending.delete(d.id);d.error?q.reject(fail(q.write?'native_submission_unknown':'native_tools_request_failed')):q.resolve(d.result);});
|
|
10
|
+
const rpc=(method,params,write=false)=>new Promise((resolve,reject)=>{
|
|
11
|
+
if(closed||signal?.aborted){reject(fail('native_tools_closed'));return;}const id=++sequence;
|
|
12
|
+
const timer=setTimeout(()=>{pending.delete(id);reject(fail(write?'native_submission_unknown':'native_tools_timeout'));},15000);
|
|
13
|
+
pending.set(id,{write,resolve:r=>{clearTimeout(timer);resolve(r)},reject:e=>{clearTimeout(timer);reject(e)}});
|
|
14
|
+
p.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');
|
|
15
|
+
});
|
|
16
|
+
try{
|
|
17
|
+
await rpc('initialize',{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'simy-original-continuation',version:'1.0.0'}});
|
|
18
|
+
p.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
|
|
19
|
+
const list=await rpc('tools/list',{});const names=new Set(list.tools.map(t=>t.name));
|
|
20
|
+
if(!names.has('read_thread')||!names.has('send_message_to_thread'))throw fail('native_tools_unsupported');
|
|
21
|
+
const call=async(name,args,write=false)=>{
|
|
22
|
+
const result=await rpc('tools/call',{name,arguments:args,_meta:{'openai/threadId':record.session}},write);
|
|
23
|
+
if(result.isError)throw fail(write?'native_submission_unknown':result.content?.some(c=>c.type==='text'&&c.text.includes('exceeds the size limit'))?'native_tools_response_too_large':'native_tools_request_failed');
|
|
24
|
+
const text=result.content?.find(c=>c.type==='text')?.text;if(!text)throw fail(write?'native_submission_unknown':'native_tools_invalid_response');
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
};
|
|
27
|
+
const read=async(cursor,includeOutputs=false)=>{
|
|
28
|
+
const r=await call('read_thread',{threadId:record.session,hostId:'local',turnLimit:1,includeOutputs,maxOutputCharsPerItem:16000,...(cursor?{cursor}:{})});
|
|
29
|
+
if(r.thread?.id!==record.session||r.thread?.hostId!=='local'||r.thread?.cwd!==record.cwd)throw fail('native_tools_owner_mismatch');return r;
|
|
30
|
+
};
|
|
31
|
+
return {transport:'codex_app_tools',close,
|
|
32
|
+
async request(method,params){
|
|
33
|
+
if(params.threadId!==record.session)throw fail('native_tools_target_mismatch');
|
|
34
|
+
const r=await read(params.cursor,method==='thread/turns/list');
|
|
35
|
+
if(method==='thread/read')return {thread:{...r.thread,status:{type:r.thread.status?.type==='active'?'active':r.thread.status?.type==='idle'?'idle':'unknown'}}};
|
|
36
|
+
if(method==='thread/turns/list')return {data:r.turns,nextCursor:r.page?.nextCursor};
|
|
37
|
+
throw fail('native_method_unsupported');
|
|
38
|
+
},
|
|
39
|
+
async submitOriginal({text,beforeSubmit}){
|
|
40
|
+
const r=await read();if(r.thread.status?.type!=='idle')throw fail('native_thread_busy');
|
|
41
|
+
await beforeSubmit();signal?.throwIfAborted();
|
|
42
|
+
return call('send_message_to_thread',{threadId:record.session,hostId:'local',prompt:text},true);
|
|
43
|
+
}};
|
|
44
|
+
}catch(e){close();throw e;}
|
|
45
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {assertGuidanceIntent, guidancePending} from './guidance-intent.mjs';
|
|
2
|
+
import {createServer, createConnection} from 'node:net';
|
|
3
|
+
import {mkdir, stat, readFile, writeFile, rename, chmod, rm, readdir} from 'node:fs/promises';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {randomUUID, createHash} from 'node:crypto';
|
|
6
|
+
import {readRecord, updateRecord, automaticContinuationAllowed, hasPendingUserStop, managedBudgetReason} from '../hooks/delivery-loop.mjs';
|
|
7
|
+
import {openAppTools} from './app-tools.mjs';
|
|
8
|
+
|
|
9
|
+
const fail = code => Object.assign(new Error(code), {code});
|
|
10
|
+
export const nativeIntentId = r => createHash('sha256').update(JSON.stringify([r.run.id, r.autoContinueEpoch, r.run.controlRevision, r.run.revision, r.run.managed.decision?.id])).digest('hex');
|
|
11
|
+
const directory = root => join(root, 'native-app-bridge');
|
|
12
|
+
// This is an app-launched transport, never a supervisor or a new task owner.
|
|
13
|
+
// Every write requires an already persisted, exact original-task send intent.
|
|
14
|
+
export async function startNativeBridge({root, node, server, pipe, openClient = openAppTools}) {
|
|
15
|
+
const pipeStat = await stat(pipe);
|
|
16
|
+
if (!pipeStat.isSocket() || pipeStat.uid !== process.getuid()) throw fail('native_tools_owner_mismatch');
|
|
17
|
+
const dir = directory(root), id = randomUUID(), token = randomUUID();
|
|
18
|
+
await mkdir(dir, {recursive: true, mode: 0o700});
|
|
19
|
+
const socket = join(dir, id + '.sock'), peers = new Set();
|
|
20
|
+
const listener = createServer(peer => {
|
|
21
|
+
peers.add(peer); peer.setEncoding('utf8'); peer.setTimeout(60000, () => peer.destroy());
|
|
22
|
+
let buffer = '', busy = false, authorizationReply = null;
|
|
23
|
+
peer.on('error', () => {}); peer.on('close', () => peers.delete(peer));
|
|
24
|
+
peer.on('data', chunk => {
|
|
25
|
+
buffer += chunk;
|
|
26
|
+
if (buffer.length > 1024 * 1024) {peer.destroy(); return;}
|
|
27
|
+
const end = buffer.indexOf('\n'); if (end < 0) return;
|
|
28
|
+
if (buffer.indexOf('\n', end + 1) !== -1) {peer.destroy(); return;}
|
|
29
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
30
|
+
if (busy) {if (authorizationReply) authorizationReply(line); else peer.destroy(); return;}
|
|
31
|
+
busy = true;
|
|
32
|
+
void (async () => {
|
|
33
|
+
let client;
|
|
34
|
+
try {
|
|
35
|
+
const q = JSON.parse(line);
|
|
36
|
+
if (q.token !== token || !['thread/read', 'thread/turns/list', 'submit', 'submit_guidance'].includes(q.method)) throw fail('native_tools_bridge_denied');
|
|
37
|
+
// Old hosts cannot answer the final controller challenge. Fail closed
|
|
38
|
+
// rather than silently accepting their earlier authorization.
|
|
39
|
+
if (q.method === 'submit_guidance' && q.guidanceAuthorization !== 1) throw fail('native_guidance_unavailable');
|
|
40
|
+
const authorizeGuidance = () => new Promise((resolve, reject) => {
|
|
41
|
+
const challenge = randomUUID();
|
|
42
|
+
const finish = error => {
|
|
43
|
+
clearTimeout(timer); authorizationReply = null; peer.removeListener('close', lost);
|
|
44
|
+
error ? reject(error) : resolve();
|
|
45
|
+
};
|
|
46
|
+
const lost = () => finish(fail('native_guidance_changed'));
|
|
47
|
+
const timer = setTimeout(lost, 15000);
|
|
48
|
+
peer.once('close', lost);
|
|
49
|
+
authorizationReply = line => {
|
|
50
|
+
try {
|
|
51
|
+
const ack = JSON.parse(line);
|
|
52
|
+
if (ack.token !== token || ack.method !== 'authorize_guidance' || ack.challenge !== challenge ||
|
|
53
|
+
ack.guidanceId !== q.guidanceId || ack.authorized !== true) throw fail('native_guidance_changed');
|
|
54
|
+
finish();
|
|
55
|
+
} catch {finish(fail('native_guidance_changed'));}
|
|
56
|
+
};
|
|
57
|
+
if (peer.destroyed) {lost(); return;}
|
|
58
|
+
peer.write(JSON.stringify({kind: 'guidance_authorization_required', guidanceId: q.guidanceId, challenge}) + '\n');
|
|
59
|
+
});
|
|
60
|
+
const r = await readRecord(join(root, 'delivery-hooks'), 'codex', q.session);
|
|
61
|
+
if (!r?.run?.managed || r.run.id !== q.runId || r.run.managed.taskId !== q.taskId || r.autoContinueEpoch !== q.epoch || !(q.guidanceId ? guidancePending(r) && r.run.managed.userGuidance.id === q.guidanceId : automaticContinuationAllowed(r))) throw fail('native_tools_attachment_changed');
|
|
62
|
+
client = await openClient({record: r, node, server, pipe});
|
|
63
|
+
const result = q.method === 'submit_guidance' ? await client.submitOriginal({text: q.text, beforeSubmit: async () => {
|
|
64
|
+
await updateRecord(join(root, 'delivery-hooks'), 'codex', q.session, async fresh => {
|
|
65
|
+
const intent = assertGuidanceIntent(fresh, q.guidanceId, q.text);
|
|
66
|
+
if (intent.phase !== 'submitting' || intent.bridgeAttemptedAt || hasPendingUserStop(join(root, 'delivery-hooks'), fresh) || managedBudgetReason(fresh.run)) throw fail('native_guidance_changed');
|
|
67
|
+
// Hold the exact intent lock while asking its original controller
|
|
68
|
+
// to recheck account/org/provider authority at the provider boundary.
|
|
69
|
+
await authorizeGuidance();
|
|
70
|
+
assertGuidanceIntent(fresh, q.guidanceId, q.text);
|
|
71
|
+
if (peer.destroyed || hasPendingUserStop(join(root, 'delivery-hooks'), fresh) || managedBudgetReason(fresh.run)) throw fail('native_guidance_changed');
|
|
72
|
+
intent.bridgeAttemptedAt = Date.now();
|
|
73
|
+
return fresh;
|
|
74
|
+
});
|
|
75
|
+
}}) : q.method === 'submit' ? await client.submitOriginal({text: q.text, beforeSubmit: async () => {
|
|
76
|
+
const fresh = await readRecord(join(root, 'delivery-hooks'), 'codex', q.session), m = fresh?.run?.managed, h = m?.nativeHandoff;
|
|
77
|
+
if (fresh?.run?.id !== q.runId || m?.taskId !== q.taskId || fresh.autoContinueEpoch !== q.epoch || !automaticContinuationAllowed(fresh) || hasPendingUserStop(join(root, 'delivery-hooks'), fresh) || fresh.run.stopReason || fresh.run.state !== 'recovery_required' || m.pendingApproval || m.resumeLease || m.liveWorkspaceLease || m.outcomeAudit?.lease || m.providerWarning || m.usageBlocked || m.maxTokens || m.understandingQuestion || m.purpose?.nativeBoundary || managedBudgetReason(fresh.run) || ['verifying', 'understanding', 'needs_clarification', 'action_association'].includes(m.phase) || m.executionUncertain || m.nativeWait || m.userQuestion || m.authenticationWait || m.dependencyWait || h?.id !== q.handoffId || h?.id !== nativeIntentId(fresh) || h.phase !== 'submitting' || h.text !== q.text) throw fail('native_tools_intent_changed');
|
|
78
|
+
}}) : await client.request(q.method, {threadId: q.session, cursor: q.cursor});
|
|
79
|
+
peer.end(JSON.stringify({ok: true, result, guidanceAuthorization: 1}) + '\n');
|
|
80
|
+
} catch (error) {peer.end(JSON.stringify({ok: false, code: error.code || 'native_tools_request_failed'}) + '\n');}
|
|
81
|
+
finally {client?.close();}
|
|
82
|
+
})();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
await new Promise((resolve, reject) => {listener.once('error', reject); listener.listen(socket, resolve);});
|
|
86
|
+
await chmod(socket, 0o600);
|
|
87
|
+
const target = join(dir, id + '.json'), temp = target + '.tmp';
|
|
88
|
+
await writeFile(temp, JSON.stringify({schema: 1, id, pid: process.pid, socket, token, startedAt: Date.now()}), {mode: 0o600});
|
|
89
|
+
await rename(temp, target);
|
|
90
|
+
return {id, async close() {
|
|
91
|
+
for (const peer of peers) peer.destroy();
|
|
92
|
+
await new Promise(resolve => listener.close(resolve));
|
|
93
|
+
await rm(socket, {force: true});
|
|
94
|
+
try {if (JSON.parse(await readFile(target, 'utf8')).id === id) await rm(target);} catch (e) {if (e.code !== 'ENOENT') throw e;}
|
|
95
|
+
}};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function openNativeBridge(options) {
|
|
99
|
+
let names;
|
|
100
|
+
try {names = (await readdir(directory(options.root))).filter(n => /^[0-9a-f-]{36}\.json$/.test(n)).sort();}
|
|
101
|
+
catch (e) {if (e.code === 'ENOENT') return null; throw e;}
|
|
102
|
+
if (!names.length) return null;
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
let client;
|
|
105
|
+
try {
|
|
106
|
+
const a = JSON.parse(await readFile(join(directory(options.root), name), 'utf8'));
|
|
107
|
+
client = await openBridgeInstance(options, a);
|
|
108
|
+
await client.request('thread/read', {threadId: options.record.session});
|
|
109
|
+
return client;
|
|
110
|
+
} catch (e) {client?.close(); if (options.signal?.aborted) throw e;}
|
|
111
|
+
}
|
|
112
|
+
throw fail('native_tools_bridge_unavailable');
|
|
113
|
+
}
|
|
114
|
+
async function openBridgeInstance({root, record, signal, guidanceId: optionsGuidanceId}, a) {
|
|
115
|
+
if (a.schema !== 1 || typeof a.token !== 'string' || a.socket !== join(directory(root), a.id + '.sock')) throw fail('native_tools_bridge_invalid');
|
|
116
|
+
const s = await stat(a.socket).catch(() => {throw fail('native_tools_bridge_unavailable');});
|
|
117
|
+
if (!s.isSocket() || s.uid !== process.getuid() || (s.mode & 0o077)) throw fail('native_tools_owner_mismatch');
|
|
118
|
+
const peers = new Set(); let closed = false, guidanceAuthorization = false;
|
|
119
|
+
const call = (method, extra = {}, authorizeGuidance) => new Promise((resolve, reject) => {
|
|
120
|
+
if (closed || signal?.aborted) {reject(fail('native_tools_closed')); return;}
|
|
121
|
+
const peer = createConnection(a.socket); peers.add(peer); let buffer = '', sent = false, settled = false, challenged = false;
|
|
122
|
+
const finish = (error, result) => {if (settled) return; settled = true; clearTimeout(timer); peers.delete(peer); peer.destroy(); error ? reject(error) : resolve(result);};
|
|
123
|
+
const lost = () => finish(fail(sent && ['submit', 'submit_guidance'].includes(method) ? 'native_submission_unknown' : 'native_tools_bridge_unavailable'));
|
|
124
|
+
const timer = setTimeout(lost, 45000);
|
|
125
|
+
peer.setEncoding('utf8'); peer.on('error', lost); peer.on('close', lost);
|
|
126
|
+
peer.on('connect', () => {sent = true; peer.write(JSON.stringify({token: a.token, session: record.session, runId: record.run.id, taskId: record.run.managed.taskId, epoch: record.autoContinueEpoch, method, ...(method === 'submit_guidance' ? {guidanceAuthorization: 1} : {}), ...(optionsGuidanceId ? {guidanceId: optionsGuidanceId} : {}), ...extra}) + '\n');});
|
|
127
|
+
peer.on('data', chunk => {
|
|
128
|
+
buffer += chunk; if (buffer.length > 16 * 1024 * 1024) {lost(); return;}
|
|
129
|
+
const end = buffer.indexOf('\n'); if (end < 0) return;
|
|
130
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
131
|
+
try {
|
|
132
|
+
const r = JSON.parse(line);
|
|
133
|
+
if (r.kind === 'guidance_authorization_required') {
|
|
134
|
+
if (method !== 'submit_guidance' || challenged || !authorizeGuidance || r.guidanceId !== optionsGuidanceId ||
|
|
135
|
+
typeof r.challenge !== 'string' || !/^[0-9a-f-]{36}$/i.test(r.challenge)) {lost(); return;}
|
|
136
|
+
challenged = true;
|
|
137
|
+
const acknowledge = authorized => {
|
|
138
|
+
if (!settled && !peer.destroyed) peer.write(JSON.stringify({token: a.token, method: 'authorize_guidance',
|
|
139
|
+
guidanceId: optionsGuidanceId, challenge: r.challenge, authorized}) + '\n');
|
|
140
|
+
};
|
|
141
|
+
Promise.resolve().then(authorizeGuidance).then(() => {signal?.throwIfAborted(); acknowledge(true);}, () => acknowledge(false)).catch(() => acknowledge(false));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (r.guidanceAuthorization === 1) guidanceAuthorization = true;
|
|
145
|
+
finish(r.ok ? null : fail(r.code), r.result);
|
|
146
|
+
} catch {lost();}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
const close = () => {closed = true; signal?.removeEventListener('abort', close); for (const peer of peers) peer.destroy();};
|
|
150
|
+
signal?.addEventListener('abort', close, {once: true});
|
|
151
|
+
return {transport: 'codex_app_tools', close, get guidanceAuthorization() {return guidanceAuthorization;},
|
|
152
|
+
request(method, params) {if (params.threadId !== record.session) throw fail('native_tools_target_mismatch'); return call(method, {cursor: params.cursor});},
|
|
153
|
+
async submitGuidance({text, beforeSubmit}) {if (!optionsGuidanceId || !guidanceAuthorization) throw fail('native_guidance_unavailable'); await beforeSubmit(); signal?.throwIfAborted(); return call('submit_guidance', {text}, beforeSubmit);},
|
|
154
|
+
async submitOriginal({text, beforeSubmit}) {await beforeSubmit(); signal?.throwIfAborted(); const fresh = await readRecord(join(root, 'delivery-hooks'), 'codex', record.session); return call('submit', {text, handoffId: fresh?.run?.managed?.nativeHandoff?.id});},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const guidancePending = record => ['prepared', 'submitting', 'submitted', 'unknown'].includes(record?.run?.managed?.userGuidance?.phase);
|
|
4
|
+
export const guidanceDigest = text => createHash('sha256').update(text).digest('hex');
|
|
5
|
+
export function guidanceTarget(record) {
|
|
6
|
+
return {provider: record?.provider, session: record?.session, cwd: record?.cwd,
|
|
7
|
+
runId: record?.run?.id, taskId: record?.run?.managed?.taskId, epoch: record?.autoContinueEpoch,
|
|
8
|
+
revision: (record?.run?.revision || 0) + (record?.run?.controlRevision || 0)};
|
|
9
|
+
}
|
|
10
|
+
export function guidanceAllowed(record, {pendingId} = {}) {
|
|
11
|
+
const m = record?.run?.managed;
|
|
12
|
+
return record?.provider === 'codex' && record.autoContinue === true && !!record.autoContinueEpoch &&
|
|
13
|
+
!record.providerArchived && !record.parentSession && record.run?.state === 'recovery_required' && !record.run.stopReason &&
|
|
14
|
+
m?.purpose?.enabled === true && m.resumeChannel === 'codex_app_server' && (!m.autoContinueEpoch || m.autoContinueEpoch === record.autoContinueEpoch) &&
|
|
15
|
+
!m.pendingApproval && !m.userQuestion && !m.understandingQuestion && !m.nativeWait && !m.dependencyWait && !m.authenticationWait &&
|
|
16
|
+
!m.resumeLease && !m.liveWorkspaceLease && !m.outcomeAudit?.lease && !m.executionUncertain && !m.providerWarning &&
|
|
17
|
+
!m.usageBlocked && !m.maxTokens && !m.purpose?.nativeBoundary &&
|
|
18
|
+
!['verifying', 'understanding', 'needs_clarification', 'action_association'].includes(m.phase) &&
|
|
19
|
+
(!m.nativeHandoff || ['finished', 'cancelled'].includes(m.nativeHandoff.phase)) &&
|
|
20
|
+
(!guidancePending(record) || pendingId === m.userGuidance.id);
|
|
21
|
+
}
|
|
22
|
+
export function assertGuidanceIntent(record, id, text) {
|
|
23
|
+
const intent = record?.run?.managed?.userGuidance;
|
|
24
|
+
if (!intent || intent.id !== id || !guidanceAllowed(record, {pendingId: id}) ||
|
|
25
|
+
JSON.stringify(intent.target) !== JSON.stringify(guidanceTarget(record)) ||
|
|
26
|
+
intent.text !== text || intent.digest !== guidanceDigest(text)) {
|
|
27
|
+
throw Object.assign(new Error('The original guidance target or send intent changed.'), {code: 'native_guidance_changed'});
|
|
28
|
+
}
|
|
29
|
+
return intent;
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
if [ "$#" -lt 1 ]; then
|
|
5
|
+
printf '%s\n' 'Codex app tools could not start: missing MCP server path.' >&2
|
|
6
|
+
exit 64
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
PATH="${PATH:-/usr/bin:/bin}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
|
10
|
+
export PATH
|
|
11
|
+
|
|
12
|
+
cache_root=${XDG_CACHE_HOME:-${HOME:-}/.cache}
|
|
13
|
+
codex_resources=
|
|
14
|
+
case "${CODEX_CLI_PATH:-}" in
|
|
15
|
+
*/*) codex_resources=${CODEX_CLI_PATH%/*} ;;
|
|
16
|
+
esac
|
|
17
|
+
for candidate in \
|
|
18
|
+
"${CODEX_MCP_NODE_PATH:-}" \
|
|
19
|
+
"${CODEX_BROWSER_USE_NODE_PATH:-}" \
|
|
20
|
+
"${CODEX_ELECTRON_RESOURCES_PATH:-}/cua_node/bin/node" \
|
|
21
|
+
"$codex_resources/cua_node/bin/node" \
|
|
22
|
+
"$cache_root/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node"
|
|
23
|
+
do
|
|
24
|
+
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
|
25
|
+
exec "$candidate" "$@"
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
if node_path=$(command -v node 2>/dev/null); then
|
|
30
|
+
case "$node_path" in
|
|
31
|
+
/*) exec "$node_path" "$@" ;;
|
|
32
|
+
esac
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
printf '%s\n' 'Codex app tools could not find a Node runtime. Reinstall or update Codex.' >&2
|
|
36
|
+
exit 127
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {createInterface} from 'node:readline';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import {join, dirname} from 'node:path';
|
|
4
|
+
import {startNativeBridge} from './bridge.mjs';
|
|
5
|
+
|
|
6
|
+
// Codex launches this ordinary stdio MCP server with its own Node runtime and
|
|
7
|
+
// fresh app-tools pipe. No saved pipe, daemon launch or app modification needed.
|
|
8
|
+
let bridge, reason = 'native_tools_attachment_missing';
|
|
9
|
+
try {
|
|
10
|
+
const resources = process.env.CODEX_ELECTRON_RESOURCES_PATH || dirname(dirname(dirname(process.execPath)));
|
|
11
|
+
bridge = await startNativeBridge({root: process.env.SIMY_HOME || join(homedir(), '.simy'), node: process.execPath,
|
|
12
|
+
server: join(resources, 'plugins/openai-bundled/plugins/codex-app-tools/server.mjs'), pipe: process.env.CODEX_APP_TOOLS_PIPE_PATH});
|
|
13
|
+
} catch (e) {reason = e.code || reason;}
|
|
14
|
+
const lines = createInterface({input: process.stdin});
|
|
15
|
+
for await (const line of lines) {
|
|
16
|
+
let q; try {q = JSON.parse(line);} catch {continue;}
|
|
17
|
+
if (q.id === undefined) continue;
|
|
18
|
+
let result;
|
|
19
|
+
if (q.method === 'initialize') result = {protocolVersion: q.params.protocolVersion, capabilities: {tools: {}}, serverInfo: {name: 'simy-native-continuation', version: '1.0.0'}};
|
|
20
|
+
else if (q.method === 'tools/list') result = {tools: [{name: 'native_connection_status', description: 'Read SIMY native Codex transport availability. Connection availability is not execution or completion proof.', inputSchema: {type: 'object', properties: {}, additionalProperties: false}, annotations: {readOnlyHint: true}}]};
|
|
21
|
+
else if (q.method === 'tools/call' && q.params?.name === 'native_connection_status') result = {content: [{type: 'text', text: JSON.stringify({connected: !!bridge, reason: bridge ? 'app_launched_transport' : reason})}]};
|
|
22
|
+
else if (q.method === 'ping') result = {};
|
|
23
|
+
else {process.stdout.write(JSON.stringify({jsonrpc: '2.0', id: q.id, error: {code: -32601, message: 'Method not found'}}) + '\n'); continue;}
|
|
24
|
+
process.stdout.write(JSON.stringify({jsonrpc: '2.0', id: q.id, result}) + '\n');
|
|
25
|
+
}
|
|
26
|
+
await bridge?.close();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Exact tool output, original sender, complete text and XML escaping are all
|
|
2
|
+
// required. A quoted message or worker assertion is never a send receipt.
|
|
3
|
+
export function matchesNativeDelegation(turn, session, text) {
|
|
4
|
+
const escaped = text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
5
|
+
const expected = `<codex_delegation>\n <source_thread_id>${session}</source_thread_id>\n <input>${escaped}</input>\n</codex_delegation>`;
|
|
6
|
+
return turn.items?.some(item => item.type === 'functionCallOutput' && item.namespace === 'codex_app' &&
|
|
7
|
+
item.name === 'send_message_to_thread' && !item.output?.truncated &&
|
|
8
|
+
(typeof item.output === 'string' ? item.output : item.output?.text) === expected) === true;
|
|
9
|
+
}
|
|
@@ -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
|
+
}
|