@siuver/omp-debug-mode 0.1.2 → 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.
@@ -0,0 +1,638 @@
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 { decideGate } from "./gate";
6
+ import {
7
+ ACTIVE_LOG_FILE,
8
+ JsonlLineCounter,
9
+ describeHypotheses,
10
+ prepareRunLog,
11
+ readJsonlLines,
12
+ summarizeHypotheses,
13
+ } from "./log-files";
14
+ import {
15
+ CLEANUP_CONTRACT,
16
+ METHODOLOGY,
17
+ PROCEED_REMINDER,
18
+ buildFixedMessage,
19
+ buildProceedMessage,
20
+ buildStartMessage,
21
+ extractAssistantText,
22
+ extractReproductionSteps,
23
+ } from "./methodology";
24
+ import { describeLedger, recordProbes, syncLedger } from "./probes";
25
+ import { REVIEW_ABORT, REVIEW_ADD_DETAILS, REVIEW_MARK_FIXED, REVIEW_PROCEED, reviewMenuOptions } from "./review-actions";
26
+ import {
27
+ DEBUG_CONTEXT_TYPE,
28
+ DEBUG_ENTRY,
29
+ type DebugState,
30
+ blackboard,
31
+ compareRunIds,
32
+ freshState,
33
+ keepLatestCustomType,
34
+ logFileFor,
35
+ } from "./state";
36
+ import { registerDebugTools } from "./tools";
37
+ import { applyUi, reviewMenuTitle } from "./ui";
38
+ import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
39
+
40
+ const COMMAND_MODE = "debug-mode";
41
+ const COMMAND_MENU = "debug-menu";
42
+ const COMMAND_DONE = "debug-done";
43
+ const COMMAND_PROCEED = "debug-proceed";
44
+ const COMMAND_NOTE = "debug-note";
45
+ const COMMAND_ABORT = "debug-abort";
46
+ const COMMAND_STATUS = "debug-status";
47
+
48
+ /** Compact transcript lines for the prompts this extension injects. */
49
+ const MESSAGE_SUMMARIES: Record<string, string> = {
50
+ "debug-mode-start": "debug mode started — hypotheses and instrumentation",
51
+ "debug-mode-proceed": "proceed — analyzing captured logs",
52
+ "debug-mode-note": "reproduction details added — analyzing captured logs",
53
+ "debug-mode-fixed": "marked fixed — removing probes and summarizing",
54
+ };
55
+
56
+ interface DebugMessageDetails {
57
+ summary?: string;
58
+ }
59
+
60
+ export function registerDebugMode(pi: ExtensionAPI): void {
61
+ const state: DebugState = freshState();
62
+ let reviewMenuOpen = false;
63
+ let uiCtx: ExtensionContext | null = null;
64
+ let watchedLogFile: string | null = null;
65
+ const lineCounter = new JsonlLineCounter();
66
+
67
+ for (const [customType, summary] of Object.entries(MESSAGE_SUMMARIES)) {
68
+ const renderer: MessageRenderer<DebugMessageDetails> = (message, _options, theme) => {
69
+ const text = typeof message.details?.summary === "string" ? message.details.summary : summary;
70
+ return new Text(theme.fg("dim", `🐞 ${text}`), 1, 0);
71
+ };
72
+ pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
73
+ }
74
+
75
+ // ============================== log files ==============================
76
+
77
+ function initializeLogDirectory(ctx: ExtensionContext): boolean {
78
+ const dir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
79
+ try {
80
+ fs.mkdirSync(dir, { recursive: true });
81
+ state.debugDir = dir;
82
+ excludeDebugLogsFromGit(ctx.cwd);
83
+ return true;
84
+ } catch (err) {
85
+ pi.logger.error("debug-mode: cannot create log dir", { dir, err });
86
+ state.debugDir = null;
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function readRunLines(run: string): string[] {
92
+ return readJsonlLines(logFileFor(state, run));
93
+ }
94
+
95
+ function refreshLogCounts(): void {
96
+ const runs = new Set(Object.keys(state.logCounts));
97
+ if (state.debugDir) {
98
+ try {
99
+ for (const file of fs.readdirSync(state.debugDir)) {
100
+ if (!file.endsWith(".jsonl")) continue;
101
+ if (file === ACTIVE_LOG_FILE) {
102
+ if (state.runId) runs.add(state.runId);
103
+ } else {
104
+ runs.add(file.replace(/\.jsonl$/, ""));
105
+ }
106
+ }
107
+ } catch {}
108
+ }
109
+ for (const run of runs) state.logCounts[run] = lineCounter.count(logFileFor(state, run));
110
+ adoptDiscoveredRuns(runs);
111
+ }
112
+
113
+ /** Keep the ordered history complete when runs are discovered from disk. */
114
+ function adoptDiscoveredRuns(runs: Iterable<string>): void {
115
+ const missing = [...runs].filter(run => run !== state.runId && !state.runHistory.includes(run));
116
+ if (missing.length === 0) return;
117
+ const active = state.runId && state.runHistory.includes(state.runId) ? state.runId : null;
118
+ const completed = state.runHistory.filter(run => run !== active).concat(missing);
119
+ completed.sort(compareRunIds);
120
+ state.runHistory = active ? [...completed, active] : completed;
121
+ }
122
+
123
+ function currentLogCount(): number {
124
+ refreshLogCounts();
125
+ return state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
126
+ }
127
+
128
+ function newRun(): string | null {
129
+ if (!state.debugDir) return null;
130
+ const run = `run${state.round}-${Date.now().toString(36)}`;
131
+ try {
132
+ prepareRunLog(state.debugDir, state.runId);
133
+ } catch (err) {
134
+ pi.logger.error("debug-mode: cannot initialize run log", {
135
+ file: path.join(state.debugDir, ACTIVE_LOG_FILE),
136
+ err,
137
+ });
138
+ return null;
139
+ }
140
+ state.runId = run;
141
+ state.runHistory.push(run);
142
+ state.logCounts[run] = 0;
143
+ return run;
144
+ }
145
+
146
+ function refreshUi(): void {
147
+ applyUi(uiCtx, state, currentLogCount);
148
+ }
149
+
150
+ /**
151
+ * While the user reproduces out-of-band nothing in the session fires, so the
152
+ * evidence counter would sit at zero. Poll the active log for appends and
153
+ * refresh the widget as observations land.
154
+ */
155
+ function watchLogFile(): void {
156
+ const file = state.phase === "waiting" ? logFileFor(state) : null;
157
+ if (file === watchedLogFile) return;
158
+ unwatchLogFile();
159
+ if (!file || !uiCtx?.hasUI) return;
160
+ try {
161
+ fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
162
+ if (state.phase !== "waiting") return;
163
+ if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
164
+ try {
165
+ refreshUi();
166
+ } catch (err) {
167
+ pi.logger.warn("debug-mode: log watch refresh failed", { err });
168
+ }
169
+ });
170
+ watchedLogFile = file;
171
+ } catch (err) {
172
+ pi.logger.warn("debug-mode: cannot watch the run log", { file, err });
173
+ }
174
+ }
175
+
176
+ function unwatchLogFile(): void {
177
+ if (!watchedLogFile) return;
178
+ try {
179
+ fs.unwatchFile(watchedLogFile);
180
+ } catch (err) {
181
+ pi.logger.warn("debug-mode: cannot stop watching the run log", { file: watchedLogFile, err });
182
+ }
183
+ watchedLogFile = null;
184
+ }
185
+
186
+ // ============================== probe ledger ==============================
187
+
188
+ pi.on("tool_call", async (event, ctx) => {
189
+ if (!state.active) return;
190
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
191
+ recordProbes(state.probes, state.round, event.input as Record<string, unknown>, ctx.cwd);
192
+ });
193
+
194
+ // ============================== prompt injection ==============================
195
+
196
+ pi.on("before_agent_start", async () => {
197
+ if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
198
+ // The blackboard claims to be ground truth, so reconcile it with disk first.
199
+ await syncLedger(state);
200
+ // Cleanup has no hypotheses left to form; the full methodology would only
201
+ // invite another round.
202
+ const contract = state.phase === "cleanup" ? CLEANUP_CONTRACT : METHODOLOGY;
203
+ return {
204
+ message: {
205
+ customType: DEBUG_CONTEXT_TYPE,
206
+ content: `${blackboard(state)}\n\n${contract}`,
207
+ display: false,
208
+ },
209
+ };
210
+ });
211
+
212
+ // Drop stale blackboard copies. Keep the newest — `context` runs after
213
+ // `before_agent_start`, so deleting every match would hide the injection
214
+ // from the model.
215
+ pi.on("context", async (event) => {
216
+ const filtered = keepLatestCustomType(event.messages, DEBUG_CONTEXT_TYPE);
217
+ if (filtered.length !== event.messages.length) return { messages: filtered };
218
+ });
219
+
220
+ // ============================== round lifecycle ==============================
221
+
222
+ pi.on("agent_start", async () => {
223
+ if (state.active) state.hasRoundContent = false;
224
+ });
225
+
226
+ pi.on("message_end", async (event) => {
227
+ if (!state.active) return;
228
+ const msg = event.message as { role?: string; content?: unknown };
229
+ if (msg?.role === "assistant") {
230
+ if (state.phase === "cleanup") state.cleanupReady = true;
231
+ state.hasRoundContent = true;
232
+ const steps = extractReproductionSteps(extractAssistantText(msg.content));
233
+ if (steps.length > 0) state.reproductionSteps = steps;
234
+ }
235
+ });
236
+
237
+ pi.on("session_stop", async (_event, ctx) => {
238
+ if (!state.active) return;
239
+ if (state.phase === "cleanup") {
240
+ // Cleanup turn settled: keep fixes and remove the temporary logs.
241
+ if (state.cleanupReady) await teardown(ctx, "finished");
242
+ return;
243
+ }
244
+ if (state.phase !== "round" || !state.hasRoundContent) return;
245
+
246
+ const probesThisRound = state.probes.filter(p => p.round === state.round).length;
247
+ const decision = decideGate({
248
+ hasReproductionSteps: state.reproductionSteps.length > 0,
249
+ probesThisRound,
250
+ nudgesUsed: state.gateNudges,
251
+ });
252
+ if (decision.kind === "stay") return;
253
+ if (decision.kind === "nudge") {
254
+ state.gateNudges += 1;
255
+ return { continue: true, additionalContext: decision.context };
256
+ }
257
+ enterGate(ctx, probesThisRound, decision.missingSteps);
258
+ });
259
+
260
+ function enterGate(ctx: ExtensionContext, probesThisRound: number, missingSteps: boolean): void {
261
+ state.phase = "waiting";
262
+ pi.appendEntry(DEBUG_ENTRY, { ...state });
263
+ refreshUi();
264
+ watchLogFile();
265
+ scheduleReviewMenu(ctx);
266
+ if (probesThisRound === 0) {
267
+ ctx.ui.notify(
268
+ `Debug round ${state.round} paused, but it added no probes — this round cannot produce runtime evidence. Use /${COMMAND_MENU} and Proceed to ask for instrumentation.`,
269
+ "warning",
270
+ );
271
+ } else if (missingSteps) {
272
+ ctx.ui.notify(
273
+ `Debug round ${state.round} paused without reproduction steps. Exercise the instrumented path, then ${PROCEED_REMINDER}`,
274
+ "warning",
275
+ );
276
+ } else {
277
+ ctx.ui.notify(`Debug round ${state.round} paused. Reproduce the bug, then ${PROCEED_REMINDER}`, "info");
278
+ }
279
+ }
280
+
281
+ // ============================== round transitions ==============================
282
+
283
+ function startDebug(ctx: ExtensionContext, problem: string): void {
284
+ if (!initializeLogDirectory(ctx)) {
285
+ ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
286
+ return;
287
+ }
288
+ state.active = true;
289
+ state.phase = "round";
290
+ state.problem = problem;
291
+ state.round = 1;
292
+ state.probes = [];
293
+ state.runHistory = [];
294
+ state.logCounts = {};
295
+ state.hasRoundContent = false;
296
+ state.cleanupReady = false;
297
+ state.reproductionSteps = [];
298
+ state.gateNudges = 0;
299
+ if (!newRun()) {
300
+ Object.assign(state, freshState());
301
+ ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
302
+ return;
303
+ }
304
+ refreshUi();
305
+ const logFile = logFileFor(state);
306
+ if (!logFile) {
307
+ Object.assign(state, freshState());
308
+ ctx.ui.notify("debug-mode: could not resolve the run log file; debug mode was not started", "error");
309
+ return;
310
+ }
311
+ pi.sendMessage(
312
+ {
313
+ customType: "debug-mode-start",
314
+ content: buildStartMessage(problem, logFile),
315
+ display: true,
316
+ details: { summary: "debug mode started — hypotheses and instrumentation" } satisfies DebugMessageDetails,
317
+ },
318
+ { triggerTurn: true },
319
+ );
320
+ }
321
+
322
+ function ensureWaiting(ctx: ExtensionContext): boolean {
323
+ if (!state.active) {
324
+ ctx.ui.notify("debug-mode: not active", "error");
325
+ return false;
326
+ }
327
+ if (state.phase !== "waiting") {
328
+ ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
329
+ return false;
330
+ }
331
+ return true;
332
+ }
333
+
334
+ async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
335
+ if (!ensureWaiting(ctx)) return;
336
+ const logCount = currentLogCount();
337
+ if (logCount === 0 && ctx.hasUI) {
338
+ const confirmed = await ctx.ui.confirm(
339
+ "Mark as fixed without runtime logs?",
340
+ "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
341
+ );
342
+ if (!confirmed) return;
343
+ }
344
+ if (!state.active || state.phase !== "waiting") return;
345
+
346
+ state.phase = "cleanup";
347
+ state.cleanupReady = false;
348
+ unwatchLogFile();
349
+ refreshUi();
350
+ pi.sendMessage(
351
+ {
352
+ customType: "debug-mode-fixed",
353
+ content: buildFixedMessage(JSON.stringify(state.probes)),
354
+ display: true,
355
+ details: { summary: `marked fixed — removing ${state.probes.length} probe(s)` } satisfies DebugMessageDetails,
356
+ },
357
+ { triggerTurn: true },
358
+ );
359
+ }
360
+
361
+ async function advanceDebug(ctx: ExtensionContext, reproductionDetails?: string): Promise<void> {
362
+ if (!ensureWaiting(ctx)) return;
363
+ refreshLogCounts();
364
+ const run = state.runId ?? "(none)";
365
+ const logCount = state.logCounts[run] ?? 0;
366
+ if (logCount === 0 && !reproductionDetails && ctx.hasUI) {
367
+ const confirmed = await ctx.ui.confirm(
368
+ "Proceed without runtime logs?",
369
+ "No runtime observations were captured for this round. Continue to log analysis anyway?",
370
+ );
371
+ if (!confirmed) return;
372
+ }
373
+ if (!state.active || state.phase !== "waiting") return;
374
+
375
+ const hypotheses = describeHypotheses(summarizeHypotheses(readRunLines(run)));
376
+ const previousRound = state.round;
377
+ const previousSteps = state.reproductionSteps;
378
+ state.round += 1;
379
+ state.phase = "round";
380
+ state.hasRoundContent = false;
381
+ state.reproductionSteps = [];
382
+ state.gateNudges = 0;
383
+ if (!newRun()) {
384
+ state.round = previousRound;
385
+ state.phase = "waiting";
386
+ state.reproductionSteps = previousSteps;
387
+ ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
388
+ refreshUi();
389
+ return;
390
+ }
391
+ unwatchLogFile();
392
+ refreshUi();
393
+
394
+ const summary = reproductionDetails
395
+ ? `reproduction details added — analyzing run ${run} (${logCount} entries)`
396
+ : `proceed — analyzing run ${run} (${logCount} entries)`;
397
+ pi.sendMessage(
398
+ {
399
+ customType: reproductionDetails ? "debug-mode-note" : "debug-mode-proceed",
400
+ content: buildProceedMessage({ run, logCount, reproductionDetails, hypotheses }),
401
+ display: true,
402
+ details: { summary } satisfies DebugMessageDetails,
403
+ },
404
+ { triggerTurn: true },
405
+ );
406
+ }
407
+
408
+ async function teardown(ctx: ExtensionContext, outcome: "finished" | "aborted"): Promise<void> {
409
+ unwatchLogFile();
410
+ lineCounter.clear();
411
+ if (state.debugDir) {
412
+ try {
413
+ fs.rmSync(state.debugDir, { recursive: true, force: true });
414
+ pruneDebugRoot(ctx.cwd);
415
+ } catch (err) {
416
+ pi.logger.warn("debug-mode: failed to remove debug dir", { err });
417
+ }
418
+ }
419
+ const scan = await syncLedger(state);
420
+ const probesLeft = [...scan.alive, ...scan.unknown];
421
+ Object.assign(state, freshState());
422
+ pi.appendEntry(DEBUG_ENTRY, { ...state });
423
+ refreshUi();
424
+ const resultLabel = outcome === "finished" ? "finished" : "aborted";
425
+ if (probesLeft.length > 0) {
426
+ ctx.ui.notify(
427
+ `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.`,
428
+ "warning",
429
+ );
430
+ } else {
431
+ ctx.ui.notify(
432
+ `Debug mode ${resultLabel}. Log files removed; applied fixes remain in the working diff for review.`,
433
+ "info",
434
+ );
435
+ }
436
+ }
437
+
438
+ async function abortDebug(ctx: ExtensionContext): Promise<void> {
439
+ if (!state.active) {
440
+ ctx.ui.notify("debug-mode: not active", "error");
441
+ return;
442
+ }
443
+ if (ctx.hasUI) {
444
+ const confirmed = await ctx.ui.confirm(
445
+ "Abort debug mode?",
446
+ "Delete captured debug logs and stop the workflow? Applied code changes will remain in the working diff.",
447
+ );
448
+ if (!confirmed) return;
449
+ }
450
+ if (!state.active) return;
451
+ await teardown(ctx, "aborted");
452
+ }
453
+
454
+ /**
455
+ * Open the action menu after `session_stop` has returned. Awaiting it
456
+ * inside that handler would hold the agent-loop lock for the whole
457
+ * reproduction; the microtask lets the round settle first.
458
+ */
459
+ function scheduleReviewMenu(ctx: ExtensionContext): void {
460
+ if (!ctx.hasUI) return;
461
+ queueMicrotask(() => {
462
+ openReviewMenu(ctx).catch(err => {
463
+ pi.logger.warn("debug-mode: review menu failed to open", { err });
464
+ });
465
+ });
466
+ }
467
+
468
+ async function openReviewMenu(ctx: ExtensionContext): Promise<void> {
469
+ if (!ensureWaiting(ctx)) return;
470
+ if (!ctx.hasUI) {
471
+ ctx.ui.notify(`/${COMMAND_MENU} requires an interactive UI; use /${COMMAND_DONE} or /${COMMAND_PROCEED} instead.`, "warning");
472
+ return;
473
+ }
474
+ if (reviewMenuOpen) {
475
+ ctx.ui.notify("debug-mode: review menu is already open", "info");
476
+ return;
477
+ }
478
+
479
+ reviewMenuOpen = true;
480
+ try {
481
+ const probesThisRound = state.probes.filter(p => p.round === state.round).length;
482
+ const title = reviewMenuTitle(state.round, currentLogCount(), probesThisRound);
483
+ const choice = await ctx.ui.select(title, reviewMenuOptions());
484
+ if (!choice) return;
485
+ if (choice === REVIEW_MARK_FIXED) {
486
+ await markDebugFixed(ctx);
487
+ } else if (choice === REVIEW_PROCEED) {
488
+ await advanceDebug(ctx);
489
+ } else if (choice === REVIEW_ADD_DETAILS) {
490
+ ctx.ui.setEditorText("/debug-note ");
491
+ ctx.ui.notify("Add reproduction details in the editor, then submit /debug-note.", "info");
492
+ } else if (choice === REVIEW_ABORT) {
493
+ await abortDebug(ctx);
494
+ }
495
+ } finally {
496
+ reviewMenuOpen = false;
497
+ }
498
+ }
499
+
500
+ // ============================== commands ==============================
501
+
502
+ pi.registerCommand(COMMAND_MODE, {
503
+ description: "Start debug mode: /debug-mode <problem description>",
504
+ handler: async (args, ctx) => {
505
+ uiCtx = ctx;
506
+ if (state.active) {
507
+ ctx.ui.notify(`debug-mode: already active (use /${COMMAND_MENU}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT})`, "error");
508
+ return;
509
+ }
510
+ const problem = args.trim();
511
+ if (!problem) {
512
+ ctx.ui.notify(
513
+ "Usage: /debug-mode <problem description — symptoms, expected vs actual, how to reproduce>",
514
+ "error",
515
+ );
516
+ return;
517
+ }
518
+ startDebug(ctx, problem);
519
+ },
520
+ });
521
+
522
+ pi.registerCommand(COMMAND_DONE, {
523
+ description: "Mark the problem fixed: remove probes and summarize",
524
+ handler: async (_args, ctx) => {
525
+ uiCtx = ctx;
526
+ await markDebugFixed(ctx);
527
+ },
528
+ });
529
+
530
+ pi.registerCommand(COMMAND_PROCEED, {
531
+ description: "Continue with captured logs: evaluate hypotheses, fix only with evidence",
532
+ handler: async (_args, ctx) => {
533
+ uiCtx = ctx;
534
+ await advanceDebug(ctx);
535
+ },
536
+ });
537
+
538
+ pi.registerCommand(COMMAND_MENU, {
539
+ description: "Open the interactive action menu for a completed debug round",
540
+ handler: async (_args, ctx) => {
541
+ uiCtx = ctx;
542
+ await openReviewMenu(ctx);
543
+ },
544
+ });
545
+
546
+ pi.registerCommand(COMMAND_NOTE, {
547
+ description: "Add reproduction details and continue: /debug-note <details>",
548
+ handler: async (args, ctx) => {
549
+ uiCtx = ctx;
550
+ const details = args.trim();
551
+ if (!details) {
552
+ ctx.ui.notify("Usage: /debug-note <reproduction details>", "error");
553
+ return;
554
+ }
555
+ await advanceDebug(ctx, details);
556
+ },
557
+ });
558
+
559
+ pi.registerCommand(COMMAND_ABORT, {
560
+ description: "Abort debug mode: delete logs (fixes stay in the working diff)",
561
+ handler: async (_args, ctx) => {
562
+ uiCtx = ctx;
563
+ await abortDebug(ctx);
564
+ },
565
+ });
566
+
567
+ pi.registerCommand(COMMAND_STATUS, {
568
+ description: "Show debug mode state",
569
+ handler: async (_args, ctx) => {
570
+ uiCtx = ctx;
571
+ if (!state.active) {
572
+ ctx.ui.notify("debug-mode: idle", "info");
573
+ return;
574
+ }
575
+ refreshLogCounts();
576
+ const scan = await syncLedger(state);
577
+ const tallies = state.runId ? summarizeHypotheses(readRunLines(state.runId)) : [];
578
+ ctx.ui.notify(
579
+ `debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
580
+ `${describeLedger(scan)}\n` +
581
+ `logs: ${state.runHistory.map(r => `${r}=${state.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
582
+ `this run by hypothesis: ${describeHypotheses(tallies)}\n` +
583
+ `current log file: ${logFileFor(state) ?? "(not initialized)"}`,
584
+ "info",
585
+ );
586
+ },
587
+ });
588
+
589
+ // ============================== tools ==============================
590
+
591
+ registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
592
+
593
+ // ============================== lifecycle ==============================
594
+
595
+ pi.on("session_start", async (_event, ctx) => {
596
+ uiCtx = ctx;
597
+ const entries = ctx.sessionManager.getEntries();
598
+ const last = entries
599
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === DEBUG_ENTRY)
600
+ .pop() as { data?: DebugState } | undefined;
601
+ if (last?.data?.active) {
602
+ Object.assign(state, last.data);
603
+ if (!Array.isArray(state.reproductionSteps)) state.reproductionSteps = [];
604
+ if (!Array.isArray(state.runHistory)) state.runHistory = state.runId ? [state.runId] : [];
605
+ if (typeof state.gateNudges !== "number") state.gateNudges = 0;
606
+ if (!state.phase) state.phase = "waiting";
607
+ if (!state.debugDir || !fs.existsSync(state.debugDir)) initializeLogDirectory(ctx);
608
+ const currentFile = logFileFor(state);
609
+ if (currentFile) {
610
+ try {
611
+ const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
612
+ if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
613
+ fs.renameSync(legacyRunFile, currentFile);
614
+ }
615
+ fs.writeFileSync(currentFile, "", { flag: "a" });
616
+ } catch (err) {
617
+ pi.logger.error("debug-mode: cannot restore run log", { file: currentFile, err });
618
+ }
619
+ }
620
+ refreshLogCounts();
621
+ ctx.ui.notify(
622
+ `debug-mode resumed: phase=${state.phase} round=${state.round}, log=${currentFile ?? "unavailable"}. Use /${COMMAND_STATUS}, /${COMMAND_MENU}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
623
+ "info",
624
+ );
625
+ }
626
+ refreshUi();
627
+ watchLogFile();
628
+ if (state.active && state.phase === "waiting") scheduleReviewMenu(ctx);
629
+ });
630
+
631
+ pi.on("turn_start", async () => {
632
+ if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
633
+ });
634
+
635
+ pi.on("session_shutdown", async () => {
636
+ unwatchLogFile();
637
+ });
638
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { PROCEED_REMINDER } from "./methodology";
2
+
3
+ /** How many times a single round may be nudged to produce reproduction steps. */
4
+ export const MAX_GATE_NUDGES = 1;
5
+
6
+ export const GATE_NUDGE =
7
+ "You instrumented this round but did not end with a <reproduction_steps> block. " +
8
+ "List the numbered steps the user must perform to exercise the instrumented path, " +
9
+ `follow them with "${PROCEED_REMINDER}", and then stop. Do not start new work.`;
10
+
11
+ export type GateDecision =
12
+ /** Hand control to the user and wait for a reproduction. */
13
+ | { kind: "gate"; missingSteps: boolean }
14
+ /** Let the agent finish the round properly before gating. */
15
+ | { kind: "nudge"; context: string }
16
+ /** Not a completed round — the agent is mid-conversation with the user. */
17
+ | { kind: "stay" };
18
+
19
+ /**
20
+ * Decide what a settled agent turn means for the reproduction gate.
21
+ *
22
+ * Cursor gates on the `<reproduction_steps>` block, so a turn without one is
23
+ * either a clarifying question (leave the user talking to the agent) or an
24
+ * instrumented round that forgot to close itself (nudge once, then gate anyway
25
+ * so the workflow can never stall).
26
+ */
27
+ export function decideGate(args: {
28
+ hasReproductionSteps: boolean;
29
+ probesThisRound: number;
30
+ nudgesUsed: number;
31
+ }): GateDecision {
32
+ if (args.hasReproductionSteps) return { kind: "gate", missingSteps: false };
33
+ if (args.probesThisRound === 0) return { kind: "stay" };
34
+ if (args.nudgesUsed < MAX_GATE_NUDGES) return { kind: "nudge", context: GATE_NUDGE };
35
+ return { kind: "gate", missingSteps: true };
36
+ }