@siuver/omp-debug-mode 0.1.4 → 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,8 +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 { addEvidenceArtifact, describeEvidence, parseEvidencePlan } from "./evidence";
6
- import { decideGate } from "./gate";
5
+ import { describeEvidence, validateEvidenceArtifact } from "./evidence";
6
+ import { describeOpenReason } from "./gate";
7
7
  import {
8
8
  ACTIVE_LOG_FILE,
9
9
  JsonlLineCounter,
@@ -12,30 +12,26 @@ import {
12
12
  readJsonlLines,
13
13
  summarizeHypotheses,
14
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";
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";
26
18
  import {
19
+ type DebugSession,
20
+ type DebugState,
27
21
  DEBUG_CONTEXT_TYPE,
28
22
  DEBUG_ENTRY,
29
- type DebugState,
30
- type EvidenceObservation,
23
+ INACTIVE,
24
+ activeRunId,
31
25
  blackboard,
32
26
  compareRunIds,
27
+ currentRound,
33
28
  evidenceSummary,
34
- freshState,
29
+ evidenceView,
30
+ hasNonProbeEvidence,
35
31
  keepLatestCustomType,
36
32
  logFileFor,
37
- pendingEvidenceRequests,
38
- replaceRoundEvidenceRequests,
33
+ pendingRequests,
34
+ reviveState,
39
35
  } from "./state";
40
36
  import { registerDebugTools } from "./tools";
41
37
  import { applyUi } from "./ui";
@@ -50,9 +46,9 @@ const COMMAND_STATUS = "debug-status";
50
46
 
51
47
  /** Compact transcript lines for the prompts this extension injects. */
52
48
  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",
49
+ [PROMPT_START]: "debug mode started — hypotheses and instrumentation",
50
+ [PROMPT_PROCEED]: "proceed — analyzing captured logs",
51
+ [PROMPT_FIXED]: "marked fixed — removing probes and summarizing",
56
52
  };
57
53
 
58
54
  interface DebugMessageDetails {
@@ -60,7 +56,7 @@ interface DebugMessageDetails {
60
56
  }
61
57
 
62
58
  export function registerDebugMode(pi: ExtensionAPI): void {
63
- const state: DebugState = freshState();
59
+ let state: DebugState = INACTIVE;
64
60
  let uiCtx: ExtensionContext | null = null;
65
61
  let watchedLogFile: string | null = null;
66
62
  const lineCounter = new JsonlLineCounter();
@@ -73,79 +69,142 @@ export function registerDebugMode(pi: ExtensionAPI): void {
73
69
  pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
74
70
  }
75
71
 
76
- // ============================== log files ==============================
72
+ // ============================== state plumbing ==============================
77
73
 
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;
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
+ }
89
143
  }
90
144
  }
91
145
 
146
+ function refreshUi(): void {
147
+ applyUi(uiCtx, state, currentLogCount);
148
+ }
149
+
150
+ // ============================== log files ==============================
151
+
92
152
  function readRunLines(run: string): string[] {
93
- return readJsonlLines(logFileFor(state, run));
153
+ return state.active ? readJsonlLines(logFileFor(state, run)) : [];
94
154
  }
95
155
 
156
+ /** Re-count every run on disk. Pure cache maintenance — never a decision. */
96
157
  function refreshLogCounts(): void {
97
- const runs = new Set(Object.keys(state.logCounts));
98
- 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) {
99
163
  try {
100
- for (const file of fs.readdirSync(state.debugDir)) {
164
+ for (const file of fs.readdirSync(session.debugDir)) {
101
165
  if (!file.endsWith(".jsonl")) continue;
102
166
  if (file === ACTIVE_LOG_FILE) {
103
- if (state.runId) runs.add(state.runId);
167
+ if (active) runs.add(active);
104
168
  } else {
105
169
  runs.add(file.replace(/\.jsonl$/, ""));
106
170
  }
107
171
  }
108
172
  } catch {}
109
173
  }
110
- for (const run of runs) state.logCounts[run] = lineCounter.count(logFileFor(state, run));
111
- 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 });
112
177
  }
113
178
 
114
179
  /** 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);
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);
120
185
  completed.sort(compareRunIds);
121
- state.runHistory = active ? [...completed, active] : completed;
186
+ return active ? [...completed, active] : completed;
122
187
  }
123
188
 
124
189
  function currentLogCount(): number {
125
190
  refreshLogCounts();
126
- 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;
127
194
  }
128
195
 
129
- function newRun(): string | null {
130
- if (!state.debugDir) return null;
131
- 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 {
132
198
  try {
133
- prepareRunLog(state.debugDir, state.runId);
199
+ prepareRunLog(debugDir, previousRun);
134
200
  } catch (err) {
135
201
  pi.logger.error("debug-mode: cannot initialize run log", {
136
- file: path.join(state.debugDir, ACTIVE_LOG_FILE),
202
+ file: path.join(debugDir, ACTIVE_LOG_FILE),
137
203
  err,
138
204
  });
139
205
  return null;
140
206
  }
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);
207
+ return `run${round}-${Date.now().toString(36)}`;
149
208
  }
150
209
 
151
210
  /**
@@ -154,13 +213,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
154
213
  * refresh the widget as observations land.
155
214
  */
156
215
  function watchLogFile(): void {
157
- const file = state.phase === "waiting" ? logFileFor(state) : null;
216
+ const file = state.active && state.stage === "awaiting_evidence" ? logFileFor(state) : null;
158
217
  if (file === watchedLogFile) return;
159
218
  unwatchLogFile();
160
219
  if (!file || !uiCtx?.hasUI) return;
161
220
  try {
162
221
  fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
163
- if (state.phase !== "waiting") return;
222
+ if (!state.active || state.stage !== "awaiting_evidence") return;
164
223
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
165
224
  try {
166
225
  refreshUi();
@@ -186,26 +245,35 @@ export function registerDebugMode(pi: ExtensionAPI): void {
186
245
 
187
246
  // ============================== probe ledger ==============================
188
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
+
189
255
  pi.on("tool_call", async (event, ctx) => {
190
256
  if (!state.active) return;
191
257
  if (event.toolName !== "edit" && event.toolName !== "write") return;
192
- 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);
193
260
  });
194
261
 
195
262
  // ============================== prompt injection ==============================
196
263
 
197
264
  pi.on("before_agent_start", async () => {
198
- if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
265
+ if (!state.active || state.stage === "awaiting_evidence") return;
199
266
  // The blackboard claims to be ground truth, so reconcile it with disk first.
200
- await syncLedger(state);
267
+ await syncLedger();
268
+ if (!state.active) return;
201
269
  // Cleanup has no hypotheses left to form; the full methodology would only
202
270
  // invite another round.
203
- const contract = state.phase === "cleanup" ? CLEANUP_CONTRACT : METHODOLOGY;
271
+ const contract = state.stage === "cleaning_up" ? CLEANUP_CONTRACT : METHODOLOGY;
204
272
  return {
205
273
  message: {
206
274
  customType: DEBUG_CONTEXT_TYPE,
207
- content: `${blackboard(state, describeEvidence(state))}\n\n${contract}`,
208
- display: false,
275
+ content: `${blackboard(state, describeEvidence(evidenceView(state)))}\n\n${contract}`,
276
+ display: false,
209
277
  },
210
278
  };
211
279
  });
@@ -218,304 +286,141 @@ export function registerDebugMode(pi: ExtensionAPI): void {
218
286
  if (filtered.length !== event.messages.length) return { messages: filtered };
219
287
  });
220
288
 
221
- // ============================== round lifecycle ==============================
289
+ // ============================== turn lifecycle ==============================
222
290
 
223
- pi.on("agent_start", async () => {
224
- if (state.active) state.hasRoundContent = false;
291
+ pi.on("agent_start", async (_event, ctx) => {
292
+ if (state.active) dispatch({ t: "turn_started" }, ctx);
225
293
  });
226
294
 
227
- pi.on("message_end", async (event) => {
295
+ pi.on("message_end", async (event, ctx) => {
228
296
  if (!state.active) return;
229
297
  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
- }
298
+ if (msg?.role !== "assistant") return;
299
+ dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
244
300
  });
245
301
 
246
302
  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);
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 };
269
310
  });
270
311
 
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
- }
312
+ registerDebugTools(pi, { getState: () => state, refreshLogCounts, readRunLines, syncLedger });
309
313
 
310
- // ============================== round transitions ==============================
314
+ // ============================== command guards ==============================
311
315
 
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;
316
+ function activeSession(ctx: ExtensionContext): DebugSession | null {
317
+ if (!state.active) {
318
+ ctx.ui.notify("debug-mode: not active", "error");
319
+ return null;
342
320
  }
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
- );
321
+ return state;
352
322
  }
353
323
 
354
- function ensureWaiting(ctx: ExtensionContext): boolean {
355
- if (!state.active) {
356
- ctx.ui.notify("debug-mode: not active", "error");
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)) {
337
+ ctx.ui.notify(
338
+ `debug-mode: the agent still has round ${currentRound(session).index} (stage: ${session.stage}) — wait for it to stop.`,
339
+ "error",
340
+ );
357
341
  return false;
358
342
  }
359
- if (state.phase !== "waiting") {
360
- ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
361
- return false;
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?`,
347
+ );
348
+ if (!confirmed) return false;
362
349
  }
363
350
  return true;
364
351
  }
365
352
 
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;
353
+ // ============================== round transitions ==============================
380
354
 
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
- }
355
+ function startDebug(ctx: ExtensionContext, problem: string): void {
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 });
362
+ ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
363
+ return;
364
+ }
365
+ const runId = createRun(debugDir, 1, null);
366
+ if (!runId) {
367
+ ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
368
+ return;
369
+ }
370
+ dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
371
+ }
399
372
 
400
373
  async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
401
- if (!ensureWaiting(ctx)) return;
374
+ if (!(await claimTurn(ctx, "Proceed from"))) return;
402
375
  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) {
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) {
409
380
  const confirmed = await ctx.ui.confirm(
410
381
  "Proceed without runtime logs?",
411
382
  "No runtime observations were captured for this round. Continue to log analysis anyway?",
412
383
  );
413
384
  if (!confirmed) return;
414
385
  }
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);
386
+ if (!state.active) return;
387
+ const stillPending = pendingRequests(state);
433
388
  if (stillPending.length > 0 && ctx.hasUI) {
434
389
  const confirmed = await ctx.ui.confirm(
435
390
  "Proceed without all requested evidence?",
436
391
  `${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
437
392
  );
438
- if (!confirmed) {
439
- pi.appendEntry(DEBUG_ENTRY, { ...state });
440
- refreshUi();
441
- return;
442
- }
393
+ if (!confirmed) return;
443
394
  }
395
+ if (!state.active || !ownsTurn(state) || !state.debugDir) return;
444
396
 
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();
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");
459
402
  return;
460
403
  }
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
- );
404
+ dispatch({ t: "proceed", runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
482
405
  }
483
406
 
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",
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?",
510
415
  );
416
+ if (!confirmed) return;
511
417
  }
418
+ if (!state.active || !ownsTurn(state)) return;
419
+ dispatch({ t: "mark_fixed" }, ctx);
512
420
  }
513
421
 
514
422
  async function abortDebug(ctx: ExtensionContext): Promise<void> {
515
- if (!state.active) {
516
- ctx.ui.notify("debug-mode: not active", "error");
517
- return;
518
- }
423
+ if (!activeSession(ctx)) return;
519
424
  if (ctx.hasUI) {
520
425
  const confirmed = await ctx.ui.confirm(
521
426
  "Abort debug mode?",
@@ -524,16 +429,21 @@ function enterGate(
524
429
  if (!confirmed) return;
525
430
  }
526
431
  if (!state.active) return;
527
- await teardown(ctx, "aborted");
432
+ dispatch({ t: "abort" }, ctx);
528
433
  }
529
434
 
530
435
  /**
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.
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.
534
439
  */
535
440
  async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
536
- if (!ensureWaiting(ctx)) return false;
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;
446
+ }
537
447
  let input = rawPath?.trim() ?? "";
538
448
  if (!input) {
539
449
  if (!ctx.hasUI) {
@@ -564,20 +474,15 @@ function enterGate(
564
474
  );
565
475
  if (!confirmed) return false;
566
476
  }
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");
477
+ const validation = validateEvidenceArtifact(trimmed, ctx.cwd);
478
+ if (!validation.ok) {
479
+ ctx.ui.notify(`debug-mode: evidence rejected — ${validation.reason}`, "error");
570
480
  return false;
571
481
  }
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
- );
482
+ if (!state.active) return false;
483
+ dispatch({ t: "attach_artifact", candidate: validation.artifact, requestId, now: Date.now() }, ctx);
579
484
  return true;
580
- }
485
+ }
581
486
 
582
487
  // ============================== commands ==============================
583
488
 
@@ -628,9 +533,9 @@ function enterGate(
628
533
  const trimmed = args.trim();
629
534
  if (!trimmed) return { requestId: null, rawPath: undefined };
630
535
  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
- );
536
+ const isPending =
537
+ state.active &&
538
+ pendingRequests(state).some(request => request.method === "user_artifact" && request.id === firstToken);
634
539
  if (isPending) {
635
540
  const rest = trimmed.slice(firstToken.length).trim();
636
541
  return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
@@ -647,7 +552,6 @@ function enterGate(
647
552
  },
648
553
  });
649
554
 
650
-
651
555
  pi.registerCommand(COMMAND_ABORT, {
652
556
  description: "Abort debug mode: delete logs (fixes stay in the working diff)",
653
557
  handler: async (_args, ctx) => {
@@ -665,11 +569,15 @@ function enterGate(
665
569
  return;
666
570
  }
667
571
  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 => {
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 => {
673
581
  try {
674
582
  const stats = fs.statSync(artifact.path);
675
583
  fs.accessSync(artifact.path, fs.constants.R_OK);
@@ -679,25 +587,22 @@ function enterGate(
679
587
  }
680
588
  });
681
589
  ctx.ui.notify(
682
- `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` : "") +
683
592
  `${describeLedger(scan)}\n` +
684
- `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` +
685
594
  `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` +
595
+ `current log file: ${logFileFor(session) ?? "(not initialized)"}\n` +
596
+ `evidence requests: ${requests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
688
597
  `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)"}` +
598
+ `observations: ${session.observations.map(o => o.id).join(", ") || "(none)"}\n` +
599
+ `artifacts: ${session.artifacts.map(a => a.id).join(", ") || "(none)"}` +
691
600
  (unavailable.length > 0 ? `\nunavailable artifacts: ${unavailable.map(a => `${a.id} ${a.path}`).join(", ")}` : ""),
692
601
  "info",
693
602
  );
694
603
  },
695
604
  });
696
605
 
697
- // ============================== tools ==============================
698
-
699
- registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
700
-
701
606
  // ============================== lifecycle ==============================
702
607
 
703
608
  pi.on("session_start", async (_event, ctx) => {
@@ -705,21 +610,24 @@ function enterGate(
705
610
  const entries = ctx.sessionManager.getEntries();
706
611
  const last = entries
707
612
  .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);
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
+ }
719
626
  const currentFile = logFileFor(state);
720
627
  if (currentFile) {
721
628
  try {
722
- 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;
723
631
  if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
724
632
  fs.renameSync(legacyRunFile, currentFile);
725
633
  }
@@ -729,19 +637,17 @@ function enterGate(
729
637
  }
730
638
  }
731
639
  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
- );
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
+ }
736
646
  }
737
647
  refreshUi();
738
648
  watchLogFile();
739
649
  });
740
650
 
741
- pi.on("turn_start", async () => {
742
- if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
743
- });
744
-
745
651
  pi.on("session_shutdown", async () => {
746
652
  unwatchLogFile();
747
653
  });