@siuver/omp-debug-mode 0.1.2 → 0.1.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.
- package/CHANGELOG.md +16 -0
- package/README.md +133 -78
- package/package.json +3 -3
- package/src/debug-mode.ts +748 -0
- package/src/evidence.ts +196 -0
- package/src/gate.ts +54 -0
- package/src/log-files.ts +69 -0
- package/src/main.ts +12 -672
- package/src/methodology.ts +188 -0
- package/src/probes.ts +97 -0
- package/src/state.ts +240 -0
- package/src/tools.ts +136 -0
- package/src/ui.ts +103 -0
- package/src/workspace.ts +79 -0
- package/src/review-actions.ts +0 -11
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { Text } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, MessageRenderer } from "@oh-my-pi/pi-coding-agent";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { addEvidenceArtifact, describeEvidence, parseEvidencePlan } from "./evidence";
|
|
6
|
+
import { decideGate } from "./gate";
|
|
7
|
+
import {
|
|
8
|
+
ACTIVE_LOG_FILE,
|
|
9
|
+
JsonlLineCounter,
|
|
10
|
+
describeHypotheses,
|
|
11
|
+
prepareRunLog,
|
|
12
|
+
readJsonlLines,
|
|
13
|
+
summarizeHypotheses,
|
|
14
|
+
} from "./log-files";
|
|
15
|
+
import { describeLedger, recordProbes, syncLedger } from "./probes";
|
|
16
|
+
import {
|
|
17
|
+
CLEANUP_CONTRACT,
|
|
18
|
+
METHODOLOGY,
|
|
19
|
+
PROCEED_REMINDER,
|
|
20
|
+
buildFixedMessage,
|
|
21
|
+
buildProceedMessage,
|
|
22
|
+
buildStartMessage,
|
|
23
|
+
extractAssistantText,
|
|
24
|
+
extractReproductionSteps,
|
|
25
|
+
} from "./methodology";
|
|
26
|
+
import {
|
|
27
|
+
DEBUG_CONTEXT_TYPE,
|
|
28
|
+
DEBUG_ENTRY,
|
|
29
|
+
type DebugState,
|
|
30
|
+
type EvidenceObservation,
|
|
31
|
+
blackboard,
|
|
32
|
+
compareRunIds,
|
|
33
|
+
evidenceSummary,
|
|
34
|
+
freshState,
|
|
35
|
+
keepLatestCustomType,
|
|
36
|
+
logFileFor,
|
|
37
|
+
pendingEvidenceRequests,
|
|
38
|
+
replaceRoundEvidenceRequests,
|
|
39
|
+
} from "./state";
|
|
40
|
+
import { registerDebugTools } from "./tools";
|
|
41
|
+
import { applyUi } from "./ui";
|
|
42
|
+
import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
|
|
43
|
+
|
|
44
|
+
const COMMAND_MODE = "debug-mode";
|
|
45
|
+
const COMMAND_DONE = "debug-done";
|
|
46
|
+
const COMMAND_PROCEED = "debug-proceed";
|
|
47
|
+
const COMMAND_EVIDENCE = "debug-evidence";
|
|
48
|
+
const COMMAND_ABORT = "debug-abort";
|
|
49
|
+
const COMMAND_STATUS = "debug-status";
|
|
50
|
+
|
|
51
|
+
/** Compact transcript lines for the prompts this extension injects. */
|
|
52
|
+
const MESSAGE_SUMMARIES: Record<string, string> = {
|
|
53
|
+
"debug-mode-start": "debug mode started — hypotheses and instrumentation",
|
|
54
|
+
"debug-mode-proceed": "proceed — analyzing captured logs",
|
|
55
|
+
"debug-mode-fixed": "marked fixed — removing probes and summarizing",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface DebugMessageDetails {
|
|
59
|
+
summary?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registerDebugMode(pi: ExtensionAPI): void {
|
|
63
|
+
const state: DebugState = freshState();
|
|
64
|
+
let uiCtx: ExtensionContext | null = null;
|
|
65
|
+
let watchedLogFile: string | null = null;
|
|
66
|
+
const lineCounter = new JsonlLineCounter();
|
|
67
|
+
|
|
68
|
+
for (const [customType, summary] of Object.entries(MESSAGE_SUMMARIES)) {
|
|
69
|
+
const renderer: MessageRenderer<DebugMessageDetails> = (message, _options, theme) => {
|
|
70
|
+
const text = typeof message.details?.summary === "string" ? message.details.summary : summary;
|
|
71
|
+
return new Text(theme.fg("dim", `🐞 ${text}`), 1, 0);
|
|
72
|
+
};
|
|
73
|
+
pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================== log files ==============================
|
|
77
|
+
|
|
78
|
+
function initializeLogDirectory(ctx: ExtensionContext): boolean {
|
|
79
|
+
const dir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
82
|
+
state.debugDir = dir;
|
|
83
|
+
excludeDebugLogsFromGit(ctx.cwd);
|
|
84
|
+
return true;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
pi.logger.error("debug-mode: cannot create log dir", { dir, err });
|
|
87
|
+
state.debugDir = null;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readRunLines(run: string): string[] {
|
|
93
|
+
return readJsonlLines(logFileFor(state, run));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshLogCounts(): void {
|
|
97
|
+
const runs = new Set(Object.keys(state.logCounts));
|
|
98
|
+
if (state.debugDir) {
|
|
99
|
+
try {
|
|
100
|
+
for (const file of fs.readdirSync(state.debugDir)) {
|
|
101
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
102
|
+
if (file === ACTIVE_LOG_FILE) {
|
|
103
|
+
if (state.runId) runs.add(state.runId);
|
|
104
|
+
} else {
|
|
105
|
+
runs.add(file.replace(/\.jsonl$/, ""));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
for (const run of runs) state.logCounts[run] = lineCounter.count(logFileFor(state, run));
|
|
111
|
+
adoptDiscoveredRuns(runs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Keep the ordered history complete when runs are discovered from disk. */
|
|
115
|
+
function adoptDiscoveredRuns(runs: Iterable<string>): void {
|
|
116
|
+
const missing = [...runs].filter(run => run !== state.runId && !state.runHistory.includes(run));
|
|
117
|
+
if (missing.length === 0) return;
|
|
118
|
+
const active = state.runId && state.runHistory.includes(state.runId) ? state.runId : null;
|
|
119
|
+
const completed = state.runHistory.filter(run => run !== active).concat(missing);
|
|
120
|
+
completed.sort(compareRunIds);
|
|
121
|
+
state.runHistory = active ? [...completed, active] : completed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function currentLogCount(): number {
|
|
125
|
+
refreshLogCounts();
|
|
126
|
+
return state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function newRun(): string | null {
|
|
130
|
+
if (!state.debugDir) return null;
|
|
131
|
+
const run = `run${state.round}-${Date.now().toString(36)}`;
|
|
132
|
+
try {
|
|
133
|
+
prepareRunLog(state.debugDir, state.runId);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
pi.logger.error("debug-mode: cannot initialize run log", {
|
|
136
|
+
file: path.join(state.debugDir, ACTIVE_LOG_FILE),
|
|
137
|
+
err,
|
|
138
|
+
});
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
state.runId = run;
|
|
142
|
+
state.runHistory.push(run);
|
|
143
|
+
state.logCounts[run] = 0;
|
|
144
|
+
return run;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function refreshUi(): void {
|
|
148
|
+
applyUi(uiCtx, state, currentLogCount);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* While the user reproduces out-of-band nothing in the session fires, so the
|
|
153
|
+
* evidence counter would sit at zero. Poll the active log for appends and
|
|
154
|
+
* refresh the widget as observations land.
|
|
155
|
+
*/
|
|
156
|
+
function watchLogFile(): void {
|
|
157
|
+
const file = state.phase === "waiting" ? logFileFor(state) : null;
|
|
158
|
+
if (file === watchedLogFile) return;
|
|
159
|
+
unwatchLogFile();
|
|
160
|
+
if (!file || !uiCtx?.hasUI) return;
|
|
161
|
+
try {
|
|
162
|
+
fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
|
|
163
|
+
if (state.phase !== "waiting") return;
|
|
164
|
+
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
|
|
165
|
+
try {
|
|
166
|
+
refreshUi();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
pi.logger.warn("debug-mode: log watch refresh failed", { err });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
watchedLogFile = file;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
pi.logger.warn("debug-mode: cannot watch the run log", { file, err });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unwatchLogFile(): void {
|
|
178
|
+
if (!watchedLogFile) return;
|
|
179
|
+
try {
|
|
180
|
+
fs.unwatchFile(watchedLogFile);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
pi.logger.warn("debug-mode: cannot stop watching the run log", { file: watchedLogFile, err });
|
|
183
|
+
}
|
|
184
|
+
watchedLogFile = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================== probe ledger ==============================
|
|
188
|
+
|
|
189
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
190
|
+
if (!state.active) return;
|
|
191
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
192
|
+
recordProbes(state.probes, state.round, event.input as Record<string, unknown>, ctx.cwd);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ============================== prompt injection ==============================
|
|
196
|
+
|
|
197
|
+
pi.on("before_agent_start", async () => {
|
|
198
|
+
if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
|
|
199
|
+
// The blackboard claims to be ground truth, so reconcile it with disk first.
|
|
200
|
+
await syncLedger(state);
|
|
201
|
+
// Cleanup has no hypotheses left to form; the full methodology would only
|
|
202
|
+
// invite another round.
|
|
203
|
+
const contract = state.phase === "cleanup" ? CLEANUP_CONTRACT : METHODOLOGY;
|
|
204
|
+
return {
|
|
205
|
+
message: {
|
|
206
|
+
customType: DEBUG_CONTEXT_TYPE,
|
|
207
|
+
content: `${blackboard(state, describeEvidence(state))}\n\n${contract}`,
|
|
208
|
+
display: false,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Drop stale blackboard copies. Keep the newest — `context` runs after
|
|
214
|
+
// `before_agent_start`, so deleting every match would hide the injection
|
|
215
|
+
// from the model.
|
|
216
|
+
pi.on("context", async (event) => {
|
|
217
|
+
const filtered = keepLatestCustomType(event.messages, DEBUG_CONTEXT_TYPE);
|
|
218
|
+
if (filtered.length !== event.messages.length) return { messages: filtered };
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ============================== round lifecycle ==============================
|
|
222
|
+
|
|
223
|
+
pi.on("agent_start", async () => {
|
|
224
|
+
if (state.active) state.hasRoundContent = false;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
pi.on("message_end", async (event) => {
|
|
228
|
+
if (!state.active) return;
|
|
229
|
+
const msg = event.message as { role?: string; content?: unknown };
|
|
230
|
+
if (msg?.role === "assistant") {
|
|
231
|
+
if (state.phase === "cleanup") state.cleanupReady = true;
|
|
232
|
+
state.hasRoundContent = true;
|
|
233
|
+
const text = extractAssistantText(msg.content);
|
|
234
|
+
const steps = extractReproductionSteps(text);
|
|
235
|
+
if (steps.length > 0) state.reproductionSteps = steps;
|
|
236
|
+
if (state.phase === "round") {
|
|
237
|
+
const plan = parseEvidencePlan(text, state.round);
|
|
238
|
+
if (plan.found) {
|
|
239
|
+
replaceRoundEvidenceRequests(state, state.round, plan.valid ? plan.requests : []);
|
|
240
|
+
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
pi.on("session_stop", async (_event, ctx) => {
|
|
247
|
+
if (!state.active) return;
|
|
248
|
+
if (state.phase === "cleanup") {
|
|
249
|
+
if (state.cleanupReady) await teardown(ctx, "finished");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Ordinary editor messages while waiting are conversational only. Only an
|
|
253
|
+
// explicit debug command advances the run or leaves the gate.
|
|
254
|
+
if (state.phase !== "round" || !state.hasRoundContent) return;
|
|
255
|
+
const probesThisRound = state.probes.filter(p => p.round === state.round).length;
|
|
256
|
+
const hasEvidencePlan = state.evidenceRequests.some(request => request.round === state.round);
|
|
257
|
+
const decision = decideGate({
|
|
258
|
+
hasReproductionSteps: state.reproductionSteps.length > 0,
|
|
259
|
+
hasEvidencePlan,
|
|
260
|
+
probesThisRound,
|
|
261
|
+
nudgesUsed: state.gateNudges,
|
|
262
|
+
});
|
|
263
|
+
if (decision.kind === "stay") return;
|
|
264
|
+
if (decision.kind === "nudge") {
|
|
265
|
+
state.gateNudges += 1;
|
|
266
|
+
return { continue: true, additionalContext: decision.context };
|
|
267
|
+
}
|
|
268
|
+
enterGate(ctx, probesThisRound, decision.missingSteps, decision.missingEvidencePlan);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
function enterGate(
|
|
272
|
+
ctx: ExtensionContext,
|
|
273
|
+
probesThisRound: number,
|
|
274
|
+
missingSteps: boolean,
|
|
275
|
+
missingEvidencePlan: boolean,
|
|
276
|
+
): void {
|
|
277
|
+
state.phase = "waiting";
|
|
278
|
+
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
279
|
+
refreshUi();
|
|
280
|
+
watchLogFile();
|
|
281
|
+
// Commands are the only interaction at the gate: the widget lists them,
|
|
282
|
+
// the user picks one. No menu is opened automatically.
|
|
283
|
+
const pending = pendingEvidenceRequests(state, state.round);
|
|
284
|
+
const hasValidPlan = state.evidenceRequests.some(request => request.round === state.round);
|
|
285
|
+
if (probesThisRound === 0 && !hasValidPlan) {
|
|
286
|
+
ctx.ui.notify(
|
|
287
|
+
`Debug round ${state.round} paused, but it added no probes and declared no evidence plan — this round cannot produce runtime evidence. Use /${COMMAND_PROCEED} to ask for instrumentation.`,
|
|
288
|
+
"warning",
|
|
289
|
+
);
|
|
290
|
+
} else if (missingSteps) {
|
|
291
|
+
ctx.ui.notify(
|
|
292
|
+
`Debug round ${state.round} paused without reproduction steps. Exercise the instrumented path, then ${PROCEED_REMINDER}`,
|
|
293
|
+
"warning",
|
|
294
|
+
);
|
|
295
|
+
} else if (pending.length > 0) {
|
|
296
|
+
ctx.ui.notify(
|
|
297
|
+
`Debug round ${state.round} paused. User evidence requested (${pending.length} pending: ${pending.map(r => r.id).join(", ")}) — attach via /${COMMAND_EVIDENCE} <request-id> <path>, then ${PROCEED_REMINDER}`,
|
|
298
|
+
"info",
|
|
299
|
+
);
|
|
300
|
+
} else if (missingEvidencePlan) {
|
|
301
|
+
ctx.ui.notify(
|
|
302
|
+
`Debug round ${state.round} paused. The gate is usable, but the next Proceed will ask the model to declare an evidence method.`,
|
|
303
|
+
"info",
|
|
304
|
+
);
|
|
305
|
+
} else {
|
|
306
|
+
ctx.ui.notify(`Debug round ${state.round} paused. Reproduce the bug, then ${PROCEED_REMINDER}`, "info");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ============================== round transitions ==============================
|
|
311
|
+
|
|
312
|
+
function startDebug(ctx: ExtensionContext, problem: string): void {
|
|
313
|
+
if (!initializeLogDirectory(ctx)) {
|
|
314
|
+
ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
state.active = true;
|
|
318
|
+
state.phase = "round";
|
|
319
|
+
state.problem = problem;
|
|
320
|
+
state.round = 1;
|
|
321
|
+
state.probes = [];
|
|
322
|
+
state.runHistory = [];
|
|
323
|
+
state.logCounts = {};
|
|
324
|
+
state.hasRoundContent = false;
|
|
325
|
+
state.cleanupReady = false;
|
|
326
|
+
state.reproductionSteps = [];
|
|
327
|
+
state.gateNudges = 0;
|
|
328
|
+
state.evidenceRequests = [];
|
|
329
|
+
state.evidenceArtifacts = [];
|
|
330
|
+
state.evidenceObservations = [];
|
|
331
|
+
if (!newRun()) {
|
|
332
|
+
Object.assign(state, freshState());
|
|
333
|
+
ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
refreshUi();
|
|
337
|
+
const logFile = logFileFor(state);
|
|
338
|
+
if (!logFile) {
|
|
339
|
+
Object.assign(state, freshState());
|
|
340
|
+
ctx.ui.notify("debug-mode: could not resolve the run log file; debug mode was not started", "error");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
pi.sendMessage(
|
|
344
|
+
{
|
|
345
|
+
customType: "debug-mode-start",
|
|
346
|
+
content: buildStartMessage(problem, logFile),
|
|
347
|
+
display: true,
|
|
348
|
+
details: { summary: "debug mode started — hypotheses and instrumentation" } satisfies DebugMessageDetails,
|
|
349
|
+
},
|
|
350
|
+
{ triggerTurn: true },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function ensureWaiting(ctx: ExtensionContext): boolean {
|
|
355
|
+
if (!state.active) {
|
|
356
|
+
ctx.ui.notify("debug-mode: not active", "error");
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
if (state.phase !== "waiting") {
|
|
360
|
+
ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
|
|
367
|
+
if (!ensureWaiting(ctx)) return;
|
|
368
|
+
const logCount = currentLogCount();
|
|
369
|
+
const hasNonProbeEvidence = state.evidenceRequests.some(
|
|
370
|
+
request => request.round === state.round && request.method !== "runtime_probe",
|
|
371
|
+
);
|
|
372
|
+
if (logCount === 0 && !hasNonProbeEvidence && ctx.hasUI) {
|
|
373
|
+
const confirmed = await ctx.ui.confirm(
|
|
374
|
+
"Mark as fixed without runtime logs?",
|
|
375
|
+
"No runtime observations were captured for this round. Mark the problem as fixed anyway?",
|
|
376
|
+
);
|
|
377
|
+
if (!confirmed) return;
|
|
378
|
+
}
|
|
379
|
+
if (!state.active || state.phase !== "waiting") return;
|
|
380
|
+
|
|
381
|
+
state.phase = "cleanup";
|
|
382
|
+
state.cleanupReady = false;
|
|
383
|
+
unwatchLogFile();
|
|
384
|
+
refreshUi();
|
|
385
|
+
const evidenceJson = JSON.stringify({
|
|
386
|
+
requests: state.evidenceRequests,
|
|
387
|
+
observations: state.evidenceObservations,
|
|
388
|
+
artifacts: state.evidenceArtifacts,
|
|
389
|
+
});
|
|
390
|
+
pi.sendMessage(
|
|
391
|
+
{
|
|
392
|
+
customType: "debug-mode-fixed",
|
|
393
|
+
content: buildFixedMessage(JSON.stringify(state.probes), evidenceJson),
|
|
394
|
+
details: { summary: `marked fixed — removing ${state.probes.length} probe(s)` } satisfies DebugMessageDetails,
|
|
395
|
+
},
|
|
396
|
+
{ triggerTurn: true },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
|
|
401
|
+
if (!ensureWaiting(ctx)) return;
|
|
402
|
+
refreshLogCounts();
|
|
403
|
+
const run = state.runId ?? "(none)";
|
|
404
|
+
const logCount = state.logCounts[run] ?? 0;
|
|
405
|
+
const hasNonProbeEvidence = state.evidenceRequests.some(
|
|
406
|
+
request => request.round === state.round && request.method !== "runtime_probe",
|
|
407
|
+
);
|
|
408
|
+
if (logCount === 0 && !userDetails && !hasNonProbeEvidence && ctx.hasUI) {
|
|
409
|
+
const confirmed = await ctx.ui.confirm(
|
|
410
|
+
"Proceed without runtime logs?",
|
|
411
|
+
"No runtime observations were captured for this round. Continue to log analysis anyway?",
|
|
412
|
+
);
|
|
413
|
+
if (!confirmed) return;
|
|
414
|
+
}
|
|
415
|
+
if (!state.active || state.phase !== "waiting") return;
|
|
416
|
+
|
|
417
|
+
// Persist optional /debug-proceed details as a batched user observation
|
|
418
|
+
// tied to every current-round user_report request.
|
|
419
|
+
if (userDetails && userDetails.trim().length > 0) {
|
|
420
|
+
const reportIds = state.evidenceRequests
|
|
421
|
+
.filter(request => request.round === state.round && request.method === "user_report")
|
|
422
|
+
.map(request => request.id);
|
|
423
|
+
const observation: EvidenceObservation = {
|
|
424
|
+
id: `observation-${Date.now().toString(36)}`,
|
|
425
|
+
requestIds: reportIds,
|
|
426
|
+
text: userDetails.trim(),
|
|
427
|
+
round: state.round,
|
|
428
|
+
addedAt: Date.now(),
|
|
429
|
+
};
|
|
430
|
+
state.evidenceObservations = [...state.evidenceObservations, observation];
|
|
431
|
+
}
|
|
432
|
+
const stillPending = pendingEvidenceRequests(state, state.round);
|
|
433
|
+
if (stillPending.length > 0 && ctx.hasUI) {
|
|
434
|
+
const confirmed = await ctx.ui.confirm(
|
|
435
|
+
"Proceed without all requested evidence?",
|
|
436
|
+
`${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
|
|
437
|
+
);
|
|
438
|
+
if (!confirmed) {
|
|
439
|
+
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
440
|
+
refreshUi();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const hypotheses = describeHypotheses(summarizeHypotheses(readRunLines(run)));
|
|
446
|
+
const previousRound = state.round;
|
|
447
|
+
const previousSteps = state.reproductionSteps;
|
|
448
|
+
state.round += 1;
|
|
449
|
+
state.phase = "round";
|
|
450
|
+
state.hasRoundContent = false;
|
|
451
|
+
state.reproductionSteps = [];
|
|
452
|
+
state.gateNudges = 0;
|
|
453
|
+
if (!newRun()) {
|
|
454
|
+
state.round = previousRound;
|
|
455
|
+
state.phase = "waiting";
|
|
456
|
+
state.reproductionSteps = previousSteps;
|
|
457
|
+
ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
|
|
458
|
+
refreshUi();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
unwatchLogFile();
|
|
462
|
+
refreshUi();
|
|
463
|
+
|
|
464
|
+
const summary = userDetails
|
|
465
|
+
? `proceed with user details — analyzing run ${run} (${logCount} entries)`
|
|
466
|
+
: `proceed — analyzing run ${run} (${logCount} entries)`;
|
|
467
|
+
pi.sendMessage(
|
|
468
|
+
{
|
|
469
|
+
customType: "debug-mode-proceed",
|
|
470
|
+
content: buildProceedMessage({
|
|
471
|
+
run,
|
|
472
|
+
logCount,
|
|
473
|
+
userDetails,
|
|
474
|
+
hypotheses,
|
|
475
|
+
evidenceSummary: evidenceSummary(state, previousRound),
|
|
476
|
+
}),
|
|
477
|
+
display: true,
|
|
478
|
+
details: { summary } satisfies DebugMessageDetails,
|
|
479
|
+
},
|
|
480
|
+
{ triggerTurn: true },
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function teardown(ctx: ExtensionContext, outcome: "finished" | "aborted"): Promise<void> {
|
|
485
|
+
unwatchLogFile();
|
|
486
|
+
lineCounter.clear();
|
|
487
|
+
if (state.debugDir) {
|
|
488
|
+
try {
|
|
489
|
+
fs.rmSync(state.debugDir, { recursive: true, force: true });
|
|
490
|
+
pruneDebugRoot(ctx.cwd);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
pi.logger.warn("debug-mode: failed to remove debug dir", { err });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const scan = await syncLedger(state);
|
|
496
|
+
const probesLeft = [...scan.alive, ...scan.unknown];
|
|
497
|
+
Object.assign(state, freshState());
|
|
498
|
+
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
499
|
+
refreshUi();
|
|
500
|
+
const resultLabel = outcome === "finished" ? "finished" : "aborted";
|
|
501
|
+
if (probesLeft.length > 0) {
|
|
502
|
+
ctx.ui.notify(
|
|
503
|
+
`Debug mode ${resultLabel}, but ${probesLeft.length} probe(s) remain in code: ${probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
|
|
504
|
+
"warning",
|
|
505
|
+
);
|
|
506
|
+
} else {
|
|
507
|
+
ctx.ui.notify(
|
|
508
|
+
`Debug mode ${resultLabel}. Log files removed; applied fixes remain in the working diff for review.`,
|
|
509
|
+
"info",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function abortDebug(ctx: ExtensionContext): Promise<void> {
|
|
515
|
+
if (!state.active) {
|
|
516
|
+
ctx.ui.notify("debug-mode: not active", "error");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (ctx.hasUI) {
|
|
520
|
+
const confirmed = await ctx.ui.confirm(
|
|
521
|
+
"Abort debug mode?",
|
|
522
|
+
"Delete captured debug logs and stop the workflow? Applied code changes will remain in the working diff.",
|
|
523
|
+
);
|
|
524
|
+
if (!confirmed) return;
|
|
525
|
+
}
|
|
526
|
+
if (!state.active) return;
|
|
527
|
+
await teardown(ctx, "aborted");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Attach one user-provided evidence file to the current waiting round. The
|
|
532
|
+
* file is referenced in place — never copied, moved or deleted — and the
|
|
533
|
+
* action stays at the gate: no model-visible message, no agent turn.
|
|
534
|
+
*/
|
|
535
|
+
async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
|
|
536
|
+
if (!ensureWaiting(ctx)) return false;
|
|
537
|
+
let input = rawPath?.trim() ?? "";
|
|
538
|
+
if (!input) {
|
|
539
|
+
if (!ctx.hasUI) {
|
|
540
|
+
ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
input = (await ctx.ui.input("Path to debug evidence file", "absolute or cwd-relative path")) ?? "";
|
|
544
|
+
if (!input.trim()) {
|
|
545
|
+
ctx.ui.notify("debug-mode: no evidence file path provided", "error");
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const trimmed = input.trim();
|
|
550
|
+
const cwdRoot = path.resolve(ctx.cwd);
|
|
551
|
+
const resolved = path.resolve(cwdRoot, trimmed);
|
|
552
|
+
const outsideCwd = resolved !== cwdRoot && !resolved.startsWith(`${cwdRoot}${path.sep}`);
|
|
553
|
+
if (outsideCwd) {
|
|
554
|
+
if (!ctx.hasUI) {
|
|
555
|
+
ctx.ui.notify(
|
|
556
|
+
`debug-mode: ${resolved} is outside the session working directory and cannot be confirmed without a UI`,
|
|
557
|
+
"error",
|
|
558
|
+
);
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
const confirmed = await ctx.ui.confirm(
|
|
562
|
+
"Attach evidence outside the working directory?",
|
|
563
|
+
`Record ${resolved} as debug evidence? The file is referenced in place and never modified.`,
|
|
564
|
+
);
|
|
565
|
+
if (!confirmed) return false;
|
|
566
|
+
}
|
|
567
|
+
const result = addEvidenceArtifact(state, trimmed, ctx.cwd, requestId);
|
|
568
|
+
if ("error" in result) {
|
|
569
|
+
ctx.ui.notify(`debug-mode: evidence rejected — ${result.error}`, "error");
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
Object.assign(state, result.state);
|
|
573
|
+
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
574
|
+
refreshUi();
|
|
575
|
+
ctx.ui.notify(
|
|
576
|
+
`debug-mode: attached ${result.artifact.id} → ${result.artifact.path} (${result.artifact.size} bytes)`,
|
|
577
|
+
"info",
|
|
578
|
+
);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ============================== commands ==============================
|
|
583
|
+
|
|
584
|
+
pi.registerCommand(COMMAND_MODE, {
|
|
585
|
+
description: "Start debug mode: /debug-mode <problem description>",
|
|
586
|
+
handler: async (args, ctx) => {
|
|
587
|
+
uiCtx = ctx;
|
|
588
|
+
if (state.active) {
|
|
589
|
+
ctx.ui.notify(`debug-mode: already active (use /${COMMAND_STATUS}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT})`, "error");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const problem = args.trim();
|
|
593
|
+
if (!problem) {
|
|
594
|
+
ctx.ui.notify(
|
|
595
|
+
"Usage: /debug-mode <problem description — symptoms, expected vs actual, how to reproduce>",
|
|
596
|
+
"error",
|
|
597
|
+
);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
startDebug(ctx, problem);
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
pi.registerCommand(COMMAND_DONE, {
|
|
605
|
+
description: "Mark the problem fixed: remove probes and summarize",
|
|
606
|
+
handler: async (_args, ctx) => {
|
|
607
|
+
uiCtx = ctx;
|
|
608
|
+
await markDebugFixed(ctx);
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
pi.registerCommand(COMMAND_PROCEED, {
|
|
613
|
+
description: "Continue with captured evidence and optional user details: /debug-proceed [details]",
|
|
614
|
+
handler: async (args, ctx) => {
|
|
615
|
+
uiCtx = ctx;
|
|
616
|
+
const details = args.trim();
|
|
617
|
+
await advanceDebug(ctx, details || undefined);
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Parse `/debug-evidence [<request-id>] <path>`: when the first
|
|
623
|
+
* whitespace-delimited token names a pending current-round user_artifact
|
|
624
|
+
* request, link to it and treat the remainder (spaces intact) as the path;
|
|
625
|
+
* otherwise the whole argument is an unlinked path.
|
|
626
|
+
*/
|
|
627
|
+
function parseEvidenceArgument(args: string): { requestId: string | null; rawPath: string | undefined } {
|
|
628
|
+
const trimmed = args.trim();
|
|
629
|
+
if (!trimmed) return { requestId: null, rawPath: undefined };
|
|
630
|
+
const firstToken = trimmed.split(/\s+/, 1)[0];
|
|
631
|
+
const isPending = pendingEvidenceRequests(state, state.round).some(
|
|
632
|
+
request => request.method === "user_artifact" && request.id === firstToken,
|
|
633
|
+
);
|
|
634
|
+
if (isPending) {
|
|
635
|
+
const rest = trimmed.slice(firstToken.length).trim();
|
|
636
|
+
return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
|
|
637
|
+
}
|
|
638
|
+
return { requestId: null, rawPath: trimmed };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
pi.registerCommand(COMMAND_EVIDENCE, {
|
|
642
|
+
description: "Attach one user-provided evidence file: /debug-evidence [<request-id>] <path> (no argument opens a path prompt)",
|
|
643
|
+
handler: async (args, ctx) => {
|
|
644
|
+
uiCtx = ctx;
|
|
645
|
+
const { requestId, rawPath } = parseEvidenceArgument(args);
|
|
646
|
+
await attachEvidence(ctx, rawPath, requestId);
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
pi.registerCommand(COMMAND_ABORT, {
|
|
652
|
+
description: "Abort debug mode: delete logs (fixes stay in the working diff)",
|
|
653
|
+
handler: async (_args, ctx) => {
|
|
654
|
+
uiCtx = ctx;
|
|
655
|
+
await abortDebug(ctx);
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
pi.registerCommand(COMMAND_STATUS, {
|
|
660
|
+
description: "Show debug mode state",
|
|
661
|
+
handler: async (_args, ctx) => {
|
|
662
|
+
uiCtx = ctx;
|
|
663
|
+
if (!state.active) {
|
|
664
|
+
ctx.ui.notify("debug-mode: idle", "info");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
refreshLogCounts();
|
|
668
|
+
const scan = await syncLedger(state);
|
|
669
|
+
const tallies = state.runId ? summarizeHypotheses(readRunLines(state.runId)) : [];
|
|
670
|
+
const roundRequests = state.evidenceRequests.filter(request => request.round === state.round);
|
|
671
|
+
const pending = pendingEvidenceRequests(state, state.round);
|
|
672
|
+
const unavailable = state.evidenceArtifacts.filter(artifact => {
|
|
673
|
+
try {
|
|
674
|
+
const stats = fs.statSync(artifact.path);
|
|
675
|
+
fs.accessSync(artifact.path, fs.constants.R_OK);
|
|
676
|
+
return !stats.isFile();
|
|
677
|
+
} catch {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
ctx.ui.notify(
|
|
682
|
+
`debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
|
|
683
|
+
`${describeLedger(scan)}\n` +
|
|
684
|
+
`logs: ${state.runHistory.map(r => `${r}=${state.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
|
|
685
|
+
`this run by hypothesis: ${describeHypotheses(tallies)}\n` +
|
|
686
|
+
`current log file: ${logFileFor(state) ?? "(not initialized)"}\n` +
|
|
687
|
+
`evidence requests: ${roundRequests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
|
|
688
|
+
`pending: ${pending.map(r => r.id).join(", ") || "(none)"}\n` +
|
|
689
|
+
`observations: ${state.evidenceObservations.map(o => o.id).join(", ") || "(none)"}\n` +
|
|
690
|
+
`artifacts: ${state.evidenceArtifacts.map(a => a.id).join(", ") || "(none)"}` +
|
|
691
|
+
(unavailable.length > 0 ? `\nunavailable artifacts: ${unavailable.map(a => `${a.id} ${a.path}`).join(", ")}` : ""),
|
|
692
|
+
"info",
|
|
693
|
+
);
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ============================== tools ==============================
|
|
698
|
+
|
|
699
|
+
registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
|
|
700
|
+
|
|
701
|
+
// ============================== lifecycle ==============================
|
|
702
|
+
|
|
703
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
704
|
+
uiCtx = ctx;
|
|
705
|
+
const entries = ctx.sessionManager.getEntries();
|
|
706
|
+
const last = entries
|
|
707
|
+
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === DEBUG_ENTRY)
|
|
708
|
+
.pop() as { data?: DebugState } | undefined;
|
|
709
|
+
if (last?.data?.active) {
|
|
710
|
+
Object.assign(state, last.data);
|
|
711
|
+
if (!Array.isArray(state.reproductionSteps)) state.reproductionSteps = [];
|
|
712
|
+
if (!Array.isArray(state.evidenceRequests)) state.evidenceRequests = [];
|
|
713
|
+
if (!Array.isArray(state.evidenceArtifacts)) state.evidenceArtifacts = [];
|
|
714
|
+
if (!Array.isArray(state.evidenceObservations)) state.evidenceObservations = [];
|
|
715
|
+
if (!Array.isArray(state.runHistory)) state.runHistory = state.runId ? [state.runId] : [];
|
|
716
|
+
if (typeof state.gateNudges !== "number") state.gateNudges = 0;
|
|
717
|
+
if (!state.phase) state.phase = "waiting";
|
|
718
|
+
if (!state.debugDir || !fs.existsSync(state.debugDir)) initializeLogDirectory(ctx);
|
|
719
|
+
const currentFile = logFileFor(state);
|
|
720
|
+
if (currentFile) {
|
|
721
|
+
try {
|
|
722
|
+
const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
|
|
723
|
+
if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
|
|
724
|
+
fs.renameSync(legacyRunFile, currentFile);
|
|
725
|
+
}
|
|
726
|
+
fs.writeFileSync(currentFile, "", { flag: "a" });
|
|
727
|
+
} catch (err) {
|
|
728
|
+
pi.logger.error("debug-mode: cannot restore run log", { file: currentFile, err });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
refreshLogCounts();
|
|
732
|
+
ctx.ui.notify(
|
|
733
|
+
`debug-mode resumed: phase=${state.phase} round=${state.round}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state, state.round)}. Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
|
|
734
|
+
"info",
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
refreshUi();
|
|
738
|
+
watchLogFile();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
pi.on("turn_start", async () => {
|
|
742
|
+
if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
pi.on("session_shutdown", async () => {
|
|
746
|
+
unwatchLogFile();
|
|
747
|
+
});
|
|
748
|
+
}
|