@fiale-plus/pi-rogue 0.2.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/README.md +50 -0
- package/node_modules/@fiale-plus/pi-core/README.md +13 -0
- package/node_modules/@fiale-plus/pi-core/package.json +25 -0
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +109 -0
- package/node_modules/@fiale-plus/pi-core/src/index.ts +5 -0
- package/node_modules/@fiale-plus/pi-core/src/paths.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.test.ts +129 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.ts +97 -0
- package/node_modules/@fiale-plus/pi-core/src/storage.ts +39 -0
- package/node_modules/@fiale-plus/pi-core/src/text.test.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/text.ts +14 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +59 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/advisor/index.ts +1 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/assets/binary-gate-model.json +24026 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/package.json +50 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/skills/advisor/SKILL.md +51 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +19 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +248 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate.test.ts +66 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/completions.test.ts +28 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/completions.ts +79 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.test.ts +364 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +1677 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/index.ts +3 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/internal.ts +63 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +512 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/preflight-signals.test.ts +22 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/preflight-signals.ts +21 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +126 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +580 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/state-versioning.test.ts +227 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +53 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +31 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +749 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +818 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +191 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +302 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +369 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +122 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +561 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/README.md +56 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/orchestration/index.ts +1 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +44 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +44 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.test.ts +142 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.ts +102 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch-state.ts +70 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch.test.ts +143 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/autoresearch.ts +139 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/completions.test.ts +23 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/completions.ts +53 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/extension.ts +23 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal-resolution.ts +36 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +182 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +232 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/index.ts +1 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/internal.ts +98 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +274 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +35 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +145 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/state.ts +24 -0
- package/package.json +51 -0
- package/src/context-broker-file.ts +1 -0
- package/src/context-broker-sqlite.ts +1 -0
- package/src/context-broker.ts +1 -0
- package/src/extension.test.ts +68 -0
- package/src/extension.ts +27 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
const ROOT_DIR = join(homedir(), ".pi", "agent", "pi-rogue");
|
|
6
|
+
|
|
7
|
+
export function appDir(): string {
|
|
8
|
+
mkdirSync(ROOT_DIR, { recursive: true });
|
|
9
|
+
return ROOT_DIR;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function featureDir(feature: string): string {
|
|
13
|
+
const dir = join(appDir(), feature);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function featureFile(feature: string, filename: string): string {
|
|
19
|
+
return join(featureDir(feature), filename);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function truncate(text: string, max: number): string {
|
|
23
|
+
if (text.length <= max) return text;
|
|
24
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readText(filePath: string, fallback = ""): string {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(filePath, "utf8");
|
|
30
|
+
} catch {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ensureParent(filePath: string): string {
|
|
36
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
37
|
+
return filePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function writeText(filePath: string, text: string): void {
|
|
41
|
+
ensureParent(filePath);
|
|
42
|
+
writeFileSync(filePath, text, "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function appendText(filePath: string, text: string): void {
|
|
46
|
+
ensureParent(filePath);
|
|
47
|
+
appendFileSync(filePath, text, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write text atomically: write to temp file, then rename. Falls back to direct write on failure. */
|
|
51
|
+
export function atomicWriteText(filePath: string, text: string): void {
|
|
52
|
+
const tempPath = filePath + ".tmp";
|
|
53
|
+
try {
|
|
54
|
+
writeText(tempPath, text);
|
|
55
|
+
try { renameSync(tempPath, filePath); } catch {
|
|
56
|
+
// If rename fails (e.g., cross-device), fall back to overwrite
|
|
57
|
+
writeText(filePath, text);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// If temp write fails, try direct write
|
|
61
|
+
writeText(filePath, text);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { completeSimple } from "@earendil-works/pi-ai";
|
|
7
|
+
import { registerAdvisor } from "./extension.js";
|
|
8
|
+
|
|
9
|
+
const testHome = vi.hoisted(() => `/tmp/pi-rogue-advisor-loop-convergence-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
10
|
+
|
|
11
|
+
vi.mock("node:os", async () => {
|
|
12
|
+
const actual = await vi.importActual<typeof import("node:os")>("node:os");
|
|
13
|
+
return { ...actual, homedir: () => testHome };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("@earendil-works/pi-ai", async () => {
|
|
17
|
+
const actual = await vi.importActual<typeof import("@earendil-works/pi-ai")>("@earendil-works/pi-ai");
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
completeSimple: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type Handler = (event: any, ctx: any) => any;
|
|
25
|
+
|
|
26
|
+
type HandlerMap = Record<string, Handler[]>;
|
|
27
|
+
type CommandMap = Record<string, { handler: (args: string, ctx: any) => any }>;
|
|
28
|
+
type MessageRendererMap = Record<string, (message: any, options: { expanded?: boolean }, theme: any) => any>;
|
|
29
|
+
|
|
30
|
+
function makeHandlers() {
|
|
31
|
+
const handlers: HandlerMap = {};
|
|
32
|
+
const commands: CommandMap = {};
|
|
33
|
+
const messageRenderers: MessageRendererMap = {};
|
|
34
|
+
const sendMessage = vi.fn();
|
|
35
|
+
|
|
36
|
+
const pi = {
|
|
37
|
+
on: (event: string, handler: Handler) => {
|
|
38
|
+
handlers[event] ??= [];
|
|
39
|
+
handlers[event].push(handler);
|
|
40
|
+
},
|
|
41
|
+
registerMessageRenderer: (customType: string, renderer: MessageRendererMap[string]) => {
|
|
42
|
+
messageRenderers[customType] = renderer;
|
|
43
|
+
},
|
|
44
|
+
registerCommand: (name: string, command: { handler: (args: string, ctx: any) => any }) => {
|
|
45
|
+
commands[name] = command;
|
|
46
|
+
},
|
|
47
|
+
registerTool: vi.fn(),
|
|
48
|
+
sendMessage,
|
|
49
|
+
sendUserMessage: () => undefined,
|
|
50
|
+
ui: {
|
|
51
|
+
setStatus: () => undefined,
|
|
52
|
+
notify: () => undefined,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { handlers, commands, messageRenderers, pi: pi as any, sendMessage };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ADVISOR_STATE_DIR = join(homedir(), ".pi", "agent", "pi-rogue", "advisor");
|
|
60
|
+
const ADVISOR_STATE_PATH = join(ADVISOR_STATE_DIR, "state.json");
|
|
61
|
+
const ADVISOR_CONFIG_PATH = join(ADVISOR_STATE_DIR, "config.json");
|
|
62
|
+
const ADVISOR_CACHE_PATH = join(ADVISOR_STATE_DIR, "cache.json");
|
|
63
|
+
|
|
64
|
+
function readAdvisorState(): any {
|
|
65
|
+
return JSON.parse(readFileSync(ADVISOR_STATE_PATH, "utf8"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mkCtx() {
|
|
69
|
+
return {
|
|
70
|
+
sessionManager: {
|
|
71
|
+
getSessionFile: () => join(homedir(), ".pi", "agent", "pi-rogue", "advisor", "session.jsonl"),
|
|
72
|
+
},
|
|
73
|
+
isIdle: () => true,
|
|
74
|
+
modelRegistry: {
|
|
75
|
+
find: (provider: string, model: string) => {
|
|
76
|
+
if (provider === "openai-codex") return { id: `${provider}/${model}`, provider, input: ["text"] };
|
|
77
|
+
return null;
|
|
78
|
+
},
|
|
79
|
+
getAvailable: () => [{ id: "provider/text-light", provider: "provider", input: ["text"] }],
|
|
80
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
81
|
+
},
|
|
82
|
+
ui: {
|
|
83
|
+
setStatus: () => undefined,
|
|
84
|
+
notify: () => undefined,
|
|
85
|
+
},
|
|
86
|
+
} as any;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("advisor two-agent convergence", () => {
|
|
90
|
+
let ctx: any;
|
|
91
|
+
let handlers: HandlerMap;
|
|
92
|
+
let commands: CommandMap;
|
|
93
|
+
let messageRenderers: MessageRendererMap;
|
|
94
|
+
let sendMessageMock: ReturnType<typeof vi.fn>;
|
|
95
|
+
let completeSimpleMock: ReturnType<typeof vi.fn>;
|
|
96
|
+
let piMock: any;
|
|
97
|
+
let priorState: string | null = null;
|
|
98
|
+
let priorConfig: string | null = null;
|
|
99
|
+
let priorCache: string | null = null;
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
priorState = existsSync(ADVISOR_STATE_PATH) ? readFileSync(ADVISOR_STATE_PATH, "utf8") : null;
|
|
103
|
+
priorConfig = existsSync(ADVISOR_CONFIG_PATH) ? readFileSync(ADVISOR_CONFIG_PATH, "utf8") : null;
|
|
104
|
+
priorCache = existsSync(ADVISOR_CACHE_PATH) ? readFileSync(ADVISOR_CACHE_PATH, "utf8") : null;
|
|
105
|
+
|
|
106
|
+
const setup = makeHandlers();
|
|
107
|
+
handlers = setup.handlers;
|
|
108
|
+
commands = setup.commands;
|
|
109
|
+
messageRenderers = setup.messageRenderers;
|
|
110
|
+
sendMessageMock = setup.sendMessage;
|
|
111
|
+
piMock = setup.pi;
|
|
112
|
+
|
|
113
|
+
mkdirSync(dirname(ADVISOR_STATE_PATH), { recursive: true });
|
|
114
|
+
writeFileSync(ADVISOR_CONFIG_PATH, JSON.stringify({ mode: "auto", review: "light", checkins: "off", checkinIntervalMinutes: 30 }, null, 2), "utf8");
|
|
115
|
+
writeFileSync(ADVISOR_CACHE_PATH, "{}", "utf8");
|
|
116
|
+
writeFileSync(ADVISOR_STATE_PATH, JSON.stringify({
|
|
117
|
+
turns: 0,
|
|
118
|
+
lastTask: "",
|
|
119
|
+
notes: [],
|
|
120
|
+
files: [],
|
|
121
|
+
errors: [],
|
|
122
|
+
advisorCalls: 0,
|
|
123
|
+
cacheHits: 0,
|
|
124
|
+
followUp: "",
|
|
125
|
+
router: {},
|
|
126
|
+
checkin: { queued: false },
|
|
127
|
+
reviewControl: {
|
|
128
|
+
status: "idle",
|
|
129
|
+
pending: false,
|
|
130
|
+
consumed: true,
|
|
131
|
+
running: false,
|
|
132
|
+
},
|
|
133
|
+
}, null, 2), "utf8");
|
|
134
|
+
|
|
135
|
+
registerAdvisor(setup.pi);
|
|
136
|
+
|
|
137
|
+
ctx = mkCtx();
|
|
138
|
+
completeSimpleMock = vi.mocked(completeSimple as any);
|
|
139
|
+
completeSimpleMock.mockReset();
|
|
140
|
+
|
|
141
|
+
const verdict = {
|
|
142
|
+
verdict: "not_done",
|
|
143
|
+
summary: "Closeout is incomplete",
|
|
144
|
+
reason: "Please run one concrete check and report the result",
|
|
145
|
+
actions: ["run focused check"],
|
|
146
|
+
checklist: [],
|
|
147
|
+
notify: true,
|
|
148
|
+
};
|
|
149
|
+
completeSimpleMock.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify(verdict) }] });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
if (priorState === null) {
|
|
154
|
+
writeFileSync(ADVISOR_STATE_PATH, "{}", "utf8");
|
|
155
|
+
} else {
|
|
156
|
+
writeFileSync(ADVISOR_STATE_PATH, priorState, "utf8");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (priorConfig === null) {
|
|
160
|
+
writeFileSync(ADVISOR_CONFIG_PATH, "{}", "utf8");
|
|
161
|
+
} else {
|
|
162
|
+
writeFileSync(ADVISOR_CONFIG_PATH, priorConfig, "utf8");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (priorCache === null) {
|
|
166
|
+
writeFileSync(ADVISOR_CACHE_PATH, "{}", "utf8");
|
|
167
|
+
} else {
|
|
168
|
+
writeFileSync(ADVISOR_CACHE_PATH, priorCache, "utf8");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does not re-run advisory review on repeated material snapshots", async () => {
|
|
173
|
+
const preflight = handlers.before_agent_start;
|
|
174
|
+
const turnEnd = handlers.turn_end;
|
|
175
|
+
expect(preflight?.length).toBe(1);
|
|
176
|
+
expect(turnEnd?.length).toBe(1);
|
|
177
|
+
|
|
178
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
179
|
+
|
|
180
|
+
const basePrompt = "Continue the current goal";
|
|
181
|
+
const statusText = "Repo-side autoresearch is verified closed. Only optional external rollout/CI smoke remains.";
|
|
182
|
+
|
|
183
|
+
const firstPrompt = await preflight;
|
|
184
|
+
expect(typeof firstPrompt).toBe("object");
|
|
185
|
+
|
|
186
|
+
await turnEnd;
|
|
190
|
+
|
|
191
|
+
const firstState = readAdvisorState();
|
|
192
|
+
expect(firstState.reviewControl.lastDecision).toBe("review");
|
|
193
|
+
expect(firstState.followUp).toContain("Closeout is incomplete");
|
|
194
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({
|
|
196
|
+
customType: "advisor:llm",
|
|
197
|
+
content: expect.stringContaining("Summary: Closeout is incomplete"),
|
|
198
|
+
}),
|
|
199
|
+
expect.anything(),
|
|
200
|
+
);
|
|
201
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
202
|
+
expect.objectContaining({ content: expect.stringContaining("Actions: run focused check") }),
|
|
203
|
+
expect.anything(),
|
|
204
|
+
);
|
|
205
|
+
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
|
206
|
+
|
|
207
|
+
const consumedPrompt = await preflight;
|
|
208
|
+
expect(String(consumedPrompt?.systemPrompt)).toContain("Advisor follow-up");
|
|
209
|
+
|
|
210
|
+
const consumedState = readAdvisorState();
|
|
211
|
+
expect(consumedState.reviewControl.status).toBe("consumed");
|
|
212
|
+
expect(consumedState.followUp).toBe("");
|
|
213
|
+
|
|
214
|
+
await turnEnd;
|
|
218
|
+
|
|
219
|
+
const secondState = readAdvisorState();
|
|
220
|
+
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(secondState.reviewControl.status).toBe("consumed");
|
|
222
|
+
expect(["repeated material snapshot", firstState.reviewControl.lastReason]).toContain(secondState.reviewControl.lastReason);
|
|
223
|
+
|
|
224
|
+
const withoutFollowUp = await preflight;
|
|
225
|
+
expect(String(withoutFollowUp?.systemPrompt)).not.toContain("Advisor follow-up");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("normalizes string actions in advisor handoffs", async () => {
|
|
229
|
+
const preflight = handlers.before_agent_start;
|
|
230
|
+
const turnEnd = handlers.turn_end;
|
|
231
|
+
expect(preflight?.length).toBe(1);
|
|
232
|
+
expect(turnEnd?.length).toBe(1);
|
|
233
|
+
|
|
234
|
+
completeSimpleMock.mockResolvedValue({
|
|
235
|
+
content: [{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: JSON.stringify({
|
|
238
|
+
verdict: "not_done",
|
|
239
|
+
summary: "Closeout is incomplete",
|
|
240
|
+
reason: "Verification is missing",
|
|
241
|
+
actions: "run focused check",
|
|
242
|
+
checklist: [],
|
|
243
|
+
notify: true,
|
|
244
|
+
}),
|
|
245
|
+
}],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
249
|
+
await preflight;
|
|
250
|
+
await turnEnd;
|
|
254
|
+
|
|
255
|
+
const state = readAdvisorState();
|
|
256
|
+
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(state.followUp).toBe("Closeout is incomplete — run focused check");
|
|
258
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
customType: "advisor:llm",
|
|
261
|
+
content: expect.stringContaining("Actions: run focused check"),
|
|
262
|
+
details: expect.objectContaining({ actions: ["run focused check"] }),
|
|
263
|
+
}),
|
|
264
|
+
expect.anything(),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("redacts transient clipboard image paths from emitted advisor handoffs", async () => {
|
|
269
|
+
const preflight = handlers.before_agent_start;
|
|
270
|
+
const turnEnd = handlers.turn_end;
|
|
271
|
+
const clipboardPath = "/var/folders/fm/rwczdnws5j58x7kbyn3vcx_h0000gn/T/clipboard-2026-06-04-012248-DEE3A154.png";
|
|
272
|
+
|
|
273
|
+
completeSimpleMock.mockResolvedValue({
|
|
274
|
+
content: [{
|
|
275
|
+
type: "text",
|
|
276
|
+
text: JSON.stringify({
|
|
277
|
+
verdict: "not_done",
|
|
278
|
+
summary: `The visible handoff should not include ${clipboardPath}`,
|
|
279
|
+
reason: `Expanded Ctrl+O output leaks ${clipboardPath}`,
|
|
280
|
+
actions: [`redact ${clipboardPath}`],
|
|
281
|
+
checklist: [],
|
|
282
|
+
notify: true,
|
|
283
|
+
}),
|
|
284
|
+
}],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
288
|
+
await preflight;
|
|
289
|
+
await turnEnd;
|
|
293
|
+
|
|
294
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
|
295
|
+
const sent = sendMessageMock.mock.calls[0]?.[0];
|
|
296
|
+
expect(JSON.stringify(sent)).not.toContain(clipboardPath);
|
|
297
|
+
expect(sent.content).toContain("[clipboard image]");
|
|
298
|
+
expect(readAdvisorState().followUp).toContain("[clipboard image]");
|
|
299
|
+
|
|
300
|
+
const theme = {
|
|
301
|
+
fg: (_name: string, text: string) => text,
|
|
302
|
+
bg: (_name: string, text: string) => text,
|
|
303
|
+
bold: (text: string) => text,
|
|
304
|
+
};
|
|
305
|
+
const expanded = messageRenderers["advisor:llm"](sent, { expanded: true }, theme).render(120).join("\n");
|
|
306
|
+
expect(expanded).toContain("full handoff:");
|
|
307
|
+
expect(expanded).toContain("Advisor verdict: review.");
|
|
308
|
+
expect(expanded).toContain("[clipboard image]");
|
|
309
|
+
expect(expanded).not.toContain(clipboardPath);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("suppresses duplicate reason and summary in advisor handoffs", async () => {
|
|
313
|
+
const preflight = handlers.before_agent_start;
|
|
314
|
+
const turnEnd = handlers.turn_end;
|
|
315
|
+
const duplicate = "The agent made a safe attempt, but it did not demonstrate that the advisor post-turn review was induced.";
|
|
316
|
+
|
|
317
|
+
completeSimpleMock.mockResolvedValue({
|
|
318
|
+
content: [{
|
|
319
|
+
type: "text",
|
|
320
|
+
text: JSON.stringify({
|
|
321
|
+
verdict: "not_done",
|
|
322
|
+
reason: duplicate,
|
|
323
|
+
summary: duplicate,
|
|
324
|
+
actions: ["Invoke the real review hook if available."],
|
|
325
|
+
checklist: [],
|
|
326
|
+
notify: true,
|
|
327
|
+
}),
|
|
328
|
+
}],
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
332
|
+
await preflight;
|
|
333
|
+
await turnEnd;
|
|
337
|
+
|
|
338
|
+
const sent = sendMessageMock.mock.calls[0]?.[0];
|
|
339
|
+
expect(sent.content).toContain(`Reason: ${duplicate}`);
|
|
340
|
+
expect(sent.content).not.toContain("Summary:");
|
|
341
|
+
expect(sent.details.summary).toBe("");
|
|
342
|
+
|
|
343
|
+
const theme = {
|
|
344
|
+
fg: (_name: string, text: string) => text,
|
|
345
|
+
bg: (_name: string, text: string) => text,
|
|
346
|
+
bold: (text: string) => text,
|
|
347
|
+
};
|
|
348
|
+
const collapsed = messageRenderers["advisor:llm"](sent, { expanded: false }, theme).render(120).join("\n");
|
|
349
|
+
const expanded = messageRenderers["advisor:llm"](sent, { expanded: true }, theme).render(120).join("\n");
|
|
350
|
+
expect(collapsed).toContain("reason:");
|
|
351
|
+
expect(collapsed).not.toContain("summary:");
|
|
352
|
+
expect(expanded).toContain("Reason:");
|
|
353
|
+
expect(expanded).not.toContain("reason:");
|
|
354
|
+
expect(expanded).not.toContain("Summary:");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("renders manual advisor answers as advisor custom messages", async () => {
|
|
358
|
+
expect(commands.advisor).toBeTruthy();
|
|
359
|
+
|
|
360
|
+
completeSimpleMock.mockResolvedValue({
|
|
361
|
+
content: [{
|
|
362
|
+
type: "text",
|
|
363
|
+
text: "Post-turn review: no merge blockers identified from the session brief.",
|
|
364
|
+
}],
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await commands.advisor.handler("should we merge this pr?", ctx);
|
|
368
|
+
|
|
369
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
370
|
+
expect.objectContaining({
|
|
371
|
+
customType: "advisor:llm",
|
|
372
|
+
content: "Post-turn review: no merge blockers identified from the session brief.",
|
|
373
|
+
display: true,
|
|
374
|
+
details: expect.objectContaining({
|
|
375
|
+
kind: "answer",
|
|
376
|
+
summary: "Post-turn review: no merge blockers identified from the session brief.",
|
|
377
|
+
}),
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("includes broker briefs in manual advisor context when available", async () => {
|
|
383
|
+
expect(commands.advisor).toBeTruthy();
|
|
384
|
+
piMock.__piRogueContextBroker = {
|
|
385
|
+
renderBrief: () => "## Context Broker\nHot:\n- ctx://session/s/tool_output/abc/ctx-1 summary=\"npm test passed\"",
|
|
386
|
+
};
|
|
387
|
+
completeSimpleMock.mockResolvedValue({ content: [{ type: "text", text: "Use the broker handle as evidence." }] });
|
|
388
|
+
|
|
389
|
+
await commands.advisor.handler("should we use broker context", ctx);
|
|
390
|
+
|
|
391
|
+
const messages = completeSimpleMock.mock.calls.at(-1)?.[1]?.messages;
|
|
392
|
+
const promptText = JSON.stringify(messages ?? completeSimpleMock.mock.calls.at(-1));
|
|
393
|
+
expect(promptText).toContain("Context broker brief");
|
|
394
|
+
expect(promptText).toContain("ctx://session/s/tool_output/abc/ctx-1");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("does not re-run advisory review on repeated agent-end material snapshots", async () => {
|
|
398
|
+
const preflight = handlers.before_agent_start;
|
|
399
|
+
const agentEnd = handlers.agent_end;
|
|
400
|
+
expect(preflight?.length).toBe(1);
|
|
401
|
+
expect(agentEnd?.length).toBe(1);
|
|
402
|
+
|
|
403
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
404
|
+
|
|
405
|
+
const basePrompt = "Continue the current goal";
|
|
406
|
+
const statusText = "Repo-side autoresearch is verified closed. Only optional external rollout/CI smoke remains.";
|
|
407
|
+
|
|
408
|
+
const firstPrompt = await preflight;
|
|
409
|
+
expect(typeof firstPrompt).toBe("object");
|
|
410
|
+
|
|
411
|
+
await agentEnd;
|
|
417
|
+
|
|
418
|
+
const firstState = readAdvisorState();
|
|
419
|
+
expect(firstState.reviewControl).toBeTruthy();
|
|
420
|
+
const callsBeforeSecond = completeSimpleMock.mock.calls.length;
|
|
421
|
+
|
|
422
|
+
const consumedPrompt = await preflight;
|
|
423
|
+
if (firstState.followUp) {
|
|
424
|
+
expect(String(consumedPrompt?.systemPrompt)).toContain("Advisor follow-up");
|
|
425
|
+
} else {
|
|
426
|
+
expect(String(consumedPrompt?.systemPrompt)).not.toContain("Advisor follow-up");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const consumedState = readAdvisorState();
|
|
430
|
+
expect(consumedState.reviewControl.status).toBe("consumed");
|
|
431
|
+
expect(consumedState.followUp).toBe("");
|
|
432
|
+
|
|
433
|
+
await agentEnd;
|
|
439
|
+
|
|
440
|
+
const secondState = readAdvisorState();
|
|
441
|
+
expect(completeSimpleMock).toHaveBeenCalledTimes(callsBeforeSecond);
|
|
442
|
+
expect(secondState.reviewControl.status).toBe("consumed");
|
|
443
|
+
expect(secondState.reviewControl.lastReason).toBe("repeated material snapshot");
|
|
444
|
+
|
|
445
|
+
const withoutFollowUp = await preflight;
|
|
446
|
+
expect(String(withoutFollowUp?.systemPrompt)).not.toContain("Advisor follow-up");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("records on-track reviews silently instead of emitting repetitive continue hints", async () => {
|
|
450
|
+
const preflight = handlers.before_agent_start;
|
|
451
|
+
const turnEnd = handlers.turn_end;
|
|
452
|
+
expect(preflight?.length).toBe(1);
|
|
453
|
+
expect(turnEnd?.length).toBe(1);
|
|
454
|
+
|
|
455
|
+
completeSimpleMock.mockResolvedValue({
|
|
456
|
+
content: [{
|
|
457
|
+
type: "text",
|
|
458
|
+
text: JSON.stringify({
|
|
459
|
+
verdict: "on_track",
|
|
460
|
+
summary: "Implementation aligns with the requested advisor check-in behavior",
|
|
461
|
+
actions: [],
|
|
462
|
+
checklist: [],
|
|
463
|
+
notify: true,
|
|
464
|
+
}),
|
|
465
|
+
}],
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await handlers.session_start?.[0]?.({}, ctx);
|
|
469
|
+
await preflight;
|
|
470
|
+
await turnEnd;
|
|
474
|
+
|
|
475
|
+
const state = readAdvisorState();
|
|
476
|
+
expect(completeSimpleMock).toHaveBeenCalledTimes(1);
|
|
477
|
+
expect(state.reviewControl.lastDecision).toBe("continue");
|
|
478
|
+
expect(sendMessageMock).not.toHaveBeenCalledWith(
|
|
479
|
+
expect.objectContaining({ customType: "advisor:llm" }),
|
|
480
|
+
expect.anything(),
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("recovers running review control state on session start", async () => {
|
|
485
|
+
const preflight = handlers.before_agent_start;
|
|
486
|
+
const sessionStart = handlers.session_start;
|
|
487
|
+
expect(sessionStart?.length).toBe(1);
|
|
488
|
+
expect(preflight?.length).toBe(1);
|
|
489
|
+
|
|
490
|
+
const state = readAdvisorState();
|
|
491
|
+
state.reviewControl = {
|
|
492
|
+
status: "running",
|
|
493
|
+
pending: true,
|
|
494
|
+
consumed: false,
|
|
495
|
+
running: true,
|
|
496
|
+
lastMaterialSignature: "stale",
|
|
497
|
+
lastTrigger: "turn-1",
|
|
498
|
+
};
|
|
499
|
+
writeFileSync(ADVISOR_STATE_PATH, JSON.stringify(state, null, 2), "utf8");
|
|
500
|
+
|
|
501
|
+
await sessionStart;
|
|
502
|
+
|
|
503
|
+
const recovered = readAdvisorState();
|
|
504
|
+
expect(recovered.reviewControl.running).toBe(false);
|
|
505
|
+
expect(recovered.reviewControl.status).toBe("needed");
|
|
506
|
+
expect(recovered.reviewControl.pending).toBe(true);
|
|
507
|
+
expect(recovered.reviewControl.consumed).toBe(false);
|
|
508
|
+
|
|
509
|
+
const status = await preflight;
|
|
510
|
+
expect(status?.systemPrompt).toContain("Review-control: needed");
|
|
511
|
+
});
|
|
512
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { classifyIntent, classifyMode } from "./preflight-signals.js";
|
|
3
|
+
|
|
4
|
+
describe("preflight signal classifiers", () => {
|
|
5
|
+
it("classifies planning prompts", () => {
|
|
6
|
+
expect(classifyIntent("what should we do next, design the architecture")).toBe("plan");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("classifies implementation prompts", () => {
|
|
10
|
+
expect(classifyIntent("implement the auth flow and add tests")).toBe("implement");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("classifies review prompts", () => {
|
|
14
|
+
expect(classifyIntent("please review this PR and check the diff")).toBe("review");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("classifies questions vs commands", () => {
|
|
18
|
+
expect(classifyMode("what should we do next?")).toBe("question");
|
|
19
|
+
expect(classifyMode("run the tests and fix the bug")).toBe("command");
|
|
20
|
+
expect(classifyMode("hello there")).toBe("neutral");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Lightweight intent classifier for preflight signal enrichment */
|
|
2
|
+
export function classifyIntent(text: string): string {
|
|
3
|
+
const t = ` ${String(text ?? "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ")} `;
|
|
4
|
+
if (/\b(plan|design|architecture|scope|next step|strategy|proposal|should we|what should|tradeoff|decision|path forward|how to approach)\b/.test(t)) return "plan";
|
|
5
|
+
if (/\b(debug|bug|error|fail|broken|crash|stack|traceback|investigate|why (is|was|does|did|are) )/.test(t)) return "debug";
|
|
6
|
+
if (/\b(review|check |verify|validate|look at|diff|pr |pull request|feedback)\b/i.test(t)) return "review";
|
|
7
|
+
if (/\b(research|compare|difference|which (one|model|lib|is better)|how does|documentation|read (about|the)|what is)\b/i.test(t)) return "research";
|
|
8
|
+
if (/\b(implement|build|write|create|add|make|refactor|rename|extract|migrate|fix|patch)\b/i.test(t)) return "implement";
|
|
9
|
+
if (/\b(install|config|setup|run|build|deploy|ssh|status|stats|logs?|theme|terminal|shell|brew|npm|git)\b/i.test(t)) return "ops";
|
|
10
|
+
if (/\b(continue|resume|compact|summarize|after compact|move on)\b/i.test(t)) return "handoff";
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Classify prompt as question/command/neutral for signal enrichment */
|
|
15
|
+
export function classifyMode(text: string): string {
|
|
16
|
+
const t = String(text ?? "").trim();
|
|
17
|
+
if (!t) return "";
|
|
18
|
+
if (/^(create|add|make|change|write|fix|update|remove|delete|run|install|set[\s-]?up|build|deploy|check|investigate|debug|review|test|refactor|merge|close|open|start|stop|restart|continue|show|list|compact|setup)\b/i.test(t)) return "command";
|
|
19
|
+
if (t.includes("?") || /^(what|why|how|when|where|who|which|is there|can we|should|does|did|are we|is it|do you|would you|could we)\b/i.test(t)) return "question";
|
|
20
|
+
return "neutral";
|
|
21
|
+
}
|