loopgen 0.2.1 → 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 +82 -0
- package/dist/cli.js +69 -0
- package/dist/core/agent-loop.js +167 -0
- package/dist/core/apply-actions.js +77 -0
- package/dist/core/audit.js +56 -0
- package/dist/core/forbidden.js +38 -0
- package/dist/core/git.js +69 -0
- package/dist/core/loop-file.js +53 -0
- package/dist/core/model-client.js +63 -0
- package/dist/core/model-config.js +39 -0
- package/dist/core/report.js +83 -0
- package/dist/core/runner.js +184 -0
- package/dist/core/verify.js +77 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -139,10 +139,48 @@ 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
|
+
- 说明: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 负责框住、限制、验证、留证。
|
|
183
|
+
|
|
146
184
|
可用 adapter:
|
|
147
185
|
|
|
148
186
|
- `agents-md`:通用 `AGENTS.md`,可被 Claude Code、Codex、Cursor、Copilot、Gemini CLI、Aider 等读取
|
|
@@ -350,10 +388,54 @@ npm run loopgen -- preview [project] --templates all --adapters codex,claude
|
|
|
350
388
|
npm run loopgen -- preview [project] --templates test-repair --adapters ollama --ollama-model llama3.1
|
|
351
389
|
npm run loopgen -- preview [project] --templates test-repair --adapters openai-compatible --openai-compatible-model qwen2.5-coder
|
|
352
390
|
npm run loopgen -- apply [project] --templates all --adapters codex,claude
|
|
391
|
+
npm run loopgen -- run [loop] [project]
|
|
353
392
|
```
|
|
354
393
|
|
|
355
394
|
`apply` always shows a diff first. Without `--yes`, it asks for confirmation before writing files.
|
|
356
395
|
|
|
396
|
+
### Run & prove the work (`loopgen run`)
|
|
397
|
+
|
|
398
|
+
Generating config is just instructions. **`loopgen run` actually runs the verification and leaves proof** —
|
|
399
|
+
something a stateless model cannot do. After you (or any agent — Claude Code, Cursor, Codex) complete a
|
|
400
|
+
bounded change, run:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
npm run loopgen -- run test-repair .
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
It diffs your working tree against git, executes the loop's `verification.commands` and gates pass/fail on
|
|
407
|
+
the real exit codes, checks whether any `forbiddenPaths` (e.g. `.env`) were touched, and writes a
|
|
408
|
+
**tamper-evident, hash-chained audit record** to `.loopgen/audit.jsonl` plus a human-readable proof report
|
|
409
|
+
in `.loopgen/reports/*.md`. The process exits `0` on pass and `1` on fail, so it composes into CI / git hooks.
|
|
410
|
+
|
|
411
|
+
- `--dry-run` — run the checks, write nothing.
|
|
412
|
+
- `--base <ref>` — git ref to diff against (default `HEAD`).
|
|
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.
|
|
438
|
+
|
|
357
439
|
Available adapters:
|
|
358
440
|
|
|
359
441
|
- `agents-md` — one `AGENTS.md` read by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, and more
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { scanProject } from "./core/scanner.js";
|
|
|
12
12
|
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
|
+
import { runLoop } from "./core/runner.js";
|
|
15
16
|
const PROJECT_MANIFESTS = [
|
|
16
17
|
"package.json",
|
|
17
18
|
"pyproject.toml",
|
|
@@ -145,6 +146,51 @@ program
|
|
|
145
146
|
const written = await applyGeneratedFiles(result.scan.root, result.files);
|
|
146
147
|
console.log(`Wrote ${written.length} files.`);
|
|
147
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
|
+
.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")
|
|
167
|
+
.description("Run a loop's verification against the working tree and write a tamper-evident proof.")
|
|
168
|
+
.action(async (loop, project, options) => {
|
|
169
|
+
const result = await runLoop({
|
|
170
|
+
projectRoot: path.resolve(project),
|
|
171
|
+
loopId: loop,
|
|
172
|
+
mode: options.mode ?? "referee",
|
|
173
|
+
base: options.base,
|
|
174
|
+
loopsFile: options.loopsFile,
|
|
175
|
+
dryRun: options.dryRun,
|
|
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
|
|
185
|
+
});
|
|
186
|
+
if (options.json) {
|
|
187
|
+
console.log(JSON.stringify(result, null, 2));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
printRunResult(result);
|
|
191
|
+
}
|
|
192
|
+
process.exitCode = result.passed ? 0 : 1;
|
|
193
|
+
});
|
|
148
194
|
program.parseAsync(process.argv).catch((error) => {
|
|
149
195
|
console.error(error instanceof Error ? error.message : String(error));
|
|
150
196
|
process.exitCode = 1;
|
|
@@ -196,6 +242,29 @@ function printGenerationSummary(result) {
|
|
|
196
242
|
console.warn(`Warning: ${warning}`);
|
|
197
243
|
}
|
|
198
244
|
}
|
|
245
|
+
function printRunResult(result) {
|
|
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
|
+
}
|
|
251
|
+
for (const command of result.verification.results) {
|
|
252
|
+
const mark = command.timedOut ? "timeout" : command.exitCode === 0 ? "ok" : `exit ${command.exitCode}`;
|
|
253
|
+
console.log(` verify: ${command.command} — ${mark}`);
|
|
254
|
+
}
|
|
255
|
+
if (!result.verification.results.length) {
|
|
256
|
+
console.log(" verify: no verification commands configured for this loop");
|
|
257
|
+
}
|
|
258
|
+
for (const violation of result.forbidden.violations) {
|
|
259
|
+
console.log(` forbidden: ${violation.file} (matched ${violation.pattern})`);
|
|
260
|
+
}
|
|
261
|
+
const changed = result.entry.changedFiles.tracked.length + result.entry.changedFiles.untracked.length;
|
|
262
|
+
console.log(` files changed: ${changed}`);
|
|
263
|
+
if (result.reportPath)
|
|
264
|
+
console.log(` report: ${result.reportPath}`);
|
|
265
|
+
if (!result.dryRun)
|
|
266
|
+
console.log(` audit: .loopgen/audit.jsonl (${result.entry.hash.slice(0, 12)}…)`);
|
|
267
|
+
}
|
|
199
268
|
function formatCommands(commands) {
|
|
200
269
|
const entries = Object.entries(commands).filter(([, command]) => command);
|
|
201
270
|
return entries.length ? entries.map(([name, command]) => `${name}=${command}`).join(", ") : "none inferred";
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
// 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.
|
|
8
|
+
function canonicalize(value) {
|
|
9
|
+
if (value === null || typeof value !== "object")
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return `[${value.map(canonicalize).join(",")}]`;
|
|
13
|
+
const record = value;
|
|
14
|
+
const entries = Object.keys(record)
|
|
15
|
+
.filter((key) => record[key] !== undefined)
|
|
16
|
+
.sort()
|
|
17
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`);
|
|
18
|
+
return `{${entries.join(",")}}`;
|
|
19
|
+
}
|
|
20
|
+
export function hashEntry(input, prevHash) {
|
|
21
|
+
return createHash("sha256").update(canonicalize({ ...input, prevHash })).digest("hex");
|
|
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);
|
|
28
|
+
if (!raw)
|
|
29
|
+
return [];
|
|
30
|
+
return raw
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((line) => JSON.parse(line));
|
|
35
|
+
}
|
|
36
|
+
export async function appendAuditEntry(projectRoot, input) {
|
|
37
|
+
const existing = await readAuditLog(projectRoot);
|
|
38
|
+
const prevHash = existing.length ? existing[existing.length - 1].hash : null;
|
|
39
|
+
const entry = { ...input, prevHash, hash: hashEntry(input, prevHash) };
|
|
40
|
+
const filePath = path.join(projectRoot, AUDIT_FILE);
|
|
41
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
42
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
export function verifyAuditChain(entries) {
|
|
46
|
+
let prevHash = null;
|
|
47
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
48
|
+
const { hash, prevHash: storedPrev, ...input } = entries[index];
|
|
49
|
+
if (storedPrev !== prevHash)
|
|
50
|
+
return { valid: false, brokenAt: index };
|
|
51
|
+
if (hashEntry(input, storedPrev) !== hash)
|
|
52
|
+
return { valid: false, brokenAt: index };
|
|
53
|
+
prevHash = hash;
|
|
54
|
+
}
|
|
55
|
+
return { valid: true };
|
|
56
|
+
}
|
|
@@ -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,69 @@
|
|
|
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
|
+
// 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
|
+
}
|
|
50
|
+
export async function changedFiles(root, base) {
|
|
51
|
+
const hasCommits = (await headSha(root)) !== null;
|
|
52
|
+
const tracked = hasCommits
|
|
53
|
+
? splitLines((await git(root, ["diff", "--name-only", base])).stdout)
|
|
54
|
+
: splitLines((await git(root, ["ls-files"])).stdout);
|
|
55
|
+
const untracked = splitLines((await git(root, ["ls-files", "--others", "--exclude-standard"])).stdout);
|
|
56
|
+
return { tracked, untracked };
|
|
57
|
+
}
|
|
58
|
+
export async function diffStat(root, base) {
|
|
59
|
+
if ((await headSha(root)) === null)
|
|
60
|
+
return "(no commits yet — diff against empty tree)";
|
|
61
|
+
const { stdout } = await git(root, ["diff", "--stat", base]);
|
|
62
|
+
return stdout.trimEnd();
|
|
63
|
+
}
|
|
64
|
+
function splitLines(value) {
|
|
65
|
+
return value
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((line) => line.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function renderProofReport(loop, entry, verification, iterationLogs) {
|
|
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
|
+
${entry.driven && iterationLogs ? `\n${renderIterationHistory(iterationLogs)}` : ""}
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
${scopeFooter(entry)}
|
|
55
|
+
`;
|
|
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
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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 { runDrivenLoop } from "./agent-loop.js";
|
|
6
|
+
import { appendAuditEntry, hashEntry, readAuditLog } from "./audit.js";
|
|
7
|
+
import { checkForbiddenPaths } from "./forbidden.js";
|
|
8
|
+
import * as git from "./git.js";
|
|
9
|
+
import { loadLoopFile, selectLoop } from "./loop-file.js";
|
|
10
|
+
import { createModelClient } from "./model-client.js";
|
|
11
|
+
import { resolveModelConfig } from "./model-config.js";
|
|
12
|
+
import { renderProofReport } from "./report.js";
|
|
13
|
+
import { runVerification } from "./verify.js";
|
|
14
|
+
export async function runLoop(options) {
|
|
15
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
16
|
+
const mode = options.mode ?? "referee";
|
|
17
|
+
if (!(await git.isGitRepo(projectRoot))) {
|
|
18
|
+
throw new Error("loopgen run requires a git repository — it 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
|
+
return mode === "driven"
|
|
23
|
+
? runDriven(projectRoot, loop, loopFile, options)
|
|
24
|
+
: runReferee(projectRoot, loop, loopFile, options);
|
|
25
|
+
}
|
|
26
|
+
async function runReferee(projectRoot, loop, loopFile, options) {
|
|
27
|
+
const base = options.base ?? "HEAD";
|
|
28
|
+
const shaBefore = await git.headSha(projectRoot, base);
|
|
29
|
+
const clean = await git.isClean(projectRoot);
|
|
30
|
+
const changed = await git.changedFiles(projectRoot, base);
|
|
31
|
+
const diffstat = await git.diffStat(projectRoot, base);
|
|
32
|
+
const shaAfter = await git.headSha(projectRoot, "HEAD");
|
|
33
|
+
const allChanged = [...changed.tracked, ...changed.untracked];
|
|
34
|
+
const forbidden = checkForbiddenPaths(allChanged, loop.permissions.forbiddenPaths);
|
|
35
|
+
const timeoutMs = commandTimeoutMs(loop);
|
|
36
|
+
const verification = await runVerification(loop.verification.commands, {
|
|
37
|
+
cwd: projectRoot,
|
|
38
|
+
timeoutMs,
|
|
39
|
+
allowedCommands: loop.permissions.allowedCommands
|
|
40
|
+
});
|
|
41
|
+
const passed = verification.passed && forbidden.ok;
|
|
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 {
|
|
82
|
+
schemaVersion: "1",
|
|
83
|
+
entryId: randomUUID(),
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
project: loopFile.project,
|
|
86
|
+
loopId: loop.id,
|
|
87
|
+
mode,
|
|
88
|
+
actor: { user: safe(() => os.userInfo().username), host: safe(() => os.hostname()) },
|
|
89
|
+
git: gitInfo,
|
|
90
|
+
changedFiles: { tracked: changed.tracked, untracked: changed.untracked, diffstat },
|
|
91
|
+
forbidden: { ok: forbidden.ok, violations: forbidden.violations },
|
|
92
|
+
verification: {
|
|
93
|
+
passed: verification.passed,
|
|
94
|
+
commands: verification.results.map((result) => ({
|
|
95
|
+
command: result.command,
|
|
96
|
+
exitCode: result.exitCode,
|
|
97
|
+
timedOut: result.timedOut,
|
|
98
|
+
durationMs: result.durationMs
|
|
99
|
+
}))
|
|
100
|
+
},
|
|
101
|
+
iterations,
|
|
102
|
+
passed
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function finalize(projectRoot, loop, input, verification, forbidden, options, iterationLogs) {
|
|
106
|
+
let entry;
|
|
107
|
+
if (options.dryRun) {
|
|
108
|
+
const existing = await readAuditLog(projectRoot);
|
|
109
|
+
const prevHash = existing.length ? existing[existing.length - 1].hash : null;
|
|
110
|
+
entry = { ...input, prevHash, hash: hashEntry(input, prevHash) };
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
entry = await appendAuditEntry(projectRoot, input);
|
|
114
|
+
}
|
|
115
|
+
let reportPath;
|
|
116
|
+
if (!options.dryRun && options.writeReport !== false) {
|
|
117
|
+
const stamp = entry.timestamp.replace(/[:.]/g, "-");
|
|
118
|
+
reportPath = path.join(".loopgen", "reports", `${loop.id}-${stamp}.md`);
|
|
119
|
+
const absolute = path.join(projectRoot, reportPath);
|
|
120
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
121
|
+
await fs.writeFile(absolute, renderProofReport(loop, entry, verification, iterationLogs), "utf8");
|
|
122
|
+
}
|
|
123
|
+
if (!options.dryRun) {
|
|
124
|
+
await appendStateEntry(projectRoot, loop, entry);
|
|
125
|
+
}
|
|
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;
|
|
157
|
+
}
|
|
158
|
+
async function appendStateEntry(projectRoot, loop, entry) {
|
|
159
|
+
const stateFile = loop.stateFile || path.join(".loopgen", "state", `${loop.id}.md`);
|
|
160
|
+
const absolute = path.join(projectRoot, stateFile);
|
|
161
|
+
const passedCount = entry.verification.commands.filter((command) => command.exitCode === 0 && !command.timedOut).length;
|
|
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}`;
|
|
163
|
+
const existing = await fs.readFile(absolute, "utf8").catch(() => undefined);
|
|
164
|
+
let next;
|
|
165
|
+
if (existing && existing.includes("- No attempts yet.")) {
|
|
166
|
+
next = existing.replace("- No attempts yet.", line);
|
|
167
|
+
}
|
|
168
|
+
else if (existing) {
|
|
169
|
+
next = `${existing.trimEnd()}\n${line}\n`;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
next = `# ${loop.title} state\n\nLoop id: ${loop.id}\n\n## Attempts\n\n${line}\n`;
|
|
173
|
+
}
|
|
174
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
175
|
+
await fs.writeFile(absolute, next, "utf8");
|
|
176
|
+
}
|
|
177
|
+
function safe(getter) {
|
|
178
|
+
try {
|
|
179
|
+
return getter();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -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