@loops-adk/core 0.1.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/LICENSE +21 -0
- package/README.md +486 -0
- package/bin/loops.mjs +16 -0
- package/dist/App-3YQS6DXA.js +461 -0
- package/dist/App-3YQS6DXA.js.map +1 -0
- package/dist/agent-sdk-RF5VJZAT.js +95 -0
- package/dist/agent-sdk-RF5VJZAT.js.map +1 -0
- package/dist/anthropic-api-XJY6Y4T2.js +131 -0
- package/dist/anthropic-api-XJY6Y4T2.js.map +1 -0
- package/dist/api.d.ts +949 -0
- package/dist/api.js +898 -0
- package/dist/api.js.map +1 -0
- package/dist/chunk-33YIGWNU.js +63 -0
- package/dist/chunk-33YIGWNU.js.map +1 -0
- package/dist/chunk-3BPU34DE.js +2163 -0
- package/dist/chunk-3BPU34DE.js.map +1 -0
- package/dist/chunk-CXEPZHSR.js +86 -0
- package/dist/chunk-CXEPZHSR.js.map +1 -0
- package/dist/chunk-I3STY7U6.js +61 -0
- package/dist/chunk-I3STY7U6.js.map +1 -0
- package/dist/chunk-JFTXJ7I2.js +18 -0
- package/dist/chunk-JFTXJ7I2.js.map +1 -0
- package/dist/chunk-XC46B4FD.js +9 -0
- package/dist/chunk-XC46B4FD.js.map +1 -0
- package/dist/chunk-Y2SD7GBL.js +30 -0
- package/dist/chunk-Y2SD7GBL.js.map +1 -0
- package/dist/claude-cli-U7WEVAOL.js +124 -0
- package/dist/claude-cli-U7WEVAOL.js.map +1 -0
- package/dist/codex-6I5UZ2HM.js +60 -0
- package/dist/codex-6I5UZ2HM.js.map +1 -0
- package/dist/env/command.d.ts +53 -0
- package/dist/env/command.js +3 -0
- package/dist/env/command.js.map +1 -0
- package/dist/env/docker.d.ts +38 -0
- package/dist/env/docker.js +33 -0
- package/dist/env/docker.js.map +1 -0
- package/dist/env/sst.d.ts +39 -0
- package/dist/env/sst.js +20 -0
- package/dist/env/sst.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +620 -0
- package/dist/index.js.map +1 -0
- package/dist/types-B4wGVpqo.d.ts +898 -0
- package/package.json +100 -0
- package/skills/author-loop/SKILL.md +121 -0
|
@@ -0,0 +1,2163 @@
|
|
|
1
|
+
import { redactSecrets } from './chunk-JFTXJ7I2.js';
|
|
2
|
+
import { isEngine } from './chunk-XC46B4FD.js';
|
|
3
|
+
import { isLimitError, waitMsFor } from './chunk-Y2SD7GBL.js';
|
|
4
|
+
import { LoopError } from './chunk-I3STY7U6.js';
|
|
5
|
+
import { readFileSync, mkdtempSync, existsSync, writeFileSync, appendFileSync, mkdirSync, rmSync } from 'fs';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
|
|
10
|
+
// src/core/describe.ts
|
|
11
|
+
var META = /* @__PURE__ */ new WeakMap();
|
|
12
|
+
var LABEL = /* @__PURE__ */ new WeakMap();
|
|
13
|
+
function setMeta(target, meta) {
|
|
14
|
+
META.set(target, meta);
|
|
15
|
+
return target;
|
|
16
|
+
}
|
|
17
|
+
function jobMeta(job) {
|
|
18
|
+
return typeof job === "function" ? META.get(job) : void 0;
|
|
19
|
+
}
|
|
20
|
+
function setLabel(cond, label) {
|
|
21
|
+
LABEL.set(cond, label);
|
|
22
|
+
return cond;
|
|
23
|
+
}
|
|
24
|
+
function condLabel(input) {
|
|
25
|
+
if (typeof input === "function") {
|
|
26
|
+
const l = LABEL.get(input);
|
|
27
|
+
if (l) return l;
|
|
28
|
+
}
|
|
29
|
+
return "check";
|
|
30
|
+
}
|
|
31
|
+
function describeConditions(input) {
|
|
32
|
+
if (input == null) return [];
|
|
33
|
+
if (Array.isArray(input)) return input.flatMap(describeConditions);
|
|
34
|
+
return [condLabel(input)];
|
|
35
|
+
}
|
|
36
|
+
var count = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
37
|
+
function renderPlan(meta, indent = "") {
|
|
38
|
+
if (!meta) return [`${indent}(a runnable job, shape not introspectable)`];
|
|
39
|
+
const nm = meta.name ? ` "${meta.name}"` : "";
|
|
40
|
+
const out = [];
|
|
41
|
+
switch (meta.kind) {
|
|
42
|
+
case "loop": {
|
|
43
|
+
const max = typeof meta.max === "number" ? ` (max ${meta.max})` : "";
|
|
44
|
+
out.push(`${indent}loop${nm}${max}`);
|
|
45
|
+
const start = meta.start;
|
|
46
|
+
const gate = meta.gate;
|
|
47
|
+
const stopOn = meta.stopOn;
|
|
48
|
+
if (start?.length) out.push(`${indent} start: ${start.join(", ")}`);
|
|
49
|
+
if (gate?.length) out.push(`${indent} gate: ${gate.join(", ")}`);
|
|
50
|
+
if (stopOn?.length) out.push(`${indent} stopOn: ${stopOn.join(", ")}`);
|
|
51
|
+
const tail = [meta.review ? "review" : null, meta.commit ? "commit" : null].filter(Boolean);
|
|
52
|
+
if (tail.length) out.push(`${indent} on convergence: ${tail.join(" + ")}`);
|
|
53
|
+
out.push(`${indent} body:`);
|
|
54
|
+
out.push(...renderPlan(meta.body, `${indent} `));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "dag": {
|
|
58
|
+
const nodes = meta.nodes ?? [];
|
|
59
|
+
out.push(`${indent}dag${nm} (${count(nodes.length, "node")})`);
|
|
60
|
+
for (const node of nodes) {
|
|
61
|
+
const bits = [];
|
|
62
|
+
if (node.needs?.length) bits.push(`needs ${node.needs.join(", ")}`);
|
|
63
|
+
if (node.isolate) bits.push("isolated");
|
|
64
|
+
out.push(`${indent} - ${node.name}${bits.length ? ` (${bits.join("; ")})` : ""}`);
|
|
65
|
+
out.push(...renderPlan(node.job, `${indent} `));
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "agent":
|
|
70
|
+
out.push(`${indent}agent${nm}${meta.ground ? " (grounded)" : ""}`);
|
|
71
|
+
break;
|
|
72
|
+
case "fn":
|
|
73
|
+
out.push(`${indent}fn${nm}`);
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
out.push(`${indent}${meta.kind}${nm}`);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/core/budget.ts
|
|
82
|
+
var Budget = class {
|
|
83
|
+
limit;
|
|
84
|
+
headroom;
|
|
85
|
+
soft;
|
|
86
|
+
tokens = 0;
|
|
87
|
+
constructor(config) {
|
|
88
|
+
this.limit = config.limit;
|
|
89
|
+
this.headroom = config.headroom ?? 0;
|
|
90
|
+
this.soft = config.soft ?? false;
|
|
91
|
+
}
|
|
92
|
+
/** Record consumed tokens. Non-finite or non-positive values are ignored. */
|
|
93
|
+
add(tokens) {
|
|
94
|
+
if (Number.isFinite(tokens) && tokens > 0) this.tokens += tokens;
|
|
95
|
+
}
|
|
96
|
+
spent() {
|
|
97
|
+
return this.tokens;
|
|
98
|
+
}
|
|
99
|
+
remaining() {
|
|
100
|
+
return Math.max(0, this.limit - this.tokens);
|
|
101
|
+
}
|
|
102
|
+
/** True once the next call would breach the cap (accounting for headroom). */
|
|
103
|
+
exceeded() {
|
|
104
|
+
return this.tokens + this.headroom >= this.limit;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function assertBudget(ctx) {
|
|
108
|
+
const budget = ctx.budget;
|
|
109
|
+
if (!budget || !budget.exceeded()) return;
|
|
110
|
+
if (budget.soft) {
|
|
111
|
+
ctx.log(
|
|
112
|
+
`token budget reached (${budget.spent()}/${budget.limit}) \u2014 continuing (soft)`,
|
|
113
|
+
"warn"
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
throw new LoopError({
|
|
118
|
+
code: "BUDGET",
|
|
119
|
+
phase: "engine",
|
|
120
|
+
message: `token budget exhausted: ${budget.spent()}/${budget.limit} tokens spent`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function fromFile(path) {
|
|
124
|
+
return readFileSync(path, "utf8").trim();
|
|
125
|
+
}
|
|
126
|
+
function defineSkill(skill) {
|
|
127
|
+
if (!skill.name) throw new Error("defineSkill: `name` is required");
|
|
128
|
+
if (!skill.instructions?.trim()) throw new Error(`defineSkill "${skill.name}": empty instructions`);
|
|
129
|
+
return skill;
|
|
130
|
+
}
|
|
131
|
+
function defineAgent(def) {
|
|
132
|
+
if (!def.name) throw new Error("defineAgent: `name` is required");
|
|
133
|
+
if (!def.system?.trim()) throw new Error(`defineAgent "${def.name}": empty system prompt`);
|
|
134
|
+
def.skills?.forEach((s) => defineSkill(s));
|
|
135
|
+
return def;
|
|
136
|
+
}
|
|
137
|
+
function resolveSystem(agent) {
|
|
138
|
+
if (!agent.skills?.length) return agent.system;
|
|
139
|
+
const methods = agent.skills.map((s) => `### ${s.name}
|
|
140
|
+
|
|
141
|
+
${s.instructions.trim()}`).join("\n\n");
|
|
142
|
+
return `${agent.system.trim()}
|
|
143
|
+
|
|
144
|
+
## Methodologies you apply
|
|
145
|
+
|
|
146
|
+
${methods}`;
|
|
147
|
+
}
|
|
148
|
+
function isForge(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.createPr === "function";
|
|
150
|
+
}
|
|
151
|
+
function buildViewArgs(branch) {
|
|
152
|
+
return ["pr", "view", branch, "--json", "number,url,headRefName"];
|
|
153
|
+
}
|
|
154
|
+
function buildCreateArgs(input) {
|
|
155
|
+
const args = [
|
|
156
|
+
"pr",
|
|
157
|
+
"create",
|
|
158
|
+
"--base",
|
|
159
|
+
input.base,
|
|
160
|
+
"--head",
|
|
161
|
+
input.branch,
|
|
162
|
+
"--title",
|
|
163
|
+
input.title,
|
|
164
|
+
"--body-file",
|
|
165
|
+
"-"
|
|
166
|
+
];
|
|
167
|
+
if (input.draft) args.push("--draft");
|
|
168
|
+
return args;
|
|
169
|
+
}
|
|
170
|
+
function buildEditArgs(pr, patch) {
|
|
171
|
+
const args = ["pr", "edit", String(pr.number)];
|
|
172
|
+
if (patch.title) args.push("--title", patch.title);
|
|
173
|
+
if (patch.body !== void 0) args.push("--body-file", "-");
|
|
174
|
+
return args;
|
|
175
|
+
}
|
|
176
|
+
function buildMergeArgs(pr, opts) {
|
|
177
|
+
const args = ["pr", "merge", String(pr.number)];
|
|
178
|
+
args.push(opts.squash === false ? "--merge" : "--squash");
|
|
179
|
+
if (opts.auto) args.push("--auto");
|
|
180
|
+
if (opts.subject) args.push("--subject", opts.subject);
|
|
181
|
+
if (opts.body !== void 0) args.push("--body-file", "-");
|
|
182
|
+
if (opts.deleteBranch) args.push("--delete-branch");
|
|
183
|
+
return args;
|
|
184
|
+
}
|
|
185
|
+
function buildChecksArgs(pr) {
|
|
186
|
+
return ["pr", "checks", String(pr.number), "--required"];
|
|
187
|
+
}
|
|
188
|
+
async function gh(bin, args, opts, input) {
|
|
189
|
+
let r;
|
|
190
|
+
try {
|
|
191
|
+
r = await execa(bin, args, {
|
|
192
|
+
cwd: opts.cwd,
|
|
193
|
+
cancelSignal: opts.signal,
|
|
194
|
+
reject: false,
|
|
195
|
+
all: true,
|
|
196
|
+
stdin: input === void 0 ? "ignore" : void 0,
|
|
197
|
+
input
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw new LoopError({
|
|
201
|
+
code: "CONFIG",
|
|
202
|
+
message: `the GitHub CLI (gh) is required for PR operations but could not be run (install it and run \`gh auth login\`): ${e.message}`
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
stdout: r.stdout ?? "",
|
|
207
|
+
all: r.all ?? r.stdout ?? "",
|
|
208
|
+
exitCode: r.exitCode ?? 1
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function ghOrThrow(r, action) {
|
|
212
|
+
if (r.exitCode !== 0)
|
|
213
|
+
throw new LoopError({
|
|
214
|
+
code: "CONFIG",
|
|
215
|
+
message: `gh ${action} failed (exit ${r.exitCode}): ${redactSecrets(String(r.all).slice(0, 400))}`
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
var GhForge = class {
|
|
219
|
+
constructor(bin = "gh") {
|
|
220
|
+
this.bin = bin;
|
|
221
|
+
}
|
|
222
|
+
bin;
|
|
223
|
+
name = "gh";
|
|
224
|
+
async viewPr(branch, opts) {
|
|
225
|
+
const r = await gh(this.bin, buildViewArgs(branch), opts);
|
|
226
|
+
if (r.exitCode !== 0) return void 0;
|
|
227
|
+
try {
|
|
228
|
+
const j = JSON.parse(r.stdout);
|
|
229
|
+
return { number: j.number, url: j.url, branch: j.headRefName };
|
|
230
|
+
} catch {
|
|
231
|
+
return void 0;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async createPr(input, opts) {
|
|
235
|
+
const r = await gh(this.bin, buildCreateArgs(input), opts, input.body);
|
|
236
|
+
ghOrThrow(r, "pr create");
|
|
237
|
+
const url = r.stdout.trim().split("\n").pop() ?? "";
|
|
238
|
+
const m = url.match(/\/pull\/(\d+)/);
|
|
239
|
+
return { number: m ? Number(m[1]) : 0, url, branch: input.branch };
|
|
240
|
+
}
|
|
241
|
+
async editPr(pr, patch, opts) {
|
|
242
|
+
const r = await gh(this.bin, buildEditArgs(pr, patch), opts, patch.body);
|
|
243
|
+
ghOrThrow(r, "pr edit");
|
|
244
|
+
}
|
|
245
|
+
async mergePr(pr, opts) {
|
|
246
|
+
const r = await gh(this.bin, buildMergeArgs(pr, opts), opts, opts.body);
|
|
247
|
+
ghOrThrow(r, "pr merge");
|
|
248
|
+
}
|
|
249
|
+
async checksPass(pr, opts) {
|
|
250
|
+
const r = await gh(this.bin, buildChecksArgs(pr), opts);
|
|
251
|
+
return r.exitCode === 0;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var MockForge = class {
|
|
255
|
+
constructor(opts = {}) {
|
|
256
|
+
this.opts = opts;
|
|
257
|
+
this.prs = new Map(Object.entries(opts.existing ?? {}));
|
|
258
|
+
}
|
|
259
|
+
opts;
|
|
260
|
+
name = "mock-forge";
|
|
261
|
+
calls = [];
|
|
262
|
+
prs;
|
|
263
|
+
seq = 100;
|
|
264
|
+
async viewPr(branch) {
|
|
265
|
+
this.calls.push({ method: "viewPr", args: { branch } });
|
|
266
|
+
return this.prs.get(branch);
|
|
267
|
+
}
|
|
268
|
+
async createPr(input) {
|
|
269
|
+
this.calls.push({ method: "createPr", args: { ...input } });
|
|
270
|
+
const number = this.seq += 1;
|
|
271
|
+
const pr = {
|
|
272
|
+
number,
|
|
273
|
+
url: `https://example.test/pull/${number}`,
|
|
274
|
+
branch: input.branch
|
|
275
|
+
};
|
|
276
|
+
this.prs.set(input.branch, pr);
|
|
277
|
+
return pr;
|
|
278
|
+
}
|
|
279
|
+
async editPr(pr, patch) {
|
|
280
|
+
this.calls.push({ method: "editPr", args: { pr, patch } });
|
|
281
|
+
}
|
|
282
|
+
async mergePr(pr, opts) {
|
|
283
|
+
this.calls.push({
|
|
284
|
+
method: "mergePr",
|
|
285
|
+
args: {
|
|
286
|
+
pr,
|
|
287
|
+
squash: opts.squash,
|
|
288
|
+
auto: opts.auto,
|
|
289
|
+
subject: opts.subject,
|
|
290
|
+
body: opts.body,
|
|
291
|
+
deleteBranch: opts.deleteBranch
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async checksPass() {
|
|
296
|
+
this.calls.push({ method: "checksPass", args: {} });
|
|
297
|
+
return this.opts.checks ?? true;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function toCondition(input, combine = "all") {
|
|
301
|
+
if (Array.isArray(input)) {
|
|
302
|
+
const conds = input.map((i) => toCondition(i, combine));
|
|
303
|
+
return combine === "any" ? any(...conds) : all(...conds);
|
|
304
|
+
}
|
|
305
|
+
return coerceOne(input);
|
|
306
|
+
}
|
|
307
|
+
function coerceOne(fn) {
|
|
308
|
+
return async (ctx, last) => {
|
|
309
|
+
const r = await fn(
|
|
310
|
+
ctx,
|
|
311
|
+
last
|
|
312
|
+
);
|
|
313
|
+
if (typeof r === "boolean") {
|
|
314
|
+
return { met: r, reason: `predicate: ${r}` };
|
|
315
|
+
}
|
|
316
|
+
if (r && typeof r === "object" && "met" in r) {
|
|
317
|
+
const res = r;
|
|
318
|
+
if (typeof res.met !== "boolean") {
|
|
319
|
+
throw new LoopError({
|
|
320
|
+
code: "VALIDATION",
|
|
321
|
+
message: `condition returned a non-boolean "met": ${String(res.met)}`
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return r;
|
|
325
|
+
}
|
|
326
|
+
return { met: Boolean(r), reason: `coerced: ${String(r)}` };
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function predicate(fn, reason = "predicate") {
|
|
330
|
+
return async (ctx, last) => {
|
|
331
|
+
const met = await fn(ctx, last);
|
|
332
|
+
return { met, reason: met ? `${reason}: true` : `${reason}: false` };
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function bodyPassed() {
|
|
336
|
+
return async (_ctx, last) => ({
|
|
337
|
+
met: last?.status === "pass",
|
|
338
|
+
confidence: last?.confidence,
|
|
339
|
+
reason: `last body status = ${last?.status ?? "none"}`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function minConfidence(threshold) {
|
|
343
|
+
return async (_ctx, last) => {
|
|
344
|
+
const c = last?.confidence ?? 0;
|
|
345
|
+
return {
|
|
346
|
+
met: c >= threshold,
|
|
347
|
+
confidence: c,
|
|
348
|
+
reason: `confidence ${c.toFixed(2)} ${c >= threshold ? ">=" : "<"} ${threshold}`
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function commandSucceeds(command, args = [], opts = {}) {
|
|
353
|
+
return setLabel(async (ctx) => {
|
|
354
|
+
try {
|
|
355
|
+
const r = await execa(command, args, {
|
|
356
|
+
cwd: opts.cwd ?? ctx.workspace.dir,
|
|
357
|
+
timeout: opts.timeoutMs,
|
|
358
|
+
cancelSignal: ctx.signal,
|
|
359
|
+
reject: false,
|
|
360
|
+
stdin: "ignore",
|
|
361
|
+
// Inherit the running environment's vars (BASE_URL, …) so the gate can
|
|
362
|
+
// test the live preview, not just static files on disk.
|
|
363
|
+
env: ctx.environment?.env
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
met: r.exitCode === 0,
|
|
367
|
+
reason: `\`${command}\` exited ${r.exitCode ?? "?"}`
|
|
368
|
+
};
|
|
369
|
+
} catch (e) {
|
|
370
|
+
return {
|
|
371
|
+
met: false,
|
|
372
|
+
reason: `\`${command}\` failed to run: ${e instanceof Error ? e.message : String(e)}`
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}, `${command}${args.length ? ` ${args.join(" ")}` : ""}`);
|
|
376
|
+
}
|
|
377
|
+
function forgeChecks() {
|
|
378
|
+
return async (ctx) => {
|
|
379
|
+
const branch = ctx.workspace.branch;
|
|
380
|
+
if (!branch) return { met: false, reason: "no branch checked out" };
|
|
381
|
+
const forge = ctx.forge ?? new GhForge();
|
|
382
|
+
const fopts = { cwd: ctx.workspace.dir, signal: ctx.signal };
|
|
383
|
+
const pr = await forge.viewPr(branch, fopts);
|
|
384
|
+
if (!pr) return { met: false, reason: `no open PR for "${branch}"` };
|
|
385
|
+
const ok = await forge.checksPass(pr, fopts);
|
|
386
|
+
return {
|
|
387
|
+
met: ok,
|
|
388
|
+
reason: ok ? "required checks pass" : "required checks not green"
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
var always = async () => ({ met: true, reason: "always" });
|
|
393
|
+
var never = async () => ({ met: false, reason: "never" });
|
|
394
|
+
function not(c) {
|
|
395
|
+
const cond = toCondition(c);
|
|
396
|
+
return async (ctx, last) => {
|
|
397
|
+
const r = await cond(ctx, last);
|
|
398
|
+
return {
|
|
399
|
+
met: !r.met,
|
|
400
|
+
confidence: r.confidence,
|
|
401
|
+
reason: `not(${r.reason})`
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function all(...inputs) {
|
|
406
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
407
|
+
return async (ctx, last) => {
|
|
408
|
+
const results = [];
|
|
409
|
+
for (const c of conds) {
|
|
410
|
+
const r = await c(ctx, last);
|
|
411
|
+
results.push(r);
|
|
412
|
+
if (!r.met) return { met: false, reason: `all -> failed: ${r.reason}` };
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
met: true,
|
|
416
|
+
reason: `all(${results.map((r) => r.reason).join(" & ")})`
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function any(...inputs) {
|
|
421
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
422
|
+
return async (ctx, last) => {
|
|
423
|
+
const reasons = [];
|
|
424
|
+
for (const c of conds) {
|
|
425
|
+
const r = await c(ctx, last);
|
|
426
|
+
reasons.push(r.reason);
|
|
427
|
+
if (r.met)
|
|
428
|
+
return {
|
|
429
|
+
met: true,
|
|
430
|
+
confidence: r.confidence,
|
|
431
|
+
reason: `any -> ${r.reason}`
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return { met: false, reason: `any(${reasons.join(" | ")})` };
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function quorum(k, ...inputs) {
|
|
438
|
+
if (k < 1 || k > inputs.length)
|
|
439
|
+
throw new LoopError({
|
|
440
|
+
code: "CONFIG",
|
|
441
|
+
message: `quorum requires 1 <= k <= inputs (got k=${k}, n=${inputs.length})`
|
|
442
|
+
});
|
|
443
|
+
const conds = inputs.map((i) => toCondition(i));
|
|
444
|
+
return setLabel(async (ctx, last) => {
|
|
445
|
+
const settled = await Promise.allSettled(conds.map((c) => c(ctx, last)));
|
|
446
|
+
const results = settled.map(
|
|
447
|
+
(s) => s.status === "fulfilled" ? s.value : {
|
|
448
|
+
met: false,
|
|
449
|
+
reason: `judge errored: ${s.reason instanceof Error ? s.reason.message : String(s.reason)}`
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
const held = results.filter((r) => r.met);
|
|
453
|
+
const confs = held.map((r) => r.confidence).filter((c) => typeof c === "number");
|
|
454
|
+
const confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : void 0;
|
|
455
|
+
return {
|
|
456
|
+
met: held.length >= k,
|
|
457
|
+
confidence,
|
|
458
|
+
reason: `quorum ${held.length}/${inputs.length} held (need ${k})`
|
|
459
|
+
};
|
|
460
|
+
}, `quorum ${k}/${inputs.length}`);
|
|
461
|
+
}
|
|
462
|
+
function defaultContext(ctx, last) {
|
|
463
|
+
const parts = [];
|
|
464
|
+
if (last?.summary) parts.push(`Last outcome summary: ${last.summary}`);
|
|
465
|
+
if (last?.status) parts.push(`Last outcome status: ${last.status}`);
|
|
466
|
+
if (last?.data !== void 0)
|
|
467
|
+
parts.push(`Last outcome data: ${safeJson(last.data)}`);
|
|
468
|
+
const stateKeys = Object.keys(ctx.state);
|
|
469
|
+
if (stateKeys.length) parts.push(`Shared state: ${safeJson(ctx.state)}`);
|
|
470
|
+
return parts.join("\n") || "(no prior context)";
|
|
471
|
+
}
|
|
472
|
+
function safeJson(value, limit = 4e3) {
|
|
473
|
+
try {
|
|
474
|
+
const s = JSON.stringify(value, null, 2) ?? String(value);
|
|
475
|
+
return s.length > limit ? `${s.slice(0, limit)}\u2026 (truncated)` : s;
|
|
476
|
+
} catch {
|
|
477
|
+
return String(value);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function* balancedObjects(text) {
|
|
481
|
+
let cursor = 0;
|
|
482
|
+
while (cursor < text.length) {
|
|
483
|
+
const start = text.indexOf("{", cursor);
|
|
484
|
+
if (start === -1) return;
|
|
485
|
+
let depth = 0;
|
|
486
|
+
let inString = false;
|
|
487
|
+
let escaped = false;
|
|
488
|
+
let end = -1;
|
|
489
|
+
for (let i = start; i < text.length; i += 1) {
|
|
490
|
+
const ch = text[i];
|
|
491
|
+
if (inString) {
|
|
492
|
+
if (escaped) escaped = false;
|
|
493
|
+
else if (ch === "\\") escaped = true;
|
|
494
|
+
else if (ch === '"') inString = false;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (ch === '"') inString = true;
|
|
498
|
+
else if (ch === "{") depth += 1;
|
|
499
|
+
else if (ch === "}" && --depth === 0) {
|
|
500
|
+
end = i;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (end === -1) return;
|
|
505
|
+
yield text.slice(start, end + 1);
|
|
506
|
+
cursor = end + 1;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function toVerdict(obj) {
|
|
510
|
+
const verdict = obj.verdict === "yes" ? "yes" : "no";
|
|
511
|
+
const confidence = typeof obj.confidence === "number" ? clamp01(obj.confidence) : 0;
|
|
512
|
+
const reason = typeof obj.reason === "string" ? obj.reason : "(no reason given)";
|
|
513
|
+
return { verdict, confidence, reason };
|
|
514
|
+
}
|
|
515
|
+
function parseVerdict(text) {
|
|
516
|
+
let fallback;
|
|
517
|
+
for (const candidate of balancedObjects(text)) {
|
|
518
|
+
let parsed;
|
|
519
|
+
try {
|
|
520
|
+
parsed = JSON.parse(candidate);
|
|
521
|
+
} catch {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
525
|
+
continue;
|
|
526
|
+
const obj = parsed;
|
|
527
|
+
if ("verdict" in obj) return toVerdict(obj);
|
|
528
|
+
fallback ??= obj;
|
|
529
|
+
}
|
|
530
|
+
if (fallback) return toVerdict(fallback);
|
|
531
|
+
throw new LoopError({
|
|
532
|
+
code: "VALIDATION",
|
|
533
|
+
message: `validator returned no JSON verdict: ${text.slice(0, 200)}`
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function clamp01(n) {
|
|
537
|
+
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
538
|
+
}
|
|
539
|
+
var VALIDATOR_SYSTEM = 'You are a strict, sceptical evaluator. You judge whether a stated condition is truly met given the evidence. Do not be generous. The `confidence` field is MANDATORY: always include a number in 0..1 for how sure you are, and when in doubt give a LOW number \u2014 never omit it (an omitted confidence is treated as zero). Respond with ONLY a single JSON object and no other text:\n{"verdict":"yes"|"no","confidence":<number 0..1>,"reason":"<one sentence>"}';
|
|
540
|
+
var CONFIDENCE_TAG_SYSTEM = "You are a rigorous, report-only reviewer. Do not edit anything and do not imply you will. Assess the evidence against the stated condition, listing each concern tied to a concrete location and a concrete failure scenario (not a vibe). Judge against the stated contract, not an ideal: do not penalise the absence of hardening the contract does not require, and when the evidence meets the contract and you cannot name a concrete fault, say so plainly. Close with a single final line and nothing after it: `<confidence>N%</confidence>` \u2014 N is an integer 0-100, where 100 means you found no genuine contract violation or real bug, and below 100 means at least one concrete, addressable concern is open.";
|
|
541
|
+
function parseConfidenceTag(text) {
|
|
542
|
+
const re = /<confidence>\s*([0-9]+(?:\.[0-9]+)?)\s*%?\s*<\/confidence>/gi;
|
|
543
|
+
let m;
|
|
544
|
+
let last = null;
|
|
545
|
+
while ((m = re.exec(text)) !== null) last = m;
|
|
546
|
+
if (!last) return null;
|
|
547
|
+
let n = parseFloat(last[1]);
|
|
548
|
+
if (n > 1) n = n / 100;
|
|
549
|
+
return { confidence: clamp01(n), findings: text.slice(0, last.index).trim() };
|
|
550
|
+
}
|
|
551
|
+
function validatorScoreSystem(dimensions) {
|
|
552
|
+
return `You are a strict, sceptical evaluator. Score how well the condition is met on EACH named dimension, from 0 (not at all) to 1 (fully). Do not be generous; when in doubt score low. Respond with ONLY a single JSON object and no other text:
|
|
553
|
+
{"scores":{${dimensions.map((d) => `"${d}":<0..1>`).join(",")}},"reason":"<one sentence>"}`;
|
|
554
|
+
}
|
|
555
|
+
function geometricMean(values) {
|
|
556
|
+
if (!values.length) return 0;
|
|
557
|
+
if (values.some((v) => v <= 0)) return 0;
|
|
558
|
+
return Math.exp(values.reduce((a, b) => a + Math.log(b), 0) / values.length);
|
|
559
|
+
}
|
|
560
|
+
function parseScores(text, dimensions) {
|
|
561
|
+
for (const candidate of balancedObjects(text)) {
|
|
562
|
+
let parsed;
|
|
563
|
+
try {
|
|
564
|
+
parsed = JSON.parse(candidate);
|
|
565
|
+
} catch {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
569
|
+
continue;
|
|
570
|
+
const raw = parsed.scores;
|
|
571
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
572
|
+
const scoreObj = raw;
|
|
573
|
+
const scores = {};
|
|
574
|
+
for (const d of dimensions) {
|
|
575
|
+
const val = scoreObj[d];
|
|
576
|
+
scores[d] = typeof val === "number" ? clamp01(val) : 0;
|
|
577
|
+
}
|
|
578
|
+
const reasonField = parsed.reason;
|
|
579
|
+
return {
|
|
580
|
+
score: geometricMean(dimensions.map((d) => scores[d])),
|
|
581
|
+
scores,
|
|
582
|
+
reason: typeof reasonField === "string" ? reasonField : "(no reason given)"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
throw new LoopError({
|
|
586
|
+
code: "VALIDATION",
|
|
587
|
+
message: `validator returned no JSON scores: ${text.slice(0, 200)}`
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function agentCheck(config) {
|
|
591
|
+
const threshold = config.threshold ?? 0.8;
|
|
592
|
+
const confidenceTag = config.confidenceTag === true;
|
|
593
|
+
const dimensions = !confidenceTag && config.dimensions?.length ? config.dimensions : void 0;
|
|
594
|
+
return setLabel(async (ctx, last) => {
|
|
595
|
+
const engine = config.engine ? ctx.resolveEngine(config.engine) : ctx.engine;
|
|
596
|
+
const contextText = await (config.context ?? defaultContext)(ctx, last);
|
|
597
|
+
const closing = confidenceTag ? "Write your review now, then close with `<confidence>N%</confidence>`." : `Return the JSON ${dimensions ? "scores" : "verdict"} now.`;
|
|
598
|
+
const prompt = `CONDITION TO EVALUATE:
|
|
599
|
+
${config.question}
|
|
600
|
+
|
|
601
|
+
EVIDENCE:
|
|
602
|
+
${contextText}
|
|
603
|
+
|
|
604
|
+
` + closing;
|
|
605
|
+
const baseSystem = confidenceTag ? CONFIDENCE_TAG_SYSTEM : dimensions ? validatorScoreSystem(dimensions) : VALIDATOR_SYSTEM;
|
|
606
|
+
const system = config.agent ? `${resolveSystem(config.agent)}
|
|
607
|
+
|
|
608
|
+
${baseSystem}` : baseSystem;
|
|
609
|
+
let result;
|
|
610
|
+
try {
|
|
611
|
+
assertBudget(ctx);
|
|
612
|
+
result = await engine.run(
|
|
613
|
+
{
|
|
614
|
+
prompt,
|
|
615
|
+
system,
|
|
616
|
+
model: config.model ?? config.agent?.model,
|
|
617
|
+
// A report-then-rate reviewer needs room for findings before the tag.
|
|
618
|
+
maxTokens: config.maxTokens ?? (confidenceTag ? 2048 : 512)
|
|
619
|
+
},
|
|
620
|
+
(e) => {
|
|
621
|
+
if (e.type === "usage") {
|
|
622
|
+
ctx.emit({
|
|
623
|
+
kind: "engine:usage",
|
|
624
|
+
ts: Date.now(),
|
|
625
|
+
path: [...ctx.path],
|
|
626
|
+
model: e.model,
|
|
627
|
+
usage: e.usage
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
ctx.signal
|
|
632
|
+
);
|
|
633
|
+
} catch (e) {
|
|
634
|
+
throw LoopError.from(e, { code: "ENGINE", path: ctx.path });
|
|
635
|
+
}
|
|
636
|
+
if (confidenceTag) {
|
|
637
|
+
const parsed = parseConfidenceTag(result.text);
|
|
638
|
+
if (!parsed)
|
|
639
|
+
return {
|
|
640
|
+
met: false,
|
|
641
|
+
confidence: 0,
|
|
642
|
+
reason: `no <confidence> tag: ${result.text.slice(0, 140)}`
|
|
643
|
+
};
|
|
644
|
+
const pct = Math.round(parsed.confidence * 100);
|
|
645
|
+
const need = Math.round(threshold * 100);
|
|
646
|
+
return {
|
|
647
|
+
met: parsed.confidence >= threshold,
|
|
648
|
+
confidence: parsed.confidence,
|
|
649
|
+
reason: `confidence ${pct}% (need ${need}%)${parsed.findings ? ` \u2014 ${parsed.findings.slice(0, 280)}` : ""}`
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (dimensions) {
|
|
653
|
+
let sv;
|
|
654
|
+
try {
|
|
655
|
+
sv = parseScores(result.text, dimensions);
|
|
656
|
+
} catch {
|
|
657
|
+
return {
|
|
658
|
+
met: false,
|
|
659
|
+
confidence: 0,
|
|
660
|
+
reason: `unparseable scores: ${result.text.slice(0, 120)}`
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const detail = dimensions.map((d) => `${d}=${sv.scores[d].toFixed(2)}`).join(", ");
|
|
664
|
+
return {
|
|
665
|
+
met: sv.score >= threshold,
|
|
666
|
+
confidence: sv.score,
|
|
667
|
+
reason: `geo ${sv.score.toFixed(2)} (need ${threshold}) [${detail}] \u2014 ${sv.reason}`
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
let v;
|
|
671
|
+
try {
|
|
672
|
+
v = parseVerdict(result.text);
|
|
673
|
+
} catch {
|
|
674
|
+
return {
|
|
675
|
+
met: false,
|
|
676
|
+
confidence: 0,
|
|
677
|
+
reason: `unparseable verdict: ${result.text.slice(0, 120)}`
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const met = v.verdict === "yes" && v.confidence >= threshold;
|
|
681
|
+
return {
|
|
682
|
+
met,
|
|
683
|
+
confidence: v.confidence,
|
|
684
|
+
reason: `${v.verdict} @ ${v.confidence.toFixed(2)} (need ${threshold}) \u2014 ${v.reason}`
|
|
685
|
+
};
|
|
686
|
+
}, `judge "${config.question}" >=${threshold}`);
|
|
687
|
+
}
|
|
688
|
+
function gateJob(label, condition) {
|
|
689
|
+
const cond = toCondition(condition);
|
|
690
|
+
return setMeta(async (ctx) => {
|
|
691
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path: [...ctx.path], label });
|
|
692
|
+
const r = await cond(ctx, ctx.lastOutcome);
|
|
693
|
+
const outcome = {
|
|
694
|
+
status: r.met ? "pass" : "fail",
|
|
695
|
+
confidence: r.confidence,
|
|
696
|
+
summary: r.reason
|
|
697
|
+
};
|
|
698
|
+
ctx.emit({
|
|
699
|
+
kind: "job:end",
|
|
700
|
+
ts: Date.now(),
|
|
701
|
+
path: [...ctx.path],
|
|
702
|
+
label,
|
|
703
|
+
outcome
|
|
704
|
+
});
|
|
705
|
+
return outcome;
|
|
706
|
+
}, { kind: "gate", name: label });
|
|
707
|
+
}
|
|
708
|
+
var FS = "";
|
|
709
|
+
var RS = "";
|
|
710
|
+
var LOG_FORMAT = `%H${FS}%aI${FS}%s${FS}%b${RS}`;
|
|
711
|
+
async function git(args, { cwd, signal }, input) {
|
|
712
|
+
const r = await execa("git", args, {
|
|
713
|
+
cwd,
|
|
714
|
+
cancelSignal: signal,
|
|
715
|
+
reject: false,
|
|
716
|
+
stdin: input === void 0 ? "ignore" : void 0,
|
|
717
|
+
input
|
|
718
|
+
});
|
|
719
|
+
return { stdout: r.stdout ?? "", exitCode: r.exitCode ?? 1 };
|
|
720
|
+
}
|
|
721
|
+
async function isRepo(opts) {
|
|
722
|
+
try {
|
|
723
|
+
const r = await git(["rev-parse", "--is-inside-work-tree"], opts);
|
|
724
|
+
return r.exitCode === 0 && r.stdout.trim() === "true";
|
|
725
|
+
} catch {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function currentBranch(opts) {
|
|
730
|
+
const r = await git(["rev-parse", "--abbrev-ref", "HEAD"], opts);
|
|
731
|
+
if (r.exitCode !== 0) return void 0;
|
|
732
|
+
const name = r.stdout.trim();
|
|
733
|
+
return name && name !== "HEAD" ? name : void 0;
|
|
734
|
+
}
|
|
735
|
+
async function headSha(opts) {
|
|
736
|
+
const r = await git(["rev-parse", "HEAD"], opts);
|
|
737
|
+
return r.exitCode === 0 ? r.stdout.trim() || void 0 : void 0;
|
|
738
|
+
}
|
|
739
|
+
async function stageAll(opts) {
|
|
740
|
+
await git(["add", "-A"], opts);
|
|
741
|
+
}
|
|
742
|
+
async function hasStagedChanges(opts) {
|
|
743
|
+
const r = await git(["diff", "--cached", "--quiet"], opts);
|
|
744
|
+
return r.exitCode === 1;
|
|
745
|
+
}
|
|
746
|
+
async function isDirty(opts) {
|
|
747
|
+
const r = await git(["status", "--porcelain"], opts);
|
|
748
|
+
return r.stdout.trim().length > 0;
|
|
749
|
+
}
|
|
750
|
+
async function commit(input, opts) {
|
|
751
|
+
if (!input.allowEmpty && !await hasStagedChanges(opts)) return void 0;
|
|
752
|
+
const message = input.body ? `${input.subject}
|
|
753
|
+
|
|
754
|
+
${input.body}
|
|
755
|
+
` : `${input.subject}
|
|
756
|
+
`;
|
|
757
|
+
const args = ["commit", "-F", "-"];
|
|
758
|
+
if (input.allowEmpty) args.push("--allow-empty");
|
|
759
|
+
const r = await git(args, opts, message);
|
|
760
|
+
if (r.exitCode !== 0) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`git commit failed (exit ${r.exitCode}): ${r.stdout}`.trim()
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
return headSha(opts);
|
|
766
|
+
}
|
|
767
|
+
async function log(query) {
|
|
768
|
+
const { cwd, signal, since, max } = query;
|
|
769
|
+
const ref = query.ref ?? "HEAD";
|
|
770
|
+
const args = ["log", `--format=${LOG_FORMAT}`];
|
|
771
|
+
if (max != null) args.push(`-n${max}`);
|
|
772
|
+
args.push(since ? `${since}..${ref}` : ref);
|
|
773
|
+
const r = await git(args, { cwd, signal });
|
|
774
|
+
if (r.exitCode !== 0) return [];
|
|
775
|
+
return parseLog(r.stdout);
|
|
776
|
+
}
|
|
777
|
+
function parseLog(stdout) {
|
|
778
|
+
const records = [];
|
|
779
|
+
for (const chunk of stdout.split(RS)) {
|
|
780
|
+
const fields = chunk.replace(/^\n+/, "").split(FS);
|
|
781
|
+
if (fields.length < 4 || !fields[0].trim()) continue;
|
|
782
|
+
records.push({
|
|
783
|
+
sha: fields[0].trim(),
|
|
784
|
+
date: fields[1].trim(),
|
|
785
|
+
subject: fields[2],
|
|
786
|
+
body: fields[3].trim()
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return records;
|
|
790
|
+
}
|
|
791
|
+
async function addWorktree(repoDir, opts) {
|
|
792
|
+
const dir = mkdtempSync(join(tmpdir(), "loops-wt-"));
|
|
793
|
+
const r = await git(
|
|
794
|
+
["worktree", "add", "-b", opts.branch, dir, opts.base ?? "HEAD"],
|
|
795
|
+
{ cwd: repoDir, signal: opts.signal }
|
|
796
|
+
);
|
|
797
|
+
if (r.exitCode !== 0)
|
|
798
|
+
throw new Error(
|
|
799
|
+
`git worktree add failed (exit ${r.exitCode}): ${r.stdout}`.trim()
|
|
800
|
+
);
|
|
801
|
+
return { dir, branch: opts.branch };
|
|
802
|
+
}
|
|
803
|
+
async function removeWorktree(repoDir, dir, opts = {}) {
|
|
804
|
+
await git(["worktree", "remove", "--force", dir], {
|
|
805
|
+
cwd: repoDir,
|
|
806
|
+
signal: opts.signal
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async function deleteBranch(repoDir, branch, opts = {}) {
|
|
810
|
+
await git(["branch", "-D", branch], { cwd: repoDir, signal: opts.signal });
|
|
811
|
+
}
|
|
812
|
+
async function mergeBranch(repoDir, branch, opts = {}) {
|
|
813
|
+
const r = await git(
|
|
814
|
+
["merge", "--no-ff", "-m", opts.message ?? `merge ${branch}`, branch],
|
|
815
|
+
{ cwd: repoDir, signal: opts.signal }
|
|
816
|
+
);
|
|
817
|
+
if (r.exitCode === 0) return { ok: true, conflict: false };
|
|
818
|
+
await git(["merge", "--abort"], { cwd: repoDir, signal: opts.signal });
|
|
819
|
+
return { ok: false, conflict: true };
|
|
820
|
+
}
|
|
821
|
+
async function mergeNoCommit(repoDir, branch, opts = {}) {
|
|
822
|
+
const r = await git(["merge", "--no-ff", "--no-commit", branch], {
|
|
823
|
+
cwd: repoDir,
|
|
824
|
+
signal: opts.signal
|
|
825
|
+
});
|
|
826
|
+
if (r.exitCode === 0) return { clean: true, conflicted: [] };
|
|
827
|
+
return { clean: false, conflicted: await conflictedFiles(repoDir, opts) };
|
|
828
|
+
}
|
|
829
|
+
async function conflictedFiles(repoDir, opts = {}) {
|
|
830
|
+
const r = await git(["diff", "--name-only", "--diff-filter=U"], {
|
|
831
|
+
cwd: repoDir,
|
|
832
|
+
signal: opts.signal
|
|
833
|
+
});
|
|
834
|
+
return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
835
|
+
}
|
|
836
|
+
async function mergeAbort(repoDir, opts = {}) {
|
|
837
|
+
await git(["merge", "--abort"], { cwd: repoDir, signal: opts.signal });
|
|
838
|
+
}
|
|
839
|
+
async function push(opts) {
|
|
840
|
+
const branch = opts.branch ?? await currentBranch(opts);
|
|
841
|
+
const args = ["push"];
|
|
842
|
+
if (opts.setUpstream ?? true) args.push("-u");
|
|
843
|
+
if (opts.force) args.push("--force-with-lease");
|
|
844
|
+
args.push(opts.remote ?? "origin");
|
|
845
|
+
if (branch) args.push(branch);
|
|
846
|
+
const r = await execa("git", args, {
|
|
847
|
+
cwd: opts.cwd,
|
|
848
|
+
cancelSignal: opts.signal,
|
|
849
|
+
reject: false,
|
|
850
|
+
stdin: "ignore",
|
|
851
|
+
all: true
|
|
852
|
+
});
|
|
853
|
+
return { ok: (r.exitCode ?? 1) === 0, output: (r.all ?? r.stdout ?? "").trim() };
|
|
854
|
+
}
|
|
855
|
+
var SCRATCH_DIR = ".loops";
|
|
856
|
+
var LEDGER_FILE = "ledger.md";
|
|
857
|
+
var PROMPT_FILE = "prompt.md";
|
|
858
|
+
function ledgerPath(workspace) {
|
|
859
|
+
return join(workspace.dir, SCRATCH_DIR, LEDGER_FILE);
|
|
860
|
+
}
|
|
861
|
+
function promptPath(workspace) {
|
|
862
|
+
return join(workspace.dir, SCRATCH_DIR, PROMPT_FILE);
|
|
863
|
+
}
|
|
864
|
+
function ensureDir(workspace) {
|
|
865
|
+
mkdirSync(join(workspace.dir, SCRATCH_DIR), { recursive: true });
|
|
866
|
+
ensureIgnored(workspace);
|
|
867
|
+
}
|
|
868
|
+
function ensureIgnored(workspace) {
|
|
869
|
+
const dir = join(workspace.dir, SCRATCH_DIR);
|
|
870
|
+
if (!existsSync(dir)) return;
|
|
871
|
+
const ignore = join(dir, ".gitignore");
|
|
872
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
873
|
+
}
|
|
874
|
+
function read(path) {
|
|
875
|
+
try {
|
|
876
|
+
return readFileSync(path, "utf8").trim();
|
|
877
|
+
} catch {
|
|
878
|
+
return "";
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function reset(path) {
|
|
882
|
+
try {
|
|
883
|
+
rmSync(path, { force: true });
|
|
884
|
+
} catch {
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function appendPrompt(workspace, note) {
|
|
888
|
+
ensureDir(workspace);
|
|
889
|
+
const n = typeof note === "string" ? { body: note } : note;
|
|
890
|
+
const header = n.heading ? `## ${n.heading}${n.author ? ` \u2014 ${n.author}` : ""}
|
|
891
|
+
|
|
892
|
+
` : n.author ? `_${n.author}:_ ` : "";
|
|
893
|
+
appendFileSync(promptPath(workspace), `${header}${n.body.trim()}
|
|
894
|
+
|
|
895
|
+
`);
|
|
896
|
+
}
|
|
897
|
+
function readPrompt(workspace) {
|
|
898
|
+
return read(promptPath(workspace));
|
|
899
|
+
}
|
|
900
|
+
function resetPrompt(workspace) {
|
|
901
|
+
reset(promptPath(workspace));
|
|
902
|
+
}
|
|
903
|
+
function appendLedger(workspace, entry) {
|
|
904
|
+
ensureDir(workspace);
|
|
905
|
+
const path = ledgerPath(workspace);
|
|
906
|
+
if (typeof entry === "string") {
|
|
907
|
+
const body = entry.trim();
|
|
908
|
+
if (body) appendFileSync(path, `${body}
|
|
909
|
+
|
|
910
|
+
`);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const head = entry.label ? `### ${entry.label}${entry.iteration ? ` \xB7 iteration ${entry.iteration}` : ""}` : entry.iteration ? `### iteration ${entry.iteration}` : "";
|
|
914
|
+
const lines = [];
|
|
915
|
+
if (head) lines.push(head);
|
|
916
|
+
if (entry.text?.trim()) lines.push(entry.text.trim());
|
|
917
|
+
if (entry.tools?.length) lines.push(`_actions: ${entry.tools.join(", ")}_`);
|
|
918
|
+
if (!lines.length) return;
|
|
919
|
+
appendFileSync(path, `${lines.join("\n\n")}
|
|
920
|
+
|
|
921
|
+
`);
|
|
922
|
+
}
|
|
923
|
+
function readLedger(workspace) {
|
|
924
|
+
return read(ledgerPath(workspace));
|
|
925
|
+
}
|
|
926
|
+
function resetLedger(workspace) {
|
|
927
|
+
reset(ledgerPath(workspace));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/core/consolidate.ts
|
|
931
|
+
var CONSOLIDATE_SYSTEM = "You maintain a project's CONSOLIDATED LEDGER from its commit history \u2014 the bounded coarse memory a fresh context reads to continue safely. Capture the current state and the open threads, and PRESERVE every binding decision, convention and constraint with its exact values verbatim (downstream work must honour them, so dropping or generalising even one is a failure). Tight markdown; MERGE new commits into the prior ledger, deduplicate, omit only narrative \u2014 never omit a decision.";
|
|
932
|
+
function digest(body, n = 280) {
|
|
933
|
+
const text = body.split("\n").filter((l) => l.trim() && !l.trimStart().startsWith("#")).join(" ").replace(/\s+/g, " ").trim();
|
|
934
|
+
return text.length > n ? `${text.slice(0, n).trimEnd()}\u2026` : text;
|
|
935
|
+
}
|
|
936
|
+
async function consolidate(ctx, opts = {}) {
|
|
937
|
+
const records = await log({
|
|
938
|
+
cwd: ctx.workspace.dir,
|
|
939
|
+
max: opts.max ?? 30,
|
|
940
|
+
since: opts.since,
|
|
941
|
+
signal: ctx.signal
|
|
942
|
+
});
|
|
943
|
+
const entries = records.map(
|
|
944
|
+
(r) => `- ${r.sha.slice(0, 7)} ${r.subject}${r.body ? `
|
|
945
|
+
${digest(r.body)}` : ""}`
|
|
946
|
+
).join("\n");
|
|
947
|
+
const engine = opts.engine ? ctx.resolveEngine(opts.engine) : ctx.engine;
|
|
948
|
+
const result = await engine.run(
|
|
949
|
+
{
|
|
950
|
+
prompt: (opts.prior ? `CURRENT LEDGER:
|
|
951
|
+
${opts.prior}
|
|
952
|
+
|
|
953
|
+
` : "") + `COMMITS (newest first):
|
|
954
|
+
${entries || "(none)"}
|
|
955
|
+
|
|
956
|
+
Output the updated consolidated ledger.`,
|
|
957
|
+
system: CONSOLIDATE_SYSTEM,
|
|
958
|
+
model: opts.model,
|
|
959
|
+
maxTokens: 1e3
|
|
960
|
+
},
|
|
961
|
+
() => {
|
|
962
|
+
},
|
|
963
|
+
ctx.signal
|
|
964
|
+
);
|
|
965
|
+
return result.text.trim();
|
|
966
|
+
}
|
|
967
|
+
var COMPACT_SYSTEM = "You write the HANDOFF a future agent reads if it lost ALL memory of this work. Include EVERYTHING it needs to continue safely, as structured markdown: ## Why (the problem and the root cause), ## What (exactly what changed, and where \u2014 names, paths, signatures), ## Alternatives (what was ruled out and why), ## Constraints (the invariants and limits that shaped it), ## Next (what is left or to watch). Preserve every decision and specific value verbatim. Completeness matters more than brevity \u2014 drop only literal repetition and play-by-play narration, never a decision or a detail. Omit a section only if it truly has nothing. No preamble.";
|
|
968
|
+
function truncate(s, n) {
|
|
969
|
+
const t = s.trim();
|
|
970
|
+
return t.length > n ? `${t.slice(0, n).trimEnd()}
|
|
971
|
+
\u2026` : t;
|
|
972
|
+
}
|
|
973
|
+
async function compactLedger(ctx, text, opts = {}) {
|
|
974
|
+
const trimmed = text.trim();
|
|
975
|
+
if (!trimmed) return "";
|
|
976
|
+
const max = opts.maxChars ?? 2e3;
|
|
977
|
+
if (trimmed.length <= max) return trimmed;
|
|
978
|
+
try {
|
|
979
|
+
const engine = opts.engine ? ctx.resolveEngine(opts.engine) : ctx.engine;
|
|
980
|
+
const result = await engine.run(
|
|
981
|
+
{
|
|
982
|
+
prompt: `WORKING LOG:
|
|
983
|
+
${trimmed}
|
|
984
|
+
|
|
985
|
+
Write the complete handoff.`,
|
|
986
|
+
system: COMPACT_SYSTEM,
|
|
987
|
+
model: opts.model,
|
|
988
|
+
maxTokens: 1200
|
|
989
|
+
},
|
|
990
|
+
() => {
|
|
991
|
+
},
|
|
992
|
+
ctx.signal
|
|
993
|
+
);
|
|
994
|
+
return result.text.trim() || truncate(trimmed, max);
|
|
995
|
+
} catch {
|
|
996
|
+
return truncate(trimmed, max);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async function composeCommitBody(ctx, workspace, opts = {}) {
|
|
1000
|
+
const material = [readPrompt(workspace), readLedger(workspace)].map((s) => s.trim()).filter(Boolean).join("\n\n");
|
|
1001
|
+
return material ? await compactLedger(ctx, material, opts) : "";
|
|
1002
|
+
}
|
|
1003
|
+
function consolidateJob(config = {}) {
|
|
1004
|
+
return async (ctx) => {
|
|
1005
|
+
const label = config.label ?? "consolidate";
|
|
1006
|
+
const subject = config.subject ?? "consolidate: ledger";
|
|
1007
|
+
const path = [...ctx.path];
|
|
1008
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
|
|
1009
|
+
try {
|
|
1010
|
+
const recent = await log({ cwd: ctx.workspace.dir, max: 50, signal: ctx.signal });
|
|
1011
|
+
const prior = recent.find((r) => r.subject === subject)?.body || void 0;
|
|
1012
|
+
const ledger = await consolidate(ctx, { ...config, prior });
|
|
1013
|
+
const sha = await commit(
|
|
1014
|
+
{ subject, body: ledger, allowEmpty: true },
|
|
1015
|
+
{ cwd: ctx.workspace.dir, signal: ctx.signal }
|
|
1016
|
+
);
|
|
1017
|
+
const outcome = {
|
|
1018
|
+
status: "pass",
|
|
1019
|
+
summary: sha ? `ledger ${sha.slice(0, 7)}` : "ledger unchanged",
|
|
1020
|
+
data: { sha: sha ?? null }
|
|
1021
|
+
};
|
|
1022
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
1023
|
+
return outcome;
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
const error = LoopError.from(e, { code: "BODY", path: ctx.path });
|
|
1026
|
+
ctx.emit({
|
|
1027
|
+
kind: "error",
|
|
1028
|
+
ts: Date.now(),
|
|
1029
|
+
path,
|
|
1030
|
+
message: error.message,
|
|
1031
|
+
code: error.code
|
|
1032
|
+
});
|
|
1033
|
+
const outcome = { status: "fail", summary: error.message, error };
|
|
1034
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
1035
|
+
return outcome;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/core/ground.ts
|
|
1041
|
+
function truncate2(s, n) {
|
|
1042
|
+
return s.length > n ? `${s.slice(0, n).trimEnd()}
|
|
1043
|
+
\u2026` : s;
|
|
1044
|
+
}
|
|
1045
|
+
async function groundingText(workspace, opts = {}) {
|
|
1046
|
+
const records = await log({
|
|
1047
|
+
cwd: workspace.dir,
|
|
1048
|
+
since: opts.since,
|
|
1049
|
+
max: opts.max ?? 10,
|
|
1050
|
+
signal: opts.signal
|
|
1051
|
+
});
|
|
1052
|
+
if (!records.length) return "";
|
|
1053
|
+
const where = workspace.branch ? `\`${workspace.branch}\`` : "this branch";
|
|
1054
|
+
const header = `## Recent work on ${where} (the commit log)
|
|
1055
|
+
What prior iterations already did and why \u2014 read it before working so you do not repeat a dead end. Most recent first.`;
|
|
1056
|
+
const bodyChars = opts.bodyChars ?? 1200;
|
|
1057
|
+
const entries = records.map((r) => {
|
|
1058
|
+
const head = `### ${r.sha.slice(0, 7)} ${r.subject}`;
|
|
1059
|
+
return r.body ? `${head}
|
|
1060
|
+
|
|
1061
|
+
${truncate2(r.body, bodyChars)}` : head;
|
|
1062
|
+
});
|
|
1063
|
+
return `${header}
|
|
1064
|
+
|
|
1065
|
+
${entries.join("\n\n")}`;
|
|
1066
|
+
}
|
|
1067
|
+
var SELECT_SYSTEM = "You select which past commits are relevant CONTEXT for a task. Be selective: return only genuinely relevant commits, fewer is better. Output ONLY shas, comma-separated, most relevant first \u2014 or the single word NONE.";
|
|
1068
|
+
function pickShas(text, records) {
|
|
1069
|
+
const ids = text.toLowerCase().match(/[0-9a-f]{7,40}/g) ?? [];
|
|
1070
|
+
const out = [];
|
|
1071
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1072
|
+
for (const id of ids) {
|
|
1073
|
+
const rec = records.find((r) => r.sha.startsWith(id));
|
|
1074
|
+
if (rec && !seen.has(rec.sha)) {
|
|
1075
|
+
seen.add(rec.sha);
|
|
1076
|
+
out.push(rec);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return out;
|
|
1080
|
+
}
|
|
1081
|
+
async function retrieveLedger(ctx, opts) {
|
|
1082
|
+
const records = await log({
|
|
1083
|
+
cwd: ctx.workspace.dir,
|
|
1084
|
+
max: opts.candidates ?? 100,
|
|
1085
|
+
signal: ctx.signal
|
|
1086
|
+
});
|
|
1087
|
+
if (!records.length) return "";
|
|
1088
|
+
const list = records.map((r) => `${r.sha.slice(0, 9)}: ${r.subject}`).join("\n");
|
|
1089
|
+
const engine = opts.engine ? ctx.resolveEngine(opts.engine) : ctx.engine;
|
|
1090
|
+
const result = await engine.run(
|
|
1091
|
+
{
|
|
1092
|
+
prompt: `TASK:
|
|
1093
|
+
${opts.intent}
|
|
1094
|
+
|
|
1095
|
+
CANDIDATE COMMITS (sha: subject):
|
|
1096
|
+
${list}
|
|
1097
|
+
|
|
1098
|
+
Return the shas relevant to the TASK (up to ${opts.max ?? 8}), or NONE.`,
|
|
1099
|
+
system: SELECT_SYSTEM,
|
|
1100
|
+
model: opts.model,
|
|
1101
|
+
maxTokens: 200
|
|
1102
|
+
},
|
|
1103
|
+
() => {
|
|
1104
|
+
},
|
|
1105
|
+
ctx.signal
|
|
1106
|
+
);
|
|
1107
|
+
const picked = pickShas(result.text, records).slice(0, opts.max ?? 8);
|
|
1108
|
+
if (!picked.length) return "";
|
|
1109
|
+
const where = ctx.workspace.branch ? `\`${ctx.workspace.branch}\`` : "this branch";
|
|
1110
|
+
const header = `## Relevant prior work on ${where} (retrieved for this task)
|
|
1111
|
+
Commits a search judged relevant \u2014 read them before working.`;
|
|
1112
|
+
const bodyChars = opts.bodyChars ?? 1200;
|
|
1113
|
+
const entries = picked.map((r) => {
|
|
1114
|
+
const head = `### ${r.sha.slice(0, 7)} ${r.subject}`;
|
|
1115
|
+
return r.body ? `${head}
|
|
1116
|
+
|
|
1117
|
+
${truncate2(r.body, bodyChars)}` : head;
|
|
1118
|
+
});
|
|
1119
|
+
return `${header}
|
|
1120
|
+
|
|
1121
|
+
${entries.join("\n\n")}`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/core/job.ts
|
|
1125
|
+
var HANDOFF_MARK = "===HANDOFF===";
|
|
1126
|
+
function recordBlock() {
|
|
1127
|
+
return `## Before you finish: the handoff
|
|
1128
|
+
Answer one question for whoever continues this: **what is everything future-you needs to know about this if you lost all memory of it?** The harness keeps your answer as the memory the next agent reads and as the commit body, so carry the WHY, not just the what \u2014 write it so they cannot repeat your dead ends or break your decisions.
|
|
1129
|
+
End your reply with this block (keep the \`${HANDOFF_MARK}\` marker exactly; drop a section only if it truly has nothing):
|
|
1130
|
+
|
|
1131
|
+
${HANDOFF_MARK}
|
|
1132
|
+
## Why
|
|
1133
|
+
<the problem and the root cause you found>
|
|
1134
|
+
## What
|
|
1135
|
+
<the change you made>
|
|
1136
|
+
## Alternatives
|
|
1137
|
+
<what you ruled out, and why>
|
|
1138
|
+
## Constraints
|
|
1139
|
+
<the invariants and limits that shaped it>
|
|
1140
|
+
## Next
|
|
1141
|
+
<what is left, or what to watch>`;
|
|
1142
|
+
}
|
|
1143
|
+
function splitTurn(text) {
|
|
1144
|
+
const lines = text.split("\n");
|
|
1145
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1146
|
+
if (lines[i].trim().replace(/\s+/g, "").toUpperCase() === HANDOFF_MARK) {
|
|
1147
|
+
return {
|
|
1148
|
+
work: lines.slice(0, i).join("\n").trim(),
|
|
1149
|
+
handoff: lines.slice(i + 1).join("\n").trim() || void 0
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return { work: text.trim() };
|
|
1154
|
+
}
|
|
1155
|
+
async function withGrounding(ctx, userPrompt, ground) {
|
|
1156
|
+
const opts = typeof ground === "object" ? ground : {};
|
|
1157
|
+
const parts = [];
|
|
1158
|
+
const committed = opts.retrieve ? await retrieveLedger(ctx, {
|
|
1159
|
+
intent: userPrompt,
|
|
1160
|
+
max: opts.max,
|
|
1161
|
+
bodyChars: opts.bodyChars,
|
|
1162
|
+
candidates: typeof opts.retrieve === "object" ? opts.retrieve.candidates : void 0,
|
|
1163
|
+
model: typeof opts.retrieve === "object" ? opts.retrieve.model : void 0
|
|
1164
|
+
}) : await groundingText(ctx.workspace, {
|
|
1165
|
+
max: opts.max,
|
|
1166
|
+
bodyChars: opts.bodyChars,
|
|
1167
|
+
signal: ctx.signal
|
|
1168
|
+
});
|
|
1169
|
+
if (committed) parts.push(committed);
|
|
1170
|
+
if (opts.includeScratch !== false) {
|
|
1171
|
+
const working = readLedger(ctx.workspace);
|
|
1172
|
+
if (working)
|
|
1173
|
+
parts.push(
|
|
1174
|
+
`## Working memory (this run so far)
|
|
1175
|
+
|
|
1176
|
+
What earlier turns in this run tried and found \u2014 build on it.
|
|
1177
|
+
|
|
1178
|
+
${working}`
|
|
1179
|
+
);
|
|
1180
|
+
const handoff = readPrompt(ctx.workspace);
|
|
1181
|
+
if (handoff)
|
|
1182
|
+
parts.push(
|
|
1183
|
+
`## Handoff so far (what earlier work distilled for the next agent)
|
|
1184
|
+
|
|
1185
|
+
${handoff}`
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
if (opts.recordInstruction !== false) parts.push(recordBlock());
|
|
1189
|
+
parts.push(userPrompt);
|
|
1190
|
+
return parts.join("\n\n---\n\n");
|
|
1191
|
+
}
|
|
1192
|
+
function summariseTools(uses) {
|
|
1193
|
+
return [...uses].map(([name, n]) => n > 1 ? `${name}\xD7${n}` : name);
|
|
1194
|
+
}
|
|
1195
|
+
var TERMINAL = (text) => ({
|
|
1196
|
+
status: "pass",
|
|
1197
|
+
summary: text.trim().slice(0, 280),
|
|
1198
|
+
data: text
|
|
1199
|
+
});
|
|
1200
|
+
function agentJob(config) {
|
|
1201
|
+
const job = async (ctx) => {
|
|
1202
|
+
const path = [...ctx.path];
|
|
1203
|
+
const label = config.label ?? config.agent?.name ?? "agent";
|
|
1204
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
|
|
1205
|
+
const engine = ctx.resolveEngine(config.engine);
|
|
1206
|
+
const userPrompt = typeof config.prompt === "function" ? await config.prompt(ctx) : config.prompt;
|
|
1207
|
+
const prompt = config.ground ? await withGrounding(ctx, userPrompt, config.ground) : userPrompt;
|
|
1208
|
+
const system = config.system !== void 0 ? typeof config.system === "function" ? config.system(ctx) : config.system : config.agent ? resolveSystem(config.agent) : void 0;
|
|
1209
|
+
let result;
|
|
1210
|
+
const toolUses = /* @__PURE__ */ new Map();
|
|
1211
|
+
try {
|
|
1212
|
+
assertBudget(ctx);
|
|
1213
|
+
result = await engine.run(
|
|
1214
|
+
{
|
|
1215
|
+
prompt,
|
|
1216
|
+
system,
|
|
1217
|
+
model: config.model ?? config.agent?.model,
|
|
1218
|
+
maxTokens: config.maxTokens,
|
|
1219
|
+
allowedTools: config.allowedTools ?? config.agent?.tools,
|
|
1220
|
+
leaf: config.leaf ?? config.agent?.leaf,
|
|
1221
|
+
cwd: config.cwd ?? ctx.workspace.dir,
|
|
1222
|
+
timeoutMs: config.timeoutMs
|
|
1223
|
+
},
|
|
1224
|
+
(e) => {
|
|
1225
|
+
const ts = Date.now();
|
|
1226
|
+
switch (e.type) {
|
|
1227
|
+
case "text":
|
|
1228
|
+
ctx.emit({ kind: "engine:text", ts, path, delta: e.delta });
|
|
1229
|
+
break;
|
|
1230
|
+
case "thinking":
|
|
1231
|
+
ctx.emit({ kind: "engine:thinking", ts, path, delta: e.delta });
|
|
1232
|
+
break;
|
|
1233
|
+
case "tool":
|
|
1234
|
+
if (e.phase === "use")
|
|
1235
|
+
toolUses.set(e.name, (toolUses.get(e.name) ?? 0) + 1);
|
|
1236
|
+
ctx.emit({
|
|
1237
|
+
kind: "engine:tool",
|
|
1238
|
+
ts,
|
|
1239
|
+
path,
|
|
1240
|
+
name: e.name,
|
|
1241
|
+
phase: e.phase
|
|
1242
|
+
});
|
|
1243
|
+
break;
|
|
1244
|
+
case "usage":
|
|
1245
|
+
ctx.emit({
|
|
1246
|
+
kind: "engine:usage",
|
|
1247
|
+
ts,
|
|
1248
|
+
path,
|
|
1249
|
+
model: e.model,
|
|
1250
|
+
usage: e.usage
|
|
1251
|
+
});
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
ctx.signal
|
|
1256
|
+
);
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
const error = LoopError.from(e, {
|
|
1259
|
+
code: ctx.signal.aborted ? "ABORTED" : "ENGINE",
|
|
1260
|
+
phase: "body",
|
|
1261
|
+
path: ctx.path,
|
|
1262
|
+
iteration: ctx.iteration
|
|
1263
|
+
});
|
|
1264
|
+
ctx.emit({
|
|
1265
|
+
kind: "error",
|
|
1266
|
+
ts: Date.now(),
|
|
1267
|
+
path,
|
|
1268
|
+
message: error.message,
|
|
1269
|
+
code: error.code
|
|
1270
|
+
});
|
|
1271
|
+
const outcome2 = {
|
|
1272
|
+
status: ctx.signal.aborted ? "aborted" : "fail",
|
|
1273
|
+
summary: error.message,
|
|
1274
|
+
error
|
|
1275
|
+
};
|
|
1276
|
+
ctx.emit({
|
|
1277
|
+
kind: "job:end",
|
|
1278
|
+
ts: Date.now(),
|
|
1279
|
+
path,
|
|
1280
|
+
label,
|
|
1281
|
+
outcome: outcome2
|
|
1282
|
+
});
|
|
1283
|
+
return outcome2;
|
|
1284
|
+
}
|
|
1285
|
+
if (config.ground) {
|
|
1286
|
+
const { work, handoff } = splitTurn(result.text);
|
|
1287
|
+
appendLedger(ctx.workspace, {
|
|
1288
|
+
label,
|
|
1289
|
+
iteration: ctx.iteration,
|
|
1290
|
+
text: work,
|
|
1291
|
+
tools: summariseTools(toolUses)
|
|
1292
|
+
});
|
|
1293
|
+
if (handoff) appendPrompt(ctx.workspace, handoff);
|
|
1294
|
+
}
|
|
1295
|
+
const outcome = config.outcome ? await config.outcome(result.text, ctx) : TERMINAL(result.text);
|
|
1296
|
+
ctx.emit({
|
|
1297
|
+
kind: "job:end",
|
|
1298
|
+
ts: Date.now(),
|
|
1299
|
+
path,
|
|
1300
|
+
label,
|
|
1301
|
+
outcome
|
|
1302
|
+
});
|
|
1303
|
+
return outcome;
|
|
1304
|
+
};
|
|
1305
|
+
return setMeta(job, {
|
|
1306
|
+
kind: "agent",
|
|
1307
|
+
name: config.label ?? config.agent?.name ?? "agent",
|
|
1308
|
+
ground: !!config.ground
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
function composeWay(ctx, last) {
|
|
1312
|
+
const sections = [];
|
|
1313
|
+
const head = [];
|
|
1314
|
+
if (ctx.iteration) head.push(`iteration: ${ctx.iteration}`);
|
|
1315
|
+
if (last?.status) head.push(`status: ${last.status}`);
|
|
1316
|
+
if (typeof last?.confidence === "number")
|
|
1317
|
+
head.push(`confidence: ${last.confidence.toFixed(2)}`);
|
|
1318
|
+
if (head.length) sections.push(`## Outcome
|
|
1319
|
+
${head.join("\n")}`);
|
|
1320
|
+
if (last?.summary) sections.push(`## Summary
|
|
1321
|
+
${last.summary.trim()}`);
|
|
1322
|
+
if (ctx.lastReview?.summary)
|
|
1323
|
+
sections.push(`## Next
|
|
1324
|
+
${ctx.lastReview.summary.trim()}`);
|
|
1325
|
+
return sections.join("\n\n");
|
|
1326
|
+
}
|
|
1327
|
+
function commitJob(config) {
|
|
1328
|
+
return async (ctx) => {
|
|
1329
|
+
const label = config.label ?? "commit";
|
|
1330
|
+
const path = [...ctx.path];
|
|
1331
|
+
const cwd = ctx.workspace.dir;
|
|
1332
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
|
|
1333
|
+
try {
|
|
1334
|
+
if (!await isRepo({ cwd, signal: ctx.signal })) {
|
|
1335
|
+
throw new LoopError({
|
|
1336
|
+
code: "CONFIG",
|
|
1337
|
+
message: `commitJob "${label}" requires a git repository (cwd: ${cwd})`
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
const last = ctx.lastOutcome;
|
|
1341
|
+
const subject = typeof config.subject === "function" ? await config.subject(ctx, last) : config.subject;
|
|
1342
|
+
const body = config.body !== void 0 ? typeof config.body === "function" ? await config.body(ctx, last) : config.body : await composeCommitBody(ctx, ctx.workspace, {
|
|
1343
|
+
model: config.compactModel
|
|
1344
|
+
}) || composeWay(ctx, last);
|
|
1345
|
+
if (config.stageAll ?? true) {
|
|
1346
|
+
ensureIgnored(ctx.workspace);
|
|
1347
|
+
await stageAll({ cwd, signal: ctx.signal });
|
|
1348
|
+
}
|
|
1349
|
+
const sha = await commit(
|
|
1350
|
+
{ subject, body, allowEmpty: config.allowEmpty },
|
|
1351
|
+
{ cwd, signal: ctx.signal }
|
|
1352
|
+
);
|
|
1353
|
+
if (sha) {
|
|
1354
|
+
resetPrompt(ctx.workspace);
|
|
1355
|
+
resetLedger(ctx.workspace);
|
|
1356
|
+
}
|
|
1357
|
+
const outcome = sha ? {
|
|
1358
|
+
status: "pass",
|
|
1359
|
+
summary: `committed ${sha.slice(0, 7)}: ${subject}`,
|
|
1360
|
+
data: { sha }
|
|
1361
|
+
} : { status: "pass", summary: "nothing to commit", data: { sha: null } };
|
|
1362
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
1363
|
+
return outcome;
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
const error = LoopError.from(e, {
|
|
1366
|
+
code: "BODY",
|
|
1367
|
+
phase: "body",
|
|
1368
|
+
path: ctx.path,
|
|
1369
|
+
iteration: ctx.iteration
|
|
1370
|
+
});
|
|
1371
|
+
ctx.emit({
|
|
1372
|
+
kind: "error",
|
|
1373
|
+
ts: Date.now(),
|
|
1374
|
+
path,
|
|
1375
|
+
message: error.message,
|
|
1376
|
+
code: error.code
|
|
1377
|
+
});
|
|
1378
|
+
const outcome = { status: "fail", summary: error.message, error };
|
|
1379
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
1380
|
+
return outcome;
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
function kickback(to, reason, over) {
|
|
1385
|
+
return {
|
|
1386
|
+
status: "fail",
|
|
1387
|
+
summary: reason,
|
|
1388
|
+
...over,
|
|
1389
|
+
kickback: { to, reason }
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
function fnJob(label, fn) {
|
|
1393
|
+
const job = async (ctx) => {
|
|
1394
|
+
const path = [...ctx.path];
|
|
1395
|
+
ctx.emit({ kind: "job:start", ts: Date.now(), path, label });
|
|
1396
|
+
let outcome;
|
|
1397
|
+
try {
|
|
1398
|
+
outcome = await fn(ctx);
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
const error = LoopError.from(e, {
|
|
1401
|
+
code: "BODY",
|
|
1402
|
+
phase: "body",
|
|
1403
|
+
path: ctx.path,
|
|
1404
|
+
iteration: ctx.iteration
|
|
1405
|
+
});
|
|
1406
|
+
outcome = { status: "fail", summary: error.message, error };
|
|
1407
|
+
ctx.emit({
|
|
1408
|
+
kind: "error",
|
|
1409
|
+
ts: Date.now(),
|
|
1410
|
+
path,
|
|
1411
|
+
message: error.message,
|
|
1412
|
+
code: error.code
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
ctx.emit({ kind: "job:end", ts: Date.now(), path, label, outcome });
|
|
1416
|
+
return outcome;
|
|
1417
|
+
};
|
|
1418
|
+
return setMeta(job, { kind: "fn", name: label });
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/core/context.ts
|
|
1422
|
+
function childContext(parent, over) {
|
|
1423
|
+
return {
|
|
1424
|
+
engine: parent.engine,
|
|
1425
|
+
resolveEngine: parent.resolveEngine,
|
|
1426
|
+
signal: parent.signal,
|
|
1427
|
+
emit: parent.emit,
|
|
1428
|
+
state: parent.state,
|
|
1429
|
+
// A child inherits the parent's workspace by default; a concurrency
|
|
1430
|
+
// boundary forks it into an isolated worktree by passing `workspace`.
|
|
1431
|
+
workspace: over.workspace ?? parent.workspace,
|
|
1432
|
+
environment: over.environment ?? parent.environment,
|
|
1433
|
+
forge: parent.forge,
|
|
1434
|
+
budget: parent.budget,
|
|
1435
|
+
onLimit: parent.onLimit,
|
|
1436
|
+
maxWaitMs: parent.maxWaitMs,
|
|
1437
|
+
resumeCommand: parent.resumeCommand,
|
|
1438
|
+
log: parent.log,
|
|
1439
|
+
depth: over.depth,
|
|
1440
|
+
path: over.path,
|
|
1441
|
+
// Inherit the enclosing iteration by default. A `loop` always passes one
|
|
1442
|
+
// explicitly; a `dag`/`sequence` does not, so without this a node nested in a
|
|
1443
|
+
// loop would reset to 0 — the "Attempt 0" confound where a retry body could not
|
|
1444
|
+
// see which attempt it was on. A top-level dag still gets 0 (the root's value).
|
|
1445
|
+
iteration: over.iteration ?? parent.iteration,
|
|
1446
|
+
lastOutcome: over.lastOutcome,
|
|
1447
|
+
lastReview: over.lastReview
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/core/loop.ts
|
|
1452
|
+
var VALID_STATUS = /* @__PURE__ */ new Set([
|
|
1453
|
+
"pass",
|
|
1454
|
+
"fail",
|
|
1455
|
+
"aborted",
|
|
1456
|
+
"exhausted",
|
|
1457
|
+
"paused"
|
|
1458
|
+
]);
|
|
1459
|
+
function decideLimit(error, ctx) {
|
|
1460
|
+
const reason = error.message;
|
|
1461
|
+
if (ctx.onLimit === "exit-resume") return { kind: "pause", reason };
|
|
1462
|
+
const waitMs = waitMsFor(error);
|
|
1463
|
+
if (waitMs == null) return { kind: "pause", reason };
|
|
1464
|
+
if (ctx.onLimit === "wait") return { kind: "wait", waitMs };
|
|
1465
|
+
if (waitMs <= ctx.maxWaitMs) return { kind: "wait", waitMs };
|
|
1466
|
+
return {
|
|
1467
|
+
kind: "pause",
|
|
1468
|
+
reason: `${reason} (reset in ${Math.round(waitMs / 1e3)}s exceeds maxWait ${Math.round(ctx.maxWaitMs / 1e3)}s)`
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
var yieldToLoop = () => new Promise((r) => setImmediate(r));
|
|
1472
|
+
function loop(config) {
|
|
1473
|
+
if (!config.name)
|
|
1474
|
+
throw new LoopError({
|
|
1475
|
+
code: "CONFIG",
|
|
1476
|
+
message: "loop() requires a non-empty name"
|
|
1477
|
+
});
|
|
1478
|
+
const start = config.start ? toCondition(config.start) : void 0;
|
|
1479
|
+
const until = config.until ? toCondition(config.until) : void 0;
|
|
1480
|
+
const stopOn = config.stopOn ? toCondition(config.stopOn) : void 0;
|
|
1481
|
+
const onError = config.retry?.onError ?? "continue";
|
|
1482
|
+
const job = async (parent) => {
|
|
1483
|
+
const path = [...parent.path, config.name];
|
|
1484
|
+
const depth = parent.depth + 1;
|
|
1485
|
+
const ts = () => Date.now();
|
|
1486
|
+
let lastReview;
|
|
1487
|
+
let iteration = 0;
|
|
1488
|
+
const ctxAt = (iter, lastOutcome) => childContext(parent, {
|
|
1489
|
+
depth,
|
|
1490
|
+
path,
|
|
1491
|
+
iteration: iter,
|
|
1492
|
+
lastOutcome,
|
|
1493
|
+
lastReview
|
|
1494
|
+
});
|
|
1495
|
+
parent.emit({ kind: "loop:start", ts: ts(), path, depth, max: config.max });
|
|
1496
|
+
const commitCfg = config.commit ? config.commit === true ? {
|
|
1497
|
+
label: `${config.name}:checkpoint`,
|
|
1498
|
+
subject: (_c, l) => l?.summary?.split("\n")[0]?.trim().slice(0, 72) || `chore(${config.name}): checkpoint`
|
|
1499
|
+
} : { label: `${config.name}:checkpoint`, ...config.commit } : void 0;
|
|
1500
|
+
const checkpoint = commitCfg ? commitJob(commitCfg) : void 0;
|
|
1501
|
+
const recordMilestone = async (ctx) => {
|
|
1502
|
+
if (!checkpoint) return;
|
|
1503
|
+
const outcome = await checkpoint(ctx);
|
|
1504
|
+
if (outcome.status !== "pass") {
|
|
1505
|
+
parent.emit({
|
|
1506
|
+
kind: "error",
|
|
1507
|
+
ts: ts(),
|
|
1508
|
+
path,
|
|
1509
|
+
message: `checkpoint commit did not pass: ${outcome.summary ?? outcome.status}`,
|
|
1510
|
+
code: outcome.error?.code ?? "UNKNOWN"
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
const finish = async (outcome, iterations) => {
|
|
1515
|
+
parent.emit({ kind: "loop:end", ts: ts(), path, outcome, iterations });
|
|
1516
|
+
if (config.onComplete) {
|
|
1517
|
+
try {
|
|
1518
|
+
await config.onComplete(outcome, ctxAt(iterations));
|
|
1519
|
+
} catch (e) {
|
|
1520
|
+
const error = LoopError.from(e, {
|
|
1521
|
+
code: "BODY",
|
|
1522
|
+
phase: "review",
|
|
1523
|
+
path,
|
|
1524
|
+
iteration: iterations
|
|
1525
|
+
});
|
|
1526
|
+
parent.emit({
|
|
1527
|
+
kind: "error",
|
|
1528
|
+
ts: ts(),
|
|
1529
|
+
path,
|
|
1530
|
+
message: `onComplete threw: ${error.message}`,
|
|
1531
|
+
code: error.code
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return outcome;
|
|
1536
|
+
};
|
|
1537
|
+
const gate = async (cond, which, ctx, last) => {
|
|
1538
|
+
try {
|
|
1539
|
+
return await cond(ctx, last);
|
|
1540
|
+
} catch (e) {
|
|
1541
|
+
throw LoopError.from(e, {
|
|
1542
|
+
code: "VALIDATION",
|
|
1543
|
+
phase: which,
|
|
1544
|
+
path,
|
|
1545
|
+
iteration
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
try {
|
|
1550
|
+
if (start) {
|
|
1551
|
+
const r = await gate(start, "start", ctxAt(0), void 0);
|
|
1552
|
+
parent.emit({
|
|
1553
|
+
kind: "loop:condition",
|
|
1554
|
+
ts: ts(),
|
|
1555
|
+
path,
|
|
1556
|
+
which: "start",
|
|
1557
|
+
result: r
|
|
1558
|
+
});
|
|
1559
|
+
if (!r.met)
|
|
1560
|
+
return finish(
|
|
1561
|
+
{ status: "aborted", summary: `start gate not met: ${r.reason}` },
|
|
1562
|
+
0
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
let last;
|
|
1566
|
+
let consecutiveErrors = 0;
|
|
1567
|
+
let consecutiveReviewFails = 0;
|
|
1568
|
+
while (true) {
|
|
1569
|
+
await yieldToLoop();
|
|
1570
|
+
if (parent.signal.aborted)
|
|
1571
|
+
return finish(
|
|
1572
|
+
{ status: "aborted", summary: "aborted by signal" },
|
|
1573
|
+
iteration
|
|
1574
|
+
);
|
|
1575
|
+
if (config.max != null && iteration >= config.max) {
|
|
1576
|
+
return finish(
|
|
1577
|
+
{
|
|
1578
|
+
status: "exhausted",
|
|
1579
|
+
summary: last?.summary ?? `reached max iterations (${config.max})`,
|
|
1580
|
+
confidence: last?.confidence,
|
|
1581
|
+
data: last?.data
|
|
1582
|
+
},
|
|
1583
|
+
iteration
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
iteration += 1;
|
|
1587
|
+
const ctx = ctxAt(iteration, last);
|
|
1588
|
+
parent.emit({ kind: "loop:iteration", ts: ts(), path, iteration });
|
|
1589
|
+
let bodyThrew = false;
|
|
1590
|
+
try {
|
|
1591
|
+
last = await config.body(ctx);
|
|
1592
|
+
consecutiveErrors = 0;
|
|
1593
|
+
} catch (e) {
|
|
1594
|
+
bodyThrew = true;
|
|
1595
|
+
const error = LoopError.from(e, {
|
|
1596
|
+
code: "BODY",
|
|
1597
|
+
phase: "body",
|
|
1598
|
+
path,
|
|
1599
|
+
iteration
|
|
1600
|
+
});
|
|
1601
|
+
parent.emit({
|
|
1602
|
+
kind: "error",
|
|
1603
|
+
ts: ts(),
|
|
1604
|
+
path,
|
|
1605
|
+
message: error.message,
|
|
1606
|
+
code: error.code
|
|
1607
|
+
});
|
|
1608
|
+
consecutiveErrors += 1;
|
|
1609
|
+
const tooMany = config.retry?.maxConsecutive != null && consecutiveErrors >= config.retry.maxConsecutive;
|
|
1610
|
+
if (onError === "fail" || tooMany)
|
|
1611
|
+
return finish(
|
|
1612
|
+
{ status: "fail", summary: error.message, error },
|
|
1613
|
+
iteration
|
|
1614
|
+
);
|
|
1615
|
+
last = { status: "fail", summary: error.message, error };
|
|
1616
|
+
if (config.retry?.backoffMs)
|
|
1617
|
+
await delay(config.retry.backoffMs, parent.signal);
|
|
1618
|
+
}
|
|
1619
|
+
if (!last || !VALID_STATUS.has(last.status)) {
|
|
1620
|
+
const error = new LoopError({
|
|
1621
|
+
code: "VALIDATION",
|
|
1622
|
+
phase: "body",
|
|
1623
|
+
path,
|
|
1624
|
+
iteration,
|
|
1625
|
+
message: `body returned an Outcome with no valid "status" (got ${JSON.stringify(last?.status)})`
|
|
1626
|
+
});
|
|
1627
|
+
parent.emit({
|
|
1628
|
+
kind: "error",
|
|
1629
|
+
ts: ts(),
|
|
1630
|
+
path,
|
|
1631
|
+
message: error.message,
|
|
1632
|
+
code: error.code
|
|
1633
|
+
});
|
|
1634
|
+
return finish(
|
|
1635
|
+
{ status: "fail", summary: error.message, error },
|
|
1636
|
+
iteration
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
if (last.status === "fail" && isLimitError(last.error) && ctx.onLimit !== "fail") {
|
|
1640
|
+
const action = decideLimit(last.error, ctx);
|
|
1641
|
+
if (action.kind === "wait") {
|
|
1642
|
+
const now = ts();
|
|
1643
|
+
parent.emit({
|
|
1644
|
+
kind: "limit:wait",
|
|
1645
|
+
ts: now,
|
|
1646
|
+
path,
|
|
1647
|
+
code: last.error.code,
|
|
1648
|
+
waitMs: action.waitMs,
|
|
1649
|
+
resumeAt: now + action.waitMs
|
|
1650
|
+
});
|
|
1651
|
+
await delay(action.waitMs, parent.signal);
|
|
1652
|
+
iteration -= 1;
|
|
1653
|
+
last = void 0;
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
parent.emit({
|
|
1657
|
+
kind: "limit:pause",
|
|
1658
|
+
ts: ts(),
|
|
1659
|
+
path,
|
|
1660
|
+
code: last.error.code,
|
|
1661
|
+
reason: action.reason,
|
|
1662
|
+
resumeCommand: ctx.resumeCommand
|
|
1663
|
+
});
|
|
1664
|
+
return finish(
|
|
1665
|
+
{
|
|
1666
|
+
status: "paused",
|
|
1667
|
+
summary: action.reason,
|
|
1668
|
+
error: last.error,
|
|
1669
|
+
data: last.data
|
|
1670
|
+
},
|
|
1671
|
+
iteration
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
if (!bodyThrew && last.status === "fail" && last.error && !last.error.retryable) {
|
|
1675
|
+
return finish(
|
|
1676
|
+
{ status: "fail", summary: last.summary, error: last.error },
|
|
1677
|
+
iteration
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
if (config.onIteration) {
|
|
1681
|
+
try {
|
|
1682
|
+
await config.onIteration(last, ctx);
|
|
1683
|
+
} catch (e) {
|
|
1684
|
+
throw LoopError.from(e, {
|
|
1685
|
+
code: "VALIDATION",
|
|
1686
|
+
phase: "body",
|
|
1687
|
+
path,
|
|
1688
|
+
iteration
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
if (stopOn) {
|
|
1693
|
+
const r = await gate(stopOn, "stopOn", ctx, last);
|
|
1694
|
+
parent.emit({
|
|
1695
|
+
kind: "loop:condition",
|
|
1696
|
+
ts: ts(),
|
|
1697
|
+
path,
|
|
1698
|
+
which: "stopOn",
|
|
1699
|
+
result: r
|
|
1700
|
+
});
|
|
1701
|
+
if (r.met)
|
|
1702
|
+
return finish(
|
|
1703
|
+
{
|
|
1704
|
+
status: "aborted",
|
|
1705
|
+
summary: `stopOn met: ${r.reason}`,
|
|
1706
|
+
data: last.data
|
|
1707
|
+
},
|
|
1708
|
+
iteration
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
const conv = until ? await gate(until, "until", ctx, last) : {
|
|
1712
|
+
met: last.status === "pass",
|
|
1713
|
+
confidence: last.confidence,
|
|
1714
|
+
reason: `body status = ${last.status}`
|
|
1715
|
+
};
|
|
1716
|
+
if (until)
|
|
1717
|
+
parent.emit({
|
|
1718
|
+
kind: "loop:condition",
|
|
1719
|
+
ts: ts(),
|
|
1720
|
+
path,
|
|
1721
|
+
which: "until",
|
|
1722
|
+
result: conv
|
|
1723
|
+
});
|
|
1724
|
+
if (conv.met) {
|
|
1725
|
+
if (!config.review) {
|
|
1726
|
+
await recordMilestone(ctxAt(iteration, last));
|
|
1727
|
+
return finish(
|
|
1728
|
+
{
|
|
1729
|
+
status: "pass",
|
|
1730
|
+
confidence: conv.confidence ?? last.confidence,
|
|
1731
|
+
summary: last.summary,
|
|
1732
|
+
data: last.data
|
|
1733
|
+
},
|
|
1734
|
+
iteration
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
let reviewOutcome;
|
|
1738
|
+
try {
|
|
1739
|
+
reviewOutcome = await config.review(ctxAt(iteration, last));
|
|
1740
|
+
} catch (e) {
|
|
1741
|
+
throw LoopError.from(e, {
|
|
1742
|
+
code: "VALIDATION",
|
|
1743
|
+
phase: "review",
|
|
1744
|
+
path,
|
|
1745
|
+
iteration
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
parent.emit({
|
|
1749
|
+
kind: "loop:review",
|
|
1750
|
+
ts: ts(),
|
|
1751
|
+
path,
|
|
1752
|
+
outcome: reviewOutcome
|
|
1753
|
+
});
|
|
1754
|
+
if (reviewOutcome.status === "pass") {
|
|
1755
|
+
await recordMilestone(ctxAt(iteration, last));
|
|
1756
|
+
return finish(
|
|
1757
|
+
{
|
|
1758
|
+
status: "pass",
|
|
1759
|
+
confidence: reviewOutcome.confidence ?? conv.confidence,
|
|
1760
|
+
summary: reviewOutcome.summary ?? last.summary,
|
|
1761
|
+
data: last.data
|
|
1762
|
+
},
|
|
1763
|
+
iteration
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
consecutiveReviewFails += 1;
|
|
1767
|
+
lastReview = reviewOutcome;
|
|
1768
|
+
parent.log(
|
|
1769
|
+
`review did not pass (${reviewOutcome.summary ?? reviewOutcome.status}); re-entering ${config.name}`,
|
|
1770
|
+
"warn"
|
|
1771
|
+
);
|
|
1772
|
+
if (config.maxReviewRestarts != null && consecutiveReviewFails >= config.maxReviewRestarts) {
|
|
1773
|
+
return finish(
|
|
1774
|
+
{
|
|
1775
|
+
status: "exhausted",
|
|
1776
|
+
summary: `review rejected ${consecutiveReviewFails}\xD7 (maxReviewRestarts)`,
|
|
1777
|
+
data: last.data
|
|
1778
|
+
},
|
|
1779
|
+
iteration
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (config.delayMs) await delay(config.delayMs, parent.signal);
|
|
1784
|
+
}
|
|
1785
|
+
} catch (e) {
|
|
1786
|
+
const error = LoopError.from(e, { code: "UNKNOWN", path, iteration });
|
|
1787
|
+
parent.emit({
|
|
1788
|
+
kind: "error",
|
|
1789
|
+
ts: ts(),
|
|
1790
|
+
path,
|
|
1791
|
+
message: error.message,
|
|
1792
|
+
code: error.code
|
|
1793
|
+
});
|
|
1794
|
+
return finish(
|
|
1795
|
+
{ status: "fail", summary: error.message, error },
|
|
1796
|
+
iteration
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
return setMeta(job, {
|
|
1801
|
+
kind: "loop",
|
|
1802
|
+
name: config.name,
|
|
1803
|
+
max: config.max,
|
|
1804
|
+
start: describeConditions(config.start),
|
|
1805
|
+
gate: describeConditions(config.until),
|
|
1806
|
+
stopOn: describeConditions(config.stopOn),
|
|
1807
|
+
review: !!config.review,
|
|
1808
|
+
commit: !!config.commit,
|
|
1809
|
+
body: jobMeta(config.body)
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
function delay(ms, signal) {
|
|
1813
|
+
if (signal.aborted) return Promise.resolve();
|
|
1814
|
+
return new Promise((resolve) => {
|
|
1815
|
+
const t = setTimeout(done, ms);
|
|
1816
|
+
const onAbort = () => done();
|
|
1817
|
+
function done() {
|
|
1818
|
+
clearTimeout(t);
|
|
1819
|
+
signal.removeEventListener("abort", onAbort);
|
|
1820
|
+
resolve();
|
|
1821
|
+
}
|
|
1822
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// src/engines/registry.ts
|
|
1827
|
+
var EngineRegistry = class {
|
|
1828
|
+
constructor(opts = {}) {
|
|
1829
|
+
this.opts = opts;
|
|
1830
|
+
this.registerBuiltins();
|
|
1831
|
+
}
|
|
1832
|
+
opts;
|
|
1833
|
+
factories = /* @__PURE__ */ new Map();
|
|
1834
|
+
cache = /* @__PURE__ */ new Map();
|
|
1835
|
+
/** Add or override an engine. The key is what you pass as an `EngineRef`. */
|
|
1836
|
+
register(name, factory) {
|
|
1837
|
+
this.factories.set(name, factory);
|
|
1838
|
+
this.cache.delete(name);
|
|
1839
|
+
return this;
|
|
1840
|
+
}
|
|
1841
|
+
has(name) {
|
|
1842
|
+
return this.factories.has(name);
|
|
1843
|
+
}
|
|
1844
|
+
names() {
|
|
1845
|
+
return [...this.factories.keys()];
|
|
1846
|
+
}
|
|
1847
|
+
/** Resolve a ref to an `Engine`: instance → as-is; name → built/cached. */
|
|
1848
|
+
create(ref, fallback) {
|
|
1849
|
+
if (isEngine(ref)) return ref;
|
|
1850
|
+
const name = ref ?? fallback;
|
|
1851
|
+
const cached = this.cache.get(name);
|
|
1852
|
+
if (cached) return cached;
|
|
1853
|
+
const factory = this.factories.get(name);
|
|
1854
|
+
if (!factory) {
|
|
1855
|
+
throw new LoopError({
|
|
1856
|
+
code: "CONFIG",
|
|
1857
|
+
message: `unknown engine "${name}" (have: ${this.names().join(", ")})`
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
const engine = factory(this.opts);
|
|
1861
|
+
this.cache.set(name, engine);
|
|
1862
|
+
return engine;
|
|
1863
|
+
}
|
|
1864
|
+
registerBuiltins() {
|
|
1865
|
+
this.register(
|
|
1866
|
+
"agent-sdk",
|
|
1867
|
+
(o) => lazy(
|
|
1868
|
+
() => import('./agent-sdk-RF5VJZAT.js').then((m) => new m.AgentSdkEngine(o)),
|
|
1869
|
+
"agent-sdk"
|
|
1870
|
+
)
|
|
1871
|
+
);
|
|
1872
|
+
this.register(
|
|
1873
|
+
"claude-cli",
|
|
1874
|
+
(o) => lazy(
|
|
1875
|
+
() => import('./claude-cli-U7WEVAOL.js').then((m) => new m.ClaudeCliEngine(o)),
|
|
1876
|
+
"claude-cli"
|
|
1877
|
+
)
|
|
1878
|
+
);
|
|
1879
|
+
this.register(
|
|
1880
|
+
"anthropic-api",
|
|
1881
|
+
(o) => lazy(
|
|
1882
|
+
() => import('./anthropic-api-XJY6Y4T2.js').then((m) => new m.AnthropicApiEngine(o)),
|
|
1883
|
+
"anthropic-api"
|
|
1884
|
+
)
|
|
1885
|
+
);
|
|
1886
|
+
this.register(
|
|
1887
|
+
"codex",
|
|
1888
|
+
(o) => lazy(() => import('./codex-6I5UZ2HM.js').then((m) => new m.CodexEngine(o)), "codex")
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
function lazy(load, name) {
|
|
1893
|
+
let inner;
|
|
1894
|
+
return {
|
|
1895
|
+
name,
|
|
1896
|
+
run(req, onEvent, signal) {
|
|
1897
|
+
inner ??= load();
|
|
1898
|
+
return inner.then((engine) => engine.run(req, onEvent, signal));
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// src/core/stats.ts
|
|
1904
|
+
var Stats = class {
|
|
1905
|
+
startedAt = Date.now();
|
|
1906
|
+
loops = /* @__PURE__ */ new Map();
|
|
1907
|
+
models = /* @__PURE__ */ new Map();
|
|
1908
|
+
errors = [];
|
|
1909
|
+
record(event) {
|
|
1910
|
+
const key = event.path.join(" / ") || "(root)";
|
|
1911
|
+
switch (event.kind) {
|
|
1912
|
+
case "loop:start":
|
|
1913
|
+
this.loopFor(key);
|
|
1914
|
+
break;
|
|
1915
|
+
case "loop:iteration":
|
|
1916
|
+
this.loopFor(key).iterations = event.iteration;
|
|
1917
|
+
break;
|
|
1918
|
+
case "loop:review": {
|
|
1919
|
+
const s = this.loopFor(key);
|
|
1920
|
+
if (event.outcome.status === "pass") s.reviewsPassed += 1;
|
|
1921
|
+
else s.reviewsFailed += 1;
|
|
1922
|
+
break;
|
|
1923
|
+
}
|
|
1924
|
+
case "loop:end":
|
|
1925
|
+
this.loopFor(key).lastStatus = event.outcome.status;
|
|
1926
|
+
break;
|
|
1927
|
+
case "engine:usage": {
|
|
1928
|
+
const m = this.modelFor(event.model);
|
|
1929
|
+
m.calls += 1;
|
|
1930
|
+
m.inputTokens += event.usage.inputTokens;
|
|
1931
|
+
m.outputTokens += event.usage.outputTokens;
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
case "error":
|
|
1935
|
+
this.errors.push({
|
|
1936
|
+
path: key,
|
|
1937
|
+
code: event.code,
|
|
1938
|
+
message: event.message,
|
|
1939
|
+
ts: event.ts
|
|
1940
|
+
});
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
snapshot() {
|
|
1945
|
+
const models = [...this.models.values()];
|
|
1946
|
+
return {
|
|
1947
|
+
startedAt: this.startedAt,
|
|
1948
|
+
elapsedMs: Date.now() - this.startedAt,
|
|
1949
|
+
loops: [...this.loops.values()],
|
|
1950
|
+
models,
|
|
1951
|
+
totalInputTokens: models.reduce((a, m) => a + m.inputTokens, 0),
|
|
1952
|
+
totalOutputTokens: models.reduce((a, m) => a + m.outputTokens, 0),
|
|
1953
|
+
agentCalls: models.reduce((a, m) => a + m.calls, 0),
|
|
1954
|
+
errors: this.errors
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
loopFor(path) {
|
|
1958
|
+
let s = this.loops.get(path);
|
|
1959
|
+
if (!s) {
|
|
1960
|
+
s = { path, iterations: 0, reviewsPassed: 0, reviewsFailed: 0 };
|
|
1961
|
+
this.loops.set(path, s);
|
|
1962
|
+
}
|
|
1963
|
+
return s;
|
|
1964
|
+
}
|
|
1965
|
+
modelFor(model) {
|
|
1966
|
+
let m = this.models.get(model);
|
|
1967
|
+
if (!m) {
|
|
1968
|
+
m = { model, calls: 0, inputTokens: 0, outputTokens: 0 };
|
|
1969
|
+
this.models.set(model, m);
|
|
1970
|
+
}
|
|
1971
|
+
return m;
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
var NOISE = /* @__PURE__ */ new Set([
|
|
1975
|
+
"engine:text",
|
|
1976
|
+
"engine:thinking"
|
|
1977
|
+
]);
|
|
1978
|
+
var CHECKPOINT_AT = /* @__PURE__ */ new Set([
|
|
1979
|
+
"loop:iteration",
|
|
1980
|
+
"loop:end",
|
|
1981
|
+
"dag:end",
|
|
1982
|
+
"job:end"
|
|
1983
|
+
]);
|
|
1984
|
+
function ensureDir2(path) {
|
|
1985
|
+
const dir = dirname(path);
|
|
1986
|
+
if (dir && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
1987
|
+
}
|
|
1988
|
+
function makeRecorder(path) {
|
|
1989
|
+
ensureDir2(path);
|
|
1990
|
+
writeFileSync(path, "");
|
|
1991
|
+
return (event) => {
|
|
1992
|
+
if (NOISE.has(event.kind)) return;
|
|
1993
|
+
try {
|
|
1994
|
+
appendFileSync(path, `${JSON.stringify(event)}
|
|
1995
|
+
`);
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
function makeCheckpointer(path, state) {
|
|
2001
|
+
ensureDir2(path);
|
|
2002
|
+
return (event) => {
|
|
2003
|
+
if (!CHECKPOINT_AT.has(event.kind)) return;
|
|
2004
|
+
flushCheckpoint(path, state);
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
function flushCheckpoint(path, state) {
|
|
2008
|
+
ensureDir2(path);
|
|
2009
|
+
try {
|
|
2010
|
+
writeFileSync(path, JSON.stringify({ ts: Date.now(), state }, null, 2));
|
|
2011
|
+
} catch {
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
function loadCheckpoint(path) {
|
|
2015
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2016
|
+
if (parsed && typeof parsed === "object" && "state" in parsed && parsed.state && typeof parsed.state === "object") {
|
|
2017
|
+
return parsed.state;
|
|
2018
|
+
}
|
|
2019
|
+
return {};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/runtime/runner.ts
|
|
2023
|
+
var DEFAULT_MAX_WAIT_MS = 3e5;
|
|
2024
|
+
var EXIT_PAUSED = 75;
|
|
2025
|
+
async function run(job, options = {}) {
|
|
2026
|
+
const registry = new EngineRegistry(options.engineOptions ?? {});
|
|
2027
|
+
for (const [name, value] of Object.entries(options.engines ?? {})) {
|
|
2028
|
+
registry.register(name, typeof value === "function" ? value : () => value);
|
|
2029
|
+
}
|
|
2030
|
+
const defaultEngine = options.engine ?? "agent-sdk";
|
|
2031
|
+
const stats = new Stats();
|
|
2032
|
+
const controller = new AbortController();
|
|
2033
|
+
if (options.signal) {
|
|
2034
|
+
if (options.signal.aborted) controller.abort();
|
|
2035
|
+
else
|
|
2036
|
+
options.signal.addEventListener("abort", () => controller.abort(), {
|
|
2037
|
+
once: true
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
const budget = options.budget != null ? new Budget(
|
|
2041
|
+
typeof options.budget === "number" ? { limit: options.budget } : options.budget
|
|
2042
|
+
) : void 0;
|
|
2043
|
+
let initialState = options.state ?? {};
|
|
2044
|
+
if (options.resumeFrom) {
|
|
2045
|
+
try {
|
|
2046
|
+
initialState = { ...loadCheckpoint(options.resumeFrom), ...initialState };
|
|
2047
|
+
} catch (e) {
|
|
2048
|
+
throw new LoopError({
|
|
2049
|
+
code: "CONFIG",
|
|
2050
|
+
message: `cannot resume from "${options.resumeFrom}": ${e instanceof Error ? e.message : String(e)}`
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
const sinks = [];
|
|
2055
|
+
if (options.recordTo) sinks.push(makeRecorder(options.recordTo));
|
|
2056
|
+
if (options.checkpoint)
|
|
2057
|
+
sinks.push(makeCheckpointer(options.checkpoint, initialState));
|
|
2058
|
+
const emit = (event) => {
|
|
2059
|
+
stats.record(event);
|
|
2060
|
+
if (budget && event.kind === "engine:usage")
|
|
2061
|
+
budget.add(event.usage.inputTokens + event.usage.outputTokens);
|
|
2062
|
+
options.onEvent?.(event);
|
|
2063
|
+
for (const sink of sinks) sink(event);
|
|
2064
|
+
};
|
|
2065
|
+
const resolveEngine = (ref) => registry.create(ref, defaultEngine);
|
|
2066
|
+
const dir = options.cwd ?? process.cwd();
|
|
2067
|
+
const workspace = {
|
|
2068
|
+
dir,
|
|
2069
|
+
branch: await currentBranch({ cwd: dir, signal: controller.signal })
|
|
2070
|
+
};
|
|
2071
|
+
let environment;
|
|
2072
|
+
if (options.environment) {
|
|
2073
|
+
try {
|
|
2074
|
+
environment = await options.environment.up(workspace, controller.signal);
|
|
2075
|
+
} catch (e) {
|
|
2076
|
+
const error = LoopError.from(e, { code: "CONFIG" });
|
|
2077
|
+
emit({
|
|
2078
|
+
kind: "error",
|
|
2079
|
+
ts: Date.now(),
|
|
2080
|
+
path: [],
|
|
2081
|
+
message: `environment "${options.environment.name}" failed to start: ${error.message}`,
|
|
2082
|
+
code: error.code
|
|
2083
|
+
});
|
|
2084
|
+
return {
|
|
2085
|
+
outcome: {
|
|
2086
|
+
status: "fail",
|
|
2087
|
+
summary: `environment failed to start: ${error.message}`,
|
|
2088
|
+
error
|
|
2089
|
+
},
|
|
2090
|
+
stats: stats.snapshot(),
|
|
2091
|
+
budget: budget ? {
|
|
2092
|
+
limit: budget.limit,
|
|
2093
|
+
spent: budget.spent(),
|
|
2094
|
+
remaining: budget.remaining()
|
|
2095
|
+
} : void 0
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
const rootCtx = {
|
|
2100
|
+
engine: resolveEngine(defaultEngine),
|
|
2101
|
+
resolveEngine,
|
|
2102
|
+
signal: controller.signal,
|
|
2103
|
+
emit,
|
|
2104
|
+
state: initialState,
|
|
2105
|
+
workspace,
|
|
2106
|
+
environment,
|
|
2107
|
+
forge: options.forge,
|
|
2108
|
+
budget,
|
|
2109
|
+
onLimit: options.onLimit ?? "auto",
|
|
2110
|
+
maxWaitMs: options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS,
|
|
2111
|
+
resumeCommand: options.resumeCommand,
|
|
2112
|
+
iteration: 0,
|
|
2113
|
+
depth: 0,
|
|
2114
|
+
path: [],
|
|
2115
|
+
log: (message, level = "info") => emit({ kind: "log", ts: Date.now(), path: [], level, message })
|
|
2116
|
+
};
|
|
2117
|
+
let outcome;
|
|
2118
|
+
try {
|
|
2119
|
+
outcome = await job(rootCtx);
|
|
2120
|
+
} catch (e) {
|
|
2121
|
+
const error = LoopError.from(e, { code: "UNKNOWN" });
|
|
2122
|
+
emit({
|
|
2123
|
+
kind: "error",
|
|
2124
|
+
ts: Date.now(),
|
|
2125
|
+
path: [],
|
|
2126
|
+
message: error.message,
|
|
2127
|
+
code: error.code
|
|
2128
|
+
});
|
|
2129
|
+
outcome = { status: "fail", summary: error.message, error };
|
|
2130
|
+
} finally {
|
|
2131
|
+
if (environment) await environment.down(controller.signal).catch(() => {
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
if (outcome.status === "paused" && options.checkpoint)
|
|
2135
|
+
flushCheckpoint(options.checkpoint, initialState);
|
|
2136
|
+
return {
|
|
2137
|
+
outcome,
|
|
2138
|
+
stats: stats.snapshot(),
|
|
2139
|
+
budget: budget ? {
|
|
2140
|
+
limit: budget.limit,
|
|
2141
|
+
spent: budget.spent(),
|
|
2142
|
+
remaining: budget.remaining()
|
|
2143
|
+
} : void 0
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function exitCodeFor(outcome) {
|
|
2147
|
+
switch (outcome.status) {
|
|
2148
|
+
case "pass":
|
|
2149
|
+
return 0;
|
|
2150
|
+
case "fail":
|
|
2151
|
+
return 1;
|
|
2152
|
+
case "exhausted":
|
|
2153
|
+
return 2;
|
|
2154
|
+
case "aborted":
|
|
2155
|
+
return 130;
|
|
2156
|
+
case "paused":
|
|
2157
|
+
return EXIT_PAUSED;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export { Budget, EXIT_PAUSED, EngineRegistry, GhForge, MockForge, Stats, addWorktree, agentCheck, agentJob, all, always, any, appendLedger, appendPrompt, bodyPassed, buildChecksArgs, buildCreateArgs, buildEditArgs, buildMergeArgs, buildViewArgs, childContext, commandSucceeds, commit, commitJob, compactLedger, composeCommitBody, conflictedFiles, consolidate, consolidateJob, currentBranch, defineAgent, defineSkill, deleteBranch, describeConditions, ensureIgnored, exitCodeFor, fnJob, forgeChecks, fromFile, gateJob, groundingText, hasStagedChanges, headSha, isDirty, isForge, isRepo, jobMeta, kickback, ledgerPath, log, loop, mergeAbort, mergeBranch, mergeNoCommit, minConfidence, never, not, predicate, promptPath, push, quorum, readLedger, readPrompt, removeWorktree, renderPlan, resetLedger, resetPrompt, resolveSystem, retrieveLedger, run, setMeta, stageAll, toCondition };
|
|
2162
|
+
//# sourceMappingURL=chunk-3BPU34DE.js.map
|
|
2163
|
+
//# sourceMappingURL=chunk-3BPU34DE.js.map
|