@siuver/omp-debug-mode 0.1.1 → 0.1.3
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/CHANGELOG.md +15 -0
- package/README.md +42 -14
- package/package.json +1 -1
- package/src/debug-mode.ts +638 -0
- package/src/gate.ts +36 -0
- package/src/log-files.ts +69 -0
- package/src/main.ts +13 -577
- package/src/methodology.ts +140 -0
- package/src/probes.ts +97 -0
- package/src/review-actions.ts +22 -0
- package/src/state.ts +147 -0
- package/src/tools.ts +78 -0
- package/src/ui.ts +78 -0
- package/src/workspace.ts +79 -0
package/src/log-files.ts
CHANGED
|
@@ -24,6 +24,75 @@ export function readJsonlLines(file: string | null): string[] {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
interface CountEntry {
|
|
28
|
+
size: number;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
count: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Line counts keyed by file identity. The reproduction gate polls the active
|
|
35
|
+
* log once a second, so re-reading every archived run each time would scale
|
|
36
|
+
* with the length of the debugging session for no new information.
|
|
37
|
+
*/
|
|
38
|
+
export class JsonlLineCounter {
|
|
39
|
+
#cache = new Map<string, CountEntry>();
|
|
40
|
+
|
|
41
|
+
count(file: string | null): number {
|
|
42
|
+
if (!file) return 0;
|
|
43
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
44
|
+
if (!stat) {
|
|
45
|
+
this.#cache.delete(file);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
const hit = this.#cache.get(file);
|
|
49
|
+
if (hit && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) return hit.count;
|
|
50
|
+
const count = readJsonlLines(file).length;
|
|
51
|
+
this.#cache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, count });
|
|
52
|
+
return count;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear(): void {
|
|
56
|
+
this.#cache.clear();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HypothesisTally {
|
|
61
|
+
id: string;
|
|
62
|
+
count: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const UNATTRIBUTED = "(unattributed)";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Group observations by the hypothesis each probe was meant to settle, so the
|
|
69
|
+
* user and the agent can see which hypotheses actually produced evidence
|
|
70
|
+
* before anyone reads the raw log.
|
|
71
|
+
*/
|
|
72
|
+
export function summarizeHypotheses(lines: readonly string[]): HypothesisTally[] {
|
|
73
|
+
const counts = new Map<string, number>();
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
let id = UNATTRIBUTED;
|
|
76
|
+
try {
|
|
77
|
+
const entry = JSON.parse(line) as { hypothesisId?: unknown };
|
|
78
|
+
if (typeof entry.hypothesisId === "string" && entry.hypothesisId.trim().length > 0) {
|
|
79
|
+
id = entry.hypothesisId.trim();
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return [...counts]
|
|
87
|
+
.map(([id, count]) => ({ id, count }))
|
|
88
|
+
.sort((a, b) => (b.count !== a.count ? b.count - a.count : a.id < b.id ? -1 : 1));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function describeHypotheses(tallies: readonly HypothesisTally[]): string {
|
|
92
|
+
if (tallies.length === 0) return "no hypothesis-attributed observations";
|
|
93
|
+
return tallies.map(t => `${t.id}=${t.count}`).join(", ");
|
|
94
|
+
}
|
|
95
|
+
|
|
27
96
|
export function prepareRunLog(debugDir: string, previousRun: string | null): string {
|
|
28
97
|
const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
|
|
29
98
|
const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
|
package/src/main.ts
CHANGED
|
@@ -1,588 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Debug Mode Extension — Cursor-style human-in-the-loop debugging for omp.
|
|
3
3
|
*
|
|
4
|
-
* State machine:
|
|
4
|
+
* State machine (matches Cursor Debug Mode):
|
|
5
5
|
* IDLE → /debug-mode <problem>
|
|
6
|
-
* → agent
|
|
6
|
+
* → agent writes 3-5 hypotheses and @omp-probe instrumentation (no product fix)
|
|
7
7
|
* → WAITING_REPRO: agent stops; user reproduces out-of-band
|
|
8
|
-
* → /
|
|
9
|
-
*
|
|
8
|
+
* → Proceed/add details → agent reads logs, evaluates hypotheses, fixes only
|
|
9
|
+
* with evidence, keeps probes, and asks for a verification reproduce → loop
|
|
10
|
+
* → Mark as fixed → agent removes probes + summarizes → teardown (logs deleted)
|
|
10
11
|
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* - Commands: /debug-mode, /debug-status, /debug-done fixed|proceed, /debug-abort
|
|
12
|
+
* - `state.ts` — persisted round state and the injected blackboard
|
|
13
|
+
* - `probes.ts` — `@omp-probe` ledger and its on-disk ground truth
|
|
14
|
+
* - `log-files.ts` — stable `<cwd>/.omp/debug/current.jsonl` and run archival
|
|
15
|
+
* - `methodology.ts` — the Cursor Debug Mode prompt contract
|
|
16
|
+
* - `debug-mode.ts` — state machine, commands, and lifecycle wiring
|
|
17
17
|
*/
|
|
18
|
-
import type { ExtensionAPI
|
|
19
|
-
import
|
|
20
|
-
import * as path from "node:path";
|
|
21
|
-
import { ACTIVE_LOG_FILE, prepareRunLog, readJsonlLines, resolveRunLogFile } from "./log-files";
|
|
22
|
-
|
|
23
|
-
const DEBUG_ENTRY = "com.omp.debug-mode.state";
|
|
24
|
-
const PROBE_MARK = /@omp-probe\s+([A-Za-z0-9_-]+)/g;
|
|
25
|
-
|
|
26
|
-
type Phase = "idle" | "round" | "waiting" | "cleanup";
|
|
27
|
-
|
|
28
|
-
interface Probe {
|
|
29
|
-
id: string;
|
|
30
|
-
file: string;
|
|
31
|
-
round: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface DebugState {
|
|
35
|
-
active: boolean;
|
|
36
|
-
phase: Phase;
|
|
37
|
-
problem: string;
|
|
38
|
-
round: number;
|
|
39
|
-
runId: string | null;
|
|
40
|
-
probes: Probe[];
|
|
41
|
-
debugDir: string | null;
|
|
42
|
-
logCounts: Record<string, number>;
|
|
43
|
-
hasRoundContent: boolean;
|
|
44
|
-
cleanupReady: boolean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
function freshState(): DebugState {
|
|
49
|
-
return {
|
|
50
|
-
active: false,
|
|
51
|
-
phase: "idle",
|
|
52
|
-
problem: "",
|
|
53
|
-
round: 0,
|
|
54
|
-
runId: null,
|
|
55
|
-
probes: [],
|
|
56
|
-
debugDir: null,
|
|
57
|
-
logCounts: {},
|
|
58
|
-
hasRoundContent: false,
|
|
59
|
-
cleanupReady: false,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function probeIdsIn(text: unknown): string[] {
|
|
64
|
-
if (typeof text !== "string") return [];
|
|
65
|
-
const ids: string[] = [];
|
|
66
|
-
for (const m of text.matchAll(PROBE_MARK)) ids.push(m[1]);
|
|
67
|
-
return ids;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function logFileFor(s: DebugState, run = s.runId): string | null {
|
|
71
|
-
return resolveRunLogFile(s.debugDir, run, s.runId);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function blackboard(s: DebugState): string {
|
|
75
|
-
const probes = s.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
|
|
76
|
-
const counts = Object.entries(s.logCounts)
|
|
77
|
-
.map(([run, n]) => `${run}: ${n}`)
|
|
78
|
-
.join(", ") || "(none yet)";
|
|
79
|
-
return `\
|
|
80
|
-
[DEBUG MODE ACTIVE — round ${s.round}]
|
|
81
|
-
|
|
82
|
-
Problem under investigation:
|
|
83
|
-
${s.problem}
|
|
84
|
-
|
|
85
|
-
Deployed probes (ground truth, maintained by the extension):
|
|
86
|
-
${probes}
|
|
87
|
-
|
|
88
|
-
Current run log file (absolute path): ${logFileFor(s) ?? "(not initialized)"}
|
|
89
|
-
Runtime probes MUST append JSONL directly to that exact file using the target runtime's native file APIs.
|
|
90
|
-
Probe code MUST carry the marker comment \`// @omp-probe <id>\` adjacent to it.
|
|
91
|
-
Logs by run: ${counts}
|
|
92
|
-
Current run id: ${s.runId ?? "(not started)"}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const METHODOLOGY = `\
|
|
96
|
-
[DEBUG MODE METHODOLOGY — follow strictly]
|
|
97
|
-
Each round consists of, in order:
|
|
98
|
-
1. Restate hypotheses (mark each: pending / ruled-out / confirmed via runtime evidence).
|
|
99
|
-
2. Update instrumentation: remove probes that yielded no information, add probes that
|
|
100
|
-
discriminate between remaining hypotheses. Each probe must use the target runtime's
|
|
101
|
-
native file append API to append exactly one compact JSON object plus a newline to the
|
|
102
|
-
exact absolute log file shown above. Use the schema {"probe":"<id>","ts":<epoch-ms>,
|
|
103
|
-
"data":<JSON-serializable-observation>}. Append; never overwrite or truncate. Open,
|
|
104
|
-
append, flush, and close promptly for each observation; do not retain an exclusive file
|
|
105
|
-
handle across the reproduction gate. Do not use HTTP, POST, localhost, sockets, or any
|
|
106
|
-
network transport. Escape the path correctly for the target language. Keep the marker
|
|
107
|
-
comment \`// @omp-probe <id>\` adjacent to the code.
|
|
108
|
-
3. ATTEMPT A FIX for your leading hypothesis in the same round. Instrumentation without
|
|
109
|
-
a fix is an incomplete round — the loop only advances when you fix something.
|
|
110
|
-
4. End the round by writing concise reproduction steps for the user (exact commands or
|
|
111
|
-
actions, what to observe). Then STOP — the user reproduces out-of-band and answers
|
|
112
|
-
with /debug-done fixed or /debug-done proceed.
|
|
113
|
-
When told the fix is confirmed: remove every probe, verify with the list_debug_probes
|
|
114
|
-
tool that the ledger is empty, then summarize root cause and the final fix.`;
|
|
18
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
19
|
+
import { registerDebugMode } from "./debug-mode";
|
|
115
20
|
|
|
116
21
|
export default function debugModeExtension(pi: ExtensionAPI) {
|
|
117
|
-
const z = pi.zod;
|
|
118
|
-
const state: DebugState = freshState();
|
|
119
|
-
let uiCtx: ExtensionContext | null = null;
|
|
120
|
-
|
|
121
|
-
// ============================== log files ==============================
|
|
122
|
-
|
|
123
|
-
function initializeLogDirectory(ctx: ExtensionContext): boolean {
|
|
124
|
-
const dir = path.resolve(ctx.cwd, ".omp", "debug");
|
|
125
|
-
try {
|
|
126
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
-
state.debugDir = dir;
|
|
128
|
-
return true;
|
|
129
|
-
} catch (err) {
|
|
130
|
-
pi.logger.error("debug-mode: cannot create log dir", { dir, err });
|
|
131
|
-
state.debugDir = null;
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function readRunLines(run: string): string[] {
|
|
137
|
-
return readJsonlLines(logFileFor(state, run));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function refreshLogCounts(): void {
|
|
141
|
-
const runs = new Set(Object.keys(state.logCounts));
|
|
142
|
-
if (state.debugDir) {
|
|
143
|
-
try {
|
|
144
|
-
for (const file of fs.readdirSync(state.debugDir)) {
|
|
145
|
-
if (!file.endsWith(".jsonl")) continue;
|
|
146
|
-
if (file === ACTIVE_LOG_FILE) {
|
|
147
|
-
if (state.runId) runs.add(state.runId);
|
|
148
|
-
} else {
|
|
149
|
-
runs.add(file.replace(/\.jsonl$/, ""));
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch {}
|
|
153
|
-
}
|
|
154
|
-
for (const run of runs) state.logCounts[run] = readRunLines(run).length;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function newRun(): string | null {
|
|
158
|
-
if (!state.debugDir) return null;
|
|
159
|
-
const run = `run${state.round}-${Date.now().toString(36)}`;
|
|
160
|
-
try {
|
|
161
|
-
prepareRunLog(state.debugDir, state.runId);
|
|
162
|
-
} catch (err) {
|
|
163
|
-
pi.logger.error("debug-mode: cannot initialize run log", {
|
|
164
|
-
file: path.join(state.debugDir, ACTIVE_LOG_FILE),
|
|
165
|
-
err,
|
|
166
|
-
});
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
state.runId = run;
|
|
170
|
-
state.logCounts[run] = 0;
|
|
171
|
-
return run;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ============================== probe ledger ==============================
|
|
175
|
-
|
|
176
|
-
pi.on("tool_call", async (event) => {
|
|
177
|
-
if (!state.active) return;
|
|
178
|
-
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
179
|
-
const input = event.input as Record<string, unknown>;
|
|
180
|
-
const file =
|
|
181
|
-
typeof input.path === "string" ? input.path : typeof input.file_path === "string" ? input.file_path : "(unknown)";
|
|
182
|
-
for (const v of Object.values(input)) {
|
|
183
|
-
for (const id of probeIdsIn(v)) {
|
|
184
|
-
if (!state.probes.some(p => p.id === id)) state.probes.push({ id, file, round: state.round });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// removal detection: edit inputs alone can't prove deletion — checkLedger()
|
|
188
|
-
// rescans files for ground truth on demand.
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
/** Ground truth: which registered probes still exist in code. */
|
|
192
|
-
async function checkLedger(): Promise<Probe[]> {
|
|
193
|
-
const alive: Probe[] = [];
|
|
194
|
-
const byFile = new Map<string, Probe[]>();
|
|
195
|
-
for (const p of state.probes) {
|
|
196
|
-
const list = byFile.get(p.file) ?? [];
|
|
197
|
-
list.push(p);
|
|
198
|
-
byFile.set(p.file, list);
|
|
199
|
-
}
|
|
200
|
-
for (const [file, probes] of byFile) {
|
|
201
|
-
let text: string;
|
|
202
|
-
try {
|
|
203
|
-
text = await Bun.file(file).text();
|
|
204
|
-
} catch {
|
|
205
|
-
continue; // file gone → probe gone
|
|
206
|
-
}
|
|
207
|
-
for (const p of probes) {
|
|
208
|
-
if (text.includes(`@omp-probe ${p.id}`)) alive.push(p);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return alive;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ============================== UI ==============================
|
|
215
|
-
|
|
216
|
-
function refreshUi(): void {
|
|
217
|
-
const ctx = uiCtx;
|
|
218
|
-
if (!ctx?.hasUI) return;
|
|
219
|
-
if (!state.active) {
|
|
220
|
-
ctx.ui.setStatus("debug-mode", undefined);
|
|
221
|
-
ctx.ui.setWidget("debug-mode", undefined);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
const label =
|
|
225
|
-
state.phase === "waiting" ? "🐞 waiting-repro" : state.phase === "round" ? `🐞 round ${state.round}` : "🐞 cleanup";
|
|
226
|
-
ctx.ui.setStatus("debug-mode", ctx.ui.theme.fg("warning", label));
|
|
227
|
-
const lines: string[] = [];
|
|
228
|
-
if (state.phase === "waiting") {
|
|
229
|
-
refreshLogCounts();
|
|
230
|
-
const n = state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
|
|
231
|
-
lines.push(ctx.ui.theme.fg("accent", "reproduce the bug, then: /debug-done fixed | proceed"));
|
|
232
|
-
lines.push(ctx.ui.theme.fg("dim", `run ${state.runId} — ${n} log entries`));
|
|
233
|
-
}
|
|
234
|
-
ctx.ui.setWidget("debug-mode", lines.length ? lines : undefined);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ============================== prompt injection ==============================
|
|
238
|
-
|
|
239
|
-
pi.on("before_agent_start", async () => {
|
|
240
|
-
if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
|
|
241
|
-
return {
|
|
242
|
-
message: {
|
|
243
|
-
customType: "debug-mode-context",
|
|
244
|
-
content: `${blackboard(state)}\n\n${METHODOLOGY}`,
|
|
245
|
-
display: false,
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// Drop stale blackboard messages; the freshest is re-injected each round.
|
|
251
|
-
pi.on("context", async (event) => {
|
|
252
|
-
const filtered = event.messages.filter(
|
|
253
|
-
m => !(m.role === "custom" && (m as { customType?: string }).customType === "debug-mode-context"),
|
|
254
|
-
);
|
|
255
|
-
if (filtered.length !== event.messages.length) return { messages: filtered };
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
// ============================== round lifecycle ==============================
|
|
259
|
-
|
|
260
|
-
pi.on("agent_start", async () => {
|
|
261
|
-
if (state.active) state.hasRoundContent = false;
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
pi.on("message_end", async (event) => {
|
|
265
|
-
if (!state.active) return;
|
|
266
|
-
const msg = event.message as { role?: string };
|
|
267
|
-
if (msg?.role === "assistant") {
|
|
268
|
-
if (state.phase === "cleanup") state.cleanupReady = true;
|
|
269
|
-
state.hasRoundContent = true;
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
pi.on("session_stop", async (_event, ctx) => {
|
|
274
|
-
if (!state.active) return;
|
|
275
|
-
if (state.phase === "cleanup") {
|
|
276
|
-
// Cleanup turn settled: keep fixes and remove the temporary logs.
|
|
277
|
-
if (state.cleanupReady) await teardown(ctx, true);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (state.phase !== "round" || !state.hasRoundContent) return;
|
|
281
|
-
state.phase = "waiting";
|
|
282
|
-
refreshUi();
|
|
283
|
-
ctx.ui.notify(
|
|
284
|
-
`Debug round ${state.round} paused.\nReproduce the bug now, then run:\n /debug-done fixed — fix confirmed, clean up & summarize\n /debug-done proceed — analyze logs, next round`,
|
|
285
|
-
"info",
|
|
286
|
-
);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
pi.registerCommand("debug-mode", {
|
|
290
|
-
description: "Start debug mode: /debug-mode <problem description>",
|
|
291
|
-
handler: async (args, ctx) => {
|
|
292
|
-
uiCtx = ctx;
|
|
293
|
-
if (state.active) {
|
|
294
|
-
ctx.ui.notify("debug-mode: already active (use /debug-done or /debug-abort)", "error");
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const problem = args.trim();
|
|
298
|
-
if (!problem) {
|
|
299
|
-
ctx.ui.notify("Usage: /debug-mode <problem description — symptoms, expected vs actual, how to reproduce>", "error");
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
startDebug(ctx, problem);
|
|
303
|
-
},
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
function startDebug(ctx: ExtensionContext, problem: string): void {
|
|
307
|
-
if (!initializeLogDirectory(ctx)) {
|
|
308
|
-
ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
state.active = true;
|
|
312
|
-
state.phase = "round";
|
|
313
|
-
state.problem = problem;
|
|
314
|
-
state.round = 1;
|
|
315
|
-
state.probes = [];
|
|
316
|
-
state.logCounts = {};
|
|
317
|
-
state.hasRoundContent = false;
|
|
318
|
-
if (!newRun()) {
|
|
319
|
-
Object.assign(state, freshState());
|
|
320
|
-
ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
refreshUi();
|
|
324
|
-
pi.sendMessage(
|
|
325
|
-
{
|
|
326
|
-
customType: "debug-mode-start",
|
|
327
|
-
content: `Starting debug mode. Problem report:\n\n${problem}\n\nBegin round 1: explore, hypothesize, instrument with direct file writes, and attempt a fix. Then give reproduction steps and stop.`,
|
|
328
|
-
display: true,
|
|
329
|
-
},
|
|
330
|
-
{ triggerTurn: true },
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function finishDebug(ctx: ExtensionContext, verdict: "fixed" | "proceed"): void {
|
|
335
|
-
if (!state.active) {
|
|
336
|
-
ctx.ui.notify("debug-mode: not active", "error");
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
if (state.phase !== "waiting") {
|
|
340
|
-
ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
refreshLogCounts();
|
|
344
|
-
if (verdict === "fixed") {
|
|
345
|
-
state.phase = "cleanup";
|
|
346
|
-
state.cleanupReady = false;
|
|
347
|
-
refreshUi();
|
|
348
|
-
pi.sendMessage(
|
|
349
|
-
{
|
|
350
|
-
customType: "debug-mode-fixed",
|
|
351
|
-
content:
|
|
352
|
-
"User marked the problem FIXED.\n" +
|
|
353
|
-
"1. Remove every debug probe from the code (probe ledger below; verify with list_debug_probes after edits).\n" +
|
|
354
|
-
"2. Then summarize: root cause, the fix applied, what remains in the working diff.\n" +
|
|
355
|
-
`Probe ledger: ${JSON.stringify(state.probes)}`,
|
|
356
|
-
display: true,
|
|
357
|
-
},
|
|
358
|
-
{ triggerTurn: true },
|
|
359
|
-
);
|
|
360
|
-
// teardown happens when the cleanup turn settles (session_stop above)
|
|
361
|
-
} else {
|
|
362
|
-
const run = state.runId ?? "(none)";
|
|
363
|
-
const n = state.logCounts[run] ?? 0;
|
|
364
|
-
const previousRound = state.round;
|
|
365
|
-
state.round += 1;
|
|
366
|
-
state.phase = "round";
|
|
367
|
-
state.hasRoundContent = false;
|
|
368
|
-
if (!newRun()) {
|
|
369
|
-
state.round = previousRound;
|
|
370
|
-
state.phase = "waiting";
|
|
371
|
-
ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
|
|
372
|
-
refreshUi();
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
refreshUi();
|
|
376
|
-
pi.sendMessage(
|
|
377
|
-
{
|
|
378
|
-
customType: "debug-mode-proceed",
|
|
379
|
-
content:
|
|
380
|
-
`User chose PROCEED — the fix did not resolve it (run ${run} captured ${n} log entries).\n` +
|
|
381
|
-
(n === 0
|
|
382
|
-
? "No logs were captured: the instrumented code path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed — treat that as a signal.\n"
|
|
383
|
-
: "") +
|
|
384
|
-
"Read the logs with get_debug_logs, rule out / strengthen hypotheses, re-instrument, attempt the next fix, give reproduction steps, and stop.",
|
|
385
|
-
display: true,
|
|
386
|
-
},
|
|
387
|
-
{ triggerTurn: true },
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
async function teardown(ctx: ExtensionContext, keepFixes: boolean): Promise<void> {
|
|
393
|
-
if (state.debugDir) {
|
|
394
|
-
try {
|
|
395
|
-
fs.rmSync(state.debugDir, { recursive: true, force: true });
|
|
396
|
-
} catch (err) {
|
|
397
|
-
pi.logger.warn("debug-mode: failed to remove debug dir", { err });
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
const probesLeft = await checkLedger();
|
|
401
|
-
Object.assign(state, freshState());
|
|
402
|
-
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
403
|
-
refreshUi();
|
|
404
|
-
if (probesLeft.length > 0) {
|
|
405
|
-
ctx.ui.notify(
|
|
406
|
-
`debug-mode ended, but ${probesLeft.length} probe(s) remain in code: ${probesLeft.map(p => p.id).join(", ")} — remove manually.`,
|
|
407
|
-
"warning",
|
|
408
|
-
);
|
|
409
|
-
} else if (keepFixes) {
|
|
410
|
-
ctx.ui.notify("Debug mode finished. Log files removed; working diff contains the fix — review with git diff.", "info");
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// ============================== commands ==============================
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
pi.registerCommand("debug-done", {
|
|
418
|
-
description: "Answer the reproduction gate: /debug-done fixed|proceed",
|
|
419
|
-
handler: async (args, ctx) => {
|
|
420
|
-
uiCtx = ctx;
|
|
421
|
-
const verdict = args.trim().toLowerCase();
|
|
422
|
-
if (verdict !== "fixed" && verdict !== "proceed") {
|
|
423
|
-
ctx.ui.notify("Usage: /debug-done fixed|proceed", "error");
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
finishDebug(ctx, verdict);
|
|
427
|
-
},
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
pi.registerCommand("debug-abort", {
|
|
431
|
-
description: "Abort debug mode: delete logs (fixes stay in the working diff)",
|
|
432
|
-
handler: async (_args, ctx) => {
|
|
433
|
-
uiCtx = ctx;
|
|
434
|
-
if (!state.active) {
|
|
435
|
-
ctx.ui.notify("debug-mode: not active", "error");
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
const ledger = await checkLedger();
|
|
439
|
-
await teardown(ctx, true);
|
|
440
|
-
if (ledger.length > 0) {
|
|
441
|
-
ctx.ui.notify(
|
|
442
|
-
`Debug mode aborted. These probes remain (remove manually or ask the agent):\n${ledger.map(p => ` ${p.id} — ${p.file}`).join("\n")}\nApplied fixes are kept in the working diff.`,
|
|
443
|
-
"warning",
|
|
444
|
-
);
|
|
445
|
-
} else {
|
|
446
|
-
ctx.ui.notify("Debug mode aborted. No probes in code; applied fixes are kept in the working diff.", "info");
|
|
447
|
-
}
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
pi.registerCommand("debug-status", {
|
|
452
|
-
description: "Show debug mode state",
|
|
453
|
-
handler: async (_args, ctx) => {
|
|
454
|
-
uiCtx = ctx;
|
|
455
|
-
if (!state.active) {
|
|
456
|
-
ctx.ui.notify("debug-mode: idle", "info");
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
refreshLogCounts();
|
|
460
|
-
const alive = await checkLedger();
|
|
461
|
-
ctx.ui.notify(
|
|
462
|
-
`debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
|
|
463
|
-
`probes (ledger ${state.probes.length}, alive ${alive.length}):\n` +
|
|
464
|
-
(alive.map(p => ` ${p.id} — ${p.file}`).join("\n") || " (none)") +
|
|
465
|
-
`\nlogs: ${Object.entries(state.logCounts).map(([r, n]) => `${r}=${n}`).join(", ") || "(none)"}` +
|
|
466
|
-
`\ncurrent log file: ${logFileFor(state) ?? "(not initialized)"}`,
|
|
467
|
-
"info",
|
|
468
|
-
);
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// ============================== tools ==============================
|
|
473
|
-
|
|
474
|
-
pi.registerTool({
|
|
475
|
-
name: "get_debug_logs",
|
|
476
|
-
label: "Get Debug Logs",
|
|
477
|
-
description:
|
|
478
|
-
"Read JSONL observations appended directly by debug-mode runtime probes. Each entry: {probe, ts, data}. Call with previous=true to analyze the last completed reproduction run.",
|
|
479
|
-
parameters: z.object({
|
|
480
|
-
run: z.string().optional().describe("Run id filter (default: current run)"),
|
|
481
|
-
probe: z.string().optional().describe("Probe id filter"),
|
|
482
|
-
previous: z.boolean().optional().describe("Use the previous (completed) run instead of the current one"),
|
|
483
|
-
}),
|
|
484
|
-
approval: "read",
|
|
485
|
-
async execute(_toolCallId, params) {
|
|
486
|
-
refreshLogCounts();
|
|
487
|
-
const runs = Object.keys(state.logCounts);
|
|
488
|
-
let run = params.run;
|
|
489
|
-
if (!run && params.previous) {
|
|
490
|
-
run = runs[runs.length - 2];
|
|
491
|
-
if (!run) {
|
|
492
|
-
return {
|
|
493
|
-
content: [{ type: "text", text: "(no completed previous debug run is available)" }],
|
|
494
|
-
details: { run: null, file: null, count: 0 },
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
if (!run) run = state.runId ?? undefined;
|
|
499
|
-
|
|
500
|
-
let lines = run ? readRunLines(run) : [];
|
|
501
|
-
if (params.probe) {
|
|
502
|
-
lines = lines.filter(line => {
|
|
503
|
-
try {
|
|
504
|
-
const entry = JSON.parse(line) as { probe?: unknown };
|
|
505
|
-
return entry.probe === params.probe;
|
|
506
|
-
} catch {
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
const text = lines.join("\n");
|
|
512
|
-
return {
|
|
513
|
-
content: [
|
|
514
|
-
{
|
|
515
|
-
type: "text",
|
|
516
|
-
text:
|
|
517
|
-
text ||
|
|
518
|
-
"(no logs captured — the instrumented path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed)",
|
|
519
|
-
},
|
|
520
|
-
],
|
|
521
|
-
details: { run: run ?? null, file: run ? logFileFor(state, run) : null, count: lines.length },
|
|
522
|
-
};
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
pi.registerTool({
|
|
527
|
-
name: "list_debug_probes",
|
|
528
|
-
label: "List Debug Probes",
|
|
529
|
-
description:
|
|
530
|
-
"Ground-truth probe ledger for debug mode: rescans files for `@omp-probe <id>` markers and reports which probes are actually present in code, with their files. Use to verify cleanup is complete.",
|
|
531
|
-
parameters: z.object({}),
|
|
532
|
-
approval: "read",
|
|
533
|
-
async execute() {
|
|
534
|
-
const alive = await checkLedger();
|
|
535
|
-
return {
|
|
536
|
-
content: [
|
|
537
|
-
{
|
|
538
|
-
type: "text",
|
|
539
|
-
text:
|
|
540
|
-
alive.length === 0
|
|
541
|
-
? "Probe ledger is EMPTY — all probes removed."
|
|
542
|
-
: `Alive probes (${alive.length}):\n` + alive.map(p => `${p.id} — ${p.file}`).join("\n"),
|
|
543
|
-
},
|
|
544
|
-
],
|
|
545
|
-
details: { alive },
|
|
546
|
-
};
|
|
547
|
-
},
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
// ============================== lifecycle ==============================
|
|
551
|
-
|
|
552
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
553
|
-
uiCtx = ctx;
|
|
554
|
-
const entries = ctx.sessionManager.getEntries();
|
|
555
|
-
const last = entries
|
|
556
|
-
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === DEBUG_ENTRY)
|
|
557
|
-
.pop() as { data?: DebugState } | undefined;
|
|
558
|
-
if (last?.data?.active) {
|
|
559
|
-
Object.assign(state, last.data);
|
|
560
|
-
if (!state.debugDir || !fs.existsSync(state.debugDir)) initializeLogDirectory(ctx);
|
|
561
|
-
const currentFile = logFileFor(state);
|
|
562
|
-
if (currentFile) {
|
|
563
|
-
try {
|
|
564
|
-
const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
|
|
565
|
-
if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
|
|
566
|
-
fs.renameSync(legacyRunFile, currentFile);
|
|
567
|
-
}
|
|
568
|
-
fs.writeFileSync(currentFile, "", { flag: "a" });
|
|
569
|
-
} catch (err) {
|
|
570
|
-
pi.logger.error("debug-mode: cannot restore run log", { file: currentFile, err });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
refreshLogCounts();
|
|
574
|
-
ctx.ui.notify(
|
|
575
|
-
`debug-mode resumed: phase=${state.phase} round=${state.round}, log=${currentFile ?? "unavailable"}. Use /debug-status, /debug-done fixed|proceed, or /debug-abort.`,
|
|
576
|
-
"info",
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
refreshUi();
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
pi.on("turn_start", async () => {
|
|
583
|
-
if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
|
|
587
22
|
pi.setLabel("Debug Mode");
|
|
23
|
+
registerDebugMode(pi);
|
|
588
24
|
}
|