odai-dsh-agent 0.2.21 → 0.2.23

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 (82) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +155 -157
  3. package/build/bin/odai-dsh-agent.mjs +18 -36
  4. package/build/bin/odai-dsh-agent.mjs.map +1 -1
  5. package/build/src/installer.d.mts +1 -6
  6. package/build/src/installer.d.mts.map +1 -1
  7. package/build/src/installer.mjs +3 -83
  8. package/build/src/installer.mjs.map +1 -1
  9. package/client/client.js +36 -58
  10. package/control-center.cordis.patch.yml +3 -3
  11. package/package.json +90 -90
  12. package/preset/odai/agent.cordis.yml +260 -260
  13. package/preset/odai/preset.yml +3 -3
  14. package/preset/odai/runtime/control-center-runtime.d.mts +1 -0
  15. package/preset/odai/runtime/control-center-runtime.d.mts.map +1 -1
  16. package/preset/odai/runtime/control-center-runtime.mjs +20 -0
  17. package/preset/odai/runtime/control-center-runtime.mjs.map +1 -1
  18. package/preset/odai/runtime/index.d.mts.map +1 -1
  19. package/preset/odai/runtime/index.mjs +9 -7
  20. package/preset/odai/runtime/index.mjs.map +1 -1
  21. package/preset/odai/runtime/{session-compat.d.mts → legacy-session-repair.d.mts} +7 -7
  22. package/preset/odai/runtime/legacy-session-repair.d.mts.map +1 -0
  23. package/preset/odai/runtime/{session-compat.mjs → legacy-session-repair.mjs} +1 -1
  24. package/preset/odai/runtime/legacy-session-repair.mjs.map +1 -0
  25. package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
  26. package/preset/odai/runtime/lifecycle-runtime.mjs +10 -2
  27. package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
  28. package/preset/odai/runtime/responsibility-gap.d.mts +19 -1
  29. package/preset/odai/runtime/responsibility-gap.d.mts.map +1 -1
  30. package/preset/odai/runtime/responsibility-gap.mjs +180 -16
  31. package/preset/odai/runtime/responsibility-gap.mjs.map +1 -1
  32. package/preset/odai/runtime/role-overlays.mjs +9 -9
  33. package/preset/odai/runtime/role-overlays.mjs.map +1 -1
  34. package/preset/odai/runtime/router.d.mts +1 -0
  35. package/preset/odai/runtime/router.d.mts.map +1 -1
  36. package/preset/odai/runtime/router.mjs +5 -1
  37. package/preset/odai/runtime/router.mjs.map +1 -1
  38. package/preset/odai/runtime/routing-context.d.mts +9 -2
  39. package/preset/odai/runtime/routing-context.d.mts.map +1 -1
  40. package/preset/odai/runtime/routing-context.mjs +30 -9
  41. package/preset/odai/runtime/routing-context.mjs.map +1 -1
  42. package/preset/odai/runtime/runtime-support.d.mts +0 -1
  43. package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
  44. package/preset/odai/runtime/runtime-support.mjs.map +1 -1
  45. package/preset/odai/runtime/runtime-types.d.mts +1 -3
  46. package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
  47. package/preset/odai/runtime/runtime-types.mjs +1 -5
  48. package/preset/odai/runtime/runtime-types.mjs.map +1 -1
  49. package/preset/odai/runtime/session-evidence.d.mts.map +1 -1
  50. package/preset/odai/runtime/session-evidence.mjs +1 -3
  51. package/preset/odai/runtime/session-evidence.mjs.map +1 -1
  52. package/preset/odai/runtime/tool-runtime.d.mts.map +1 -1
  53. package/preset/odai/runtime/tool-runtime.mjs +23 -3
  54. package/preset/odai/runtime/tool-runtime.mjs.map +1 -1
  55. package/preset/odai/skills/odai/SKILL.md +68 -68
  56. package/preset/odai/skills/odai/agents/openai.yaml +4 -4
  57. package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
  58. package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
  59. package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
  60. package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
  61. package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
  62. package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
  63. package/preset/odai/skills/odai/assets/routing-roles/frontend.md +7 -7
  64. package/preset/odai/skills/odai/assets/routing-roles/planner.md +28 -28
  65. package/preset/odai/skills/odai/assets/routing-roles/researcher.md +6 -6
  66. package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
  67. package/preset/odai/skills/odai/assets/task-state.md +14 -14
  68. package/preset/odai/skills/odai/manifest.json +52 -52
  69. package/preset/odai/skills/odai/references/craft.md +54 -54
  70. package/preset/odai/skills/odai/references/dao.md +31 -31
  71. package/preset/odai/skills/odai/references/leverage.md +50 -50
  72. package/preset/odai/skills/odai/references/planning.md +83 -83
  73. package/preset/odai/skills/odai/references/support.md +30 -30
  74. package/preset/odai/skills/odai/references/verification.md +24 -24
  75. package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
  76. package/preset/odai/skills/odai/scripts/build-routing.mjs +315 -315
  77. package/preset/odai/skills/odai/scripts/install-routing.mjs +393 -393
  78. package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
  79. package/preset/odai/skills/odai/scripts/run-role.mjs +285 -285
  80. package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
  81. package/preset/odai/runtime/session-compat.d.mts.map +0 -1
  82. package/preset/odai/runtime/session-compat.mjs.map +0 -1
@@ -1,393 +1,393 @@
1
- #!/usr/bin/env node
2
-
3
- import {
4
- existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync,
5
- rmSync, unlinkSync, writeFileSync,
6
- } from "node:fs";
7
- import { homedir, tmpdir } from "node:os";
8
- import path from "node:path";
9
- import process from "node:process";
10
- import { spawnSync } from "node:child_process";
11
- import { createHash, randomUUID } from "node:crypto";
12
- import { fileURLToPath } from "node:url";
13
-
14
- process.on("uncaughtException", (error) => {
15
- console.error(error?.message || String(error));
16
- process.exitCode = 1;
17
- });
18
-
19
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
- const builder = path.join(scriptDir, "build-routing.mjs");
21
- const manifestName = "odai-routing.json";
22
- const requiredRoles = ["controller", "planner", "reviewer"];
23
- const configurableRoles = [...requiredRoles, "researcher", "frontend"];
24
- const args = parseArgs(process.argv.slice(2));
25
- const roles = Object.freeze([
26
- ...requiredRoles,
27
- ...(args.researcherModel ? ["researcher"] : []),
28
- ...(args.frontendModel ? ["frontend"] : []),
29
- ]);
30
- const retiredByHost = {
31
- codex: [
32
- "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs",
33
- "odai-run-provider-role.mjs", "agents/odai-advisor.toml", "agents/odai-implementer.toml", "agents/odai-worker.toml", "agents/odai-executor.toml", "role-contracts/odai-executor.md",
34
- "provider-roles/odai-controller.md", "provider-roles/odai-planner.md", "provider-roles/odai-executor.md",
35
- "provider-roles/odai-advisor.md", "provider-roles/odai-implementer.md", "provider-roles/odai-worker.md", "provider-roles/odai-reviewer.md",
36
- ],
37
- claude: ["agents/odai-advisor.md", "agents/odai-implementer.md", "agents/odai-worker.md", "agents/odai-executor.md"],
38
- copilot: ["agents/odai-advisor.agent.md", "agents/odai-implementer.agent.md", "agents/odai-worker.agent.md", "agents/odai-executor.agent.md"],
39
- };
40
-
41
- if (args.help) { printHelp(); process.exit(0); }
42
- if (!new Set(["codex", "claude", "copilot"]).has(args.host)) fail(`不支持的 host:${args.host || "(missing)"}`);
43
- if (!new Set(["project", "user"]).has(args.scope)) fail(`不支持的 scope:${args.scope}`);
44
- if (!args.yes) fail("安装、更新或卸载会持久化宿主配置;取得用户授权后传入 --yes");
45
-
46
- const targetRoot = resolveTarget(args.host, args.scope, args.target);
47
- const configRoot = resolveConfigRoot(args.host, args.scope, targetRoot);
48
- const layout = hostLayout(args.host, args.scope);
49
- layout.retiredFiles = retiredByHost[args.host];
50
- const manifestPath = path.join(configRoot, manifestName);
51
- const previous = loadManifest(manifestPath);
52
- assertSafeDestination(configRoot, layout, Object.keys(previous?.files || {}));
53
- assertManagedState(configRoot, layout, previous);
54
-
55
- if (args.uninstall) {
56
- if (hasMappingArgs(args)) fail("--uninstall 不接受模型、推理档或 policy 参数");
57
- if (!previous) returnResult({ status: "not-installed", host: args.host, scope: args.scope, target: targetRoot, configRoot, requiresNewSession: false });
58
- const removed = uninstall(configRoot, manifestPath, previous);
59
- returnResult({ status: "uninstalled", host: args.host, scope: args.scope, target: targetRoot, configRoot, removed, requiresNewSession: true });
60
- }
61
-
62
- for (const role of requiredRoles) if (!args[`${role}Model`]) fail(`缺少 --${role}-model`);
63
- if (args.researcherEffort && !args.researcherModel) fail("--researcher-effort 需要同时提供 --researcher-model");
64
- if (args.frontendEffort && !args.frontendModel) fail("--frontend-effort 需要同时提供 --frontend-model");
65
- if (!existsSync(builder)) fail(`缺少路由生成器:${builder}`);
66
-
67
- const generatedRoot = mkdtempSync(path.join(tmpdir(), `odai-${args.host}-routing-install-`));
68
- const snapshots = new Map();
69
- try {
70
- const generated = buildAdapter(generatedRoot, configRoot);
71
- const files = collectGeneratedFiles(generatedRoot, layout);
72
- const originalFiles = prepareOriginalFiles(configRoot, previous, files);
73
- const settings = planSettings(configRoot, layout, previous);
74
- const candidates = new Set([...layout.managedFiles, ...layout.retiredFiles, ...Object.keys(previous?.files || {}), manifestName]);
75
- for (const relative of candidates) snapshot(configRoot, relative, snapshots);
76
- if (settings) snapshot(configRoot, settings.file, snapshots);
77
- for (const [relative, content] of files) atomicWrite(path.join(configRoot, relative), content);
78
- if (settings) atomicWrite(path.join(configRoot, settings.file), Buffer.from(`${JSON.stringify(settings.value, null, 2)}\n`));
79
- removeObsolete(configRoot, previous, files);
80
- const manifest = {
81
- version: 13,
82
- id: "odai-routing-installation",
83
- host: args.host,
84
- scope: args.scope,
85
- target: targetRoot,
86
- installedAt: new Date().toISOString(),
87
- generatedFrom: "skills/odai/scripts/install-routing.mjs",
88
- mapping: generated.mapping,
89
- routingPolicy: generated.routing_policy,
90
- activation: generated.activation,
91
- files: Object.fromEntries([...files].map(([relative, content]) => [relative, sha256(content)])),
92
- originalFiles,
93
- settings: settings?.manifest || null,
94
- };
95
- atomicWrite(manifestPath, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
96
- assertManagedState(configRoot, layout, manifest);
97
- returnResult({
98
- status: previous ? "updated" : "installed", host: args.host, scope: args.scope,
99
- target: targetRoot, configRoot, mapping: generated.mapping, routingPolicy: generated.routing_policy,
100
- activation: generated.activation, requiresNewSession: true,
101
- });
102
- } catch (error) {
103
- restore(configRoot, snapshots);
104
- throw error;
105
- } finally {
106
- rmSync(generatedRoot, { recursive: true, force: true });
107
- }
108
-
109
- function buildAdapter(outputRoot, root) {
110
- const command = [builder, "--host", args.host, "--out", outputRoot];
111
- for (const role of roles) {
112
- command.push(`--${role}-model`, args[`${role}Model`]);
113
- if (args[`${role}Effort`]) command.push(`--${role}-effort`, args[`${role}Effort`]);
114
- }
115
- if (args.host === "codex") command.push("--verifier-command", `node ${JSON.stringify(path.join(root, "odai-verify-routing.mjs"))}`);
116
- const result = spawnSync(process.execPath, command, { encoding: "utf8", windowsHide: true });
117
- if (result.status !== 0) fail(result.stderr.trim() || "路由生成失败");
118
- const adapter = path.join(outputRoot, args.host, "ADAPTER.json");
119
- if (!existsSync(adapter)) fail("路由生成结果缺少 ADAPTER.json");
120
- return JSON.parse(readFileSync(adapter, "utf8"));
121
- }
122
-
123
- function collectGeneratedFiles(root, layoutValue) {
124
- const result = new Map();
125
- for (const [sourceRelative, targetRelative] of layoutValue.generatedFiles) {
126
- const source = path.join(root, args.host, sourceRelative);
127
- if (!existsSync(source)) fail(`生成结果缺少文件:${sourceRelative}`);
128
- result.set(targetRelative, readFileSync(source));
129
- }
130
- return result;
131
- }
132
-
133
- function prepareOriginalFiles(root, manifest, files) {
134
- if (args.host !== "codex" || !files.has("config.toml")) return manifest?.originalFiles || {};
135
- const previousOriginal = manifest?.originalFiles?.["config.toml"] || (manifest
136
- ? { existed: false, sha256: null, content_base64: null }
137
- : originalFileRecord(path.join(root, "config.toml")));
138
- const original = previousOriginal.existed
139
- ? Buffer.from(previousOriginal.content_base64 || "", "base64")
140
- : Buffer.alloc(0);
141
- if (previousOriginal.existed && sha256(original) !== previousOriginal.sha256) fail("旧路由的原始 Codex 配置恢复记录已损坏");
142
- files.set("config.toml", Buffer.from(mergeCodexConfig(original.toString("utf8"), files.get("config.toml").toString("utf8"))));
143
- return { ...(manifest?.originalFiles || {}), "config.toml": previousOriginal };
144
- }
145
-
146
- function originalFileRecord(file) {
147
- if (!existsSync(file)) return { existed: false, sha256: null, content_base64: null };
148
- const content = readFileSync(file);
149
- return { existed: true, sha256: sha256(content), content_base64: content.toString("base64") };
150
- }
151
-
152
- function mergeCodexConfig(original, generated) {
153
- if (/^\s*\[agents\.odai_(?:researcher|planner|executor|reviewer|frontend)\]\s*$/m.test(original)) {
154
- fail("既有 Codex 配置已声明 odai 角色但不受当前安装清单管理");
155
- }
156
- const generatedFeatures = generated.search(/^\s*\[features\]\s*$/m);
157
- if (generatedFeatures < 0) fail("生成的 Codex 配置缺少 [features]");
158
- const generatedAgents = generated.search(/^\s*\[agents\.odai_/m);
159
- if (generatedAgents < 0) fail("生成的 Codex 配置缺少 odai 角色注册");
160
- const existingInstructions = topLevelTomlString(original, "developer_instructions");
161
- const generatedController = generated.slice(0, generatedFeatures).trim();
162
- const controller = existingInstructions
163
- ? appendDeveloperInstructions(generatedController, existingInstructions)
164
- : generatedController;
165
- const agents = generated.slice(generatedAgents).trim();
166
- let preserved = removeTopLevelCodexKeys(original, new Set(["model", "model_reasoning_effort", "developer_instructions"]));
167
- preserved = setFeatureFlag(preserved, "multi_agent", "true");
168
- return [controller, preserved.trim(), agents].filter(Boolean).join("\n\n") + "\n";
169
- }
170
-
171
- function topLevelTomlString(source, name) {
172
- const firstTable = String(source || "").search(/^\s*\[/m);
173
- const top = firstTable < 0 ? String(source || "") : String(source || "").slice(0, firstTable);
174
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
175
- const multiline = new RegExp(`^\\s*${escaped}\\s*=\\s*(\"\"\"|''')([\\s\\S]*?)\\1\\s*$`, "m").exec(top);
176
- if (multiline) return multiline[2].replace(/^\r?\n|\r?\n$/g, "");
177
- const line = new RegExp(`^\\s*${escaped}\\s*=\\s*(.+?)\\s*$`, "m").exec(top);
178
- if (!line) return "";
179
- const raw = line[1].trim();
180
- if (raw.startsWith('"')) {
181
- try { return JSON.parse(raw); } catch { fail(`无法安全解析既有 Codex ${name}`); }
182
- }
183
- if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
184
- fail(`无法安全解析既有 Codex ${name}`);
185
- }
186
-
187
- function appendDeveloperInstructions(controller, existing) {
188
- const match = /developer_instructions\s*=\s*\"\"\"\n?([\s\S]*?)\n?\"\"\"/.exec(controller);
189
- if (!match) fail("生成的 Codex controller 缺少 developer_instructions");
190
- const combined = `${match[1].trim()}\n\n## 既有宿主指令\n\n${existing.trim()}`;
191
- return controller.replace(match[0], `developer_instructions = ${JSON.stringify(combined)}`);
192
- }
193
-
194
- function removeTopLevelCodexKeys(source, names) {
195
- const lines = String(source || "").split(/\r?\n/);
196
- const kept = [];
197
- let tableSeen = false;
198
- for (let index = 0; index < lines.length; index += 1) {
199
- const line = lines[index];
200
- if (/^\s*\[/.test(line)) tableSeen = true;
201
- const match = !tableSeen && /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
202
- if (!match || !names.has(match[1])) { kept.push(line); continue; }
203
- const delimiter = match[2].includes('"""') ? '"""' : match[2].includes("'''") ? "'''" : "";
204
- if (!delimiter || match[2].split(delimiter).length >= 3) continue;
205
- while (++index < lines.length && !lines[index].includes(delimiter)) { /* remove multiline value */ }
206
- }
207
- return kept.join("\n");
208
- }
209
-
210
- function setFeatureFlag(source, name, value) {
211
- const lines = String(source || "").split(/\r?\n/);
212
- const start = lines.findIndex((line) => /^\s*\[features\]\s*$/.test(line));
213
- if (start < 0) return `${source.trim()}${source.trim() ? "\n\n" : ""}[features]\n${name} = ${value}\n`;
214
- let end = lines.length;
215
- for (let index = start + 1; index < lines.length; index += 1) {
216
- if (/^\s*\[/.test(lines[index])) { end = index; break; }
217
- }
218
- const filtered = lines.slice(start + 1, end).filter((line) => !new RegExp(`^\\s*${name}\\s*=`).test(line));
219
- lines.splice(start + 1, end - start - 1, ...filtered, `${name} = ${value}`);
220
- return lines.join("\n");
221
- }
222
-
223
- function hostLayout(host, scope) {
224
- if (host === "codex") {
225
- const researcherFiles = [
226
- [".codex/agents/odai-researcher.toml", "agents/odai-researcher.toml"],
227
- [".codex/role-contracts/odai-researcher.md", "role-contracts/odai-researcher.md"],
228
- ];
229
- const researcher = roles.includes("researcher") ? researcherFiles : [];
230
- const frontendFiles = [
231
- [".codex/agents/odai-frontend.toml", "agents/odai-frontend.toml"],
232
- [".codex/role-contracts/odai-frontend.md", "role-contracts/odai-frontend.md"],
233
- ];
234
- const frontend = roles.includes("frontend") ? frontendFiles : [];
235
- const common = [
236
- [".codex/config.toml", "config.toml"],
237
- [".codex/odai-run-role.mjs", "odai-run-role.mjs"], [".codex/odai-verify-routing.mjs", "odai-verify-routing.mjs"],
238
- [".codex/agents/odai-planner.toml", "agents/odai-planner.toml"],
239
- [".codex/agents/odai-reviewer.toml", "agents/odai-reviewer.toml"],
240
- [".codex/role-contracts/odai-controller.md", "role-contracts/odai-controller.md"],
241
- [".codex/role-contracts/odai-planner.md", "role-contracts/odai-planner.md"],
242
- [".codex/role-contracts/odai-reviewer.md", "role-contracts/odai-reviewer.md"],
243
- ...researcher,
244
- ...frontend,
245
- ];
246
- const generatedFiles = common;
247
- return {
248
- knownFiles: [...new Set([...common, ...researcherFiles, ...frontendFiles].map(([, target]) => target)), "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs", "agents/odai-executor.toml", "role-contracts/odai-executor.md"],
249
- managedFiles: generatedFiles.map(([, target]) => target),
250
- generatedFiles,
251
- settings: null,
252
- };
253
- }
254
- if (host === "claude") return {
255
- knownFiles: configurableRoles.map((role) => `agents/odai-${role}.md`),
256
- managedFiles: roles.map((role) => `agents/odai-${role}.md`),
257
- generatedFiles: roles.map((role) => [`.claude/agents/odai-${role}.md`, `agents/odai-${role}.md`]),
258
- settings: { file: scope === "project" ? "settings.local.json" : "settings.json", key: "agent", value: "odai-controller" },
259
- };
260
- return {
261
- knownFiles: configurableRoles.map((role) => `agents/odai-${role}.agent.md`),
262
- managedFiles: roles.map((role) => `agents/odai-${role}.agent.md`),
263
- generatedFiles: roles.map((role) => [`.github/agents/odai-${role}.agent.md`, `agents/odai-${role}.agent.md`]),
264
- settings: null,
265
- };
266
- }
267
-
268
- function planSettings(root, layoutValue, manifest) {
269
- if (!layoutValue.settings) return null;
270
- const spec = layoutValue.settings;
271
- const file = path.join(root, spec.file);
272
- const current = readJsonObject(file);
273
- if (manifest) {
274
- const installed = manifest.settings;
275
- if (!installed || installed.file !== spec.file || installed.key !== spec.key || installed.installed !== spec.value) fail("旧路由缺少可恢复的宿主设置记录");
276
- if (current[spec.key] !== spec.value) fail(`宿主设置 ${spec.key} 已被外部修改`);
277
- return { file: spec.file, value: current, manifest: installed };
278
- }
279
- const present = Object.hasOwn(current, spec.key);
280
- if (present && current[spec.key] !== spec.value) fail(`目标已有非 odai 管理的宿主设置 ${spec.key}`);
281
- return {
282
- file: spec.file,
283
- value: { ...current, [spec.key]: spec.value },
284
- manifest: { file: spec.file, key: spec.key, installed: spec.value, fileExistedBefore: existsSync(file), previous: { present, value: present ? current[spec.key] : null } },
285
- };
286
- }
287
-
288
- function assertManagedState(root, layoutValue, manifest) {
289
- if (!manifest) {
290
- const mergeable = args.host === "codex" ? new Set(["config.toml"]) : new Set();
291
- const conflicts = layoutValue.managedFiles.filter((relative) => !mergeable.has(relative) && existsSync(path.join(root, relative)));
292
- if (conflicts.length) fail(`目标已有非 odai 管理的配置:${conflicts.join(", ")}`);
293
- return;
294
- }
295
- if (manifest.id !== "odai-routing-installation" || manifest.host !== args.host) fail("无效的 odai 路由清单");
296
- const accepted = new Set([...(layoutValue.knownFiles || layoutValue.managedFiles), ...layoutValue.retiredFiles]);
297
- for (const [relative, expected] of Object.entries(manifest.files || {})) {
298
- if (!accepted.has(relative)) fail(`旧路由包含未知文件:${relative}`);
299
- const file = path.join(root, relative);
300
- if (!existsSync(file) || sha256(readFileSync(file)) !== expected) fail(`旧路由文件缺失或已漂移:${relative}`);
301
- }
302
- for (const relative of layoutValue.managedFiles) {
303
- if (!manifest.files?.[relative] && existsSync(path.join(root, relative))) fail(`新托管位置已有非 odai 文件:${relative}`);
304
- }
305
- }
306
-
307
- function removeObsolete(root, manifest, desiredFiles) {
308
- if (!manifest) return;
309
- const desired = new Set(desiredFiles.keys());
310
- const obsolete = new Set([
311
- ...layout.retiredFiles.filter((relative) => manifest.files?.[relative]),
312
- ...Object.keys(manifest.files || {}).filter((relative) => !desired.has(relative)),
313
- ]);
314
- for (const relative of obsolete) if (existsSync(path.join(root, relative))) unlinkSync(path.join(root, relative));
315
- }
316
-
317
- function uninstall(root, manifestPathValue, manifest) {
318
- const files = Object.keys(manifest.files || {});
319
- for (const relative of files) {
320
- const file = path.join(root, relative);
321
- const original = manifest.originalFiles?.[relative];
322
- if (original?.existed) atomicWrite(file, Buffer.from(original.content_base64 || "", "base64"));
323
- else if (existsSync(file)) unlinkSync(file);
324
- }
325
- if (manifest.settings) restoreSetting(root, manifest.settings);
326
- unlinkSync(manifestPathValue);
327
- return [...files, ...(manifest.settings ? [`${manifest.settings.file}#${manifest.settings.key}`] : []), manifestName];
328
- }
329
-
330
- function restoreSetting(root, spec) {
331
- const file = path.join(root, spec.file);
332
- const current = readJsonObject(file);
333
- if (current[spec.key] !== spec.installed) fail(`宿主设置 ${spec.key} 已被外部修改,拒绝删除`);
334
- if (spec.previous?.present) current[spec.key] = spec.previous.value; else delete current[spec.key];
335
- if (Object.keys(current).length === 0 && !spec.fileExistedBefore) unlinkSync(file);
336
- else atomicWrite(file, Buffer.from(`${JSON.stringify(current, null, 2)}\n`));
337
- }
338
-
339
- function assertSafeDestination(root, layoutValue, previouslyManaged = []) {
340
- for (const relative of [...layoutValue.managedFiles, ...layoutValue.retiredFiles, ...previouslyManaged, manifestName, ...(layoutValue.settings ? [layoutValue.settings.file] : [])]) {
341
- const target = path.join(root, relative);
342
- if (existsSync(target) && lstatSync(target).isSymbolicLink()) fail(`托管目标是符号链接:${target}`);
343
- }
344
- }
345
-
346
- function loadManifest(file) { return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; }
347
- function readJsonObject(file) { return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}; }
348
- function snapshot(root, relative, map) { const file = path.join(root, relative); map.set(relative, existsSync(file) ? readFileSync(file) : null); }
349
- function restore(root, map) { for (const [relative, content] of map) { const file = path.join(root, relative); if (content === null) rmSync(file, { force: true }); else atomicWrite(file, content); } }
350
- function atomicWrite(target, content) { mkdirSync(path.dirname(target), { recursive: true }); const temp = `${target}.odai-${process.pid}-${randomUUID()}.tmp`; writeFileSync(temp, content); renameSync(temp, target); }
351
- function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
352
-
353
- function resolveTarget(host, scope, supplied) {
354
- if (supplied) return path.resolve(supplied);
355
- if (scope === "project") return process.cwd();
356
- if (host === "codex") return path.resolve(process.env.CODEX_HOME || path.join(homedir(), ".codex"));
357
- if (host === "claude") return path.resolve(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude"));
358
- return path.resolve(process.env.COPILOT_HOME || path.join(homedir(), ".copilot"));
359
- }
360
- function resolveConfigRoot(host, scope, target) { if (scope === "user") return target; if (host === "codex") return path.join(target, ".codex"); if (host === "claude") return path.join(target, ".claude"); return path.join(target, ".github"); }
361
-
362
- function parseArgs(values) {
363
- const result = { host: "", scope: "project", target: "", uninstall: false, yes: false, help: false };
364
- for (const role of configurableRoles) { result[`${role}Model`] = ""; result[`${role}Effort`] = ""; }
365
- const fields = new Map([["--host", "host"], ["--scope", "scope"], ["--target", "target"]]);
366
- for (const role of configurableRoles) { fields.set(`--${role}-model`, `${role}Model`); fields.set(`--${role}-effort`, `${role}Effort`); }
367
- for (let index = 0; index < values.length; index += 1) {
368
- const value = values[index];
369
- if (value === "--help" || value === "-h") result.help = true;
370
- else if (value === "--uninstall") result.uninstall = true;
371
- else if (value === "--yes") result.yes = true;
372
- else if (fields.has(value)) { const next = values[++index] || ""; if (!next || next.startsWith("--")) fail(`${value} 需要一个值`); result[fields.get(value)] = next; }
373
- else fail(`未知参数:${value}`);
374
- }
375
- return result;
376
- }
377
-
378
- function hasMappingArgs(value) { return configurableRoles.some((role) => value[`${role}Model`] || value[`${role}Effort`]); }
379
- function printHelp() {
380
- console.log(`由 odai 在取得用户授权后安装、更新或卸载宿主路由。
381
-
382
- Usage:
383
- node skills/odai/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] \\
384
- --controller-model <model> --planner-model <model> --reviewer-model <model> \\
385
- [--researcher-model <model>] [--frontend-model <model>] [--<role>-effort <effort>] --yes
386
-
387
- node skills/odai/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] --uninstall --yes
388
-
389
- 安装后用户只需正常使用 odai。总控是唯一持续任务线程并持有实施;planner、reviewer 以及可选 researcher、frontend 只在能改变结果时启动。researcher 与 frontend 映射默认不配置。更新会安全移除旧版 advisor、implementer、worker、executor 和 stage runner 托管文件。`);
390
- }
391
-
392
- function returnResult(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); process.exit(0); }
393
- function fail(message) { throw new Error(message); }
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync,
5
+ rmSync, unlinkSync, writeFileSync,
6
+ } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import { spawnSync } from "node:child_process";
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ process.on("uncaughtException", (error) => {
15
+ console.error(error?.message || String(error));
16
+ process.exitCode = 1;
17
+ });
18
+
19
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
+ const builder = path.join(scriptDir, "build-routing.mjs");
21
+ const manifestName = "odai-routing.json";
22
+ const requiredRoles = ["controller", "planner", "reviewer"];
23
+ const configurableRoles = [...requiredRoles, "researcher", "frontend"];
24
+ const args = parseArgs(process.argv.slice(2));
25
+ const roles = Object.freeze([
26
+ ...requiredRoles,
27
+ ...(args.researcherModel ? ["researcher"] : []),
28
+ ...(args.frontendModel ? ["frontend"] : []),
29
+ ]);
30
+ const retiredByHost = {
31
+ codex: [
32
+ "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs",
33
+ "odai-run-provider-role.mjs", "agents/odai-advisor.toml", "agents/odai-implementer.toml", "agents/odai-worker.toml", "agents/odai-executor.toml", "role-contracts/odai-executor.md",
34
+ "provider-roles/odai-controller.md", "provider-roles/odai-planner.md", "provider-roles/odai-executor.md",
35
+ "provider-roles/odai-advisor.md", "provider-roles/odai-implementer.md", "provider-roles/odai-worker.md", "provider-roles/odai-reviewer.md",
36
+ ],
37
+ claude: ["agents/odai-advisor.md", "agents/odai-implementer.md", "agents/odai-worker.md", "agents/odai-executor.md"],
38
+ copilot: ["agents/odai-advisor.agent.md", "agents/odai-implementer.agent.md", "agents/odai-worker.agent.md", "agents/odai-executor.agent.md"],
39
+ };
40
+
41
+ if (args.help) { printHelp(); process.exit(0); }
42
+ if (!new Set(["codex", "claude", "copilot"]).has(args.host)) fail(`不支持的 host:${args.host || "(missing)"}`);
43
+ if (!new Set(["project", "user"]).has(args.scope)) fail(`不支持的 scope:${args.scope}`);
44
+ if (!args.yes) fail("安装、更新或卸载会持久化宿主配置;取得用户授权后传入 --yes");
45
+
46
+ const targetRoot = resolveTarget(args.host, args.scope, args.target);
47
+ const configRoot = resolveConfigRoot(args.host, args.scope, targetRoot);
48
+ const layout = hostLayout(args.host, args.scope);
49
+ layout.retiredFiles = retiredByHost[args.host];
50
+ const manifestPath = path.join(configRoot, manifestName);
51
+ const previous = loadManifest(manifestPath);
52
+ assertSafeDestination(configRoot, layout, Object.keys(previous?.files || {}));
53
+ assertManagedState(configRoot, layout, previous);
54
+
55
+ if (args.uninstall) {
56
+ if (hasMappingArgs(args)) fail("--uninstall 不接受模型、推理档或 policy 参数");
57
+ if (!previous) returnResult({ status: "not-installed", host: args.host, scope: args.scope, target: targetRoot, configRoot, requiresNewSession: false });
58
+ const removed = uninstall(configRoot, manifestPath, previous);
59
+ returnResult({ status: "uninstalled", host: args.host, scope: args.scope, target: targetRoot, configRoot, removed, requiresNewSession: true });
60
+ }
61
+
62
+ for (const role of requiredRoles) if (!args[`${role}Model`]) fail(`缺少 --${role}-model`);
63
+ if (args.researcherEffort && !args.researcherModel) fail("--researcher-effort 需要同时提供 --researcher-model");
64
+ if (args.frontendEffort && !args.frontendModel) fail("--frontend-effort 需要同时提供 --frontend-model");
65
+ if (!existsSync(builder)) fail(`缺少路由生成器:${builder}`);
66
+
67
+ const generatedRoot = mkdtempSync(path.join(tmpdir(), `odai-${args.host}-routing-install-`));
68
+ const snapshots = new Map();
69
+ try {
70
+ const generated = buildAdapter(generatedRoot, configRoot);
71
+ const files = collectGeneratedFiles(generatedRoot, layout);
72
+ const originalFiles = prepareOriginalFiles(configRoot, previous, files);
73
+ const settings = planSettings(configRoot, layout, previous);
74
+ const candidates = new Set([...layout.managedFiles, ...layout.retiredFiles, ...Object.keys(previous?.files || {}), manifestName]);
75
+ for (const relative of candidates) snapshot(configRoot, relative, snapshots);
76
+ if (settings) snapshot(configRoot, settings.file, snapshots);
77
+ for (const [relative, content] of files) atomicWrite(path.join(configRoot, relative), content);
78
+ if (settings) atomicWrite(path.join(configRoot, settings.file), Buffer.from(`${JSON.stringify(settings.value, null, 2)}\n`));
79
+ removeObsolete(configRoot, previous, files);
80
+ const manifest = {
81
+ version: 13,
82
+ id: "odai-routing-installation",
83
+ host: args.host,
84
+ scope: args.scope,
85
+ target: targetRoot,
86
+ installedAt: new Date().toISOString(),
87
+ generatedFrom: "skills/odai/scripts/install-routing.mjs",
88
+ mapping: generated.mapping,
89
+ routingPolicy: generated.routing_policy,
90
+ activation: generated.activation,
91
+ files: Object.fromEntries([...files].map(([relative, content]) => [relative, sha256(content)])),
92
+ originalFiles,
93
+ settings: settings?.manifest || null,
94
+ };
95
+ atomicWrite(manifestPath, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
96
+ assertManagedState(configRoot, layout, manifest);
97
+ returnResult({
98
+ status: previous ? "updated" : "installed", host: args.host, scope: args.scope,
99
+ target: targetRoot, configRoot, mapping: generated.mapping, routingPolicy: generated.routing_policy,
100
+ activation: generated.activation, requiresNewSession: true,
101
+ });
102
+ } catch (error) {
103
+ restore(configRoot, snapshots);
104
+ throw error;
105
+ } finally {
106
+ rmSync(generatedRoot, { recursive: true, force: true });
107
+ }
108
+
109
+ function buildAdapter(outputRoot, root) {
110
+ const command = [builder, "--host", args.host, "--out", outputRoot];
111
+ for (const role of roles) {
112
+ command.push(`--${role}-model`, args[`${role}Model`]);
113
+ if (args[`${role}Effort`]) command.push(`--${role}-effort`, args[`${role}Effort`]);
114
+ }
115
+ if (args.host === "codex") command.push("--verifier-command", `node ${JSON.stringify(path.join(root, "odai-verify-routing.mjs"))}`);
116
+ const result = spawnSync(process.execPath, command, { encoding: "utf8", windowsHide: true });
117
+ if (result.status !== 0) fail(result.stderr.trim() || "路由生成失败");
118
+ const adapter = path.join(outputRoot, args.host, "ADAPTER.json");
119
+ if (!existsSync(adapter)) fail("路由生成结果缺少 ADAPTER.json");
120
+ return JSON.parse(readFileSync(adapter, "utf8"));
121
+ }
122
+
123
+ function collectGeneratedFiles(root, layoutValue) {
124
+ const result = new Map();
125
+ for (const [sourceRelative, targetRelative] of layoutValue.generatedFiles) {
126
+ const source = path.join(root, args.host, sourceRelative);
127
+ if (!existsSync(source)) fail(`生成结果缺少文件:${sourceRelative}`);
128
+ result.set(targetRelative, readFileSync(source));
129
+ }
130
+ return result;
131
+ }
132
+
133
+ function prepareOriginalFiles(root, manifest, files) {
134
+ if (args.host !== "codex" || !files.has("config.toml")) return manifest?.originalFiles || {};
135
+ const previousOriginal = manifest?.originalFiles?.["config.toml"] || (manifest
136
+ ? { existed: false, sha256: null, content_base64: null }
137
+ : originalFileRecord(path.join(root, "config.toml")));
138
+ const original = previousOriginal.existed
139
+ ? Buffer.from(previousOriginal.content_base64 || "", "base64")
140
+ : Buffer.alloc(0);
141
+ if (previousOriginal.existed && sha256(original) !== previousOriginal.sha256) fail("旧路由的原始 Codex 配置恢复记录已损坏");
142
+ files.set("config.toml", Buffer.from(mergeCodexConfig(original.toString("utf8"), files.get("config.toml").toString("utf8"))));
143
+ return { ...(manifest?.originalFiles || {}), "config.toml": previousOriginal };
144
+ }
145
+
146
+ function originalFileRecord(file) {
147
+ if (!existsSync(file)) return { existed: false, sha256: null, content_base64: null };
148
+ const content = readFileSync(file);
149
+ return { existed: true, sha256: sha256(content), content_base64: content.toString("base64") };
150
+ }
151
+
152
+ function mergeCodexConfig(original, generated) {
153
+ if (/^\s*\[agents\.odai_(?:researcher|planner|executor|reviewer|frontend)\]\s*$/m.test(original)) {
154
+ fail("既有 Codex 配置已声明 odai 角色但不受当前安装清单管理");
155
+ }
156
+ const generatedFeatures = generated.search(/^\s*\[features\]\s*$/m);
157
+ if (generatedFeatures < 0) fail("生成的 Codex 配置缺少 [features]");
158
+ const generatedAgents = generated.search(/^\s*\[agents\.odai_/m);
159
+ if (generatedAgents < 0) fail("生成的 Codex 配置缺少 odai 角色注册");
160
+ const existingInstructions = topLevelTomlString(original, "developer_instructions");
161
+ const generatedController = generated.slice(0, generatedFeatures).trim();
162
+ const controller = existingInstructions
163
+ ? appendDeveloperInstructions(generatedController, existingInstructions)
164
+ : generatedController;
165
+ const agents = generated.slice(generatedAgents).trim();
166
+ let preserved = removeTopLevelCodexKeys(original, new Set(["model", "model_reasoning_effort", "developer_instructions"]));
167
+ preserved = setFeatureFlag(preserved, "multi_agent", "true");
168
+ return [controller, preserved.trim(), agents].filter(Boolean).join("\n\n") + "\n";
169
+ }
170
+
171
+ function topLevelTomlString(source, name) {
172
+ const firstTable = String(source || "").search(/^\s*\[/m);
173
+ const top = firstTable < 0 ? String(source || "") : String(source || "").slice(0, firstTable);
174
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
175
+ const multiline = new RegExp(`^\\s*${escaped}\\s*=\\s*(\"\"\"|''')([\\s\\S]*?)\\1\\s*$`, "m").exec(top);
176
+ if (multiline) return multiline[2].replace(/^\r?\n|\r?\n$/g, "");
177
+ const line = new RegExp(`^\\s*${escaped}\\s*=\\s*(.+?)\\s*$`, "m").exec(top);
178
+ if (!line) return "";
179
+ const raw = line[1].trim();
180
+ if (raw.startsWith('"')) {
181
+ try { return JSON.parse(raw); } catch { fail(`无法安全解析既有 Codex ${name}`); }
182
+ }
183
+ if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
184
+ fail(`无法安全解析既有 Codex ${name}`);
185
+ }
186
+
187
+ function appendDeveloperInstructions(controller, existing) {
188
+ const match = /developer_instructions\s*=\s*\"\"\"\n?([\s\S]*?)\n?\"\"\"/.exec(controller);
189
+ if (!match) fail("生成的 Codex controller 缺少 developer_instructions");
190
+ const combined = `${match[1].trim()}\n\n## 既有宿主指令\n\n${existing.trim()}`;
191
+ return controller.replace(match[0], `developer_instructions = ${JSON.stringify(combined)}`);
192
+ }
193
+
194
+ function removeTopLevelCodexKeys(source, names) {
195
+ const lines = String(source || "").split(/\r?\n/);
196
+ const kept = [];
197
+ let tableSeen = false;
198
+ for (let index = 0; index < lines.length; index += 1) {
199
+ const line = lines[index];
200
+ if (/^\s*\[/.test(line)) tableSeen = true;
201
+ const match = !tableSeen && /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
202
+ if (!match || !names.has(match[1])) { kept.push(line); continue; }
203
+ const delimiter = match[2].includes('"""') ? '"""' : match[2].includes("'''") ? "'''" : "";
204
+ if (!delimiter || match[2].split(delimiter).length >= 3) continue;
205
+ while (++index < lines.length && !lines[index].includes(delimiter)) { /* remove multiline value */ }
206
+ }
207
+ return kept.join("\n");
208
+ }
209
+
210
+ function setFeatureFlag(source, name, value) {
211
+ const lines = String(source || "").split(/\r?\n/);
212
+ const start = lines.findIndex((line) => /^\s*\[features\]\s*$/.test(line));
213
+ if (start < 0) return `${source.trim()}${source.trim() ? "\n\n" : ""}[features]\n${name} = ${value}\n`;
214
+ let end = lines.length;
215
+ for (let index = start + 1; index < lines.length; index += 1) {
216
+ if (/^\s*\[/.test(lines[index])) { end = index; break; }
217
+ }
218
+ const filtered = lines.slice(start + 1, end).filter((line) => !new RegExp(`^\\s*${name}\\s*=`).test(line));
219
+ lines.splice(start + 1, end - start - 1, ...filtered, `${name} = ${value}`);
220
+ return lines.join("\n");
221
+ }
222
+
223
+ function hostLayout(host, scope) {
224
+ if (host === "codex") {
225
+ const researcherFiles = [
226
+ [".codex/agents/odai-researcher.toml", "agents/odai-researcher.toml"],
227
+ [".codex/role-contracts/odai-researcher.md", "role-contracts/odai-researcher.md"],
228
+ ];
229
+ const researcher = roles.includes("researcher") ? researcherFiles : [];
230
+ const frontendFiles = [
231
+ [".codex/agents/odai-frontend.toml", "agents/odai-frontend.toml"],
232
+ [".codex/role-contracts/odai-frontend.md", "role-contracts/odai-frontend.md"],
233
+ ];
234
+ const frontend = roles.includes("frontend") ? frontendFiles : [];
235
+ const common = [
236
+ [".codex/config.toml", "config.toml"],
237
+ [".codex/odai-run-role.mjs", "odai-run-role.mjs"], [".codex/odai-verify-routing.mjs", "odai-verify-routing.mjs"],
238
+ [".codex/agents/odai-planner.toml", "agents/odai-planner.toml"],
239
+ [".codex/agents/odai-reviewer.toml", "agents/odai-reviewer.toml"],
240
+ [".codex/role-contracts/odai-controller.md", "role-contracts/odai-controller.md"],
241
+ [".codex/role-contracts/odai-planner.md", "role-contracts/odai-planner.md"],
242
+ [".codex/role-contracts/odai-reviewer.md", "role-contracts/odai-reviewer.md"],
243
+ ...researcher,
244
+ ...frontend,
245
+ ];
246
+ const generatedFiles = common;
247
+ return {
248
+ knownFiles: [...new Set([...common, ...researcherFiles, ...frontendFiles].map(([, target]) => target)), "hooks.json", "odai-route-hook.mjs", "odai-run-routing.mjs", "agents/odai-executor.toml", "role-contracts/odai-executor.md"],
249
+ managedFiles: generatedFiles.map(([, target]) => target),
250
+ generatedFiles,
251
+ settings: null,
252
+ };
253
+ }
254
+ if (host === "claude") return {
255
+ knownFiles: configurableRoles.map((role) => `agents/odai-${role}.md`),
256
+ managedFiles: roles.map((role) => `agents/odai-${role}.md`),
257
+ generatedFiles: roles.map((role) => [`.claude/agents/odai-${role}.md`, `agents/odai-${role}.md`]),
258
+ settings: { file: scope === "project" ? "settings.local.json" : "settings.json", key: "agent", value: "odai-controller" },
259
+ };
260
+ return {
261
+ knownFiles: configurableRoles.map((role) => `agents/odai-${role}.agent.md`),
262
+ managedFiles: roles.map((role) => `agents/odai-${role}.agent.md`),
263
+ generatedFiles: roles.map((role) => [`.github/agents/odai-${role}.agent.md`, `agents/odai-${role}.agent.md`]),
264
+ settings: null,
265
+ };
266
+ }
267
+
268
+ function planSettings(root, layoutValue, manifest) {
269
+ if (!layoutValue.settings) return null;
270
+ const spec = layoutValue.settings;
271
+ const file = path.join(root, spec.file);
272
+ const current = readJsonObject(file);
273
+ if (manifest) {
274
+ const installed = manifest.settings;
275
+ if (!installed || installed.file !== spec.file || installed.key !== spec.key || installed.installed !== spec.value) fail("旧路由缺少可恢复的宿主设置记录");
276
+ if (current[spec.key] !== spec.value) fail(`宿主设置 ${spec.key} 已被外部修改`);
277
+ return { file: spec.file, value: current, manifest: installed };
278
+ }
279
+ const present = Object.hasOwn(current, spec.key);
280
+ if (present && current[spec.key] !== spec.value) fail(`目标已有非 odai 管理的宿主设置 ${spec.key}`);
281
+ return {
282
+ file: spec.file,
283
+ value: { ...current, [spec.key]: spec.value },
284
+ manifest: { file: spec.file, key: spec.key, installed: spec.value, fileExistedBefore: existsSync(file), previous: { present, value: present ? current[spec.key] : null } },
285
+ };
286
+ }
287
+
288
+ function assertManagedState(root, layoutValue, manifest) {
289
+ if (!manifest) {
290
+ const mergeable = args.host === "codex" ? new Set(["config.toml"]) : new Set();
291
+ const conflicts = layoutValue.managedFiles.filter((relative) => !mergeable.has(relative) && existsSync(path.join(root, relative)));
292
+ if (conflicts.length) fail(`目标已有非 odai 管理的配置:${conflicts.join(", ")}`);
293
+ return;
294
+ }
295
+ if (manifest.id !== "odai-routing-installation" || manifest.host !== args.host) fail("无效的 odai 路由清单");
296
+ const accepted = new Set([...(layoutValue.knownFiles || layoutValue.managedFiles), ...layoutValue.retiredFiles]);
297
+ for (const [relative, expected] of Object.entries(manifest.files || {})) {
298
+ if (!accepted.has(relative)) fail(`旧路由包含未知文件:${relative}`);
299
+ const file = path.join(root, relative);
300
+ if (!existsSync(file) || sha256(readFileSync(file)) !== expected) fail(`旧路由文件缺失或已漂移:${relative}`);
301
+ }
302
+ for (const relative of layoutValue.managedFiles) {
303
+ if (!manifest.files?.[relative] && existsSync(path.join(root, relative))) fail(`新托管位置已有非 odai 文件:${relative}`);
304
+ }
305
+ }
306
+
307
+ function removeObsolete(root, manifest, desiredFiles) {
308
+ if (!manifest) return;
309
+ const desired = new Set(desiredFiles.keys());
310
+ const obsolete = new Set([
311
+ ...layout.retiredFiles.filter((relative) => manifest.files?.[relative]),
312
+ ...Object.keys(manifest.files || {}).filter((relative) => !desired.has(relative)),
313
+ ]);
314
+ for (const relative of obsolete) if (existsSync(path.join(root, relative))) unlinkSync(path.join(root, relative));
315
+ }
316
+
317
+ function uninstall(root, manifestPathValue, manifest) {
318
+ const files = Object.keys(manifest.files || {});
319
+ for (const relative of files) {
320
+ const file = path.join(root, relative);
321
+ const original = manifest.originalFiles?.[relative];
322
+ if (original?.existed) atomicWrite(file, Buffer.from(original.content_base64 || "", "base64"));
323
+ else if (existsSync(file)) unlinkSync(file);
324
+ }
325
+ if (manifest.settings) restoreSetting(root, manifest.settings);
326
+ unlinkSync(manifestPathValue);
327
+ return [...files, ...(manifest.settings ? [`${manifest.settings.file}#${manifest.settings.key}`] : []), manifestName];
328
+ }
329
+
330
+ function restoreSetting(root, spec) {
331
+ const file = path.join(root, spec.file);
332
+ const current = readJsonObject(file);
333
+ if (current[spec.key] !== spec.installed) fail(`宿主设置 ${spec.key} 已被外部修改,拒绝删除`);
334
+ if (spec.previous?.present) current[spec.key] = spec.previous.value; else delete current[spec.key];
335
+ if (Object.keys(current).length === 0 && !spec.fileExistedBefore) unlinkSync(file);
336
+ else atomicWrite(file, Buffer.from(`${JSON.stringify(current, null, 2)}\n`));
337
+ }
338
+
339
+ function assertSafeDestination(root, layoutValue, previouslyManaged = []) {
340
+ for (const relative of [...layoutValue.managedFiles, ...layoutValue.retiredFiles, ...previouslyManaged, manifestName, ...(layoutValue.settings ? [layoutValue.settings.file] : [])]) {
341
+ const target = path.join(root, relative);
342
+ if (existsSync(target) && lstatSync(target).isSymbolicLink()) fail(`托管目标是符号链接:${target}`);
343
+ }
344
+ }
345
+
346
+ function loadManifest(file) { return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; }
347
+ function readJsonObject(file) { return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {}; }
348
+ function snapshot(root, relative, map) { const file = path.join(root, relative); map.set(relative, existsSync(file) ? readFileSync(file) : null); }
349
+ function restore(root, map) { for (const [relative, content] of map) { const file = path.join(root, relative); if (content === null) rmSync(file, { force: true }); else atomicWrite(file, content); } }
350
+ function atomicWrite(target, content) { mkdirSync(path.dirname(target), { recursive: true }); const temp = `${target}.odai-${process.pid}-${randomUUID()}.tmp`; writeFileSync(temp, content); renameSync(temp, target); }
351
+ function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
352
+
353
+ function resolveTarget(host, scope, supplied) {
354
+ if (supplied) return path.resolve(supplied);
355
+ if (scope === "project") return process.cwd();
356
+ if (host === "codex") return path.resolve(process.env.CODEX_HOME || path.join(homedir(), ".codex"));
357
+ if (host === "claude") return path.resolve(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude"));
358
+ return path.resolve(process.env.COPILOT_HOME || path.join(homedir(), ".copilot"));
359
+ }
360
+ function resolveConfigRoot(host, scope, target) { if (scope === "user") return target; if (host === "codex") return path.join(target, ".codex"); if (host === "claude") return path.join(target, ".claude"); return path.join(target, ".github"); }
361
+
362
+ function parseArgs(values) {
363
+ const result = { host: "", scope: "project", target: "", uninstall: false, yes: false, help: false };
364
+ for (const role of configurableRoles) { result[`${role}Model`] = ""; result[`${role}Effort`] = ""; }
365
+ const fields = new Map([["--host", "host"], ["--scope", "scope"], ["--target", "target"]]);
366
+ for (const role of configurableRoles) { fields.set(`--${role}-model`, `${role}Model`); fields.set(`--${role}-effort`, `${role}Effort`); }
367
+ for (let index = 0; index < values.length; index += 1) {
368
+ const value = values[index];
369
+ if (value === "--help" || value === "-h") result.help = true;
370
+ else if (value === "--uninstall") result.uninstall = true;
371
+ else if (value === "--yes") result.yes = true;
372
+ else if (fields.has(value)) { const next = values[++index] || ""; if (!next || next.startsWith("--")) fail(`${value} 需要一个值`); result[fields.get(value)] = next; }
373
+ else fail(`未知参数:${value}`);
374
+ }
375
+ return result;
376
+ }
377
+
378
+ function hasMappingArgs(value) { return configurableRoles.some((role) => value[`${role}Model`] || value[`${role}Effort`]); }
379
+ function printHelp() {
380
+ console.log(`由 odai 在取得用户授权后安装、更新或卸载宿主路由。
381
+
382
+ Usage:
383
+ node skills/odai/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] \\
384
+ --controller-model <model> --planner-model <model> --reviewer-model <model> \\
385
+ [--researcher-model <model>] [--frontend-model <model>] [--<role>-effort <effort>] --yes
386
+
387
+ node skills/odai/scripts/install-routing.mjs --host <codex|claude|copilot> --scope <project|user> [--target <path>] --uninstall --yes
388
+
389
+ 安装后用户只需正常使用 odai。总控是唯一持续任务线程并持有实施;planner、reviewer 以及可选 researcher、frontend 只在能改变结果时启动。researcher 与 frontend 映射默认不配置。更新会安全移除旧版 advisor、implementer、worker、executor 和 stage runner 托管文件。`);
390
+ }
391
+
392
+ function returnResult(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); process.exit(0); }
393
+ function fail(message) { throw new Error(message); }