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 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
- - 说明:v1 是**事后检测,而非沙箱阻断**——它证明改动通过了你的真实验证、且没有改动禁止路径。
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: v1 is **detection, not a sandbox** — it proves the change passed your real verification and didn't
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
+ }
@@ -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 entries = Object.keys(value)
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(value[key])}`);
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
+ }
@@ -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
- > Scope: this is **detection, not prevention**. loopgen ran the verification commands above and diffed the
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
+ }
@@ -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 — referee mode diffs the working tree against a base ref.");
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 = Math.max(loop.stopCriteria.timeoutMinutes || 1, 1) * 60_000;
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: { base, shaBefore, shaAfter, clean },
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: 1,
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 { loop, passed, entry, verification, forbidden, reportPath, dryRun: Boolean(options.dryRun) };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopgen",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Generate bounded, verifiable AI agent configs for Codex, Claude, Cursor, and local models — with safety rails baked in.",
5
5
  "type": "module",
6
6
  "engines": {