@lnilluv/pi-ralph-loop 0.1.1 → 0.1.4-dev.0
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/.github/workflows/ci.yml +5 -2
- package/.github/workflows/release.yml +7 -4
- package/README.md +97 -11
- package/package.json +13 -4
- package/src/index.ts +561 -184
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +269 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +800 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +23 -0
- package/tests/index.test.ts +464 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +361 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +611 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tsconfig.json +3 -2
package/src/ralph.ts
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { SECRET_PATH_POLICY_TOKEN, filterSecretBearingTopLevelNames, isSecretBearingPath, isSecretBearingTopLevelName } from "./secret-paths.ts";
|
|
5
|
+
|
|
6
|
+
export type CommandDef = { name: string; run: string; timeout: number };
|
|
7
|
+
export type DraftSource = "deterministic" | "llm-strengthened" | "fallback";
|
|
8
|
+
export type DraftStrengtheningScope = "body-only" | "body-and-commands";
|
|
9
|
+
export type CommandIntent = CommandDef & { source: "heuristic" | "repo-signal" };
|
|
10
|
+
export type Frontmatter = {
|
|
11
|
+
commands: CommandDef[];
|
|
12
|
+
maxIterations: number;
|
|
13
|
+
timeout: number;
|
|
14
|
+
completionPromise?: string;
|
|
15
|
+
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
16
|
+
invalidCommandEntries?: number[];
|
|
17
|
+
};
|
|
18
|
+
export type ParsedRalph = { frontmatter: Frontmatter; body: string };
|
|
19
|
+
export type CommandOutput = { name: string; output: string };
|
|
20
|
+
export type RalphTargetResolution = {
|
|
21
|
+
target: string;
|
|
22
|
+
absoluteTarget: string;
|
|
23
|
+
markdownPath: string;
|
|
24
|
+
};
|
|
25
|
+
export type CommandArgs =
|
|
26
|
+
| { mode: "path" | "task"; value: string }
|
|
27
|
+
| { mode: "auto"; value: string };
|
|
28
|
+
export type ExistingTargetInspection =
|
|
29
|
+
| { kind: "run"; ralphPath: string }
|
|
30
|
+
| { kind: "invalid-markdown"; path: string }
|
|
31
|
+
| { kind: "invalid-target"; path: string }
|
|
32
|
+
| { kind: "dir-without-ralph"; dirPath: string; ralphPath: string }
|
|
33
|
+
| { kind: "missing-path"; dirPath: string; ralphPath: string }
|
|
34
|
+
| { kind: "not-path" };
|
|
35
|
+
export type DraftMode = "analysis" | "fix" | "migration" | "general";
|
|
36
|
+
export type DraftMetadata =
|
|
37
|
+
| {
|
|
38
|
+
generator: "pi-ralph-loop";
|
|
39
|
+
version: 1;
|
|
40
|
+
task: string;
|
|
41
|
+
mode: DraftMode;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
generator: "pi-ralph-loop";
|
|
45
|
+
version: 2;
|
|
46
|
+
source: DraftSource;
|
|
47
|
+
task: string;
|
|
48
|
+
mode: DraftMode;
|
|
49
|
+
};
|
|
50
|
+
export type DraftTarget = {
|
|
51
|
+
slug: string;
|
|
52
|
+
dirPath: string;
|
|
53
|
+
ralphPath: string;
|
|
54
|
+
};
|
|
55
|
+
export type PlannedTaskTarget =
|
|
56
|
+
| { kind: "draft"; target: DraftTarget }
|
|
57
|
+
| { kind: "conflict"; target: DraftTarget };
|
|
58
|
+
export type RepoSignals = {
|
|
59
|
+
packageManager?: "npm" | "pnpm" | "yarn" | "bun";
|
|
60
|
+
testCommand?: string;
|
|
61
|
+
lintCommand?: string;
|
|
62
|
+
hasGit: boolean;
|
|
63
|
+
topLevelDirs: string[];
|
|
64
|
+
topLevelFiles: string[];
|
|
65
|
+
};
|
|
66
|
+
export type RepoContextSelectedFile = {
|
|
67
|
+
path: string;
|
|
68
|
+
content: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
};
|
|
71
|
+
export type RepoContext = {
|
|
72
|
+
summaryLines: string[];
|
|
73
|
+
selectedFiles: RepoContextSelectedFile[];
|
|
74
|
+
};
|
|
75
|
+
export type DraftRequest = {
|
|
76
|
+
task: string;
|
|
77
|
+
mode: DraftMode;
|
|
78
|
+
target: DraftTarget;
|
|
79
|
+
repoSignals: RepoSignals;
|
|
80
|
+
repoContext: RepoContext;
|
|
81
|
+
commandIntent: CommandIntent[];
|
|
82
|
+
baselineDraft: string;
|
|
83
|
+
};
|
|
84
|
+
export type DraftPlan = {
|
|
85
|
+
task: string;
|
|
86
|
+
mode: DraftMode;
|
|
87
|
+
target: DraftTarget;
|
|
88
|
+
source: DraftSource;
|
|
89
|
+
content: string;
|
|
90
|
+
commandLabels: string[];
|
|
91
|
+
safetyLabel: string;
|
|
92
|
+
finishLabel: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type UnknownRecord = Record<string, unknown>;
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const draftModes: DraftMode[] = ["analysis", "fix", "migration", "general"];
|
|
102
|
+
const draftSources: DraftSource[] = ["deterministic", "llm-strengthened", "fallback"];
|
|
103
|
+
|
|
104
|
+
function isDraftMode(value: unknown): value is DraftMode {
|
|
105
|
+
return typeof value === "string" && draftModes.includes(value as DraftMode);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isDraftSource(value: unknown): value is DraftSource {
|
|
109
|
+
return typeof value === "string" && draftSources.includes(value as DraftSource);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseRalphFrontmatter(raw: string): UnknownRecord {
|
|
113
|
+
const parsed: unknown = parseYaml(raw);
|
|
114
|
+
return isRecord(parsed) ? parsed : {};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseCommandDef(value: unknown): CommandDef | null {
|
|
118
|
+
if (!isRecord(value)) return null;
|
|
119
|
+
return {
|
|
120
|
+
name: String(value.name ?? ""),
|
|
121
|
+
run: String(value.run ?? ""),
|
|
122
|
+
timeout: Number(value.timeout ?? 60),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toUnknownArray(value: unknown): unknown[] {
|
|
127
|
+
return Array.isArray(value) ? value : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toStringArray(value: unknown): string[] {
|
|
131
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeRawRalph(raw: string): string {
|
|
135
|
+
return raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchRalphMarkdown(raw: string): RegExpMatchArray | null {
|
|
139
|
+
return normalizeRawRalph(raw).match(/^(?:\s*<!--[\s\S]*?-->\s*)*---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasRalphFrontmatter(raw: string): boolean {
|
|
143
|
+
return matchRalphMarkdown(raw) !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeMissingMarkdownTarget(absoluteTarget: string): { dirPath: string; ralphPath: string } {
|
|
147
|
+
if (basename(absoluteTarget) === "RALPH.md") {
|
|
148
|
+
return { dirPath: dirname(absoluteTarget), ralphPath: absoluteTarget };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const dirPath = absoluteTarget.slice(0, -3);
|
|
152
|
+
return { dirPath, ralphPath: join(dirPath, "RALPH.md") };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
|
|
156
|
+
const labels: string[] = [];
|
|
157
|
+
if (guardrails.blockCommands.some((pattern) => pattern.includes("git") && pattern.includes("push"))) {
|
|
158
|
+
labels.push("blocks git push");
|
|
159
|
+
} else if (guardrails.blockCommands.length > 0) {
|
|
160
|
+
labels.push(`blocks ${guardrails.blockCommands.length} command pattern${guardrails.blockCommands.length === 1 ? "" : "s"}`);
|
|
161
|
+
}
|
|
162
|
+
if (guardrails.protectedFiles.some((pattern) => pattern === SECRET_PATH_POLICY_TOKEN || isSecretBearingPath(pattern))) {
|
|
163
|
+
labels.push("blocks write/edit to secret files");
|
|
164
|
+
} else if (guardrails.protectedFiles.length > 0) {
|
|
165
|
+
labels.push(`blocks write/edit to ${guardrails.protectedFiles.length} file glob${guardrails.protectedFiles.length === 1 ? "" : "s"}`);
|
|
166
|
+
}
|
|
167
|
+
return labels.length > 0 ? labels.join(" and ") : "No extra safety rules";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function summarizeFinishLabel(maxIterations: number): string {
|
|
171
|
+
return `Stop after ${maxIterations} iterations or /ralph-stop`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isRalphMarkdownPath(path: string): boolean {
|
|
175
|
+
return basename(path) === "RALPH.md";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function detectPackageManager(cwd: string): RepoSignals["packageManager"] {
|
|
179
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
180
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
181
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
|
|
182
|
+
if (existsSync(join(cwd, "package-lock.json")) || existsSync(join(cwd, "package.json"))) return "npm";
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function packageRunCommand(packageManager: RepoSignals["packageManager"], script: string): string {
|
|
187
|
+
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
188
|
+
if (packageManager === "yarn") return `yarn ${script}`;
|
|
189
|
+
if (packageManager === "bun") return `bun run ${script}`;
|
|
190
|
+
if (script === "test") return "npm test";
|
|
191
|
+
return `npm run ${script}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function detectPackageScripts(cwd: string, packageManager: RepoSignals["packageManager"]): Pick<RepoSignals, "testCommand" | "lintCommand"> {
|
|
195
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
196
|
+
if (!existsSync(packageJsonPath)) return {};
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { scripts?: Record<string, unknown> };
|
|
200
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {};
|
|
201
|
+
const testValue = typeof scripts.test === "string" ? scripts.test : undefined;
|
|
202
|
+
const lintValue = typeof scripts.lint === "string" ? scripts.lint : undefined;
|
|
203
|
+
|
|
204
|
+
const testCommand = testValue && !/no test specified/i.test(testValue) ? packageRunCommand(packageManager, "test") : undefined;
|
|
205
|
+
const lintCommand = lintValue ? packageRunCommand(packageManager, "lint") : undefined;
|
|
206
|
+
return { testCommand, lintCommand };
|
|
207
|
+
} catch {
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function encodeDraftMetadata(metadata: DraftMetadata): string {
|
|
213
|
+
return encodeURIComponent(JSON.stringify(metadata));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function decodeDraftMetadata(value: string): string {
|
|
217
|
+
try {
|
|
218
|
+
return decodeURIComponent(value);
|
|
219
|
+
} catch {
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function metadataComment(metadata: DraftMetadata): string {
|
|
225
|
+
return `<!-- pi-ralph-loop: ${encodeDraftMetadata(metadata)} -->`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function yamlBlock(lines: string[]): string {
|
|
229
|
+
return `---\n${lines.join("\n")}\n---`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function yamlQuote(value: string): string {
|
|
233
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderCommandsYaml(commands: CommandDef[]): string[] {
|
|
237
|
+
if (commands.length === 0) return ["commands: []"];
|
|
238
|
+
return [
|
|
239
|
+
"commands:",
|
|
240
|
+
...commands.flatMap((command) => [
|
|
241
|
+
` - name: ${command.name}`,
|
|
242
|
+
` run: ${command.run}`,
|
|
243
|
+
` timeout: ${command.timeout}`,
|
|
244
|
+
]),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function bodySection(title: string, placeholder: string): string {
|
|
249
|
+
return `${title}:\n${placeholder}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function escapeHtmlCommentMarkers(text: string): string {
|
|
253
|
+
return text.replace(/<!--/g, "<!--").replace(/-->/g, "-->");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function defaultFrontmatter(): Frontmatter {
|
|
257
|
+
return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function parseRalphMarkdown(raw: string): ParsedRalph {
|
|
261
|
+
const normalized = normalizeRawRalph(raw);
|
|
262
|
+
const match = matchRalphMarkdown(normalized);
|
|
263
|
+
if (!match) return { frontmatter: defaultFrontmatter(), body: normalized };
|
|
264
|
+
|
|
265
|
+
const yaml = parseRalphFrontmatter(match[1]);
|
|
266
|
+
const invalidCommandEntries: number[] = [];
|
|
267
|
+
const commands = toUnknownArray(yaml.commands).flatMap((command, index) => {
|
|
268
|
+
const parsed = parseCommandDef(command);
|
|
269
|
+
if (!parsed) {
|
|
270
|
+
invalidCommandEntries.push(index);
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
return [parsed];
|
|
274
|
+
});
|
|
275
|
+
const guardrails = isRecord(yaml.guardrails) ? yaml.guardrails : {};
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
frontmatter: {
|
|
279
|
+
commands,
|
|
280
|
+
maxIterations: Number(yaml.max_iterations ?? 50),
|
|
281
|
+
timeout: Number(yaml.timeout ?? 300),
|
|
282
|
+
completionPromise:
|
|
283
|
+
typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
|
|
284
|
+
guardrails: {
|
|
285
|
+
blockCommands: toStringArray(guardrails.block_commands),
|
|
286
|
+
protectedFiles: toStringArray(guardrails.protected_files),
|
|
287
|
+
},
|
|
288
|
+
invalidCommandEntries: invalidCommandEntries.length > 0 ? invalidCommandEntries : undefined,
|
|
289
|
+
},
|
|
290
|
+
body: match[2] ?? "",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function validateFrontmatter(fm: Frontmatter): string | null {
|
|
295
|
+
if ((fm.invalidCommandEntries?.length ?? 0) > 0) {
|
|
296
|
+
return `Invalid command entry at index ${fm.invalidCommandEntries![0]}`;
|
|
297
|
+
}
|
|
298
|
+
if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
|
|
299
|
+
return "Invalid max_iterations: must be a positive finite integer";
|
|
300
|
+
}
|
|
301
|
+
if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
|
|
302
|
+
return "Invalid timeout: must be a positive finite number";
|
|
303
|
+
}
|
|
304
|
+
for (const pattern of fm.guardrails.blockCommands) {
|
|
305
|
+
try {
|
|
306
|
+
new RegExp(pattern);
|
|
307
|
+
} catch {
|
|
308
|
+
return `Invalid block_commands regex: ${pattern}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const cmd of fm.commands) {
|
|
312
|
+
if (!cmd.name.trim()) {
|
|
313
|
+
return "Invalid command: name is required";
|
|
314
|
+
}
|
|
315
|
+
if (!cmd.run.trim()) {
|
|
316
|
+
return `Invalid command ${cmd.name}: run is required`;
|
|
317
|
+
}
|
|
318
|
+
if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
|
|
319
|
+
return `Invalid command ${cmd.name}: timeout must be positive`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function findBlockedCommandPattern(command: string, blockPatterns: string[]): string | undefined {
|
|
326
|
+
for (const pattern of blockPatterns) {
|
|
327
|
+
try {
|
|
328
|
+
if (new RegExp(pattern).test(command)) return pattern;
|
|
329
|
+
} catch {
|
|
330
|
+
// ignore malformed regexes; validateFrontmatter should catch these first
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function parseCommandArgs(raw: string): CommandArgs {
|
|
337
|
+
const trimmed = raw.trim();
|
|
338
|
+
if (trimmed.startsWith("--task=")) return { mode: "task", value: trimmed.slice("--task=".length).trim() };
|
|
339
|
+
if (trimmed.startsWith("--path=")) return { mode: "path", value: trimmed.slice("--path=".length).trim() };
|
|
340
|
+
if (trimmed.startsWith("--task ")) return { mode: "task", value: trimmed.slice("--task ".length).trim() };
|
|
341
|
+
if (trimmed.startsWith("--path ")) return { mode: "path", value: trimmed.slice("--path ".length).trim() };
|
|
342
|
+
return { mode: "auto", value: trimmed };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function looksLikePath(value: string): boolean {
|
|
346
|
+
const trimmed = value.trim();
|
|
347
|
+
if (!trimmed) return false;
|
|
348
|
+
if (/\s/.test(trimmed)) return false;
|
|
349
|
+
return (
|
|
350
|
+
trimmed.startsWith(".") ||
|
|
351
|
+
trimmed.startsWith("/") ||
|
|
352
|
+
trimmed.includes("\\") ||
|
|
353
|
+
trimmed.includes("/") ||
|
|
354
|
+
trimmed.endsWith(".md") ||
|
|
355
|
+
trimmed.includes("-")
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function resolveRalphTarget(args: string): string {
|
|
360
|
+
return args.trim() || ".";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function resolveRalphTargetResolution(args: string, cwd: string): RalphTargetResolution {
|
|
364
|
+
const target = resolveRalphTarget(args);
|
|
365
|
+
const absoluteTarget = resolve(cwd, target);
|
|
366
|
+
return {
|
|
367
|
+
target,
|
|
368
|
+
absoluteTarget,
|
|
369
|
+
markdownPath: absoluteTarget.endsWith(".md") ? absoluteTarget : join(absoluteTarget, "RALPH.md"),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function inspectExistingTarget(input: string, cwd: string, explicitPath = false): ExistingTargetInspection {
|
|
374
|
+
const resolution = resolveRalphTargetResolution(input, cwd);
|
|
375
|
+
const absoluteTarget = resolution.absoluteTarget;
|
|
376
|
+
const markdownPath = resolution.markdownPath;
|
|
377
|
+
|
|
378
|
+
if (existsSync(absoluteTarget)) {
|
|
379
|
+
const stats = statSync(absoluteTarget);
|
|
380
|
+
if (stats.isDirectory()) {
|
|
381
|
+
return existsSync(markdownPath)
|
|
382
|
+
? { kind: "run", ralphPath: markdownPath }
|
|
383
|
+
: { kind: "dir-without-ralph", dirPath: absoluteTarget, ralphPath: markdownPath };
|
|
384
|
+
}
|
|
385
|
+
if (isRalphMarkdownPath(absoluteTarget)) {
|
|
386
|
+
return { kind: "run", ralphPath: absoluteTarget };
|
|
387
|
+
}
|
|
388
|
+
if (absoluteTarget.endsWith(".md")) {
|
|
389
|
+
return { kind: "invalid-markdown", path: absoluteTarget };
|
|
390
|
+
}
|
|
391
|
+
return { kind: "invalid-target", path: absoluteTarget };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!explicitPath && !looksLikePath(input)) {
|
|
395
|
+
return { kind: "not-path" };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (absoluteTarget.endsWith(".md")) {
|
|
399
|
+
return { kind: "missing-path", ...normalizeMissingMarkdownTarget(absoluteTarget) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { kind: "missing-path", dirPath: absoluteTarget, ralphPath: markdownPath };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function slugifyTask(task: string): string {
|
|
406
|
+
const slug = task
|
|
407
|
+
.toLowerCase()
|
|
408
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
409
|
+
.replace(/^-+|-+$/g, "")
|
|
410
|
+
.slice(0, 80)
|
|
411
|
+
.replace(/^-+|-+$/g, "");
|
|
412
|
+
return slug || "ralph-task";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function nextSiblingSlug(baseSlug: string, hasRalphAtSlug: (slug: string) => boolean): string {
|
|
416
|
+
let suffix = 2;
|
|
417
|
+
let next = `${baseSlug}-${suffix}`;
|
|
418
|
+
while (hasRalphAtSlug(next)) {
|
|
419
|
+
suffix += 1;
|
|
420
|
+
next = `${baseSlug}-${suffix}`;
|
|
421
|
+
}
|
|
422
|
+
return next;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function classifyTaskMode(task: string): DraftMode {
|
|
426
|
+
const normalized = task.toLowerCase();
|
|
427
|
+
if (/(reverse engineer|analy[sz]e|understand|investigate|map|audit|explore)/.test(normalized)) return "analysis";
|
|
428
|
+
if (/(fix|debug|repair|failing test|flaky|failure|broken)/.test(normalized)) return "fix";
|
|
429
|
+
if (/(migrate|upgrade|convert|port|modernize)/.test(normalized)) return "migration";
|
|
430
|
+
return "general";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function planTaskDraftTarget(cwd: string, task: string): PlannedTaskTarget {
|
|
434
|
+
const slug = slugifyTask(task);
|
|
435
|
+
const target: DraftTarget = {
|
|
436
|
+
slug,
|
|
437
|
+
dirPath: join(cwd, slug),
|
|
438
|
+
ralphPath: join(cwd, slug, "RALPH.md"),
|
|
439
|
+
};
|
|
440
|
+
return existsSync(target.dirPath) ? { kind: "conflict", target } : { kind: "draft", target };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function createSiblingTarget(cwd: string, baseSlug: string): DraftTarget {
|
|
444
|
+
const siblingSlug = nextSiblingSlug(baseSlug, (candidate) => existsSync(join(cwd, candidate)));
|
|
445
|
+
return {
|
|
446
|
+
slug: siblingSlug,
|
|
447
|
+
dirPath: join(cwd, siblingSlug),
|
|
448
|
+
ralphPath: join(cwd, siblingSlug, "RALPH.md"),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function inspectRepo(cwd: string): RepoSignals {
|
|
453
|
+
const packageManager = detectPackageManager(cwd);
|
|
454
|
+
const packageScripts = detectPackageScripts(cwd, packageManager);
|
|
455
|
+
let topLevelDirs: string[] = [];
|
|
456
|
+
let topLevelFiles: string[] = [];
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const entries = readdirSync(cwd, { withFileTypes: true }).slice(0, 50);
|
|
460
|
+
const filteredEntries = entries.filter((entry) => !isSecretBearingTopLevelName(entry.name));
|
|
461
|
+
topLevelDirs = filteredEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).slice(0, 10);
|
|
462
|
+
topLevelFiles = filteredEntries.filter((entry) => entry.isFile()).map((entry) => entry.name).slice(0, 10);
|
|
463
|
+
} catch {
|
|
464
|
+
// ignore bounded inspection failures
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
packageManager,
|
|
469
|
+
testCommand: packageScripts.testCommand,
|
|
470
|
+
lintCommand: packageScripts.lintCommand,
|
|
471
|
+
hasGit: existsSync(join(cwd, ".git")),
|
|
472
|
+
topLevelDirs,
|
|
473
|
+
topLevelFiles,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function buildRepoContext(signals: RepoSignals): RepoContext {
|
|
478
|
+
const topLevelDirs = filterSecretBearingTopLevelNames(signals.topLevelDirs);
|
|
479
|
+
const topLevelFiles = filterSecretBearingTopLevelNames(signals.topLevelFiles);
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
summaryLines: [
|
|
483
|
+
`package manager: ${signals.packageManager ?? "unknown"}`,
|
|
484
|
+
`test command: ${signals.testCommand ?? "none"}`,
|
|
485
|
+
`lint command: ${signals.lintCommand ?? "none"}`,
|
|
486
|
+
`git repository: ${signals.hasGit ? "present" : "absent"}`,
|
|
487
|
+
`top-level dirs: ${topLevelDirs.length > 0 ? topLevelDirs.join(", ") : "none"}`,
|
|
488
|
+
`top-level files: ${topLevelFiles.length > 0 ? topLevelFiles.join(", ") : "none"}`,
|
|
489
|
+
],
|
|
490
|
+
selectedFiles: topLevelFiles.slice(0, 10).map((path) => ({
|
|
491
|
+
path,
|
|
492
|
+
content: "",
|
|
493
|
+
reason: "top-level file",
|
|
494
|
+
})),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeSelectedFile(file: unknown): RepoContextSelectedFile {
|
|
499
|
+
if (isRecord(file)) {
|
|
500
|
+
return {
|
|
501
|
+
path: String(file.path ?? ""),
|
|
502
|
+
content: String(file.content ?? ""),
|
|
503
|
+
reason: String(file.reason ?? "selected file"),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (typeof file === "string") {
|
|
507
|
+
return { path: file, content: "", reason: "selected file" };
|
|
508
|
+
}
|
|
509
|
+
return { path: String(file), content: "", reason: "selected file" };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function normalizeRepoContext(repoContext: RepoContext | undefined, signals: RepoSignals): RepoContext {
|
|
513
|
+
if (repoContext && Array.isArray(repoContext.summaryLines) && Array.isArray(repoContext.selectedFiles)) {
|
|
514
|
+
return {
|
|
515
|
+
summaryLines: repoContext.summaryLines.map((line) => String(line)),
|
|
516
|
+
selectedFiles: repoContext.selectedFiles.map((file) => normalizeSelectedFile(file)),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return buildRepoContext(signals);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function buildCommandIntent(mode: DraftMode, signals: RepoSignals): CommandIntent[] {
|
|
523
|
+
if (mode === "analysis") {
|
|
524
|
+
const commands: CommandIntent[] = [{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20, source: "heuristic" }];
|
|
525
|
+
if (signals.hasGit) commands.unshift({ name: "git-log", run: "git log --oneline -10", timeout: 20, source: "heuristic" });
|
|
526
|
+
return commands;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const commands: CommandIntent[] = [];
|
|
530
|
+
if (signals.testCommand) commands.push({ name: "tests", run: signals.testCommand, timeout: 120, source: "repo-signal" });
|
|
531
|
+
if (signals.lintCommand) commands.push({ name: "lint", run: signals.lintCommand, timeout: 90, source: "repo-signal" });
|
|
532
|
+
if (signals.hasGit) commands.push({ name: "git-log", run: "git log --oneline -10", timeout: 20, source: "heuristic" });
|
|
533
|
+
if (commands.length === 0) commands.push({ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20, source: "heuristic" });
|
|
534
|
+
return commands;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function suggestedCommandsForMode(mode: DraftMode, signals: RepoSignals): CommandDef[] {
|
|
538
|
+
return buildCommandIntent(mode, signals).map(({ source: _source, ...command }) => command);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function formatCommandLabel(command: CommandDef): string {
|
|
542
|
+
return `${command.name}: ${command.run}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function extractVisibleTask(body: string): string | undefined {
|
|
546
|
+
const match = body.match(/^Task:\s*(.+)$/m);
|
|
547
|
+
return match?.[1]?.trim() || undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontmatter {
|
|
551
|
+
const guardrails = {
|
|
552
|
+
blockCommands: ["git\\s+push"],
|
|
553
|
+
protectedFiles: mode === "analysis" ? [] : [SECRET_PATH_POLICY_TOKEN],
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
commands,
|
|
557
|
+
maxIterations: mode === "analysis" ? 12 : mode === "migration" ? 30 : 25,
|
|
558
|
+
timeout: 300,
|
|
559
|
+
guardrails,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function renderDraftBody(task: string, mode: DraftMode, commands: CommandDef[]): string {
|
|
564
|
+
const commandSections = commands.map((command) => bodySection(command.name === "git-log" ? "Recent git history" : `Latest ${command.name} output`, `{{ commands.${command.name} }}`));
|
|
565
|
+
return mode === "analysis"
|
|
566
|
+
? [
|
|
567
|
+
`Task: ${escapeHtmlCommentMarkers(task)}`,
|
|
568
|
+
"",
|
|
569
|
+
...commandSections,
|
|
570
|
+
"",
|
|
571
|
+
"Start with read-only inspection. Avoid edits and commits until you have a clear plan.",
|
|
572
|
+
"Map the architecture, identify entry points, and summarize the important moving parts.",
|
|
573
|
+
"End each iteration with concrete findings, open questions, and the next files to inspect.",
|
|
574
|
+
"Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
|
|
575
|
+
].join("\n")
|
|
576
|
+
: [
|
|
577
|
+
`Task: ${escapeHtmlCommentMarkers(task)}`,
|
|
578
|
+
"",
|
|
579
|
+
...commandSections,
|
|
580
|
+
"",
|
|
581
|
+
mode === "fix" ? "If tests or lint are failing, fix those failures before starting new work." : "Make the smallest safe change that moves the task forward.",
|
|
582
|
+
"Prefer concrete, verifiable progress. Explain why your change works.",
|
|
583
|
+
"Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
|
|
584
|
+
].join("\n");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function commandIntentsToCommands(commandIntents: CommandIntent[]): CommandDef[] {
|
|
588
|
+
return commandIntents.map(({ source: _source, ...command }) => command);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, frontmatter: Frontmatter, source: DraftSource, body: string): DraftPlan {
|
|
592
|
+
const metadata: DraftMetadata = { generator: "pi-ralph-loop", version: 2, source, task, mode };
|
|
593
|
+
const frontmatterLines = [
|
|
594
|
+
...renderCommandsYaml(frontmatter.commands),
|
|
595
|
+
`max_iterations: ${frontmatter.maxIterations}`,
|
|
596
|
+
`timeout: ${frontmatter.timeout}`,
|
|
597
|
+
"guardrails:",
|
|
598
|
+
" block_commands:",
|
|
599
|
+
...frontmatter.guardrails.blockCommands.map((pattern) => ` - ${yamlQuote(pattern)}`),
|
|
600
|
+
" protected_files:",
|
|
601
|
+
...frontmatter.guardrails.protectedFiles.map((pattern) => ` - ${yamlQuote(pattern)}`),
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
task,
|
|
606
|
+
mode,
|
|
607
|
+
target,
|
|
608
|
+
source,
|
|
609
|
+
content: `${metadataComment(metadata)}\n${yamlBlock(frontmatterLines)}\n\n${body}`,
|
|
610
|
+
commandLabels: frontmatter.commands.map(formatCommandLabel),
|
|
611
|
+
safetyLabel: summarizeSafetyLabel(frontmatter.guardrails),
|
|
612
|
+
finishLabel: summarizeFinishLabel(frontmatter.maxIterations),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function generateDraftFromRequest(request: Omit<DraftRequest, "baselineDraft">, source: DraftSource): DraftPlan {
|
|
617
|
+
const commands = commandIntentsToCommands(request.commandIntent);
|
|
618
|
+
const frontmatter = buildDraftFrontmatter(request.mode, commands);
|
|
619
|
+
return renderDraftPlan(request.task, request.mode, request.target, frontmatter, source, renderDraftBody(request.task, request.mode, commands));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function buildDraftRequest(task: string, target: DraftTarget, repoSignals: RepoSignals, repoContext?: RepoContext): DraftRequest {
|
|
623
|
+
const mode = classifyTaskMode(task);
|
|
624
|
+
const commandIntents = buildCommandIntent(mode, repoSignals);
|
|
625
|
+
const request: Omit<DraftRequest, "baselineDraft"> = {
|
|
626
|
+
task,
|
|
627
|
+
mode,
|
|
628
|
+
target,
|
|
629
|
+
repoSignals,
|
|
630
|
+
repoContext: normalizeRepoContext(repoContext, repoSignals),
|
|
631
|
+
commandIntent: commandIntents,
|
|
632
|
+
};
|
|
633
|
+
return { ...request, baselineDraft: generateDraftFromRequest(request, "deterministic").content };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function normalizeStrengthenedDraft(request: DraftRequest, strengthenedDraft: string, scope: DraftStrengtheningScope): DraftPlan {
|
|
637
|
+
const baseline = parseRalphMarkdown(request.baselineDraft);
|
|
638
|
+
const strengthened = parseRalphMarkdown(strengthenedDraft);
|
|
639
|
+
|
|
640
|
+
if (scope === "body-only") {
|
|
641
|
+
return renderDraftPlan(request.task, request.mode, request.target, baseline.frontmatter, "llm-strengthened", strengthened.body);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return renderDraftPlan(request.task, request.mode, request.target, strengthened.frontmatter, "llm-strengthened", strengthened.body);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function hasFakeRuntimeEnforcementClaim(text: string): boolean {
|
|
648
|
+
return /read[-\s]?only enforced|write protection is enforced/i.test(text);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function isWeakStrengthenedDraft(baselineBody: string, analysisText: string, strengthenedBody: string): boolean {
|
|
652
|
+
return baselineBody.trim() === strengthenedBody.trim() || hasFakeRuntimeEnforcementClaim(analysisText) || hasFakeRuntimeEnforcementClaim(strengthenedBody);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function generateDraft(task: string, target: DraftTarget, signals: RepoSignals): DraftPlan {
|
|
656
|
+
const request = buildDraftRequest(task, target, signals);
|
|
657
|
+
return generateDraftFromRequest(request, "deterministic");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function extractDraftMetadata(raw: string): DraftMetadata | undefined {
|
|
661
|
+
const match = raw.match(/^<!-- pi-ralph-loop: (.+?) -->/);
|
|
662
|
+
if (!match) return undefined;
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
const parsed: unknown = JSON.parse(decodeDraftMetadata(match[1]));
|
|
666
|
+
if (!isRecord(parsed) || parsed.generator !== "pi-ralph-loop") return undefined;
|
|
667
|
+
if (!isDraftMode(parsed.mode) || typeof parsed.task !== "string") return undefined;
|
|
668
|
+
|
|
669
|
+
if (parsed.version === 1) {
|
|
670
|
+
return { generator: "pi-ralph-loop", version: 1, task: parsed.task, mode: parsed.mode };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (parsed.version === 2 && isDraftSource(parsed.source)) {
|
|
674
|
+
return { generator: "pi-ralph-loop", version: 2, source: parsed.source, task: parsed.task, mode: parsed.mode };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return undefined;
|
|
678
|
+
} catch {
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function shouldValidateExistingDraft(raw: string): boolean {
|
|
684
|
+
return extractDraftMetadata(raw) !== undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export type DraftContentInspection = {
|
|
688
|
+
metadata?: DraftMetadata;
|
|
689
|
+
parsed?: ParsedRalph;
|
|
690
|
+
error?: string;
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
export function inspectDraftContent(raw: string): DraftContentInspection {
|
|
694
|
+
const metadata = extractDraftMetadata(raw);
|
|
695
|
+
const normalized = normalizeRawRalph(raw);
|
|
696
|
+
|
|
697
|
+
if (!hasRalphFrontmatter(normalized)) {
|
|
698
|
+
return { metadata, error: "Missing RALPH frontmatter" };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
const parsed = parseRalphMarkdown(normalized);
|
|
703
|
+
const error = validateFrontmatter(parsed.frontmatter);
|
|
704
|
+
return error ? { metadata, parsed, error } : { metadata, parsed };
|
|
705
|
+
} catch (err) {
|
|
706
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
707
|
+
return { metadata, error: `Invalid RALPH frontmatter: ${message}` };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export function validateDraftContent(raw: string): string | null {
|
|
712
|
+
return inspectDraftContent(raw).error ?? null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function buildMissionBrief(plan: DraftPlan): string {
|
|
716
|
+
const inspection = inspectDraftContent(plan.content);
|
|
717
|
+
const task = extractVisibleTask(inspection.parsed?.body ?? "") ?? inspection.metadata?.task ?? "Task metadata missing from current draft";
|
|
718
|
+
|
|
719
|
+
if (inspection.error) {
|
|
720
|
+
return [
|
|
721
|
+
"Mission Brief",
|
|
722
|
+
"Review what Ralph will do before it starts.",
|
|
723
|
+
"",
|
|
724
|
+
"Task",
|
|
725
|
+
task,
|
|
726
|
+
"",
|
|
727
|
+
"File",
|
|
728
|
+
plan.target.ralphPath,
|
|
729
|
+
"",
|
|
730
|
+
"Draft status",
|
|
731
|
+
`- Invalid RALPH.md: ${inspection.error}`,
|
|
732
|
+
"- Reopen RALPH.md to fix it or cancel",
|
|
733
|
+
].join("\n");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const parsed = inspection.parsed!;
|
|
737
|
+
const mode = inspection.metadata?.mode ?? "general";
|
|
738
|
+
const commandLabels = parsed.frontmatter.commands.map(formatCommandLabel);
|
|
739
|
+
const finishLabel = summarizeFinishLabel(parsed.frontmatter.maxIterations);
|
|
740
|
+
const safetyLabel = summarizeSafetyLabel(parsed.frontmatter.guardrails);
|
|
741
|
+
|
|
742
|
+
return [
|
|
743
|
+
"Mission Brief",
|
|
744
|
+
"Review what Ralph will do before it starts.",
|
|
745
|
+
"",
|
|
746
|
+
"Task",
|
|
747
|
+
task,
|
|
748
|
+
"",
|
|
749
|
+
"File",
|
|
750
|
+
plan.target.ralphPath,
|
|
751
|
+
"",
|
|
752
|
+
"Suggested checks",
|
|
753
|
+
...commandLabels.map((label) => `- ${label}`),
|
|
754
|
+
"",
|
|
755
|
+
"Finish behavior",
|
|
756
|
+
`- ${finishLabel}`,
|
|
757
|
+
"",
|
|
758
|
+
"Safety",
|
|
759
|
+
`- ${safetyLabel}`,
|
|
760
|
+
].join("\n");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export function extractCompletionPromise(text: string): string | undefined {
|
|
764
|
+
const match = text.match(/<promise>([^<]+)<\/promise>/);
|
|
765
|
+
return match?.[1]?.trim() || undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function shouldStopForCompletionPromise(text: string, expected: string): boolean {
|
|
769
|
+
return extractCompletionPromise(text) === expected.trim();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
|
|
773
|
+
const map = new Map(outputs.map((o) => [o.name, o.output]));
|
|
774
|
+
return body
|
|
775
|
+
.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
|
|
776
|
+
.replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
|
|
777
|
+
.replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function renderRalphBody(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
|
|
781
|
+
return resolvePlaceholders(body, outputs, ralph).replace(/<!--[\s\S]*?-->/g, "");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function renderIterationPrompt(body: string, iteration: number, maxIterations: number): string {
|
|
785
|
+
return `[ralph: iteration ${iteration}/${maxIterations}]\n\n${body}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export function shouldWarnForBashFailure(output: string): boolean {
|
|
789
|
+
return /FAIL|ERROR|error:|failed/i.test(output);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function classifyIdleState(timedOut: boolean, idleError?: Error): "ok" | "timeout" | "error" {
|
|
793
|
+
if (timedOut) return "timeout";
|
|
794
|
+
if (idleError) return "error";
|
|
795
|
+
return "ok";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export function shouldResetFailCount(previousSessionFile?: string, nextSessionFile?: string): boolean {
|
|
799
|
+
return Boolean(previousSessionFile && nextSessionFile && previousSessionFile !== nextSessionFile);
|
|
800
|
+
}
|