@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.
@@ -0,0 +1,169 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { EvidenceArtifact, EvidenceMethod, EvidenceRequest, EvidenceView } from "./state";
4
+
5
+ /** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
6
+ export const MAX_EVIDENCE_REQUESTS = 12;
7
+
8
+ const METHODS: readonly EvidenceMethod[] = ["agent_inspection", "runtime_probe", "user_report", "user_artifact"];
9
+
10
+ export interface EvidencePlanParseResult {
11
+ found: boolean;
12
+ valid: boolean;
13
+ requests: EvidenceRequest[];
14
+ error?: string;
15
+ }
16
+
17
+ function isNonEmptyString(value: unknown): value is string {
18
+ return typeof value === "string" && value.trim().length > 0;
19
+ }
20
+
21
+ function isNonEmptyStringArray(value: unknown): value is string[] {
22
+ return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
23
+ }
24
+
25
+ function hasOnlyUniqueValues(values: readonly string[]): boolean {
26
+ return new Set(values).size === values.length;
27
+ }
28
+
29
+ /**
30
+ * Parse the `<evidence_plan>` block from a model reply. Validation is
31
+ * all-or-nothing: any malformed or invalid entry rejects the whole plan so a
32
+ * partial plan can never silently drop a hypothesis.
33
+ */
34
+ export function parseEvidencePlan(text: string): EvidencePlanParseResult {
35
+ const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
36
+ if (!match) return { found: false, valid: false, requests: [] };
37
+ const fail = (error: string): EvidencePlanParseResult => ({ found: true, valid: false, requests: [], error });
38
+
39
+ let parsed: unknown;
40
+ try {
41
+ parsed = JSON.parse(match[1].trim());
42
+ } catch (error) {
43
+ return fail(`malformed JSON in <evidence_plan>: ${(error as Error).message}`);
44
+ }
45
+ if (!Array.isArray(parsed)) return fail("<evidence_plan> must contain a JSON array");
46
+ if (parsed.length === 0) return fail("<evidence_plan> array must not be empty");
47
+ if (parsed.length > MAX_EVIDENCE_REQUESTS) {
48
+ return fail(`<evidence_plan> has ${parsed.length} entries; at most ${MAX_EVIDENCE_REQUESTS} are allowed`);
49
+ }
50
+
51
+ const requests: EvidenceRequest[] = [];
52
+ const seenIds = new Set<string>();
53
+ for (const entry of parsed) {
54
+ if (typeof entry !== "object" || entry === null) return fail("every evidence plan entry must be an object");
55
+ const record = entry as Record<string, unknown>;
56
+ if (!isNonEmptyString(record.id)) return fail("every evidence plan entry needs a non-empty id");
57
+ if (seenIds.has(record.id)) return fail(`duplicate evidence request id ${record.id}`);
58
+ seenIds.add(record.id);
59
+ if (!isNonEmptyStringArray(record.hypothesisIds) || !hasOnlyUniqueValues(record.hypothesisIds)) {
60
+ return fail(`request ${record.id} needs one or more unique non-empty hypothesisIds`);
61
+ }
62
+ if (!METHODS.includes(record.method as EvidenceMethod)) {
63
+ return fail(`request ${record.id} has unknown method ${JSON.stringify(record.method)}`);
64
+ }
65
+ if (!isNonEmptyString(record.title)) return fail(`request ${record.id} needs a non-empty title`);
66
+ if (!isNonEmptyString(record.rationale)) {
67
+ return fail(`request ${record.id} needs a non-empty rationale explaining why this method is decisive`);
68
+ }
69
+ if (!isNonEmptyStringArray(record.instructions)) {
70
+ return fail(`request ${record.id} needs one or more non-empty instructions`);
71
+ }
72
+ if (record.artifactHint !== undefined && !isNonEmptyString(record.artifactHint)) {
73
+ return fail(`request ${record.id} has an empty artifactHint`);
74
+ }
75
+ requests.push({
76
+ id: record.id,
77
+ hypothesisIds: record.hypothesisIds,
78
+ method: record.method as EvidenceMethod,
79
+ title: record.title,
80
+ rationale: record.rationale,
81
+ instructions: record.instructions,
82
+ ...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
83
+ });
84
+ }
85
+ return { found: true, valid: true, requests };
86
+ }
87
+
88
+ export type ArtifactValidation =
89
+ | { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
90
+ | { ok: false; reason: string };
91
+
92
+ /**
93
+ * Resolve a user-supplied path and capture filesystem metadata only. The file
94
+ * is referenced in place: its contents are never read, copied or deleted here.
95
+ */
96
+ export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
97
+ const trimmed = rawPath.trim();
98
+ if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
99
+ const absolute = path.resolve(cwd, trimmed);
100
+ let stats: fs.Stats;
101
+ try {
102
+ stats = fs.statSync(absolute);
103
+ } catch (error) {
104
+ return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
105
+ }
106
+ if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
107
+ try {
108
+ fs.accessSync(absolute, fs.constants.R_OK);
109
+ } catch {
110
+ return { ok: false, reason: `${absolute} is not readable` };
111
+ }
112
+ return {
113
+ ok: true,
114
+ artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Human-readable evidence description for the blackboard and the model-facing
120
+ * tool. Availability is re-checked on every call: a missing or unreadable file
121
+ * is never treated as captured evidence.
122
+ */
123
+ export function describeEvidence(view: EvidenceView): string {
124
+ const { requests, artifacts, observations } = view;
125
+ if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
126
+ return "(none)";
127
+ }
128
+ const lines: string[] = [];
129
+ for (const request of requests) {
130
+ const linked = artifacts.filter(artifact => artifact.requestId === request.id);
131
+ const reports = observations.filter(observation => observation.requestIds.includes(request.id));
132
+ const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
133
+ const coverage =
134
+ request.method === "user_artifact"
135
+ ? linked.length > 0
136
+ ? "artifact attached"
137
+ : "PENDING artifact"
138
+ : request.method === "user_report"
139
+ ? reports.length > 0
140
+ ? "report submitted"
141
+ : "PENDING report"
142
+ : "agent-collected";
143
+ lines.push(
144
+ `- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
145
+ );
146
+ }
147
+ for (const observation of observations) {
148
+ lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
149
+ }
150
+ for (const artifact of artifacts) {
151
+ let stats: fs.Stats;
152
+ try {
153
+ stats = fs.statSync(artifact.path);
154
+ fs.accessSync(artifact.path, fs.constants.R_OK);
155
+ } catch {
156
+ lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
157
+ continue;
158
+ }
159
+ if (!stats.isFile()) {
160
+ lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
161
+ continue;
162
+ }
163
+ const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
164
+ lines.push(
165
+ `- ${artifact.id} [artifact] ${artifact.path} — available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
166
+ );
167
+ }
168
+ return lines.join("\n");
169
+ }
package/src/gate.ts CHANGED
@@ -1,36 +1,83 @@
1
- import { 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 produce reproduction steps. */
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
- "You instrumented this round but did not end with a <reproduction_steps> block. " +
8
- "List the numbered steps the user must perform to exercise the instrumented path, " +
9
- `follow them with "${PROCEED_REMINDER}", and then stop. Do not start new work.`;
9
+ "This round did not close properly. It must end with BOTH a <evidence_plan> JSON block " +
10
+ '({"id","hypothesisIds","method","title","rationale","instructions", optional "artifactHint"}; methods: ' +
11
+ "agent_inspection, runtime_probe, user_report, user_artifact) AND a <reproduction_steps> numbered list " +
12
+ `followed by the exact sentence "${PROCEED_REMINDER}", then stop. ` +
13
+ MINIMIZE_USER_INTERVENTION +
14
+ " Every entry needs a concrete rationale; a user_report/user_artifact rationale must name why BOTH " +
15
+ "autonomous inspection and model-added probes cannot answer its hypotheses. " +
16
+ "A user_artifact request must state the file type, path/capture instructions, how you will inspect the file, " +
17
+ "and why inspection/probes are inadequate. Do not start new work.";
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;
10
24
 
11
25
  export type GateDecision =
12
- /** Hand control to the user and wait for a reproduction. */
13
- | { kind: "gate"; missingSteps: boolean }
26
+ /** Hand control to the user and wait for a reproduction/capture. */
27
+ | { kind: "gate"; missingPlan: boolean }
14
28
  /** Let the agent finish the round properly before gating. */
15
- | { kind: "nudge"; context: string }
16
- /** Not a completed round the agent is mid-conversation with the user. */
17
- | { kind: "stay" };
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
+ }
18
42
 
19
43
  /**
20
- * Decide what a settled agent turn means for the reproduction gate.
44
+ * Decide what a settled agent turn means.
21
45
  *
22
- * Cursor gates on the `<reproduction_steps>` block, so a turn without one is
23
- * either a clarifying question (leave the user talking to the agent) or an
24
- * instrumented round that forgot to close itself (nudge once, then gate anyway
25
- * so the workflow can never stall).
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.
26
50
  */
27
- export function decideGate(args: {
28
- hasReproductionSteps: boolean;
29
- probesThisRound: number;
30
- nudgesUsed: number;
31
- }): GateDecision {
32
- if (args.hasReproductionSteps) return { kind: "gate", missingSteps: false };
33
- if (args.probesThisRound === 0) return { kind: "stay" };
34
- if (args.nudgesUsed < MAX_GATE_NUDGES) return { kind: "nudge", context: GATE_NUDGE };
35
- return { kind: "gate", missingSteps: true };
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 };
60
+ }
61
+ // Legacy/no-probe closure: reproduction steps alone still gate the turn.
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.`;
81
+ }
82
+ return `Debug round ${round} is still open — the agent is waiting on your reply, not on a reproduction.`;
36
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
+ }