loopgen 0.4.0 → 0.6.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
@@ -181,6 +181,32 @@ npm run loopgen -- run test-repair . --mode driven --adapter ollama --ollama-mod
181
181
  - 需要干净的 git 工作区(`--allow-dirty` 可跳过);`--dry-run` 只预览第一轮提议、不写文件。
182
182
  - 诚实说明:**有界 + 强制 + 验证 + 留证 —— 不是沙箱。** 模型仍会提议,loopgen 负责框住、限制、验证、留证。
183
183
 
184
+ #### 治理 —— 把审计账本变成团队证据(`loopgen audit`)
185
+
186
+ 每次 `loopgen run` 都会往本仓库的、带哈希链的 `.loopgen/audit.jsonl` 追加一条。`audit` 命令族把这些账本
187
+ 变成团队级、可用于合规的证据,以及一个 CI 闸门 —— local-first、无需服务器:
188
+
189
+ ```bash
190
+ npm run loopgen -- audit verify # 校验哈希链是否完好(防篡改)
191
+ npm run loopgen -- audit summary # 单仓库:通过率、按 loop、违规数
192
+ npm run loopgen -- audit aggregate ../repos --html gov.html --report gov.md # 聚合多个仓库/开发者
193
+ npm run loopgen -- audit check --require test-repair --require-no-violations --require-chain # CI 闸门
194
+ ```
195
+
196
+ `aggregate` 会在给定的文件/目录里找 `audit.jsonl`,合并成一份汇总,并可生成一个**自包含的 HTML 治理看板**
197
+ (直接打开即可,无需服务器)和一份 markdown 报告。`check` 是**合并闸门**:当某个必需 loop 没有通过记录、
198
+ 有改动碰了禁止路径、或链断了,就以非 0 退出 —— 接进 CI 即可在「证据缺失/不足」时挡住合并。
199
+
200
+ 也有现成的 GitHub Action(会把治理汇总写进 job summary,并在策略违规时让 job 失败):
201
+
202
+ ```yaml
203
+ - uses: Nagiici/Loopgen/.github/actions/audit-check@v0.6.0
204
+ with:
205
+ require: test-repair,ci-failure-repair
206
+ require-no-violations: "true"
207
+ require-chain: "true"
208
+ ```
209
+
184
210
  可用 adapter:
185
211
 
186
212
  - `agents-md`:通用 `AGENTS.md`,可被 Claude Code、Codex、Cursor、Copilot、Gemini CLI、Aider 等读取
@@ -436,6 +462,34 @@ audit + a proof report with the full iteration history (including every blocked
436
462
  - Honest scope: **bounded + enforced + verified + proven — not a sandbox.** The model still proposes; loopgen
437
463
  bounds, confines, verifies, and proves.
438
464
 
465
+ #### Governance — turn the ledgers into team evidence (`loopgen audit`)
466
+
467
+ Every `loopgen run` appends to a per-repo, hash-chained `.loopgen/audit.jsonl`. The `audit` commands turn
468
+ those ledgers into team-level, compliance-ready evidence and a CI gate — local-first, no server:
469
+
470
+ ```bash
471
+ npm run loopgen -- audit verify # prove the hash chain is intact (tamper check)
472
+ npm run loopgen -- audit summary # one repo: pass rate, by loop, violations
473
+ npm run loopgen -- audit aggregate ../repos --html gov.html --report gov.md # roll up many repos/devs
474
+ npm run loopgen -- audit check --require test-repair --require-no-violations --require-chain # CI gate
475
+ ```
476
+
477
+ `aggregate` scans the given files/directories for `audit.jsonl`, merges them into one rollup, and can write
478
+ a self-contained **HTML governance dashboard** (just open it — no server) plus a markdown report. `check` is
479
+ the **merge gate**: it exits non-zero if a required loop has no passing run, a run touched forbidden paths, or
480
+ the chain is broken — wire it into CI to block merges on missing or insufficient proof.
481
+
482
+ There's a ready-made GitHub Action (it renders the governance rollup into the job summary and fails the job
483
+ on policy violations):
484
+
485
+ ```yaml
486
+ - uses: Nagiici/Loopgen/.github/actions/audit-check@v0.6.0
487
+ with:
488
+ require: test-repair,ci-failure-repair
489
+ require-no-violations: "true"
490
+ require-chain: "true"
491
+ ```
492
+
439
493
  Available adapters:
440
494
 
441
495
  - `agents-md` — one `AGENTS.md` read by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, and more
package/dist/cli.js CHANGED
@@ -13,6 +13,10 @@ import { TEMPLATE_DEFINITIONS } from "./core/templates.js";
13
13
  import { startLoopgenServer } from "./server.js";
14
14
  import { DEFAULT_ADAPTER_IDS, parseAdapterIds } from "./core/adapters.js";
15
15
  import { runLoop } from "./core/runner.js";
16
+ import { readAuditLog, verifyAuditChain } from "./core/audit.js";
17
+ import { buildSummary, collectAuditFiles, evaluatePolicy } from "./core/governance.js";
18
+ import { renderGovernanceHtml, renderGovernanceMarkdown } from "./core/governance-report.js";
19
+ import { promises as fsp } from "node:fs";
16
20
  const PROJECT_MANIFESTS = [
17
21
  "package.json",
18
22
  "pyproject.toml",
@@ -191,6 +195,90 @@ program
191
195
  }
192
196
  process.exitCode = result.passed ? 0 : 1;
193
197
  });
198
+ const audit = program.command("audit").description("Inspect, aggregate, and gate on loopgen audit logs (governance).");
199
+ audit
200
+ .command("verify")
201
+ .argument("[project]", "project directory", ".")
202
+ .description("Verify the audit hash chain is intact (tamper check).")
203
+ .action(async (project) => {
204
+ const entries = await readAuditLog(path.resolve(project));
205
+ const chain = verifyAuditChain(entries);
206
+ if (chain.valid) {
207
+ console.log(`Audit chain valid (${entries.length} entries).`);
208
+ }
209
+ else {
210
+ console.log(`Audit chain BROKEN at entry ${chain.brokenAt}.`);
211
+ process.exitCode = 1;
212
+ }
213
+ });
214
+ audit
215
+ .command("summary")
216
+ .argument("[project]", "project directory", ".")
217
+ .option("--json", "print the summary as JSON")
218
+ .description("Summarize one repo's audit log (pass rate, by loop, violations).")
219
+ .action(async (project, options) => {
220
+ const root = path.resolve(project);
221
+ const { summary } = await buildSummary([{ label: ".", filePath: path.join(root, ".loopgen", "audit.jsonl") }]);
222
+ if (options.json)
223
+ console.log(JSON.stringify(summary, null, 2));
224
+ else
225
+ printSummary(summary);
226
+ });
227
+ audit
228
+ .command("aggregate")
229
+ .argument("<paths...>", "audit.jsonl files or directories to scan")
230
+ .option("--json", "print the rollup as JSON")
231
+ .option("--report <file>", "write a markdown governance report")
232
+ .option("--html <file>", "write a self-contained HTML governance dashboard")
233
+ .description("Aggregate many devs'/repos' audit logs into one team governance rollup.")
234
+ .action(async (paths, options) => {
235
+ const files = await collectAuditFiles(paths);
236
+ if (!files.length) {
237
+ console.error("No audit.jsonl files found in the given paths.");
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ const { summary } = await buildSummary(files.map((file) => ({ label: relLabel(file), filePath: file })));
242
+ if (options.report) {
243
+ await fsp.writeFile(options.report, renderGovernanceMarkdown(summary), "utf8");
244
+ console.log(`Wrote ${options.report}`);
245
+ }
246
+ if (options.html) {
247
+ await fsp.writeFile(options.html, renderGovernanceHtml(summary), "utf8");
248
+ console.log(`Wrote ${options.html}`);
249
+ }
250
+ if (options.json)
251
+ console.log(JSON.stringify(summary, null, 2));
252
+ else
253
+ printSummary(summary);
254
+ });
255
+ audit
256
+ .command("check")
257
+ .argument("[project]", "project directory", ".")
258
+ .option("--require <loops>", "comma-separated loop ids that must have a passing run")
259
+ .option("--since <iso>", "only consider runs at/after this ISO timestamp")
260
+ .option("--require-no-violations", "fail if any run modified forbidden paths")
261
+ .option("--require-chain", "fail if the audit chain is broken")
262
+ .description("Gate CI/merge on the audit log; exits 1 if the policy is not satisfied.")
263
+ .action(async (project, options) => {
264
+ const entries = await readAuditLog(path.resolve(project));
265
+ const policy = {
266
+ requireLoops: options.require ? options.require.split(",").map((item) => item.trim()).filter(Boolean) : undefined,
267
+ since: options.since,
268
+ requireNoViolations: options.requireNoViolations,
269
+ requireChainValid: options.requireChain
270
+ };
271
+ const result = evaluatePolicy(entries, policy);
272
+ if (result.ok) {
273
+ console.log(`Policy satisfied (${result.checked} run(s) checked).`);
274
+ }
275
+ else {
276
+ console.log("Policy FAILED:");
277
+ for (const failure of result.failures)
278
+ console.log(` - ${failure}`);
279
+ process.exitCode = 1;
280
+ }
281
+ });
194
282
  program.parseAsync(process.argv).catch((error) => {
195
283
  console.error(error instanceof Error ? error.message : String(error));
196
284
  process.exitCode = 1;
@@ -265,6 +353,21 @@ function printRunResult(result) {
265
353
  if (!result.dryRun)
266
354
  console.log(` audit: .loopgen/audit.jsonl (${result.entry.hash.slice(0, 12)}…)`);
267
355
  }
356
+ function printSummary(summary) {
357
+ console.log(`Runs: ${summary.total} (${summary.passed} pass / ${summary.failed} fail) — ${Math.round(summary.passRate * 100)}%`);
358
+ console.log(`Modes: referee ${summary.byMode.referee}, driven ${summary.byMode.driven}`);
359
+ console.log(`Chain: ${summary.chain.valid ? "valid" : `BROKEN at ${summary.chain.brokenAt}`}`);
360
+ console.log(`Forbidden violations: ${summary.forbiddenViolationRuns} run(s); blocked attempts (prevented): ${summary.blockedAttempts}`);
361
+ for (const [loop, stat] of Object.entries(summary.byLoop).sort((a, b) => b[1].total - a[1].total)) {
362
+ console.log(` ${loop}: ${stat.passed}/${stat.total} passed`);
363
+ }
364
+ if (summary.sources.length > 1) {
365
+ console.log(`Sources: ${summary.sources.length} (${summary.sources.filter((source) => source.chainValid).length} with a valid chain)`);
366
+ }
367
+ }
368
+ function relLabel(file) {
369
+ return path.relative(process.cwd(), file) || file;
370
+ }
268
371
  function formatCommands(commands) {
269
372
  const entries = Object.entries(commands).filter(([, command]) => command);
270
373
  return entries.length ? entries.map(([name, command]) => `${name}=${command}`).join(", ") : "none inferred";
@@ -20,11 +20,8 @@ function canonicalize(value) {
20
20
  export function hashEntry(input, prevHash) {
21
21
  return createHash("sha256").update(canonicalize({ ...input, prevHash })).digest("hex");
22
22
  }
23
- async function readRaw(projectRoot) {
24
- return fs.readFile(path.join(projectRoot, AUDIT_FILE), "utf8").catch(() => undefined);
25
- }
26
- export async function readAuditLog(projectRoot) {
27
- const raw = await readRaw(projectRoot);
23
+ export async function readAuditFile(filePath) {
24
+ const raw = await fs.readFile(filePath, "utf8").catch(() => undefined);
28
25
  if (!raw)
29
26
  return [];
30
27
  return raw
@@ -33,6 +30,9 @@ export async function readAuditLog(projectRoot) {
33
30
  .filter(Boolean)
34
31
  .map((line) => JSON.parse(line));
35
32
  }
33
+ export async function readAuditLog(projectRoot) {
34
+ return readAuditFile(path.join(projectRoot, AUDIT_FILE));
35
+ }
36
36
  export async function appendAuditEntry(projectRoot, input) {
37
37
  const existing = await readAuditLog(projectRoot);
38
38
  const prevHash = existing.length ? existing[existing.length - 1].hash : null;
@@ -0,0 +1,111 @@
1
+ const pct = (value) => `${Math.round(value * 100)}%`;
2
+ export function renderGovernanceMarkdown(summary) {
3
+ const loopRows = Object.entries(summary.byLoop)
4
+ .sort((a, b) => b[1].total - a[1].total)
5
+ .map(([loop, stat]) => `| \`${loop}\` | ${stat.total} | ${stat.passed} | ${pct(rate(stat))} |`)
6
+ .join("\n");
7
+ const actorRows = Object.entries(summary.byActor)
8
+ .sort((a, b) => b[1].total - a[1].total)
9
+ .map(([actor, stat]) => `| ${actor} | ${stat.total} | ${stat.passed} | ${pct(rate(stat))} |`)
10
+ .join("\n");
11
+ const sourceRows = summary.sources
12
+ .map((source) => `| ${source.label} | ${source.entries} | ${source.chainValid ? "valid" : "BROKEN"} |`)
13
+ .join("\n");
14
+ return `# loopgen governance report
15
+
16
+ - Runs: **${summary.total}** (${summary.passed} passed / ${summary.failed} failed) — pass rate **${pct(summary.passRate)}**
17
+ - Window: ${summary.firstAt ?? "—"} → ${summary.lastAt ?? "—"}
18
+ - Modes: referee ${summary.byMode.referee} · driven ${summary.byMode.driven}
19
+ - Chain integrity: ${summary.chain.valid ? "**valid**" : `**BROKEN** (entry ${summary.chain.brokenAt})`}
20
+ - Forbidden-path violations: **${summary.forbiddenViolationRuns}** run(s)
21
+ - Blocked attempts (driven, prevented at apply time): **${summary.blockedAttempts}**
22
+
23
+ ## By loop
24
+
25
+ | loop | runs | passed | pass rate |
26
+ |---|---|---|---|
27
+ ${loopRows || "| — | 0 | 0 | 0% |"}
28
+
29
+ ## By actor
30
+
31
+ | actor | runs | passed | pass rate |
32
+ |---|---|---|---|
33
+ ${actorRows || "| — | 0 | 0 | 0% |"}
34
+
35
+ ## Sources
36
+
37
+ | source | entries | chain |
38
+ |---|---|---|
39
+ ${sourceRows || "| — | 0 | — |"}
40
+ `;
41
+ }
42
+ export function renderGovernanceHtml(summary) {
43
+ const loopRows = Object.entries(summary.byLoop)
44
+ .sort((a, b) => b[1].total - a[1].total)
45
+ .map(([loop, stat]) => row(loop, stat.total, stat.passed))
46
+ .join("");
47
+ const actorRows = Object.entries(summary.byActor)
48
+ .sort((a, b) => b[1].total - a[1].total)
49
+ .map(([actor, stat]) => row(actor, stat.total, stat.passed))
50
+ .join("");
51
+ const sourceRows = summary.sources
52
+ .map((source) => `<tr><td>${esc(source.label)}</td><td>${source.entries}</td><td>${source.chainValid ? "valid" : '<b class="bad">BROKEN</b>'}</td></tr>`)
53
+ .join("");
54
+ return `<!doctype html>
55
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
56
+ <title>loopgen governance</title>
57
+ <style>
58
+ :root{--paper:#f4efe4;--ink:#1a1714;--muted:#7a7064;--line:#d8cfbd;--rule:#b9ad96;--ok:#4a6b3f;--bad:#c2362b;--surface:#fbf7ee}
59
+ @media(prefers-color-scheme:dark){:root{--paper:#17150f;--ink:#ece4d4;--muted:#948b78;--line:#322d22;--rule:#4a4334;--ok:#8fb079;--bad:#e0524a;--surface:#221e16}}
60
+ *{box-sizing:border-box}
61
+ body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
62
+ .wrap{max-width:920px;margin:0 auto;padding:40px 24px}
63
+ h1{font:500 30px/1.1 Georgia,"Times New Roman",serif;margin:0}
64
+ .sub{color:var(--muted);font-size:12px;letter-spacing:.16em;text-transform:uppercase;margin-top:6px}
65
+ hr{border:0;border-top:2px solid var(--ink);margin:16px 0 28px}
66
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0;border:1px solid var(--line);margin-bottom:28px}
67
+ .card{padding:16px 18px;border-right:1px solid var(--line)}
68
+ .card:last-child{border-right:0}
69
+ .card .k{color:var(--muted);font-size:11px;letter-spacing:.1em;text-transform:uppercase}
70
+ .card .v{font:500 26px/1.1 Georgia,serif;margin-top:6px}
71
+ .bar{height:6px;background:var(--line);margin-top:10px}
72
+ .bar>span{display:block;height:6px;background:var(--ok)}
73
+ h2{font:500 18px/1.1 Georgia,serif;margin:28px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--line)}
74
+ table{width:100%;border-collapse:collapse;font-size:14px}
75
+ th{text-align:left;color:var(--muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500;padding:8px 6px;border-bottom:1px solid var(--rule)}
76
+ td{padding:9px 6px;border-bottom:1px solid var(--line)}
77
+ code{font-family:"IBM Plex Mono",monospace;font-size:13px}
78
+ .good{color:var(--ok)}.bad{color:var(--bad)}
79
+ .note{color:var(--muted);font-size:12px;margin-top:28px;border-top:1px solid var(--line);padding-top:14px}
80
+ </style></head>
81
+ <body><div class="wrap">
82
+ <h1>loopgen governance</h1>
83
+ <div class="sub">audit rollup · ${esc(summary.firstAt ?? "—")} → ${esc(summary.lastAt ?? "—")}</div>
84
+ <hr>
85
+ <div class="cards">
86
+ <div class="card"><div class="k">Runs</div><div class="v">${summary.total}</div></div>
87
+ <div class="card"><div class="k">Pass rate</div><div class="v">${pct(summary.passRate)}</div><div class="bar"><span style="width:${pct(summary.passRate)}"></span></div></div>
88
+ <div class="card"><div class="k">Forbidden violations</div><div class="v ${summary.forbiddenViolationRuns ? "bad" : "good"}">${summary.forbiddenViolationRuns}</div></div>
89
+ <div class="card"><div class="k">Blocked (prevented)</div><div class="v">${summary.blockedAttempts}</div></div>
90
+ <div class="card"><div class="k">Chain</div><div class="v ${summary.chain.valid ? "good" : "bad"}">${summary.chain.valid ? "valid" : "BROKEN"}</div></div>
91
+ </div>
92
+ <h2>By loop</h2>
93
+ <table><thead><tr><th>Loop</th><th>Runs</th><th>Passed</th><th>Pass rate</th></tr></thead><tbody>${loopRows || '<tr><td colspan="4">No runs.</td></tr>'}</tbody></table>
94
+ <h2>By actor</h2>
95
+ <table><thead><tr><th>Actor</th><th>Runs</th><th>Passed</th><th>Pass rate</th></tr></thead><tbody>${actorRows || '<tr><td colspan="4">No runs.</td></tr>'}</tbody></table>
96
+ <h2>Sources</h2>
97
+ <table><thead><tr><th>Source</th><th>Entries</th><th>Chain</th></tr></thead><tbody>${sourceRows || '<tr><td colspan="3">No sources.</td></tr>'}</tbody></table>
98
+ <div class="note">Generated by loopgen from hash-chained <code>.loopgen/audit.jsonl</code> ledgers. Local-first — open this file directly; no server. A broken chain means a ledger was edited in place.</div>
99
+ </div></body></html>
100
+ `;
101
+ }
102
+ function row(label, total, passed) {
103
+ const r = rate({ total, passed });
104
+ return `<tr><td><code>${esc(label)}</code></td><td>${total}</td><td>${passed}</td><td><span class="${r === 1 ? "good" : r < 0.5 ? "bad" : ""}">${pct(r)}</span></td></tr>`;
105
+ }
106
+ function rate(stat) {
107
+ return stat.total ? stat.passed / stat.total : 0;
108
+ }
109
+ function esc(value) {
110
+ return value.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char] ?? char);
111
+ }
@@ -0,0 +1,127 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { readAuditFile, verifyAuditChain } from "./audit.js";
4
+ // Resolve CLI inputs (files or directories) into a deduped list of audit.jsonl paths.
5
+ export async function collectAuditFiles(inputs) {
6
+ const found = [];
7
+ for (const input of inputs) {
8
+ const stat = await fs.stat(input).catch(() => undefined);
9
+ if (!stat)
10
+ continue;
11
+ if (stat.isFile()) {
12
+ found.push(path.resolve(input));
13
+ }
14
+ else if (stat.isDirectory()) {
15
+ await walk(path.resolve(input), found, 0);
16
+ }
17
+ }
18
+ return [...new Set(found)];
19
+ }
20
+ async function walk(dir, found, depth) {
21
+ if (depth > 6)
22
+ return;
23
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
24
+ for (const entry of entries) {
25
+ if (entry.name === "node_modules" || entry.name === ".git")
26
+ continue;
27
+ const full = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ await walk(full, found, depth + 1);
30
+ }
31
+ else if (entry.name === "audit.jsonl") {
32
+ found.push(full);
33
+ }
34
+ }
35
+ }
36
+ export async function buildSummary(sources) {
37
+ const all = [];
38
+ const sourceMeta = [];
39
+ let allChainsValid = true;
40
+ let brokenAt;
41
+ for (const source of sources) {
42
+ const entries = await readAuditFile(source.filePath);
43
+ const chain = verifyAuditChain(entries);
44
+ if (!chain.valid) {
45
+ allChainsValid = false;
46
+ if (brokenAt === undefined)
47
+ brokenAt = chain.brokenAt;
48
+ }
49
+ sourceMeta.push({ label: source.label, entries: entries.length, chainValid: chain.valid });
50
+ all.push(...entries);
51
+ }
52
+ return { summary: summarizeEntries(all, sourceMeta, { valid: allChainsValid, brokenAt }), entries: all };
53
+ }
54
+ function summarizeEntries(entries, sources, chain) {
55
+ const byLoop = {};
56
+ const byActor = {};
57
+ const byMode = { referee: 0, driven: 0 };
58
+ let passed = 0;
59
+ let blockedAttempts = 0;
60
+ let forbiddenViolationRuns = 0;
61
+ let firstAt;
62
+ let lastAt;
63
+ for (const entry of entries) {
64
+ if (entry.passed)
65
+ passed += 1;
66
+ if (entry.mode === "driven")
67
+ byMode.driven += 1;
68
+ else
69
+ byMode.referee += 1;
70
+ byLoop[entry.loopId] ??= { total: 0, passed: 0 };
71
+ byLoop[entry.loopId].total += 1;
72
+ if (entry.passed)
73
+ byLoop[entry.loopId].passed += 1;
74
+ const actor = entry.actor.user ?? "unknown";
75
+ byActor[actor] ??= { total: 0, passed: 0 };
76
+ byActor[actor].total += 1;
77
+ if (entry.passed)
78
+ byActor[actor].passed += 1;
79
+ if (!entry.forbidden.ok)
80
+ forbiddenViolationRuns += 1;
81
+ if (entry.driven) {
82
+ for (const attempt of entry.driven.attempts)
83
+ blockedAttempts += attempt.blocked.length;
84
+ }
85
+ if (!firstAt || entry.timestamp < firstAt)
86
+ firstAt = entry.timestamp;
87
+ if (!lastAt || entry.timestamp > lastAt)
88
+ lastAt = entry.timestamp;
89
+ }
90
+ const total = entries.length;
91
+ return {
92
+ total,
93
+ passed,
94
+ failed: total - passed,
95
+ passRate: total ? passed / total : 0,
96
+ byLoop,
97
+ byMode,
98
+ byActor,
99
+ blockedAttempts,
100
+ forbiddenViolationRuns,
101
+ firstAt,
102
+ lastAt,
103
+ chain,
104
+ sources
105
+ };
106
+ }
107
+ // Evaluate a team policy against a repo's audit entries — the CI / merge gate.
108
+ export function evaluatePolicy(entries, policy) {
109
+ const failures = [];
110
+ const scoped = policy.since ? entries.filter((entry) => entry.timestamp >= policy.since) : entries;
111
+ if (policy.requireChainValid) {
112
+ const chain = verifyAuditChain(entries);
113
+ if (!chain.valid)
114
+ failures.push(`audit chain is broken at entry ${chain.brokenAt}`);
115
+ }
116
+ if (policy.requireNoViolations) {
117
+ const violations = scoped.filter((entry) => !entry.forbidden.ok);
118
+ if (violations.length)
119
+ failures.push(`${violations.length} run(s) modified forbidden paths`);
120
+ }
121
+ for (const loopId of policy.requireLoops ?? []) {
122
+ const ok = scoped.some((entry) => entry.loopId === loopId && entry.passed);
123
+ if (!ok)
124
+ failures.push(`no passing run found for required loop "${loopId}"`);
125
+ }
126
+ return { ok: failures.length === 0, failures, checked: scoped.length };
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopgen",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {