@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/evidence.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { DebugState, EvidenceArtifact, EvidenceMethod, EvidenceRequest } 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, round: number): 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
|
+
round,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { found: true, valid: true, requests };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ArtifactValidation =
|
|
90
|
+
| { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
|
|
91
|
+
| { ok: false; reason: string };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a user-supplied path and capture filesystem metadata only. The file
|
|
95
|
+
* is referenced in place: its contents are never read, copied or deleted here.
|
|
96
|
+
*/
|
|
97
|
+
export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
|
|
98
|
+
const trimmed = rawPath.trim();
|
|
99
|
+
if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
|
|
100
|
+
const absolute = path.resolve(cwd, trimmed);
|
|
101
|
+
let stats: fs.Stats;
|
|
102
|
+
try {
|
|
103
|
+
stats = fs.statSync(absolute);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
|
|
106
|
+
}
|
|
107
|
+
if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
|
|
108
|
+
try {
|
|
109
|
+
fs.accessSync(absolute, fs.constants.R_OK);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, reason: `${absolute} is not readable` };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
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
|
+
/**
|
|
146
|
+
* Human-readable evidence description for the blackboard and the model-facing
|
|
147
|
+
* tool. Availability is re-checked on every call: a missing or unreadable file
|
|
148
|
+
* is never treated as captured evidence.
|
|
149
|
+
*/
|
|
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) {
|
|
153
|
+
return "(none)";
|
|
154
|
+
}
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
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));
|
|
159
|
+
const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
|
|
160
|
+
const coverage =
|
|
161
|
+
request.method === "user_artifact"
|
|
162
|
+
? linked.length > 0
|
|
163
|
+
? "artifact attached"
|
|
164
|
+
: "PENDING artifact"
|
|
165
|
+
: request.method === "user_report"
|
|
166
|
+
? reports.length > 0
|
|
167
|
+
? "report submitted"
|
|
168
|
+
: "PENDING report"
|
|
169
|
+
: "agent-collected";
|
|
170
|
+
lines.push(
|
|
171
|
+
`- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
for (const observation of state.evidenceObservations) {
|
|
175
|
+
lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
|
|
176
|
+
}
|
|
177
|
+
for (const artifact of state.evidenceArtifacts) {
|
|
178
|
+
let stats: fs.Stats;
|
|
179
|
+
try {
|
|
180
|
+
stats = fs.statSync(artifact.path);
|
|
181
|
+
fs.accessSync(artifact.path, fs.constants.R_OK);
|
|
182
|
+
} catch {
|
|
183
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!stats.isFile()) {
|
|
187
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
|
|
191
|
+
lines.push(
|
|
192
|
+
`- ${artifact.id} [artifact] ${artifact.path} — available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return lines.join("\n");
|
|
196
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { MINIMIZE_USER_INTERVENTION, PROCEED_REMINDER } from "./methodology";
|
|
2
|
+
|
|
3
|
+
/** How many times a single round may be nudged to close itself properly. */
|
|
4
|
+
export const MAX_GATE_NUDGES = 1;
|
|
5
|
+
|
|
6
|
+
export const GATE_NUDGE =
|
|
7
|
+
"This round did not close properly. It must end with BOTH a <evidence_plan> JSON block " +
|
|
8
|
+
'({"id","hypothesisIds","method","title","rationale","instructions", optional "artifactHint"}; methods: ' +
|
|
9
|
+
"agent_inspection, runtime_probe, user_report, user_artifact) AND a <reproduction_steps> numbered list " +
|
|
10
|
+
`followed by the exact sentence "${PROCEED_REMINDER}", then stop. ` +
|
|
11
|
+
MINIMIZE_USER_INTERVENTION +
|
|
12
|
+
" Every entry needs a concrete rationale; a user_report/user_artifact rationale must name why BOTH " +
|
|
13
|
+
"autonomous inspection and model-added probes cannot answer its hypotheses. " +
|
|
14
|
+
"A user_artifact request must state the file type, path/capture instructions, how you will inspect the file, " +
|
|
15
|
+
"and why inspection/probes are inadequate. Do not start new work.";
|
|
16
|
+
|
|
17
|
+
export type GateDecision =
|
|
18
|
+
/** Hand control to the user and wait for a reproduction/capture. */
|
|
19
|
+
| { kind: "gate"; missingSteps: boolean; missingEvidencePlan: boolean }
|
|
20
|
+
/** 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" };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decide what a settled agent turn means for the reproduction gate.
|
|
27
|
+
*
|
|
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.
|
|
33
|
+
*/
|
|
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 };
|
|
42
|
+
}
|
|
43
|
+
// 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 };
|
|
46
|
+
}
|
|
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
|
+
};
|
|
54
|
+
}
|
package/src/log-files.ts
CHANGED
|
@@ -24,6 +24,75 @@ export function readJsonlLines(file: string | null): string[] {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
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
|
+
|
|
27
96
|
export function prepareRunLog(debugDir: string, previousRun: string | null): string {
|
|
28
97
|
const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
|
|
29
98
|
const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
|