loopgen 0.2.0 → 0.3.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
@@ -139,10 +139,29 @@ npm run loopgen -- preview [project] --templates all --adapters codex,claude
139
139
  npm run loopgen -- preview [project] --templates test-repair --adapters ollama --ollama-model llama3.1
140
140
  npm run loopgen -- preview [project] --templates test-repair --adapters openai-compatible --openai-compatible-model qwen2.5-coder
141
141
  npm run loopgen -- apply [project] --templates all --adapters codex,claude
142
+ npm run loopgen -- run [loop] [project]
142
143
  ```
143
144
 
144
145
  `apply` 会先展示 diff。没有 `--yes` 时,它会要求你确认后才写入文件。
145
146
 
147
+ ### 运行并验证(loopgen run)
148
+
149
+ 生成配置只是说明,**`loopgen run` 会真正执行验证并留下证据** —— 这是模型本身做不到的。
150
+ 在用任意 agent(Claude Code、Cursor、Codex 等)完成一段有界的改动后,运行:
151
+
152
+ ```bash
153
+ npm run loopgen -- run test-repair .
154
+ ```
155
+
156
+ 它会:对照 git 跑出本次改动 → 执行该 loop 的 `verification.commands` 并按退出码判定通过/失败 →
157
+ 检查是否动了 `forbiddenPaths`(如 `.env`)→ 写入一条**带哈希链、防篡改**的审计记录
158
+ `.loopgen/audit.jsonl`,以及一份可读的「证明报告」`.loopgen/reports/*.md`。通过则进程退出码为 0,
159
+ 失败为 1(便于接入 CI / git hook)。
160
+
161
+ - `--dry-run`:只检查、不写文件。
162
+ - `--base <ref>`:指定对比的 git ref(默认 `HEAD`)。
163
+ - 说明:v1 是**事后检测,而非沙箱阻断**——它证明改动通过了你的真实验证、且没有改动禁止路径。
164
+
146
165
  可用 adapter:
147
166
 
148
167
  - `agents-md`:通用 `AGENTS.md`,可被 Claude Code、Codex、Cursor、Copilot、Gemini CLI、Aider 等读取
@@ -350,10 +369,31 @@ npm run loopgen -- preview [project] --templates all --adapters codex,claude
350
369
  npm run loopgen -- preview [project] --templates test-repair --adapters ollama --ollama-model llama3.1
351
370
  npm run loopgen -- preview [project] --templates test-repair --adapters openai-compatible --openai-compatible-model qwen2.5-coder
352
371
  npm run loopgen -- apply [project] --templates all --adapters codex,claude
372
+ npm run loopgen -- run [loop] [project]
353
373
  ```
354
374
 
355
375
  `apply` always shows a diff first. Without `--yes`, it asks for confirmation before writing files.
356
376
 
377
+ ### Run & prove the work (`loopgen run`)
378
+
379
+ Generating config is just instructions. **`loopgen run` actually runs the verification and leaves proof** —
380
+ something a stateless model cannot do. After you (or any agent — Claude Code, Cursor, Codex) complete a
381
+ bounded change, run:
382
+
383
+ ```bash
384
+ npm run loopgen -- run test-repair .
385
+ ```
386
+
387
+ It diffs your working tree against git, executes the loop's `verification.commands` and gates pass/fail on
388
+ the real exit codes, checks whether any `forbiddenPaths` (e.g. `.env`) were touched, and writes a
389
+ **tamper-evident, hash-chained audit record** to `.loopgen/audit.jsonl` plus a human-readable proof report
390
+ in `.loopgen/reports/*.md`. The process exits `0` on pass and `1` on fail, so it composes into CI / git hooks.
391
+
392
+ - `--dry-run` — run the checks, write nothing.
393
+ - `--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.
396
+
357
397
  Available adapters:
358
398
 
359
399
  - `agents-md` — one `AGENTS.md` read by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, and more
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
7
  import readline from "node:readline/promises";
@@ -11,6 +12,7 @@ import { scanProject } from "./core/scanner.js";
11
12
  import { TEMPLATE_DEFINITIONS } from "./core/templates.js";
12
13
  import { startLoopgenServer } from "./server.js";
13
14
  import { DEFAULT_ADAPTER_IDS, parseAdapterIds } from "./core/adapters.js";
15
+ import { runLoop } from "./core/runner.js";
14
16
  const PROJECT_MANIFESTS = [
15
17
  "package.json",
16
18
  "pyproject.toml",
@@ -25,11 +27,13 @@ const PROJECT_MANIFESTS = [
25
27
  function looksLikeProject(dir) {
26
28
  return PROJECT_MANIFESTS.some((file) => existsSync(path.join(dir, file)));
27
29
  }
30
+ const require = createRequire(import.meta.url);
31
+ const { version } = require("../package.json");
28
32
  const program = new Command();
29
33
  program
30
34
  .name("loopgen")
31
35
  .description("Generate bounded, verifiable AI agent configs for Codex, Claude, Cursor, and local models — with safety rails baked in.")
32
- .version("0.1.0");
36
+ .version(version);
33
37
  program
34
38
  .command("init")
35
39
  .argument("[project]", "project directory", ".")
@@ -142,6 +146,35 @@ program
142
146
  const written = await applyGeneratedFiles(result.scan.root, result.files);
143
147
  console.log(`Wrote ${written.length} files.`);
144
148
  });
149
+ program
150
+ .command("run")
151
+ .argument("[loop]", "loop id from .loopgen/loopgen.loop.yaml")
152
+ .argument("[project]", "project directory", ".")
153
+ .option("--mode <mode>", "referee | driven", "referee")
154
+ .option("--base <ref>", "git ref to diff the working tree against", "HEAD")
155
+ .option("--loops-file <path>", "path to the loop file")
156
+ .option("--json", "print the run result as JSON")
157
+ .option("--dry-run", "run checks without writing audit, report, or state")
158
+ .option("--no-report", "do not write the markdown proof report")
159
+ .description("Run a loop's verification against the working tree and write a tamper-evident proof.")
160
+ .action(async (loop, project, options) => {
161
+ const result = await runLoop({
162
+ projectRoot: path.resolve(project),
163
+ loopId: loop,
164
+ mode: options.mode ?? "referee",
165
+ base: options.base,
166
+ loopsFile: options.loopsFile,
167
+ dryRun: options.dryRun,
168
+ writeReport: options.report
169
+ });
170
+ if (options.json) {
171
+ console.log(JSON.stringify(result, null, 2));
172
+ }
173
+ else {
174
+ printRunResult(result);
175
+ }
176
+ process.exitCode = result.passed ? 0 : 1;
177
+ });
145
178
  program.parseAsync(process.argv).catch((error) => {
146
179
  console.error(error instanceof Error ? error.message : String(error));
147
180
  process.exitCode = 1;
@@ -193,6 +226,25 @@ function printGenerationSummary(result) {
193
226
  console.warn(`Warning: ${warning}`);
194
227
  }
195
228
  }
229
+ function printRunResult(result) {
230
+ console.log(`${result.passed ? "PASS" : "FAIL"} — loop ${result.loop.id} (${result.entry.mode}${result.dryRun ? ", dry-run" : ""})`);
231
+ for (const command of result.verification.results) {
232
+ const mark = command.timedOut ? "timeout" : command.exitCode === 0 ? "ok" : `exit ${command.exitCode}`;
233
+ console.log(` verify: ${command.command} — ${mark}`);
234
+ }
235
+ if (!result.verification.results.length) {
236
+ console.log(" verify: no verification commands configured for this loop");
237
+ }
238
+ for (const violation of result.forbidden.violations) {
239
+ console.log(` forbidden: ${violation.file} (matched ${violation.pattern})`);
240
+ }
241
+ const changed = result.entry.changedFiles.tracked.length + result.entry.changedFiles.untracked.length;
242
+ console.log(` files changed: ${changed}`);
243
+ if (result.reportPath)
244
+ console.log(` report: ${result.reportPath}`);
245
+ if (!result.dryRun)
246
+ console.log(` audit: .loopgen/audit.jsonl (${result.entry.hash.slice(0, 12)}…)`);
247
+ }
196
248
  function formatCommands(commands) {
197
249
  const entries = Object.entries(commands).filter(([, command]) => command);
198
250
  return entries.length ? entries.map(([name, command]) => `${name}=${command}`).join(", ") : "none inferred";
@@ -0,0 +1,52 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ export const AUDIT_FILE = ".loopgen/audit.jsonl";
5
+ // Deterministic JSON: object keys sorted recursively so the hash is stable across runs/machines.
6
+ function canonicalize(value) {
7
+ if (value === null || typeof value !== "object")
8
+ return JSON.stringify(value);
9
+ if (Array.isArray(value))
10
+ return `[${value.map(canonicalize).join(",")}]`;
11
+ const entries = Object.keys(value)
12
+ .sort()
13
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`);
14
+ return `{${entries.join(",")}}`;
15
+ }
16
+ export function hashEntry(input, prevHash) {
17
+ return createHash("sha256").update(canonicalize({ ...input, prevHash })).digest("hex");
18
+ }
19
+ async function readRaw(projectRoot) {
20
+ return fs.readFile(path.join(projectRoot, AUDIT_FILE), "utf8").catch(() => undefined);
21
+ }
22
+ export async function readAuditLog(projectRoot) {
23
+ const raw = await readRaw(projectRoot);
24
+ if (!raw)
25
+ return [];
26
+ return raw
27
+ .split("\n")
28
+ .map((line) => line.trim())
29
+ .filter(Boolean)
30
+ .map((line) => JSON.parse(line));
31
+ }
32
+ export async function appendAuditEntry(projectRoot, input) {
33
+ const existing = await readAuditLog(projectRoot);
34
+ const prevHash = existing.length ? existing[existing.length - 1].hash : null;
35
+ const entry = { ...input, prevHash, hash: hashEntry(input, prevHash) };
36
+ const filePath = path.join(projectRoot, AUDIT_FILE);
37
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
38
+ await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
39
+ return entry;
40
+ }
41
+ export function verifyAuditChain(entries) {
42
+ let prevHash = null;
43
+ for (let index = 0; index < entries.length; index += 1) {
44
+ const { hash, prevHash: storedPrev, ...input } = entries[index];
45
+ if (storedPrev !== prevHash)
46
+ return { valid: false, brokenAt: index };
47
+ if (hashEntry(input, storedPrev) !== hash)
48
+ return { valid: false, brokenAt: index };
49
+ prevHash = hash;
50
+ }
51
+ return { valid: true };
52
+ }
@@ -0,0 +1,38 @@
1
+ // Translate the loop's glob-ish forbidden patterns (e.g. ".env", ".env.*", "secrets/**",
2
+ // "**/*credential*") into anchored RegExps. No glob dependency is needed for these patterns.
3
+ export function globToRegExp(pattern) {
4
+ let regex = "";
5
+ for (let index = 0; index < pattern.length; index += 1) {
6
+ const char = pattern[index];
7
+ if (char === "*") {
8
+ if (pattern[index + 1] === "*") {
9
+ regex += ".*"; // ** crosses path separators
10
+ index += 1;
11
+ }
12
+ else {
13
+ regex += "[^/]*"; // * stays within a path segment
14
+ }
15
+ }
16
+ else if (char === "?") {
17
+ regex += "[^/]";
18
+ }
19
+ else {
20
+ regex += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
21
+ }
22
+ }
23
+ return new RegExp(`^${regex}$`);
24
+ }
25
+ export function checkForbiddenPaths(changed, forbiddenPaths) {
26
+ const matchers = forbiddenPaths.map((pattern) => ({ pattern, regex: globToRegExp(pattern) }));
27
+ const violations = [];
28
+ for (const file of changed) {
29
+ const normalized = file.replace(/\\/g, "/");
30
+ for (const matcher of matchers) {
31
+ if (matcher.regex.test(normalized)) {
32
+ violations.push({ file: normalized, pattern: matcher.pattern });
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ return { ok: violations.length === 0, violations };
38
+ }
@@ -0,0 +1,58 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ async function git(root, args) {
5
+ try {
6
+ const { stdout } = await execFileAsync("git", args, { cwd: root, maxBuffer: 32 * 1024 * 1024 });
7
+ return { stdout, code: 0 };
8
+ }
9
+ catch (error) {
10
+ if (error && typeof error === "object" && "code" in error) {
11
+ const code = error.code;
12
+ const stdout = String(error.stdout ?? "");
13
+ if (typeof code === "number")
14
+ return { stdout, code };
15
+ }
16
+ throw error;
17
+ }
18
+ }
19
+ export async function isGitRepo(root) {
20
+ try {
21
+ const { stdout, code } = await git(root, ["rev-parse", "--is-inside-work-tree"]);
22
+ return code === 0 && stdout.trim() === "true";
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export async function headSha(root, ref = "HEAD") {
29
+ const { stdout, code } = await git(root, ["rev-parse", ref]);
30
+ if (code !== 0)
31
+ return null;
32
+ const sha = stdout.trim();
33
+ return sha.length ? sha : null;
34
+ }
35
+ export async function isClean(root) {
36
+ const { stdout } = await git(root, ["status", "--porcelain"]);
37
+ return stdout.trim().length === 0;
38
+ }
39
+ export async function changedFiles(root, base) {
40
+ const hasCommits = (await headSha(root)) !== null;
41
+ const tracked = hasCommits
42
+ ? splitLines((await git(root, ["diff", "--name-only", base])).stdout)
43
+ : splitLines((await git(root, ["ls-files"])).stdout);
44
+ const untracked = splitLines((await git(root, ["ls-files", "--others", "--exclude-standard"])).stdout);
45
+ return { tracked, untracked };
46
+ }
47
+ export async function diffStat(root, base) {
48
+ if ((await headSha(root)) === null)
49
+ return "(no commits yet — diff against empty tree)";
50
+ const { stdout } = await git(root, ["diff", "--stat", base]);
51
+ return stdout.trimEnd();
52
+ }
53
+ function splitLines(value) {
54
+ return value
55
+ .split("\n")
56
+ .map((line) => line.trim())
57
+ .filter(Boolean);
58
+ }
@@ -0,0 +1,53 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse } from "yaml";
4
+ export const DEFAULT_LOOPS_FILE = ".loopgen/loopgen.loop.yaml";
5
+ export async function loadLoopFile(projectRoot, loopsFile = DEFAULT_LOOPS_FILE) {
6
+ const filePath = path.join(projectRoot, loopsFile);
7
+ const raw = await fs.readFile(filePath, "utf8").catch(() => {
8
+ throw new Error(`No loop file at ${loopsFile}. Run \`loopgen apply\` first to generate it.`);
9
+ });
10
+ const parsed = parse(raw);
11
+ if (!parsed || !Array.isArray(parsed.loops)) {
12
+ throw new Error(`Malformed loop file at ${loopsFile}: missing a "loops" array.`);
13
+ }
14
+ parsed.loops.forEach((loop, index) => assertLoopSpec(loop, index));
15
+ return {
16
+ version: typeof parsed.version === "string" ? parsed.version : "unknown",
17
+ project: typeof parsed.project === "string" ? parsed.project : path.basename(projectRoot),
18
+ loops: parsed.loops
19
+ };
20
+ }
21
+ export function selectLoop(loopFile, id) {
22
+ if (id) {
23
+ const found = loopFile.loops.find((loop) => loop.id === id);
24
+ if (!found) {
25
+ throw new Error(`Unknown loop "${id}". Available: ${loopFile.loops.map((loop) => loop.id).join(", ") || "(none)"}`);
26
+ }
27
+ return found;
28
+ }
29
+ if (loopFile.loops.length === 1)
30
+ return loopFile.loops[0];
31
+ throw new Error(`Multiple loops found — specify one. Available: ${loopFile.loops.map((loop) => loop.id).join(", ")}`);
32
+ }
33
+ // YAML is untrusted at runtime: assert the fields the runner depends on.
34
+ function assertLoopSpec(loop, index) {
35
+ const where = `loops[${index}]`;
36
+ if (!loop || typeof loop !== "object")
37
+ throw new Error(`${where} is not an object.`);
38
+ const value = loop;
39
+ if (typeof value.id !== "string")
40
+ throw new Error(`${where}.id must be a string.`);
41
+ const verification = value.verification;
42
+ if (!verification || !Array.isArray(verification.commands)) {
43
+ throw new Error(`${where}.verification.commands must be an array.`);
44
+ }
45
+ const stopCriteria = value.stopCriteria;
46
+ if (!stopCriteria || typeof stopCriteria.timeoutMinutes !== "number") {
47
+ throw new Error(`${where}.stopCriteria.timeoutMinutes must be a number.`);
48
+ }
49
+ const permissions = value.permissions;
50
+ if (!permissions || !Array.isArray(permissions.forbiddenPaths)) {
51
+ throw new Error(`${where}.permissions.forbiddenPaths must be an array.`);
52
+ }
53
+ }
@@ -0,0 +1,58 @@
1
+ export function renderProofReport(loop, entry, verification) {
2
+ const banner = entry.passed ? "✅ PASS" : "❌ FAIL";
3
+ const changed = [...entry.changedFiles.tracked, ...entry.changedFiles.untracked];
4
+ const commandBlocks = verification.results
5
+ .map((result) => {
6
+ const status = result.timedOut ? "TIMED OUT" : result.exitCode === 0 ? "pass (exit 0)" : `fail (exit ${result.exitCode})`;
7
+ const out = [result.stdoutExcerpt, result.stderrExcerpt].filter(Boolean).join("\n");
8
+ return `#### \`${result.command}\` — ${status} (${result.durationMs} ms)
9
+
10
+ \`\`\`
11
+ ${out || "(no output)"}
12
+ \`\`\``;
13
+ })
14
+ .join("\n\n");
15
+ const forbiddenSection = entry.forbidden.ok
16
+ ? "No forbidden paths were changed."
17
+ : entry.forbidden.violations.map((violation) => `- \`${violation.file}\` matched forbidden pattern \`${violation.pattern}\``).join("\n");
18
+ return `# Proof report — ${loop.title} ${banner}
19
+
20
+ Loop: \`${entry.loopId}\` · Mode: ${entry.mode} · Iterations: ${entry.iterations}
21
+ Generated: ${entry.timestamp}
22
+ By: ${entry.actor.user ?? "unknown"}@${entry.actor.host ?? "unknown"}
23
+ Audit entry: \`${entry.hash}\`
24
+
25
+ ## Goal
26
+
27
+ ${loop.goal}
28
+
29
+ ## Git
30
+
31
+ - Base: \`${entry.git.base}\`
32
+ - Before: \`${entry.git.shaBefore ?? "(no commits)"}\`
33
+ - After: \`${entry.git.shaAfter ?? "(no commits)"}\`
34
+ - Working tree clean at start: ${entry.git.clean ? "yes" : "no"}
35
+
36
+ ## Files changed (${changed.length})
37
+
38
+ ${changed.length ? changed.map((file) => `- \`${file}\``).join("\n") : "- (none)"}
39
+
40
+ \`\`\`
41
+ ${entry.changedFiles.diffstat || "(no diffstat)"}
42
+ \`\`\`
43
+
44
+ ## Verification — ${verification.passed ? "passed" : "failed"}
45
+
46
+ ${commandBlocks || "_No verification commands were configured for this loop._"}
47
+ ${verification.warnings.length ? `\n> Warnings:\n${verification.warnings.map((warning) => `> - ${warning}`).join("\n")}\n` : ""}
48
+ ## Forbidden paths — ${entry.forbidden.ok ? "clean" : "VIOLATION"}
49
+
50
+ ${forbiddenSection}
51
+
52
+ ---
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).
57
+ `;
58
+ }
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { appendAuditEntry, hashEntry, readAuditLog } from "./audit.js";
6
+ import { checkForbiddenPaths } from "./forbidden.js";
7
+ import * as git from "./git.js";
8
+ import { loadLoopFile, selectLoop } from "./loop-file.js";
9
+ import { renderProofReport } from "./report.js";
10
+ import { runVerification } from "./verify.js";
11
+ export async function runLoop(options) {
12
+ const projectRoot = path.resolve(options.projectRoot);
13
+ 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
+ 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.");
19
+ }
20
+ const loopFile = await loadLoopFile(projectRoot, options.loopsFile);
21
+ const loop = selectLoop(loopFile, options.loopId);
22
+ const base = options.base ?? "HEAD";
23
+ const shaBefore = await git.headSha(projectRoot, base);
24
+ const clean = await git.isClean(projectRoot);
25
+ const changed = await git.changedFiles(projectRoot, base);
26
+ const diffstat = await git.diffStat(projectRoot, base);
27
+ const shaAfter = await git.headSha(projectRoot, "HEAD");
28
+ const allChanged = [...changed.tracked, ...changed.untracked];
29
+ const forbidden = checkForbiddenPaths(allChanged, loop.permissions.forbiddenPaths);
30
+ const timeoutMs = Math.max(loop.stopCriteria.timeoutMinutes || 1, 1) * 60_000;
31
+ const verification = await runVerification(loop.verification.commands, {
32
+ cwd: projectRoot,
33
+ timeoutMs,
34
+ allowedCommands: loop.permissions.allowedCommands
35
+ });
36
+ const passed = verification.passed && forbidden.ok;
37
+ const input = {
38
+ schemaVersion: "1",
39
+ entryId: randomUUID(),
40
+ timestamp: new Date().toISOString(),
41
+ project: loopFile.project,
42
+ loopId: loop.id,
43
+ mode,
44
+ actor: { user: safe(() => os.userInfo().username), host: safe(() => os.hostname()) },
45
+ git: { base, shaBefore, shaAfter, clean },
46
+ changedFiles: { tracked: changed.tracked, untracked: changed.untracked, diffstat },
47
+ forbidden: { ok: forbidden.ok, violations: forbidden.violations },
48
+ verification: {
49
+ passed: verification.passed,
50
+ commands: verification.results.map((result) => ({
51
+ command: result.command,
52
+ exitCode: result.exitCode,
53
+ timedOut: result.timedOut,
54
+ durationMs: result.durationMs
55
+ }))
56
+ },
57
+ iterations: 1,
58
+ passed
59
+ };
60
+ let entry;
61
+ if (options.dryRun) {
62
+ const existing = await readAuditLog(projectRoot);
63
+ const prevHash = existing.length ? existing[existing.length - 1].hash : null;
64
+ entry = { ...input, prevHash, hash: hashEntry(input, prevHash) };
65
+ }
66
+ else {
67
+ entry = await appendAuditEntry(projectRoot, input);
68
+ }
69
+ let reportPath;
70
+ if (!options.dryRun && options.writeReport !== false) {
71
+ const stamp = entry.timestamp.replace(/[:.]/g, "-");
72
+ reportPath = path.join(".loopgen", "reports", `${loop.id}-${stamp}.md`);
73
+ const absolute = path.join(projectRoot, reportPath);
74
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
75
+ await fs.writeFile(absolute, renderProofReport(loop, entry, verification), "utf8");
76
+ }
77
+ if (!options.dryRun) {
78
+ await appendStateEntry(projectRoot, loop, entry);
79
+ }
80
+ return { loop, passed, entry, verification, forbidden, reportPath, dryRun: Boolean(options.dryRun) };
81
+ }
82
+ async function appendStateEntry(projectRoot, loop, entry) {
83
+ const stateFile = loop.stateFile || path.join(".loopgen", "state", `${loop.id}.md`);
84
+ const absolute = path.join(projectRoot, stateFile);
85
+ 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}`;
87
+ const existing = await fs.readFile(absolute, "utf8").catch(() => undefined);
88
+ let next;
89
+ if (existing && existing.includes("- No attempts yet.")) {
90
+ next = existing.replace("- No attempts yet.", line);
91
+ }
92
+ else if (existing) {
93
+ next = `${existing.trimEnd()}\n${line}\n`;
94
+ }
95
+ else {
96
+ next = `# ${loop.title} state\n\nLoop id: ${loop.id}\n\n## Attempts\n\n${line}\n`;
97
+ }
98
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
99
+ await fs.writeFile(absolute, next, "utf8");
100
+ }
101
+ function safe(getter) {
102
+ try {
103
+ return getter();
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
@@ -0,0 +1,77 @@
1
+ import { spawn } from "node:child_process";
2
+ const EXCERPT_CAP = 64 * 1024;
3
+ const KILL_GRACE_MS = 2000;
4
+ export async function runVerification(commands, options) {
5
+ const results = [];
6
+ const warnings = [];
7
+ const allow = options.allowedCommands?.length ? options.allowedCommands : undefined;
8
+ for (const command of commands) {
9
+ if (allow && !allow.includes(command)) {
10
+ warnings.push(`Verification command is not in the loop's allowed commands: ${command}`);
11
+ }
12
+ results.push(await runOne(command, options));
13
+ }
14
+ const passed = results.length > 0 && results.every((result) => result.exitCode === 0 && !result.timedOut);
15
+ return { passed, results, warnings };
16
+ }
17
+ function runOne(command, options) {
18
+ return new Promise((resolve) => {
19
+ const start = Date.now();
20
+ // detached on POSIX so the shell and its children share a process group we can kill together —
21
+ // otherwise killing the shell leaves grandchildren holding the stdio pipes open and `close` never fires.
22
+ const posix = process.platform !== "win32";
23
+ const child = spawn(command, { cwd: options.cwd, shell: true, detached: posix });
24
+ const killTree = (signal) => {
25
+ if (child.pid == null)
26
+ return;
27
+ try {
28
+ if (posix)
29
+ process.kill(-child.pid, signal);
30
+ else
31
+ child.kill(signal);
32
+ }
33
+ catch {
34
+ // process group already gone
35
+ }
36
+ };
37
+ let stdout = "";
38
+ let stderr = "";
39
+ let timedOut = false;
40
+ const capture = (buffer, target) => {
41
+ const text = buffer.toString("utf8");
42
+ if (target === "out")
43
+ stdout = capExcerpt(stdout + text);
44
+ else
45
+ stderr = capExcerpt(stderr + text);
46
+ };
47
+ child.stdout?.on("data", (buffer) => capture(buffer, "out"));
48
+ child.stderr?.on("data", (buffer) => capture(buffer, "err"));
49
+ const timer = setTimeout(() => {
50
+ timedOut = true;
51
+ killTree("SIGTERM");
52
+ setTimeout(() => killTree("SIGKILL"), KILL_GRACE_MS).unref();
53
+ }, options.timeoutMs);
54
+ const finish = (exitCode, signal) => {
55
+ clearTimeout(timer);
56
+ resolve({
57
+ command,
58
+ exitCode: timedOut ? null : exitCode,
59
+ signal: signal ?? null,
60
+ timedOut,
61
+ durationMs: Date.now() - start,
62
+ stdoutExcerpt: stdout.trimEnd(),
63
+ stderrExcerpt: stderr.trimEnd()
64
+ });
65
+ };
66
+ child.on("error", (error) => {
67
+ stderr = capExcerpt(stderr + `\n[spawn error] ${error instanceof Error ? error.message : String(error)}`);
68
+ finish(127, null);
69
+ });
70
+ child.on("close", (code, signal) => finish(code, signal));
71
+ });
72
+ }
73
+ function capExcerpt(value) {
74
+ if (value.length <= EXCERPT_CAP)
75
+ return value;
76
+ return value.slice(value.length - EXCERPT_CAP);
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopgen",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {