@fiale-plus/pi-rogue 0.2.0 → 0.2.2

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 (39) hide show
  1. package/README.md +2 -1
  2. package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +75 -31
  3. package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +2 -2
  4. package/node_modules/@fiale-plus/pi-rogue-advisor/src/state-versioning.test.ts +25 -4
  5. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +4 -3
  6. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +38 -4
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +52 -6
  8. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.test.ts +10 -0
  9. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.ts +17 -2
  10. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +2 -2
  11. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/internal.ts +11 -2
  12. package/node_modules/@fiale-plus/pi-rogue-router/README.md +32 -0
  13. package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
  14. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
  15. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +355 -0
  16. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
  17. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
  18. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +133 -0
  19. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +168 -0
  20. package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
  21. package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
  22. package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +119 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +119 -0
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
  35. package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
  36. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +293 -0
  37. package/package.json +5 -3
  38. package/src/extension.test.ts +1 -0
  39. package/src/extension.ts +2 -0
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { decideRoute, readCheckpointJsonl, selectCheckpoint } from "./decision.js";
5
+ import { writeSessionCheckpointsJsonl } from "./checkpoints.js";
6
+ import { appendRouteEvent, buildRouteEvent } from "./ledger.js";
7
+ import { writeCapabilityCards, writeShadowEval, writeTeacherPromptRequests, writeTeacherReflection } from "./learning.js";
8
+ import { writeTrainingRows } from "./dataset.js";
9
+ import { writeInferredOutcomes } from "./outcomes.js";
10
+
11
+ interface Args {
12
+ command?: string;
13
+ sessions: string[];
14
+ sessionDir?: string;
15
+ output?: string;
16
+ checkpointFile?: string;
17
+ checkpointId?: string;
18
+ ledger?: string;
19
+ events?: string;
20
+ labels?: string;
21
+ reflection?: string;
22
+ teacher?: string;
23
+ teacherOutput?: string;
24
+ teacherPrompts?: string;
25
+ outcomes?: string;
26
+ includeLocalRuleLabels?: boolean;
27
+ workspaceDiff?: boolean;
28
+ pretty: boolean;
29
+ }
30
+
31
+ function usage(): never {
32
+ console.error(`Usage:
33
+ npm run router:rebuild -- --session <session.jsonl> [--session <session2.jsonl>] [--output <path>] [--workspace-diff] [--pretty]
34
+ npm run router:rebuild -- --session-dir <dir> [--output <path>] [--workspace-diff] [--pretty]
35
+ npm run router:decide -- --checkpoint-file <checkpoints.jsonl> [--checkpoint-id <id>] [--ledger <events.jsonl>] [--pretty]
36
+ npm run router:cards -- --events <events.jsonl> --output <model-cards.jsonl> [--outcomes <outcomes.jsonl>] [--pretty]
37
+ npm run router:outcomes -- --checkpoint-file <checkpoints.jsonl> --events <events.jsonl> --output <outcomes.jsonl> [--pretty]
38
+ npm run router:teacher-requests -- --checkpoint-file <checkpoints.jsonl> --output <requests.jsonl> [--teacher openai-codex/gpt-5.5] [--pretty]
39
+ npm run router:reflect -- --checkpoint-file <checkpoints.jsonl> --labels <labels.jsonl> --reflection <reflection.md> [--teacher local-rule] [--teacher-output <decisions.jsonl>] [--teacher-prompts <requests.jsonl>] [--pretty]
40
+ npm run router:dataset -- --checkpoint-file <checkpoints.jsonl> --output <training.jsonl> [--events <events.jsonl>] [--outcomes <outcomes.jsonl>] [--labels <labels.jsonl>] [--include-local-rule-labels] [--pretty]
41
+ npm run router:shadow -- --checkpoint-file <checkpoints.jsonl> --output <report.json> [--ledger <events.jsonl>] [--pretty]
42
+
43
+ Commands:
44
+ rebuild Rebuild derived router checkpoints from raw Pi session JSONL files.
45
+ decide Emit a strict JSON route decision for a checkpoint and optionally append a route event.
46
+ cards Generate local observed model capability cards from route events and optional outcomes.
47
+ outcomes Infer conservative outcome skeletons that can be manually enriched.
48
+ teacher-requests Generate local JSONL prompt requests for explicit teacher labeling.
49
+ reflect Generate command-triggered soft routing labels and a reflection artifact.
50
+ dataset Export trainable rows for a conservative continue-vs-intervene gate.
51
+ shadow Shadow-evaluate the current rule policy over historical checkpoints.
52
+ `);
53
+ process.exit(2);
54
+ }
55
+
56
+ function parseArgs(argv: string[]): Args {
57
+ const args: Args = { command: argv[0], sessions: [], pretty: false };
58
+ for (let index = 1; index < argv.length; index++) {
59
+ const arg = argv[index];
60
+ const next = argv[index + 1];
61
+ if (arg === "--help" || arg === "-h") usage();
62
+ if (arg === "--session" && next) {
63
+ args.sessions.push(next);
64
+ index++;
65
+ continue;
66
+ }
67
+ if (arg === "--session-dir" && next) {
68
+ args.sessionDir = next;
69
+ index++;
70
+ continue;
71
+ }
72
+ if (arg === "--output" && next) {
73
+ args.output = next;
74
+ index++;
75
+ continue;
76
+ }
77
+ if (arg === "--checkpoint-file" && next) {
78
+ args.checkpointFile = next;
79
+ index++;
80
+ continue;
81
+ }
82
+ if (arg === "--checkpoint-id" && next) {
83
+ args.checkpointId = next;
84
+ index++;
85
+ continue;
86
+ }
87
+ if (arg === "--ledger" && next) {
88
+ args.ledger = next;
89
+ index++;
90
+ continue;
91
+ }
92
+ if (arg === "--events" && next) {
93
+ args.events = next;
94
+ index++;
95
+ continue;
96
+ }
97
+ if (arg === "--labels" && next) {
98
+ args.labels = next;
99
+ index++;
100
+ continue;
101
+ }
102
+ if (arg === "--reflection" && next) {
103
+ args.reflection = next;
104
+ index++;
105
+ continue;
106
+ }
107
+ if (arg === "--teacher" && next) {
108
+ args.teacher = next;
109
+ index++;
110
+ continue;
111
+ }
112
+ if (arg === "--teacher-output" && next) {
113
+ args.teacherOutput = next;
114
+ index++;
115
+ continue;
116
+ }
117
+ if (arg === "--teacher-prompts" && next) {
118
+ args.teacherPrompts = next;
119
+ index++;
120
+ continue;
121
+ }
122
+ if (arg === "--outcomes" && next) {
123
+ args.outcomes = next;
124
+ index++;
125
+ continue;
126
+ }
127
+ if (arg === "--include-local-rule-labels") {
128
+ args.includeLocalRuleLabels = true;
129
+ continue;
130
+ }
131
+ if (arg === "--workspace-diff") {
132
+ args.workspaceDiff = true;
133
+ continue;
134
+ }
135
+ if (arg === "--pretty") {
136
+ args.pretty = true;
137
+ continue;
138
+ }
139
+ usage();
140
+ }
141
+ return args;
142
+ }
143
+
144
+ function defaultOutput(): string {
145
+ return join(process.cwd(), ".pi", "router", "checkpoints.jsonl");
146
+ }
147
+
148
+ function sessionFilesFromDir(dir: string): string[] {
149
+ const resolved = resolve(dir);
150
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
151
+ throw new Error(`--session-dir is not a directory: ${dir}`);
152
+ }
153
+ const files: string[] = [];
154
+ const visit = (current: string): void => {
155
+ for (const entry of readdirSync(current)) {
156
+ const path = join(current, entry);
157
+ const stat = statSync(path);
158
+ if (stat.isDirectory()) {
159
+ visit(path);
160
+ continue;
161
+ }
162
+ if (stat.isFile() && path.endsWith(".jsonl")) files.push(path);
163
+ }
164
+ };
165
+ visit(resolved);
166
+ return files.sort();
167
+ }
168
+
169
+ function resolveSessions(args: Args): string[] {
170
+ const sessions = [...args.sessions];
171
+ if (args.sessionDir) sessions.push(...sessionFilesFromDir(args.sessionDir));
172
+ return [...new Set(sessions.map((session) => resolve(session)))];
173
+ }
174
+
175
+ async function rebuild(args: Args): Promise<unknown> {
176
+ const sessions = resolveSessions(args);
177
+ if (sessions.length === 0) usage();
178
+ const output = args.output ?? defaultOutput();
179
+ const result = await writeSessionCheckpointsJsonl(sessions, output, { workspaceDiff: args.workspaceDiff });
180
+ return {
181
+ schema: "pi-router.rebuild-summary.v1",
182
+ sessions: result.sessions,
183
+ output: result.output,
184
+ checkpoints: result.checkpoints,
185
+ firstCheckpointId: result.firstCheckpointId,
186
+ lastCheckpointId: result.lastCheckpointId,
187
+ };
188
+ }
189
+
190
+ function decide(args: Args): unknown {
191
+ if (!args.checkpointFile) usage();
192
+ const checkpoints = readCheckpointJsonl(args.checkpointFile);
193
+ const checkpoint = selectCheckpoint(checkpoints, args.checkpointId);
194
+ const decision = decideRoute(checkpoint);
195
+ if (args.ledger) appendRouteEvent(args.ledger, buildRouteEvent(checkpoint, decision));
196
+ return decision;
197
+ }
198
+
199
+ function cards(args: Args): unknown {
200
+ if (!args.events || !args.output) usage();
201
+ const cards = writeCapabilityCards(args.events, args.output, args.outcomes);
202
+ return { schema: "pi-router.cards-summary.v1", output: resolve(args.output), cards: cards.length };
203
+ }
204
+
205
+ function outcomes(args: Args): unknown {
206
+ if (!args.checkpointFile || !args.events || !args.output) usage();
207
+ return writeInferredOutcomes({ checkpointPath: args.checkpointFile, eventsPath: args.events, outputPath: args.output });
208
+ }
209
+
210
+ function teacherRequests(args: Args): unknown {
211
+ if (!args.checkpointFile || !args.output) usage();
212
+ const requests = writeTeacherPromptRequests(args.checkpointFile, args.output, args.teacher ?? "openai-codex/gpt-5.5");
213
+ return { schema: "pi-router.teacher-requests-summary.v1", output: resolve(args.output), requests: requests.length, teacher: args.teacher ?? "openai-codex/gpt-5.5" };
214
+ }
215
+
216
+ function reflect(args: Args): unknown {
217
+ if (!args.checkpointFile || !args.labels || !args.reflection) usage();
218
+ const result = writeTeacherReflection({
219
+ checkpointPath: args.checkpointFile,
220
+ labelsPath: args.labels,
221
+ reflectionPath: args.reflection,
222
+ teacher: args.teacher ?? "local-rule",
223
+ teacherOutputPath: args.teacherOutput,
224
+ teacherPromptPath: args.teacherPrompts,
225
+ });
226
+ return {
227
+ schema: "pi-router.reflect-summary.v1",
228
+ labels: resolve(args.labels),
229
+ reflection: resolve(args.reflection),
230
+ labelCount: result.labels.length,
231
+ teacher: args.teacher ?? "local-rule",
232
+ };
233
+ }
234
+
235
+ function dataset(args: Args): unknown {
236
+ if (!args.checkpointFile || !args.output) usage();
237
+ return writeTrainingRows({
238
+ checkpointPath: args.checkpointFile,
239
+ outputPath: args.output,
240
+ eventsPath: args.events,
241
+ outcomesPath: args.outcomes,
242
+ labelsPath: args.labels,
243
+ includeLocalRuleLabels: args.includeLocalRuleLabels,
244
+ });
245
+ }
246
+
247
+ function shadow(args: Args): unknown {
248
+ if (!args.checkpointFile || !args.output) usage();
249
+ return writeShadowEval(args.checkpointFile, args.output, args.ledger);
250
+ }
251
+
252
+ async function main(): Promise<void> {
253
+ const args = parseArgs(process.argv.slice(2));
254
+ const result = args.command === "rebuild"
255
+ ? await rebuild(args)
256
+ : args.command === "decide"
257
+ ? decide(args)
258
+ : args.command === "cards"
259
+ ? cards(args)
260
+ : args.command === "outcomes"
261
+ ? outcomes(args)
262
+ : args.command === "teacher-requests"
263
+ ? teacherRequests(args)
264
+ : args.command === "reflect"
265
+ ? reflect(args)
266
+ : args.command === "dataset"
267
+ ? dataset(args)
268
+ : args.command === "shadow"
269
+ ? shadow(args)
270
+ : usage();
271
+ console.log(args.pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result));
272
+ }
273
+
274
+ main().catch((error: unknown) => {
275
+ console.error(error instanceof Error ? error.message : String(error));
276
+ process.exit(1);
277
+ });
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_ROUTER_CONFIG, loadRouterConfig } from "./config.js";
2
+
3
+ type CompletionItem = { value: string; label: string; description?: string };
4
+
5
+ function item(value: string, description?: string): CompletionItem {
6
+ return { value, label: value, ...(description ? { description } : {}) };
7
+ }
8
+
9
+ function filter(items: CompletionItem[], prefix: string): CompletionItem[] | null {
10
+ const q = prefix.trimStart().toLowerCase();
11
+ const out = q ? items.filter((entry) => entry.value.toLowerCase().startsWith(q)) : items;
12
+ return out.length ? out : null;
13
+ }
14
+
15
+ export function routerArgumentCompletions(prefix: string, ctx?: any): CompletionItem[] | null {
16
+ const trimmed = prefix.trimStart();
17
+ const [cmd, rest = ""] = trimmed.split(/\s+/, 2);
18
+ const top = [
19
+ item("on", "enable observe-only router summaries"),
20
+ item("off", "disable router summaries"),
21
+ item("status", "show router state and active profile"),
22
+ item("profile", "show or set active router profile"),
23
+ item("profiles", "list router profiles"),
24
+ item("models", "show active role to model mapping"),
25
+ item("configure", "write default local config if missing"),
26
+ item("cycle", "cycle to the next router profile"),
27
+ ];
28
+ if (!cmd || !trimmed.includes(" ")) return filter(top, trimmed);
29
+ if (cmd === "profile") {
30
+ const config = ctx ? loadRouterConfig(ctx) : DEFAULT_ROUTER_CONFIG;
31
+ return filter(config.profileOrder.map((name) => item(`profile ${name}`, config.profiles[name]?.worker)), `profile ${rest}`);
32
+ }
33
+ return null;
34
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdtempSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { routerArgumentCompletions } from "./completions.js";
6
+ import { activeProfile, cycleRouterProfile, ensureRouterConfig, loadRouterConfig, routerConfigPath, setRouterProfile } from "./config.js";
7
+ import { registerRouter } from "./extension.js";
8
+ import { decideRoute } from "./decision.js";
9
+ import { summarizeRouterDecision } from "./observe.js";
10
+ import type { RouterCheckpoint } from "./types.js";
11
+
12
+ function ctxMock() {
13
+ const cwd = mkdtempSync(join(tmpdir(), "pi-router-ext-"));
14
+ const notifications: Array<{ text: string; level: string }> = [];
15
+ return {
16
+ cwd,
17
+ notifications,
18
+ ui: {
19
+ notify(text: string, level: string) {
20
+ notifications.push({ text, level });
21
+ },
22
+ },
23
+ };
24
+ }
25
+
26
+ function piMock() {
27
+ const commands = new Map<string, any>();
28
+ const shortcuts = new Map<string, any>();
29
+ const handlers = new Map<string, any[]>();
30
+ const pi: any = {
31
+ registerCommand(name: string, options: any) { commands.set(name, options); },
32
+ registerShortcut(key: string, options: any) { shortcuts.set(key, options); },
33
+ on(name: string, handler: any) { handlers.set(name, [...(handlers.get(name) ?? []), handler]); },
34
+ };
35
+ return { pi, commands, shortcuts, handlers };
36
+ }
37
+
38
+ function checkpoint(overrides: Partial<RouterCheckpoint> = {}): RouterCheckpoint {
39
+ const base: RouterCheckpoint = {
40
+ schema: "pi-router.checkpoint.v1",
41
+ sessionId: "session-1",
42
+ checkpointId: "session-1:event-1",
43
+ createdAt: "2026-06-12T00:00:00.000Z",
44
+ rawSessionRef: { schema: "pi-router.raw-session-ref.v1", path: "/tmp/session.jsonl", fromEvent: 0, toEvent: 1, fromByte: 0, toByte: 10, contentHash: "hash" },
45
+ harness: "pi",
46
+ phase: "debug",
47
+ activeModel: "gpt-5.3-codex-spark",
48
+ provider: "openai-codex",
49
+ features: {
50
+ turnIndex: 1,
51
+ sameCommandRepeatedCount: 2,
52
+ sameErrorRepeatedCount: 2,
53
+ errorChanged: false,
54
+ testsImproved: null,
55
+ filesTouched: 1,
56
+ diffLines: 0,
57
+ diffFilesChanged: 0,
58
+ diffLinesAdded: 0,
59
+ diffLinesDeleted: 0,
60
+ diffChurnScore: 0,
61
+ toolThrashScore: 0.2,
62
+ goalDriftScore: 0,
63
+ loopScore: 0.55,
64
+ progressScore: 0.45,
65
+ verifierUsed: true,
66
+ noVerifierUsed: false,
67
+ toolCallsLast10Turns: 4,
68
+ contextTokensApprox: 1000,
69
+ gitDirty: null,
70
+ },
71
+ recent: { touchedFileHashes: [] },
72
+ sourceEvent: { index: 1, byteStart: 0, byteEnd: 10, type: "message", role: "toolResult" },
73
+ };
74
+ return { ...base, ...overrides, features: { ...base.features, ...(overrides.features ?? {}) } };
75
+ }
76
+
77
+ describe("router config profiles", () => {
78
+ it("creates default all-smart/spark/local profiles", () => {
79
+ const ctx = ctxMock();
80
+ const config = ensureRouterConfig(ctx);
81
+
82
+ expect(config.activeProfile).toBe("all-smart");
83
+ expect(config.profileOrder).toEqual(["all-smart", "spark-smart", "local-smart"]);
84
+ expect(activeProfile(config).worker).toBe("openai-codex/gpt-5.5");
85
+ expect(readFileSync(routerConfigPath(ctx), "utf8")).toContain("spark-smart");
86
+ });
87
+
88
+ it("sets and cycles profiles", () => {
89
+ const config = loadRouterConfig(ctxMock());
90
+ const spark = setRouterProfile(config, "spark-smart");
91
+
92
+ expect(spark?.activeProfile).toBe("spark-smart");
93
+ expect(cycleRouterProfile(spark!, 1).activeProfile).toBe("local-smart");
94
+ expect(setRouterProfile(config, "missing")).toBeNull();
95
+ });
96
+
97
+ it("completes router commands and profile names", () => {
98
+ expect(routerArgumentCompletions("")?.map((item) => item.value)).toEqual(expect.arrayContaining(["on", "off", "status", "profile"]));
99
+ expect(routerArgumentCompletions("profile s")?.map((item) => item.value)).toEqual(["profile spark-smart"]);
100
+ });
101
+ });
102
+
103
+ describe("router extension", () => {
104
+ it("registers slash command, ctrl-alt-p profile cycling, and observe hook", async () => {
105
+ const { pi, commands, shortcuts, handlers } = piMock();
106
+ const ctx = ctxMock();
107
+
108
+ registerRouter(pi);
109
+
110
+ expect(commands.has("router")).toBe(true);
111
+ expect(shortcuts.has("ctrl+alt+p")).toBe(true);
112
+ expect(handlers.has("turn_end")).toBe(true);
113
+
114
+ await commands.get("router").handler("on", ctx);
115
+ expect(loadRouterConfig(ctx).enabled).toBe(true);
116
+
117
+ await commands.get("router").handler("profile spark-smart", ctx);
118
+ expect(loadRouterConfig(ctx).activeProfile).toBe("spark-smart");
119
+
120
+ await shortcuts.get("ctrl+alt+p").handler(ctx);
121
+ expect(loadRouterConfig(ctx).activeProfile).toBe("local-smart");
122
+ });
123
+
124
+ it("formats observe-only mismatch summaries without changing models", () => {
125
+ const config = { ...loadRouterConfig(ctxMock()), enabled: true, activeProfile: "spark-smart" };
126
+ const item = checkpoint();
127
+ const summary = summarizeRouterDecision(item, decideRoute(item), config);
128
+
129
+ expect(summary.text).toContain("MISMATCH");
130
+ expect(summary.text).toContain("smart(openai-codex/gpt-5.5)");
131
+ expect(summary.text).toContain("current=gpt-5.3-codex-spark");
132
+ });
133
+ });
@@ -0,0 +1,168 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export type RouterMode = "observe";
5
+ export type RouterPrintMode = "all" | "mismatch_only" | "off";
6
+
7
+ export interface RouterProfile {
8
+ main?: string;
9
+ worker: string;
10
+ smart: string;
11
+ teacher: string;
12
+ reviewer: string;
13
+ explore?: string;
14
+ debug_diagnose?: string;
15
+ review?: string;
16
+ verify?: string;
17
+ }
18
+
19
+ export interface RouterConfig {
20
+ enabled: boolean;
21
+ mode: RouterMode;
22
+ print: RouterPrintMode;
23
+ activeProfile: string;
24
+ profileOrder: string[];
25
+ profiles: Record<string, RouterProfile>;
26
+ }
27
+
28
+ export interface RouterState {
29
+ lastObservedCheckpointId?: string;
30
+ lastDecisionAction?: string;
31
+ lastSummary?: string;
32
+ }
33
+
34
+ export const DEFAULT_ROUTER_CONFIG: RouterConfig = {
35
+ enabled: false,
36
+ mode: "observe",
37
+ print: "mismatch_only",
38
+ activeProfile: "all-smart",
39
+ profileOrder: ["all-smart", "spark-smart", "local-smart"],
40
+ profiles: {
41
+ "all-smart": {
42
+ worker: "openai-codex/gpt-5.5",
43
+ smart: "openai-codex/gpt-5.5",
44
+ teacher: "openai-codex/gpt-5.5",
45
+ reviewer: "openai-codex/gpt-5.5",
46
+ explore: "openai-codex/gpt-5.5",
47
+ debug_diagnose: "openai-codex/gpt-5.5",
48
+ review: "openai-codex/gpt-5.5",
49
+ verify: "openai-codex/gpt-5.5",
50
+ },
51
+ "spark-smart": {
52
+ worker: "openai-codex/gpt-5.3-codex-spark",
53
+ smart: "openai-codex/gpt-5.5",
54
+ teacher: "openai-codex/gpt-5.5",
55
+ reviewer: "openai-codex/gpt-5.5",
56
+ explore: "openai-codex/gpt-5.3-codex-spark",
57
+ debug_diagnose: "openai-codex/gpt-5.5",
58
+ review: "openai-codex/gpt-5.5",
59
+ verify: "openai-codex/gpt-5.3-codex-spark",
60
+ },
61
+ "local-smart": {
62
+ worker: "qwen3.6-35b-a3b-128k",
63
+ smart: "openai-codex/gpt-5.5",
64
+ teacher: "openai-codex/gpt-5.5",
65
+ reviewer: "openai-codex/gpt-5.5",
66
+ explore: "qwen3.6-35b-a3b-128k",
67
+ debug_diagnose: "openai-codex/gpt-5.5",
68
+ review: "openai-codex/gpt-5.5",
69
+ verify: "qwen3.6-35b-a3b-128k",
70
+ },
71
+ },
72
+ };
73
+
74
+ function cwdFromCtx(ctx: any): string {
75
+ return resolve(String(ctx?.cwd ?? process.cwd()));
76
+ }
77
+
78
+ export function routerDir(ctx: any): string {
79
+ return join(cwdFromCtx(ctx), ".pi", "router");
80
+ }
81
+
82
+ export function routerConfigPath(ctx: any): string {
83
+ return join(routerDir(ctx), "config.json");
84
+ }
85
+
86
+ export function routerStatePath(ctx: any): string {
87
+ return join(routerDir(ctx), "state.json");
88
+ }
89
+
90
+ export function routerEventsPath(ctx: any): string {
91
+ return join(routerDir(ctx), "events.jsonl");
92
+ }
93
+
94
+ function readJson<T>(path: string, fallback: T): T {
95
+ try {
96
+ return JSON.parse(readFileSync(path, "utf8")) as T;
97
+ } catch {
98
+ return fallback;
99
+ }
100
+ }
101
+
102
+ export function normalizeRouterConfig(raw: Partial<RouterConfig> | null | undefined): RouterConfig {
103
+ const mergedProfiles = { ...DEFAULT_ROUTER_CONFIG.profiles, ...(raw?.profiles ?? {}) };
104
+ const profileOrder = Array.isArray(raw?.profileOrder) && raw.profileOrder.length > 0
105
+ ? raw.profileOrder.filter((name) => typeof name === "string" && mergedProfiles[name])
106
+ : DEFAULT_ROUTER_CONFIG.profileOrder;
107
+ const activeProfile = raw?.activeProfile && mergedProfiles[raw.activeProfile]
108
+ ? raw.activeProfile
109
+ : profileOrder[0] ?? DEFAULT_ROUTER_CONFIG.activeProfile;
110
+ const print = raw?.print === "all" || raw?.print === "off" || raw?.print === "mismatch_only" ? raw.print : DEFAULT_ROUTER_CONFIG.print;
111
+ return {
112
+ enabled: Boolean(raw?.enabled ?? DEFAULT_ROUTER_CONFIG.enabled),
113
+ mode: "observe",
114
+ print,
115
+ activeProfile,
116
+ profileOrder,
117
+ profiles: mergedProfiles,
118
+ };
119
+ }
120
+
121
+ export function loadRouterConfig(ctx: any): RouterConfig {
122
+ return normalizeRouterConfig(readJson<Partial<RouterConfig>>(routerConfigPath(ctx), {}));
123
+ }
124
+
125
+ export function saveRouterConfig(ctx: any, config: RouterConfig): void {
126
+ const path = routerConfigPath(ctx);
127
+ mkdirSync(dirname(path), { recursive: true });
128
+ writeFileSync(path, `${JSON.stringify(normalizeRouterConfig(config), null, 2)}\n`);
129
+ }
130
+
131
+ export function ensureRouterConfig(ctx: any): RouterConfig {
132
+ const path = routerConfigPath(ctx);
133
+ const config = loadRouterConfig(ctx);
134
+ if (!existsSync(path)) saveRouterConfig(ctx, config);
135
+ return config;
136
+ }
137
+
138
+ export function loadRouterState(ctx: any): RouterState {
139
+ return readJson<RouterState>(routerStatePath(ctx), {});
140
+ }
141
+
142
+ export function saveRouterState(ctx: any, state: RouterState): void {
143
+ const path = routerStatePath(ctx);
144
+ mkdirSync(dirname(path), { recursive: true });
145
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
146
+ }
147
+
148
+ export function activeProfile(config: RouterConfig): RouterProfile {
149
+ return config.profiles[config.activeProfile] ?? config.profiles[config.profileOrder[0]] ?? DEFAULT_ROUTER_CONFIG.profiles[DEFAULT_ROUTER_CONFIG.activeProfile];
150
+ }
151
+
152
+ export function cycleRouterProfile(config: RouterConfig, direction: 1 | -1 = 1): RouterConfig {
153
+ const order = config.profileOrder.filter((name) => config.profiles[name]);
154
+ if (order.length === 0) return normalizeRouterConfig(config);
155
+ const currentIndex = Math.max(0, order.indexOf(config.activeProfile));
156
+ const nextIndex = (currentIndex + direction + order.length) % order.length;
157
+ return { ...config, activeProfile: order[nextIndex] };
158
+ }
159
+
160
+ export function setRouterProfile(config: RouterConfig, name: string): RouterConfig | null {
161
+ if (!config.profiles[name]) return null;
162
+ return { ...config, activeProfile: name, profileOrder: config.profileOrder.includes(name) ? config.profileOrder : [...config.profileOrder, name] };
163
+ }
164
+
165
+ export function formatProfile(name: string, profile: RouterProfile): string {
166
+ const subagents = [`explore=${profile.explore ?? profile.worker}`, `debug=${profile.debug_diagnose ?? profile.smart}`, `review=${profile.review ?? profile.reviewer}`, `verify=${profile.verify ?? profile.worker}`].join(" ");
167
+ return `${name}: worker=${profile.worker} smart=${profile.smart} teacher=${profile.teacher} reviewer=${profile.reviewer} ${subagents}`;
168
+ }