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 +40 -0
- package/dist/cli.js +53 -1
- package/dist/core/audit.js +52 -0
- package/dist/core/forbidden.js +38 -0
- package/dist/core/git.js +58 -0
- package/dist/core/loop-file.js +53 -0
- package/dist/core/report.js +58 -0
- package/dist/core/runner.js +108 -0
- package/dist/core/verify.js +77 -0
- package/package.json +1 -1
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(
|
|
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
|
+
}
|
package/dist/core/git.js
ADDED
|
@@ -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