odai-dsh-agent 0.2.55 → 0.2.57
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/README.md +11 -9
- package/client/client.js +14 -7
- package/package.json +1 -1
- package/preset/odai/package.json +2 -1
- package/preset/odai/runtime/context-activation.mjs +1 -1
- package/preset/odai/runtime/context-activation.mjs.map +1 -1
- package/preset/odai/runtime/index.d.mts.map +1 -1
- package/preset/odai/runtime/index.mjs +51 -2
- package/preset/odai/runtime/index.mjs.map +1 -1
- package/preset/odai/runtime/lead-mode.d.mts +23 -0
- package/preset/odai/runtime/lead-mode.d.mts.map +1 -0
- package/preset/odai/runtime/lead-mode.mjs +17 -0
- package/preset/odai/runtime/lead-mode.mjs.map +1 -0
- package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/lifecycle-runtime.mjs +197 -14
- package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
- package/preset/odai/runtime/responsibility-gap.d.mts +4 -1
- package/preset/odai/runtime/responsibility-gap.d.mts.map +1 -1
- package/preset/odai/runtime/responsibility-gap.mjs +1 -0
- package/preset/odai/runtime/responsibility-gap.mjs.map +1 -1
- package/preset/odai/runtime/review-evidence.d.mts +2 -0
- package/preset/odai/runtime/review-evidence.d.mts.map +1 -1
- package/preset/odai/runtime/review-evidence.mjs +45 -0
- package/preset/odai/runtime/review-evidence.mjs.map +1 -1
- package/preset/odai/runtime/role-overlays.d.mts +1 -1
- package/preset/odai/runtime/role-overlays.d.mts.map +1 -1
- package/preset/odai/runtime/role-overlays.mjs +4 -1
- package/preset/odai/runtime/role-overlays.mjs.map +1 -1
- package/preset/odai/runtime/route-session.d.mts +20 -0
- package/preset/odai/runtime/route-session.d.mts.map +1 -0
- package/preset/odai/runtime/route-session.mjs +44 -0
- package/preset/odai/runtime/route-session.mjs.map +1 -0
- package/preset/odai/runtime/router.d.mts +2 -1
- package/preset/odai/runtime/router.d.mts.map +1 -1
- package/preset/odai/runtime/router.mjs +52 -1
- package/preset/odai/runtime/router.mjs.map +1 -1
- package/preset/odai/runtime/routing-config.d.mts +5 -2
- package/preset/odai/runtime/routing-config.d.mts.map +1 -1
- package/preset/odai/runtime/routing-config.mjs +25 -20
- package/preset/odai/runtime/routing-config.mjs.map +1 -1
- package/preset/odai/runtime/runtime-config.d.mts +1 -1
- package/preset/odai/runtime/runtime-config.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-support.d.mts +6 -1
- package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-support.mjs +21 -8
- package/preset/odai/runtime/runtime-support.mjs.map +1 -1
- package/preset/odai/runtime/runtime-types.d.mts +2 -0
- package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-types.mjs.map +1 -1
- package/preset/odai/runtime/session-evidence.d.mts.map +1 -1
- package/preset/odai/runtime/session-evidence.mjs +6 -0
- package/preset/odai/runtime/session-evidence.mjs.map +1 -1
- package/preset/odai/runtime/skill-bundle.d.mts +1 -1
- package/preset/odai/runtime/skill-bundle.d.mts.map +1 -1
- package/preset/odai/runtime/tool-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/tool-runtime.mjs +28 -0
- package/preset/odai/runtime/tool-runtime.mjs.map +1 -1
- package/preset/odai/skills/odai-orchestration/SKILL.md +1 -1
- package/preset/odai/skills/odai-orchestration/assets/routing-roles/controller.md +1 -1
- package/preset/odai/skills/odai-orchestration/assets/routing-roles/writer.md +9 -0
- package/preset/odai/skills/odai-orchestration/manifest.json +10 -4
- package/preset/odai/skills/odai-orchestration/references/install.md +66 -4
- package/preset/odai/skills/odai-orchestration/references/orchestration.md +2 -1
- package/preset/odai/skills/odai-orchestration/scripts/build-routing.mjs +78 -24
- package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.d.mts +1 -1
- package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.mjs +6 -5
- package/preset/odai/skills/odai-orchestration/scripts/install-routing.mjs +66 -15
- package/preset/odai/skills/odai-orchestration/scripts/local-cli.d.mts +8 -0
- package/preset/odai/skills/odai-orchestration/scripts/local-cli.mjs +25 -9
- package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.d.mts +6 -0
- package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.mjs +93 -0
- package/preset/odai/skills/odai-orchestration/scripts/route-hook.mjs +243 -0
- package/preset/odai/skills/odai-orchestration/scripts/run-role.mjs +71 -16
- package/preset/odai/skills/odai-orchestration/scripts/verify-routing.mjs +2 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { recognizeNamedRequests, recognizeLeadSwitch, parseLeadVerdict } from './recognize-routing.mjs';
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const manifest = JSON.parse(readFileSync(path.join(root, 'odai-named-dispatch.json'), 'utf8'));
|
|
10
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
11
|
+
const event = input.hook_event_name;
|
|
12
|
+
for (const field of ['tool_input', 'tool_response']) {
|
|
13
|
+
if (typeof input[field] === 'string') { try { input[field] = JSON.parse(input[field]); } catch { /* shell output may be plain text */ } }
|
|
14
|
+
}
|
|
15
|
+
// Subagent hooks must never consume the controller's pending requests.
|
|
16
|
+
if (!input.session_id || input.agent_id) process.exit(0);
|
|
17
|
+
const stateRoot = manifest.host === 'codex' ? path.join(input.cwd || process.cwd(), '.odai', 'named-dispatch') : path.join(root, 'named-dispatch');
|
|
18
|
+
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
19
|
+
const file = path.join(stateRoot, createHash('sha256').update(input.session_id).digest('hex') + '.json');
|
|
20
|
+
// Serialize host callbacks; fail visibly instead of racing the pending queue.
|
|
21
|
+
const lock = file + '.lock';
|
|
22
|
+
const deadline = Date.now() + 5000;
|
|
23
|
+
while (true) {
|
|
24
|
+
try { mkdirSync(lock); break; }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'EEXIST' || Date.now() >= deadline) throw error;
|
|
27
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let state;
|
|
31
|
+
try {
|
|
32
|
+
state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { requests: [], started: Date.now() };
|
|
33
|
+
if (event === 'UserPromptSubmit') {
|
|
34
|
+
const text = String(input.prompt || '');
|
|
35
|
+
const notification = hostNotification(text);
|
|
36
|
+
if (notification) completeNotification(text);
|
|
37
|
+
const requests = notification || manifest.namedDispatch === false ? [] : recognizeNamedRequests(text, manifest.mapping);
|
|
38
|
+
if (!notification && !/^\s*(继续|接着|continue|go on)[。.!!\s]*$/iu.test(text) && !requests.some(r => r.canceled)) state = { requests: [], started: Date.now(), ...(state.lead ? { lead: state.lead } : {}) };
|
|
39
|
+
if (!notification && manifest.leadMode) {
|
|
40
|
+
const change = recognizeLeadSwitch(text, Object.fromEntries(['planner', 'reviewer'].map(role => [role, manifest.mapping[role]])));
|
|
41
|
+
if (change) {
|
|
42
|
+
state.lead = change;
|
|
43
|
+
context(`主导模式${change.enabled ? '已开启' : '未开启或已关闭'};规划与审查:${change.roles.map(role => `${role}=${manifest.mapping[role]?.model}`).join(',') || '无'}。${change.issue || ''} 缺少或未匹配的插槽不调用;配置请更新宿主路由映射。说“关闭主导模式”退出。`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const request of requests) {
|
|
47
|
+
if (request.canceled) state.requests = state.requests.filter(r => r.responsibility !== request.responsibility || r.done);
|
|
48
|
+
else state.requests.push({ ...request });
|
|
49
|
+
}
|
|
50
|
+
if (!notification && leadEnabled()) context(leadFlow());
|
|
51
|
+
if (state.requests.length) context(state.requests.map(describe).join('\n'));
|
|
52
|
+
} else if (event === 'PreToolUse') {
|
|
53
|
+
if (leadEnabled() && !readOnly(input.tool_name) && !state.requests.some(r => !r.issue && invocation(r))) ensureLead('planner');
|
|
54
|
+
const pending = state.requests.find(r => !r.done && !r.issue && r.timing === 'start');
|
|
55
|
+
if (pending && !invocation(pending) && !(pending.agentId && input.tool_name === 'wait' && input.tool_input?.ids?.includes(pending.agentId)) && !readOnly(input.tool_name)) {
|
|
56
|
+
console.error(describe(pending)); process.exitCode = 2;
|
|
57
|
+
}
|
|
58
|
+
} else if (event === 'PostToolUse') {
|
|
59
|
+
if (leadEnabled()) {
|
|
60
|
+
for (const role of state.lead.roles) {
|
|
61
|
+
if (manifest.mapping[role] && invocation({ responsibility: role }) && (role === 'planner' || state.writes)) {
|
|
62
|
+
const last = state.requests.findLast(r => r.leadReview);
|
|
63
|
+
ensureLead(role, role === 'reviewer' && last?.verdict === 'changes-requested' && last.repairNudged && last.round < 2 && state.writes > last.reviewWrites ? last.round + 1 : 0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const r of state.requests.filter(r => r.agentId && !r.done)) {
|
|
68
|
+
const response = input.tool_response;
|
|
69
|
+
const status = response?.status?.[r.agentId];
|
|
70
|
+
if (input.tool_name === 'wait' && status && typeof status.completed === 'string') { r.done = true; r.result = 'satisfied'; r.handback = status.completed; }
|
|
71
|
+
}
|
|
72
|
+
const roleCall = state.requests.some(r => !r.issue && (invocation(r) || (input.tool_name === 'wait' && r.agentId && input.tool_response?.status?.[r.agentId])));
|
|
73
|
+
const request = state.requests.find(r => !r.done && !r.issue && invocation(r));
|
|
74
|
+
if (request) {
|
|
75
|
+
const response = input.tool_response;
|
|
76
|
+
const failed = (leadEnabled() && ['failed', 'killed', 'cancelled', 'canceled'].includes(response?.status)) || response?.is_error || response?.error || (Number.isInteger(response?.exit_code) && response.exit_code !== 0);
|
|
77
|
+
if (failed) { request.done = true; request.result = 'failed'; context('点名调用失败,不重试、不替换模型;最终答复说明原因。'); }
|
|
78
|
+
else if (manifest.mapping[request.responsibility].backend === 'local-cli') {
|
|
79
|
+
// Accept only the evidence explicitly returned by this executor invocation.
|
|
80
|
+
const match = /odai evidence: ([^\r\n]+)/u.exec(typeof response === 'string' ? response : JSON.stringify(response).replaceAll('\\n', '\n'));
|
|
81
|
+
let evidence;
|
|
82
|
+
try { if (match) evidence = JSON.parse(readFileSync(match[1].trim(), 'utf8')); } catch { /* missing evidence is not success */ }
|
|
83
|
+
const observed = evidence?.observed;
|
|
84
|
+
if (evidence?.role === request.responsibility && Date.parse(observed?.started_at) >= state.started && observed?.model_verified && observed?.model_verification === 'verified' && observed?.exit_code === 0 && !observed?.error) {
|
|
85
|
+
request.done = true; request.result = 'satisfied';
|
|
86
|
+
if (request.leadReview) request.handback = evidence.output && existsSync(evidence.output) ? readHandback(evidence.output) : responseText(response).replace(/(?:^|\n)odai evidence:[^\n]*/gu, '').trim();
|
|
87
|
+
} else { request.done = true; request.result = 'unverified'; context('点名职责的执行器证据缺失或未核实;最终答复说明,不重试。'); }
|
|
88
|
+
} else if (input.tool_name === 'spawn_agent') {
|
|
89
|
+
if (response?.agent_id) request.agentId = response.agent_id;
|
|
90
|
+
} else if (response) {
|
|
91
|
+
const text = responseText(response);
|
|
92
|
+
if (typeof response.resolvedModel === 'string') {
|
|
93
|
+
request.resolvedModel = response.resolvedModel;
|
|
94
|
+
context(modelReport(request));
|
|
95
|
+
}
|
|
96
|
+
// Claude may launch asynchronously even when run_in_background was omitted.
|
|
97
|
+
if (response.isAsync === true || response.status === 'async_launched' || /^Async agent launched successfully\./u.test(text) || input.tool_input?.run_in_background || input.tool_input?.background) {
|
|
98
|
+
request.inProgress = true;
|
|
99
|
+
request.toolUseId = input.tool_use_id;
|
|
100
|
+
request.outputFile = response.outputFile;
|
|
101
|
+
request.agentId = response.agentId || /\bagentId:\s*([^\s(]+)/u.exec(text)?.[1];
|
|
102
|
+
} else if (response.status === 'completed') {
|
|
103
|
+
request.done = true; request.result = 'satisfied'; request.handback = text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (leadEnabled() && !roleCall && !readOnly(input.tool_name) && !input.tool_response?.is_error && !input.tool_response?.error && !(Number.isInteger(input.tool_response?.exit_code) && input.tool_response.exit_code !== 0)) state.writes = (state.writes || 0) + 1;
|
|
108
|
+
} else if (event === 'Stop' && !input.stop_hook_active) {
|
|
109
|
+
updateReviews();
|
|
110
|
+
const invalid = state.requests.find(r => r.leadReview && r.verdict === 'invalid' && !r.invalidNudged);
|
|
111
|
+
if (invalid) {
|
|
112
|
+
invalid.invalidNudged = true;
|
|
113
|
+
console.log(JSON.stringify({ decision: 'block', reason: '审查回交没有可识别的 VERDICT 行,请在最终答复里如实说明审查结论未按格式给出' }));
|
|
114
|
+
}
|
|
115
|
+
const repair = state.requests.find(r => r.leadReview && r.verdict === 'changes-requested' && !r.repairNudged && r.round < 2);
|
|
116
|
+
if (repair) {
|
|
117
|
+
repair.repairNudged = true;
|
|
118
|
+
console.log(JSON.stringify({ decision: 'block', reason: `主导审查要求修正:${repair.handback}。按意见修正;有成功写入后再审查。` }));
|
|
119
|
+
}
|
|
120
|
+
if (leadEnabled() && state.writes && !repair) {
|
|
121
|
+
const last = state.requests.findLast(r => r.leadReview);
|
|
122
|
+
if (!last || (last.verdict === 'changes-requested' && last.repairNudged && last.round < 2 && state.writes > last.reviewWrites)) ensureLead('reviewer', last ? last.round + 1 : 0);
|
|
123
|
+
}
|
|
124
|
+
const waiting = state.requests.some(r => !r.done && !r.issue && (r.timing === 'start' || r.inProgress || r.agentId));
|
|
125
|
+
const pending = waiting || repair ? [] : state.requests.filter(r => !r.done && !r.issue && !r.nudged && r.timing === 'completion');
|
|
126
|
+
if (pending.length) {
|
|
127
|
+
for (const request of pending) request.nudged = true;
|
|
128
|
+
console.log(JSON.stringify({ decision: 'block', reason: pending.map(describe).join('\n') }));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
updateReviews();
|
|
132
|
+
const temp = file + '.' + randomUUID() + '.tmp';
|
|
133
|
+
writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); renameSync(temp, file);
|
|
134
|
+
} finally {
|
|
135
|
+
const { rmdirSync } = await import('node:fs'); rmdirSync(lock);
|
|
136
|
+
}
|
|
137
|
+
function responseText(response) {
|
|
138
|
+
if (typeof response === 'string') return response;
|
|
139
|
+
if (Array.isArray(response)) return response.map(responseText).join('\n');
|
|
140
|
+
if (typeof response?.text === 'string') return response.text;
|
|
141
|
+
if (typeof response?.stdout === 'string') return response.stdout;
|
|
142
|
+
if (typeof response?.output === 'string') return response.output;
|
|
143
|
+
if (response?.content) return responseText(response.content);
|
|
144
|
+
return JSON.stringify(response);
|
|
145
|
+
}
|
|
146
|
+
function hostNotification(text) {
|
|
147
|
+
// Host envelopes are continuation input, never a new direct user task.
|
|
148
|
+
return /^\s*<(task-notification|system-reminder|subagent_notification)(?:\s|>)/u.test(text) || input.origin?.kind === 'task-notification';
|
|
149
|
+
}
|
|
150
|
+
function completeNotification(text) {
|
|
151
|
+
const body = /^\s*(?:<system-reminder>\s*)?(<task-notification>[\s\S]*?<\/task-notification>)/u.exec(text)?.[1];
|
|
152
|
+
if (!body) return;
|
|
153
|
+
// Read only header fields, never status-like text embedded in an agent result.
|
|
154
|
+
const header = body.split(/<(?:summary|result|usage|note)>/u)[0];
|
|
155
|
+
const field = name => new RegExp(`<${name}>([^<]*)</${name}>`, 'u').exec(header)?.[1].trim();
|
|
156
|
+
const toolUseId = field('tool-use-id');
|
|
157
|
+
const status = field('status');
|
|
158
|
+
const pending = state.requests.filter(r => r.inProgress && !r.done);
|
|
159
|
+
const byTool = toolUseId ? pending.filter(r => r.toolUseId === toolUseId) : [];
|
|
160
|
+
const taskId = field('task-id');
|
|
161
|
+
const matches = byTool.length ? byTool : pending.filter(r => taskId && r.agentId === taskId);
|
|
162
|
+
for (const request of matches) {
|
|
163
|
+
if (status === 'completed') {
|
|
164
|
+
request.done = true; request.result = 'satisfied';
|
|
165
|
+
if (request.leadReview) {
|
|
166
|
+
try { request.handback = readHandback(request.outputFile); } catch { request.handback = ''; }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (['failed', 'killed', 'cancelled', 'canceled'].includes(status)) {
|
|
170
|
+
request.done = true; request.result = 'failed';
|
|
171
|
+
context(`点名 ${request.responsibility} 后台调用${status};最终答复说明原因,不重试、不替换模型。`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function modelReport(r) {
|
|
176
|
+
const configured = manifest.mapping[r.responsibility]?.model;
|
|
177
|
+
const expected = String(configured || '').toLowerCase();
|
|
178
|
+
const actual = r.resolvedModel.toLowerCase();
|
|
179
|
+
const mismatch = expected && !actual.includes(expected) && !expected.includes(actual);
|
|
180
|
+
return `点名 ${r.responsibility} 的宿主 resolvedModel:${r.resolvedModel}。最终答复按此说明实际模型,不采用子代理自报。${mismatch ? `与配置模型 ${configured} 明显不符,请如实说明;不拦截、不重试。` : ''}`;
|
|
181
|
+
}
|
|
182
|
+
function describe(r) {
|
|
183
|
+
const route = manifest.mapping[r.responsibility];
|
|
184
|
+
if (r.issue) return `点名 ${r.responsibility} 未执行:${r.issue};当前映射 ${JSON.stringify(route || null)}。如实说明,不替换模型或修改配置。`;
|
|
185
|
+
const command = route.backend === 'local-cli' ? `node ${JSON.stringify(path.join(root, 'odai-run-role.mjs'))} --role ${r.responsibility} --input <任务包文件>` : `${manifest.host === 'codex' ? 'odai_' : 'odai-'}${r.responsibility}`;
|
|
186
|
+
return `${r.source === 'lead-mode' ? '主导模式' : '用户点名'} ${r.responsibility}(${r.timing === 'start' ? '动手前' : '完成时'}):${r.excerpt}。调用 ${command},回交后继续;最终答复说明哪个模型做了什么或未完成原因。${(leadEnabled() && r.responsibility === 'planner' && state.lead.roles.includes('reviewer')) || r.leadReview || (leadEnabled() && state.lead.roles.includes('reviewer') && r.responsibility === 'reviewer' && r.timing === 'completion') ? '审查任务包必须要求:回交最后单独一行写 VERDICT: pass 或 VERDICT: changes-requested。' : ''}${r.resolvedModel ? modelReport(r) : ''}`;
|
|
187
|
+
}
|
|
188
|
+
function invocation(r) {
|
|
189
|
+
const args = input.tool_input || {};
|
|
190
|
+
const route = manifest.mapping[r.responsibility];
|
|
191
|
+
if (route.backend === 'local-cli') {
|
|
192
|
+
const command = args.command || args.cmd;
|
|
193
|
+
// Require an actual standalone executor command, not an echo or quoted mention.
|
|
194
|
+
return typeof command === 'string' && /^\s*node\s+/u.test(command) && new RegExp(`(?:[/'\"]|^)odai-run-role\\.mjs['\"]?\\s+--role\\s+${r.responsibility}(?:\\s|$)`, 'u').test(command) && !/[;&|`\n]|\$\(/u.test(command);
|
|
195
|
+
}
|
|
196
|
+
return /^(Agent|Task|spawn_agent)$/u.test(input.tool_name) && [args.subagent_type, args.agent_type].includes(`${manifest.host === 'codex' ? 'odai_' : 'odai-'}${r.responsibility}`);
|
|
197
|
+
}
|
|
198
|
+
function readOnly(name) { return /^(Read|Glob|Grep|read_file|list_dir|glob|grep|web_search|view_image)$/u.test(name || ''); }
|
|
199
|
+
function context(text) { console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } })); }
|
|
200
|
+
|
|
201
|
+
function leadFlow() {
|
|
202
|
+
const roles = state.lead.roles;
|
|
203
|
+
return '主导模式流程:' + (roles.includes('planner') ? '第一次改动前先派规划插槽。' : '') + (roles.includes('reviewer') ? '有改动时做完派审查插槽;派审查时要求回交最后单独一行写 VERDICT: pass 或 VERDICT: changes-requested。' : '') + '最终答复按宿主记录说明实际模型。';
|
|
204
|
+
}
|
|
205
|
+
function leadEnabled() { return manifest.leadMode && state.lead?.enabled; }
|
|
206
|
+
function ensureLead(role, round = 0) {
|
|
207
|
+
if (!state.lead.roles.includes(role) || !manifest.mapping[role]) return;
|
|
208
|
+
const timing = role === 'planner' ? 'start' : 'completion';
|
|
209
|
+
let request = state.requests.find(r => r.responsibility === role && r.timing === timing && !r.issue && (role === 'planner' || (r.round || 0) === round));
|
|
210
|
+
if (!request) {
|
|
211
|
+
request = { responsibility: role, timing, source: 'lead-mode', excerpt: role === 'planner' ? '第一次写入前规划' : '验收本任务写入' };
|
|
212
|
+
state.requests.push(request);
|
|
213
|
+
}
|
|
214
|
+
if (role === 'reviewer') {
|
|
215
|
+
request.leadReview = true; request.round = round; request.reviewWrites ??= state.writes || 0;
|
|
216
|
+
}
|
|
217
|
+
return request;
|
|
218
|
+
}
|
|
219
|
+
function updateReviews() {
|
|
220
|
+
if (!leadEnabled()) return;
|
|
221
|
+
for (const r of state.requests.filter(r => r.leadReview && r.done && !r.verdict)) {
|
|
222
|
+
r.verdict = r.result === 'satisfied' && !modelMismatch(r) ? parseLeadVerdict(r.handback || '') : 'failed';
|
|
223
|
+
if (r.verdict === 'invalid' || r.verdict === 'failed' || (r.verdict === 'changes-requested' && r.round >= 2)) context(`主导审查 ${r.verdict};不再追加轮次,最终答复如实说明未解决的问题。`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function modelMismatch(r) {
|
|
228
|
+
const expected = String(manifest.mapping[r.responsibility]?.model || '').toLowerCase();
|
|
229
|
+
const actual = String(r.resolvedModel || '').toLowerCase();
|
|
230
|
+
return expected && actual && !actual.includes(expected) && !expected.includes(actual);
|
|
231
|
+
}
|
|
232
|
+
function readHandback(file) {
|
|
233
|
+
const text = readFileSync(file, 'utf8');
|
|
234
|
+
// Claude background output may be a transcript rather than plain text.
|
|
235
|
+
const messages = [];
|
|
236
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
237
|
+
try {
|
|
238
|
+
const entry = JSON.parse(line);
|
|
239
|
+
if (entry.type === 'assistant' && entry.message?.content) messages.push(responseText(entry.message.content));
|
|
240
|
+
} catch { /* plain text handback */ }
|
|
241
|
+
}
|
|
242
|
+
return messages.length ? messages.join('\n') : text;
|
|
243
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
|
|
11
11
|
process.on("uncaughtException", (error) => {
|
|
12
12
|
console.error(error?.message || String(error));
|
|
@@ -15,17 +15,18 @@ process.on("uncaughtException", (error) => {
|
|
|
15
15
|
|
|
16
16
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const args = parseArgs(process.argv.slice(2));
|
|
18
|
-
const supportedRoles = new Set(["controller", "researcher", "planner", "reviewer", "frontend", "creative"]);
|
|
18
|
+
const supportedRoles = new Set(["controller", "researcher", "planner", "reviewer", "frontend", "creative", "writer"]);
|
|
19
19
|
|
|
20
20
|
if (args.help) {
|
|
21
21
|
console.log(`Usage:
|
|
22
|
-
node .codex/odai-run-role.mjs --role <controller|researcher|planner|reviewer|frontend|creative> \\
|
|
22
|
+
node .codex/odai-run-role.mjs --role <controller|researcher|planner|reviewer|frontend|creative|writer> \\
|
|
23
23
|
[--input FILE] [--output FILE] [--evidence FILE] [--cwd PATH] [--manifest FILE] \\
|
|
24
|
-
[--codex-bin FILE] [--sandbox MODE] [--session-id ID] [--closeout-ids A1,A2]
|
|
24
|
+
[--session-key KEY] [--codex-bin FILE] [--sandbox MODE] [--session-id ID] [--closeout-ids A1,A2]
|
|
25
25
|
|
|
26
26
|
从 odai 托管清单取得角色、模型与推理档,运行一次实际责任并记录可核对证据。
|
|
27
|
-
Claude 宿主使用 .claude/odai-run-role.mjs
|
|
28
|
-
|
|
27
|
+
Claude 宿主使用 .claude/odai-run-role.mjs;Codex 宿主支持 claude/antigravity/grok 本机 CLI。
|
|
28
|
+
--session-key 为缺少宿主环境变量时显式提供的宿主会话唯一键;
|
|
29
|
+
未传 --evidence 时,Codex 本机 CLI 保存到当前项目 .odai/role-evidence/,Claude 保存到执行器同目录 role-evidence/。--timeout 可覆盖本机 CLI 超时(1–7200 整数秒)。
|
|
29
30
|
--codex-bin、--sandbox、--session-id、--closeout-ids 仅用于 Codex 宿主。`);
|
|
30
31
|
process.exit(0);
|
|
31
32
|
}
|
|
@@ -36,13 +37,14 @@ const manifest = readJson(manifestFile, "路由清单");
|
|
|
36
37
|
if (manifest.id !== "odai-routing-installation" || !["codex", "claude"].includes(manifest.host)) fail("路由清单不是 Codex 或 Claude odai 托管安装");
|
|
37
38
|
const mapping = manifest.mapping?.[args.role];
|
|
38
39
|
if (manifest.host === "codex" && !mapping?.model) fail(`路由清单缺少 ${args.role} 映射`);
|
|
39
|
-
|
|
40
|
+
|
|
40
41
|
|
|
41
42
|
const workdir = path.resolve(args.cwd || process.cwd());
|
|
42
|
-
if (manifest.host === "claude") await runLocalRole();
|
|
43
|
+
if (manifest.host === "claude" || mapping?.backend === "local-cli") await runLocalRole();
|
|
43
44
|
else runCodexRole();
|
|
44
45
|
|
|
45
46
|
function runCodexRole() {
|
|
47
|
+
if (mapping.session === "continue") fail("Codex 原生角色暂不支持会话续接:证据不足");
|
|
46
48
|
const input = args.input ? readFileSync(path.resolve(args.input), "utf8").trim() : readFileSync(0, "utf8").trim();
|
|
47
49
|
if (!input) fail("角色输入为空");
|
|
48
50
|
const contractFile = path.join(scriptDir, "role-contracts", `odai-${args.role}.md`);
|
|
@@ -56,7 +58,7 @@ function runCodexRole() {
|
|
|
56
58
|
mkdirSync(path.dirname(outputFile), { recursive: true });
|
|
57
59
|
|
|
58
60
|
try {
|
|
59
|
-
const readOnly = ["researcher", "planner", "reviewer", "creative"].includes(args.role);
|
|
61
|
+
const readOnly = ["researcher", "planner", "reviewer", "creative", "writer"].includes(args.role);
|
|
60
62
|
const closeoutIds = parseCloseoutIds(args.closeoutIds);
|
|
61
63
|
const schemaFile = closeoutIds.length > 0 ? writeCloseoutSchema(temporary, closeoutIds) : "";
|
|
62
64
|
const hostOwnsRouting = process.env.ODAI_ROUTING_ACTIVE === "1";
|
|
@@ -143,20 +145,23 @@ function runCodexRole() {
|
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
async function runLocalRole() {
|
|
146
|
-
const
|
|
148
|
+
const stateBase = manifest.host === "codex" ? path.join(workdir, ".odai") : scriptDir;
|
|
149
|
+
const evidenceFile = path.resolve(args.evidence || path.join(stateBase, "role-evidence", `${args.role}-${Date.now()}-${randomUUID()}.json`));
|
|
147
150
|
const outputFile = args.output ? path.resolve(args.output) : null;
|
|
148
151
|
const startedAt = new Date().toISOString();
|
|
149
152
|
const started = Date.now();
|
|
150
153
|
let result;
|
|
151
154
|
let error;
|
|
155
|
+
let sessionEvidence;
|
|
156
|
+
let unlock;
|
|
152
157
|
try {
|
|
153
|
-
if (args.role === "controller") fail("
|
|
154
|
-
if (mapping?.backend !== "local-cli" || !["codex", "antigravity", "grok"].includes(mapping.cli)) {
|
|
158
|
+
if (args.role === "controller") fail("controller 不支持 local-cli 映射");
|
|
159
|
+
if (mapping?.backend !== "local-cli" || !(manifest.host === "codex" ? ["claude", "antigravity", "grok"] : ["codex", "antigravity", "grok"]).includes(mapping.cli)) {
|
|
155
160
|
fail("Claude 宿主执行器仅接受 codex、antigravity 或 grok 的 local-cli 映射");
|
|
156
161
|
}
|
|
157
162
|
const unsupported = ["--codex-bin", "--sandbox", "--session-id", "--closeout-ids"].filter((option) => args.supplied.has(option));
|
|
158
163
|
if (unsupported.length) fail(`Claude 本机 CLI 分支不支持 Codex 专有选项:${unsupported.join("、")}`);
|
|
159
|
-
const allowedFields = new Set(["backend", "cli", "model", "reasoning_effort", "timeoutSeconds"]);
|
|
164
|
+
const allowedFields = new Set(["backend", "cli", "model", "reasoning_effort", "timeoutSeconds", "session"]);
|
|
160
165
|
for (const key of Object.keys(mapping)) {
|
|
161
166
|
if (!allowedFields.has(key)) fail(`未知本机 CLI 映射字段:${key}`);
|
|
162
167
|
}
|
|
@@ -166,6 +171,7 @@ async function runLocalRole() {
|
|
|
166
171
|
const route = validateLocalCliRoute({
|
|
167
172
|
backend: mapping.backend, cli: mapping.cli, model: mapping.model,
|
|
168
173
|
...(mapping.reasoning_effort != null ? { reasoningEffort: mapping.reasoning_effort } : {}),
|
|
174
|
+
...(mapping.session !== undefined ? { session: mapping.session } : {}),
|
|
169
175
|
...(mapping.timeoutSeconds !== undefined ? { timeoutSeconds: mapping.timeoutSeconds } : {}),
|
|
170
176
|
});
|
|
171
177
|
// Only an explicit flag overrides the shared executor's 1200-second default.
|
|
@@ -176,7 +182,54 @@ async function runLocalRole() {
|
|
|
176
182
|
const contractFile = path.join(scriptDir, "role-contracts", `odai-${args.role}.md`);
|
|
177
183
|
if (!existsSync(contractFile)) fail(`缺少角色契约:${contractFile}`);
|
|
178
184
|
const contract = readFileSync(contractFile, "utf8");
|
|
179
|
-
|
|
185
|
+
const invoke = resumeSessionId => executeLocalCli({ route, prompt: `${contract}\n\n${input}`, cwd: workdir, ...(resumeSessionId ? { resumeSessionId } : {}) });
|
|
186
|
+
let stateFile;
|
|
187
|
+
let previous;
|
|
188
|
+
if (route.session === "continue") {
|
|
189
|
+
const hostSession = (manifest.host === "codex" ? process.env.CODEX_THREAD_ID || process.env.CLAUDE_CODE_SESSION_ID : process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID) || args.sessionKey;
|
|
190
|
+
if (!hostSession) fail("会话续接缺少宿主会话键;请显式传 --session-key");
|
|
191
|
+
const key = createHash("sha256").update(JSON.stringify([manifest.host, hostSession, route.backend, route.cli, route.model])).digest("hex");
|
|
192
|
+
const stateRoot = path.join(stateBase, "role-sessions");
|
|
193
|
+
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
194
|
+
stateFile = path.join(stateRoot, `${key}.json`);
|
|
195
|
+
const lock = `${stateFile}.lock`;
|
|
196
|
+
const deadline = Date.now() + args.timeout * 1000;
|
|
197
|
+
while (true) {
|
|
198
|
+
try { mkdirSync(lock, { mode: 0o700 }); break; }
|
|
199
|
+
catch (failure) {
|
|
200
|
+
if (failure.code !== "EEXIST") throw failure;
|
|
201
|
+
if (Date.now() >= deadline) fail(`等待会话锁超时:${lock};确认没有运行进程后再移除遗留锁`);
|
|
202
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
unlock = () => rmSync(lock, { recursive: true });
|
|
206
|
+
try {
|
|
207
|
+
if (existsSync(stateFile)) {
|
|
208
|
+
previous = JSON.parse(readFileSync(stateFile, "utf8")).sessionId;
|
|
209
|
+
if (typeof previous !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(previous)) throw new Error("Invalid saved session ID");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch { sessionEvidence = { mode: "resume-failed-created", notice: "续接状态损坏,已新开" }; }
|
|
213
|
+
}
|
|
214
|
+
result = await invoke(previous);
|
|
215
|
+
if (previous) {
|
|
216
|
+
sessionEvidence = { mode: "resumed" };
|
|
217
|
+
if (result.status === "error" && !result.boundaryStatus) {
|
|
218
|
+
result = await invoke();
|
|
219
|
+
sessionEvidence = { mode: "resume-failed-created", notice: "续接失败,已新开" };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (stateFile && result.status === "completed" && !result.sessionId) { result = { ...result, status: "error", error: "后端未提供可续接会话标识" }; }
|
|
223
|
+
if (stateFile) {
|
|
224
|
+
sessionEvidence ||= { mode: "created" };
|
|
225
|
+
if (sessionEvidence.notice) console.error(sessionEvidence.notice);
|
|
226
|
+
// Clear stale state after failed resume; only verified handbacks become reusable.
|
|
227
|
+
if (result.status === "completed" && result.modelVerified && result.modelVerification === "verified" && result.reasoningEffortVerification !== "mismatch" && result.text?.trim() && result.sessionId) {
|
|
228
|
+
const temporaryState = `${stateFile}.${randomUUID()}.tmp`;
|
|
229
|
+
writeFileSync(temporaryState, JSON.stringify({ sessionId: result.sessionId }), { mode: 0o600 });
|
|
230
|
+
renameSync(temporaryState, stateFile);
|
|
231
|
+
} else if (existsSync(stateFile)) rmSync(stateFile);
|
|
232
|
+
}
|
|
180
233
|
if (result.status !== "completed") fail(result.error || `本机 CLI 未完成:${result.status}`);
|
|
181
234
|
if (result.exitCode !== 0) fail(`本机 CLI 退出状态异常:${result.exitCode ?? "unknown"}`);
|
|
182
235
|
if (typeof result.text !== "string" || !result.text.trim()) fail("角色没有形成输出");
|
|
@@ -195,6 +248,7 @@ async function runLocalRole() {
|
|
|
195
248
|
} catch (failure) {
|
|
196
249
|
error = failure instanceof Error ? failure : new Error(String(failure));
|
|
197
250
|
}
|
|
251
|
+
if (unlock) unlock();
|
|
198
252
|
const provider = `local-cli:${mapping?.cli || "unknown"}`;
|
|
199
253
|
const evidence = {
|
|
200
254
|
version: 1,
|
|
@@ -203,6 +257,7 @@ async function runLocalRole() {
|
|
|
203
257
|
observed: {
|
|
204
258
|
provider,
|
|
205
259
|
session_id: result?.sessionId || null,
|
|
260
|
+
...(sessionEvidence ? { session: sessionEvidence } : {}),
|
|
206
261
|
models: result?.actualModel ? [result.actualModel] : [],
|
|
207
262
|
model_verified: result?.modelVerified === true,
|
|
208
263
|
model_verification: result?.modelVerification || "missing",
|
|
@@ -308,7 +363,7 @@ function parseArgs(values) {
|
|
|
308
363
|
const result = { role: "", input: "", output: "", evidence: "", cwd: "", manifest: "", codexBin: "", sandbox: "workspace-write", sessionId: "", closeoutIds: "", timeout: 900, help: false, supplied: new Set() };
|
|
309
364
|
const fields = new Map([
|
|
310
365
|
["--role", "role"], ["--input", "input"], ["--output", "output"], ["--evidence", "evidence"], ["--cwd", "cwd"],
|
|
311
|
-
["--manifest", "manifest"], ["--codex-bin", "codexBin"], ["--sandbox", "sandbox"], ["--session-id", "sessionId"], ["--closeout-ids", "closeoutIds"], ["--timeout", "timeout"],
|
|
366
|
+
["--session-key", "sessionKey"], ["--manifest", "manifest"], ["--codex-bin", "codexBin"], ["--sandbox", "sandbox"], ["--session-id", "sessionId"], ["--closeout-ids", "closeoutIds"], ["--timeout", "timeout"],
|
|
312
367
|
]);
|
|
313
368
|
for (let index = 0; index < values.length; index += 1) {
|
|
314
369
|
const value = values[index];
|
|
@@ -9,12 +9,12 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
|
|
10
10
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const args = parseArgs(process.argv.slice(2));
|
|
12
|
-
const mappings = new Map([["odai_researcher", "researcher"], ["odai_planner", "planner"], ["odai_reviewer", "reviewer"], ["odai_frontend", "frontend"], ["odai_creative", "creative"]]);
|
|
12
|
+
const mappings = new Map([["odai_researcher", "researcher"], ["odai_planner", "planner"], ["odai_reviewer", "reviewer"], ["odai_frontend", "frontend"], ["odai_creative", "creative"], ["odai_writer", "writer"]]);
|
|
13
13
|
|
|
14
14
|
if (args.help) {
|
|
15
15
|
console.log(`Usage:
|
|
16
16
|
node odai-verify-routing.mjs --host codex --project <directory> \\
|
|
17
|
-
--role <odai_researcher|odai_planner|odai_reviewer|odai_frontend|odai_creative> --after <ISO timestamp> \\
|
|
17
|
+
--role <odai_researcher|odai_planner|odai_reviewer|odai_frontend|odai_creative|odai_writer> --after <ISO timestamp> \\
|
|
18
18
|
[--agent-path </root/task>] [--sessions <directory>]
|
|
19
19
|
|
|
20
20
|
只读取近期 Codex rollout metadata,把实际角色、模型与推理档和 odai 托管清单对账。`);
|