@siuver/omp-debug-mode 0.1.4 → 0.1.6

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/state.ts CHANGED
@@ -4,7 +4,14 @@ export const DEBUG_ENTRY = "com.omp.debug-mode.state";
4
4
  /** Custom message that carries the blackboard + methodology into the model. */
5
5
  export const DEBUG_CONTEXT_TYPE = "debug-mode-context";
6
6
 
7
- export type Phase = "idle" | "round" | "waiting" | "cleanup";
7
+ /**
8
+ * Whose move it is, and nothing else. There is exactly one user stage: a turn
9
+ * the agent has handed back is the user's regardless of how well the agent
10
+ * closed the round, so command legality never depends on protocol compliance.
11
+ */
12
+ export type Stage = "investigating" | "user_turn" | "cleaning_up";
13
+
14
+ export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
8
15
 
9
16
  export interface Probe {
10
17
  id: string;
@@ -12,8 +19,6 @@ export interface Probe {
12
19
  round: number;
13
20
  }
14
21
 
15
- export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
16
-
17
22
  export interface EvidenceRequest {
18
23
  /** Model-authored request identifier, unique within a plan. */
19
24
  id: string;
@@ -27,7 +32,6 @@ export interface EvidenceRequest {
27
32
  instructions: string[];
28
33
  /** Expected file kind when method is user_artifact. */
29
34
  artifactHint?: string;
30
- round: number;
31
35
  }
32
36
 
33
37
  export interface EvidenceArtifact {
@@ -49,47 +53,186 @@ export interface EvidenceObservation {
49
53
  addedAt: number;
50
54
  }
51
55
 
52
- export interface DebugState {
53
- active: boolean;
54
- phase: Phase;
55
- problem: string;
56
- round: number;
56
+ /**
57
+ * How the agent handed the turn back. This is wording and guidance only: every
58
+ * mode is the same `user_turn` stage and allows the same commands, so a mode
59
+ * the agent got wrong can never make a legitimate command illegal.
60
+ */
61
+ export type HandoffMode = "reproduce" | "capture" | "question" | "incomplete";
62
+
63
+ /**
64
+ * One evidence round. Round membership is structural: everything scoped to a
65
+ * round lives here instead of being filtered out of a session-wide array by a
66
+ * mutable counter, so starting a round cannot forget to reset a field.
67
+ */
68
+ export interface Round {
69
+ index: number;
57
70
  runId: string | null;
71
+ /** Parsed `<evidence_plan>`; null until a valid plan arrives. */
72
+ plan: EvidenceRequest[] | null;
73
+ reproductionSteps: string[];
74
+ /** Ledger ids of probes introduced while this round was investigating. */
75
+ probeIds: string[];
76
+ /** Separate budgets: a probe nudge must not consume the closing one. */
77
+ nudges: { handoff: number; probes: number };
78
+ /**
79
+ * A reminder went out and no tool call has landed since. One reminder per
80
+ * round of visible progress: a model that answers every reminder with more
81
+ * prose would otherwise spend the whole budget and delay the user by three
82
+ * turns to reach the same handoff.
83
+ */
84
+ awaitingProgress: boolean;
85
+ /** Set while the round sits with the user; null while the agent has the turn. */
86
+ handoff: HandoffMode | null;
87
+ }
88
+
89
+ export interface DebugSession {
90
+ stage: Stage;
91
+ problem: string;
92
+ debugDir: string | null;
93
+ /** Every round in order; the last entry is the current one. */
94
+ rounds: Round[];
95
+ /** Session-wide probe ledger; a probe outlives the round that created it. */
96
+ probes: Probe[];
58
97
  /** Every run in creation order; the last entry is always the active run. */
59
98
  runHistory: string[];
60
- probes: Probe[];
61
- debugDir: string | null;
62
99
  logCounts: Record<string, number>;
63
- hasRoundContent: boolean;
64
- cleanupReady: boolean;
65
- reproductionSteps: string[];
66
- evidenceRequests: EvidenceRequest[];
67
- evidenceArtifacts: EvidenceArtifact[];
68
- evidenceObservations: EvidenceObservation[];
69
- gateNudges: number;
100
+ artifacts: EvidenceArtifact[];
101
+ observations: EvidenceObservation[];
102
+ /** An assistant message landed during the current agent turn. */
103
+ turnProduced: boolean;
104
+ /** How often cleanup was sent back to finish removing probes. */
105
+ cleanupNudges: number;
70
106
  }
71
107
 
72
- export function freshState(): DebugState {
108
+ /**
109
+ * `active` is the discriminant, so the cheap `if (!state.active) return` guard
110
+ * in hot handlers doubles as the type narrowing and there is no second source
111
+ * of truth for "is debug mode running".
112
+ */
113
+ export type DebugState = { active: false } | ({ active: true } & DebugSession);
114
+
115
+ export const INACTIVE: DebugState = { active: false };
116
+
117
+ export function blankRound(index: number, runId: string | null): Round {
73
118
  return {
74
- active: false,
75
- phase: "idle",
76
- problem: "",
77
- round: 0,
78
- runId: null,
79
- runHistory: [],
80
- probes: [],
81
- debugDir: null,
82
- logCounts: {},
83
- hasRoundContent: false,
84
- cleanupReady: false,
119
+ index,
120
+ runId,
121
+ plan: null,
85
122
  reproductionSteps: [],
86
- gateNudges: 0,
87
- evidenceRequests: [],
88
- evidenceArtifacts: [],
89
- evidenceObservations: [],
123
+ probeIds: [],
124
+ nudges: { handoff: 0, probes: 0 },
125
+ awaitingProgress: false,
126
+ handoff: null,
127
+ };
128
+ }
129
+
130
+ export function freshSession(problem: string, debugDir: string | null, runId: string | null): DebugSession {
131
+ return {
132
+ stage: "investigating",
133
+ problem,
134
+ debugDir,
135
+ rounds: [blankRound(1, runId)],
136
+ probes: [],
137
+ runHistory: runId ? [runId] : [],
138
+ logCounts: runId ? { [runId]: 0 } : {},
139
+ artifacts: [],
140
+ observations: [],
141
+ turnProduced: false,
142
+ cleanupNudges: 0,
143
+ };
144
+ }
145
+
146
+ export function currentRound(session: DebugSession): Round {
147
+ return session.rounds[session.rounds.length - 1] ?? blankRound(1, null);
148
+ }
149
+
150
+ export function activeRunId(session: DebugSession): string | null {
151
+ return currentRound(session).runId;
152
+ }
153
+
154
+ export function logFileFor(session: DebugSession, run: string | null = activeRunId(session)): string | null {
155
+ return resolveRunLogFile(session.debugDir, run, activeRunId(session));
156
+ }
157
+
158
+ /**
159
+ * Every probe the ledger still finds on disk, session-wide. Instrumentation
160
+ * deliberately outlives the round that installed it — the methodology keeps
161
+ * probes in place across a fix so the verification round can reuse them — so a
162
+ * round-scoped count would report "nothing instrumented" on exactly the rounds
163
+ * that are best instrumented.
164
+ */
165
+ export function liveProbeCount(session: DebugSession): number {
166
+ return session.probes.length;
167
+ }
168
+
169
+ /** Probes this round introduced that are still in the ledger (i.e. still on disk). */
170
+ export function roundProbeIds(session: DebugSession, round: Round = currentRound(session)): string[] {
171
+ return round.probeIds.filter(id => session.probes.some(probe => probe.id === id));
172
+ }
173
+
174
+ export function declaresRuntimeProbe(round: Round): boolean {
175
+ return (round.plan ?? []).some(request => request.method === "runtime_probe");
176
+ }
177
+
178
+ /** The plan asks the user to report or capture something themselves. */
179
+ export function needsUserCapture(round: Round): boolean {
180
+ return (round.plan ?? []).some(
181
+ request => request.method === "user_report" || request.method === "user_artifact",
182
+ );
183
+ }
184
+
185
+ /** Requests whose method is satisfied by the agent alone, so they never gate on the user. */
186
+ export function hasNonProbeEvidence(round: Round): boolean {
187
+ return (round.plan ?? []).some(request => request.method !== "runtime_probe");
188
+ }
189
+
190
+ /**
191
+ * Round requests that still need something from the user: a `user_artifact`
192
+ * request until a linked artifact exists, a `user_report` request until an
193
+ * observation names it. Agent-collected methods never create user work.
194
+ */
195
+ export function pendingRequests(session: DebugSession, round: Round = currentRound(session)): EvidenceRequest[] {
196
+ return (round.plan ?? []).filter(request => {
197
+ if (request.method === "user_artifact") {
198
+ return !session.artifacts.some(artifact => artifact.requestId === request.id);
199
+ }
200
+ if (request.method === "user_report") {
201
+ return !session.observations.some(observation => observation.requestIds.includes(request.id));
202
+ }
203
+ return false;
204
+ });
205
+ }
206
+
207
+ /** Every request the session has ever declared, newest round last. */
208
+ export function allRequests(session: DebugSession): EvidenceRequest[] {
209
+ return session.rounds.flatMap(round => round.plan ?? []);
210
+ }
211
+
212
+ /** Decoupled evidence projection so ledger formatting never depends on the state shape. */
213
+ export interface EvidenceView {
214
+ requests: readonly EvidenceRequest[];
215
+ observations: readonly EvidenceObservation[];
216
+ artifacts: readonly EvidenceArtifact[];
217
+ }
218
+
219
+ export function evidenceView(session: DebugSession, round?: Round | null): EvidenceView {
220
+ return {
221
+ requests: round === undefined ? allRequests(session) : (round?.plan ?? []),
222
+ observations: session.observations,
223
+ artifacts: session.artifacts,
90
224
  };
91
225
  }
92
226
 
227
+ /** One-line evidence status used by messages, the gate and the UI. */
228
+ export function evidenceSummary(session: DebugSession, round: Round = currentRound(session)): string {
229
+ const requests = round.plan ?? [];
230
+ if (requests.length === 0) return "no evidence requests this round";
231
+ const pending = pendingRequests(session, round);
232
+ const methods = requests.map(request => `${request.id}:${request.method}`).join(", ");
233
+ return `${requests.length} request(s) [${methods}], ${pending.length} pending user action(s), ${session.artifacts.length} artifact(s), ${session.observations.length} observation(s)`;
234
+ }
235
+
93
236
  /** Order run ids by round number, then by the base36 creation stamp. */
94
237
  export function compareRunIds(a: string, b: string): number {
95
238
  const parse = (id: string): [number, string] => {
@@ -144,88 +287,253 @@ export function resolveRun(
144
287
  return { run: currentRun, note: null };
145
288
  }
146
289
 
147
- export function logFileFor(s: DebugState, run = s.runId): string | null {
148
- return resolveRunLogFile(s.debugDir, run, s.runId);
149
- }
150
-
151
290
  /**
152
- * Keep only the newest copy of a custom-type injection. `before_agent_start`
153
- * re-injects a fresh blackboard every turn; `context` runs afterwards on the
154
- * combined history, so deleting every match would drop the copy that was just
155
- * added and the model would never see it.
291
+ * Leave the context holding exactly one, current copy of a custom-type
292
+ * injection: drop every stale copy and rewrite the survivor's content.
293
+ *
294
+ * Both halves are load-bearing. `before_agent_start` appends a fresh copy every
295
+ * prompt, so keeping all of them would grow the context without bound — but it
296
+ * does not fire at all when the host resumes the agent loop itself, so a turn
297
+ * started by a reminder or a nudge would otherwise read whatever the previous
298
+ * turn was told. Rewriting here, where the request is actually assembled, is
299
+ * the only point that no path into the model can skip.
300
+ *
301
+ * Returns null when the context already says the right thing, so an unchanged
302
+ * request is passed through untouched.
156
303
  */
157
- export function keepLatestCustomType<M extends { role?: string; customType?: string }>(
304
+ export function syncCustomType<M extends { role?: string; customType?: string; content?: unknown }>(
158
305
  messages: readonly M[],
159
306
  customType: string,
160
- ): M[] {
307
+ content: string | null,
308
+ ): M[] | null {
309
+ const isCopy = (message: M | undefined): boolean =>
310
+ message?.role === "custom" && message.customType === customType;
161
311
  let last = -1;
312
+ let copies = 0;
313
+ for (let i = 0; i < messages.length; i++) {
314
+ if (!isCopy(messages[i])) continue;
315
+ last = i;
316
+ copies++;
317
+ }
318
+ if (last < 0) return null;
319
+ const rewrite = content !== null && messages[last]?.content !== content;
320
+ if (copies === 1 && !rewrite) return null;
321
+ const kept: M[] = [];
162
322
  for (let i = 0; i < messages.length; i++) {
163
- const message = messages[i];
164
- if (message?.role === "custom" && message.customType === customType) last = i;
323
+ const message = messages[i] as M;
324
+ if (!isCopy(message)) {
325
+ kept.push(message);
326
+ continue;
327
+ }
328
+ if (i !== last) continue;
329
+ kept.push(rewrite ? ({ ...message, content } as M) : message);
165
330
  }
166
- if (last < 0) return [...messages];
167
- return messages.filter((message, index) => !(message.role === "custom" && message.customType === customType && index !== last));
331
+ return kept;
168
332
  }
169
333
 
334
+ const STAGES: readonly Stage[] = ["investigating", "user_turn", "cleaning_up"];
335
+ const HANDOFF_MODES: readonly HandoffMode[] = ["reproduce", "capture", "question", "incomplete"];
336
+
337
+ /** Pre-`user_turn` stage names and the handoff each one implied. */
338
+ const LEGACY_STAGES: Readonly<Record<string, { stage: Stage; handoff: HandoffMode | null }>> = {
339
+ open: { stage: "user_turn", handoff: null },
340
+ awaiting_evidence: { stage: "user_turn", handoff: "reproduce" },
341
+ };
342
+
343
+ /** Pre-`handoff` reasons, mapped onto the mode that carries the same guidance. */
344
+ const LEGACY_HANDOFFS: Readonly<Record<string, HandoffMode>> = {
345
+ awaiting_reply: "question",
346
+ probes_missing: "incomplete",
347
+ unclosed: "incomplete",
348
+ };
349
+
350
+ function asArray<T>(value: unknown): T[] {
351
+ return Array.isArray(value) ? (value as T[]) : [];
352
+ }
170
353
 
171
- /** Replace the evidence requests that belong to one round. */
172
- export function replaceRoundEvidenceRequests(
173
- state: DebugState,
174
- round: number,
175
- requests: EvidenceRequest[],
176
- ): DebugState {
354
+ function asRecord(value: unknown): Record<string, unknown> {
355
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
356
+ }
357
+
358
+ function reviveHandoff(raw: unknown): HandoffMode | null {
359
+ if (typeof raw !== "string") return null;
360
+ if (HANDOFF_MODES.includes(raw as HandoffMode)) return raw as HandoffMode;
361
+ return LEGACY_HANDOFFS[raw] ?? null;
362
+ }
363
+
364
+ function reviveRound(raw: unknown, fallbackIndex: number): Round {
365
+ const record = asRecord(raw);
366
+ const nudges = asRecord(record.nudges);
177
367
  return {
178
- ...state,
179
- evidenceRequests: state.evidenceRequests.filter(request => request.round !== round).concat(requests),
368
+ index: typeof record.index === "number" ? record.index : fallbackIndex,
369
+ runId: typeof record.runId === "string" ? record.runId : null,
370
+ plan: Array.isArray(record.plan) ? (record.plan as EvidenceRequest[]) : null,
371
+ reproductionSteps: asArray<string>(record.reproductionSteps),
372
+ probeIds: asArray<string>(record.probeIds),
373
+ nudges: {
374
+ // `tags` is the pre-tool budget name, when prose was still a closure.
375
+ handoff: numberOr(nudges.handoff, numberOr(nudges.tags, 0)),
376
+ probes: numberOr(nudges.probes, 0),
377
+ },
378
+ // Never restored: no agent turn survives a restart, so nothing is pending.
379
+ awaitingProgress: false,
380
+ handoff: reviveHandoff(record.handoff ?? record.openReason),
180
381
  };
181
382
  }
182
383
 
384
+ function numberOr(value: unknown, fallback: number): number {
385
+ return typeof value === "number" ? value : fallback;
386
+ }
387
+
183
388
  /**
184
- * Round requests that still need something from the user: a `user_artifact`
185
- * request until a linked artifact exists, a `user_report` request until an
186
- * observation names it. Agent-collected methods never create user work.
389
+ * Rebuild persisted state, tolerating the pre-`user_turn` stage names and the
390
+ * pre-`rounds` layout so a session that was mid-debug across an upgrade keeps
391
+ * working. Anything unrecognised including a persisted `investigating`, since
392
+ * no agent turn survives a restore — resolves to `user_turn`, because the user
393
+ * must always be able to act on a restored investigation.
187
394
  */
188
- export function pendingEvidenceRequests(state: DebugState, round = state.round): EvidenceRequest[] {
189
- return state.evidenceRequests.filter(request => {
190
- if (request.round !== round) return false;
191
- if (request.method === "user_artifact") {
192
- return !state.evidenceArtifacts.some(artifact => artifact.requestId === request.id);
193
- }
194
- if (request.method === "user_report") {
195
- return !state.evidenceObservations.some(observation => observation.requestIds.includes(request.id));
196
- }
197
- return false;
198
- });
395
+ export function reviveState(data: unknown): DebugState {
396
+ const record = asRecord(data);
397
+ if (record.active !== true) return INACTIVE;
398
+ const problem = typeof record.problem === "string" ? record.problem : "";
399
+ const debugDir = typeof record.debugDir === "string" ? record.debugDir : null;
400
+ const probes = asArray<Probe>(record.probes);
401
+ const logCounts = asRecord(record.logCounts) as Record<string, number>;
402
+ const restored = restoreStage(record);
403
+
404
+ const rounds = Array.isArray(record.rounds)
405
+ ? record.rounds.map((round, i) => reviveRound(round, i + 1))
406
+ : [legacyRound(record, probes)];
407
+ const resolved = rounds.length > 0 ? rounds : [blankRound(1, null)];
408
+ const last = resolved[resolved.length - 1] as Round;
409
+ if (restored.stage === "user_turn" && last.handoff === null) {
410
+ resolved[resolved.length - 1] = { ...last, handoff: restored.handoff ?? "incomplete" };
411
+ }
412
+
413
+ return {
414
+ active: true,
415
+ stage: restored.stage,
416
+ problem,
417
+ debugDir,
418
+ rounds: resolved,
419
+ probes,
420
+ runHistory: asArray<string>(record.runHistory),
421
+ logCounts,
422
+ artifacts: asArray<EvidenceArtifact>(record.artifacts ?? record.evidenceArtifacts),
423
+ observations: asArray<EvidenceObservation>(record.observations ?? record.evidenceObservations),
424
+ turnProduced: false,
425
+ cleanupNudges: typeof record.cleanupNudges === "number" ? record.cleanupNudges : 0,
426
+ };
199
427
  }
200
428
 
201
- /** One-line evidence status used by messages, the gate and the UI. */
202
- export function evidenceSummary(state: DebugState, round = state.round): string {
203
- const requests = state.evidenceRequests.filter(request => request.round === round);
204
- if (requests.length === 0) return "no evidence requests this round";
205
- const pending = pendingEvidenceRequests(state, round);
206
- const methods = requests.map(request => `${request.id}:${request.method}`).join(", ");
207
- return `${requests.length} request(s) [${methods}], ${pending.length} pending user action(s), ${state.evidenceArtifacts.length} artifact(s), ${state.evidenceObservations.length} observation(s)`;
429
+ function restoreStage(record: Record<string, unknown>): { stage: Stage; handoff: HandoffMode | null } {
430
+ const raw = typeof record.stage === "string" ? record.stage : legacyPhase(record.phase);
431
+ const legacy = LEGACY_STAGES[raw];
432
+ if (legacy) return legacy;
433
+ // A persisted `investigating` cannot be resumed: no agent turn survives a
434
+ // restore, so honouring it would strand the user with every command refused.
435
+ if (raw !== "investigating" && STAGES.includes(raw as Stage)) return { stage: raw as Stage, handoff: null };
436
+ return { stage: "user_turn", handoff: null };
208
437
  }
209
438
 
210
- export function blackboard(s: DebugState, evidenceDescription = "(none)"): string {
211
- const probes = s.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
212
- const counts = Object.entries(s.logCounts)
439
+ /** Collapse the original `phase` field onto the stage name it became. */
440
+ function legacyPhase(phase: unknown): string {
441
+ if (phase === "waiting") return "awaiting_evidence";
442
+ if (phase === "cleanup") return "cleaning_up";
443
+ return "open";
444
+ }
445
+
446
+ /** Collapse the pre-`rounds` fields into the single round they described. */
447
+ function legacyRound(record: Record<string, unknown>, probes: readonly Probe[]): Round {
448
+ const index = typeof record.round === "number" && record.round > 0 ? record.round : 1;
449
+ const requests = asArray<EvidenceRequest & { round?: number }>(record.evidenceRequests).filter(
450
+ request => request.round === undefined || request.round === index,
451
+ );
452
+ return {
453
+ index,
454
+ runId: typeof record.runId === "string" ? record.runId : null,
455
+ plan: requests.length > 0 ? requests.map(({ round: _round, ...rest }) => rest) : null,
456
+ reproductionSteps: asArray<string>(record.reproductionSteps),
457
+ probeIds: probes.filter(probe => probe.round === index).map(probe => probe.id),
458
+ nudges: { handoff: numberOr(record.gateNudges, 0), probes: 0 },
459
+ awaitingProgress: false,
460
+ handoff: null,
461
+ };
462
+ }
463
+
464
+ /** The closing requirements of the current round, as observed facts. */
465
+ export function roundFacts(session: DebugSession, round: Round = currentRound(session)): string {
466
+ const plan = round.plan ? `${round.plan.length} request(s)` : "MISSING";
467
+ const steps = round.reproductionSteps.length > 0 ? `${round.reproductionSteps.length} step(s)` : "MISSING";
468
+ return `evidence plan: ${plan}, reproduction steps: ${steps}, live probes: ${liveProbeCount(session)}`;
469
+ }
470
+
471
+ /**
472
+ * Whose turn it is and what closing the round still requires, stated to the
473
+ * model. The model drives these transitions, so leaving the stage out is what
474
+ * lets it announce a reproduction while the round is still its own.
475
+ */
476
+ export function stageBriefing(session: DebugSession): string {
477
+ const round = currentRound(session);
478
+ const facts = `Round ${round.index} facts — ${roundFacts(session, round)}.`;
479
+ if (session.stage === "cleaning_up") {
480
+ return `Turn owner: AGENT (cleanup). Remove every probe, verify the ledger is empty, then summarize. ${facts}`;
481
+ }
482
+ if (session.stage === "user_turn") {
483
+ // Written to be true whichever way this turn started. The context is
484
+ // assembled before a user message is announced, so this text cannot know
485
+ // which case it is in — but the model can see its own transcript, so the
486
+ // test it is given is one it can actually apply.
487
+ //
488
+ // Three triggers land here, and only the first one is the user's: a reply,
489
+ // the request that follows the `hand_off_to_user` call in the same turn
490
+ // (a tool call never ends a turn, so this one is unavoidable), and a host
491
+ // continuation. The last two need the same answer, so they are described
492
+ // together rather than as a list of causes to match against.
493
+ return (
494
+ `Turn owner: USER. Round ${round.index} was handed back as "${round.handoff ?? "incomplete"}" and is NOT closed. ${facts} ` +
495
+ "The user has not run /debug-proceed. Check what started this turn before you do anything else. " +
496
+ "If a user message is part of it, the round is yours again: answer it, then call hand_off_to_user to " +
497
+ "re-close the round — never assume the earlier handoff still stands. " +
498
+ "If nothing in this turn came from the user, nothing has changed since the handoff: you are either " +
499
+ "finishing the turn in which you just called hand_off_to_user, or you were resumed by a reminder or " +
500
+ "another automatic continuation. Either way the user has not acted, no reproduction has run, and no new " +
501
+ "observation exists. Do not read logs, do not analyze, do not resume the plan. Write one short line " +
502
+ "addressed to the user — what to do now, and that /debug-proceed comes after they reproduce (or " +
503
+ "/debug-done if the bug is already fixed) — then end the turn. Never report " +
504
+ "that you were reminded and never restate this briefing back to them."
505
+ );
506
+ }
507
+ return (
508
+ `Turn owner: AGENT. Round ${round.index} is yours and you must close it before you stop. ${facts} ` +
509
+ "Close it by calling hand_off_to_user: that call is what moves the session into the user's hands. " +
510
+ "Prose telling the user to reproduce does not change the state, and a turn that stops without that call is " +
511
+ "sent straight back to you to make it."
512
+ );
513
+ }
514
+
515
+ export function blackboard(session: DebugSession, evidenceDescription = "(none)"): string {
516
+ const round = currentRound(session);
517
+ const probes = session.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
518
+ const counts = Object.entries(session.logCounts)
213
519
  .map(([run, n]) => `${run}: ${n}`)
214
520
  .join(", ") || "(none yet)";
215
521
  return `\
216
- [DEBUG MODE ACTIVE — round ${s.round}]
522
+ [DEBUG MODE ACTIVE — round ${round.index} · stage ${session.stage}]
523
+
524
+ ${stageBriefing(session)}
217
525
 
218
526
  Problem under investigation:
219
- ${s.problem}
527
+ ${session.problem}
220
528
 
221
529
  Deployed probes (ground truth, maintained by the extension):
222
530
  ${probes}
223
531
 
224
- Current run log file (absolute path): ${logFileFor(s) ?? "(not initialized)"}
532
+ Current run log file (absolute path): ${logFileFor(session) ?? "(not initialized)"}
225
533
  Runtime probes MUST append JSONL directly to that exact file using the target environment's native file APIs.
226
534
  Console output such as Unity Debug.Log may supplement diagnostics but is never the runtime evidence for this workflow. Never ask the user to transcribe console output.
227
535
  Logs by run: ${counts}
228
- Current run id: ${s.runId ?? "(not started)"}
536
+ Current run id: ${round.runId ?? "(not started)"}
229
537
 
230
538
  Evidence method priority (MINIMIZE USER INTERVENTION):
231
539
  1. agent_inspection — reuse existing logs/files and agent tools