@siuver/omp-debug-mode 0.1.3 → 0.1.5

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/src/debug-mode.ts CHANGED
@@ -2,7 +2,8 @@ import { Text } from "@oh-my-pi/pi-coding-agent";
2
2
  import type { ExtensionAPI, ExtensionContext, MessageRenderer } from "@oh-my-pi/pi-coding-agent";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
- import { decideGate } from "./gate";
5
+ import { describeEvidence, validateEvidenceArtifact } from "./evidence";
6
+ import { describeOpenReason } from "./gate";
6
7
  import {
7
8
  ACTIVE_LOG_FILE,
8
9
  JsonlLineCounter,
@@ -11,46 +12,43 @@ import {
11
12
  readJsonlLines,
12
13
  summarizeHypotheses,
13
14
  } from "./log-files";
15
+ import { type DebugEvent, type Effect, PROMPT_FIXED, PROMPT_PROCEED, PROMPT_START, reduce } from "./machine";
16
+ import { CLEANUP_CONTRACT, METHODOLOGY, extractAssistantText } from "./methodology";
17
+ import { type LedgerScan, describeLedger, probesInInput, scanLedger, survivingProbes } from "./probes";
14
18
  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 {
19
+ type DebugSession,
20
+ type DebugState,
27
21
  DEBUG_CONTEXT_TYPE,
28
22
  DEBUG_ENTRY,
29
- type DebugState,
23
+ INACTIVE,
24
+ activeRunId,
30
25
  blackboard,
31
26
  compareRunIds,
32
- freshState,
27
+ currentRound,
28
+ evidenceSummary,
29
+ evidenceView,
30
+ hasNonProbeEvidence,
33
31
  keepLatestCustomType,
34
32
  logFileFor,
33
+ pendingRequests,
34
+ reviveState,
35
35
  } from "./state";
36
36
  import { registerDebugTools } from "./tools";
37
- import { applyUi, reviewMenuTitle } from "./ui";
37
+ import { applyUi } from "./ui";
38
38
  import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
39
39
 
40
40
  const COMMAND_MODE = "debug-mode";
41
- const COMMAND_MENU = "debug-menu";
42
41
  const COMMAND_DONE = "debug-done";
43
42
  const COMMAND_PROCEED = "debug-proceed";
44
- const COMMAND_NOTE = "debug-note";
43
+ const COMMAND_EVIDENCE = "debug-evidence";
45
44
  const COMMAND_ABORT = "debug-abort";
46
45
  const COMMAND_STATUS = "debug-status";
47
46
 
48
47
  /** Compact transcript lines for the prompts this extension injects. */
49
48
  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",
49
+ [PROMPT_START]: "debug mode started — hypotheses and instrumentation",
50
+ [PROMPT_PROCEED]: "proceed — analyzing captured logs",
51
+ [PROMPT_FIXED]: "marked fixedremoving probes and summarizing",
54
52
  };
55
53
 
56
54
  interface DebugMessageDetails {
@@ -58,8 +56,7 @@ interface DebugMessageDetails {
58
56
  }
59
57
 
60
58
  export function registerDebugMode(pi: ExtensionAPI): void {
61
- const state: DebugState = freshState();
62
- let reviewMenuOpen = false;
59
+ let state: DebugState = INACTIVE;
63
60
  let uiCtx: ExtensionContext | null = null;
64
61
  let watchedLogFile: string | null = null;
65
62
  const lineCounter = new JsonlLineCounter();
@@ -72,79 +69,142 @@ export function registerDebugMode(pi: ExtensionAPI): void {
72
69
  pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
73
70
  }
74
71
 
75
- // ============================== log files ==============================
72
+ // ============================== state plumbing ==============================
76
73
 
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;
74
+ /**
75
+ * Cache-only events carry no effects, so they may run inside a render pass
76
+ * without re-entering the UI refresh that asked for them.
77
+ */
78
+ function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" }>): void {
79
+ state = reduce(state, event).state;
80
+ }
81
+
82
+ /** Run one event through the machine, then realise whatever it asked for. */
83
+ function dispatch(event: DebugEvent, ctx: ExtensionContext): string | null {
84
+ const transition = reduce(state, event);
85
+ // The reducer returns the previous object for a genuine no-op, so a turn
86
+ // full of tool calls does not append a state snapshot per message.
87
+ const changed = transition.state !== state;
88
+ state = transition.state;
89
+ if (changed) pi.appendEntry(DEBUG_ENTRY, { ...state });
90
+ refreshUi();
91
+ watchLogFile();
92
+ let continueContext: string | null = null;
93
+ for (const effect of transition.effects) {
94
+ const context = applyEffect(effect, ctx);
95
+ if (context) continueContext = context;
96
+ }
97
+ return continueContext;
98
+ }
99
+
100
+ function applyEffect(effect: Effect, ctx: ExtensionContext): string | null {
101
+ switch (effect.kind) {
102
+ case "notify":
103
+ ctx.ui.notify(effect.text, effect.level);
104
+ return null;
105
+ case "prompt":
106
+ pi.sendMessage(
107
+ {
108
+ customType: effect.customType,
109
+ content: effect.content,
110
+ display: true,
111
+ details: { summary: effect.summary } satisfies DebugMessageDetails,
112
+ },
113
+ { triggerTurn: true },
114
+ );
115
+ return null;
116
+ case "continue":
117
+ return effect.context;
118
+ case "teardown": {
119
+ unwatchLogFile();
120
+ lineCounter.clear();
121
+ if (effect.debugDir) {
122
+ try {
123
+ fs.rmSync(effect.debugDir, { recursive: true, force: true });
124
+ pruneDebugRoot(ctx.cwd);
125
+ } catch (err) {
126
+ pi.logger.warn("debug-mode: failed to remove debug dir", { err });
127
+ }
128
+ }
129
+ const label = effect.outcome === "finished" ? "finished" : "aborted";
130
+ if (effect.probesLeft.length > 0) {
131
+ ctx.ui.notify(
132
+ `Debug mode ${label}, but ${effect.probesLeft.length} probe(s) remain in code: ${effect.probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
133
+ "warning",
134
+ );
135
+ } else {
136
+ ctx.ui.notify(
137
+ `Debug mode ${label}. Log files removed; applied fixes remain in the working diff for review.`,
138
+ "info",
139
+ );
140
+ }
141
+ return null;
142
+ }
88
143
  }
89
144
  }
90
145
 
146
+ function refreshUi(): void {
147
+ applyUi(uiCtx, state, currentLogCount);
148
+ }
149
+
150
+ // ============================== log files ==============================
151
+
91
152
  function readRunLines(run: string): string[] {
92
- return readJsonlLines(logFileFor(state, run));
153
+ return state.active ? readJsonlLines(logFileFor(state, run)) : [];
93
154
  }
94
155
 
156
+ /** Re-count every run on disk. Pure cache maintenance — never a decision. */
95
157
  function refreshLogCounts(): void {
96
- const runs = new Set(Object.keys(state.logCounts));
97
- if (state.debugDir) {
158
+ if (!state.active) return;
159
+ const session: DebugSession = state;
160
+ const active = activeRunId(session);
161
+ const runs = new Set(Object.keys(session.logCounts));
162
+ if (session.debugDir) {
98
163
  try {
99
- for (const file of fs.readdirSync(state.debugDir)) {
164
+ for (const file of fs.readdirSync(session.debugDir)) {
100
165
  if (!file.endsWith(".jsonl")) continue;
101
166
  if (file === ACTIVE_LOG_FILE) {
102
- if (state.runId) runs.add(state.runId);
167
+ if (active) runs.add(active);
103
168
  } else {
104
169
  runs.add(file.replace(/\.jsonl$/, ""));
105
170
  }
106
171
  }
107
172
  } catch {}
108
173
  }
109
- for (const run of runs) state.logCounts[run] = lineCounter.count(logFileFor(state, run));
110
- adoptDiscoveredRuns(runs);
174
+ const logCounts: Record<string, number> = {};
175
+ for (const run of runs) logCounts[run] = lineCounter.count(logFileFor(session, run));
176
+ absorbCache({ t: "runs_observed", runHistory: orderedHistory(session, runs), logCounts });
111
177
  }
112
178
 
113
179
  /** 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);
180
+ function orderedHistory(session: DebugSession, runs: Iterable<string>): string[] {
181
+ const active = activeRunId(session);
182
+ const missing = [...runs].filter(run => run !== active && !session.runHistory.includes(run));
183
+ if (missing.length === 0) return session.runHistory;
184
+ const completed = session.runHistory.filter(run => run !== active).concat(missing);
119
185
  completed.sort(compareRunIds);
120
- state.runHistory = active ? [...completed, active] : completed;
186
+ return active ? [...completed, active] : completed;
121
187
  }
122
188
 
123
189
  function currentLogCount(): number {
124
190
  refreshLogCounts();
125
- return state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
191
+ if (!state.active) return 0;
192
+ const run = activeRunId(state);
193
+ return run ? (state.logCounts[run] ?? 0) : 0;
126
194
  }
127
195
 
128
- function newRun(): string | null {
129
- if (!state.debugDir) return null;
130
- const run = `run${state.round}-${Date.now().toString(36)}`;
196
+ /** Archive the active log, truncate it for the next reproduction, and name the run. */
197
+ function createRun(debugDir: string, round: number, previousRun: string | null): string | null {
131
198
  try {
132
- prepareRunLog(state.debugDir, state.runId);
199
+ prepareRunLog(debugDir, previousRun);
133
200
  } catch (err) {
134
201
  pi.logger.error("debug-mode: cannot initialize run log", {
135
- file: path.join(state.debugDir, ACTIVE_LOG_FILE),
202
+ file: path.join(debugDir, ACTIVE_LOG_FILE),
136
203
  err,
137
204
  });
138
205
  return null;
139
206
  }
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);
207
+ return `run${round}-${Date.now().toString(36)}`;
148
208
  }
149
209
 
150
210
  /**
@@ -153,13 +213,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
153
213
  * refresh the widget as observations land.
154
214
  */
155
215
  function watchLogFile(): void {
156
- const file = state.phase === "waiting" ? logFileFor(state) : null;
216
+ const file = state.active && state.stage === "awaiting_evidence" ? logFileFor(state) : null;
157
217
  if (file === watchedLogFile) return;
158
218
  unwatchLogFile();
159
219
  if (!file || !uiCtx?.hasUI) return;
160
220
  try {
161
221
  fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
162
- if (state.phase !== "waiting") return;
222
+ if (!state.active || state.stage !== "awaiting_evidence") return;
163
223
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
164
224
  try {
165
225
  refreshUi();
@@ -185,25 +245,34 @@ export function registerDebugMode(pi: ExtensionAPI): void {
185
245
 
186
246
  // ============================== probe ledger ==============================
187
247
 
248
+ /** Rescan the recorded files so the ledger matches the code on disk. */
249
+ async function syncLedger(): Promise<LedgerScan> {
250
+ const scan = await scanLedger(state.active ? state.probes : []);
251
+ if (state.active) absorbCache({ t: "ledger_synced", probes: survivingProbes(scan) });
252
+ return scan;
253
+ }
254
+
188
255
  pi.on("tool_call", async (event, ctx) => {
189
256
  if (!state.active) return;
190
257
  if (event.toolName !== "edit" && event.toolName !== "write") return;
191
- recordProbes(state.probes, state.round, event.input as Record<string, unknown>, ctx.cwd);
258
+ const probes = probesInInput(event.input as Record<string, unknown>, ctx.cwd, currentRound(state).index);
259
+ if (probes.length > 0) dispatch({ t: "probes_found", probes }, ctx);
192
260
  });
193
261
 
194
262
  // ============================== prompt injection ==============================
195
263
 
196
264
  pi.on("before_agent_start", async () => {
197
- if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
265
+ if (!state.active || state.stage === "awaiting_evidence") return;
198
266
  // The blackboard claims to be ground truth, so reconcile it with disk first.
199
- await syncLedger(state);
267
+ await syncLedger();
268
+ if (!state.active) return;
200
269
  // Cleanup has no hypotheses left to form; the full methodology would only
201
270
  // invite another round.
202
- const contract = state.phase === "cleanup" ? CLEANUP_CONTRACT : METHODOLOGY;
271
+ const contract = state.stage === "cleaning_up" ? CLEANUP_CONTRACT : METHODOLOGY;
203
272
  return {
204
273
  message: {
205
274
  customType: DEBUG_CONTEXT_TYPE,
206
- content: `${blackboard(state)}\n\n${contract}`,
275
+ content: `${blackboard(state, describeEvidence(evidenceView(state)))}\n\n${contract}`,
207
276
  display: false,
208
277
  },
209
278
  };
@@ -217,229 +286,141 @@ export function registerDebugMode(pi: ExtensionAPI): void {
217
286
  if (filtered.length !== event.messages.length) return { messages: filtered };
218
287
  });
219
288
 
220
- // ============================== round lifecycle ==============================
289
+ // ============================== turn lifecycle ==============================
221
290
 
222
- pi.on("agent_start", async () => {
223
- if (state.active) state.hasRoundContent = false;
291
+ pi.on("agent_start", async (_event, ctx) => {
292
+ if (state.active) dispatch({ t: "turn_started" }, ctx);
224
293
  });
225
294
 
226
- pi.on("message_end", async (event) => {
295
+ pi.on("message_end", async (event, ctx) => {
227
296
  if (!state.active) return;
228
297
  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
- }
298
+ if (msg?.role !== "assistant") return;
299
+ dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
235
300
  });
236
301
 
237
302
  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);
303
+ if (!state.active || !state.turnProduced) return;
304
+ if (state.stage !== "investigating" && state.stage !== "cleaning_up") return;
305
+ // A declared runtime_probe must be backed by a marker that is really on
306
+ // disk, so the ledger is reconciled before the round may close.
307
+ await syncLedger();
308
+ const context = dispatch({ t: "turn_settled" }, ctx);
309
+ if (context) return { continue: true, additionalContext: context };
258
310
  });
259
311
 
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) {
312
+ registerDebugTools(pi, { getState: () => state, refreshLogCounts, readRunLines, syncLedger });
313
+
314
+ // ============================== command guards ==============================
315
+
316
+ function activeSession(ctx: ExtensionContext): DebugSession | null {
317
+ if (!state.active) {
318
+ ctx.ui.notify("debug-mode: not active", "error");
319
+ return null;
320
+ }
321
+ return state;
322
+ }
323
+
324
+ function ownsTurn(session: DebugSession): boolean {
325
+ return session.stage === "awaiting_evidence" || session.stage === "open";
326
+ }
327
+
328
+ /**
329
+ * The debug commands belong to the user's turn. An unclosed round is still
330
+ * the user's move, so it gets a confirmation rather than the flat "not
331
+ * waiting for reproduction" refusal that made an open round look stuck.
332
+ */
333
+ async function claimTurn(ctx: ExtensionContext, action: string): Promise<boolean> {
334
+ const session = activeSession(ctx);
335
+ if (!session) return false;
336
+ if (!ownsTurn(session)) {
267
337
  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",
338
+ `debug-mode: the agent still has round ${currentRound(session).index} (stage: ${session.stage}) wait for it to stop.`,
339
+ "error",
270
340
  );
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",
341
+ return false;
342
+ }
343
+ if (session.stage === "open" && ctx.hasUI) {
344
+ const confirmed = await ctx.ui.confirm(
345
+ `${action} an unclosed round?`,
346
+ `${describeOpenReason(currentRound(session).openReason ?? "awaiting_reply", currentRound(session).index)} Continue with the evidence that exists?`,
275
347
  );
276
- } else {
277
- ctx.ui.notify(`Debug round ${state.round} paused. Reproduce the bug, then ${PROCEED_REMINDER}`, "info");
348
+ if (!confirmed) return false;
278
349
  }
350
+ return true;
279
351
  }
280
352
 
281
353
  // ============================== round transitions ==============================
282
354
 
283
355
  function startDebug(ctx: ExtensionContext, problem: string): void {
284
- if (!initializeLogDirectory(ctx)) {
356
+ const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
357
+ try {
358
+ fs.mkdirSync(debugDir, { recursive: true });
359
+ excludeDebugLogsFromGit(ctx.cwd);
360
+ } catch (err) {
361
+ pi.logger.error("debug-mode: cannot create log dir", { dir: debugDir, err });
285
362
  ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
286
363
  return;
287
364
  }
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());
365
+ const runId = createRun(debugDir, 1, null);
366
+ if (!runId) {
301
367
  ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
302
368
  return;
303
369
  }
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
- );
370
+ dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
320
371
  }
321
372
 
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) {
373
+ async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
374
+ if (!(await claimTurn(ctx, "Proceed from"))) return;
375
+ refreshLogCounts();
376
+ if (!state.active) return;
377
+ const closingRun = activeRunId(state);
378
+ const logCount = closingRun ? (state.logCounts[closingRun] ?? 0) : 0;
379
+ if (logCount === 0 && !userDetails && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
338
380
  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?",
381
+ "Proceed without runtime logs?",
382
+ "No runtime observations were captured for this round. Continue to log analysis anyway?",
341
383
  );
342
384
  if (!confirmed) return;
343
385
  }
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) {
386
+ if (!state.active) return;
387
+ const stillPending = pendingRequests(state);
388
+ if (stillPending.length > 0 && ctx.hasUI) {
367
389
  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?",
390
+ "Proceed without all requested evidence?",
391
+ `${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
370
392
  );
371
393
  if (!confirmed) return;
372
394
  }
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();
395
+ if (!state.active || !ownsTurn(state) || !state.debugDir) return;
396
+
397
+ // The digest must be read before the active log is archived and truncated.
398
+ const hypotheses = describeHypotheses(summarizeHypotheses(closingRun ? readRunLines(closingRun) : []));
399
+ const runId = createRun(state.debugDir, currentRound(state).index + 1, closingRun);
400
+ if (!runId) {
401
+ ctx.ui.notify("debug-mode: could not initialize the next run log file; staying on this round", "error");
389
402
  return;
390
403
  }
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
- );
404
+ dispatch({ t: "proceed", runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
406
405
  }
407
406
 
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",
407
+ async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
408
+ if (!(await claimTurn(ctx, "Mark as fixed from"))) return;
409
+ const logCount = currentLogCount();
410
+ if (!state.active) return;
411
+ if (logCount === 0 && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
412
+ const confirmed = await ctx.ui.confirm(
413
+ "Mark as fixed without runtime logs?",
414
+ "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
434
415
  );
416
+ if (!confirmed) return;
435
417
  }
418
+ if (!state.active || !ownsTurn(state)) return;
419
+ dispatch({ t: "mark_fixed" }, ctx);
436
420
  }
437
421
 
438
422
  async function abortDebug(ctx: ExtensionContext): Promise<void> {
439
- if (!state.active) {
440
- ctx.ui.notify("debug-mode: not active", "error");
441
- return;
442
- }
423
+ if (!activeSession(ctx)) return;
443
424
  if (ctx.hasUI) {
444
425
  const confirmed = await ctx.ui.confirm(
445
426
  "Abort debug mode?",
@@ -448,53 +429,59 @@ export function registerDebugMode(pi: ExtensionAPI): void {
448
429
  if (!confirmed) return;
449
430
  }
450
431
  if (!state.active) return;
451
- await teardown(ctx, "aborted");
432
+ dispatch({ t: "abort" }, ctx);
452
433
  }
453
434
 
454
435
  /**
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.
436
+ * Attach one user-provided evidence file to the current round. The file is
437
+ * referenced in place never copied, moved or deleted — and the action does
438
+ * not advance the workflow: no model-visible message, no agent turn.
458
439
  */
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;
440
+ async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
441
+ const session = activeSession(ctx);
442
+ if (!session) return false;
443
+ if (!ownsTurn(session)) {
444
+ ctx.ui.notify(`debug-mode: cannot attach evidence while the agent is working (stage: ${session.stage})`, "error");
445
+ return false;
473
446
  }
474
- if (reviewMenuOpen) {
475
- ctx.ui.notify("debug-mode: review menu is already open", "info");
476
- return;
447
+ let input = rawPath?.trim() ?? "";
448
+ if (!input) {
449
+ if (!ctx.hasUI) {
450
+ ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
451
+ return false;
452
+ }
453
+ input = (await ctx.ui.input("Path to debug evidence file", "absolute or cwd-relative path")) ?? "";
454
+ if (!input.trim()) {
455
+ ctx.ui.notify("debug-mode: no evidence file path provided", "error");
456
+ return false;
457
+ }
477
458
  }
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);
459
+ const trimmed = input.trim();
460
+ const cwdRoot = path.resolve(ctx.cwd);
461
+ const resolved = path.resolve(cwdRoot, trimmed);
462
+ const outsideCwd = resolved !== cwdRoot && !resolved.startsWith(`${cwdRoot}${path.sep}`);
463
+ if (outsideCwd) {
464
+ if (!ctx.hasUI) {
465
+ ctx.ui.notify(
466
+ `debug-mode: ${resolved} is outside the session working directory and cannot be confirmed without a UI`,
467
+ "error",
468
+ );
469
+ return false;
494
470
  }
495
- } finally {
496
- reviewMenuOpen = false;
471
+ const confirmed = await ctx.ui.confirm(
472
+ "Attach evidence outside the working directory?",
473
+ `Record ${resolved} as debug evidence? The file is referenced in place and never modified.`,
474
+ );
475
+ if (!confirmed) return false;
476
+ }
477
+ const validation = validateEvidenceArtifact(trimmed, ctx.cwd);
478
+ if (!validation.ok) {
479
+ ctx.ui.notify(`debug-mode: evidence rejected — ${validation.reason}`, "error");
480
+ return false;
497
481
  }
482
+ if (!state.active) return false;
483
+ dispatch({ t: "attach_artifact", candidate: validation.artifact, requestId, now: Date.now() }, ctx);
484
+ return true;
498
485
  }
499
486
 
500
487
  // ============================== commands ==============================
@@ -504,7 +491,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
504
491
  handler: async (args, ctx) => {
505
492
  uiCtx = ctx;
506
493
  if (state.active) {
507
- ctx.ui.notify(`debug-mode: already active (use /${COMMAND_MENU}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT})`, "error");
494
+ ctx.ui.notify(`debug-mode: already active (use /${COMMAND_STATUS}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT})`, "error");
508
495
  return;
509
496
  }
510
497
  const problem = args.trim();
@@ -528,31 +515,40 @@ export function registerDebugMode(pi: ExtensionAPI): void {
528
515
  });
529
516
 
530
517
  pi.registerCommand(COMMAND_PROCEED, {
531
- description: "Continue with captured logs: evaluate hypotheses, fix only with evidence",
532
- handler: async (_args, ctx) => {
518
+ description: "Continue with captured evidence and optional user details: /debug-proceed [details]",
519
+ handler: async (args, ctx) => {
533
520
  uiCtx = ctx;
534
- await advanceDebug(ctx);
521
+ const details = args.trim();
522
+ await advanceDebug(ctx, details || undefined);
535
523
  },
536
524
  });
537
525
 
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
- });
526
+ /**
527
+ * Parse `/debug-evidence [<request-id>] <path>`: when the first
528
+ * whitespace-delimited token names a pending current-round user_artifact
529
+ * request, link to it and treat the remainder (spaces intact) as the path;
530
+ * otherwise the whole argument is an unlinked path.
531
+ */
532
+ function parseEvidenceArgument(args: string): { requestId: string | null; rawPath: string | undefined } {
533
+ const trimmed = args.trim();
534
+ if (!trimmed) return { requestId: null, rawPath: undefined };
535
+ const firstToken = trimmed.split(/\s+/, 1)[0];
536
+ const isPending =
537
+ state.active &&
538
+ pendingRequests(state).some(request => request.method === "user_artifact" && request.id === firstToken);
539
+ if (isPending) {
540
+ const rest = trimmed.slice(firstToken.length).trim();
541
+ return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
542
+ }
543
+ return { requestId: null, rawPath: trimmed };
544
+ }
545
545
 
546
- pi.registerCommand(COMMAND_NOTE, {
547
- description: "Add reproduction details and continue: /debug-note <details>",
546
+ pi.registerCommand(COMMAND_EVIDENCE, {
547
+ description: "Attach one user-provided evidence file: /debug-evidence [<request-id>] <path> (no argument opens a path prompt)",
548
548
  handler: async (args, ctx) => {
549
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);
550
+ const { requestId, rawPath } = parseEvidenceArgument(args);
551
+ await attachEvidence(ctx, rawPath, requestId);
556
552
  },
557
553
  });
558
554
 
@@ -573,23 +569,40 @@ export function registerDebugMode(pi: ExtensionAPI): void {
573
569
  return;
574
570
  }
575
571
  refreshLogCounts();
576
- const scan = await syncLedger(state);
577
- const tallies = state.runId ? summarizeHypotheses(readRunLines(state.runId)) : [];
572
+ const scan = await syncLedger();
573
+ if (!state.active) return;
574
+ const session: DebugSession = state;
575
+ const round = currentRound(session);
576
+ const run = activeRunId(session);
577
+ const tallies = run ? summarizeHypotheses(readRunLines(run)) : [];
578
+ const requests = round.plan ?? [];
579
+ const pending = pendingRequests(session, round);
580
+ const unavailable = session.artifacts.filter(artifact => {
581
+ try {
582
+ const stats = fs.statSync(artifact.path);
583
+ fs.accessSync(artifact.path, fs.constants.R_OK);
584
+ return !stats.isFile();
585
+ } catch {
586
+ return true;
587
+ }
588
+ });
578
589
  ctx.ui.notify(
579
- `debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
590
+ `debug-mode: stage=${session.stage} round=${round.index} run=${run ?? "(none)"}\n` +
591
+ (round.openReason ? `${describeOpenReason(round.openReason, round.index)}\n` : "") +
580
592
  `${describeLedger(scan)}\n` +
581
- `logs: ${state.runHistory.map(r => `${r}=${state.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
593
+ `logs: ${session.runHistory.map(r => `${r}=${session.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
582
594
  `this run by hypothesis: ${describeHypotheses(tallies)}\n` +
583
- `current log file: ${logFileFor(state) ?? "(not initialized)"}`,
595
+ `current log file: ${logFileFor(session) ?? "(not initialized)"}\n` +
596
+ `evidence requests: ${requests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
597
+ `pending: ${pending.map(r => r.id).join(", ") || "(none)"}\n` +
598
+ `observations: ${session.observations.map(o => o.id).join(", ") || "(none)"}\n` +
599
+ `artifacts: ${session.artifacts.map(a => a.id).join(", ") || "(none)"}` +
600
+ (unavailable.length > 0 ? `\nunavailable artifacts: ${unavailable.map(a => `${a.id} ${a.path}`).join(", ")}` : ""),
584
601
  "info",
585
602
  );
586
603
  },
587
604
  });
588
605
 
589
- // ============================== tools ==============================
590
-
591
- registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
592
-
593
606
  // ============================== lifecycle ==============================
594
607
 
595
608
  pi.on("session_start", async (_event, ctx) => {
@@ -597,18 +610,24 @@ export function registerDebugMode(pi: ExtensionAPI): void {
597
610
  const entries = ctx.sessionManager.getEntries();
598
611
  const last = entries
599
612
  .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);
613
+ .pop() as { data?: unknown } | undefined;
614
+ state = reviveState(last?.data);
615
+ if (state.active) {
616
+ if (!state.debugDir || !fs.existsSync(state.debugDir)) {
617
+ const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
618
+ try {
619
+ fs.mkdirSync(debugDir, { recursive: true });
620
+ excludeDebugLogsFromGit(ctx.cwd);
621
+ state = { ...state, debugDir };
622
+ } catch (err) {
623
+ pi.logger.error("debug-mode: cannot restore log dir", { dir: debugDir, err });
624
+ }
625
+ }
608
626
  const currentFile = logFileFor(state);
609
627
  if (currentFile) {
610
628
  try {
611
- const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
629
+ const run = activeRunId(state);
630
+ const legacyRunFile = state.debugDir && run ? path.join(state.debugDir, `${run}.jsonl`) : null;
612
631
  if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
613
632
  fs.renameSync(legacyRunFile, currentFile);
614
633
  }
@@ -618,18 +637,15 @@ export function registerDebugMode(pi: ExtensionAPI): void {
618
637
  }
619
638
  }
620
639
  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
- );
640
+ if (state.active) {
641
+ ctx.ui.notify(
642
+ `debug-mode resumed: stage=${state.stage} round=${currentRound(state).index}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state)}. Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
643
+ "info",
644
+ );
645
+ }
625
646
  }
626
647
  refreshUi();
627
648
  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
649
  });
634
650
 
635
651
  pi.on("session_shutdown", async () => {