@siuver/omp-debug-mode 0.1.5 → 0.1.7

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/gate.ts CHANGED
@@ -1,83 +1,147 @@
1
- import { CLOSE_ROUND_RULES, MINIMIZE_USER_INTERVENTION, PROCEED_REMINDER } from "./methodology";
2
- import type { OpenReason } from "./state";
1
+ import { CLOSE_ROUND_RULES, HANDOFF_TOOL } from "./methodology";
2
+ import type { HandoffMode } from "./state";
3
3
 
4
- /** Per-reason nudge budgets: a probe nudge must not spend the closing-tag one. */
5
- export const MAX_TAG_NUDGES = 1;
4
+ /**
5
+ * Per-reason nudge budgets: a probe nudge must not spend the closing one. Three
6
+ * matches the host's own cap for this exact failure (a turn that ended without
7
+ * its required tool call) and stays well under the host's continuation cap. The
8
+ * budget is only reachable by a model that keeps making tool calls between
9
+ * reminders — {@link GateFacts.awaitingProgress} stops a purely conversational
10
+ * turn from spending more than one.
11
+ */
12
+ export const MAX_HANDOFF_NUDGES = 3;
6
13
  export const MAX_PROBE_NUDGES = 1;
7
14
 
8
- export const GATE_NUDGE =
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.";
15
+ /**
16
+ * Reminders are shaped like the host's own `<system-reminder>` prompts: one
17
+ * instruction, numbered choices, and an explicit ban on answering in prose. The
18
+ * blackboard already carries the full methodology on every turn, so restating
19
+ * it here only buries the single action being asked for.
20
+ */
21
+ function reminder(body: string): string {
22
+ return `<system-reminder>\n${body}\n\nYou NEVER answer this reminder with plain text.\n</system-reminder>`;
23
+ }
24
+
25
+ /**
26
+ * Sent back into a turn that stopped without the closing tool call. `wroteProse`
27
+ * marks the common near-miss: the round already describes the user's work, so
28
+ * the reminder asks for the same values as arguments instead of new content.
29
+ */
30
+ export function handoffNudge(round: number, wroteProse: boolean): string {
31
+ const prose = wroteProse
32
+ ? "You already described the steps and/or the plan in prose. Prose does not close a round: pass those same " +
33
+ "values as the `steps` and `plan` arguments, unchanged. "
34
+ : "";
35
+ return reminder(
36
+ `Debug round ${round} ended without the required ${HANDOFF_TOOL} call.\n\n` +
37
+ `${prose}Call ${HANDOFF_TOOL} now with exactly one mode:\n` +
38
+ '1. mode "reproduce" — the user must run the app so the probes record. Pass steps and plan.\n' +
39
+ '2. mode "capture" — the user only supplies a report or a file. Pass steps and plan.\n' +
40
+ '3. mode "question" — you need an answer before you can plan. steps and plan are optional.\n\n' +
41
+ "That call is the only thing that hands the session to the user: it renders the steps in their widget and " +
42
+ "makes /debug-proceed available. A rejected call names exactly what to fix and the round stays yours. Do not start new work.",
43
+ );
44
+ }
18
45
 
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;
46
+ export const PROBE_NUDGE = reminder(
47
+ "Your evidence plan selects runtime_probe, but the probe ledger is empty — no @omp-probe marker exists anywhere " +
48
+ "in the working tree. Instrument the code now with edit/write — that is required work for this round, not a " +
49
+ `product fix and only then call ${HANDOFF_TOOL}.\n\n${CLOSE_ROUND_RULES}`,
50
+ );
24
51
 
25
52
  export type GateDecision =
26
- /** Hand control to the user and wait for a reproduction/capture. */
27
- | { kind: "gate"; missingPlan: boolean }
28
- /** Let the agent finish the round properly before gating. */
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 };
53
+ /**
54
+ * Hand the turn to the user. `mode` is guidance for wording only: every mode
55
+ * is the same user stage and allows the same commands, so a misjudged mode
56
+ * can never make a legitimate command illegal.
57
+ */
58
+ | { kind: "handoff"; mode: HandoffMode; missingPlan: boolean }
59
+ /** Let the agent finish the round properly before handing it over. */
60
+ | { kind: "nudge"; budget: "handoff" | "probes"; context: string };
32
61
 
33
62
  export interface GateFacts {
63
+ /** Round index, so a reminder can name the round it is about. */
64
+ round: number;
34
65
  hasReproductionSteps: boolean;
35
66
  hasEvidencePlan: boolean;
36
67
  /** The plan selects runtime_probe for at least one hypothesis. */
37
68
  declaresRuntimeProbe: boolean;
38
- /** Probes this round introduced that the ledger still finds on disk. */
69
+ /** The plan asks the user to report or capture something themselves. */
70
+ needsUserCapture: boolean;
71
+ /** Probes the ledger still finds on disk, session-wide. */
39
72
  liveProbes: number;
40
- nudges: { tags: number; probes: number };
73
+ /** Probes this round introduced that are still on disk. */
74
+ probesAddedThisRound: number;
75
+ nudges: { handoff: number; probes: number };
76
+ /** A reminder already went out and the agent has not used a tool since. */
77
+ awaitingProgress: boolean;
41
78
  }
42
79
 
43
80
  /**
44
81
  * Decide what a settled agent turn means.
45
82
  *
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.
83
+ * The gate only ever runs on a turn that did **not** call the handoff tool: an
84
+ * explicit call moves the stage itself and never settles through here. So every
85
+ * path below is a missing tool call, and it is answered with a reminder rather
86
+ * than with a silent closure accepting the prose form as equivalent is what
87
+ * taught the model that skipping the call costs nothing.
88
+ *
89
+ * Every outcome is still either one reminder or a handoff, so the gate can never
90
+ * park a round in a state the user cannot act on.
50
91
  */
51
92
  export function decideGate(facts: GateFacts): GateDecision {
52
93
  // Declared instrumentation that never reached disk: the reproduction the
53
94
  // user is about to be asked for could not record anything.
54
95
  if (facts.declaresRuntimeProbe && facts.liveProbes === 0) {
55
96
  if (facts.nudges.probes < MAX_PROBE_NUDGES) return { kind: "nudge", budget: "probes", context: PROBE_NUDGE };
56
- return { kind: "open", reason: "probes_missing" };
97
+ return handoff("incomplete", facts);
57
98
  }
58
- if (facts.hasReproductionSteps && facts.hasEvidencePlan) {
59
- return { kind: "gate", missingPlan: false };
99
+ if (facts.nudges.handoff < MAX_HANDOFF_NUDGES && !facts.awaitingProgress) {
100
+ return { kind: "nudge", budget: "handoff", context: handoffNudge(facts.round, wroteProse(facts)) };
60
101
  }
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" };
102
+ return handoff(proseMode(facts), facts);
103
+ }
104
+
105
+ function wroteProse(facts: GateFacts): boolean {
106
+ return facts.hasReproductionSteps || facts.hasEvidencePlan;
107
+ }
108
+
109
+ /**
110
+ * What the round meant when the tool call never came. The prose is still the
111
+ * best available description of the user's work: reproduction steps are a work
112
+ * order the user has already read, so handing over as anything but `reproduce`
113
+ * would leave them acting on instructions the session claims do not exist.
114
+ */
115
+ function proseMode(facts: GateFacts): HandoffMode {
116
+ if (facts.hasReproductionSteps) {
117
+ return facts.needsUserCapture && !facts.declaresRuntimeProbe ? "capture" : "reproduce";
69
118
  }
70
- if (facts.nudges.tags < MAX_TAG_NUDGES) return { kind: "nudge", budget: "tags", context: GATE_NUDGE };
71
- return { kind: "open", reason: "unclosed" };
119
+ // A user-assisted plan describes user work even when the steps tag is absent.
120
+ if (facts.hasEvidencePlan && facts.needsUserCapture) return "capture";
121
+ // Nothing declared and nothing instrumented this round: the agent was talking
122
+ // to the user, which is an ordinary question rather than a broken round.
123
+ if (!facts.hasEvidencePlan && facts.probesAddedThisRound === 0) return "question";
124
+ return "incomplete";
72
125
  }
73
126
 
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.`;
127
+ function handoff(mode: HandoffMode, facts: GateFacts): GateDecision {
128
+ const expectsPlan = mode === "reproduce" || mode === "capture";
129
+ return { kind: "handoff", mode, missingPlan: expectsPlan && !facts.hasEvidencePlan };
130
+ }
131
+
132
+ /** What the user should do now, and which command clears this mode. */
133
+ export function describeHandoff(mode: HandoffMode, round: number, missingPlan = false): string {
134
+ const planNote = missingPlan
135
+ ? " The agent declared no valid evidence plan for this round, so nothing links what you capture to a hypothesis."
136
+ : "";
137
+ if (mode === "reproduce") {
138
+ return `Debug round ${round} is yours: reproduce the bug so the probes can record, then /debug-proceed.${planNote}`;
139
+ }
140
+ if (mode === "capture") {
141
+ return `Debug round ${round} is yours: supply the requested report or file, then /debug-proceed.${planNote}`;
78
142
  }
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.`;
143
+ if (mode === "question") {
144
+ return `Debug round ${round} is yours: the agent is waiting on your reply, not on a reproduction. Run /debug-proceed only once you have something to analyze.`;
81
145
  }
82
- return `Debug round ${round} is still open the agent is waiting on your reply, not on a reproduction.`;
146
+ return `Debug round ${round} stopped without telling you what to reproduce or capture. Reply in the editor to get it finished, or /debug-proceed to continue with whatever evidence already exists.`;
83
147
  }
package/src/log-files.ts CHANGED
@@ -1,115 +1,161 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
-
4
- export const ACTIVE_LOG_FILE = "current.jsonl";
5
-
6
- export function resolveRunLogFile(
7
- debugDir: string | null,
8
- run: string | null,
9
- activeRun: string | null,
10
- ): string | null {
11
- if (!debugDir || !run) return null;
12
- return path.join(debugDir, run === activeRun ? ACTIVE_LOG_FILE : `${run}.jsonl`);
13
- }
14
-
15
- export function readJsonlLines(file: string | null): string[] {
16
- if (!file) return [];
17
- try {
18
- return fs
19
- .readFileSync(file, "utf8")
20
- .split(/\r?\n/)
21
- .filter(line => line.trim().length > 0);
22
- } catch {
23
- return [];
24
- }
25
- }
26
-
27
- interface CountEntry {
28
- size: number;
29
- mtimeMs: number;
30
- count: number;
31
- }
32
-
33
- /**
34
- * Line counts keyed by file identity. The reproduction gate polls the active
35
- * log once a second, so re-reading every archived run each time would scale
36
- * with the length of the debugging session for no new information.
37
- */
38
- export class JsonlLineCounter {
39
- #cache = new Map<string, CountEntry>();
40
-
41
- count(file: string | null): number {
42
- if (!file) return 0;
43
- const stat = fs.statSync(file, { throwIfNoEntry: false });
44
- if (!stat) {
45
- this.#cache.delete(file);
46
- return 0;
47
- }
48
- const hit = this.#cache.get(file);
49
- if (hit && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) return hit.count;
50
- const count = readJsonlLines(file).length;
51
- this.#cache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, count });
52
- return count;
53
- }
54
-
55
- clear(): void {
56
- this.#cache.clear();
57
- }
58
- }
59
-
60
- export interface HypothesisTally {
61
- id: string;
62
- count: number;
63
- }
64
-
65
- const UNATTRIBUTED = "(unattributed)";
66
-
67
- /**
68
- * Group observations by the hypothesis each probe was meant to settle, so the
69
- * user and the agent can see which hypotheses actually produced evidence
70
- * before anyone reads the raw log.
71
- */
72
- export function summarizeHypotheses(lines: readonly string[]): HypothesisTally[] {
73
- const counts = new Map<string, number>();
74
- for (const line of lines) {
75
- let id = UNATTRIBUTED;
76
- try {
77
- const entry = JSON.parse(line) as { hypothesisId?: unknown };
78
- if (typeof entry.hypothesisId === "string" && entry.hypothesisId.trim().length > 0) {
79
- id = entry.hypothesisId.trim();
80
- }
81
- } catch {
82
- continue;
83
- }
84
- counts.set(id, (counts.get(id) ?? 0) + 1);
85
- }
86
- return [...counts]
87
- .map(([id, count]) => ({ id, count }))
88
- .sort((a, b) => (b.count !== a.count ? b.count - a.count : a.id < b.id ? -1 : 1));
89
- }
90
-
91
- export function describeHypotheses(tallies: readonly HypothesisTally[]): string {
92
- if (tallies.length === 0) return "no hypothesis-attributed observations";
93
- return tallies.map(t => `${t.id}=${t.count}`).join(", ");
94
- }
95
-
96
- export function prepareRunLog(debugDir: string, previousRun: string | null): string {
97
- const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
98
- const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
99
-
100
- try {
101
- if (archivedFile) {
102
- if (fs.existsSync(activeFile)) fs.copyFileSync(activeFile, archivedFile);
103
- else fs.writeFileSync(archivedFile, "", { flag: "a" });
104
- }
105
- fs.writeFileSync(activeFile, "", { flag: "w" });
106
- return activeFile;
107
- } catch (error) {
108
- if (archivedFile) {
109
- try {
110
- fs.rmSync(archivedFile, { force: true });
111
- } catch {}
112
- }
113
- throw error;
114
- }
115
- }
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const ACTIVE_LOG_FILE = "current.jsonl";
5
+
6
+ export function resolveRunLogFile(
7
+ debugDir: string | null,
8
+ run: string | null,
9
+ activeRun: string | null,
10
+ ): string | null {
11
+ if (!debugDir || !run) return null;
12
+ return path.join(debugDir, run === activeRun ? ACTIVE_LOG_FILE : `${run}.jsonl`);
13
+ }
14
+
15
+ export function readJsonlLines(file: string | null): string[] {
16
+ if (!file) return [];
17
+ try {
18
+ return fs
19
+ .readFileSync(file, "utf8")
20
+ .split(/\r?\n/)
21
+ .filter(line => line.trim().length > 0);
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ interface CountEntry {
28
+ size: number;
29
+ mtimeMs: number;
30
+ count: number;
31
+ }
32
+
33
+ /**
34
+ * Line counts keyed by file identity. The reproduction gate polls the active
35
+ * log once a second, so re-reading every archived run each time would scale
36
+ * with the length of the debugging session for no new information.
37
+ */
38
+ export class JsonlLineCounter {
39
+ #cache = new Map<string, CountEntry>();
40
+
41
+ count(file: string | null): number {
42
+ if (!file) return 0;
43
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
44
+ if (!stat) {
45
+ this.#cache.delete(file);
46
+ return 0;
47
+ }
48
+ const hit = this.#cache.get(file);
49
+ if (hit && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) return hit.count;
50
+ const count = readJsonlLines(file).length;
51
+ this.#cache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, count });
52
+ return count;
53
+ }
54
+
55
+ clear(): void {
56
+ this.#cache.clear();
57
+ }
58
+ }
59
+
60
+ export interface HypothesisTally {
61
+ id: string;
62
+ count: number;
63
+ }
64
+
65
+ const UNATTRIBUTED = "(unattributed)";
66
+
67
+ /**
68
+ * Group observations by the hypothesis each probe was meant to settle, so the
69
+ * user and the agent can see which hypotheses actually produced evidence
70
+ * before anyone reads the raw log.
71
+ */
72
+ export function summarizeHypotheses(lines: readonly string[]): HypothesisTally[] {
73
+ const counts = new Map<string, number>();
74
+ for (const line of lines) {
75
+ let id = UNATTRIBUTED;
76
+ try {
77
+ const entry = JSON.parse(line) as { hypothesisId?: unknown };
78
+ if (typeof entry.hypothesisId === "string" && entry.hypothesisId.trim().length > 0) {
79
+ id = entry.hypothesisId.trim();
80
+ }
81
+ } catch {
82
+ continue;
83
+ }
84
+ counts.set(id, (counts.get(id) ?? 0) + 1);
85
+ }
86
+ return [...counts]
87
+ .map(([id, count]) => ({ id, count }))
88
+ .sort((a, b) => (b.count !== a.count ? b.count - a.count : a.id < b.id ? -1 : 1));
89
+ }
90
+
91
+ export function describeHypotheses(tallies: readonly HypothesisTally[]): string {
92
+ if (tallies.length === 0) return "no hypothesis-attributed observations";
93
+ return tallies.map(t => `${t.id}=${t.count}`).join(", ");
94
+ }
95
+
96
+ const BUSY_CODES = new Set(["EBUSY", "EACCES", "EAGAIN"]);
97
+ const TRUNCATE_ATTEMPTS = 6;
98
+ const TRUNCATE_RETRY_MS = 50;
99
+
100
+ function sleepSync(ms: number): void {
101
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
102
+ }
103
+
104
+ function errorCode(error: unknown): string | undefined {
105
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
106
+ return error.code;
107
+ }
108
+ }
109
+
110
+ function isBusyError(error: unknown): boolean {
111
+ const code = errorCode(error);
112
+ return code !== undefined && BUSY_CODES.has(code);
113
+ }
114
+
115
+ /**
116
+ * Empty the stable probe path for the next round. Windows fails CREATE_ALWAYS
117
+ * while another process (or this process's own `watchFile`) still has the
118
+ * file open, so busy errors are retried; a successful archive is never
119
+ * rolled back if truncate then fails.
120
+ */
121
+ function truncateActiveLog(activeFile: string): void {
122
+ let last: unknown;
123
+ for (let attempt = 0; attempt < TRUNCATE_ATTEMPTS; attempt++) {
124
+ if (attempt > 0) sleepSync(TRUNCATE_RETRY_MS);
125
+ try {
126
+ fs.writeFileSync(activeFile, "", { flag: "w" });
127
+ return;
128
+ } catch (error) {
129
+ last = error;
130
+ if (!isBusyError(error)) throw error;
131
+ }
132
+ try {
133
+ fs.truncateSync(activeFile, 0);
134
+ return;
135
+ } catch (error) {
136
+ last = error;
137
+ if (!isBusyError(error)) throw error;
138
+ }
139
+ }
140
+ throw last;
141
+ }
142
+
143
+ export function clearActiveLog(debugDir: string): string {
144
+ fs.mkdirSync(debugDir, { recursive: true });
145
+ const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
146
+ truncateActiveLog(activeFile);
147
+ return activeFile;
148
+ }
149
+
150
+ export function prepareRunLog(debugDir: string, previousRun: string | null): string {
151
+ fs.mkdirSync(debugDir, { recursive: true });
152
+ const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
153
+ const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
154
+
155
+ if (archivedFile) {
156
+ if (fs.existsSync(activeFile)) fs.copyFileSync(activeFile, archivedFile);
157
+ else fs.writeFileSync(archivedFile, "", { flag: "a" });
158
+ }
159
+ truncateActiveLog(activeFile);
160
+ return activeFile;
161
+ }