@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/evidence.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { DebugState, EvidenceArtifact, EvidenceMethod, EvidenceRequest } from "./state";
3
+ import type { EvidenceArtifact, EvidenceMethod, EvidenceRequest, EvidenceView } from "./state";
4
4
 
5
5
  /** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
6
6
  export const MAX_EVIDENCE_REQUESTS = 12;
@@ -31,7 +31,7 @@ function hasOnlyUniqueValues(values: readonly string[]): boolean {
31
31
  * all-or-nothing: any malformed or invalid entry rejects the whole plan so a
32
32
  * partial plan can never silently drop a hypothesis.
33
33
  */
34
- export function parseEvidencePlan(text: string, round: number): EvidencePlanParseResult {
34
+ export function parseEvidencePlan(text: string): EvidencePlanParseResult {
35
35
  const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
36
36
  if (!match) return { found: false, valid: false, requests: [] };
37
37
  const fail = (error: string): EvidencePlanParseResult => ({ found: true, valid: false, requests: [], error });
@@ -80,7 +80,6 @@ export function parseEvidencePlan(text: string, round: number): EvidencePlanPars
80
80
  rationale: record.rationale,
81
81
  instructions: record.instructions,
82
82
  ...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
83
- round,
84
83
  });
85
84
  }
86
85
  return { found: true, valid: true, requests };
@@ -116,46 +115,20 @@ export function validateEvidenceArtifact(rawPath: string, cwd: string): Artifact
116
115
  };
117
116
  }
118
117
 
119
- /** Record a user-supplied artifact, deduplicating by exact absolute path. */
120
- export function addEvidenceArtifact(
121
- state: DebugState,
122
- rawPath: string,
123
- cwd: string,
124
- requestId: string | null,
125
- now = Date.now(),
126
- ): { state: DebugState; artifact: EvidenceArtifact } | { error: string } {
127
- if (requestId !== null && !state.evidenceRequests.some(request => request.id === requestId)) {
128
- return { error: `unknown evidence request id ${requestId}` };
129
- }
130
- const validation = validateEvidenceArtifact(rawPath, cwd);
131
- if (!validation.ok) return { error: validation.reason };
132
- const base = validation.artifact;
133
- const existing = state.evidenceArtifacts.find(artifact => artifact.path === base.path);
134
- if (existing) {
135
- const artifact: EvidenceArtifact = { ...existing, ...base, requestId };
136
- return {
137
- state: { ...state, evidenceArtifacts: state.evidenceArtifacts.map(a => (a.path === base.path ? artifact : a)) },
138
- artifact,
139
- };
140
- }
141
- const artifact: EvidenceArtifact = { id: `artifact-${now.toString(36)}`, requestId, ...base, addedAt: now };
142
- return { state: { ...state, evidenceArtifacts: [...state.evidenceArtifacts, artifact] }, artifact };
143
- }
144
-
145
118
  /**
146
119
  * Human-readable evidence description for the blackboard and the model-facing
147
120
  * tool. Availability is re-checked on every call: a missing or unreadable file
148
121
  * is never treated as captured evidence.
149
122
  */
150
- export function describeEvidence(state: DebugState, round?: number): string {
151
- const requests = round === undefined ? state.evidenceRequests : state.evidenceRequests.filter(r => r.round === round);
152
- if (requests.length === 0 && state.evidenceArtifacts.length === 0 && state.evidenceObservations.length === 0) {
123
+ export function describeEvidence(view: EvidenceView): string {
124
+ const { requests, artifacts, observations } = view;
125
+ if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
153
126
  return "(none)";
154
127
  }
155
128
  const lines: string[] = [];
156
129
  for (const request of requests) {
157
- const linked = state.evidenceArtifacts.filter(artifact => artifact.requestId === request.id);
158
- const reports = state.evidenceObservations.filter(observation => observation.requestIds.includes(request.id));
130
+ const linked = artifacts.filter(artifact => artifact.requestId === request.id);
131
+ const reports = observations.filter(observation => observation.requestIds.includes(request.id));
159
132
  const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
160
133
  const coverage =
161
134
  request.method === "user_artifact"
@@ -171,10 +144,10 @@ export function describeEvidence(state: DebugState, round?: number): string {
171
144
  `- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
172
145
  );
173
146
  }
174
- for (const observation of state.evidenceObservations) {
147
+ for (const observation of observations) {
175
148
  lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
176
149
  }
177
- for (const artifact of state.evidenceArtifacts) {
150
+ for (const artifact of artifacts) {
178
151
  let stats: fs.Stats;
179
152
  try {
180
153
  stats = fs.statSync(artifact.path);
package/src/gate.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { MINIMIZE_USER_INTERVENTION, PROCEED_REMINDER } from "./methodology";
1
+ import { CLOSE_ROUND_RULES, MINIMIZE_USER_INTERVENTION, PROCEED_REMINDER } from "./methodology";
2
+ import type { OpenReason } from "./state";
2
3
 
3
- /** How many times a single round may be nudged to close itself properly. */
4
- export const MAX_GATE_NUDGES = 1;
4
+ /** Per-reason nudge budgets: a probe nudge must not spend the closing-tag one. */
5
+ export const MAX_TAG_NUDGES = 1;
6
+ export const MAX_PROBE_NUDGES = 1;
5
7
 
6
8
  export const GATE_NUDGE =
7
9
  "This round did not close properly. It must end with BOTH a <evidence_plan> JSON block " +
@@ -14,41 +16,68 @@ export const GATE_NUDGE =
14
16
  "A user_artifact request must state the file type, path/capture instructions, how you will inspect the file, " +
15
17
  "and why inspection/probes are inadequate. Do not start new work.";
16
18
 
19
+ export const PROBE_NUDGE =
20
+ "Your evidence plan selects runtime_probe, but no @omp-probe marker for this round exists in the working tree. " +
21
+ "Instrument the code now with edit/write — that is required work for this round, not a product fix — and only then " +
22
+ `re-emit <evidence_plan> and <reproduction_steps> followed by "${PROCEED_REMINDER}". ` +
23
+ CLOSE_ROUND_RULES;
24
+
17
25
  export type GateDecision =
18
26
  /** Hand control to the user and wait for a reproduction/capture. */
19
- | { kind: "gate"; missingSteps: boolean; missingEvidencePlan: boolean }
27
+ | { kind: "gate"; missingPlan: boolean }
20
28
  /** Let the agent finish the round properly before gating. */
21
- | { kind: "nudge"; context: string }
22
- /** Not a completed round the agent is mid-conversation with the user. */
23
- | { kind: "stay" };
29
+ | { kind: "nudge"; budget: "tags" | "probes"; context: string }
30
+ /** The round settled without closing: the ball is with the user, not the gate. */
31
+ | { kind: "open"; reason: OpenReason };
32
+
33
+ export interface GateFacts {
34
+ hasReproductionSteps: boolean;
35
+ hasEvidencePlan: boolean;
36
+ /** The plan selects runtime_probe for at least one hypothesis. */
37
+ declaresRuntimeProbe: boolean;
38
+ /** Probes this round introduced that the ledger still finds on disk. */
39
+ liveProbes: number;
40
+ nudges: { tags: number; probes: number };
41
+ }
24
42
 
25
43
  /**
26
- * Decide what a settled agent turn means for the reproduction gate.
44
+ * Decide what a settled agent turn means.
27
45
  *
28
- * A properly closed round carries both the `<evidence_plan>` block and the
29
- * `<reproduction_steps>` block. Legacy turns that only supply reproduction
30
- * steps with no probes still gate (old sessions never emit plans). A turn
31
- * with work but a missing tag gets one nudge, then gates with the missing
32
- * flags set so the workflow can never stall.
46
+ * A round may only close when its declarations are backed by observable facts:
47
+ * a `runtime_probe` plan needs real `@omp-probe` markers, and both closing tags
48
+ * must be present. Anything short of that is either one automatic nudge or an
49
+ * `open` round the user can talk to never a silent stop that looks gated.
33
50
  */
34
- export function decideGate(args: {
35
- hasReproductionSteps: boolean;
36
- hasEvidencePlan: boolean;
37
- probesThisRound: number;
38
- nudgesUsed: number;
39
- }): GateDecision {
40
- if (args.hasReproductionSteps && args.hasEvidencePlan) {
41
- return { kind: "gate", missingSteps: false, missingEvidencePlan: false };
51
+ export function decideGate(facts: GateFacts): GateDecision {
52
+ // Declared instrumentation that never reached disk: the reproduction the
53
+ // user is about to be asked for could not record anything.
54
+ if (facts.declaresRuntimeProbe && facts.liveProbes === 0) {
55
+ if (facts.nudges.probes < MAX_PROBE_NUDGES) return { kind: "nudge", budget: "probes", context: PROBE_NUDGE };
56
+ return { kind: "open", reason: "probes_missing" };
57
+ }
58
+ if (facts.hasReproductionSteps && facts.hasEvidencePlan) {
59
+ return { kind: "gate", missingPlan: false };
42
60
  }
43
61
  // Legacy/no-probe closure: reproduction steps alone still gate the turn.
44
- if (args.hasReproductionSteps && !args.hasEvidencePlan && args.probesThisRound === 0) {
45
- return { kind: "gate", missingSteps: false, missingEvidencePlan: true };
62
+ if (facts.hasReproductionSteps && !facts.hasEvidencePlan && facts.liveProbes === 0) {
63
+ return { kind: "gate", missingPlan: true };
64
+ }
65
+ // Nothing was declared and nothing was instrumented — the agent is talking
66
+ // to the user, so leave the round open instead of inventing a gate.
67
+ if (facts.liveProbes === 0 && !facts.hasEvidencePlan) {
68
+ return { kind: "open", reason: "awaiting_reply" };
69
+ }
70
+ if (facts.nudges.tags < MAX_TAG_NUDGES) return { kind: "nudge", budget: "tags", context: GATE_NUDGE };
71
+ return { kind: "open", reason: "unclosed" };
72
+ }
73
+
74
+ /** User-facing explanation of why a round is open, plus what to do about it. */
75
+ export function describeOpenReason(reason: OpenReason, round: number): string {
76
+ if (reason === "probes_missing") {
77
+ return `Debug round ${round} declared runtime_probe but no probe reached the code, so there is nothing to reproduce yet. Reply to the agent to get it instrumented.`;
78
+ }
79
+ if (reason === "unclosed") {
80
+ return `Debug round ${round} stopped without a complete evidence plan and reproduction steps. Reply to the agent, or run /debug-proceed to close it with whatever evidence exists.`;
46
81
  }
47
- if (args.probesThisRound === 0 && !args.hasEvidencePlan) return { kind: "stay" };
48
- if (args.nudgesUsed < MAX_GATE_NUDGES) return { kind: "nudge", context: GATE_NUDGE };
49
- return {
50
- kind: "gate",
51
- missingSteps: !args.hasReproductionSteps,
52
- missingEvidencePlan: !args.hasEvidencePlan,
53
- };
82
+ return `Debug round ${round} is still open the agent is waiting on your reply, not on a reproduction.`;
54
83
  }
package/src/machine.ts ADDED
@@ -0,0 +1,334 @@
1
+ import { parseEvidencePlan } from "./evidence";
2
+ import { decideGate, describeOpenReason } from "./gate";
3
+ import {
4
+ CLEANUP_NUDGE,
5
+ buildFixedMessage,
6
+ buildProceedMessage,
7
+ buildStartMessage,
8
+ extractReproductionSteps,
9
+ } from "./methodology";
10
+ import {
11
+ type DebugSession,
12
+ type DebugState,
13
+ type EvidenceArtifact,
14
+ type EvidenceObservation,
15
+ type Probe,
16
+ type Round,
17
+ INACTIVE,
18
+ allRequests,
19
+ blankRound,
20
+ currentRound,
21
+ declaresRuntimeProbe,
22
+ evidenceSummary,
23
+ freshSession,
24
+ liveProbeIds,
25
+ pendingRequests,
26
+ } from "./state";
27
+
28
+ export const PROMPT_START = "debug-mode-start";
29
+ export const PROMPT_PROCEED = "debug-mode-proceed";
30
+ export const PROMPT_FIXED = "debug-mode-fixed";
31
+
32
+ /** Artifact metadata gathered by the shell; the reducer only files it. */
33
+ export type ArtifactCandidate = Omit<EvidenceArtifact, "id" | "addedAt" | "requestId">;
34
+
35
+ export type DebugEvent =
36
+ | { t: "start"; problem: string; debugDir: string; runId: string; logFile: string }
37
+ | { t: "turn_started" }
38
+ | { t: "assistant_message"; text: string }
39
+ | { t: "probes_found"; probes: Probe[] }
40
+ | { t: "ledger_synced"; probes: Probe[] }
41
+ | { t: "runs_observed"; runHistory: string[]; logCounts: Record<string, number> }
42
+ | { t: "turn_settled" }
43
+ | { t: "proceed"; runId: string; logCount: number; hypotheses: string; details?: string; now: number }
44
+ | { t: "mark_fixed" }
45
+ | { t: "attach_artifact"; candidate: ArtifactCandidate; requestId: string | null; now: number }
46
+ | { t: "abort" };
47
+
48
+ export type Effect =
49
+ | { kind: "notify"; level: "info" | "warning" | "error"; text: string }
50
+ /** Inject a prompt and start an agent turn. */
51
+ | { kind: "prompt"; customType: string; content: string; summary: string }
52
+ /** Keep the current turn going instead of settling it. */
53
+ | { kind: "continue"; context: string }
54
+ | { kind: "teardown"; outcome: "finished" | "aborted"; probesLeft: Probe[]; debugDir: string | null };
55
+
56
+ export interface Transition {
57
+ state: DebugState;
58
+ effects: Effect[];
59
+ }
60
+
61
+ /** A narrowed active state. Kept as one type so no-ops can return it verbatim. */
62
+ type ActiveState = { active: true } & DebugSession;
63
+
64
+ function unchanged(state: DebugState): Transition {
65
+ return { state, effects: [] };
66
+ }
67
+
68
+ function withRound(session: ActiveState, round: Round): ActiveState {
69
+ return { ...session, rounds: [...session.rounds.slice(0, -1), round] };
70
+ }
71
+
72
+ /**
73
+ * The whole state machine. Pure and total: every transition is a function of
74
+ * the previous state plus one event, and anything that touches the filesystem,
75
+ * the host UI or the model is returned as an effect for the shell to apply.
76
+ */
77
+ export function reduce(state: DebugState, event: DebugEvent): Transition {
78
+ if (event.t === "start") {
79
+ if (state.active) return unchanged(state);
80
+ return {
81
+ state: { active: true, ...freshSession(event.problem, event.debugDir, event.runId) },
82
+ effects: [
83
+ {
84
+ kind: "prompt",
85
+ customType: PROMPT_START,
86
+ content: buildStartMessage(event.problem, event.logFile),
87
+ summary: "debug mode started — hypotheses and instrumentation",
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ if (!state.active) return unchanged(state);
93
+ const session: ActiveState = state;
94
+
95
+ switch (event.t) {
96
+ case "turn_started":
97
+ // A reply to an open round hands the turn back to the agent. The
98
+ // reproduction gate deliberately survives ordinary conversation.
99
+ if (session.stage !== "open" && !session.turnProduced) return unchanged(session);
100
+ return unchanged({
101
+ ...session,
102
+ stage: session.stage === "open" ? "investigating" : session.stage,
103
+ turnProduced: false,
104
+ });
105
+
106
+ case "assistant_message":
107
+ return unchanged(absorbAssistantText(session, event.text));
108
+
109
+ case "probes_found": {
110
+ const fresh = event.probes.filter(probe => !session.probes.some(known => known.id === probe.id));
111
+ if (fresh.length === 0) return unchanged(session);
112
+ const probes = [...session.probes, ...fresh];
113
+ if (session.stage !== "investigating") return unchanged({ ...session, probes });
114
+ const round = currentRound(session);
115
+ return unchanged(
116
+ withRound({ ...session, probes }, { ...round, probeIds: [...round.probeIds, ...fresh.map(p => p.id)] }),
117
+ );
118
+ }
119
+
120
+ case "ledger_synced": {
121
+ const same =
122
+ event.probes.length === session.probes.length &&
123
+ event.probes.every((probe, i) => probe.id === session.probes[i]?.id);
124
+ return unchanged(same ? session : { ...session, probes: event.probes });
125
+ }
126
+
127
+ case "runs_observed":
128
+ return unchanged({ ...session, runHistory: event.runHistory, logCounts: event.logCounts });
129
+
130
+ case "turn_settled":
131
+ return settle(session);
132
+
133
+ case "proceed":
134
+ return advance(session, event);
135
+
136
+ case "mark_fixed":
137
+ return startCleanup(session);
138
+
139
+ case "attach_artifact":
140
+ return attach(session, event);
141
+
142
+ case "abort":
143
+ return {
144
+ state: INACTIVE,
145
+ effects: [
146
+ { kind: "teardown", outcome: "aborted", probesLeft: session.probes, debugDir: session.debugDir },
147
+ ],
148
+ };
149
+ }
150
+ }
151
+
152
+ /** Record the round artefacts a settled-but-not-yet-judged turn produced. */
153
+ function absorbAssistantText(session: ActiveState, text: string): ActiveState {
154
+ const round = currentRound(session);
155
+ const steps = session.stage === "investigating" ? extractReproductionSteps(text) : [];
156
+ const plan = session.stage === "investigating" ? parseEvidencePlan(text) : { found: false, valid: false, requests: [] };
157
+ const next: Round = {
158
+ ...round,
159
+ reproductionSteps: steps.length > 0 ? steps : round.reproductionSteps,
160
+ plan: plan.found ? (plan.valid ? plan.requests : null) : round.plan,
161
+ };
162
+ const roundChanged = next.reproductionSteps !== round.reproductionSteps || next.plan !== round.plan;
163
+ if (!roundChanged) return session.turnProduced ? session : { ...session, turnProduced: true };
164
+ return withRound({ ...session, turnProduced: true }, next);
165
+ }
166
+
167
+ function settle(session: ActiveState): Transition {
168
+ if (session.stage === "cleaning_up") return finishCleanup(session);
169
+ if (session.stage !== "investigating" || !session.turnProduced) return unchanged(session);
170
+
171
+ const round = currentRound(session);
172
+ const decision = decideGate({
173
+ hasReproductionSteps: round.reproductionSteps.length > 0,
174
+ hasEvidencePlan: round.plan !== null,
175
+ declaresRuntimeProbe: declaresRuntimeProbe(round),
176
+ liveProbes: liveProbeIds(session, round).length,
177
+ nudges: round.nudges,
178
+ });
179
+
180
+ if (decision.kind === "nudge") {
181
+ const nudges =
182
+ decision.budget === "probes"
183
+ ? { ...round.nudges, probes: round.nudges.probes + 1 }
184
+ : { ...round.nudges, tags: round.nudges.tags + 1 };
185
+ return {
186
+ state: withRound(session, { ...round, nudges }),
187
+ effects: [{ kind: "continue", context: decision.context }],
188
+ };
189
+ }
190
+
191
+ if (decision.kind === "open") {
192
+ return {
193
+ state: withRound({ ...session, stage: "open" }, { ...round, openReason: decision.reason }),
194
+ effects: [
195
+ {
196
+ kind: "notify",
197
+ level: decision.reason === "awaiting_reply" ? "info" : "warning",
198
+ text: describeOpenReason(decision.reason, round.index),
199
+ },
200
+ ],
201
+ };
202
+ }
203
+
204
+ const gated = withRound({ ...session, stage: "awaiting_evidence" }, { ...round, openReason: null });
205
+ return { state: gated, effects: [{ kind: "notify", ...gateNotice(gated, decision.missingPlan) }] };
206
+ }
207
+
208
+ function gateNotice(session: ActiveState, missingPlan: boolean): { level: "info" | "warning"; text: string } {
209
+ const round = currentRound(session);
210
+ if (missingPlan) {
211
+ return {
212
+ level: "warning",
213
+ text: `Debug round ${round.index} paused, but it added no probes and declared no evidence plan — this round cannot produce runtime evidence. Use /debug-proceed to ask for instrumentation.`,
214
+ };
215
+ }
216
+ const pending = pendingRequests(session, round);
217
+ if (pending.length > 0) {
218
+ return {
219
+ level: "info",
220
+ text: `Debug round ${round.index} paused. User evidence requested (${pending.length} pending: ${pending.map(r => r.id).join(", ")}) — attach via /debug-evidence <request-id> <path>, then press Proceed.`,
221
+ };
222
+ }
223
+ return { level: "info", text: `Debug round ${round.index} paused. Reproduce the bug, then press Proceed.` };
224
+ }
225
+
226
+ /** Cleanup may only finish once the ledger is actually empty. */
227
+ function finishCleanup(session: ActiveState): Transition {
228
+ if (!session.turnProduced) return unchanged(session);
229
+ if (session.probes.length > 0 && session.cleanupNudges < 1) {
230
+ return {
231
+ state: { ...session, cleanupNudges: session.cleanupNudges + 1 },
232
+ effects: [{ kind: "continue", context: `${CLEANUP_NUDGE}\nRemaining probes: ${JSON.stringify(session.probes)}` }],
233
+ };
234
+ }
235
+ return {
236
+ state: INACTIVE,
237
+ effects: [{ kind: "teardown", outcome: "finished", probesLeft: session.probes, debugDir: session.debugDir }],
238
+ };
239
+ }
240
+
241
+ function advance(session: ActiveState, event: Extract<DebugEvent, { t: "proceed" }>): Transition {
242
+ const closing = currentRound(session);
243
+ const details = event.details?.trim() ?? "";
244
+ let withObservation: ActiveState = session;
245
+ if (details.length > 0) {
246
+ const reportIds = (closing.plan ?? [])
247
+ .filter(request => request.method === "user_report")
248
+ .map(request => request.id);
249
+ const observation: EvidenceObservation = {
250
+ id: `observation-${event.now.toString(36)}`,
251
+ requestIds: reportIds,
252
+ text: details,
253
+ round: closing.index,
254
+ addedAt: event.now,
255
+ };
256
+ withObservation = { ...session, observations: [...session.observations, observation] };
257
+ }
258
+
259
+ const summary = evidenceSummary(withObservation, closing);
260
+ const next: ActiveState = {
261
+ ...withObservation,
262
+ stage: "investigating",
263
+ turnProduced: false,
264
+ rounds: [...withObservation.rounds, blankRound(closing.index + 1, event.runId)],
265
+ runHistory: [...withObservation.runHistory, event.runId],
266
+ logCounts: { ...withObservation.logCounts, [event.runId]: 0 },
267
+ };
268
+ const label = details.length > 0 ? "proceed with user details" : "proceed";
269
+ return {
270
+ state: next,
271
+ effects: [
272
+ {
273
+ kind: "prompt",
274
+ customType: PROMPT_PROCEED,
275
+ content: buildProceedMessage({
276
+ run: closing.runId ?? "(none)",
277
+ logCount: event.logCount,
278
+ userDetails: details.length > 0 ? details : undefined,
279
+ hypotheses: event.hypotheses,
280
+ evidenceSummary: summary,
281
+ }),
282
+ summary: `${label} — analyzing run ${closing.runId ?? "(none)"} (${event.logCount} entries)`,
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ function startCleanup(session: ActiveState): Transition {
289
+ const next: ActiveState = { ...session, stage: "cleaning_up", turnProduced: false, cleanupNudges: 0 };
290
+ const evidenceJson = JSON.stringify({
291
+ requests: allRequests(session),
292
+ observations: session.observations,
293
+ artifacts: session.artifacts,
294
+ });
295
+ return {
296
+ state: next,
297
+ effects: [
298
+ {
299
+ kind: "prompt",
300
+ customType: PROMPT_FIXED,
301
+ content: buildFixedMessage(JSON.stringify(session.probes), evidenceJson),
302
+ summary: `marked fixed — removing ${session.probes.length} probe(s)`,
303
+ },
304
+ ],
305
+ };
306
+ }
307
+
308
+ function attach(session: ActiveState, event: Extract<DebugEvent, { t: "attach_artifact" }>): Transition {
309
+ if (event.requestId !== null && !allRequests(session).some(request => request.id === event.requestId)) {
310
+ return {
311
+ state: session,
312
+ effects: [
313
+ { kind: "notify", level: "error", text: `debug-mode: unknown evidence request id ${event.requestId}` },
314
+ ],
315
+ };
316
+ }
317
+ const existing = session.artifacts.find(artifact => artifact.path === event.candidate.path);
318
+ const artifact: EvidenceArtifact = existing
319
+ ? { ...existing, ...event.candidate, requestId: event.requestId }
320
+ : { id: `artifact-${event.now.toString(36)}`, requestId: event.requestId, ...event.candidate, addedAt: event.now };
321
+ const artifacts = existing
322
+ ? session.artifacts.map(a => (a.path === artifact.path ? artifact : a))
323
+ : [...session.artifacts, artifact];
324
+ return {
325
+ state: { ...session, artifacts },
326
+ effects: [
327
+ {
328
+ kind: "notify",
329
+ level: "info",
330
+ text: `debug-mode: attached ${artifact.id} → ${artifact.path} (${artifact.size} bytes)`,
331
+ },
332
+ ],
333
+ };
334
+ }
@@ -12,6 +12,18 @@ export const MINIMIZE_USER_INTERVENTION =
12
12
  "NEVER ask the user to run a command you can run yourself. Batch all unavoidable user actions into the fewest reproductions/captures. " +
13
13
  "Do not choose a lower-priority method merely because it is familiar.";
14
14
 
15
+ /**
16
+ * Closing-tag order repeated in the methodology, start prompt, and proceed prompt.
17
+ * Probe edits must land before the round may close; reproduction_steps are user-only.
18
+ */
19
+ export const CLOSE_ROUND_RULES =
20
+ "If any selected method is runtime_probe, write @omp-probe instrumentation into the working tree in THIS turn before closing — probe edits are required and are not a product fix. " +
21
+ `Do not emit <${EVIDENCE_PLAN_TAG}> or <reproduction_steps> for a runtime_probe round until those probes are already on disk. ` +
22
+ "A runtime_probe plan without @omp-probe markers is an incomplete round: keep going and instrument first. " +
23
+ "<reproduction_steps> lists only actions the user performs now (reproduce, capture, restart). " +
24
+ "Never include future agent work such as installing probes, reading logs, or analyzing results. " +
25
+ "agent_inspection, user_report, and user_artifact rounds may close without adding probes.";
26
+
15
27
  export const METHODOLOGY = `\
16
28
  [DEBUG MODE METHODOLOGY — follow strictly]
17
29
  This is OMP Debug Mode. Follow the steps in order. Do not skip them.
@@ -27,8 +39,9 @@ This is OMP Debug Mode. Follow the steps in order. Do not skip them.
27
39
 
28
40
  Every plan entry needs a concrete rationale. A user_report or user_artifact rationale must explicitly name why BOTH autonomous inspection AND model-added probes cannot answer the hypotheses; a generic statement such as "need more information" is invalid.
29
41
 
30
- 3. For runtime_probe rounds, instrument code with probes that test ALL remaining
31
- hypotheses in parallel. Do not apply a product fix in this step. NEVER fix without evidence first. Always rely on runtime logs plus code — never code inspection alone. Unit tests are optional and never replace a user reproduction.
42
+ 3. For runtime_probe rounds, instrument code in THIS turn with probes that test
43
+ ALL remaining hypotheses in parallel. ${CLOSE_ROUND_RULES}
44
+ Do not apply a product fix in this step. NEVER fix without evidence first. Always rely on runtime logs plus code — never code inspection alone. Unit tests are optional and never replace a user reproduction.
32
45
 
33
46
  Probe rules:
34
47
  - Wrap EACH probe in a collapsible region (\`// #region agent log\` /
@@ -47,7 +60,8 @@ This is OMP Debug Mode. Follow the steps in order. Do not skip them.
47
60
  - The extension truncates the current log file at the start of each round.
48
61
  Do not delete, rename, or overwrite that file yourself.
49
62
 
50
- 4. Close the round. Emit exactly one <${EVIDENCE_PLAN_TAG}> block containing a
63
+ 4. Close the round only after step 3 is done for any runtime_probe plan. Emit
64
+ exactly one <${EVIDENCE_PLAN_TAG}> block containing a
51
65
  non-empty JSON array covering EVERY hypothesis:
52
66
  <${EVIDENCE_PLAN_TAG}>
53
67
  [{"id":"E1","hypothesisIds":["A","B"],"method":"runtime_probe","title":"...","rationale":"The disputed runtime branches are not present in existing logs; one model-added probe set can capture both without a separate user artifact.","instructions":["..."],"artifactHint":"optional"}]
@@ -58,8 +72,9 @@ This is OMP Debug Mode. Follow the steps in order. Do not skip them.
58
72
 
59
73
  5. Ask the user to reproduce (or capture/report, per the plan). End your response
60
74
  with a <reproduction_steps> numbered list (no header inside the tag) describing
61
- the single combined reproduction/capture sequence, and this exact sentence
62
- after the tag: "${PROCEED_REMINDER}"
75
+ the single combined reproduction/capture sequence the USER performs now, and
76
+ this exact sentence after the tag: "${PROCEED_REMINDER}"
77
+ ${CLOSE_ROUND_RULES}
63
78
  Never say "click". Never ask the user to reply "done". Remind them to restart
64
79
  the app or service if the instrumented code would otherwise be stale.
65
80
  Then STOP. The user reproduces out-of-band.
@@ -104,9 +119,11 @@ export function buildStartMessage(problem: string, logFile: string): string {
104
119
  MINIMIZE_USER_INTERVENTION +
105
120
  "\n\n" +
106
121
  "Begin round 1: generate 3-5 precise hypotheses, decide an evidence method for each, and do NOT apply a product fix yet. " +
122
+ CLOSE_ROUND_RULES +
123
+ " " +
107
124
  "Call list_debug_evidence whenever you need the request/observation/artifact ledger. " +
108
125
  "Cite evidence as hypothesis ID plus log-line number or attached observation/artifact/report path. " +
109
- `Close with exactly one <${EVIDENCE_PLAN_TAG}> JSON block, then <reproduction_steps> and "${PROCEED_REMINDER}" then STOP.`
126
+ `Then close with exactly one <${EVIDENCE_PLAN_TAG}> JSON block, then <reproduction_steps> and "${PROCEED_REMINDER}" then STOP.`
110
127
  );
111
128
  }
112
129
 
@@ -137,8 +154,11 @@ export function buildProceedMessage(args: {
137
154
  "Call list_debug_evidence first, then read the previous run with get_debug_logs (previous=true). " +
138
155
  "Evaluate each hypothesis CONFIRMED/REJECTED/INCONCLUSIVE citing hypothesis ID plus log-line number or attached observation/artifact/report path. " +
139
156
  "Fix only if a hypothesis is confirmed with 100% confidence; keep all probes in place for a verification reproduce. " +
140
- "If not confident, re-instrument without a speculative patch; after an inconclusive round, reconsider a lower-burden method instead of repeating the same user request. " +
141
- `If a previous fix failed, first revert code changes from rejected hypotheses. End with <${EVIDENCE_PLAN_TAG}>, <reproduction_steps> and "${PROCEED_REMINDER}" then STOP.`
157
+ "If not confident, re-instrument in this turn without a speculative patch; after an inconclusive round, reconsider a lower-burden method instead of repeating the same user request. " +
158
+ "If a previous fix failed, first revert code changes from rejected hypotheses. " +
159
+ CLOSE_ROUND_RULES +
160
+ " " +
161
+ `Then end with <${EVIDENCE_PLAN_TAG}>, <reproduction_steps> and "${PROCEED_REMINDER}" then STOP.`
142
162
  );
143
163
  }
144
164
 
@@ -156,6 +176,12 @@ The user confirmed the fix. Only two things remain:
156
176
  2. Summarize in 1-2 lines: the root cause and the fix that is staying.
157
177
  Do not add probes, form new hypotheses, or ask for another reproduction.`;
158
178
 
179
+ /** Sent back into a cleanup turn that ended with probes still in the code. */
180
+ export const CLEANUP_NUDGE =
181
+ "Cleanup is not finished: the probe ledger below is still not empty. Remove each remaining `@omp-probe` marker " +
182
+ "and its `#region agent log` wrapper from the code, keep the proven fix, then call list_debug_probes to confirm " +
183
+ "the ledger is empty before summarizing.";
184
+
159
185
  export function buildFixedMessage(probesJson: string, evidenceJson = "[]"): string {
160
186
  return (
161
187
  "User marked the problem FIXED.\n" +
package/src/probes.ts CHANGED
@@ -23,15 +23,17 @@ export function probeFileFromInput(input: Record<string, unknown>, cwd: string):
23
23
  return path.resolve(cwd, raw);
24
24
  }
25
25
 
26
- /** Register every `@omp-probe <id>` marker introduced by an edit/write tool call. */
27
- export function recordProbes(probes: Probe[], round: number, input: Record<string, unknown>, cwd: string): void {
26
+ /** Every `@omp-probe <id>` marker an edit/write tool call introduces, deduplicated by id. */
27
+ export function probesInInput(input: Record<string, unknown>, cwd: string, round: number): Probe[] {
28
28
  const file = probeFileFromInput(input, cwd);
29
+ const found: Probe[] = [];
29
30
  for (const [key, value] of Object.entries(input)) {
30
31
  if (PRE_EDIT_KEYS.test(key)) continue;
31
32
  for (const id of probeIdsIn(value)) {
32
- if (!probes.some(p => p.id === id)) probes.push({ id, file, round });
33
+ if (!found.some(p => p.id === id)) found.push({ id, file, round });
33
34
  }
34
35
  }
36
+ return found;
35
37
  }
36
38
 
37
39
  export interface LedgerScan {
@@ -73,13 +75,11 @@ export async function scanLedger(probes: readonly Probe[]): Promise<LedgerScan>
73
75
  }
74
76
 
75
77
  /**
76
- * Rescan and prune the ledger so it mirrors the code on disk. Unreadable files
77
- * keep their probes so a transient read error cannot fake a clean teardown.
78
+ * The ledger as it should be after a rescan. Unreadable files keep their probes
79
+ * so a transient read error cannot fake a clean teardown.
78
80
  */
79
- export async function syncLedger(state: { probes: Probe[] }): Promise<LedgerScan> {
80
- const scan = await scanLedger(state.probes);
81
- state.probes = [...scan.alive, ...scan.unknown];
82
- return scan;
81
+ export function survivingProbes(scan: LedgerScan): Probe[] {
82
+ return [...scan.alive, ...scan.unknown];
83
83
  }
84
84
 
85
85
  export function describeLedger(scan: LedgerScan): string {