@fiale-plus/pi-rogue 0.2.2 → 0.2.4

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 (42) hide show
  1. package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
  2. package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +1 -0
  3. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +8 -0
  4. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +7 -0
  5. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +26 -0
  6. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +10 -1
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -2
  8. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +81 -3
  9. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +72 -10
  10. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
  11. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
  12. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
  13. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
  14. package/node_modules/@fiale-plus/pi-rogue-orchestration/README.md +3 -3
  15. package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +3 -0
  16. package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +3 -2
  17. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +65 -2
  18. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +84 -4
  19. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +3 -0
  20. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +43 -0
  21. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +96 -11
  22. package/node_modules/@fiale-plus/pi-rogue-router/README.md +46 -5
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.test.ts +88 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.ts +232 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +9 -1
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +123 -9
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +39 -16
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +145 -6
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +51 -11
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +67 -7
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +27 -12
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +4 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +87 -9
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +130 -6
  35. package/node_modules/@fiale-plus/pi-rogue-router/src/reports.test.ts +92 -0
  36. package/node_modules/@fiale-plus/pi-rogue-router/src/reports.ts +116 -0
  37. package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.test.ts +223 -0
  38. package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.ts +344 -0
  39. package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.test.ts +126 -0
  40. package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.ts +238 -0
  41. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +59 -2
  42. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { hashText } from "./hash.js";
6
+ import { TEACHER_LABEL_SCHEMA, type TeacherLabel, type TeacherPromptRequest } from "./learning.js";
7
+ import type { AdviceShape, ContextPolicy, RouteAction, RouteDecision } from "./types.js";
8
+
9
+ const ROUTE_ACTIONS = new Set<RouteAction>([
10
+ "continue_current",
11
+ "continue_local",
12
+ "summarize_context",
13
+ "run_verifier",
14
+ "ask_micro_hint",
15
+ "escalate_plan_critique",
16
+ "escalate_debug_diagnosis",
17
+ "escalate_diff_review",
18
+ "delegate_full_step",
19
+ "spawn_subagent",
20
+ "merge_subagent_result",
21
+ "stop_and_ask_user",
22
+ ]);
23
+ const ADVICE_SHAPES = new Set<AdviceShape>(["none", "micro_hint", "plan_critique", "debug_diagnosis", "diff_review", "full_delegation"]);
24
+ const CONTEXT_POLICIES = new Set<ContextPolicy>(["none", "minimal", "recent_events", "focused_error_and_diff", "diff_only", "session_summary", "full_context"]);
25
+
26
+ export const TEACHER_RUN_SUMMARY_SCHEMA = "pi-router.teacher-run-summary.v1" as const;
27
+
28
+ export interface TeacherRunSummary {
29
+ schema: typeof TEACHER_RUN_SUMMARY_SCHEMA;
30
+ teacher: string;
31
+ teachers: string[];
32
+ requests: number;
33
+ decisions: number;
34
+ labels: number;
35
+ decisionsOutput: string;
36
+ labelsOutput: string;
37
+ dryRun: boolean;
38
+ }
39
+
40
+ export interface TeacherModelExecutor {
41
+ (input: { request: TeacherPromptRequest; prompt: string; teacher: string }): string | Promise<string>;
42
+ }
43
+
44
+ export function readTeacherPromptRequests(path: string): TeacherPromptRequest[] {
45
+ const resolved = resolve(path);
46
+ if (!existsSync(resolved)) throw new Error(`teacher request file not found: ${path}`);
47
+ return readFileSync(resolved, "utf8")
48
+ .split("\n")
49
+ .filter((line) => line.trim())
50
+ .map((line) => JSON.parse(line) as TeacherPromptRequest);
51
+ }
52
+
53
+ function writeJsonl(path: string, rows: unknown[]): void {
54
+ const resolved = resolve(path);
55
+ mkdirSync(dirname(resolved), { recursive: true });
56
+ writeFileSync(resolved, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""));
57
+ }
58
+
59
+ function readRawSessionSpan(request: TeacherPromptRequest, maxBytes = 20_000): { text: string; truncated: boolean } | null {
60
+ const { path, fromByte, toByte } = request.rawSessionRef;
61
+ const spanBytes = Math.max(0, toByte - fromByte);
62
+ const length = Math.min(maxBytes, spanBytes);
63
+ if (!path || length <= 0) return null;
64
+ const truncated = spanBytes > maxBytes;
65
+ const offset = truncated ? Math.max(fromByte, toByte - length) : fromByte;
66
+ let fd: number | undefined;
67
+ try {
68
+ fd = openSync(resolve(path), "r");
69
+ const buffer = Buffer.alloc(length);
70
+ const bytes = readSync(fd, buffer, 0, length, offset);
71
+ return { text: buffer.subarray(0, bytes).toString("utf8"), truncated };
72
+ } catch {
73
+ return null;
74
+ } finally {
75
+ if (fd !== undefined) closeSync(fd);
76
+ }
77
+ }
78
+
79
+ export function teacherPromptText(request: TeacherPromptRequest): string {
80
+ const span = readRawSessionSpan(request);
81
+ return [
82
+ "You are labeling a Pi router checkpoint for model routing.",
83
+ "Return exactly one JSON object matching pi-router.decision.v1 and no markdown.",
84
+ "Use the bounded raw session span as evidence, but do not quote transcript text in the reason; summarize evidence only.",
85
+ `Allowed actions: ${request.allowedActions.join(", ")}`,
86
+ request.instruction,
87
+ "Request:",
88
+ JSON.stringify(request, null, 2),
89
+ "Bounded raw session span (not persisted by the router; do not quote it in output):",
90
+ span ? `${span.text}${span.truncated ? "\n[truncated]" : ""}` : "[unavailable]",
91
+ ].join("\n\n");
92
+ }
93
+
94
+ function extractJsonObject(text: string): unknown {
95
+ const trimmed = text.trim();
96
+ if (trimmed.startsWith("{")) {
97
+ try {
98
+ return JSON.parse(trimmed);
99
+ } catch {
100
+ // Fall through to object slicing; models often append prose after a valid JSON object.
101
+ }
102
+ }
103
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
104
+ if (fenced) return JSON.parse(fenced[1]);
105
+ const start = trimmed.indexOf("{");
106
+ const end = trimmed.lastIndexOf("}");
107
+ if (start >= 0 && end > start) return JSON.parse(trimmed.slice(start, end + 1));
108
+ throw new Error("teacher response did not contain a JSON object");
109
+ }
110
+
111
+ function teacherPolicyVersion(request: TeacherPromptRequest): string {
112
+ return `teacher/${request.teacher}/request/${request.requestId}`.replace(/\s+/g, "-");
113
+ }
114
+
115
+ function sanitizeRationale(text: string): string {
116
+ // Do not persist free-form teacher rationale: the prompt includes a raw session span,
117
+ // so even unquoted excerpts would violate the router's derived-artifact privacy rule.
118
+ return `teacher rationale redacted; rationaleHash=${hashText(text)}`;
119
+ }
120
+
121
+ export function parseTeacherDecision(request: TeacherPromptRequest, text: string): RouteDecision {
122
+ const value = extractJsonObject(text) as Partial<RouteDecision>;
123
+ if (value.schema !== "pi-router.decision.v1") throw new Error(`teacher decision has invalid schema for ${request.checkpointId}`);
124
+ if (value.checkpointId !== request.checkpointId) throw new Error(`teacher decision checkpoint mismatch for ${request.checkpointId}`);
125
+ const allowedActions = request.allowedActions.filter((action): action is RouteAction => ROUTE_ACTIONS.has(action));
126
+ if (!value.action || !ROUTE_ACTIONS.has(value.action) || !allowedActions.includes(value.action)) throw new Error(`teacher decision action not allowed for ${request.checkpointId}: ${String(value.action)}`);
127
+ if (!value.adviceShape || !ADVICE_SHAPES.has(value.adviceShape)) throw new Error(`teacher decision adviceShape invalid for ${request.checkpointId}`);
128
+ if (!value.contextPolicy || !CONTEXT_POLICIES.has(value.contextPolicy)) throw new Error(`teacher decision contextPolicy invalid for ${request.checkpointId}`);
129
+ if (typeof value.confidence !== "number" || value.confidence < 0 || value.confidence > 1) throw new Error(`teacher decision confidence invalid for ${request.checkpointId}`);
130
+ if (typeof value.reason !== "string" || !value.reason.trim()) throw new Error(`teacher decision missing reason for ${request.checkpointId}`);
131
+ return {
132
+ schema: "pi-router.decision.v1",
133
+ checkpointId: request.checkpointId,
134
+ action: value.action,
135
+ adviceShape: value.adviceShape,
136
+ contextPolicy: value.contextPolicy,
137
+ confidence: Number(value.confidence.toFixed(3)),
138
+ reason: sanitizeRationale(value.reason),
139
+ policyVersion: teacherPolicyVersion(request),
140
+ };
141
+ }
142
+
143
+ export function labelFromTeacherDecision(request: TeacherPromptRequest, decision: RouteDecision, generatedAt: string): TeacherLabel {
144
+ return {
145
+ schema: TEACHER_LABEL_SCHEMA,
146
+ labelId: hashText("teacher-label", request.teacher, request.requestId, decision.action, request.rawSessionRef.contentHash),
147
+ generatedAt,
148
+ teacher: request.teacher,
149
+ checkpointId: request.checkpointId,
150
+ sessionId: request.sessionId,
151
+ rawSessionRef: request.rawSessionRef,
152
+ suggestedAction: decision.action,
153
+ confidence: decision.confidence,
154
+ rationale: decision.reason,
155
+ source: "teacher-output",
156
+ };
157
+ }
158
+
159
+ export function defaultPiTeacherExecutor(input: { request: TeacherPromptRequest; prompt: string; teacher: string }): string {
160
+ const dir = mkdtempSync(join(tmpdir(), "pi-router-teacher-"));
161
+ const promptPath = join(dir, "prompt.md");
162
+ try {
163
+ writeFileSync(promptPath, input.prompt, { mode: 0o600 });
164
+ return execFileSync("pi", [
165
+ "-p",
166
+ "--no-session",
167
+ "--no-tools",
168
+ "--no-context-files",
169
+ "--no-extensions",
170
+ "--no-skills",
171
+ "--no-prompt-templates",
172
+ "--model",
173
+ input.teacher,
174
+ `@${promptPath}`,
175
+ ], {
176
+ encoding: "utf8",
177
+ maxBuffer: 1024 * 1024,
178
+ stdio: ["ignore", "pipe", "pipe"],
179
+ });
180
+ } finally {
181
+ rmSync(dir, { recursive: true, force: true });
182
+ }
183
+ }
184
+
185
+ export async function runTeacherLabeling(options: {
186
+ requestsPath: string;
187
+ decisionsOutputPath: string;
188
+ labelsOutputPath: string;
189
+ teacher?: string;
190
+ dryRun?: boolean;
191
+ generatedAt?: string;
192
+ executor?: TeacherModelExecutor;
193
+ }): Promise<TeacherRunSummary> {
194
+ const requests = readTeacherPromptRequests(options.requestsPath).map((request) => options.teacher ? { ...request, teacher: options.teacher } : request);
195
+ const teachers = [...new Set(requests.map((request) => request.teacher))].sort();
196
+ const teacher = teachers.length === 1 ? teachers[0] : teachers.length > 1 ? "mixed" : options.teacher ?? "openai-codex/gpt-5.5";
197
+ const executor = options.executor ?? defaultPiTeacherExecutor;
198
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
199
+
200
+ if (options.dryRun) {
201
+ writeJsonl(options.decisionsOutputPath, []);
202
+ writeJsonl(options.labelsOutputPath, []);
203
+ return {
204
+ schema: TEACHER_RUN_SUMMARY_SCHEMA,
205
+ teacher,
206
+ teachers,
207
+ requests: requests.length,
208
+ decisions: 0,
209
+ labels: 0,
210
+ decisionsOutput: resolve(options.decisionsOutputPath),
211
+ labelsOutput: resolve(options.labelsOutputPath),
212
+ dryRun: true,
213
+ };
214
+ }
215
+
216
+ const decisions: RouteDecision[] = [];
217
+ const labels: TeacherLabel[] = [];
218
+ for (const request of requests) {
219
+ const prompt = teacherPromptText(request);
220
+ const response = await executor({ request, prompt, teacher: request.teacher });
221
+ const decision = parseTeacherDecision(request, response);
222
+ decisions.push(decision);
223
+ labels.push(labelFromTeacherDecision(request, decision, generatedAt));
224
+ }
225
+ writeJsonl(options.decisionsOutputPath, decisions);
226
+ writeJsonl(options.labelsOutputPath, labels);
227
+ return {
228
+ schema: TEACHER_RUN_SUMMARY_SCHEMA,
229
+ teacher,
230
+ teachers,
231
+ requests: requests.length,
232
+ decisions: decisions.length,
233
+ labels: labels.length,
234
+ decisionsOutput: resolve(options.decisionsOutputPath),
235
+ labelsOutput: resolve(options.labelsOutputPath),
236
+ dryRun: false,
237
+ };
238
+ }
@@ -9,7 +9,7 @@ import { decideRoute } from "./decision.js";
9
9
  import { buildRouteEvent } from "./ledger.js";
10
10
  import { readGitDiffStats } from "./git-features.js";
11
11
  import { generateTeacherPromptRequests } from "./learning.js";
12
- import { buildUnknownOutcome, inferOutcomes, writeInferredOutcomes } from "./outcomes.js";
12
+ import { buildUnknownOutcome, enrichOutcome, inferOutcomes, writeEnrichedOutcomes, writeInferredOutcomes } from "./outcomes.js";
13
13
  import { buildSubagentLedgerEvent, recommendSubagentDecision } from "./subagents.js";
14
14
  import type { RouterCheckpoint } from "./types.js";
15
15
 
@@ -91,14 +91,18 @@ describe("router v1 outcome and feature telemetry", () => {
91
91
  writeFileSync(join(repo, "new-file.txt"), "secret-ish content\n");
92
92
 
93
93
  const stats = readGitDiffStats(repo);
94
+ execFileSync("mkdir", ["-p", ".pi/router/sessions/other"], { cwd: repo });
95
+ writeFileSync(join(repo, ".pi/router/sessions/other/events.jsonl"), "{}\n");
94
96
  const excluded = readGitDiffStats(repo, { excludePaths: [join(repo, "new-file.txt")] });
97
+ const excludedRouterDir = readGitDiffStats(repo, { excludePaths: [join(repo, ".pi/router")] });
95
98
 
96
99
  expect(stats.filesChanged).toBeGreaterThanOrEqual(2);
97
100
  expect(stats.linesAdded).toBeGreaterThanOrEqual(1);
98
101
  expect(stats.fileHashes).toHaveLength(stats.filesChanged);
99
102
  expect(JSON.stringify(stats)).not.toContain("tracked.txt");
100
103
  expect(JSON.stringify(stats)).not.toContain("new-file.txt");
101
- expect(excluded.filesChanged).toBe(1);
104
+ expect(excluded.filesChanged).toBe(2);
105
+ expect(excludedRouterDir.filesChanged).toBe(2);
102
106
  });
103
107
 
104
108
  it("reads untracked files from repo root when launched in a subdirectory", () => {
@@ -148,6 +152,59 @@ describe("router v1 outcome and feature telemetry", () => {
148
152
  expect(JSON.stringify(outcome)).not.toContain("Error: boom");
149
153
  });
150
154
 
155
+ it("enriches outcome skeletons from checkpoint and route-event evidence", () => {
156
+ const item = checkpoint({ features: { verifierUsed: true, testsImproved: true, progressScore: 0.9, loopScore: 0.1, diffLines: 44, diffFilesChanged: 3, sameErrorRepeatedCount: 1 } });
157
+ const event = buildRouteEvent(item, decideRoute(item));
158
+ const outcome = buildUnknownOutcome(event, item);
159
+
160
+ const enriched = enrichOutcome({ ...outcome, taskStatus: "unknown", testsPassedAfter: true, verifierImproved: null, acceptedDiff: null }, { checkpoint: item, event, recordedAt: "2026-06-14T00:00:00.000Z" });
161
+
162
+ expect(enriched).toMatchObject({
163
+ taskStatus: "success",
164
+ testsPassedAfter: true,
165
+ verifierImproved: true,
166
+ acceptedDiff: true,
167
+ finalFilesTouched: 3,
168
+ finalDiffLines: 44,
169
+ });
170
+ expect(enriched.evidence.notesHash).toBeTruthy();
171
+ expect(JSON.stringify(enriched)).not.toContain("npm test");
172
+ });
173
+
174
+ it("writes enriched outcomes from explicit inputs", () => {
175
+ const item = checkpoint({ features: { verifierUsed: true, testsImproved: false, progressScore: 0.2, loopScore: 0.8, sameErrorRepeatedCount: 3 } });
176
+ const event = buildRouteEvent(item, decideRoute(item));
177
+ const checkpointPath = tempFile("checkpoints.jsonl");
178
+ const eventsPath = tempFile("events.jsonl");
179
+ const outcomesPath = tempFile("outcomes.jsonl");
180
+ const outputPath = tempFile("outcomes.enriched.jsonl");
181
+ writeFileSync(checkpointPath, `${JSON.stringify(item)}\n`);
182
+ writeFileSync(eventsPath, `${JSON.stringify(event)}\n`);
183
+ writeFileSync(outcomesPath, `${JSON.stringify({ ...buildUnknownOutcome(event, item), testsPassedAfter: false })}\n`);
184
+
185
+ const summary = writeEnrichedOutcomes({ outcomesPath, checkpointPath, eventsPath, outputPath });
186
+ const enriched = JSON.parse(readFileSync(outputPath, "utf8").trim());
187
+
188
+ expect(summary).toMatchObject({ schema: "pi-router.outcome-enrich-summary.v1", inputOutcomes: 1, outputOutcomes: 1, enriched: 1 });
189
+ expect(enriched).toMatchObject({ testsPassedAfter: false, verifierImproved: false, taskStatus: "failed" });
190
+ const eventOnlyOutput = tempFile("outcomes.event-only.jsonl");
191
+ writeEnrichedOutcomes({ outcomesPath, eventsPath, outputPath: eventOnlyOutput });
192
+ expect(JSON.parse(readFileSync(eventOnlyOutput, "utf8").trim())).toMatchObject({ finalDiffLines: event.metrics.diffLines, finalFilesTouched: event.metrics.diffFilesChanged, reworkTurns: 2 });
193
+ expect(writeEnrichedOutcomes({ outcomesPath: outputPath, checkpointPath, eventsPath, outputPath: tempFile("outcomes.enriched.again.jsonl") }).enriched).toBe(0);
194
+ const manualOutput = tempFile("outcomes.manual.enriched.jsonl");
195
+ writeFileSync(outcomesPath, `${JSON.stringify({ ...buildUnknownOutcome(event, item), evidence: { source: "manual", notesHash: "manual-notes-hash" } })}\n`);
196
+ writeEnrichedOutcomes({ outcomesPath, checkpointPath, eventsPath, outputPath: manualOutput });
197
+ expect(JSON.parse(readFileSync(manualOutput, "utf8").trim()).evidence.notesHash).toBe("manual-notes-hash");
198
+ const wrongEventsPath = tempFile("wrong-events.jsonl");
199
+ writeFileSync(wrongEventsPath, `${JSON.stringify({ ...event, checkpointId: "other-checkpoint" })}\n`);
200
+ expect(() => writeEnrichedOutcomes({ outcomesPath, eventsPath: wrongEventsPath, outputPath: tempFile("bad-wrong-events.jsonl") })).toThrow(/outcome routeEventId\/checkpointId mismatch/);
201
+ const emptyEvents = tempFile("empty-events.jsonl");
202
+ writeFileSync(emptyEvents, "");
203
+ expect(() => writeEnrichedOutcomes({ outcomesPath, eventsPath: emptyEvents, outputPath: tempFile("bad-empty-evidence.jsonl") })).toThrow(/contains no events/);
204
+ expect(() => writeEnrichedOutcomes({ outcomesPath, outputPath: tempFile("bad-no-evidence.jsonl") })).toThrow(/requires --checkpoint-file or --events/);
205
+ expect(() => writeEnrichedOutcomes({ outcomesPath, eventsPath: join(tmpdir(), "missing-events.jsonl"), outputPath: tempFile("bad-enriched.jsonl") })).toThrow(/route events file not found/);
206
+ });
207
+
151
208
  it("writes inferred outcomes from checkpoint and route-event files", () => {
152
209
  const item = checkpoint();
153
210
  const checkpointPath = tempFile("checkpoints.jsonl");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Public Pi-Rogue package for bundled advisor, orchestration, and context broker logic.",
5
5
  "type": "module",
6
6
  "license": "MIT",