loopgen 0.3.0 → 0.4.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/README.md +45 -3
- package/dist/cli.js +21 -1
- package/dist/core/agent-loop.js +167 -0
- package/dist/core/apply-actions.js +77 -0
- package/dist/core/audit.js +6 -2
- package/dist/core/git.js +11 -0
- package/dist/core/model-client.js +63 -0
- package/dist/core/model-config.js +39 -0
- package/dist/core/report.js +30 -5
- package/dist/core/runner.js +87 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,7 +160,26 @@ npm run loopgen -- run test-repair .
|
|
|
160
160
|
|
|
161
161
|
- `--dry-run`:只检查、不写文件。
|
|
162
162
|
- `--base <ref>`:指定对比的 git ref(默认 `HEAD`)。
|
|
163
|
-
- 说明:
|
|
163
|
+
- 说明:referee 模式是**事后检测,而非沙箱阻断**——它证明改动通过了你的真实验证、且没有改动禁止路径。
|
|
164
|
+
|
|
165
|
+
#### driven 模式 —— 让 loopgen 自己跑这个 loop(`--mode driven`)
|
|
166
|
+
|
|
167
|
+
driven 模式从「事后检测」升级为「过程阻断」:loopgen 驱动一个**本地模型**(Ollama 或任意
|
|
168
|
+
OpenAI-compatible 服务)跑有界的 agent 循环,并**在落盘前强制护栏** —— 写禁止路径会在落盘前被拦,
|
|
169
|
+
不在白名单里的命令不会执行,超过迭代/时间上限就停。
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npm run loopgen -- run test-repair . --mode driven --adapter ollama --ollama-model qwen2.5-coder
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
每轮模型给出一小批 JSON 动作(`write_file` / `run_command` / `finish`);loopgen 逐条校验
|
|
176
|
+
(限制在仓库内、拦禁止路径、命令白名单、大小上限),应用允许的动作,跑你的 `verification.commands`,
|
|
177
|
+
再把结果喂回去 —— 直到验证通过、模型 finish、或触达 `maxIterations` / 超时。它写入**同一套**带哈希链的
|
|
178
|
+
审计 + 一份含完整迭代历史(包括每次被拦动作)的证明报告。
|
|
179
|
+
|
|
180
|
+
- local-first:只调用你配置的本地/自托管模型;API key 只按环境变量名读取,绝不记录或写入文件。
|
|
181
|
+
- 需要干净的 git 工作区(`--allow-dirty` 可跳过);`--dry-run` 只预览第一轮提议、不写文件。
|
|
182
|
+
- 诚实说明:**有界 + 强制 + 验证 + 留证 —— 不是沙箱。** 模型仍会提议,loopgen 负责框住、限制、验证、留证。
|
|
164
183
|
|
|
165
184
|
可用 adapter:
|
|
166
185
|
|
|
@@ -391,8 +410,31 @@ in `.loopgen/reports/*.md`. The process exits `0` on pass and `1` on fail, so it
|
|
|
391
410
|
|
|
392
411
|
- `--dry-run` — run the checks, write nothing.
|
|
393
412
|
- `--base <ref>` — git ref to diff against (default `HEAD`).
|
|
394
|
-
- Scope:
|
|
395
|
-
modify forbidden paths; it does not block reads or out-of-tree writes.
|
|
413
|
+
- Scope: referee mode is **detection, not a sandbox** — it proves the change passed your real verification
|
|
414
|
+
and didn't modify forbidden paths; it does not block reads or out-of-tree writes.
|
|
415
|
+
|
|
416
|
+
#### Driven mode — loopgen runs the loop (`--mode driven`)
|
|
417
|
+
|
|
418
|
+
Driven mode goes from *detection* to **prevention**: loopgen drives a **local model** (Ollama or any
|
|
419
|
+
OpenAI-compatible server) through a bounded agentic loop and **enforces guardrails at apply time** — a
|
|
420
|
+
forbidden-path write is blocked *before it lands*, a non-allowlisted command is never run, and the loop
|
|
421
|
+
stops at the iteration/time limit.
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
npm run loopgen -- run test-repair . --mode driven --adapter ollama --ollama-model qwen2.5-coder
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Each iteration the model proposes a small JSON action batch (`write_file` / `run_command` / `finish`);
|
|
428
|
+
loopgen validates every action (root-confined, forbidden paths blocked, command allowlist, size caps),
|
|
429
|
+
applies the allowed ones, runs your `verification.commands`, and feeds the result back — until verification
|
|
430
|
+
passes, the model finishes, or it hits `maxIterations` / the timeout. It writes the **same** hash-chained
|
|
431
|
+
audit + a proof report with the full iteration history (including every blocked attempt).
|
|
432
|
+
|
|
433
|
+
- Local-first: only your configured local/self-hosted model is called; API keys are read by env-var name
|
|
434
|
+
only and never logged or stored.
|
|
435
|
+
- Needs a clean git tree (`--allow-dirty` to override); `--dry-run` previews the first proposal without writing.
|
|
436
|
+
- Honest scope: **bounded + enforced + verified + proven — not a sandbox.** The model still proposes; loopgen
|
|
437
|
+
bounds, confines, verifies, and proves.
|
|
396
438
|
|
|
397
439
|
Available adapters:
|
|
398
440
|
|
package/dist/cli.js
CHANGED
|
@@ -156,6 +156,14 @@ program
|
|
|
156
156
|
.option("--json", "print the run result as JSON")
|
|
157
157
|
.option("--dry-run", "run checks without writing audit, report, or state")
|
|
158
158
|
.option("--no-report", "do not write the markdown proof report")
|
|
159
|
+
.option("--adapter <id>", "driven mode: ollama | openai-compatible")
|
|
160
|
+
.option("--max-iterations <n>", "driven mode: override the loop's max iterations")
|
|
161
|
+
.option("--allow-dirty", "driven mode: allow running with a dirty working tree")
|
|
162
|
+
.option("--ollama-model <model>", "driven mode: Ollama model name")
|
|
163
|
+
.option("--ollama-base-url <url>", "driven mode: Ollama base URL")
|
|
164
|
+
.option("--openai-compatible-model <model>", "driven mode: OpenAI-compatible model name")
|
|
165
|
+
.option("--openai-compatible-base-url <url>", "driven mode: OpenAI-compatible base URL")
|
|
166
|
+
.option("--openai-compatible-api-key-env <name>", "driven mode: env var name for the API key")
|
|
159
167
|
.description("Run a loop's verification against the working tree and write a tamper-evident proof.")
|
|
160
168
|
.action(async (loop, project, options) => {
|
|
161
169
|
const result = await runLoop({
|
|
@@ -165,7 +173,15 @@ program
|
|
|
165
173
|
base: options.base,
|
|
166
174
|
loopsFile: options.loopsFile,
|
|
167
175
|
dryRun: options.dryRun,
|
|
168
|
-
writeReport: options.report
|
|
176
|
+
writeReport: options.report,
|
|
177
|
+
allowDirty: options.allowDirty,
|
|
178
|
+
adapter: options.adapter === "openai-compatible" ? "openai-compatible" : options.adapter === "ollama" ? "ollama" : undefined,
|
|
179
|
+
maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined,
|
|
180
|
+
ollamaModel: options.ollamaModel,
|
|
181
|
+
ollamaBaseUrl: options.ollamaBaseUrl,
|
|
182
|
+
openaiCompatibleModel: options.openaiCompatibleModel,
|
|
183
|
+
openaiCompatibleBaseUrl: options.openaiCompatibleBaseUrl,
|
|
184
|
+
openaiCompatibleApiKeyEnv: options.openaiCompatibleApiKeyEnv
|
|
169
185
|
});
|
|
170
186
|
if (options.json) {
|
|
171
187
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -228,6 +244,10 @@ function printGenerationSummary(result) {
|
|
|
228
244
|
}
|
|
229
245
|
function printRunResult(result) {
|
|
230
246
|
console.log(`${result.passed ? "PASS" : "FAIL"} — loop ${result.loop.id} (${result.entry.mode}${result.dryRun ? ", dry-run" : ""})`);
|
|
247
|
+
if (result.entry.driven) {
|
|
248
|
+
const blocked = result.entry.driven.attempts.reduce((sum, attempt) => sum + attempt.blocked.length, 0);
|
|
249
|
+
console.log(` driven: ${result.entry.iterations} iteration(s), stop=${result.entry.driven.stopReason}, blocked=${blocked}`);
|
|
250
|
+
}
|
|
231
251
|
for (const command of result.verification.results) {
|
|
232
252
|
const mark = command.timedOut ? "timeout" : command.exitCode === 0 ? "ok" : `exit ${command.exitCode}`;
|
|
233
253
|
console.log(` verify: ${command.command} — ${mark}`);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { applyActions } from "./apply-actions.js";
|
|
2
|
+
import { runVerification } from "./verify.js";
|
|
3
|
+
export async function runDrivenLoop(options) {
|
|
4
|
+
const { projectRoot, loop, modelClient, timeoutMs, deadline } = options;
|
|
5
|
+
const system = buildSystemPrompt();
|
|
6
|
+
const budget = { filesWritten: 0, bytesWritten: 0 };
|
|
7
|
+
const logs = [];
|
|
8
|
+
let lastVerification;
|
|
9
|
+
let prevSignature;
|
|
10
|
+
let feedback = { blocked: [] };
|
|
11
|
+
const iterCap = options.dryRun ? 1 : Math.max(options.maxIterations, 1);
|
|
12
|
+
for (let iteration = 1; iteration <= iterCap; iteration += 1) {
|
|
13
|
+
if (Date.now() > deadline) {
|
|
14
|
+
return { passed: false, stopReason: "timeout", iterations: logs, lastVerification };
|
|
15
|
+
}
|
|
16
|
+
const messages = [
|
|
17
|
+
{ role: "system", content: system },
|
|
18
|
+
{ role: "user", content: buildUserPrompt(loop, iteration, feedback) }
|
|
19
|
+
];
|
|
20
|
+
const raw = await modelClient.chat(messages);
|
|
21
|
+
const log = { iteration, reasoning: "", applied: [], blocked: [] };
|
|
22
|
+
const parsed = parseModelTurn(raw);
|
|
23
|
+
if (!parsed.ok) {
|
|
24
|
+
log.parseError = parsed.reason;
|
|
25
|
+
logs.push(log);
|
|
26
|
+
feedback = { blocked: [], parseError: parsed.reason };
|
|
27
|
+
lastVerification = undefined;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const turn = parsed.turn;
|
|
31
|
+
log.reasoning = turn.reasoning;
|
|
32
|
+
const hasFinish = turn.actions.some((action) => action.type === "finish");
|
|
33
|
+
const batch = await applyActions(projectRoot, turn.actions, loop, budget, { timeoutMs, dryRun: options.dryRun });
|
|
34
|
+
log.applied = batch.applied;
|
|
35
|
+
log.blocked = batch.blocked;
|
|
36
|
+
if (options.dryRun) {
|
|
37
|
+
logs.push(log);
|
|
38
|
+
return { passed: false, stopReason: "finish", iterations: logs, lastVerification };
|
|
39
|
+
}
|
|
40
|
+
const verification = await runVerification(loop.verification.commands, {
|
|
41
|
+
cwd: projectRoot,
|
|
42
|
+
timeoutMs,
|
|
43
|
+
allowedCommands: loop.permissions.allowedCommands
|
|
44
|
+
});
|
|
45
|
+
log.verification = verification;
|
|
46
|
+
logs.push(log);
|
|
47
|
+
lastVerification = verification;
|
|
48
|
+
feedback = { verification, blocked: batch.blocked };
|
|
49
|
+
if (verification.passed) {
|
|
50
|
+
return { passed: true, stopReason: "verified", iterations: logs, lastVerification };
|
|
51
|
+
}
|
|
52
|
+
if (hasFinish) {
|
|
53
|
+
return { passed: verification.passed, stopReason: "finish", iterations: logs, lastVerification };
|
|
54
|
+
}
|
|
55
|
+
const signature = JSON.stringify({ actions: turn.actions, codes: verification.results.map((result) => result.exitCode) });
|
|
56
|
+
if (signature === prevSignature) {
|
|
57
|
+
return { passed: false, stopReason: "repeated-failure", iterations: logs, lastVerification };
|
|
58
|
+
}
|
|
59
|
+
prevSignature = signature;
|
|
60
|
+
}
|
|
61
|
+
return { passed: lastVerification?.passed ?? false, stopReason: "max-iterations", iterations: logs, lastVerification };
|
|
62
|
+
}
|
|
63
|
+
export function parseModelTurn(raw) {
|
|
64
|
+
const candidates = [raw, stripFences(raw), extractBraced(raw)].filter((value) => Boolean(value));
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
const turn = coerceTurn(JSON.parse(candidate));
|
|
68
|
+
if (turn)
|
|
69
|
+
return { ok: true, turn };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// try the next candidate
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, reason: "Response was not valid JSON with an actions array." };
|
|
76
|
+
}
|
|
77
|
+
function coerceTurn(value) {
|
|
78
|
+
if (!value || typeof value !== "object")
|
|
79
|
+
return null;
|
|
80
|
+
const object = value;
|
|
81
|
+
if (!Array.isArray(object.actions))
|
|
82
|
+
return null;
|
|
83
|
+
const actions = [];
|
|
84
|
+
for (const raw of object.actions) {
|
|
85
|
+
const action = coerceAction(raw);
|
|
86
|
+
if (action)
|
|
87
|
+
actions.push(action);
|
|
88
|
+
}
|
|
89
|
+
return { reasoning: typeof object.reasoning === "string" ? object.reasoning : "", actions };
|
|
90
|
+
}
|
|
91
|
+
function coerceAction(value) {
|
|
92
|
+
if (!value || typeof value !== "object")
|
|
93
|
+
return null;
|
|
94
|
+
const action = value;
|
|
95
|
+
if (action.type === "write_file" && typeof action.path === "string" && typeof action.content === "string") {
|
|
96
|
+
return { type: "write_file", path: action.path, content: action.content };
|
|
97
|
+
}
|
|
98
|
+
if (action.type === "delete_file" && typeof action.path === "string") {
|
|
99
|
+
return { type: "delete_file", path: action.path };
|
|
100
|
+
}
|
|
101
|
+
if (action.type === "run_command" && typeof action.command === "string") {
|
|
102
|
+
return { type: "run_command", command: action.command };
|
|
103
|
+
}
|
|
104
|
+
if (action.type === "finish") {
|
|
105
|
+
return { type: "finish", summary: typeof action.summary === "string" ? action.summary : "" };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function stripFences(raw) {
|
|
110
|
+
const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
111
|
+
return match ? match[1].trim() : undefined;
|
|
112
|
+
}
|
|
113
|
+
function extractBraced(raw) {
|
|
114
|
+
const start = raw.indexOf("{");
|
|
115
|
+
const end = raw.lastIndexOf("}");
|
|
116
|
+
if (start === -1 || end <= start)
|
|
117
|
+
return undefined;
|
|
118
|
+
return raw.slice(start, end + 1);
|
|
119
|
+
}
|
|
120
|
+
function buildSystemPrompt() {
|
|
121
|
+
return `You are loopgen's bounded maker. You work on a software repository through a strict protocol.
|
|
122
|
+
|
|
123
|
+
Reply with ONLY a single JSON object, no prose outside it:
|
|
124
|
+
{
|
|
125
|
+
"reasoning": "one short sentence",
|
|
126
|
+
"actions": [
|
|
127
|
+
{ "type": "write_file", "path": "relative/path.ext", "content": "full new file contents" },
|
|
128
|
+
{ "type": "delete_file", "path": "relative/path.ext" },
|
|
129
|
+
{ "type": "run_command", "command": "an allowed command" },
|
|
130
|
+
{ "type": "finish", "summary": "why you are done" }
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Hard rules:
|
|
135
|
+
- Paths must be RELATIVE and inside the repository. Never use absolute paths or "..".
|
|
136
|
+
- Never write to forbidden paths. Only run commands from the allowed list.
|
|
137
|
+
- write_file replaces the entire file with "content". Make the smallest change that satisfies the goal.
|
|
138
|
+
- Emit a "finish" action when verification should pass or you cannot make progress.`;
|
|
139
|
+
}
|
|
140
|
+
function buildUserPrompt(loop, iteration, feedback) {
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(`Iteration ${iteration}.`);
|
|
143
|
+
lines.push(`\nGoal:\n${loop.goal}`);
|
|
144
|
+
lines.push(`\nAcceptance criteria: ${loop.verification.acceptanceCriteria}`);
|
|
145
|
+
if (loop.contextSources.length)
|
|
146
|
+
lines.push(`\nContext sources:\n${loop.contextSources.map((source) => `- ${source}`).join("\n")}`);
|
|
147
|
+
lines.push(`\nVerification commands (these define success):\n${loop.verification.commands.map((command) => `- ${command}`).join("\n") || "- (none)"}`);
|
|
148
|
+
lines.push(`\nForbidden paths (writes here are BLOCKED): ${loop.permissions.forbiddenPaths.join(", ") || "(none)"}`);
|
|
149
|
+
lines.push(`Allowed commands: ${loop.permissions.allowedCommands.join(", ") || "(none — do not use run_command)"}`);
|
|
150
|
+
if (feedback.parseError) {
|
|
151
|
+
lines.push(`\nYour previous response was invalid (${feedback.parseError}). Reply with ONLY the JSON object.`);
|
|
152
|
+
}
|
|
153
|
+
if (feedback.verification) {
|
|
154
|
+
const failed = feedback.verification.results.filter((result) => result.exitCode !== 0 || result.timedOut);
|
|
155
|
+
lines.push(`\nPrevious verification: ${feedback.verification.passed ? "PASSED" : "FAILED"}.`);
|
|
156
|
+
for (const result of failed) {
|
|
157
|
+
lines.push(`Command \`${result.command}\` exited ${result.timedOut ? "TIMEOUT" : result.exitCode}:\n${truncate(result.stdoutExcerpt || result.stderrExcerpt, 1500)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (feedback.blocked.length) {
|
|
161
|
+
lines.push(`\nBlocked last turn (do not retry): ${feedback.blocked.map((block) => `${block.type} ${block.target} (${block.reason})`).join("; ")}`);
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
function truncate(value, max) {
|
|
166
|
+
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { checkForbiddenPaths } from "./forbidden.js";
|
|
4
|
+
import { runVerification } from "./verify.js";
|
|
5
|
+
export const APPLY_LIMITS = {
|
|
6
|
+
maxFilesPerRun: 50,
|
|
7
|
+
maxBytesPerFile: 512 * 1024,
|
|
8
|
+
maxBytesPerRun: 2 * 1024 * 1024
|
|
9
|
+
};
|
|
10
|
+
// Validate FIRST, mutate SECOND. Forbidden-path writes, path escapes, non-allowlisted commands, and
|
|
11
|
+
// over-budget writes are blocked before anything touches disk — prevention, not just detection.
|
|
12
|
+
export async function applyActions(projectRoot, actions, loop, budget, options) {
|
|
13
|
+
const root = path.resolve(projectRoot);
|
|
14
|
+
const applied = [];
|
|
15
|
+
const blocked = [];
|
|
16
|
+
const commandResults = [];
|
|
17
|
+
for (const action of actions) {
|
|
18
|
+
if (action.type === "finish")
|
|
19
|
+
continue;
|
|
20
|
+
if (action.type === "write_file" || action.type === "delete_file") {
|
|
21
|
+
const rel = toRelative(root, action.path);
|
|
22
|
+
if (rel === null) {
|
|
23
|
+
blocked.push({ type: action.type, target: action.path, reason: "path-escape" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const forbidden = checkForbiddenPaths([rel], loop.permissions.forbiddenPaths);
|
|
27
|
+
if (!forbidden.ok) {
|
|
28
|
+
blocked.push({ type: action.type, target: rel, reason: "forbidden-path", pattern: forbidden.violations[0].pattern });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (action.type === "write_file") {
|
|
32
|
+
const bytes = Buffer.byteLength(action.content, "utf8");
|
|
33
|
+
if (bytes > APPLY_LIMITS.maxBytesPerFile ||
|
|
34
|
+
budget.filesWritten >= APPLY_LIMITS.maxFilesPerRun ||
|
|
35
|
+
budget.bytesWritten + bytes > APPLY_LIMITS.maxBytesPerRun) {
|
|
36
|
+
blocked.push({ type: action.type, target: rel, reason: "limit-exceeded" });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!options.dryRun) {
|
|
40
|
+
const absolute = path.join(root, rel);
|
|
41
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
42
|
+
await fs.writeFile(absolute, action.content, "utf8");
|
|
43
|
+
}
|
|
44
|
+
budget.filesWritten += 1;
|
|
45
|
+
budget.bytesWritten += bytes;
|
|
46
|
+
}
|
|
47
|
+
else if (!options.dryRun) {
|
|
48
|
+
await fs.rm(path.join(root, rel), { force: true }).catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
applied.push({ type: action.type, target: rel });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// run_command — only exact matches of the loop's allowed commands may execute.
|
|
54
|
+
if (!loop.permissions.allowedCommands.includes(action.command)) {
|
|
55
|
+
blocked.push({ type: "run_command", target: action.command, reason: "command-not-allowed" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!options.dryRun) {
|
|
59
|
+
const result = await runVerification([action.command], { cwd: root, timeoutMs: options.timeoutMs });
|
|
60
|
+
commandResults.push(...result.results);
|
|
61
|
+
}
|
|
62
|
+
applied.push({ type: "run_command", target: action.command });
|
|
63
|
+
}
|
|
64
|
+
return { applied, blocked, commandResults };
|
|
65
|
+
}
|
|
66
|
+
function toRelative(root, candidate) {
|
|
67
|
+
if (typeof candidate !== "string" || candidate.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
if (path.isAbsolute(candidate))
|
|
70
|
+
return null;
|
|
71
|
+
if (candidate.split(/[\\/]/).includes(".."))
|
|
72
|
+
return null;
|
|
73
|
+
const resolved = path.resolve(root, candidate);
|
|
74
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep))
|
|
75
|
+
return null;
|
|
76
|
+
return path.relative(root, resolved).replace(/\\/g, "/");
|
|
77
|
+
}
|
package/dist/core/audit.js
CHANGED
|
@@ -3,14 +3,18 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
export const AUDIT_FILE = ".loopgen/audit.jsonl";
|
|
5
5
|
// Deterministic JSON: object keys sorted recursively so the hash is stable across runs/machines.
|
|
6
|
+
// undefined-valued keys are skipped (matching JSON.stringify) so the in-memory hash equals the hash
|
|
7
|
+
// recomputed from the JSON-round-tripped entry — otherwise dropped keys would break the chain.
|
|
6
8
|
function canonicalize(value) {
|
|
7
9
|
if (value === null || typeof value !== "object")
|
|
8
10
|
return JSON.stringify(value);
|
|
9
11
|
if (Array.isArray(value))
|
|
10
12
|
return `[${value.map(canonicalize).join(",")}]`;
|
|
11
|
-
const
|
|
13
|
+
const record = value;
|
|
14
|
+
const entries = Object.keys(record)
|
|
15
|
+
.filter((key) => record[key] !== undefined)
|
|
12
16
|
.sort()
|
|
13
|
-
.map((key) => `${JSON.stringify(key)}:${canonicalize(
|
|
17
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`);
|
|
14
18
|
return `{${entries.join(",")}}`;
|
|
15
19
|
}
|
|
16
20
|
export function hashEntry(input, prevHash) {
|
package/dist/core/git.js
CHANGED
|
@@ -36,6 +36,17 @@ export async function isClean(root) {
|
|
|
36
36
|
const { stdout } = await git(root, ["status", "--porcelain"]);
|
|
37
37
|
return stdout.trim().length === 0;
|
|
38
38
|
}
|
|
39
|
+
// Changed paths from `git status --porcelain`, excluding loopgen's own output (.loopgen/).
|
|
40
|
+
// Used as the driven-mode precondition so a prior `loopgen apply` doesn't count as a dirty tree.
|
|
41
|
+
export async function dirtyPathsOutsideLoopgen(root) {
|
|
42
|
+
const { stdout } = await git(root, ["status", "--porcelain"]);
|
|
43
|
+
return stdout
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((line) => line.slice(3).trim())
|
|
46
|
+
.map((entry) => (entry.includes(" -> ") ? entry.split(" -> ")[1].trim() : entry))
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.filter((file) => !file.replace(/\\/g, "/").startsWith(".loopgen/"));
|
|
49
|
+
}
|
|
39
50
|
export async function changedFiles(root, base) {
|
|
40
51
|
const hasCommits = (await headSha(root)) !== null;
|
|
41
52
|
const tracked = hasCommits
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function createModelClient(config) {
|
|
2
|
+
return config.adapterId === "ollama" ? new OllamaClient(config) : new OpenAiCompatibleClient(config);
|
|
3
|
+
}
|
|
4
|
+
class HttpModelClient {
|
|
5
|
+
config;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
async post(url, body, headers) {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
signal: controller.signal
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw connectionError(this.config.baseUrl, error);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`Local model returned HTTP ${response.status} from ${this.config.baseUrl}`);
|
|
29
|
+
}
|
|
30
|
+
return response.json();
|
|
31
|
+
}
|
|
32
|
+
authHeader() {
|
|
33
|
+
if (!this.config.apiKeyEnv)
|
|
34
|
+
return {};
|
|
35
|
+
const value = process.env[this.config.apiKeyEnv];
|
|
36
|
+
if (!value) {
|
|
37
|
+
throw new Error(`Environment variable ${this.config.apiKeyEnv} is not set (needed for the local model API key).`);
|
|
38
|
+
}
|
|
39
|
+
return { Authorization: `Bearer ${value}` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
class OllamaClient extends HttpModelClient {
|
|
43
|
+
async chat(messages) {
|
|
44
|
+
const json = (await this.post(`${trimSlash(this.config.baseUrl)}/api/chat`, { model: this.config.model, stream: false, format: "json", messages }, {}));
|
|
45
|
+
return json.message?.content ?? "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class OpenAiCompatibleClient extends HttpModelClient {
|
|
49
|
+
async chat(messages) {
|
|
50
|
+
const json = (await this.post(`${trimSlash(this.config.baseUrl)}/chat/completions`, { model: this.config.model, messages, response_format: { type: "json_object" } }, this.authHeader()));
|
|
51
|
+
return json.choices?.[0]?.message?.content ?? "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function trimSlash(url) {
|
|
55
|
+
return url.replace(/\/+$/, "");
|
|
56
|
+
}
|
|
57
|
+
function connectionError(baseUrl, error) {
|
|
58
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
59
|
+
if (/abort/i.test(reason)) {
|
|
60
|
+
return new Error(`Local model request to ${baseUrl} timed out.`);
|
|
61
|
+
}
|
|
62
|
+
return new Error(`Could not reach the local model at ${baseUrl} (is Ollama or your server running?).`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ADAPTER_PRESETS } from "./adapters.js";
|
|
4
|
+
const MODEL_TIMEOUT_MS = 180_000;
|
|
5
|
+
// Precedence: CLI flags ▸ .loopgen/adapters/<id>/config.json ▸ defaults.
|
|
6
|
+
export async function resolveModelConfig(projectRoot, options) {
|
|
7
|
+
const adapterId = options.adapter ?? "ollama";
|
|
8
|
+
const fileConfig = await readAdapterConfig(projectRoot, adapterId);
|
|
9
|
+
if (adapterId === "ollama") {
|
|
10
|
+
const baseUrl = options.ollamaBaseUrl?.trim() || fileConfig?.baseUrl?.trim() || ADAPTER_PRESETS.ollama.baseUrl;
|
|
11
|
+
const model = options.ollamaModel?.trim() || fileConfig?.model?.trim() || "";
|
|
12
|
+
assertModel(adapterId, model);
|
|
13
|
+
return { adapterId, baseUrl, model, timeoutMs: MODEL_TIMEOUT_MS };
|
|
14
|
+
}
|
|
15
|
+
const baseUrl = options.openaiCompatibleBaseUrl?.trim() || fileConfig?.baseUrl?.trim() || ADAPTER_PRESETS["lm-studio"].baseUrl;
|
|
16
|
+
const model = options.openaiCompatibleModel?.trim() || fileConfig?.model?.trim() || "";
|
|
17
|
+
const apiKeyEnv = options.openaiCompatibleApiKeyEnv?.trim() || fileConfig?.apiKeyEnv?.trim() || undefined;
|
|
18
|
+
assertModel(adapterId, model);
|
|
19
|
+
return { adapterId, baseUrl, model, apiKeyEnv, timeoutMs: MODEL_TIMEOUT_MS };
|
|
20
|
+
}
|
|
21
|
+
async function readAdapterConfig(projectRoot, adapterId) {
|
|
22
|
+
const filePath = path.join(projectRoot, ".loopgen", "adapters", adapterId, "config.json");
|
|
23
|
+
const raw = await fs.readFile(filePath, "utf8").catch(() => undefined);
|
|
24
|
+
if (!raw)
|
|
25
|
+
return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
return { baseUrl: parsed.baseUrl, model: parsed.model, apiKeyEnv: parsed.apiKeyEnv };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function assertModel(adapterId, model) {
|
|
35
|
+
if (!model || model === "TODO_MODEL") {
|
|
36
|
+
const flag = adapterId === "ollama" ? "--ollama-model" : "--openai-compatible-model";
|
|
37
|
+
throw new Error(`No model configured for ${adapterId}. Pass ${flag} <name> or set it in .loopgen/adapters/${adapterId}/config.json.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function renderProofReport(loop, entry, verification) {
|
|
1
|
+
export function renderProofReport(loop, entry, verification, iterationLogs) {
|
|
2
2
|
const banner = entry.passed ? "✅ PASS" : "❌ FAIL";
|
|
3
3
|
const changed = [...entry.changedFiles.tracked, ...entry.changedFiles.untracked];
|
|
4
4
|
const commandBlocks = verification.results
|
|
@@ -48,11 +48,36 @@ ${verification.warnings.length ? `\n> Warnings:\n${verification.warnings.map((wa
|
|
|
48
48
|
## Forbidden paths — ${entry.forbidden.ok ? "clean" : "VIOLATION"}
|
|
49
49
|
|
|
50
50
|
${forbiddenSection}
|
|
51
|
-
|
|
51
|
+
${entry.driven && iterationLogs ? `\n${renderIterationHistory(iterationLogs)}` : ""}
|
|
52
52
|
---
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
> working tree after the work session; it does not sandbox the agent or block reads. The audit entry is
|
|
56
|
-
> hash-chained in \`.loopgen/audit.jsonl\` (tamper-evident against in-place edits).
|
|
54
|
+
${scopeFooter(entry)}
|
|
57
55
|
`;
|
|
58
56
|
}
|
|
57
|
+
function renderIterationHistory(iterationLogs) {
|
|
58
|
+
const blocks = iterationLogs.map((log) => {
|
|
59
|
+
const applied = log.applied.length ? log.applied.map((action) => `${action.type} \`${action.target}\``).join(", ") : "(none)";
|
|
60
|
+
const blocked = log.blocked.length
|
|
61
|
+
? log.blocked.map((block) => `${block.type} \`${block.target}\` — **blocked** (${block.reason}${block.pattern ? ` \`${block.pattern}\`` : ""})`).join("\n - ")
|
|
62
|
+
: "(none)";
|
|
63
|
+
const verify = log.parseError
|
|
64
|
+
? `parse error: ${log.parseError}`
|
|
65
|
+
: log.verification
|
|
66
|
+
? log.verification.passed
|
|
67
|
+
? "verification passed"
|
|
68
|
+
: "verification failed"
|
|
69
|
+
: "no verification";
|
|
70
|
+
return `### Iteration ${log.iteration}
|
|
71
|
+
|
|
72
|
+
${log.reasoning ? `> ${log.reasoning}\n` : ""}- Applied: ${applied}
|
|
73
|
+
- Blocked: ${blocked}
|
|
74
|
+
- ${verify}`;
|
|
75
|
+
});
|
|
76
|
+
return `## Iteration history\n\n${blocks.join("\n\n")}\n`;
|
|
77
|
+
}
|
|
78
|
+
function scopeFooter(entry) {
|
|
79
|
+
if (entry.driven) {
|
|
80
|
+
return `> Scope: **bounded + enforced**. loopgen drove a local model (${entry.driven.model.adapter} · ${entry.driven.model.modelName}), blocked forbidden writes and non-allowlisted commands **at apply time**, bounded iterations, and verified each one (stop reason: ${entry.driven.stopReason}). The model still proposes actions — this is enforcement, not a sandbox. Audit is hash-chained in \`.loopgen/audit.jsonl\`.`;
|
|
81
|
+
}
|
|
82
|
+
return `> Scope: this is **detection, not prevention**. loopgen ran the verification commands above and diffed the working tree after the work session; it does not sandbox the agent or block reads. The audit entry is hash-chained in \`.loopgen/audit.jsonl\` (tamper-evident against in-place edits).`;
|
|
83
|
+
}
|
package/dist/core/runner.js
CHANGED
|
@@ -2,23 +2,28 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { runDrivenLoop } from "./agent-loop.js";
|
|
5
6
|
import { appendAuditEntry, hashEntry, readAuditLog } from "./audit.js";
|
|
6
7
|
import { checkForbiddenPaths } from "./forbidden.js";
|
|
7
8
|
import * as git from "./git.js";
|
|
8
9
|
import { loadLoopFile, selectLoop } from "./loop-file.js";
|
|
10
|
+
import { createModelClient } from "./model-client.js";
|
|
11
|
+
import { resolveModelConfig } from "./model-config.js";
|
|
9
12
|
import { renderProofReport } from "./report.js";
|
|
10
13
|
import { runVerification } from "./verify.js";
|
|
11
14
|
export async function runLoop(options) {
|
|
12
15
|
const projectRoot = path.resolve(options.projectRoot);
|
|
13
16
|
const mode = options.mode ?? "referee";
|
|
14
|
-
if (mode === "driven") {
|
|
15
|
-
throw new Error("Driven mode is not implemented yet (v2). Use --mode referee.");
|
|
16
|
-
}
|
|
17
17
|
if (!(await git.isGitRepo(projectRoot))) {
|
|
18
|
-
throw new Error("loopgen run requires a git repository —
|
|
18
|
+
throw new Error("loopgen run requires a git repository — it diffs the working tree against a base ref.");
|
|
19
19
|
}
|
|
20
20
|
const loopFile = await loadLoopFile(projectRoot, options.loopsFile);
|
|
21
21
|
const loop = selectLoop(loopFile, options.loopId);
|
|
22
|
+
return mode === "driven"
|
|
23
|
+
? runDriven(projectRoot, loop, loopFile, options)
|
|
24
|
+
: runReferee(projectRoot, loop, loopFile, options);
|
|
25
|
+
}
|
|
26
|
+
async function runReferee(projectRoot, loop, loopFile, options) {
|
|
22
27
|
const base = options.base ?? "HEAD";
|
|
23
28
|
const shaBefore = await git.headSha(projectRoot, base);
|
|
24
29
|
const clean = await git.isClean(projectRoot);
|
|
@@ -27,14 +32,53 @@ export async function runLoop(options) {
|
|
|
27
32
|
const shaAfter = await git.headSha(projectRoot, "HEAD");
|
|
28
33
|
const allChanged = [...changed.tracked, ...changed.untracked];
|
|
29
34
|
const forbidden = checkForbiddenPaths(allChanged, loop.permissions.forbiddenPaths);
|
|
30
|
-
const timeoutMs =
|
|
35
|
+
const timeoutMs = commandTimeoutMs(loop);
|
|
31
36
|
const verification = await runVerification(loop.verification.commands, {
|
|
32
37
|
cwd: projectRoot,
|
|
33
38
|
timeoutMs,
|
|
34
39
|
allowedCommands: loop.permissions.allowedCommands
|
|
35
40
|
});
|
|
36
41
|
const passed = verification.passed && forbidden.ok;
|
|
37
|
-
const input = {
|
|
42
|
+
const input = baseEntry(loopFile, loop, "referee", { base, shaBefore, shaAfter, clean }, changed, diffstat, forbidden, verification, 1, passed);
|
|
43
|
+
return finalize(projectRoot, loop, input, verification, forbidden, options);
|
|
44
|
+
}
|
|
45
|
+
async function runDriven(projectRoot, loop, loopFile, options) {
|
|
46
|
+
if (!options.dryRun && !options.allowDirty) {
|
|
47
|
+
const dirty = await git.dirtyPathsOutsideLoopgen(projectRoot);
|
|
48
|
+
if (dirty.length) {
|
|
49
|
+
throw new Error("Working tree is dirty. Driven mode edits files — commit/stash first, or pass --allow-dirty.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const modelClient = options.modelClient ?? createModelClient(await resolveModelConfig(projectRoot, options));
|
|
53
|
+
const modelMeta = options.modelClient
|
|
54
|
+
? { adapter: "injected", modelName: "injected", baseUrl: "test" }
|
|
55
|
+
: await modelMetaFromConfig(projectRoot, options);
|
|
56
|
+
const base = options.base ?? "HEAD";
|
|
57
|
+
const shaBefore = await git.headSha(projectRoot, base);
|
|
58
|
+
const clean = await git.isClean(projectRoot);
|
|
59
|
+
const timeoutMs = commandTimeoutMs(loop);
|
|
60
|
+
const maxIterations = options.maxIterations ?? loop.stopCriteria.maxIterations;
|
|
61
|
+
const driven = await runDrivenLoop({
|
|
62
|
+
projectRoot,
|
|
63
|
+
loop,
|
|
64
|
+
modelClient,
|
|
65
|
+
maxIterations,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
deadline: Date.now() + timeoutMs,
|
|
68
|
+
dryRun: options.dryRun
|
|
69
|
+
});
|
|
70
|
+
const changed = await git.changedFiles(projectRoot, base);
|
|
71
|
+
const diffstat = await git.diffStat(projectRoot, base);
|
|
72
|
+
const shaAfter = await git.headSha(projectRoot, "HEAD");
|
|
73
|
+
const forbidden = checkForbiddenPaths([...changed.tracked, ...changed.untracked], loop.permissions.forbiddenPaths);
|
|
74
|
+
const verification = driven.lastVerification ?? { passed: false, results: [], warnings: [] };
|
|
75
|
+
const passed = driven.passed && forbidden.ok;
|
|
76
|
+
const input = baseEntry(loopFile, loop, "driven", { base, shaBefore, shaAfter, clean }, changed, diffstat, forbidden, verification, driven.iterations.length, passed);
|
|
77
|
+
input.driven = { stopReason: driven.stopReason, model: modelMeta, attempts: summarizeIterations(driven.iterations) };
|
|
78
|
+
return finalize(projectRoot, loop, input, verification, forbidden, options, driven.iterations);
|
|
79
|
+
}
|
|
80
|
+
function baseEntry(loopFile, loop, mode, gitInfo, changed, diffstat, forbidden, verification, iterations, passed) {
|
|
81
|
+
return {
|
|
38
82
|
schemaVersion: "1",
|
|
39
83
|
entryId: randomUUID(),
|
|
40
84
|
timestamp: new Date().toISOString(),
|
|
@@ -42,7 +86,7 @@ export async function runLoop(options) {
|
|
|
42
86
|
loopId: loop.id,
|
|
43
87
|
mode,
|
|
44
88
|
actor: { user: safe(() => os.userInfo().username), host: safe(() => os.hostname()) },
|
|
45
|
-
git:
|
|
89
|
+
git: gitInfo,
|
|
46
90
|
changedFiles: { tracked: changed.tracked, untracked: changed.untracked, diffstat },
|
|
47
91
|
forbidden: { ok: forbidden.ok, violations: forbidden.violations },
|
|
48
92
|
verification: {
|
|
@@ -54,9 +98,11 @@ export async function runLoop(options) {
|
|
|
54
98
|
durationMs: result.durationMs
|
|
55
99
|
}))
|
|
56
100
|
},
|
|
57
|
-
iterations
|
|
101
|
+
iterations,
|
|
58
102
|
passed
|
|
59
103
|
};
|
|
104
|
+
}
|
|
105
|
+
async function finalize(projectRoot, loop, input, verification, forbidden, options, iterationLogs) {
|
|
60
106
|
let entry;
|
|
61
107
|
if (options.dryRun) {
|
|
62
108
|
const existing = await readAuditLog(projectRoot);
|
|
@@ -72,18 +118,48 @@ export async function runLoop(options) {
|
|
|
72
118
|
reportPath = path.join(".loopgen", "reports", `${loop.id}-${stamp}.md`);
|
|
73
119
|
const absolute = path.join(projectRoot, reportPath);
|
|
74
120
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
75
|
-
await fs.writeFile(absolute, renderProofReport(loop, entry, verification), "utf8");
|
|
121
|
+
await fs.writeFile(absolute, renderProofReport(loop, entry, verification, iterationLogs), "utf8");
|
|
76
122
|
}
|
|
77
123
|
if (!options.dryRun) {
|
|
78
124
|
await appendStateEntry(projectRoot, loop, entry);
|
|
79
125
|
}
|
|
80
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
loop,
|
|
128
|
+
passed: entry.passed,
|
|
129
|
+
entry,
|
|
130
|
+
verification,
|
|
131
|
+
forbidden,
|
|
132
|
+
reportPath,
|
|
133
|
+
dryRun: Boolean(options.dryRun),
|
|
134
|
+
iterationLogs
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function summarizeIterations(logs) {
|
|
138
|
+
return logs.map((log) => ({
|
|
139
|
+
iteration: log.iteration,
|
|
140
|
+
actions: {
|
|
141
|
+
write: log.applied.filter((action) => action.type === "write_file").length,
|
|
142
|
+
delete: log.applied.filter((action) => action.type === "delete_file").length,
|
|
143
|
+
run: log.applied.filter((action) => action.type === "run_command").length,
|
|
144
|
+
finish: 0
|
|
145
|
+
},
|
|
146
|
+
blocked: log.blocked.map((block) => ({ type: block.type, reason: block.reason, pattern: block.pattern })),
|
|
147
|
+
verificationPassed: log.verification?.passed ?? false,
|
|
148
|
+
parseError: log.parseError
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
async function modelMetaFromConfig(projectRoot, options) {
|
|
152
|
+
const config = await resolveModelConfig(projectRoot, options);
|
|
153
|
+
return { adapter: config.adapterId, modelName: config.model, baseUrl: config.baseUrl };
|
|
154
|
+
}
|
|
155
|
+
function commandTimeoutMs(loop) {
|
|
156
|
+
return Math.max(loop.stopCriteria.timeoutMinutes || 1, 1) * 60_000;
|
|
81
157
|
}
|
|
82
158
|
async function appendStateEntry(projectRoot, loop, entry) {
|
|
83
159
|
const stateFile = loop.stateFile || path.join(".loopgen", "state", `${loop.id}.md`);
|
|
84
160
|
const absolute = path.join(projectRoot, stateFile);
|
|
85
161
|
const passedCount = entry.verification.commands.filter((command) => command.exitCode === 0 && !command.timedOut).length;
|
|
86
|
-
const line = `- ${entry.timestamp} — ${entry.passed ? "PASS" : "FAIL"} — iter ${entry.iterations} — verification ${passedCount}/${entry.verification.commands.length} — forbidden ${entry.forbidden.ok ? "ok" : `${entry.forbidden.violations.length} violation(s)`} — audit ${entry.entryId}`;
|
|
162
|
+
const line = `- ${entry.timestamp} — ${entry.passed ? "PASS" : "FAIL"} — ${entry.mode} — iter ${entry.iterations} — verification ${passedCount}/${entry.verification.commands.length} — forbidden ${entry.forbidden.ok ? "ok" : `${entry.forbidden.violations.length} violation(s)`} — audit ${entry.entryId}`;
|
|
87
163
|
const existing = await fs.readFile(absolute, "utf8").catch(() => undefined);
|
|
88
164
|
let next;
|
|
89
165
|
if (existing && existing.includes("- No attempts yet.")) {
|
package/package.json
CHANGED