@siuver/omp-debug-mode 0.1.2 โ 0.1.4
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/CHANGELOG.md +16 -0
- package/README.md +133 -78
- package/package.json +3 -3
- package/src/debug-mode.ts +748 -0
- package/src/evidence.ts +196 -0
- package/src/gate.ts +54 -0
- package/src/log-files.ts +69 -0
- package/src/main.ts +12 -672
- package/src/methodology.ts +188 -0
- package/src/probes.ts +97 -0
- package/src/state.ts +240 -0
- package/src/tools.ts +136 -0
- package/src/ui.ts +103 -0
- package/src/workspace.ts +79 -0
- package/src/review-actions.ts +0 -11
package/src/ui.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { PROCEED_REMINDER } from "./methodology";
|
|
3
|
+
import type { DebugState, EvidenceRequest } from "./state";
|
|
4
|
+
import { pendingEvidenceRequests } from "./state";
|
|
5
|
+
|
|
6
|
+
export const WIDGET_MAX_LINES = 10;
|
|
7
|
+
const WIDGET_MAX_WIDTH = 90;
|
|
8
|
+
|
|
9
|
+
export type WidgetTone = "accent" | "dim";
|
|
10
|
+
|
|
11
|
+
export interface WidgetLine {
|
|
12
|
+
text: string;
|
|
13
|
+
tone: WidgetTone;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function statusLabel(state: DebugState): string {
|
|
17
|
+
if (state.phase === "waiting") return "๐ waiting-repro";
|
|
18
|
+
if (state.phase === "round") return `๐ round ${state.round}`;
|
|
19
|
+
return "๐ cleanup";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clip(text: string): string {
|
|
23
|
+
return text.length > WIDGET_MAX_WIDTH ? `${text.slice(0, WIDGET_MAX_WIDTH - 1)}โฆ` : text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The command surface is the only interaction path at the gate. */
|
|
27
|
+
const COMMAND_LINES = [
|
|
28
|
+
"/debug-proceed [details]",
|
|
29
|
+
"/debug-evidence <request-id> <path>",
|
|
30
|
+
"/debug-done ยท /debug-abort ยท /debug-status",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
function plural(count: number, singular: string): string {
|
|
34
|
+
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function logEntries(count: number): string {
|
|
38
|
+
return count === 1 ? "1 log entry" : `${count} log entries`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reproduction-gate widget: the call to action, pending user evidence,
|
|
43
|
+
* reproduction-step context, the command surface, and the live evidence
|
|
44
|
+
* counter. Commands are the only interaction path โ there is no menu. The
|
|
45
|
+
* live log counter is always the final line within the host's line budget.
|
|
46
|
+
*/
|
|
47
|
+
export function waitingWidgetLines(
|
|
48
|
+
state: DebugState,
|
|
49
|
+
logCount: number,
|
|
50
|
+
pendingEvidence: EvidenceRequest[] = pendingEvidenceRequests(state, state.round),
|
|
51
|
+
): WidgetLine[] {
|
|
52
|
+
const lines: WidgetLine[] = [{ text: PROCEED_REMINDER, tone: "accent" }];
|
|
53
|
+
for (const request of pendingEvidence.slice(0, 2)) {
|
|
54
|
+
lines.push({ text: clip(`โช ${request.id} ${request.title}: ${request.instructions[0] ?? ""}`), tone: "accent" });
|
|
55
|
+
}
|
|
56
|
+
for (const command of COMMAND_LINES) lines.push({ text: command, tone: "accent" });
|
|
57
|
+
|
|
58
|
+
// Reproduction details are useful context, but command discoverability and
|
|
59
|
+
// the live log counter must survive the host's ten-line widget limit.
|
|
60
|
+
const reserved = lines.length + 2;
|
|
61
|
+
const stepBudget = Math.max(0, WIDGET_MAX_LINES - reserved);
|
|
62
|
+
if (stepBudget > 0 && state.reproductionSteps.length > 0) {
|
|
63
|
+
const showMoreLine = state.reproductionSteps.length > stepBudget;
|
|
64
|
+
const shownCount = showMoreLine ? Math.max(0, stepBudget - 1) : stepBudget;
|
|
65
|
+
for (const step of state.reproductionSteps.slice(0, shownCount)) {
|
|
66
|
+
lines.push({ text: clip(step), tone: "dim" });
|
|
67
|
+
}
|
|
68
|
+
const hidden = state.reproductionSteps.length - shownCount;
|
|
69
|
+
if (hidden > 0) lines.push({ text: `โฆ +${plural(hidden, "more step")} in the transcript`, tone: "dim" });
|
|
70
|
+
}
|
|
71
|
+
lines.push({
|
|
72
|
+
text: `evidence: ${plural(pendingEvidence.length, "pending request")}, ${plural(state.evidenceArtifacts.length, "attached artifact")}`,
|
|
73
|
+
tone: pendingEvidence.length > 0 || state.evidenceArtifacts.length > 0 ? "accent" : "dim",
|
|
74
|
+
});
|
|
75
|
+
lines.push({
|
|
76
|
+
text: `run ${state.runId ?? "none"} โ ${logEntries(logCount)}`,
|
|
77
|
+
tone: logCount > 0 ? "accent" : "dim",
|
|
78
|
+
});
|
|
79
|
+
return lines.slice(0, WIDGET_MAX_LINES);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the status entry and the reproduction widget. `getLogCount` is only
|
|
84
|
+
* consulted at the reproduction gate so idle phases do not touch the log files.
|
|
85
|
+
*/
|
|
86
|
+
export function applyUi(ctx: ExtensionContext | null, state: DebugState, getLogCount: () => number): void {
|
|
87
|
+
if (!ctx?.hasUI) return;
|
|
88
|
+
if (!state.active) {
|
|
89
|
+
ctx.ui.setStatus("debug-mode", undefined);
|
|
90
|
+
ctx.ui.setWidget("debug-mode", undefined);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
ctx.ui.setStatus("debug-mode", ctx.ui.theme.fg("warning", statusLabel(state)));
|
|
94
|
+
if (state.phase !== "waiting") {
|
|
95
|
+
ctx.ui.setWidget("debug-mode", undefined);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const lines = waitingWidgetLines(state, getLogCount());
|
|
99
|
+
ctx.ui.setWidget(
|
|
100
|
+
"debug-mode",
|
|
101
|
+
lines.map(line => ctx.ui.theme.fg(line.tone, line.text)),
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEBUG_ROOT = path.join(".omp", "debug");
|
|
5
|
+
/** Line written to `.git/info/exclude` so probe logs stay out of `git status`. */
|
|
6
|
+
export const EXCLUDE_LINE = ".omp/debug/";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directory name for one session's probe logs. Two omp sessions debugging the
|
|
10
|
+
* same repository must not share `current.jsonl`: each round truncates it and
|
|
11
|
+
* teardown deletes it, so a shared directory would let one session erase the
|
|
12
|
+
* other's evidence.
|
|
13
|
+
*/
|
|
14
|
+
export function sessionDirName(sessionId: string | null | undefined): string {
|
|
15
|
+
const safe = (sessionId ?? "").replace(/[^A-Za-z0-9_-]/g, "");
|
|
16
|
+
return safe.length > 0 ? safe.slice(0, 16) : "session";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function debugDirFor(cwd: string, sessionId: string | null | undefined): string {
|
|
20
|
+
return path.resolve(cwd, DEBUG_ROOT, sessionDirName(sessionId));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Walk up from `cwd` to the enclosing `.git` directory, if there is a plain one. */
|
|
24
|
+
export function findGitDir(cwd: string): string | null {
|
|
25
|
+
let dir = path.resolve(cwd);
|
|
26
|
+
for (;;) {
|
|
27
|
+
const candidate = path.join(dir, ".git");
|
|
28
|
+
try {
|
|
29
|
+
// A `.git` file means a worktree or submodule; leave those alone.
|
|
30
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
31
|
+
} catch {}
|
|
32
|
+
const parent = path.dirname(dir);
|
|
33
|
+
if (parent === dir) return null;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function needsExcludeLine(contents: string): boolean {
|
|
39
|
+
return !contents.split(/\r?\n/).some(line => line.trim() === EXCLUDE_LINE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function appendExcludeLine(contents: string): string {
|
|
43
|
+
if (!needsExcludeLine(contents)) return contents;
|
|
44
|
+
const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
|
|
45
|
+
return `${contents}${separator}${EXCLUDE_LINE}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Keep probe logs out of the user's working diff via the repository-local
|
|
50
|
+
* exclude file, which is never committed. Best effort: a read-only or absent
|
|
51
|
+
* git directory is not a reason to fail debugging.
|
|
52
|
+
*/
|
|
53
|
+
export function excludeDebugLogsFromGit(cwd: string): boolean {
|
|
54
|
+
const gitDir = findGitDir(cwd);
|
|
55
|
+
if (!gitDir) return false;
|
|
56
|
+
const excludeFile = path.join(gitDir, "info", "exclude");
|
|
57
|
+
try {
|
|
58
|
+
let contents = "";
|
|
59
|
+
try {
|
|
60
|
+
contents = fs.readFileSync(excludeFile, "utf8");
|
|
61
|
+
} catch {}
|
|
62
|
+
if (!needsExcludeLine(contents)) return false;
|
|
63
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
64
|
+
fs.writeFileSync(excludeFile, appendExcludeLine(contents));
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove the shared `.omp/debug` parent once the last session directory is gone. */
|
|
72
|
+
export function pruneDebugRoot(cwd: string): void {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmdirSync(path.resolve(cwd, DEBUG_ROOT));
|
|
75
|
+
} catch {}
|
|
76
|
+
try {
|
|
77
|
+
fs.rmdirSync(path.resolve(cwd, ".omp"));
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
package/src/review-actions.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export const REVIEW_MARK_FIXED = "Mark as fixed";
|
|
2
|
-
export const REVIEW_PROCEED = "Proceed with captured logs";
|
|
3
|
-
export const REVIEW_ADD_DETAILS = "Add reproduction details";
|
|
4
|
-
export const REVIEW_ABORT = "Abort debug mode";
|
|
5
|
-
|
|
6
|
-
export const REVIEW_OPTIONS = [
|
|
7
|
-
REVIEW_MARK_FIXED,
|
|
8
|
-
REVIEW_PROCEED,
|
|
9
|
-
REVIEW_ADD_DETAILS,
|
|
10
|
-
REVIEW_ABORT,
|
|
11
|
-
] as const;
|