@heretyc/subagent-mcp 2.6.0
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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +124 -0
- package/directives/carryover-claude.md +17 -0
- package/directives/carryover-codex.md +17 -0
- package/directives/off-turn-reminder.md +1 -0
- package/directives/orchestration-claude.md +21 -0
- package/directives/orchestration-codex.md +22 -0
- package/dist/advanced-ruleset.py +67 -0
- package/dist/deadlock.js +8 -0
- package/dist/doctor.js +32 -0
- package/dist/effort.js +78 -0
- package/dist/hooks/orchestration-claude.js +88 -0
- package/dist/hooks/orchestration-codex.js +152 -0
- package/dist/index.js +908 -0
- package/dist/orchestration/hook-core.js +208 -0
- package/dist/orchestration/marker.js +139 -0
- package/dist/output-helpers.js +128 -0
- package/dist/platform.js +59 -0
- package/dist/routing-table.json +3821 -0
- package/dist/routing.js +260 -0
- package/dist/ruleset-scaffold.js +2 -0
- package/dist/ruleset.js +319 -0
- package/dist/setup.js +507 -0
- package/dist/status-helpers.js +56 -0
- package/dist/stream-helpers.js +182 -0
- package/dist/wait-helpers.js +21 -0
- package/package.json +51 -0
- package/scripts/postinstall.mjs +102 -0
package/dist/routing.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing-table loader and pure resolver for auto-mode launches.
|
|
3
|
+
*
|
|
4
|
+
* Loads dist/routing-table.json (copied from src/routing-table.json at build by
|
|
5
|
+
* scripts/copy-provider.mjs), then builds an ordered candidate list per the
|
|
6
|
+
* supplied overrides. NO spawning happens here — the attempt loop in index.ts
|
|
7
|
+
* consumes the candidate triples and reuses buildCommand/resolveExe/spawn.
|
|
8
|
+
*
|
|
9
|
+
* Contract: docs/spec/auto-mode/routing-table-contract.md.
|
|
10
|
+
* effort.ts / platform.ts are wrapped, never modified.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
/** Launch model enum accepted by buildCommand. */
|
|
15
|
+
export const LAUNCH_MODELS = ["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"];
|
|
16
|
+
/** Launch effort enum accepted by buildCommand/resolveEffort. */
|
|
17
|
+
export const LAUNCH_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"];
|
|
18
|
+
/** Sentinel reported (and passed-through harmlessly) for haiku, whose effort is ignored. */
|
|
19
|
+
export const HAIKU_EFFORT = "none";
|
|
20
|
+
/**
|
|
21
|
+
* FULL table model id -> SHORT launch id. Only launchable models appear here.
|
|
22
|
+
* Non-launchable ids (gpt-5.5-pro, gpt-5.4-mini, claude-opus-4-7, unknown) are
|
|
23
|
+
* intentionally absent so buildCandidates skips them rather than coercing.
|
|
24
|
+
* For models with aliases (e.g., opus and opus-4-8 both refer to claude-opus-4-8),
|
|
25
|
+
* map to a Set of valid short ids to check membership during filtering.
|
|
26
|
+
*/
|
|
27
|
+
const FULL_TO_SHORT = {
|
|
28
|
+
"claude-opus-4-8": new Set(["opus", "opus-4-8"]),
|
|
29
|
+
"claude-sonnet-4-6": "sonnet",
|
|
30
|
+
"claude-haiku-4-5": "haiku",
|
|
31
|
+
"gpt-5.5": "gpt-5.5",
|
|
32
|
+
// Short ids may already appear in a hand-authored table; map them through.
|
|
33
|
+
haiku: "haiku",
|
|
34
|
+
sonnet: "sonnet",
|
|
35
|
+
opus: "opus",
|
|
36
|
+
"opus-4-8": "opus-4-8",
|
|
37
|
+
};
|
|
38
|
+
export const DEFAULT_BRANCH = "cost_efficiency";
|
|
39
|
+
/** Resolve dist/routing-table.json relative to this module at runtime. */
|
|
40
|
+
function defaultTablePath() {
|
|
41
|
+
return fileURLToPath(new URL("./routing-table.json", import.meta.url));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load + parse the routing table. Never throws: ENOENT, parse error, or any
|
|
45
|
+
* read failure -> null (handler emits ERR_TABLE_MISSING).
|
|
46
|
+
*
|
|
47
|
+
* Fresh read per launch — NO process-lifetime cache. The file is tiny and
|
|
48
|
+
* launches are infrequent, so a table emitted AFTER server start (the profiler
|
|
49
|
+
* scenario) is picked up on the next launch with no restart
|
|
50
|
+
* (routing-table-contract.md). When `path` is given (tests) it is read directly.
|
|
51
|
+
*/
|
|
52
|
+
export function loadRoutingTable(path) {
|
|
53
|
+
return readTable(path ?? defaultTablePath());
|
|
54
|
+
}
|
|
55
|
+
function readTable(path) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(path, "utf8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (parsed && typeof parsed === "object") {
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Map a table model id to its launch provider. Claude ids -> "claude"; any
|
|
70
|
+
* GPT/codex-family id -> "codex"; unrecognised -> null (skip signal; never
|
|
71
|
+
* coerce). Non-launchable codex siblings (gpt-5.5-pro) still map to "codex" so
|
|
72
|
+
* the provider filter is correct; they are dropped later at the launch-enum step.
|
|
73
|
+
*/
|
|
74
|
+
export function mapModelToProvider(model) {
|
|
75
|
+
if (model === "haiku" ||
|
|
76
|
+
model === "sonnet" ||
|
|
77
|
+
model === "opus" ||
|
|
78
|
+
model === "opus-4-8" ||
|
|
79
|
+
model.startsWith("claude-")) {
|
|
80
|
+
return "claude";
|
|
81
|
+
}
|
|
82
|
+
if (model === "gpt-5.5" || model.startsWith("gpt-")) {
|
|
83
|
+
return "codex";
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a table effort tier to the launch enum, mirroring src/effort.ts so
|
|
89
|
+
* the resolver never feeds an invalid combo into buildCommand.
|
|
90
|
+
*
|
|
91
|
+
* - haiku -> "none" sentinel (effort ignored by buildCommand; reported as-is).
|
|
92
|
+
* - `none` on effort-capable models is invalid routing data -> null (skip candidate).
|
|
93
|
+
* - ultracode is opus/opus-4-8 only; any other model clamps to "xhigh".
|
|
94
|
+
* - codex has no max/ultracode; both clamp to "xhigh".
|
|
95
|
+
* - unknown tier (not in the launch enum) -> null (skip candidate).
|
|
96
|
+
*
|
|
97
|
+
* `model` here is the SHORT launch id.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeEffort(provider, model, effort) {
|
|
100
|
+
// haiku ignores effort entirely; report the sentinel.
|
|
101
|
+
if (provider === "claude" && model === "haiku") {
|
|
102
|
+
return HAIKU_EFFORT;
|
|
103
|
+
}
|
|
104
|
+
const isOpus48 = provider === "claude" && (model === "opus" || model === "opus-4-8");
|
|
105
|
+
if (effort === "ultracode") {
|
|
106
|
+
return isOpus48 ? "ultracode" : "xhigh";
|
|
107
|
+
}
|
|
108
|
+
// Unknown tiers (not a launch-enum value, not handled above) -> skip.
|
|
109
|
+
if (typeof effort !== "string" || !LAUNCH_EFFORTS.includes(effort)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (provider === "codex" && effort === "max") {
|
|
113
|
+
return "xhigh";
|
|
114
|
+
}
|
|
115
|
+
return effort;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Build the ordered candidate list for a launch.
|
|
119
|
+
*
|
|
120
|
+
* explicit (provider+model+effort all present): one candidate from the user's
|
|
121
|
+
* triple; the table is NOT read (works with a null table).
|
|
122
|
+
*
|
|
123
|
+
* auto/provider/provider_model: read performance.<task_category> as the pairings
|
|
124
|
+
* array directly (defensive .pairings unwrap), sort by rank asc, map each model
|
|
125
|
+
* to a launch id, normalize effort, and drop non-launchable / unknown-effort
|
|
126
|
+
* pairings. An empty result sets noCandidates:true.
|
|
127
|
+
*/
|
|
128
|
+
export function buildCandidates(table, taskCategory, overrides, branch = DEFAULT_BRANCH) {
|
|
129
|
+
const { provider, model, effort } = overrides;
|
|
130
|
+
// explicit: all three present — single direct attempt, no table read. The
|
|
131
|
+
// effort is normalized exactly like table rows (haiku -> "none", codex
|
|
132
|
+
// max -> xhigh, non-opus ultracode -> xhigh) so the candidate list is
|
|
133
|
+
// always validator-legal for the advanced-ruleset payload (io-contract.md).
|
|
134
|
+
if (provider && model && effort) {
|
|
135
|
+
return {
|
|
136
|
+
mode: "explicit",
|
|
137
|
+
candidates: [
|
|
138
|
+
{ provider, model, effort: normalizeEffort(provider, model, effort) ?? effort },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const mode = provider && model ? "provider_model" : provider ? "provider" : "auto";
|
|
143
|
+
const pairings = readPairings(table, taskCategory, branch);
|
|
144
|
+
const candidates = [];
|
|
145
|
+
for (const entry of pairings) {
|
|
146
|
+
const mappedProvider = mapModelToProvider(entry.model);
|
|
147
|
+
if (mappedProvider === null)
|
|
148
|
+
continue; // unknown id — skip, never coerce.
|
|
149
|
+
const shortModelOrSet = FULL_TO_SHORT[entry.model];
|
|
150
|
+
if (shortModelOrSet === undefined)
|
|
151
|
+
continue; // not in the launch enum — skip.
|
|
152
|
+
// Unwrap shortModel: handle both string and Set of aliases.
|
|
153
|
+
// For the returned candidate, use the canonical short id (opus-4-8 not opus).
|
|
154
|
+
// For matching user's model filter: check membership in the Set.
|
|
155
|
+
let shortModel;
|
|
156
|
+
if (shortModelOrSet instanceof Set) {
|
|
157
|
+
// Canonical form: "opus-4-8" for Opus models (prefer versioned).
|
|
158
|
+
shortModel = shortModelOrSet.has("opus-4-8") ? "opus-4-8" : Array.from(shortModelOrSet)[0];
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
shortModel = shortModelOrSet;
|
|
162
|
+
}
|
|
163
|
+
// provider / provider_model filters operate on the mapped provider + short id.
|
|
164
|
+
if (provider && mappedProvider !== provider)
|
|
165
|
+
continue;
|
|
166
|
+
if (model) {
|
|
167
|
+
// Check membership: if shortModelOrSet is a Set, check if model is in it.
|
|
168
|
+
const modelMatches = shortModelOrSet instanceof Set
|
|
169
|
+
? shortModelOrSet.has(model)
|
|
170
|
+
: shortModel === model;
|
|
171
|
+
if (!modelMatches)
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const normEffort = normalizeEffort(mappedProvider, shortModel, entry.effort);
|
|
175
|
+
if (normEffort === null)
|
|
176
|
+
continue; // unknown effort tier — skip.
|
|
177
|
+
candidates.push({ provider: mappedProvider, model: shortModel, effort: normEffort });
|
|
178
|
+
}
|
|
179
|
+
if (candidates.length === 0) {
|
|
180
|
+
return { mode, candidates, noCandidates: true };
|
|
181
|
+
}
|
|
182
|
+
return { mode, candidates };
|
|
183
|
+
}
|
|
184
|
+
/** Read <branch>.<category> as the pairings array, sorted by rank asc. */
|
|
185
|
+
function readPairings(table, taskCategory, branch = DEFAULT_BRANCH) {
|
|
186
|
+
if (!table || typeof table !== "object")
|
|
187
|
+
return [];
|
|
188
|
+
const perf = table[branch];
|
|
189
|
+
if (!perf || typeof perf !== "object")
|
|
190
|
+
return [];
|
|
191
|
+
let raw = perf[taskCategory];
|
|
192
|
+
// Defensive: tolerate a {pairings:[...]} wrapper even though the contract
|
|
193
|
+
// says the value IS the array directly.
|
|
194
|
+
if (raw && !Array.isArray(raw) && typeof raw === "object" && "pairings" in raw) {
|
|
195
|
+
raw = raw.pairings;
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(raw))
|
|
198
|
+
return [];
|
|
199
|
+
const entries = raw.filter((e) => !!e && typeof e === "object" && typeof e.model === "string");
|
|
200
|
+
return [...entries].sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
|
|
201
|
+
}
|
|
202
|
+
// The fixed 10 task categories + fallback_default. Maps directly to the
|
|
203
|
+
// routing-table category keys (param-contract.md). Immutable taxonomy.
|
|
204
|
+
export const TASK_CATEGORIES = [
|
|
205
|
+
"math_proof",
|
|
206
|
+
"security_review",
|
|
207
|
+
"debugging",
|
|
208
|
+
"quality_review",
|
|
209
|
+
"architecture",
|
|
210
|
+
"agentic_execution",
|
|
211
|
+
"data_analysis",
|
|
212
|
+
"coding",
|
|
213
|
+
"knowledge_synthesis",
|
|
214
|
+
"mechanical",
|
|
215
|
+
"fallback_default",
|
|
216
|
+
];
|
|
217
|
+
// Shared error hint blocks (resolution-matrix.md). Appended verbatim.
|
|
218
|
+
export const AUTO_HINT = "Tip: omit provider/model/effort entirely and the server auto-selects the best provider/model/effort for this task_category, with automatic silent fallback.";
|
|
219
|
+
export const SPLIT_HINT = "If unsure which category fits, do NOT pass one big amorphous task: break the work into smaller atomic steps that each map to a single task_category, and launch one agent per step.";
|
|
220
|
+
/**
|
|
221
|
+
* Pure param-presence validator for launch_agent (resolution-matrix.md).
|
|
222
|
+
*
|
|
223
|
+
* Returns the exact error string for the first failing rule, or null when the
|
|
224
|
+
* presence combination is valid. Order MUST match resolution-matrix.md
|
|
225
|
+
* "Validation order": category, then effort-needs-both, then model-needs-provider,
|
|
226
|
+
* then the explicit provider↔model match rule. Exported (and side-effect-free:
|
|
227
|
+
* this module never spawns or opens a transport) so the matrix and the verbatim
|
|
228
|
+
* error text (incl. AUTO_HINT/SPLIT_HINT placement) are CI-asserted directly
|
|
229
|
+
* against the production logic (Rule 9), not a re-implementation.
|
|
230
|
+
*/
|
|
231
|
+
export function validatePresence(p) {
|
|
232
|
+
const { task_category, provider, model, effort, deadlock } = p;
|
|
233
|
+
// 1. task_category valid?
|
|
234
|
+
if (!task_category || !TASK_CATEGORIES.includes(task_category)) {
|
|
235
|
+
const got = task_category ? String(task_category) : "<none>";
|
|
236
|
+
return `Error: task_category is required and must be one of: math_proof, security_review, debugging, quality_review, architecture, agentic_execution, data_analysis, coding, knowledge_synthesis, mechanical, fallback_default. Got: ${got}.\n${SPLIT_HINT}\n${AUTO_HINT}`;
|
|
237
|
+
}
|
|
238
|
+
// 2. deadlock cannot be combined with provider/model/effort.
|
|
239
|
+
if (deadlock === true && (provider || model || effort)) {
|
|
240
|
+
return `Error: deadlock cannot be combined with provider, model, or effort. If repeated attempts at this task have failed, switch to pure auto mode — pass only prompt + task_category and let the server select — unless your assignment explicitly demands a specific model. Omit provider/model/effort and retry.\n${AUTO_HINT}`;
|
|
241
|
+
}
|
|
242
|
+
// 3. effort present must come with provider AND model (checked before model rule).
|
|
243
|
+
if (effort && !(provider && model)) {
|
|
244
|
+
return `Error: effort requires both provider and model. You passed effort=${effort} without a complete provider+model. Either pass provider+model+effort for a fully explicit launch, or omit all three.\n${AUTO_HINT}`;
|
|
245
|
+
}
|
|
246
|
+
// 4. model present must come with provider.
|
|
247
|
+
if (model && !provider) {
|
|
248
|
+
return `Error: provider is required when model is given. You passed model=${model} without provider. Either also pass provider, or omit both.\n${AUTO_HINT}`;
|
|
249
|
+
}
|
|
250
|
+
// 5. explicit mode only: provider+model must satisfy the existing match rule.
|
|
251
|
+
if (provider && model) {
|
|
252
|
+
if (provider === "claude" && !["haiku", "sonnet", "opus", "opus-4-8"].includes(model)) {
|
|
253
|
+
return `Error: Claude provider only supports haiku, sonnet, opus, or opus-4-8. Got: ${model}`;
|
|
254
|
+
}
|
|
255
|
+
if (provider === "codex" && model !== "gpt-5.5") {
|
|
256
|
+
return `Error: Codex provider only supports gpt-5.5. Got: ${model}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-ruleset-scaffold.mjs from src/advanced-ruleset.py — DO NOT EDIT.
|
|
2
|
+
export const RULESET_SCAFFOLD = "#!/usr/bin/env python3\r\n\"\"\"advanced-ruleset.py — final-authority model-routing override hook for subagent-mcp.\r\n\r\n(a) PERFORMANCE WARNING: this script runs synchronously inside EVERY launch_agent\r\n call. Slow rules slow every agent launch. Keep rules lean and low-latency —\r\n no network calls, no heavy imports at module top. This is YOUR responsibility;\r\n you have been warned.\r\n\r\n(b) OUTPUT CONTRACT (routing mode): print to stdout ONE JSON array — the modified\r\n candidate list (reorder / filter / replace allowed). Template:\r\n [\r\n {\"provider\": \"claude\", \"model\": \"sonnet\", \"effort\": \"high\", \"rank\": 1},\r\n {\"provider\": \"codex\", \"model\": \"gpt-5.5\", \"effort\": \"xhigh\", \"rank\": 2}\r\n ]\r\n Valid providers: claude, codex. Valid models: haiku, sonnet, opus, opus-4-8 (claude);\r\n gpt-5.5 (codex). Valid efforts: haiku -> \"none\" only; sonnet -> low|medium|high|xhigh|max;\r\n opus/opus-4-8 -> those plus ultracode; gpt-5.5 -> low|medium|high|xhigh.\r\n \"rank\" on output is ignored. An EMPTY array vetoes the launch. Anything else\r\n invalid fails the launch hard — the server validates strictly.\r\n\r\n(c) INPUT CONTRACT (routing mode, invoked as: <python> advanced-ruleset.py route):\r\n stdin receives one JSON object:\r\n { \"candidates\": [ {\"provider\",\"model\",\"effort\",\"rank\"} ... ], # rank 1..N best->worst\r\n \"context\": { \"task_category\": str, \"cwd\": str,\r\n \"selection_mode\": \"auto\"|\"provider\"|\"provider_model\"|\"explicit\",\r\n \"provider\": str|None, \"model\": str|None, \"effort\": str|None } }\r\n OS environment variables are visible natively (os.environ).\r\n\r\nENV-CHECK MODE (no arguments): prints {\"ready\": true|false, \"load-rules\": true|false}.\r\nRuns once per MCP server process. load-rules false => ruleset silently disabled\r\nfor the rest of the process. Set LOAD_RULES = True below to activate.\r\n\"\"\"\r\nimport json\r\nimport sys\r\n\r\nLOAD_RULES = False\r\n\r\n# --- Requirements stub (scaffold itself is stdlib-only) ----------------------\r\n# List third-party distributions your rules import, e.g.:\r\n# REQUIREMENTS = [\"requests\", \"pyyaml\"]\r\n# Install with: <python> -m pip install <name> ...\r\nREQUIREMENTS = []\r\n\r\ndef missing_requirements():\r\n \"\"\"pip-check helper: returns the REQUIREMENTS entries not importable here.\"\"\"\r\n import importlib.util\r\n return [r for r in REQUIREMENTS\r\n if importlib.util.find_spec(r.replace(\"-\", \"_\")) is None]\r\n\r\ndef env_check():\r\n missing = missing_requirements()\r\n json.dump({\"ready\": not missing, \"load-rules\": bool(LOAD_RULES)}, sys.stdout)\r\n\r\ndef apply_rules(candidates, context):\r\n \"\"\"YOUR RULES HERE. Default: passthrough (returns the list unchanged).\"\"\"\r\n return candidates\r\n\r\ndef route():\r\n payload = json.load(sys.stdin)\r\n out = apply_rules(payload.get(\"candidates\", []), payload.get(\"context\", {}))\r\n json.dump(out, sys.stdout)\r\n\r\nif __name__ == \"__main__\":\r\n if len(sys.argv) > 1 and sys.argv[1] == \"route\":\r\n route()\r\n else:\r\n env_check()\r\n";
|
package/dist/ruleset.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advanced-ruleset.py execution gate — the user-editable python override hook
|
|
3
|
+
* with final authority over launch_agent model routing.
|
|
4
|
+
*
|
|
5
|
+
* Side-effect-free at import time (like routing.ts: no spawning, no transport,
|
|
6
|
+
* no FS reads at module scope) so unit tests can import dist/ruleset.js
|
|
7
|
+
* directly. index.ts instantiates ONE createRulesetGate() singleton next to
|
|
8
|
+
* deadlockWindow; the gate's latch is per-process, exactly the deadlock-window
|
|
9
|
+
* scoping: env-check SUCCESS latches enabled/disabled for the process lifetime,
|
|
10
|
+
* FAILURE never latches (re-run on the next launch_agent so an admin fix
|
|
11
|
+
* recovers without a restart).
|
|
12
|
+
*
|
|
13
|
+
* Contract: docs/spec/advanced-ruleset/.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { LAUNCH_MODELS, LAUNCH_EFFORTS, HAIKU_EFFORT } from "./routing.js";
|
|
19
|
+
import { RULESET_SCAFFOLD } from "./ruleset-scaffold.js";
|
|
20
|
+
/** Hardcoded per-execution timeout (2 minutes per the owner spec). Tests assert the value. */
|
|
21
|
+
export const RULESET_TIMEOUT_MS = 120_000;
|
|
22
|
+
/**
|
|
23
|
+
* Verbatim hard-fail message for ANY ruleset failure (missing interpreter,
|
|
24
|
+
* non-zero exit, invalid/non-serializable JSON, timeout). NO AUTO_HINT is ever
|
|
25
|
+
* appended — a deliberate, documented exception to the every-error-gets-hints
|
|
26
|
+
* convention (resolution-matrix.md): the admin must intervene, not the model.
|
|
27
|
+
*/
|
|
28
|
+
export const RULESET_HARD_FAIL_MSG = "subagent ruleset erroring. Please ask the system administrator to debug before continuing. It is highly discouraged to continue use of this chat session as the system is now operating outside safe parameters.";
|
|
29
|
+
export const SCAFFOLD_FILENAME = "advanced-ruleset.py";
|
|
30
|
+
/** Resolve dist/advanced-ruleset.py relative to this module — the routing-table.json sibling dir. */
|
|
31
|
+
export function defaultScaffoldPath() {
|
|
32
|
+
return fileURLToPath(new URL("./advanced-ruleset.py", import.meta.url));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Recreate the scaffold when absent (runtime recovery for a deleted file). An
|
|
36
|
+
* existing file is NEVER touched — user edits are sacred. Throws on write
|
|
37
|
+
* failure; the gate methods catch any throw in the ruleset pipeline and map it
|
|
38
|
+
* to the hard-fail result.
|
|
39
|
+
*/
|
|
40
|
+
export function ensureScaffold(path = defaultScaffoldPath()) {
|
|
41
|
+
if (existsSync(path))
|
|
42
|
+
return;
|
|
43
|
+
writeFileSync(path, RULESET_SCAFFOLD);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Interpreter auto-detect order. SUBAGENT_RULESET_PYTHON (non-empty) is
|
|
47
|
+
* EXCLUSIVE — no fallback past it: a wrong override must surface as the hard
|
|
48
|
+
* fail, never be masked by PATH luck. Otherwise: py launcher (win32 only),
|
|
49
|
+
* python3, python.
|
|
50
|
+
*/
|
|
51
|
+
export function interpreterCandidates(env, platform) {
|
|
52
|
+
const override = env.SUBAGENT_RULESET_PYTHON;
|
|
53
|
+
if (override)
|
|
54
|
+
return [override];
|
|
55
|
+
return platform === "win32" ? ["py", "python3", "python"] : ["python3", "python"];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run the script once. Mode discriminator is argv: no extra args = env check;
|
|
59
|
+
* ["route"] = routing mode (payload written to stdin, then end). OS env vars
|
|
60
|
+
* reach the script natively via process.env. Spawn failures are ASYNC
|
|
61
|
+
* (ENOENT/EACCES emit 'error' after spawn() returns) — same one-shot
|
|
62
|
+
* spawn-vs-error race discipline as the launch path in index.ts. The timeout
|
|
63
|
+
* kills the child (SIGKILL fallback after 2s on POSIX) and settles the promise
|
|
64
|
+
* immediately. stderr is collected for server-side logging only; the MCP
|
|
65
|
+
* caller only ever sees the verbatim hard-fail message.
|
|
66
|
+
*/
|
|
67
|
+
function execRuleset(interpreter, scriptPath, argvExtra, stdinText, timeoutMs) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let child;
|
|
70
|
+
try {
|
|
71
|
+
child = spawn(interpreter, [scriptPath, ...argvExtra], {
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
env: process.env,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
resolve({ kind: "no-spawn", detail: e instanceof Error ? e.message : String(e) });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let stdout = "";
|
|
82
|
+
let stderr = "";
|
|
83
|
+
let settled = false;
|
|
84
|
+
let spawned = false;
|
|
85
|
+
const finish = (outcome) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve(outcome);
|
|
91
|
+
};
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
try {
|
|
94
|
+
child.kill();
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
if (process.platform !== "win32") {
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
try {
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
}, 2000).unref();
|
|
104
|
+
}
|
|
105
|
+
finish({ kind: "failed", detail: `timeout after ${timeoutMs}ms` });
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
child.stdout?.on("data", (chunk) => {
|
|
108
|
+
stdout += chunk;
|
|
109
|
+
});
|
|
110
|
+
child.stderr?.on("data", (chunk) => {
|
|
111
|
+
stderr += chunk;
|
|
112
|
+
});
|
|
113
|
+
child.once("spawn", () => {
|
|
114
|
+
spawned = true;
|
|
115
|
+
});
|
|
116
|
+
child.once("error", (err) => {
|
|
117
|
+
// Pre-spawn error = interpreter not runnable (walk advances). A late
|
|
118
|
+
// error is folded into the close-path outcome instead.
|
|
119
|
+
if (!spawned) {
|
|
120
|
+
finish({ kind: "no-spawn", detail: err instanceof Error ? err.message : String(err) });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (child.stdin) {
|
|
124
|
+
// EPIPE if the script exits without reading stdin — never crash on it.
|
|
125
|
+
child.stdin.on("error", () => { });
|
|
126
|
+
if (stdinText !== null)
|
|
127
|
+
child.stdin.write(stdinText);
|
|
128
|
+
child.stdin.end();
|
|
129
|
+
}
|
|
130
|
+
child.on("close", (code) => {
|
|
131
|
+
if (code !== 0) {
|
|
132
|
+
finish({
|
|
133
|
+
kind: "failed",
|
|
134
|
+
detail: `exit code ${code}${stderr.trim() ? `; stderr: ${stderr.trim()}` : ""}`,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (stdout.trim() === "") {
|
|
139
|
+
finish({ kind: "failed", detail: "empty stdout" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
finish({ kind: "ok", stdout });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Env-check stdout shape: {"ready": bool, "load-rules": bool} (hyphenated key), extra keys ignored. */
|
|
147
|
+
function parseEnvCheck(stdout) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(stdout);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
156
|
+
return null;
|
|
157
|
+
const ready = parsed["ready"];
|
|
158
|
+
const loadRules = parsed["load-rules"];
|
|
159
|
+
if (typeof ready !== "boolean" || typeof loadRules !== "boolean")
|
|
160
|
+
return null;
|
|
161
|
+
return { ready, loadRules };
|
|
162
|
+
}
|
|
163
|
+
// Per-model effort legality, derived from the exported launch enums. Own
|
|
164
|
+
// membership checks on purpose: resolveEffort (effort.ts) has a lenient default
|
|
165
|
+
// that silently coerces unknown efforts to "high", so buildCommand throwing can
|
|
166
|
+
// NOT be relied on to reject bad ruleset output.
|
|
167
|
+
const SONNET_EFFORTS = LAUNCH_EFFORTS.filter((e) => e !== "ultracode");
|
|
168
|
+
const CODEX_EFFORTS = LAUNCH_EFFORTS.filter((e) => e !== "ultracode" && e !== "max");
|
|
169
|
+
function effortAllowed(model, effort) {
|
|
170
|
+
if (model === "haiku")
|
|
171
|
+
return effort === HAIKU_EFFORT;
|
|
172
|
+
if (model === "sonnet")
|
|
173
|
+
return SONNET_EFFORTS.includes(effort);
|
|
174
|
+
if (model === "opus" || model === "opus-4-8") {
|
|
175
|
+
return LAUNCH_EFFORTS.includes(effort);
|
|
176
|
+
}
|
|
177
|
+
// gpt-5.5 (the only remaining launch model).
|
|
178
|
+
return CODEX_EFFORTS.includes(effort);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Strict validation of routing-mode stdout: a bare JSON array of candidate
|
|
182
|
+
* objects, validated against the STATIC launch enums (not raw table rows — the
|
|
183
|
+
* returned list is consumed verbatim by the attempt loop, so every entry must
|
|
184
|
+
* be launchable; explicit mode has no table at all).
|
|
185
|
+
*
|
|
186
|
+
* Per element: string provider/model/effort; provider ∈ {claude, codex};
|
|
187
|
+
* model ∈ launch enum; provider↔model legality (claude↔{haiku,sonnet,opus,
|
|
188
|
+
* opus-4-8}, codex↔gpt-5.5); per-model effort legality incl. haiku→"none".
|
|
189
|
+
* Extra keys (incl. rank) are ignored on output; duplicates are allowed (the
|
|
190
|
+
* attempt loop just tries them in order). An EMPTY array is VALID — it is the
|
|
191
|
+
* limit case of the allowed filter operation and means "veto the launch"
|
|
192
|
+
* (index.ts owns the veto error). The error string here is for server-side
|
|
193
|
+
* stderr logging only.
|
|
194
|
+
*/
|
|
195
|
+
export function validateRulesetOutput(raw) {
|
|
196
|
+
if (!Array.isArray(raw)) {
|
|
197
|
+
return { ok: false, error: "output must be a bare JSON array of candidate objects" };
|
|
198
|
+
}
|
|
199
|
+
const candidates = [];
|
|
200
|
+
for (let i = 0; i < raw.length; i++) {
|
|
201
|
+
const el = raw[i];
|
|
202
|
+
if (!el || typeof el !== "object" || Array.isArray(el)) {
|
|
203
|
+
return { ok: false, error: `candidate ${i}: not an object` };
|
|
204
|
+
}
|
|
205
|
+
const entry = el;
|
|
206
|
+
const provider = entry.provider;
|
|
207
|
+
const model = entry.model;
|
|
208
|
+
const effort = entry.effort;
|
|
209
|
+
if (typeof provider !== "string" || typeof model !== "string" || typeof effort !== "string") {
|
|
210
|
+
return { ok: false, error: `candidate ${i}: provider, model, and effort must be strings` };
|
|
211
|
+
}
|
|
212
|
+
if (provider !== "claude" && provider !== "codex") {
|
|
213
|
+
return { ok: false, error: `candidate ${i}: unknown provider ${provider}` };
|
|
214
|
+
}
|
|
215
|
+
if (!LAUNCH_MODELS.includes(model)) {
|
|
216
|
+
return { ok: false, error: `candidate ${i}: unknown model ${model}` };
|
|
217
|
+
}
|
|
218
|
+
if (provider === "claude" && model === "gpt-5.5") {
|
|
219
|
+
return { ok: false, error: `candidate ${i}: claude does not support gpt-5.5` };
|
|
220
|
+
}
|
|
221
|
+
if (provider === "codex" && model !== "gpt-5.5") {
|
|
222
|
+
return { ok: false, error: `candidate ${i}: codex only supports gpt-5.5, got ${model}` };
|
|
223
|
+
}
|
|
224
|
+
if (!effortAllowed(model, effort)) {
|
|
225
|
+
return { ok: false, error: `candidate ${i}: effort ${effort} is not valid for ${provider}/${model}` };
|
|
226
|
+
}
|
|
227
|
+
candidates.push({ provider: provider, model, effort });
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, candidates };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Per-process gate factory (deadlock.ts pattern: closure state, instantiated
|
|
233
|
+
* once at module scope in index.ts). The opts are injection seams for tests
|
|
234
|
+
* only; production uses the defaults.
|
|
235
|
+
*/
|
|
236
|
+
export function createRulesetGate(opts = {}) {
|
|
237
|
+
const scriptPath = opts.scriptPath ?? defaultScaffoldPath();
|
|
238
|
+
const env = opts.env ?? process.env;
|
|
239
|
+
const platform = (opts.platform ?? process.platform);
|
|
240
|
+
const timeoutMs = opts.timeoutMs ?? RULESET_TIMEOUT_MS;
|
|
241
|
+
const exec = opts.exec ?? execRuleset;
|
|
242
|
+
let state = "unknown";
|
|
243
|
+
// Remembered ONLY when the env-check latches success, so a failed walk is
|
|
244
|
+
// repeated in full on the next launch (admin may fix PATH without a restart).
|
|
245
|
+
let interpreter = null;
|
|
246
|
+
async function ensureReady() {
|
|
247
|
+
if (state !== "unknown") {
|
|
248
|
+
return { ok: true, active: state === "enabled" };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
ensureScaffold(scriptPath);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
console.error(`[ruleset] scaffold recreate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
255
|
+
return { ok: false };
|
|
256
|
+
}
|
|
257
|
+
const walk = interpreterCandidates(env, platform);
|
|
258
|
+
for (const candidate of walk) {
|
|
259
|
+
const outcome = await exec(candidate, scriptPath, [], null, timeoutMs);
|
|
260
|
+
if (outcome.kind === "no-spawn") {
|
|
261
|
+
continue; // interpreter not present/runnable — walk advances.
|
|
262
|
+
}
|
|
263
|
+
// First candidate that spawned IS the interpreter for this execution;
|
|
264
|
+
// its script-level failure is a ruleset failure, not a cue to walk on.
|
|
265
|
+
if (outcome.kind === "failed") {
|
|
266
|
+
console.error(`[ruleset] env-check failed (${candidate}): ${outcome.detail}`);
|
|
267
|
+
return { ok: false };
|
|
268
|
+
}
|
|
269
|
+
const parsed = parseEnvCheck(outcome.stdout);
|
|
270
|
+
if (parsed === null) {
|
|
271
|
+
console.error(`[ruleset] env-check output is not {"ready":bool,"load-rules":bool} (${candidate})`);
|
|
272
|
+
return { ok: false };
|
|
273
|
+
}
|
|
274
|
+
if (!parsed.ready) {
|
|
275
|
+
console.error(`[ruleset] env-check reported ready:false (${candidate})`);
|
|
276
|
+
return { ok: false };
|
|
277
|
+
}
|
|
278
|
+
interpreter = candidate;
|
|
279
|
+
state = parsed.loadRules ? "enabled" : "disabled";
|
|
280
|
+
return { ok: true, active: state === "enabled" };
|
|
281
|
+
}
|
|
282
|
+
console.error(`[ruleset] no python interpreter found (tried: ${walk.join(", ")})`);
|
|
283
|
+
return { ok: false };
|
|
284
|
+
}
|
|
285
|
+
async function applyRules(payload) {
|
|
286
|
+
try {
|
|
287
|
+
ensureScaffold(scriptPath);
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.error(`[ruleset] scaffold recreate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
291
|
+
return { ok: false };
|
|
292
|
+
}
|
|
293
|
+
if (interpreter === null) {
|
|
294
|
+
// Defensive: applyRules is only reachable after ensureReady latched "enabled".
|
|
295
|
+
console.error("[ruleset] applyRules called before a successful env-check");
|
|
296
|
+
return { ok: false };
|
|
297
|
+
}
|
|
298
|
+
const outcome = await exec(interpreter, scriptPath, ["route"], JSON.stringify(payload), timeoutMs);
|
|
299
|
+
if (outcome.kind !== "ok") {
|
|
300
|
+
console.error(`[ruleset] routing mode failed: ${outcome.detail}`);
|
|
301
|
+
return { ok: false };
|
|
302
|
+
}
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(outcome.stdout);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
console.error("[ruleset] routing mode stdout is not valid JSON");
|
|
309
|
+
return { ok: false };
|
|
310
|
+
}
|
|
311
|
+
const validated = validateRulesetOutput(parsed);
|
|
312
|
+
if (!validated.ok) {
|
|
313
|
+
console.error(`[ruleset] invalid routing output: ${validated.error}`);
|
|
314
|
+
return { ok: false };
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, candidates: validated.candidates };
|
|
317
|
+
}
|
|
318
|
+
return { ensureReady, applyRules };
|
|
319
|
+
}
|